mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 08:41:58 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfe5993831 | ||
|
|
fa0e3d1ce2 | ||
|
|
d07085c462 | ||
|
|
c89c1f9654 | ||
|
|
6cfc0ca79b | ||
|
|
5d27123bd7 | ||
|
|
79ca374bb6 | ||
|
|
b3f1a6f7e5 | ||
|
|
d521e2abc6 | ||
|
|
82eca7501b | ||
|
|
51866437fd | ||
|
|
244a56d1bb | ||
|
|
95c833b619 | ||
|
|
878fad5747 | ||
|
|
6ff3096695 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,4 +4,5 @@ postgresus-data/
|
||||
pgdata/
|
||||
docker-compose.yml
|
||||
node_modules/
|
||||
.idea
|
||||
.idea
|
||||
/articles
|
||||
@@ -58,12 +58,12 @@
|
||||
- **SSL support**: Secure connections available
|
||||
- **Easy restoration**: One-click restore from any backup
|
||||
|
||||
### 🔒 **Backup Encryption** <a href="https://postgresus.com/encryption">(docs)</a>
|
||||
### 🔒 **Enterprise-grade security** <a href="https://postgresus.com/security">(docs)</a>
|
||||
|
||||
- **AES-256-GCM encryption**: Enterprise-grade protection for backup files
|
||||
- **Zero-trust storage**: Encrypted backups are useless so you can keep in shared storages like S3, Azure Blob Storage, etc.
|
||||
- **Optionality**: Encrypted backups are optional and can be enabled or disabled if you wish
|
||||
- **Download unencrypted**: You can still download unencrypted backups via the 'Download' button to use them in `pg_restore` or other tools.
|
||||
- **Zero-trust storage**: Backups are encrypted and they are useless to attackers, so you can keep them in shared storages like S3, Azure Blob Storage, etc.
|
||||
- **Encryption for secrets**: Any sensitive data is encrypted and never exposed, even in logs or error messages
|
||||
- **Read-only user**: Postgresus uses by default a read-only user for backups and never stores anything that can change your data
|
||||
|
||||
### 👥 **Suitable for Teams** <a href="https://postgresus.com/access-management">(docs)</a>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/disk"
|
||||
"postgresus-backend/internal/features/encryption/secrets"
|
||||
healthcheck_attempt "postgresus-backend/internal/features/healthcheck/attempt"
|
||||
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
@@ -64,6 +65,12 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = secrets.GetSecretKeyService().MigrateKeyFromDbToFileIfExist()
|
||||
if err != nil {
|
||||
log.Error("Failed to migrate secret key from database to file", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = users_services.GetUserService().CreateInitialAdmin()
|
||||
if err != nil {
|
||||
log.Error("Failed to create initial admin", "error", err)
|
||||
|
||||
@@ -26,8 +26,9 @@ type EnvVariables struct {
|
||||
EnvMode env_utils.EnvMode `env:"ENV_MODE" required:"true"`
|
||||
PostgresesInstallDir string `env:"POSTGRES_INSTALL_DIR"`
|
||||
|
||||
DataFolder string
|
||||
TempFolder string
|
||||
DataFolder string
|
||||
TempFolder string
|
||||
SecretKeyPath string
|
||||
|
||||
TestGoogleDriveClientID string `env:"TEST_GOOGLE_DRIVE_CLIENT_ID"`
|
||||
TestGoogleDriveClientSecret string `env:"TEST_GOOGLE_DRIVE_CLIENT_SECRET"`
|
||||
@@ -146,6 +147,7 @@ func loadEnvVariables() {
|
||||
// (projectRoot/postgresus-data -> /postgresus-data)
|
||||
env.DataFolder = filepath.Join(filepath.Dir(backendRoot), "postgresus-data", "backups")
|
||||
env.TempFolder = filepath.Join(filepath.Dir(backendRoot), "postgresus-data", "temp")
|
||||
env.SecretKeyPath = filepath.Join(filepath.Dir(backendRoot), "postgresus-data", "secret.key")
|
||||
|
||||
if env.IsTesting {
|
||||
if env.TestPostgres12Port == "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"postgresus-backend/internal/config"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"postgresus-backend/internal/util/period"
|
||||
"time"
|
||||
)
|
||||
@@ -131,7 +132,8 @@ func (s *BackupBackgroundService) cleanOldBackups() error {
|
||||
continue
|
||||
}
|
||||
|
||||
err = storage.DeleteFile(backup.ID)
|
||||
encryptor := encryption.GetFieldEncryptor()
|
||||
err = storage.DeleteFile(encryptor, backup.ID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to delete backup file", "backupId", backup.ID, "error", err)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
users_testing "postgresus-backend/internal/features/users/testing"
|
||||
workspaces_models "postgresus-backend/internal/features/workspaces/models"
|
||||
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
test_utils "postgresus-backend/internal/util/testing"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
)
|
||||
@@ -700,7 +701,7 @@ func createTestBackup(
|
||||
dummyContent := []byte("dummy backup content for testing")
|
||||
reader := strings.NewReader(string(dummyContent))
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := storages[0].SaveFile(logger, backup.ID, reader); err != nil {
|
||||
if err := storages[0].SaveFile(encryption.GetFieldEncryptor(), logger, backup.ID, reader); err != nil {
|
||||
panic(fmt.Sprintf("Failed to create test backup file: %v", err))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
package backups
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
audit_logs "postgresus-backend/internal/features/audit_logs"
|
||||
"postgresus-backend/internal/features/backups/backups/usecases"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
encryption_secrets "postgresus-backend/internal/features/encryption/secrets"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
users_repositories "postgresus-backend/internal/features/users/repositories"
|
||||
workspaces_services "postgresus-backend/internal/features/workspaces/services"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
"time"
|
||||
)
|
||||
|
||||
var backupRepository = &BackupRepository{}
|
||||
@@ -24,7 +26,8 @@ var backupService = &BackupService{
|
||||
notifiers.GetNotifierService(),
|
||||
notifiers.GetNotifierService(),
|
||||
backups_config.GetBackupConfigService(),
|
||||
users_repositories.GetSecretKeyRepository(),
|
||||
encryption_secrets.GetSecretKeyService(),
|
||||
encryption.GetFieldEncryptor(),
|
||||
usecases.GetCreateBackupUsecase(),
|
||||
logger.GetLogger(),
|
||||
[]BackupRemoveListener{},
|
||||
|
||||
@@ -7,18 +7,20 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
audit_logs "postgresus-backend/internal/features/audit_logs"
|
||||
"postgresus-backend/internal/features/backups/backups/encryption"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
encryption_secrets "postgresus-backend/internal/features/encryption/secrets"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
users_models "postgresus-backend/internal/features/users/models"
|
||||
users_repositories "postgresus-backend/internal/features/users/repositories"
|
||||
workspaces_services "postgresus-backend/internal/features/workspaces/services"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
util_encryption "postgresus-backend/internal/util/encryption"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -30,7 +32,8 @@ type BackupService struct {
|
||||
notifierService *notifiers.NotifierService
|
||||
notificationSender NotificationSender
|
||||
backupConfigService *backups_config.BackupConfigService
|
||||
secretKeyRepo *users_repositories.SecretKeyRepository
|
||||
secretKeyService *encryption_secrets.SecretKeyService
|
||||
fieldEncryptor util_encryption.FieldEncryptor
|
||||
|
||||
createBackupUseCase CreateBackupUsecase
|
||||
|
||||
@@ -284,7 +287,7 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID, isLastTry bool) {
|
||||
// Delete partial backup from storage
|
||||
storage, storageErr := s.storageService.GetStorageByID(backup.StorageID)
|
||||
if storageErr == nil {
|
||||
if deleteErr := storage.DeleteFile(backup.ID); deleteErr != nil {
|
||||
if deleteErr := storage.DeleteFile(s.fieldEncryptor, backup.ID); deleteErr != nil {
|
||||
s.logger.Error(
|
||||
"Failed to delete partial backup file",
|
||||
"backupId",
|
||||
@@ -545,7 +548,7 @@ func (s *BackupService) deleteBackup(backup *Backup) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = storage.DeleteFile(backup.ID)
|
||||
err = storage.DeleteFile(s.fieldEncryptor, backup.ID)
|
||||
if err != nil {
|
||||
// we do not return error here, because sometimes clean up performed
|
||||
// before unavailable storage removal or change - therefore we should
|
||||
@@ -599,7 +602,7 @@ func (s *BackupService) getBackupReader(backupID uuid.UUID) (io.ReadCloser, erro
|
||||
return nil, fmt.Errorf("failed to get storage: %w", err)
|
||||
}
|
||||
|
||||
fileReader, err := storage.GetFile(backup.ID)
|
||||
fileReader, err := storage.GetFile(s.fieldEncryptor, backup.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get backup file: %w", err)
|
||||
}
|
||||
@@ -626,7 +629,7 @@ func (s *BackupService) getBackupReader(backupID uuid.UUID) (io.ReadCloser, erro
|
||||
}
|
||||
|
||||
// Get master key
|
||||
masterKey, err := s.secretKeyRepo.GetSecretKey()
|
||||
masterKey, err := s.secretKeyService.GetSecretKey()
|
||||
if err != nil {
|
||||
if closeErr := fileReader.Close(); closeErr != nil {
|
||||
s.logger.Error("Failed to close file reader", "error", closeErr)
|
||||
|
||||
@@ -3,21 +3,23 @@ package backups
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
usecases_postgresql "postgresus-backend/internal/features/backups/backups/usecases/postgresql"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
users_enums "postgresus-backend/internal/features/users/enums"
|
||||
users_repositories "postgresus-backend/internal/features/users/repositories"
|
||||
users_testing "postgresus-backend/internal/features/users/testing"
|
||||
workspaces_services "postgresus-backend/internal/features/workspaces/services"
|
||||
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
usecases_postgresql "postgresus-backend/internal/features/backups/backups/usecases/postgresql"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
encryption_secrets "postgresus-backend/internal/features/encryption/secrets"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
users_enums "postgresus-backend/internal/features/users/enums"
|
||||
users_testing "postgresus-backend/internal/features/users/testing"
|
||||
workspaces_services "postgresus-backend/internal/features/workspaces/services"
|
||||
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -55,12 +57,13 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
|
||||
notifiers.GetNotifierService(),
|
||||
mockNotificationSender,
|
||||
backups_config.GetBackupConfigService(),
|
||||
users_repositories.GetSecretKeyRepository(),
|
||||
encryption_secrets.GetSecretKeyService(),
|
||||
encryption.GetFieldEncryptor(),
|
||||
&CreateFailedBackupUsecase{},
|
||||
logger.GetLogger(),
|
||||
[]BackupRemoveListener{},
|
||||
workspaces_services.GetWorkspaceService(),
|
||||
nil, // auditLogService
|
||||
nil,
|
||||
NewBackupContextManager(),
|
||||
}
|
||||
|
||||
@@ -102,12 +105,13 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
|
||||
notifiers.GetNotifierService(),
|
||||
mockNotificationSender,
|
||||
backups_config.GetBackupConfigService(),
|
||||
users_repositories.GetSecretKeyRepository(),
|
||||
encryption_secrets.GetSecretKeyService(),
|
||||
encryption.GetFieldEncryptor(),
|
||||
&CreateSuccessBackupUsecase{},
|
||||
logger.GetLogger(),
|
||||
[]BackupRemoveListener{},
|
||||
workspaces_services.GetWorkspaceService(),
|
||||
nil, // auditLogService
|
||||
nil,
|
||||
NewBackupContextManager(),
|
||||
}
|
||||
|
||||
@@ -126,12 +130,13 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
|
||||
notifiers.GetNotifierService(),
|
||||
mockNotificationSender,
|
||||
backups_config.GetBackupConfigService(),
|
||||
users_repositories.GetSecretKeyRepository(),
|
||||
encryption_secrets.GetSecretKeyService(),
|
||||
encryption.GetFieldEncryptor(),
|
||||
&CreateSuccessBackupUsecase{},
|
||||
logger.GetLogger(),
|
||||
[]BackupRemoveListener{},
|
||||
workspaces_services.GetWorkspaceService(),
|
||||
nil, // auditLogService
|
||||
nil,
|
||||
NewBackupContextManager(),
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,13 @@ import (
|
||||
"time"
|
||||
|
||||
"postgresus-backend/internal/config"
|
||||
"postgresus-backend/internal/features/backups/backups/encryption"
|
||||
backup_encryption "postgresus-backend/internal/features/backups/backups/encryption"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
encryption_secrets "postgresus-backend/internal/features/encryption/secrets"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
users_repositories "postgresus-backend/internal/features/users/repositories"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -33,15 +34,15 @@ const (
|
||||
progressReportIntervalMB = 1.0
|
||||
pgConnectTimeout = 30
|
||||
compressionLevel = 5
|
||||
defaultBackupLimit = 1000
|
||||
exitCodeAccessViolation = -1073741819
|
||||
exitCodeGenericError = 1
|
||||
exitCodeConnectionError = 2
|
||||
)
|
||||
|
||||
type CreatePostgresqlBackupUsecase struct {
|
||||
logger *slog.Logger
|
||||
secretKeyRepo *users_repositories.SecretKeyRepository
|
||||
logger *slog.Logger
|
||||
secretKeyService *encryption_secrets.SecretKeyService
|
||||
fieldEncryptor encryption.FieldEncryptor
|
||||
}
|
||||
|
||||
// Execute creates a backup of the database
|
||||
@@ -79,6 +80,11 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
|
||||
|
||||
args := uc.buildPgDumpArgs(pg)
|
||||
|
||||
decryptedPassword, err := uc.fieldEncryptor.Decrypt(db.ID, pg.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt database password: %w", err)
|
||||
}
|
||||
|
||||
return uc.streamToStorage(
|
||||
ctx,
|
||||
backupID,
|
||||
@@ -90,7 +96,7 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
|
||||
config.GetEnv().PostgresesInstallDir,
|
||||
),
|
||||
args,
|
||||
pg.Password,
|
||||
decryptedPassword,
|
||||
storage,
|
||||
db,
|
||||
backupProgressListener,
|
||||
@@ -166,7 +172,7 @@ func (uc *CreatePostgresqlBackupUsecase) streamToStorage(
|
||||
// Start streaming into storage in its own goroutine
|
||||
saveErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
saveErr := storage.SaveFile(uc.logger, backupID, storageReader)
|
||||
saveErr := storage.SaveFile(uc.fieldEncryptor, uc.logger, backupID, storageReader)
|
||||
saveErrCh <- saveErr
|
||||
}()
|
||||
|
||||
@@ -440,7 +446,7 @@ func (uc *CreatePostgresqlBackupUsecase) setupBackupEncryption(
|
||||
backupID uuid.UUID,
|
||||
backupConfig *backups_config.BackupConfig,
|
||||
storageWriter io.WriteCloser,
|
||||
) (io.Writer, *encryption.EncryptionWriter, BackupMetadata, error) {
|
||||
) (io.Writer, *backup_encryption.EncryptionWriter, BackupMetadata, error) {
|
||||
metadata := BackupMetadata{}
|
||||
|
||||
if backupConfig.Encryption != backups_config.BackupEncryptionEncrypted {
|
||||
@@ -449,22 +455,22 @@ func (uc *CreatePostgresqlBackupUsecase) setupBackupEncryption(
|
||||
return storageWriter, nil, metadata, nil
|
||||
}
|
||||
|
||||
salt, err := encryption.GenerateSalt()
|
||||
salt, err := backup_encryption.GenerateSalt()
|
||||
if err != nil {
|
||||
return nil, nil, metadata, fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
nonce, err := encryption.GenerateNonce()
|
||||
nonce, err := backup_encryption.GenerateNonce()
|
||||
if err != nil {
|
||||
return nil, nil, metadata, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
masterKey, err := uc.secretKeyRepo.GetSecretKey()
|
||||
masterKey, err := uc.secretKeyService.GetSecretKey()
|
||||
if err != nil {
|
||||
return nil, nil, metadata, fmt.Errorf("failed to get master key: %w", err)
|
||||
}
|
||||
|
||||
encWriter, err := encryption.NewEncryptionWriter(
|
||||
encWriter, err := backup_encryption.NewEncryptionWriter(
|
||||
storageWriter,
|
||||
masterKey,
|
||||
backupID,
|
||||
@@ -486,7 +492,7 @@ func (uc *CreatePostgresqlBackupUsecase) setupBackupEncryption(
|
||||
}
|
||||
|
||||
func (uc *CreatePostgresqlBackupUsecase) cleanupOnCancellation(
|
||||
encryptionWriter *encryption.EncryptionWriter,
|
||||
encryptionWriter *backup_encryption.EncryptionWriter,
|
||||
storageWriter io.WriteCloser,
|
||||
saveErrCh chan error,
|
||||
) {
|
||||
@@ -510,7 +516,7 @@ func (uc *CreatePostgresqlBackupUsecase) cleanupOnCancellation(
|
||||
}
|
||||
|
||||
func (uc *CreatePostgresqlBackupUsecase) closeWriters(
|
||||
encryptionWriter *encryption.EncryptionWriter,
|
||||
encryptionWriter *backup_encryption.EncryptionWriter,
|
||||
storageWriter io.WriteCloser,
|
||||
) error {
|
||||
encryptionCloseErrCh := make(chan error, 1)
|
||||
@@ -713,11 +719,15 @@ func (uc *CreatePostgresqlBackupUsecase) createTempPgpassFile(
|
||||
return "", nil
|
||||
}
|
||||
|
||||
escapedHost := tools.EscapePgpassField(pgConfig.Host)
|
||||
escapedUsername := tools.EscapePgpassField(pgConfig.Username)
|
||||
escapedPassword := tools.EscapePgpassField(password)
|
||||
|
||||
pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s",
|
||||
pgConfig.Host,
|
||||
escapedHost,
|
||||
pgConfig.Port,
|
||||
pgConfig.Username,
|
||||
password,
|
||||
escapedUsername,
|
||||
escapedPassword,
|
||||
)
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "pgpass")
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package usecases_postgresql
|
||||
|
||||
import (
|
||||
users_repositories "postgresus-backend/internal/features/users/repositories"
|
||||
"postgresus-backend/internal/features/encryption/secrets"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
var createPostgresqlBackupUsecase = &CreatePostgresqlBackupUsecase{
|
||||
logger.GetLogger(),
|
||||
users_repositories.GetSecretKeyRepository(),
|
||||
secrets.GetSecretKeyService(),
|
||||
encryption.GetFieldEncryptor(),
|
||||
}
|
||||
|
||||
func GetCreatePostgresqlBackupUsecase() *CreatePostgresqlBackupUsecase {
|
||||
|
||||
@@ -26,7 +26,8 @@ func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.POST("/databases/test-connection-direct", c.TestDatabaseConnectionDirect)
|
||||
router.POST("/databases/:id/copy", c.CopyDatabase)
|
||||
router.GET("/databases/notifier/:id/is-using", c.IsNotifierUsing)
|
||||
|
||||
router.POST("/databases/is-readonly", c.IsUserReadOnly)
|
||||
router.POST("/databases/create-readonly-user", c.CreateReadOnlyUser)
|
||||
}
|
||||
|
||||
// CreateDatabase
|
||||
@@ -330,3 +331,76 @@ func (c *DatabaseController) CopyDatabase(ctx *gin.Context) {
|
||||
|
||||
ctx.JSON(http.StatusCreated, copiedDatabase)
|
||||
}
|
||||
|
||||
// IsUserReadOnly
|
||||
// @Summary Check if database user is read-only
|
||||
// @Description Check if current database credentials have only read (SELECT) privileges
|
||||
// @Tags databases
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body Database true "Database configuration to check"
|
||||
// @Success 200 {object} IsReadOnlyResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Router /databases/is-readonly [post]
|
||||
func (c *DatabaseController) IsUserReadOnly(ctx *gin.Context) {
|
||||
user, ok := users_middleware.GetUserFromContext(ctx)
|
||||
if !ok {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var request Database
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
isReadOnly, err := c.databaseService.IsUserReadOnly(user, &request)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, IsReadOnlyResponse{IsReadOnly: isReadOnly})
|
||||
}
|
||||
|
||||
// CreateReadOnlyUser
|
||||
// @Summary Create read-only database user
|
||||
// @Description Create a new PostgreSQL user with read-only privileges for backup operations
|
||||
// @Tags databases
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body Database true "Database configuration to create user for"
|
||||
// @Success 200 {object} CreateReadOnlyUserResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Router /databases/create-readonly-user [post]
|
||||
func (c *DatabaseController) CreateReadOnlyUser(ctx *gin.Context) {
|
||||
user, ok := users_middleware.GetUserFromContext(ctx)
|
||||
if !ok {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var request Database
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
username, password, err := c.databaseService.CreateReadOnlyUser(user, &request)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, CreateReadOnlyUserResponse{
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
users_testing "postgresus-backend/internal/features/users/testing"
|
||||
workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers"
|
||||
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
test_utils "postgresus-backend/internal/util/testing"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
)
|
||||
@@ -769,6 +770,71 @@ func createTestDatabaseViaAPI(
|
||||
return &database
|
||||
}
|
||||
|
||||
func Test_CreateDatabase_PasswordIsEncryptedInDB(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
testDbName := "test_db"
|
||||
plainPassword := "my-super-secret-password-123"
|
||||
request := Database{
|
||||
Name: "Test Database",
|
||||
WorkspaceID: &workspace.ID,
|
||||
Type: DatabaseTypePostgres,
|
||||
Postgresql: &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
Username: "postgres",
|
||||
Password: plainPassword,
|
||||
Database: &testDbName,
|
||||
},
|
||||
}
|
||||
|
||||
var createdDatabase Database
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/databases/create",
|
||||
"Bearer "+owner.Token,
|
||||
request,
|
||||
http.StatusCreated,
|
||||
&createdDatabase,
|
||||
)
|
||||
|
||||
repository := &DatabaseRepository{}
|
||||
databaseFromDB, err := repository.FindByID(createdDatabase.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, databaseFromDB)
|
||||
assert.NotNil(t, databaseFromDB.Postgresql)
|
||||
|
||||
assert.True(
|
||||
t,
|
||||
strings.HasPrefix(databaseFromDB.Postgresql.Password, "enc:"),
|
||||
"Password should be encrypted in database with 'enc:' prefix, got: %s",
|
||||
databaseFromDB.Postgresql.Password,
|
||||
)
|
||||
|
||||
encryptor := encryption.GetFieldEncryptor()
|
||||
decryptedPassword, err := encryptor.Decrypt(
|
||||
databaseFromDB.ID,
|
||||
databaseFromDB.Postgresql.Password,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, plainPassword, decryptedPassword,
|
||||
"Decrypted password should match original plaintext password")
|
||||
|
||||
test_utils.MakeDeleteRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/databases/"+createdDatabase.ID.String(),
|
||||
"Bearer "+owner.Token,
|
||||
http.StatusNoContent,
|
||||
)
|
||||
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func Test_DatabaseSensitiveDataLifecycle_AllTypes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -815,7 +881,15 @@ func Test_DatabaseSensitiveDataLifecycle_AllTypes(t *testing.T) {
|
||||
}
|
||||
},
|
||||
verifySensitiveData: func(t *testing.T, database *Database) {
|
||||
assert.Equal(t, "original-password-secret", database.Postgresql.Password)
|
||||
// Verify password is encrypted
|
||||
assert.True(t, strings.HasPrefix(database.Postgresql.Password, "enc:"),
|
||||
"Password should be encrypted in database")
|
||||
|
||||
// Verify it can be decrypted back to original
|
||||
encryptor := encryption.GetFieldEncryptor()
|
||||
decrypted, err := encryptor.Decrypt(database.ID, database.Postgresql.Password)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "original-password-secret", decrypted)
|
||||
},
|
||||
verifyHiddenData: func(t *testing.T, database *Database) {
|
||||
assert.Equal(t, "", database.Postgresql.Password)
|
||||
|
||||
@@ -5,9 +5,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -18,7 +19,6 @@ type PostgresqlDatabase struct {
|
||||
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
|
||||
|
||||
DatabaseID *uuid.UUID `json:"databaseId" gorm:"type:uuid;column:database_id"`
|
||||
RestoreID *uuid.UUID `json:"restoreId" gorm:"type:uuid;column:restore_id"`
|
||||
|
||||
Version tools.PostgresqlVersion `json:"version" gorm:"type:text;not null"`
|
||||
|
||||
@@ -59,11 +59,15 @@ func (p *PostgresqlDatabase) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PostgresqlDatabase) TestConnection(logger *slog.Logger) error {
|
||||
func (p *PostgresqlDatabase) TestConnection(
|
||||
logger *slog.Logger,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
databaseID uuid.UUID,
|
||||
) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return testSingleDatabaseConnection(logger, ctx, p)
|
||||
return testSingleDatabaseConnection(logger, ctx, p, encryptor, databaseID)
|
||||
}
|
||||
|
||||
func (p *PostgresqlDatabase) HideSensitiveData() {
|
||||
@@ -87,19 +91,420 @@ func (p *PostgresqlDatabase) Update(incoming *PostgresqlDatabase) {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PostgresqlDatabase) EncryptSensitiveFields(
|
||||
databaseID uuid.UUID,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
) error {
|
||||
if p.Password != "" {
|
||||
encrypted, err := encryptor.Encrypt(databaseID, p.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Password = encrypted
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUserReadOnly checks if the database user has read-only privileges.
|
||||
//
|
||||
// This method performs a comprehensive security check by examining:
|
||||
// - Role-level attributes (superuser, createrole, createdb)
|
||||
// - Database-level privileges (CREATE, TEMP)
|
||||
// - Table-level write permissions (INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER)
|
||||
//
|
||||
// A user is considered read-only only if they have ZERO write privileges
|
||||
// across all three levels. This ensures the database user follows the
|
||||
// principle of least privilege for backup operations.
|
||||
func (p *PostgresqlDatabase) IsUserReadOnly(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
databaseID uuid.UUID,
|
||||
) (bool, error) {
|
||||
password, err := decryptPasswordIfNeeded(p.Password, encryptor, databaseID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to decrypt password: %w", err)
|
||||
}
|
||||
|
||||
connStr := buildConnectionStringForDB(p, *p.Database, password)
|
||||
|
||||
conn, err := pgx.Connect(ctx, connStr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := conn.Close(ctx); closeErr != nil {
|
||||
logger.Error("Failed to close connection", "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// LEVEL 1: Check role-level attributes
|
||||
var isSuperuser, canCreateRole, canCreateDB bool
|
||||
err = conn.QueryRow(ctx, `
|
||||
SELECT
|
||||
rolsuper,
|
||||
rolcreaterole,
|
||||
rolcreatedb
|
||||
FROM pg_roles
|
||||
WHERE rolname = current_user
|
||||
`).Scan(&isSuperuser, &canCreateRole, &canCreateDB)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check role attributes: %w", err)
|
||||
}
|
||||
|
||||
if isSuperuser || canCreateRole || canCreateDB {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// LEVEL 2: Check database-level privileges
|
||||
var canCreate, canTemp bool
|
||||
err = conn.QueryRow(ctx, `
|
||||
SELECT
|
||||
has_database_privilege(current_user, current_database(), 'CREATE') as can_create,
|
||||
has_database_privilege(current_user, current_database(), 'TEMP') as can_temp
|
||||
`).Scan(&canCreate, &canTemp)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check database privileges: %w", err)
|
||||
}
|
||||
|
||||
if canCreate || canTemp {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// LEVEL 2.5: Check schema-level CREATE privileges
|
||||
schemaRows, err := conn.Query(ctx, `
|
||||
SELECT DISTINCT nspname
|
||||
FROM pg_namespace n
|
||||
WHERE has_schema_privilege(current_user, n.nspname, 'CREATE')
|
||||
AND nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
||||
`)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check schema privileges: %w", err)
|
||||
}
|
||||
defer schemaRows.Close()
|
||||
|
||||
// If user has CREATE privilege on any schema, they're not read-only
|
||||
if schemaRows.Next() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := schemaRows.Err(); err != nil {
|
||||
return false, fmt.Errorf("error iterating schema privileges: %w", err)
|
||||
}
|
||||
|
||||
// LEVEL 3: Check table-level write permissions
|
||||
rows, err := conn.Query(ctx, `
|
||||
SELECT DISTINCT privilege_type
|
||||
FROM information_schema.role_table_grants
|
||||
WHERE grantee = current_user
|
||||
AND table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
`)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check table privileges: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
writePrivileges := map[string]bool{
|
||||
"INSERT": true,
|
||||
"UPDATE": true,
|
||||
"DELETE": true,
|
||||
"TRUNCATE": true,
|
||||
"REFERENCES": true,
|
||||
"TRIGGER": true,
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var privilege string
|
||||
if err := rows.Scan(&privilege); err != nil {
|
||||
return false, fmt.Errorf("failed to scan privilege: %w", err)
|
||||
}
|
||||
|
||||
if writePrivileges[privilege] {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, fmt.Errorf("error iterating privileges: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CreateReadOnlyUser creates a new PostgreSQL user with read-only privileges.
|
||||
//
|
||||
// This method performs the following operations atomically in a single transaction:
|
||||
// 1. Creates a PostgreSQL user with a UUID-based password
|
||||
// 2. Grants CONNECT privilege on the database
|
||||
// 3. Grants USAGE on all non-system schemas
|
||||
// 4. Grants SELECT on all existing tables and sequences
|
||||
// 5. Sets default privileges for future tables and sequences
|
||||
//
|
||||
// Security features:
|
||||
// - Username format: "postgresus-{8-char-uuid}" for uniqueness
|
||||
// - Password: Full UUID (36 characters) for strong entropy
|
||||
// - Transaction safety: All operations rollback on any failure
|
||||
// - Retry logic: Up to 3 attempts if username collision occurs
|
||||
// - Pre-validation: Checks CREATEROLE privilege before starting transaction
|
||||
func (p *PostgresqlDatabase) CreateReadOnlyUser(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
databaseID uuid.UUID,
|
||||
) (string, string, error) {
|
||||
password, err := decryptPasswordIfNeeded(p.Password, encryptor, databaseID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to decrypt password: %w", err)
|
||||
}
|
||||
|
||||
connStr := buildConnectionStringForDB(p, *p.Database, password)
|
||||
|
||||
conn, err := pgx.Connect(ctx, connStr)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := conn.Close(ctx); closeErr != nil {
|
||||
logger.Error("Failed to close connection", "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Pre-validate: Check if current user can create roles
|
||||
var canCreateRole, isSuperuser bool
|
||||
err = conn.QueryRow(ctx, `
|
||||
SELECT rolcreaterole, rolsuper
|
||||
FROM pg_roles
|
||||
WHERE rolname = current_user
|
||||
`).Scan(&canCreateRole, &isSuperuser)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to check permissions: %w", err)
|
||||
}
|
||||
if !canCreateRole && !isSuperuser {
|
||||
return "", "", errors.New("current database user lacks CREATEROLE privilege")
|
||||
}
|
||||
|
||||
// Retry logic for username collision
|
||||
maxRetries := 3
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
username := fmt.Sprintf("postgresus-%s", uuid.New().String()[:8])
|
||||
newPassword := uuid.New().String()
|
||||
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
if rollbackErr := tx.Rollback(ctx); rollbackErr != nil {
|
||||
logger.Error("Failed to rollback transaction", "error", rollbackErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Step 1: Create PostgreSQL user with LOGIN privilege
|
||||
_, err = tx.Exec(
|
||||
ctx,
|
||||
fmt.Sprintf(`CREATE USER "%s" WITH PASSWORD '%s' LOGIN`, username, newPassword),
|
||||
)
|
||||
if err != nil {
|
||||
if err.Error() != "" && attempt < maxRetries-1 {
|
||||
continue
|
||||
}
|
||||
return "", "", fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
// Step 1.5: Revoke CREATE privilege from PUBLIC role on public schema
|
||||
// This is necessary because all PostgreSQL users inherit CREATE privilege on the
|
||||
// public schema through the PUBLIC role. This is a one-time operation that affects
|
||||
// the entire database, making it more secure by default.
|
||||
// Note: This only affects the public schema; other schemas are unaffected.
|
||||
_, err = tx.Exec(ctx, `REVOKE CREATE ON SCHEMA public FROM PUBLIC`)
|
||||
if err != nil {
|
||||
logger.Error("Failed to revoke CREATE on public from PUBLIC", "error", err)
|
||||
if !strings.Contains(err.Error(), "schema \"public\" does not exist") &&
|
||||
!strings.Contains(err.Error(), "permission denied") {
|
||||
return "", "", fmt.Errorf("failed to revoke CREATE from PUBLIC: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Now revoke from the specific user as well (belt and suspenders)
|
||||
_, err = tx.Exec(ctx, fmt.Sprintf(`REVOKE CREATE ON SCHEMA public FROM "%s"`, username))
|
||||
if err != nil {
|
||||
logger.Error(
|
||||
"Failed to revoke CREATE on public schema from user",
|
||||
"error",
|
||||
err,
|
||||
"username",
|
||||
username,
|
||||
)
|
||||
}
|
||||
|
||||
// Step 2: Grant database connection privilege and revoke TEMP
|
||||
_, err = tx.Exec(
|
||||
ctx,
|
||||
fmt.Sprintf(`GRANT CONNECT ON DATABASE %s TO "%s"`, *p.Database, username),
|
||||
)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to grant connect privilege: %w", err)
|
||||
}
|
||||
|
||||
// Revoke TEMP privilege from PUBLIC role (like CREATE on public schema, TEMP is granted to PUBLIC by default)
|
||||
_, err = tx.Exec(ctx, fmt.Sprintf(`REVOKE TEMP ON DATABASE %s FROM PUBLIC`, *p.Database))
|
||||
if err != nil {
|
||||
logger.Warn("Failed to revoke TEMP from PUBLIC", "error", err)
|
||||
}
|
||||
|
||||
// Also revoke from the specific user (belt and suspenders)
|
||||
_, err = tx.Exec(
|
||||
ctx,
|
||||
fmt.Sprintf(`REVOKE TEMP ON DATABASE %s FROM "%s"`, *p.Database, username),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to revoke TEMP privilege", "error", err, "username", username)
|
||||
}
|
||||
|
||||
// Step 3: Discover all user-created schemas
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT schema_name
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema')
|
||||
`)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get schemas: %w", err)
|
||||
}
|
||||
|
||||
var schemas []string
|
||||
for rows.Next() {
|
||||
var schema string
|
||||
if err := rows.Scan(&schema); err != nil {
|
||||
rows.Close()
|
||||
return "", "", fmt.Errorf("failed to scan schema: %w", err)
|
||||
}
|
||||
schemas = append(schemas, schema)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", "", fmt.Errorf("error iterating schemas: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Grant USAGE on each schema and explicitly prevent CREATE
|
||||
for _, schema := range schemas {
|
||||
// Revoke CREATE specifically (handles inheritance from PUBLIC role)
|
||||
_, err = tx.Exec(
|
||||
ctx,
|
||||
fmt.Sprintf(`REVOKE CREATE ON SCHEMA "%s" FROM "%s"`, schema, username),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn(
|
||||
"Failed to revoke CREATE on schema",
|
||||
"error",
|
||||
err,
|
||||
"schema",
|
||||
schema,
|
||||
"username",
|
||||
username,
|
||||
)
|
||||
}
|
||||
|
||||
// Grant only USAGE (not CREATE)
|
||||
_, err = tx.Exec(
|
||||
ctx,
|
||||
fmt.Sprintf(`GRANT USAGE ON SCHEMA "%s" TO "%s"`, schema, username),
|
||||
)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to grant usage on schema %s: %w", schema, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Grant SELECT on ALL existing tables and sequences
|
||||
grantSelectSQL := fmt.Sprintf(`
|
||||
DO $$
|
||||
DECLARE
|
||||
schema_rec RECORD;
|
||||
BEGIN
|
||||
FOR schema_rec IN
|
||||
SELECT schema_name
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema')
|
||||
LOOP
|
||||
EXECUTE format('GRANT SELECT ON ALL TABLES IN SCHEMA %%I TO "%s"', schema_rec.schema_name);
|
||||
EXECUTE format('GRANT SELECT ON ALL SEQUENCES IN SCHEMA %%I TO "%s"', schema_rec.schema_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`, username, username)
|
||||
|
||||
_, err = tx.Exec(ctx, grantSelectSQL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to grant select on tables: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: Set default privileges for FUTURE tables and sequences
|
||||
defaultPrivilegesSQL := fmt.Sprintf(`
|
||||
DO $$
|
||||
DECLARE
|
||||
schema_rec RECORD;
|
||||
BEGIN
|
||||
FOR schema_rec IN
|
||||
SELECT schema_name
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema')
|
||||
LOOP
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %%I GRANT SELECT ON TABLES TO "%s"', schema_rec.schema_name);
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %%I GRANT SELECT ON SEQUENCES TO "%s"', schema_rec.schema_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`, username, username)
|
||||
|
||||
_, err = tx.Exec(ctx, defaultPrivilegesSQL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to set default privileges: %w", err)
|
||||
}
|
||||
|
||||
// Step 7: Verify user creation before committing
|
||||
var verifyUsername string
|
||||
err = tx.QueryRow(ctx, fmt.Sprintf(`SELECT rolname FROM pg_roles WHERE rolname = '%s'`, username)).
|
||||
Scan(&verifyUsername)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to verify user creation: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", "", fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
success = true
|
||||
logger.Info("Read-only user created successfully", "username", username)
|
||||
return username, newPassword, nil
|
||||
}
|
||||
|
||||
return "", "", errors.New("failed to generate unique username after 3 attempts")
|
||||
}
|
||||
|
||||
// testSingleDatabaseConnection tests connection to a specific database for pg_dump
|
||||
func testSingleDatabaseConnection(
|
||||
logger *slog.Logger,
|
||||
ctx context.Context,
|
||||
postgresDb *PostgresqlDatabase,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
databaseID uuid.UUID,
|
||||
) error {
|
||||
// For single database backup, we need to connect to the specific database
|
||||
if postgresDb.Database == nil || *postgresDb.Database == "" {
|
||||
return errors.New("database name is required for single database backup (pg_dump)")
|
||||
}
|
||||
|
||||
// Decrypt password if needed
|
||||
password, err := decryptPasswordIfNeeded(postgresDb.Password, encryptor, databaseID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt password: %w", err)
|
||||
}
|
||||
|
||||
// Build connection string for the specific database
|
||||
connStr := buildConnectionStringForDB(postgresDb, *postgresDb.Database)
|
||||
connStr := buildConnectionStringForDB(postgresDb, *postgresDb.Database, password)
|
||||
|
||||
// Test connection
|
||||
conn, err := pgx.Connect(ctx, connStr)
|
||||
@@ -182,7 +587,7 @@ func testBasicOperations(ctx context.Context, conn *pgx.Conn, dbName string) err
|
||||
}
|
||||
|
||||
// buildConnectionStringForDB builds connection string for specific database
|
||||
func buildConnectionStringForDB(p *PostgresqlDatabase, dbName string) string {
|
||||
func buildConnectionStringForDB(p *PostgresqlDatabase, dbName string, password string) string {
|
||||
sslMode := "disable"
|
||||
if p.IsHttps {
|
||||
sslMode = "require"
|
||||
@@ -192,106 +597,19 @@ func buildConnectionStringForDB(p *PostgresqlDatabase, dbName string) string {
|
||||
p.Host,
|
||||
p.Port,
|
||||
p.Username,
|
||||
p.Password,
|
||||
password,
|
||||
dbName,
|
||||
sslMode,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *PostgresqlDatabase) InstallExtensions(extensions []tools.PostgresqlExtension) error {
|
||||
if len(extensions) == 0 {
|
||||
return nil
|
||||
func decryptPasswordIfNeeded(
|
||||
password string,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
databaseID uuid.UUID,
|
||||
) (string, error) {
|
||||
if encryptor == nil {
|
||||
return password, nil
|
||||
}
|
||||
|
||||
if p.Database == nil || *p.Database == "" {
|
||||
return errors.New("database name is required for installing extensions")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Build connection string for the specific database
|
||||
connStr := buildConnectionStringForDB(p, *p.Database)
|
||||
|
||||
// Connect to database
|
||||
conn, err := pgx.Connect(ctx, connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database '%s': %w", *p.Database, err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := conn.Close(ctx); closeErr != nil {
|
||||
fmt.Println("failed to close connection: %w", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Check which extensions are already installed
|
||||
installedExtensions, err := p.getInstalledExtensions(ctx, conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check installed extensions: %w", err)
|
||||
}
|
||||
|
||||
// Install missing extensions
|
||||
for _, extension := range extensions {
|
||||
if contains(installedExtensions, string(extension)) {
|
||||
continue // Extension already installed
|
||||
}
|
||||
|
||||
if err := p.installExtension(ctx, conn, string(extension)); err != nil {
|
||||
return fmt.Errorf("failed to install extension '%s': %w", extension, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getInstalledExtensions queries the database for currently installed extensions
|
||||
func (p *PostgresqlDatabase) getInstalledExtensions(
|
||||
ctx context.Context,
|
||||
conn *pgx.Conn,
|
||||
) ([]string, error) {
|
||||
query := "SELECT extname FROM pg_extension"
|
||||
|
||||
rows, err := conn.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query installed extensions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var extensions []string
|
||||
for rows.Next() {
|
||||
var extname string
|
||||
|
||||
if err := rows.Scan(&extname); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan extension name: %w", err)
|
||||
}
|
||||
|
||||
extensions = append(extensions, extname)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating over extension rows: %w", err)
|
||||
}
|
||||
|
||||
return extensions, nil
|
||||
}
|
||||
|
||||
// installExtension installs a single PostgreSQL extension
|
||||
func (p *PostgresqlDatabase) installExtension(
|
||||
ctx context.Context,
|
||||
conn *pgx.Conn,
|
||||
extensionName string,
|
||||
) error {
|
||||
query := fmt.Sprintf("CREATE EXTENSION IF NOT EXISTS %s", extensionName)
|
||||
|
||||
_, err := conn.Exec(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute CREATE EXTENSION: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// contains checks if a string slice contains a specific string
|
||||
func contains(slice []string, item string) bool {
|
||||
return slices.Contains(slice, item)
|
||||
return encryptor.Decrypt(databaseID, password)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package postgresql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"postgresus-backend/internal/config"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
)
|
||||
|
||||
func Test_IsUserReadOnly_AdminUser_ReturnsFalse(t *testing.T) {
|
||||
env := config.GetEnv()
|
||||
cases := []struct {
|
||||
name string
|
||||
version string
|
||||
port string
|
||||
}{
|
||||
{"PostgreSQL 12", "12", env.TestPostgres12Port},
|
||||
{"PostgreSQL 13", "13", env.TestPostgres13Port},
|
||||
{"PostgreSQL 14", "14", env.TestPostgres14Port},
|
||||
{"PostgreSQL 15", "15", env.TestPostgres15Port},
|
||||
{"PostgreSQL 16", "16", env.TestPostgres16Port},
|
||||
{"PostgreSQL 17", "17", env.TestPostgres17Port},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
container := connectToPostgresContainer(t, tc.port)
|
||||
defer container.DB.Close()
|
||||
|
||||
pgModel := createPostgresModel(container)
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
ctx := context.Background()
|
||||
|
||||
isReadOnly, err := pgModel.IsUserReadOnly(ctx, logger, nil, uuid.New())
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, isReadOnly, "Admin user should not be read-only")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_CreateReadOnlyUser_UserCanReadButNotWrite(t *testing.T) {
|
||||
env := config.GetEnv()
|
||||
cases := []struct {
|
||||
name string
|
||||
version string
|
||||
port string
|
||||
}{
|
||||
{"PostgreSQL 12", "12", env.TestPostgres12Port},
|
||||
{"PostgreSQL 13", "13", env.TestPostgres13Port},
|
||||
{"PostgreSQL 14", "14", env.TestPostgres14Port},
|
||||
{"PostgreSQL 15", "15", env.TestPostgres15Port},
|
||||
{"PostgreSQL 16", "16", env.TestPostgres16Port},
|
||||
{"PostgreSQL 17", "17", env.TestPostgres17Port},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
container := connectToPostgresContainer(t, tc.port)
|
||||
defer container.DB.Close()
|
||||
|
||||
_, err := container.DB.Exec(`
|
||||
DROP TABLE IF EXISTS readonly_test CASCADE;
|
||||
DROP TABLE IF EXISTS hack_table CASCADE;
|
||||
DROP TABLE IF EXISTS future_table CASCADE;
|
||||
CREATE TABLE readonly_test (
|
||||
id SERIAL PRIMARY KEY,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO readonly_test (data) VALUES ('test1'), ('test2');
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
pgModel := createPostgresModel(container)
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
ctx := context.Background()
|
||||
|
||||
username, password, err := pgModel.CreateReadOnlyUser(ctx, logger, nil, uuid.New())
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, username)
|
||||
assert.NotEmpty(t, password)
|
||||
assert.True(t, strings.HasPrefix(username, "postgresus-"))
|
||||
|
||||
readOnlyModel := &PostgresqlDatabase{
|
||||
Version: pgModel.Version,
|
||||
Host: pgModel.Host,
|
||||
Port: pgModel.Port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: pgModel.Database,
|
||||
IsHttps: false,
|
||||
}
|
||||
|
||||
isReadOnly, err := readOnlyModel.IsUserReadOnly(ctx, logger, nil, uuid.New())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, isReadOnly, "Created user should be read-only")
|
||||
|
||||
readOnlyDSN := fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
container.Host,
|
||||
container.Port,
|
||||
username,
|
||||
password,
|
||||
container.Database,
|
||||
)
|
||||
readOnlyConn, err := sqlx.Connect("postgres", readOnlyDSN)
|
||||
assert.NoError(t, err)
|
||||
defer readOnlyConn.Close()
|
||||
|
||||
var count int
|
||||
err = readOnlyConn.Get(&count, "SELECT COUNT(*) FROM readonly_test")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
|
||||
_, err = readOnlyConn.Exec("INSERT INTO readonly_test (data) VALUES ('should-fail')")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "permission denied")
|
||||
|
||||
_, err = readOnlyConn.Exec("UPDATE readonly_test SET data = 'hacked' WHERE id = 1")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "permission denied")
|
||||
|
||||
_, err = readOnlyConn.Exec("DELETE FROM readonly_test WHERE id = 1")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "permission denied")
|
||||
|
||||
_, err = readOnlyConn.Exec("CREATE TABLE hack_table (id INT)")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "permission denied")
|
||||
|
||||
// Clean up: Drop user with CASCADE to handle default privilege dependencies
|
||||
_, err = container.DB.Exec(fmt.Sprintf(`DROP OWNED BY "%s" CASCADE`, username))
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to drop owned objects: %v", err)
|
||||
}
|
||||
|
||||
_, err = container.DB.Exec(fmt.Sprintf(`DROP USER IF EXISTS "%s"`, username))
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ReadOnlyUser_FutureTables_HaveSelectPermission(t *testing.T) {
|
||||
env := config.GetEnv()
|
||||
container := connectToPostgresContainer(t, env.TestPostgres16Port)
|
||||
defer container.DB.Close()
|
||||
|
||||
pgModel := createPostgresModel(container)
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
ctx := context.Background()
|
||||
|
||||
username, password, err := pgModel.CreateReadOnlyUser(ctx, logger, nil, uuid.New())
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = container.DB.Exec(`
|
||||
CREATE TABLE future_table (
|
||||
id SERIAL PRIMARY KEY,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO future_table (data) VALUES ('future_data');
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
readOnlyDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
container.Host, container.Port, username, password, container.Database)
|
||||
readOnlyConn, err := sqlx.Connect("postgres", readOnlyDSN)
|
||||
assert.NoError(t, err)
|
||||
defer readOnlyConn.Close()
|
||||
|
||||
var data string
|
||||
err = readOnlyConn.Get(&data, "SELECT data FROM future_table LIMIT 1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "future_data", data)
|
||||
|
||||
// Clean up: Drop user with CASCADE to handle default privilege dependencies
|
||||
_, err = container.DB.Exec(fmt.Sprintf(`DROP OWNED BY "%s" CASCADE`, username))
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to drop owned objects: %v", err)
|
||||
}
|
||||
|
||||
_, err = container.DB.Exec(fmt.Sprintf(`DROP USER IF EXISTS "%s"`, username))
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_ReadOnlyUser_MultipleSchemas_AllAccessible(t *testing.T) {
|
||||
env := config.GetEnv()
|
||||
container := connectToPostgresContainer(t, env.TestPostgres16Port)
|
||||
defer container.DB.Close()
|
||||
|
||||
_, err := container.DB.Exec(`
|
||||
CREATE SCHEMA IF NOT EXISTS schema_a;
|
||||
CREATE SCHEMA IF NOT EXISTS schema_b;
|
||||
CREATE TABLE schema_a.table_a (id INT, data TEXT);
|
||||
CREATE TABLE schema_b.table_b (id INT, data TEXT);
|
||||
INSERT INTO schema_a.table_a VALUES (1, 'data_a');
|
||||
INSERT INTO schema_b.table_b VALUES (2, 'data_b');
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
pgModel := createPostgresModel(container)
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
ctx := context.Background()
|
||||
|
||||
username, password, err := pgModel.CreateReadOnlyUser(ctx, logger, nil, uuid.New())
|
||||
assert.NoError(t, err)
|
||||
|
||||
readOnlyDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
container.Host, container.Port, username, password, container.Database)
|
||||
readOnlyConn, err := sqlx.Connect("postgres", readOnlyDSN)
|
||||
assert.NoError(t, err)
|
||||
defer readOnlyConn.Close()
|
||||
|
||||
var dataA string
|
||||
err = readOnlyConn.Get(&dataA, "SELECT data FROM schema_a.table_a LIMIT 1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "data_a", dataA)
|
||||
|
||||
var dataB string
|
||||
err = readOnlyConn.Get(&dataB, "SELECT data FROM schema_b.table_b LIMIT 1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "data_b", dataB)
|
||||
|
||||
// Clean up: Drop user with CASCADE to handle default privilege dependencies
|
||||
_, err = container.DB.Exec(fmt.Sprintf(`DROP OWNED BY "%s" CASCADE`, username))
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to drop owned objects: %v", err)
|
||||
}
|
||||
|
||||
_, err = container.DB.Exec(fmt.Sprintf(`DROP USER IF EXISTS "%s"`, username))
|
||||
assert.NoError(t, err)
|
||||
_, err = container.DB.Exec(`DROP SCHEMA schema_a CASCADE; DROP SCHEMA schema_b CASCADE;`)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
type PostgresContainer struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Database string
|
||||
DB *sqlx.DB
|
||||
}
|
||||
|
||||
func connectToPostgresContainer(t *testing.T, port string) *PostgresContainer {
|
||||
dbName := "testdb"
|
||||
password := "testpassword"
|
||||
username := "testuser"
|
||||
host := "localhost"
|
||||
|
||||
portInt, err := strconv.Atoi(port)
|
||||
assert.NoError(t, err)
|
||||
|
||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, portInt, username, password, dbName)
|
||||
|
||||
db, err := sqlx.Connect("postgres", dsn)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var versionStr string
|
||||
err = db.Get(&versionStr, "SELECT version()")
|
||||
assert.NoError(t, err)
|
||||
|
||||
return &PostgresContainer{
|
||||
Host: host,
|
||||
Port: portInt,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: dbName,
|
||||
DB: db,
|
||||
}
|
||||
}
|
||||
|
||||
func createPostgresModel(container *PostgresContainer) *PostgresqlDatabase {
|
||||
var versionStr string
|
||||
err := container.DB.Get(&versionStr, "SELECT version()")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
version := extractPostgresVersion(versionStr)
|
||||
|
||||
return &PostgresqlDatabase{
|
||||
Version: version,
|
||||
Host: container.Host,
|
||||
Port: container.Port,
|
||||
Username: container.Username,
|
||||
Password: container.Password,
|
||||
Database: &container.Database,
|
||||
IsHttps: false,
|
||||
}
|
||||
}
|
||||
|
||||
func extractPostgresVersion(versionStr string) tools.PostgresqlVersion {
|
||||
if strings.Contains(versionStr, "PostgreSQL 12") {
|
||||
return tools.GetPostgresqlVersionEnum("12")
|
||||
} else if strings.Contains(versionStr, "PostgreSQL 13") {
|
||||
return tools.GetPostgresqlVersionEnum("13")
|
||||
} else if strings.Contains(versionStr, "PostgreSQL 14") {
|
||||
return tools.GetPostgresqlVersionEnum("14")
|
||||
} else if strings.Contains(versionStr, "PostgreSQL 15") {
|
||||
return tools.GetPostgresqlVersionEnum("15")
|
||||
} else if strings.Contains(versionStr, "PostgreSQL 16") {
|
||||
return tools.GetPostgresqlVersionEnum("16")
|
||||
} else if strings.Contains(versionStr, "PostgreSQL 17") {
|
||||
return tools.GetPostgresqlVersionEnum("17")
|
||||
}
|
||||
|
||||
return tools.GetPostgresqlVersionEnum("16")
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
users_services "postgresus-backend/internal/features/users/services"
|
||||
workspaces_services "postgresus-backend/internal/features/workspaces/services"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,7 @@ var databaseService = &DatabaseService{
|
||||
[]DatabaseCopyListener{},
|
||||
workspaces_services.GetWorkspaceService(),
|
||||
audit_logs.GetAuditLogService(),
|
||||
encryption.GetFieldEncryptor(),
|
||||
}
|
||||
|
||||
var databaseController = &DatabaseController{
|
||||
|
||||
10
backend/internal/features/databases/dto.go
Normal file
10
backend/internal/features/databases/dto.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package databases
|
||||
|
||||
type CreateReadOnlyUserResponse struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type IsReadOnlyResponse struct {
|
||||
IsReadOnly bool `json:"isReadOnly"`
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package databases
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -11,7 +12,11 @@ type DatabaseValidator interface {
|
||||
}
|
||||
|
||||
type DatabaseConnector interface {
|
||||
TestConnection(logger *slog.Logger) error
|
||||
TestConnection(
|
||||
logger *slog.Logger,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
databaseID uuid.UUID,
|
||||
) error
|
||||
|
||||
HideSensitiveData()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -56,14 +57,24 @@ func (d *Database) ValidateUpdate(old, new Database) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) TestConnection(logger *slog.Logger) error {
|
||||
return d.getSpecificDatabase().TestConnection(logger)
|
||||
func (d *Database) TestConnection(
|
||||
logger *slog.Logger,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
) error {
|
||||
return d.getSpecificDatabase().TestConnection(logger, encryptor, d.ID)
|
||||
}
|
||||
|
||||
func (d *Database) HideSensitiveData() {
|
||||
d.getSpecificDatabase().HideSensitiveData()
|
||||
}
|
||||
|
||||
func (d *Database) EncryptSensitiveFields(encryptor encryption.FieldEncryptor) error {
|
||||
if d.Postgresql != nil {
|
||||
return d.Postgresql.EncryptSensitiveFields(d.ID, encryptor)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) Update(incoming *Database) {
|
||||
d.Name = incoming.Name
|
||||
d.Type = incoming.Type
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package databases
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
users_models "postgresus-backend/internal/features/users/models"
|
||||
workspaces_services "postgresus-backend/internal/features/workspaces/services"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -26,6 +28,7 @@ type DatabaseService struct {
|
||||
|
||||
workspaceService *workspaces_services.WorkspaceService
|
||||
auditLogService *audit_logs.AuditLogService
|
||||
fieldEncryptor encryption.FieldEncryptor
|
||||
}
|
||||
|
||||
func (s *DatabaseService) AddDbCreationListener(
|
||||
@@ -65,6 +68,10 @@ func (s *DatabaseService) CreateDatabase(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := database.EncryptSensitiveFields(s.fieldEncryptor); err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt sensitive fields: %w", err)
|
||||
}
|
||||
|
||||
database, err = s.dbRepository.Save(database)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -118,6 +125,10 @@ func (s *DatabaseService) UpdateDatabase(
|
||||
return err
|
||||
}
|
||||
|
||||
if err := existingDatabase.EncryptSensitiveFields(s.fieldEncryptor); err != nil {
|
||||
return fmt.Errorf("failed to encrypt sensitive fields: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.dbRepository.Save(existingDatabase)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -250,7 +261,7 @@ func (s *DatabaseService) TestDatabaseConnection(
|
||||
return errors.New("insufficient permissions to test connection for this database")
|
||||
}
|
||||
|
||||
err = database.TestConnection(s.logger)
|
||||
err = database.TestConnection(s.logger, s.fieldEncryptor)
|
||||
if err != nil {
|
||||
lastSaveError := err.Error()
|
||||
database.LastBackupErrorMessage = &lastSaveError
|
||||
@@ -294,7 +305,7 @@ func (s *DatabaseService) TestDatabaseConnectionDirect(
|
||||
usingDatabase = database
|
||||
}
|
||||
|
||||
return usingDatabase.TestConnection(s.logger)
|
||||
return usingDatabase.TestConnection(s.logger, s.fieldEncryptor)
|
||||
}
|
||||
|
||||
func (s *DatabaseService) GetDatabaseByID(
|
||||
@@ -446,3 +457,148 @@ func (s *DatabaseService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DatabaseService) IsUserReadOnly(
|
||||
user *users_models.User,
|
||||
database *Database,
|
||||
) (bool, error) {
|
||||
var usingDatabase *Database
|
||||
|
||||
if database.ID != uuid.Nil {
|
||||
existingDatabase, err := s.dbRepository.FindByID(database.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if existingDatabase.WorkspaceID == nil {
|
||||
return false, errors.New("cannot check user for database without workspace")
|
||||
}
|
||||
|
||||
canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(
|
||||
*existingDatabase.WorkspaceID,
|
||||
user,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !canAccess {
|
||||
return false, errors.New("insufficient permissions to access this database")
|
||||
}
|
||||
|
||||
if database.WorkspaceID != nil && *existingDatabase.WorkspaceID != *database.WorkspaceID {
|
||||
return false, errors.New("database does not belong to this workspace")
|
||||
}
|
||||
|
||||
existingDatabase.Update(database)
|
||||
|
||||
if err := existingDatabase.Validate(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
usingDatabase = existingDatabase
|
||||
} else {
|
||||
if database.WorkspaceID != nil {
|
||||
canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !canAccess {
|
||||
return false, errors.New("insufficient permissions to access this workspace")
|
||||
}
|
||||
}
|
||||
|
||||
usingDatabase = database
|
||||
}
|
||||
|
||||
if usingDatabase.Type != DatabaseTypePostgres {
|
||||
return false, errors.New("read-only check only supported for PostgreSQL databases")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return usingDatabase.Postgresql.IsUserReadOnly(
|
||||
ctx,
|
||||
s.logger,
|
||||
s.fieldEncryptor,
|
||||
usingDatabase.ID,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *DatabaseService) CreateReadOnlyUser(
|
||||
user *users_models.User,
|
||||
database *Database,
|
||||
) (string, string, error) {
|
||||
var usingDatabase *Database
|
||||
|
||||
if database.ID != uuid.Nil {
|
||||
existingDatabase, err := s.dbRepository.FindByID(database.ID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if existingDatabase.WorkspaceID == nil {
|
||||
return "", "", errors.New("cannot create user for database without workspace")
|
||||
}
|
||||
|
||||
canManage, err := s.workspaceService.CanUserManageDBs(*existingDatabase.WorkspaceID, user)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !canManage {
|
||||
return "", "", errors.New("insufficient permissions to manage this database")
|
||||
}
|
||||
|
||||
if database.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 {
|
||||
if database.WorkspaceID != nil {
|
||||
canManage, err := s.workspaceService.CanUserManageDBs(*database.WorkspaceID, user)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !canManage {
|
||||
return "", "", errors.New("insufficient permissions to manage this workspace")
|
||||
}
|
||||
}
|
||||
|
||||
usingDatabase = database
|
||||
}
|
||||
|
||||
if usingDatabase.Type != DatabaseTypePostgres {
|
||||
return "", "", errors.New("read-only user creation only supported for PostgreSQL")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
username, password, err := usingDatabase.Postgresql.CreateReadOnlyUser(
|
||||
ctx, s.logger, s.fieldEncryptor, usingDatabase.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if usingDatabase.WorkspaceID != nil {
|
||||
s.auditLogService.WriteAuditLog(
|
||||
fmt.Sprintf(
|
||||
"Read-only user created for database: %s (username: %s)",
|
||||
usingDatabase.Name,
|
||||
username,
|
||||
),
|
||||
&user.ID,
|
||||
usingDatabase.WorkspaceID,
|
||||
)
|
||||
}
|
||||
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
9
backend/internal/features/encryption/secrets/di.go
Normal file
9
backend/internal/features/encryption/secrets/di.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package secrets
|
||||
|
||||
var secretKeyService = &SecretKeyService{
|
||||
nil,
|
||||
}
|
||||
|
||||
func GetSecretKeyService() *SecretKeyService {
|
||||
return secretKeyService
|
||||
}
|
||||
1
backend/internal/features/encryption/secrets/model.go
Normal file
1
backend/internal/features/encryption/secrets/model.go
Normal file
@@ -0,0 +1 @@
|
||||
package secrets
|
||||
73
backend/internal/features/encryption/secrets/service.go
Normal file
73
backend/internal/features/encryption/secrets/service.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"postgresus-backend/internal/config"
|
||||
user_models "postgresus-backend/internal/features/users/models"
|
||||
"postgresus-backend/internal/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SecretKeyService struct {
|
||||
cachedKey *string
|
||||
}
|
||||
|
||||
func (s *SecretKeyService) MigrateKeyFromDbToFileIfExist() error {
|
||||
var secretKey user_models.SecretKey
|
||||
|
||||
err := storage.GetDb().First(&secretKey).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to check for secret key in database: %w", err)
|
||||
}
|
||||
|
||||
if secretKey.Secret == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
secretKeyPath := config.GetEnv().SecretKeyPath
|
||||
if err := os.WriteFile(secretKeyPath, []byte(secretKey.Secret), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write secret key to file: %w", err)
|
||||
}
|
||||
|
||||
if err := storage.GetDb().Exec("DELETE FROM secret_keys").Error; err != nil {
|
||||
return fmt.Errorf("failed to delete secret key from database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SecretKeyService) GetSecretKey() (string, error) {
|
||||
if s.cachedKey != nil {
|
||||
return *s.cachedKey, nil
|
||||
}
|
||||
|
||||
secretKeyPath := config.GetEnv().SecretKeyPath
|
||||
data, err := os.ReadFile(secretKeyPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
newKey := s.generateNewSecretKey()
|
||||
if err := os.WriteFile(secretKeyPath, []byte(newKey), 0600); err != nil {
|
||||
return "", fmt.Errorf("failed to write new secret key: %w", err)
|
||||
}
|
||||
s.cachedKey = &newKey
|
||||
return newKey, nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to read secret key file: %w", err)
|
||||
}
|
||||
|
||||
key := string(data)
|
||||
s.cachedKey = &key
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (s *SecretKeyService) generateNewSecretKey() string {
|
||||
return uuid.New().String() + uuid.New().String()
|
||||
}
|
||||
@@ -453,70 +453,6 @@ func Test_CrossWorkspaceSecurity_CannotAccessNotifierFromAnotherWorkspace(t *tes
|
||||
workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
}
|
||||
|
||||
func createRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
v1 := router.Group("/api/v1")
|
||||
protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService()))
|
||||
|
||||
if routerGroup, ok := protected.(*gin.RouterGroup); ok {
|
||||
GetNotifierController().RegisterRoutes(routerGroup)
|
||||
workspaces_controllers.GetWorkspaceController().RegisterRoutes(routerGroup)
|
||||
workspaces_controllers.GetMembershipController().RegisterRoutes(routerGroup)
|
||||
}
|
||||
|
||||
audit_logs.SetupDependencies()
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func createNewNotifier(workspaceID uuid.UUID) *Notifier {
|
||||
return &Notifier{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: "Test Notifier " + uuid.New().String(),
|
||||
NotifierType: NotifierTypeWebhook,
|
||||
WebhookNotifier: &webhook_notifier.WebhookNotifier{
|
||||
WebhookURL: "https://webhook.site/test-" + uuid.New().String(),
|
||||
WebhookMethod: webhook_notifier.WebhookMethodPOST,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTelegramNotifier(workspaceID uuid.UUID) *Notifier {
|
||||
env := config.GetEnv()
|
||||
return &Notifier{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: "Test Telegram Notifier " + uuid.New().String(),
|
||||
NotifierType: NotifierTypeTelegram,
|
||||
TelegramNotifier: &telegram_notifier.TelegramNotifier{
|
||||
BotToken: env.TestTelegramBotToken,
|
||||
TargetChatID: env.TestTelegramChatID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func verifyNotifierData(t *testing.T, expected *Notifier, actual *Notifier) {
|
||||
assert.Equal(t, expected.Name, actual.Name)
|
||||
assert.Equal(t, expected.NotifierType, actual.NotifierType)
|
||||
assert.Equal(t, expected.WorkspaceID, actual.WorkspaceID)
|
||||
}
|
||||
|
||||
func deleteNotifier(
|
||||
t *testing.T,
|
||||
router *gin.Engine,
|
||||
notifierID, workspaceID uuid.UUID,
|
||||
token string,
|
||||
) {
|
||||
test_utils.MakeDeleteRequest(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/notifiers/%s", notifierID.String()),
|
||||
"Bearer "+token,
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
|
||||
func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -553,7 +489,13 @@ func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
|
||||
}
|
||||
},
|
||||
verifySensitiveData: func(t *testing.T, notifier *Notifier) {
|
||||
assert.Equal(t, "original-bot-token-12345", notifier.TelegramNotifier.BotToken)
|
||||
assert.True(
|
||||
t,
|
||||
isEncrypted(notifier.TelegramNotifier.BotToken),
|
||||
"BotToken should be encrypted in DB",
|
||||
)
|
||||
decrypted := decryptField(t, notifier.ID, notifier.TelegramNotifier.BotToken)
|
||||
assert.Equal(t, "original-bot-token-12345", decrypted)
|
||||
},
|
||||
verifyHiddenData: func(t *testing.T, notifier *Notifier) {
|
||||
assert.Equal(t, "", notifier.TelegramNotifier.BotToken)
|
||||
@@ -592,7 +534,13 @@ func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
|
||||
}
|
||||
},
|
||||
verifySensitiveData: func(t *testing.T, notifier *Notifier) {
|
||||
assert.Equal(t, "original-password-secret", notifier.EmailNotifier.SMTPPassword)
|
||||
assert.True(
|
||||
t,
|
||||
isEncrypted(notifier.EmailNotifier.SMTPPassword),
|
||||
"SMTPPassword should be encrypted in DB",
|
||||
)
|
||||
decrypted := decryptField(t, notifier.ID, notifier.EmailNotifier.SMTPPassword)
|
||||
assert.Equal(t, "original-password-secret", decrypted)
|
||||
},
|
||||
verifyHiddenData: func(t *testing.T, notifier *Notifier) {
|
||||
assert.Equal(t, "", notifier.EmailNotifier.SMTPPassword)
|
||||
@@ -625,7 +573,13 @@ func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
|
||||
}
|
||||
},
|
||||
verifySensitiveData: func(t *testing.T, notifier *Notifier) {
|
||||
assert.Equal(t, "xoxb-original-slack-token", notifier.SlackNotifier.BotToken)
|
||||
assert.True(
|
||||
t,
|
||||
isEncrypted(notifier.SlackNotifier.BotToken),
|
||||
"BotToken should be encrypted in DB",
|
||||
)
|
||||
decrypted := decryptField(t, notifier.ID, notifier.SlackNotifier.BotToken)
|
||||
assert.Equal(t, "xoxb-original-slack-token", decrypted)
|
||||
},
|
||||
verifyHiddenData: func(t *testing.T, notifier *Notifier) {
|
||||
assert.Equal(t, "", notifier.SlackNotifier.BotToken)
|
||||
@@ -656,11 +610,17 @@ func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
|
||||
}
|
||||
},
|
||||
verifySensitiveData: func(t *testing.T, notifier *Notifier) {
|
||||
assert.Equal(
|
||||
assert.True(
|
||||
t,
|
||||
"https://discord.com/api/webhooks/123/original-token",
|
||||
isEncrypted(notifier.DiscordNotifier.ChannelWebhookURL),
|
||||
"WebhookURL should be encrypted in DB",
|
||||
)
|
||||
decrypted := decryptField(
|
||||
t,
|
||||
notifier.ID,
|
||||
notifier.DiscordNotifier.ChannelWebhookURL,
|
||||
)
|
||||
assert.Equal(t, "https://discord.com/api/webhooks/123/original-token", decrypted)
|
||||
},
|
||||
verifyHiddenData: func(t *testing.T, notifier *Notifier) {
|
||||
assert.Equal(t, "", notifier.DiscordNotifier.ChannelWebhookURL)
|
||||
@@ -691,10 +651,16 @@ func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
|
||||
}
|
||||
},
|
||||
verifySensitiveData: func(t *testing.T, notifier *Notifier) {
|
||||
assert.True(
|
||||
t,
|
||||
isEncrypted(notifier.TeamsNotifier.WebhookURL),
|
||||
"WebhookURL should be encrypted in DB",
|
||||
)
|
||||
decrypted := decryptField(t, notifier.ID, notifier.TeamsNotifier.WebhookURL)
|
||||
assert.Equal(
|
||||
t,
|
||||
"https://outlook.office.com/webhook/original-token",
|
||||
notifier.TeamsNotifier.WebhookURL,
|
||||
decrypted,
|
||||
)
|
||||
},
|
||||
verifyHiddenData: func(t *testing.T, notifier *Notifier) {
|
||||
@@ -813,3 +779,263 @@ func Test_NotifierSensitiveDataLifecycle_AllTypes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_CreateNotifier_AllSensitiveFieldsEncryptedInDB(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
createNotifier func(workspaceID uuid.UUID) *Notifier
|
||||
verifySensitiveEncryption func(t *testing.T, notifier *Notifier)
|
||||
}{
|
||||
{
|
||||
name: "Telegram Notifier - BotToken encrypted",
|
||||
createNotifier: func(workspaceID uuid.UUID) *Notifier {
|
||||
return &Notifier{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: "Test Telegram",
|
||||
NotifierType: NotifierTypeTelegram,
|
||||
TelegramNotifier: &telegram_notifier.TelegramNotifier{
|
||||
BotToken: "plain-telegram-token-123",
|
||||
TargetChatID: "123456789",
|
||||
},
|
||||
}
|
||||
},
|
||||
verifySensitiveEncryption: func(t *testing.T, notifier *Notifier) {
|
||||
assert.True(
|
||||
t,
|
||||
isEncrypted(notifier.TelegramNotifier.BotToken),
|
||||
"BotToken should be encrypted",
|
||||
)
|
||||
decrypted := decryptField(t, notifier.ID, notifier.TelegramNotifier.BotToken)
|
||||
assert.Equal(t, "plain-telegram-token-123", decrypted)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Email Notifier - SMTPPassword encrypted",
|
||||
createNotifier: func(workspaceID uuid.UUID) *Notifier {
|
||||
return &Notifier{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: "Test Email",
|
||||
NotifierType: NotifierTypeEmail,
|
||||
EmailNotifier: &email_notifier.EmailNotifier{
|
||||
TargetEmail: "test@example.com",
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
SMTPUser: "user@example.com",
|
||||
SMTPPassword: "plain-smtp-password-456",
|
||||
From: "noreply@example.com",
|
||||
},
|
||||
}
|
||||
},
|
||||
verifySensitiveEncryption: func(t *testing.T, notifier *Notifier) {
|
||||
assert.True(
|
||||
t,
|
||||
isEncrypted(notifier.EmailNotifier.SMTPPassword),
|
||||
"SMTPPassword should be encrypted",
|
||||
)
|
||||
decrypted := decryptField(t, notifier.ID, notifier.EmailNotifier.SMTPPassword)
|
||||
assert.Equal(t, "plain-smtp-password-456", decrypted)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Slack Notifier - BotToken encrypted",
|
||||
createNotifier: func(workspaceID uuid.UUID) *Notifier {
|
||||
return &Notifier{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: "Test Slack",
|
||||
NotifierType: NotifierTypeSlack,
|
||||
SlackNotifier: &slack_notifier.SlackNotifier{
|
||||
BotToken: "plain-slack-token-789",
|
||||
TargetChatID: "C0123456789",
|
||||
},
|
||||
}
|
||||
},
|
||||
verifySensitiveEncryption: func(t *testing.T, notifier *Notifier) {
|
||||
assert.True(
|
||||
t,
|
||||
isEncrypted(notifier.SlackNotifier.BotToken),
|
||||
"BotToken should be encrypted",
|
||||
)
|
||||
decrypted := decryptField(t, notifier.ID, notifier.SlackNotifier.BotToken)
|
||||
assert.Equal(t, "plain-slack-token-789", decrypted)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Discord Notifier - WebhookURL encrypted",
|
||||
createNotifier: func(workspaceID uuid.UUID) *Notifier {
|
||||
return &Notifier{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: "Test Discord",
|
||||
NotifierType: NotifierTypeDiscord,
|
||||
DiscordNotifier: &discord_notifier.DiscordNotifier{
|
||||
ChannelWebhookURL: "https://discord.com/api/webhooks/123/abc",
|
||||
},
|
||||
}
|
||||
},
|
||||
verifySensitiveEncryption: func(t *testing.T, notifier *Notifier) {
|
||||
assert.True(
|
||||
t,
|
||||
isEncrypted(notifier.DiscordNotifier.ChannelWebhookURL),
|
||||
"WebhookURL should be encrypted",
|
||||
)
|
||||
decrypted := decryptField(
|
||||
t,
|
||||
notifier.ID,
|
||||
notifier.DiscordNotifier.ChannelWebhookURL,
|
||||
)
|
||||
assert.Equal(t, "https://discord.com/api/webhooks/123/abc", decrypted)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Teams Notifier - WebhookURL encrypted",
|
||||
createNotifier: func(workspaceID uuid.UUID) *Notifier {
|
||||
return &Notifier{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: "Test Teams",
|
||||
NotifierType: NotifierTypeTeams,
|
||||
TeamsNotifier: &teams_notifier.TeamsNotifier{
|
||||
WebhookURL: "https://outlook.office.com/webhook/test123",
|
||||
},
|
||||
}
|
||||
},
|
||||
verifySensitiveEncryption: func(t *testing.T, notifier *Notifier) {
|
||||
assert.True(
|
||||
t,
|
||||
isEncrypted(notifier.TeamsNotifier.WebhookURL),
|
||||
"WebhookURL should be encrypted",
|
||||
)
|
||||
decrypted := decryptField(t, notifier.ID, notifier.TeamsNotifier.WebhookURL)
|
||||
assert.Equal(t, "https://outlook.office.com/webhook/test123", decrypted)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Webhook Notifier - WebhookURL encrypted",
|
||||
createNotifier: func(workspaceID uuid.UUID) *Notifier {
|
||||
return &Notifier{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: "Test Webhook",
|
||||
NotifierType: NotifierTypeWebhook,
|
||||
WebhookNotifier: &webhook_notifier.WebhookNotifier{
|
||||
WebhookURL: "https://webhook.example.com/test456",
|
||||
WebhookMethod: webhook_notifier.WebhookMethodPOST,
|
||||
},
|
||||
}
|
||||
},
|
||||
verifySensitiveEncryption: func(t *testing.T, notifier *Notifier) {
|
||||
assert.True(
|
||||
t,
|
||||
isEncrypted(notifier.WebhookNotifier.WebhookURL),
|
||||
"WebhookURL should be encrypted",
|
||||
)
|
||||
decrypted := decryptField(t, notifier.ID, notifier.WebhookNotifier.WebhookURL)
|
||||
assert.Equal(t, "https://webhook.example.com/test456", decrypted)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Create notifier via API (plaintext credentials)
|
||||
var createdNotifier Notifier
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/notifiers",
|
||||
"Bearer "+owner.Token,
|
||||
tc.createNotifier(workspace.ID),
|
||||
http.StatusOK,
|
||||
&createdNotifier,
|
||||
)
|
||||
|
||||
// Read from DB directly (bypass service layer)
|
||||
repository := &NotifierRepository{}
|
||||
notifierFromDB, err := repository.FindByID(createdNotifier.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify encryption
|
||||
tc.verifySensitiveEncryption(t, notifierFromDB)
|
||||
|
||||
// Cleanup
|
||||
deleteNotifier(t, router, createdNotifier.ID, workspace.ID, owner.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
v1 := router.Group("/api/v1")
|
||||
protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService()))
|
||||
|
||||
if routerGroup, ok := protected.(*gin.RouterGroup); ok {
|
||||
GetNotifierController().RegisterRoutes(routerGroup)
|
||||
workspaces_controllers.GetWorkspaceController().RegisterRoutes(routerGroup)
|
||||
workspaces_controllers.GetMembershipController().RegisterRoutes(routerGroup)
|
||||
}
|
||||
|
||||
audit_logs.SetupDependencies()
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func createNewNotifier(workspaceID uuid.UUID) *Notifier {
|
||||
return &Notifier{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: "Test Notifier " + uuid.New().String(),
|
||||
NotifierType: NotifierTypeWebhook,
|
||||
WebhookNotifier: &webhook_notifier.WebhookNotifier{
|
||||
WebhookURL: "https://webhook.site/test-" + uuid.New().String(),
|
||||
WebhookMethod: webhook_notifier.WebhookMethodPOST,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTelegramNotifier(workspaceID uuid.UUID) *Notifier {
|
||||
env := config.GetEnv()
|
||||
return &Notifier{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: "Test Telegram Notifier " + uuid.New().String(),
|
||||
NotifierType: NotifierTypeTelegram,
|
||||
TelegramNotifier: &telegram_notifier.TelegramNotifier{
|
||||
BotToken: env.TestTelegramBotToken,
|
||||
TargetChatID: env.TestTelegramChatID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func verifyNotifierData(t *testing.T, expected *Notifier, actual *Notifier) {
|
||||
assert.Equal(t, expected.Name, actual.Name)
|
||||
assert.Equal(t, expected.NotifierType, actual.NotifierType)
|
||||
assert.Equal(t, expected.WorkspaceID, actual.WorkspaceID)
|
||||
}
|
||||
|
||||
func deleteNotifier(
|
||||
t *testing.T,
|
||||
router *gin.Engine,
|
||||
notifierID, workspaceID uuid.UUID,
|
||||
token string,
|
||||
) {
|
||||
test_utils.MakeDeleteRequest(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/notifiers/%s", notifierID.String()),
|
||||
"Bearer "+token,
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
|
||||
func isEncrypted(value string) bool {
|
||||
return len(value) > 4 && value[:4] == "enc:"
|
||||
}
|
||||
|
||||
func decryptField(t *testing.T, notifierID uuid.UUID, encryptedValue string) string {
|
||||
encryptor := GetNotifierService().fieldEncryptor
|
||||
decrypted, err := encryptor.Decrypt(notifierID, encryptedValue)
|
||||
assert.NoError(t, err)
|
||||
return decrypted
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package notifiers
|
||||
import (
|
||||
audit_logs "postgresus-backend/internal/features/audit_logs"
|
||||
workspaces_services "postgresus-backend/internal/features/workspaces/services"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
@@ -12,6 +13,7 @@ var notifierService = &NotifierService{
|
||||
logger.GetLogger(),
|
||||
workspaces_services.GetWorkspaceService(),
|
||||
audit_logs.GetAuditLogService(),
|
||||
encryption.GetFieldEncryptor(),
|
||||
}
|
||||
var notifierController = &NotifierController{
|
||||
notifierService,
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
package notifiers
|
||||
|
||||
import "log/slog"
|
||||
import (
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
)
|
||||
|
||||
type NotificationSender interface {
|
||||
Send(logger *slog.Logger, heading string, message string) error
|
||||
Send(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
heading string,
|
||||
message string,
|
||||
) error
|
||||
|
||||
Validate() error
|
||||
Validate(encryptor encryption.FieldEncryptor) error
|
||||
|
||||
HideSensitiveData()
|
||||
|
||||
EncryptSensitiveData(encryptor encryption.FieldEncryptor) error
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
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"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -33,16 +34,21 @@ func (n *Notifier) TableName() string {
|
||||
return "notifiers"
|
||||
}
|
||||
|
||||
func (n *Notifier) Validate() error {
|
||||
func (n *Notifier) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if n.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
|
||||
return n.getSpecificNotifier().Validate()
|
||||
return n.getSpecificNotifier().Validate(encryptor)
|
||||
}
|
||||
|
||||
func (n *Notifier) Send(logger *slog.Logger, heading string, message string) error {
|
||||
err := n.getSpecificNotifier().Send(logger, heading, message)
|
||||
func (n *Notifier) Send(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
heading string,
|
||||
message string,
|
||||
) error {
|
||||
err := n.getSpecificNotifier().Send(encryptor, logger, heading, message)
|
||||
|
||||
if err != nil {
|
||||
lastSendError := err.Error()
|
||||
@@ -58,6 +64,10 @@ func (n *Notifier) HideSensitiveData() {
|
||||
n.getSpecificNotifier().HideSensitiveData()
|
||||
}
|
||||
|
||||
func (n *Notifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
return n.getSpecificNotifier().EncryptSensitiveData(encryptor)
|
||||
}
|
||||
|
||||
func (n *Notifier) Update(incoming *Notifier) {
|
||||
n.Name = incoming.Name
|
||||
n.NotifierType = incoming.NotifierType
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -21,7 +22,7 @@ func (d *DiscordNotifier) TableName() string {
|
||||
return "discord_notifiers"
|
||||
}
|
||||
|
||||
func (d *DiscordNotifier) Validate() error {
|
||||
func (d *DiscordNotifier) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if d.ChannelWebhookURL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
}
|
||||
@@ -29,7 +30,17 @@ func (d *DiscordNotifier) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DiscordNotifier) Send(logger *slog.Logger, heading string, message string) error {
|
||||
func (d *DiscordNotifier) Send(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
heading string,
|
||||
message string,
|
||||
) error {
|
||||
webhookURL, err := encryptor.Decrypt(d.NotifierID, d.ChannelWebhookURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt webhook URL: %w", err)
|
||||
}
|
||||
|
||||
fullMessage := heading
|
||||
if message != "" {
|
||||
fullMessage = fmt.Sprintf("%s\n\n%s", heading, message)
|
||||
@@ -44,7 +55,7 @@ func (d *DiscordNotifier) Send(logger *slog.Logger, heading string, message stri
|
||||
return fmt.Errorf("failed to marshal Discord payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", d.ChannelWebhookURL, bytes.NewReader(jsonPayload))
|
||||
req, err := http.NewRequest("POST", webhookURL, bytes.NewReader(jsonPayload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -81,3 +92,14 @@ func (d *DiscordNotifier) Update(incoming *DiscordNotifier) {
|
||||
d.ChannelWebhookURL = incoming.ChannelWebhookURL
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
if d.ChannelWebhookURL != "" {
|
||||
encrypted, err := encryptor.Encrypt(d.NotifierID, d.ChannelWebhookURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt webhook URL: %w", err)
|
||||
}
|
||||
d.ChannelWebhookURL = encrypted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -34,7 +35,7 @@ func (e *EmailNotifier) TableName() string {
|
||||
return "email_notifiers"
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) Validate() error {
|
||||
func (e *EmailNotifier) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if e.TargetEmail == "" {
|
||||
return errors.New("target email is required")
|
||||
}
|
||||
@@ -55,7 +56,22 @@ func (e *EmailNotifier) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) Send(logger *slog.Logger, heading string, message string) error {
|
||||
func (e *EmailNotifier) Send(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
heading string,
|
||||
message string,
|
||||
) error {
|
||||
// Decrypt SMTP password if provided
|
||||
var smtpPassword string
|
||||
if e.SMTPPassword != "" {
|
||||
decrypted, err := encryptor.Decrypt(e.NotifierID, e.SMTPPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt SMTP password: %w", err)
|
||||
}
|
||||
smtpPassword = decrypted
|
||||
}
|
||||
|
||||
// Compose email
|
||||
from := e.From
|
||||
if from == "" {
|
||||
@@ -85,7 +101,7 @@ func (e *EmailNotifier) Send(logger *slog.Logger, heading string, message string
|
||||
timeout := DefaultTimeout
|
||||
|
||||
// Determine if authentication is required
|
||||
isAuthRequired := e.SMTPUser != "" && e.SMTPPassword != ""
|
||||
isAuthRequired := e.SMTPUser != "" && smtpPassword != ""
|
||||
|
||||
// Handle different port scenarios
|
||||
if e.SMTPPort == ImplicitTLSPort {
|
||||
@@ -116,7 +132,7 @@ func (e *EmailNotifier) Send(logger *slog.Logger, heading string, message string
|
||||
|
||||
// Set up authentication only if credentials are provided
|
||||
if isAuthRequired {
|
||||
auth := smtp.PlainAuth("", e.SMTPUser, e.SMTPPassword, e.SMTPHost)
|
||||
auth := smtp.PlainAuth("", e.SMTPUser, smtpPassword, e.SMTPHost)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
@@ -179,7 +195,7 @@ func (e *EmailNotifier) Send(logger *slog.Logger, heading string, message string
|
||||
|
||||
// Authenticate only if credentials are provided
|
||||
if isAuthRequired {
|
||||
auth := smtp.PlainAuth("", e.SMTPUser, e.SMTPPassword, e.SMTPHost)
|
||||
auth := smtp.PlainAuth("", e.SMTPUser, smtpPassword, e.SMTPHost)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
@@ -229,3 +245,14 @@ func (e *EmailNotifier) Update(incoming *EmailNotifier) {
|
||||
e.SMTPPassword = incoming.SMTPPassword
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
if e.SMTPPassword != "" {
|
||||
encrypted, err := encryptor.Encrypt(e.NotifierID, e.SMTPPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt SMTP password: %w", err)
|
||||
}
|
||||
e.SMTPPassword = encrypted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -23,7 +24,7 @@ type SlackNotifier struct {
|
||||
|
||||
func (s *SlackNotifier) TableName() string { return "slack_notifiers" }
|
||||
|
||||
func (s *SlackNotifier) Validate() error {
|
||||
func (s *SlackNotifier) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if s.BotToken == "" {
|
||||
return errors.New("bot token is required")
|
||||
}
|
||||
@@ -43,7 +44,16 @@ func (s *SlackNotifier) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SlackNotifier) Send(logger *slog.Logger, heading, message string) error {
|
||||
func (s *SlackNotifier) Send(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
heading, message string,
|
||||
) error {
|
||||
botToken, err := encryptor.Decrypt(s.NotifierID, s.BotToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt bot token: %w", err)
|
||||
}
|
||||
|
||||
full := fmt.Sprintf("*%s*", heading)
|
||||
|
||||
if message != "" {
|
||||
@@ -60,6 +70,7 @@ func (s *SlackNotifier) Send(logger *slog.Logger, heading, message string) error
|
||||
maxAttempts = 5
|
||||
defaultBackoff = 2 * time.Second // when Retry-After header missing
|
||||
backoffMultiplier = 1.5 // use exponential growth
|
||||
requestTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -67,6 +78,10 @@ func (s *SlackNotifier) Send(logger *slog.Logger, heading, message string) error
|
||||
attempts = 0
|
||||
)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
}
|
||||
|
||||
for {
|
||||
attempts++
|
||||
|
||||
@@ -80,9 +95,9 @@ func (s *SlackNotifier) Send(logger *slog.Logger, heading, message string) error
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set("Authorization", "Bearer "+s.BotToken)
|
||||
req.Header.Set("Authorization", "Bearer "+botToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send slack message: %w", err)
|
||||
}
|
||||
@@ -144,3 +159,14 @@ func (s *SlackNotifier) Update(incoming *SlackNotifier) {
|
||||
s.BotToken = incoming.BotToken
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SlackNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
if s.BotToken != "" {
|
||||
encrypted, err := encryptor.Encrypt(s.NotifierID, s.BotToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt bot token: %w", err)
|
||||
}
|
||||
s.BotToken = encrypted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -21,11 +22,17 @@ func (TeamsNotifier) TableName() string {
|
||||
return "teams_notifiers"
|
||||
}
|
||||
|
||||
func (n *TeamsNotifier) Validate() error {
|
||||
func (n *TeamsNotifier) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if n.WebhookURL == "" {
|
||||
return errors.New("webhook_url is required")
|
||||
}
|
||||
u, err := url.Parse(n.WebhookURL)
|
||||
|
||||
webhookURL, err := encryptor.Decrypt(n.NotifierID, n.WebhookURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt webhook URL: %w", err)
|
||||
}
|
||||
|
||||
u, err := url.Parse(webhookURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return errors.New("invalid webhook_url")
|
||||
}
|
||||
@@ -33,8 +40,8 @@ func (n *TeamsNotifier) Validate() error {
|
||||
}
|
||||
|
||||
type cardAttachment struct {
|
||||
ContentType string `json:"contentType"`
|
||||
Content interface{} `json:"content"`
|
||||
ContentType string `json:"contentType"`
|
||||
Content any `json:"content"`
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
@@ -43,11 +50,20 @@ type payload struct {
|
||||
Attachments []cardAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
func (n *TeamsNotifier) Send(logger *slog.Logger, heading, message string) error {
|
||||
if err := n.Validate(); err != nil {
|
||||
func (n *TeamsNotifier) Send(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
heading, message string,
|
||||
) error {
|
||||
if err := n.Validate(encryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webhookURL, err := encryptor.Decrypt(n.NotifierID, n.WebhookURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt webhook URL: %w", err)
|
||||
}
|
||||
|
||||
card := map[string]any{
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.4",
|
||||
@@ -71,7 +87,7 @@ func (n *TeamsNotifier) Send(logger *slog.Logger, heading, message string) error
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(p)
|
||||
req, err := http.NewRequest(http.MethodPost, n.WebhookURL, bytes.NewReader(body))
|
||||
req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -104,3 +120,14 @@ func (n *TeamsNotifier) Update(incoming *TeamsNotifier) {
|
||||
n.WebhookURL = incoming.WebhookURL
|
||||
}
|
||||
}
|
||||
|
||||
func (n *TeamsNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
if n.WebhookURL != "" {
|
||||
encrypted, err := encryptor.Encrypt(n.NotifierID, n.WebhookURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt webhook URL: %w", err)
|
||||
}
|
||||
n.WebhookURL = encrypted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -24,7 +25,7 @@ func (t *TelegramNotifier) TableName() string {
|
||||
return "telegram_notifiers"
|
||||
}
|
||||
|
||||
func (t *TelegramNotifier) Validate() error {
|
||||
func (t *TelegramNotifier) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if t.BotToken == "" {
|
||||
return errors.New("bot token is required")
|
||||
}
|
||||
@@ -36,13 +37,23 @@ func (t *TelegramNotifier) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TelegramNotifier) Send(logger *slog.Logger, heading string, message string) error {
|
||||
func (t *TelegramNotifier) Send(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
heading string,
|
||||
message string,
|
||||
) error {
|
||||
botToken, err := encryptor.Decrypt(t.NotifierID, t.BotToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt bot token: %w", err)
|
||||
}
|
||||
|
||||
fullMessage := heading
|
||||
if message != "" {
|
||||
fullMessage = fmt.Sprintf("%s\n\n%s", heading, message)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.BotToken)
|
||||
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("chat_id", t.TargetChatID)
|
||||
@@ -93,3 +104,14 @@ func (t *TelegramNotifier) Update(incoming *TelegramNotifier) {
|
||||
t.BotToken = incoming.BotToken
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TelegramNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
if t.BotToken != "" {
|
||||
encrypted, err := encryptor.Encrypt(t.NotifierID, t.BotToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt bot token: %w", err)
|
||||
}
|
||||
t.BotToken = encrypted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -23,7 +24,7 @@ func (t *WebhookNotifier) TableName() string {
|
||||
return "webhook_notifiers"
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) Validate() error {
|
||||
func (t *WebhookNotifier) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if t.WebhookURL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
}
|
||||
@@ -35,11 +36,21 @@ func (t *WebhookNotifier) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) Send(logger *slog.Logger, heading string, message string) error {
|
||||
func (t *WebhookNotifier) Send(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
heading string,
|
||||
message string,
|
||||
) error {
|
||||
webhookURL, err := encryptor.Decrypt(t.NotifierID, t.WebhookURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt webhook URL: %w", err)
|
||||
}
|
||||
|
||||
switch t.WebhookMethod {
|
||||
case WebhookMethodGET:
|
||||
reqURL := fmt.Sprintf("%s?heading=%s&message=%s",
|
||||
t.WebhookURL,
|
||||
webhookURL,
|
||||
url.QueryEscape(heading),
|
||||
url.QueryEscape(message),
|
||||
)
|
||||
@@ -76,7 +87,7 @@ func (t *WebhookNotifier) Send(logger *slog.Logger, heading string, message stri
|
||||
return fmt.Errorf("failed to marshal webhook payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(t.WebhookURL, "application/json", bytes.NewReader(body))
|
||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send POST webhook: %w", err)
|
||||
}
|
||||
@@ -110,3 +121,14 @@ func (t *WebhookNotifier) Update(incoming *WebhookNotifier) {
|
||||
t.WebhookURL = incoming.WebhookURL
|
||||
t.WebhookMethod = incoming.WebhookMethod
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
if t.WebhookURL != "" {
|
||||
encrypted, err := encryptor.Encrypt(t.NotifierID, t.WebhookURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt webhook URL: %w", err)
|
||||
}
|
||||
t.WebhookURL = encrypted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
audit_logs "postgresus-backend/internal/features/audit_logs"
|
||||
users_models "postgresus-backend/internal/features/users/models"
|
||||
workspaces_services "postgresus-backend/internal/features/workspaces/services"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -17,6 +18,7 @@ type NotifierService struct {
|
||||
logger *slog.Logger
|
||||
workspaceService *workspaces_services.WorkspaceService
|
||||
auditLogService *audit_logs.AuditLogService
|
||||
fieldEncryptor encryption.FieldEncryptor
|
||||
}
|
||||
|
||||
func (s *NotifierService) SaveNotifier(
|
||||
@@ -46,7 +48,11 @@ func (s *NotifierService) SaveNotifier(
|
||||
|
||||
existingNotifier.Update(notifier)
|
||||
|
||||
if err := existingNotifier.Validate(); err != nil {
|
||||
if err := existingNotifier.EncryptSensitiveData(s.fieldEncryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := existingNotifier.Validate(s.fieldEncryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -63,7 +69,11 @@ func (s *NotifierService) SaveNotifier(
|
||||
} else {
|
||||
notifier.WorkspaceID = workspaceID
|
||||
|
||||
if err := notifier.Validate(); err != nil {
|
||||
if err := notifier.EncryptSensitiveData(s.fieldEncryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := notifier.Validate(s.fieldEncryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -175,7 +185,7 @@ func (s *NotifierService) SendTestNotification(
|
||||
return errors.New("insufficient permissions to test notifier in this workspace")
|
||||
}
|
||||
|
||||
err = notifier.Send(s.logger, "Test message", "This is a test message")
|
||||
err = notifier.Send(s.fieldEncryptor, s.logger, "Test message", "This is a test message")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -205,16 +215,24 @@ func (s *NotifierService) SendTestNotificationToNotifier(
|
||||
|
||||
existingNotifier.Update(notifier)
|
||||
|
||||
if err := existingNotifier.Validate(); err != nil {
|
||||
if err := existingNotifier.EncryptSensitiveData(s.fieldEncryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := existingNotifier.Validate(s.fieldEncryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usingNotifier = existingNotifier
|
||||
} else {
|
||||
if err := notifier.EncryptSensitiveData(s.fieldEncryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usingNotifier = notifier
|
||||
}
|
||||
|
||||
return usingNotifier.Send(s.logger, "Test message", "This is a test message")
|
||||
return usingNotifier.Send(s.fieldEncryptor, s.logger, "Test message", "This is a test message")
|
||||
}
|
||||
|
||||
func (s *NotifierService) SendNotification(
|
||||
@@ -233,7 +251,7 @@ func (s *NotifierService) SendNotification(
|
||||
return
|
||||
}
|
||||
|
||||
err = notifiedFromDb.Send(s.logger, title, message)
|
||||
err = notifiedFromDb.Send(s.fieldEncryptor, s.logger, title, message)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
notifiedFromDb.LastSendError = &errMsg
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers"
|
||||
workspaces_models "postgresus-backend/internal/features/workspaces/models"
|
||||
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
|
||||
util_encryption "postgresus-backend/internal/util/encryption"
|
||||
test_utils "postgresus-backend/internal/util/testing"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
)
|
||||
@@ -309,6 +310,7 @@ func createTestBackup(
|
||||
database *databases.Database,
|
||||
owner *users_dto.SignInResponseDTO,
|
||||
) *backups.Backup {
|
||||
fieldEncryptor := util_encryption.GetFieldEncryptor()
|
||||
userService := users_services.GetUserService()
|
||||
user, err := userService.GetUserFromToken(owner.Token)
|
||||
if err != nil {
|
||||
@@ -338,7 +340,7 @@ func createTestBackup(
|
||||
dummyContent := []byte("dummy backup content for testing")
|
||||
reader := strings.NewReader(string(dummyContent))
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := storages[0].SaveFile(logger, backup.ID, reader); err != nil {
|
||||
if err := storages[0].SaveFile(fieldEncryptor, logger, backup.ID, reader); err != nil {
|
||||
panic(fmt.Sprintf("Failed to create test backup file: %v", err))
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package models
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/backups/backups"
|
||||
"postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/restores/enums"
|
||||
"time"
|
||||
|
||||
@@ -16,8 +15,6 @@ type Restore struct {
|
||||
BackupID uuid.UUID `json:"backupId" gorm:"column:backup_id;type:uuid;not null"`
|
||||
Backup *backups.Backup
|
||||
|
||||
Postgresql *postgresql.PostgresqlDatabase `json:"postgresql,omitempty" gorm:"foreignKey:RestoreID"`
|
||||
|
||||
FailMessage *string `json:"failMessage" gorm:"column:fail_message"`
|
||||
|
||||
RestoreDurationMs int64 `json:"restoreDurationMs" gorm:"column:restore_duration_ms;default:0"`
|
||||
|
||||
@@ -32,7 +32,6 @@ func (r *RestoreRepository) FindByBackupID(backupID uuid.UUID) ([]*models.Restor
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Preload("Backup").
|
||||
Preload("Postgresql").
|
||||
Where("backup_id = ?", backupID).
|
||||
Order("created_at DESC").
|
||||
Find(&restores).Error; err != nil {
|
||||
@@ -48,7 +47,6 @@ func (r *RestoreRepository) FindByID(id uuid.UUID) (*models.Restore, error) {
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Preload("Backup").
|
||||
Preload("Postgresql").
|
||||
Where("id = ?", id).
|
||||
First(&restore).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -63,7 +61,6 @@ func (r *RestoreRepository) FindByStatus(status enums.RestoreStatus) ([]*models.
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Preload("Backup").
|
||||
Preload("Postgresql").
|
||||
Where("status = ?", status).
|
||||
Order("created_at DESC").
|
||||
Find(&restores).Error; err != nil {
|
||||
|
||||
@@ -191,15 +191,9 @@ func (s *RestoreService) RestoreBackup(
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the RestoreID on the PostgreSQL database and save it
|
||||
if requestDTO.PostgresqlDatabase != nil {
|
||||
requestDTO.PostgresqlDatabase.RestoreID = &restore.ID
|
||||
restore.Postgresql = requestDTO.PostgresqlDatabase
|
||||
|
||||
// Save the restore again to include the postgresql database
|
||||
if err := s.restoreRepository.Save(&restore); err != nil {
|
||||
return err
|
||||
}
|
||||
// Save the restore again to include the postgresql database
|
||||
if err := s.restoreRepository.Save(&restore); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storage, err := s.storageService.GetStorageByID(backup.StorageID)
|
||||
@@ -216,10 +210,15 @@ func (s *RestoreService) RestoreBackup(
|
||||
|
||||
start := time.Now().UTC()
|
||||
|
||||
restoringToDB := &databases.Database{
|
||||
Postgresql: requestDTO.PostgresqlDatabase,
|
||||
}
|
||||
|
||||
err = s.restoreBackupUsecase.Execute(
|
||||
backupConfig,
|
||||
restore,
|
||||
database,
|
||||
restoringToDB,
|
||||
backup,
|
||||
storage,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package usecases_postgresql
|
||||
|
||||
import (
|
||||
users_repositories "postgresus-backend/internal/features/users/repositories"
|
||||
"postgresus-backend/internal/features/encryption/secrets"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
var restorePostgresqlBackupUsecase = &RestorePostgresqlBackupUsecase{
|
||||
logger.GetLogger(),
|
||||
users_repositories.GetSecretKeyRepository(),
|
||||
secrets.GetSecretKeyService(),
|
||||
}
|
||||
|
||||
func GetRestorePostgresqlBackupUsecase() *RestorePostgresqlBackupUsecase {
|
||||
|
||||
@@ -20,9 +20,10 @@ import (
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
encryption_secrets "postgresus-backend/internal/features/encryption/secrets"
|
||||
"postgresus-backend/internal/features/restores/models"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
users_repositories "postgresus-backend/internal/features/users/repositories"
|
||||
util_encryption "postgresus-backend/internal/util/encryption"
|
||||
files_utils "postgresus-backend/internal/util/files"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
|
||||
@@ -30,18 +31,19 @@ import (
|
||||
)
|
||||
|
||||
type RestorePostgresqlBackupUsecase struct {
|
||||
logger *slog.Logger
|
||||
secretKeyRepo *users_repositories.SecretKeyRepository
|
||||
logger *slog.Logger
|
||||
secretKeyService *encryption_secrets.SecretKeyService
|
||||
}
|
||||
|
||||
func (uc *RestorePostgresqlBackupUsecase) Execute(
|
||||
database *databases.Database,
|
||||
originalDB *databases.Database,
|
||||
restoringToDB *databases.Database,
|
||||
backupConfig *backups_config.BackupConfig,
|
||||
restore models.Restore,
|
||||
backup *backups.Backup,
|
||||
storage *storages.Storage,
|
||||
) error {
|
||||
if database.Type != databases.DatabaseTypePostgres {
|
||||
if originalDB.Type != databases.DatabaseTypePostgres {
|
||||
return errors.New("database type not supported")
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ func (uc *RestorePostgresqlBackupUsecase) Execute(
|
||||
backup.ID,
|
||||
)
|
||||
|
||||
pg := restore.Postgresql
|
||||
pg := restoringToDB.Postgresql
|
||||
if pg == nil {
|
||||
return fmt.Errorf("postgresql configuration is required for restore")
|
||||
}
|
||||
@@ -77,11 +79,12 @@ func (uc *RestorePostgresqlBackupUsecase) Execute(
|
||||
"--verbose", // Add verbose output to help with debugging
|
||||
"--clean", // Clean (drop) database objects before recreating them
|
||||
"--if-exists", // Use IF EXISTS when dropping objects
|
||||
"--no-owner",
|
||||
"--no-owner", // Skip restoring ownership
|
||||
"--no-acl", // Skip restoring access privileges (GRANT/REVOKE commands)
|
||||
}
|
||||
|
||||
return uc.restoreFromStorage(
|
||||
database,
|
||||
originalDB,
|
||||
tools.GetPostgresqlExecutable(
|
||||
pg.Version,
|
||||
"pg_restore",
|
||||
@@ -209,7 +212,8 @@ func (uc *RestorePostgresqlBackupUsecase) downloadBackupToTempFile(
|
||||
"encrypted",
|
||||
backup.Encryption == backups_config.BackupEncryptionEncrypted,
|
||||
)
|
||||
rawReader, err := storage.GetFile(backup.ID)
|
||||
fieldEncryptor := util_encryption.GetFieldEncryptor()
|
||||
rawReader, err := storage.GetFile(fieldEncryptor, backup.ID)
|
||||
if err != nil {
|
||||
cleanupFunc()
|
||||
return "", nil, fmt.Errorf("failed to get backup file from storage: %w", err)
|
||||
@@ -230,7 +234,7 @@ func (uc *RestorePostgresqlBackupUsecase) downloadBackupToTempFile(
|
||||
}
|
||||
|
||||
// Get master key
|
||||
masterKey, err := uc.secretKeyRepo.GetSecretKey()
|
||||
masterKey, err := uc.secretKeyService.GetSecretKey()
|
||||
if err != nil {
|
||||
cleanupFunc()
|
||||
return "", nil, fmt.Errorf("failed to get master key for decryption: %w", err)
|
||||
@@ -560,11 +564,15 @@ func (uc *RestorePostgresqlBackupUsecase) createTempPgpassFile(
|
||||
return "", nil
|
||||
}
|
||||
|
||||
escapedHost := tools.EscapePgpassField(pgConfig.Host)
|
||||
escapedUsername := tools.EscapePgpassField(pgConfig.Username)
|
||||
escapedPassword := tools.EscapePgpassField(password)
|
||||
|
||||
pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s",
|
||||
pgConfig.Host,
|
||||
escapedHost,
|
||||
pgConfig.Port,
|
||||
pgConfig.Username,
|
||||
password,
|
||||
escapedUsername,
|
||||
escapedPassword,
|
||||
)
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "pgpass")
|
||||
|
||||
@@ -17,13 +17,15 @@ type RestoreBackupUsecase struct {
|
||||
func (uc *RestoreBackupUsecase) Execute(
|
||||
backupConfig *backups_config.BackupConfig,
|
||||
restore models.Restore,
|
||||
database *databases.Database,
|
||||
originalDB *databases.Database,
|
||||
restoringToDB *databases.Database,
|
||||
backup *backups.Backup,
|
||||
storage *storages.Storage,
|
||||
) error {
|
||||
if database.Type == databases.DatabaseTypePostgres {
|
||||
if originalDB.Type == databases.DatabaseTypePostgres {
|
||||
return uc.restorePostgresqlBackupUsecase.Execute(
|
||||
database,
|
||||
originalDB,
|
||||
restoringToDB,
|
||||
backupConfig,
|
||||
restore,
|
||||
backup,
|
||||
|
||||
@@ -3,10 +3,14 @@ package storages
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
audit_logs "postgresus-backend/internal/features/audit_logs"
|
||||
azure_blob_storage "postgresus-backend/internal/features/storages/models/azure_blob"
|
||||
google_drive_storage "postgresus-backend/internal/features/storages/models/google_drive"
|
||||
local_storage "postgresus-backend/internal/features/storages/models/local"
|
||||
nas_storage "postgresus-backend/internal/features/storages/models/nas"
|
||||
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"
|
||||
@@ -14,6 +18,7 @@ import (
|
||||
users_testing "postgresus-backend/internal/features/users/testing"
|
||||
workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers"
|
||||
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
test_utils "postgresus-backend/internal/util/testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -438,6 +443,386 @@ func Test_CrossWorkspaceSecurity_CannotAccessStorageFromAnotherWorkspace(t *test
|
||||
workspaces_testing.RemoveTestWorkspace(workspace2, router)
|
||||
}
|
||||
|
||||
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.True(t, strings.HasPrefix(storage.S3Storage.S3AccessKey, "enc:"),
|
||||
"S3AccessKey should be encrypted with 'enc:' prefix")
|
||||
assert.True(t, strings.HasPrefix(storage.S3Storage.S3SecretKey, "enc:"),
|
||||
"S3SecretKey should be encrypted with 'enc:' prefix")
|
||||
|
||||
encryptor := encryption.GetFieldEncryptor()
|
||||
accessKey, err := encryptor.Decrypt(storage.ID, storage.S3Storage.S3AccessKey)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "original-access-key", accessKey)
|
||||
|
||||
secretKey, err := encryptor.Decrypt(storage.ID, storage.S3Storage.S3SecretKey)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "original-secret-key", secretKey)
|
||||
},
|
||||
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) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NAS Storage",
|
||||
storageType: StorageTypeNAS,
|
||||
createStorage: func(workspaceID uuid.UUID) *Storage {
|
||||
return &Storage{
|
||||
WorkspaceID: workspaceID,
|
||||
Type: StorageTypeNAS,
|
||||
Name: "Test NAS Storage",
|
||||
NASStorage: &nas_storage.NASStorage{
|
||||
Host: "nas.example.com",
|
||||
Port: 445,
|
||||
Share: "backups",
|
||||
Username: "testuser",
|
||||
Password: "original-password",
|
||||
UseSSL: false,
|
||||
Domain: "WORKGROUP",
|
||||
Path: "/test",
|
||||
},
|
||||
}
|
||||
},
|
||||
updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage {
|
||||
return &Storage{
|
||||
ID: storageID,
|
||||
WorkspaceID: workspaceID,
|
||||
Type: StorageTypeNAS,
|
||||
Name: "Updated NAS Storage",
|
||||
NASStorage: &nas_storage.NASStorage{
|
||||
Host: "nas2.example.com",
|
||||
Port: 445,
|
||||
Share: "backups2",
|
||||
Username: "testuser2",
|
||||
Password: "",
|
||||
UseSSL: true,
|
||||
Domain: "WORKGROUP2",
|
||||
Path: "/test2",
|
||||
},
|
||||
}
|
||||
},
|
||||
verifySensitiveData: func(t *testing.T, storage *Storage) {
|
||||
assert.True(t, strings.HasPrefix(storage.NASStorage.Password, "enc:"),
|
||||
"Password should be encrypted with 'enc:' prefix")
|
||||
|
||||
encryptor := encryption.GetFieldEncryptor()
|
||||
password, err := encryptor.Decrypt(storage.ID, storage.NASStorage.Password)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "original-password", password)
|
||||
},
|
||||
verifyHiddenData: func(t *testing.T, storage *Storage) {
|
||||
assert.Equal(t, "", storage.NASStorage.Password)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Azure Blob Storage (Connection String)",
|
||||
storageType: StorageTypeAzureBlob,
|
||||
createStorage: func(workspaceID uuid.UUID) *Storage {
|
||||
return &Storage{
|
||||
WorkspaceID: workspaceID,
|
||||
Type: StorageTypeAzureBlob,
|
||||
Name: "Test Azure Blob Storage",
|
||||
AzureBlobStorage: &azure_blob_storage.AzureBlobStorage{
|
||||
AuthMethod: azure_blob_storage.AuthMethodConnectionString,
|
||||
ConnectionString: "original-connection-string",
|
||||
ContainerName: "test-container",
|
||||
Endpoint: "",
|
||||
Prefix: "backups/",
|
||||
},
|
||||
}
|
||||
},
|
||||
updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage {
|
||||
return &Storage{
|
||||
ID: storageID,
|
||||
WorkspaceID: workspaceID,
|
||||
Type: StorageTypeAzureBlob,
|
||||
Name: "Updated Azure Blob Storage",
|
||||
AzureBlobStorage: &azure_blob_storage.AzureBlobStorage{
|
||||
AuthMethod: azure_blob_storage.AuthMethodConnectionString,
|
||||
ConnectionString: "",
|
||||
ContainerName: "updated-container",
|
||||
Endpoint: "https://custom.blob.core.windows.net",
|
||||
Prefix: "backups2/",
|
||||
},
|
||||
}
|
||||
},
|
||||
verifySensitiveData: func(t *testing.T, storage *Storage) {
|
||||
assert.True(t, strings.HasPrefix(storage.AzureBlobStorage.ConnectionString, "enc:"),
|
||||
"ConnectionString should be encrypted with 'enc:' prefix")
|
||||
|
||||
encryptor := encryption.GetFieldEncryptor()
|
||||
connectionString, err := encryptor.Decrypt(
|
||||
storage.ID,
|
||||
storage.AzureBlobStorage.ConnectionString,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "original-connection-string", connectionString)
|
||||
},
|
||||
verifyHiddenData: func(t *testing.T, storage *Storage) {
|
||||
assert.Equal(t, "", storage.AzureBlobStorage.ConnectionString)
|
||||
assert.Equal(t, "", storage.AzureBlobStorage.AccountKey)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Azure Blob Storage (Account Key)",
|
||||
storageType: StorageTypeAzureBlob,
|
||||
createStorage: func(workspaceID uuid.UUID) *Storage {
|
||||
return &Storage{
|
||||
WorkspaceID: workspaceID,
|
||||
Type: StorageTypeAzureBlob,
|
||||
Name: "Test Azure Blob with Account Key",
|
||||
AzureBlobStorage: &azure_blob_storage.AzureBlobStorage{
|
||||
AuthMethod: azure_blob_storage.AuthMethodAccountKey,
|
||||
AccountName: "testaccount",
|
||||
AccountKey: "original-account-key",
|
||||
ContainerName: "test-container",
|
||||
Endpoint: "",
|
||||
Prefix: "backups/",
|
||||
},
|
||||
}
|
||||
},
|
||||
updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage {
|
||||
return &Storage{
|
||||
ID: storageID,
|
||||
WorkspaceID: workspaceID,
|
||||
Type: StorageTypeAzureBlob,
|
||||
Name: "Updated Azure Blob with Account Key",
|
||||
AzureBlobStorage: &azure_blob_storage.AzureBlobStorage{
|
||||
AuthMethod: azure_blob_storage.AuthMethodAccountKey,
|
||||
AccountName: "updatedaccount",
|
||||
AccountKey: "",
|
||||
ContainerName: "updated-container",
|
||||
Endpoint: "https://custom.blob.core.windows.net",
|
||||
Prefix: "backups2/",
|
||||
},
|
||||
}
|
||||
},
|
||||
verifySensitiveData: func(t *testing.T, storage *Storage) {
|
||||
assert.True(t, strings.HasPrefix(storage.AzureBlobStorage.AccountKey, "enc:"),
|
||||
"AccountKey should be encrypted with 'enc:' prefix")
|
||||
|
||||
encryptor := encryption.GetFieldEncryptor()
|
||||
accountKey, err := encryptor.Decrypt(
|
||||
storage.ID,
|
||||
storage.AzureBlobStorage.AccountKey,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "original-account-key", accountKey)
|
||||
},
|
||||
verifyHiddenData: func(t *testing.T, storage *Storage) {
|
||||
assert.Equal(t, "", storage.AzureBlobStorage.ConnectionString)
|
||||
assert.Equal(t, "", storage.AzureBlobStorage.AccountKey)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Google Drive Storage",
|
||||
storageType: StorageTypeGoogleDrive,
|
||||
createStorage: func(workspaceID uuid.UUID) *Storage {
|
||||
return &Storage{
|
||||
WorkspaceID: workspaceID,
|
||||
Type: StorageTypeGoogleDrive,
|
||||
Name: "Test Google Drive Storage",
|
||||
GoogleDriveStorage: &google_drive_storage.GoogleDriveStorage{
|
||||
ClientID: "original-client-id",
|
||||
ClientSecret: "original-client-secret",
|
||||
TokenJSON: `{"access_token":"ya29.test-access-token","token_type":"Bearer","expiry":"2030-12-31T23:59:59Z","refresh_token":"1//test-refresh-token"}`,
|
||||
},
|
||||
}
|
||||
},
|
||||
updateStorage: func(workspaceID uuid.UUID, storageID uuid.UUID) *Storage {
|
||||
return &Storage{
|
||||
ID: storageID,
|
||||
WorkspaceID: workspaceID,
|
||||
Type: StorageTypeGoogleDrive,
|
||||
Name: "Updated Google Drive Storage",
|
||||
GoogleDriveStorage: &google_drive_storage.GoogleDriveStorage{
|
||||
ClientID: "updated-client-id",
|
||||
ClientSecret: "",
|
||||
TokenJSON: "",
|
||||
},
|
||||
}
|
||||
},
|
||||
verifySensitiveData: func(t *testing.T, storage *Storage) {
|
||||
assert.True(t, strings.HasPrefix(storage.GoogleDriveStorage.ClientSecret, "enc:"),
|
||||
"ClientSecret should be encrypted with 'enc:' prefix")
|
||||
assert.True(t, strings.HasPrefix(storage.GoogleDriveStorage.TokenJSON, "enc:"),
|
||||
"TokenJSON should be encrypted with 'enc:' prefix")
|
||||
|
||||
encryptor := encryption.GetFieldEncryptor()
|
||||
clientSecret, err := encryptor.Decrypt(
|
||||
storage.ID,
|
||||
storage.GoogleDriveStorage.ClientSecret,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "original-client-secret", clientSecret)
|
||||
|
||||
tokenJSON, err := encryptor.Decrypt(
|
||||
storage.ID,
|
||||
storage.GoogleDriveStorage.TokenJSON,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(
|
||||
t,
|
||||
`{"access_token":"ya29.test-access-token","token_type":"Bearer","expiry":"2030-12-31T23:59:59Z","refresh_token":"1//test-refresh-token"}`,
|
||||
tokenJSON,
|
||||
)
|
||||
},
|
||||
verifyHiddenData: func(t *testing.T, storage *Storage) {
|
||||
assert.Equal(t, "", storage.GoogleDriveStorage.ClientSecret)
|
||||
assert.Equal(t, "", storage.GoogleDriveStorage.TokenJSON)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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: Verify sensitive data is encrypted in repository after creation
|
||||
repository := &StorageRepository{}
|
||||
storageFromDBAfterCreate, err := repository.FindByID(createdStorage.ID)
|
||||
assert.NoError(t, err)
|
||||
tc.verifySensitiveData(t, storageFromDBAfterCreate)
|
||||
|
||||
// Phase 3: 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 4: 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 5: Retrieve directly from repository to verify sensitive data preservation
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
@@ -485,158 +870,3 @@ 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package storages
|
||||
import (
|
||||
audit_logs "postgresus-backend/internal/features/audit_logs"
|
||||
workspaces_services "postgresus-backend/internal/features/workspaces/services"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
)
|
||||
|
||||
var storageRepository = &StorageRepository{}
|
||||
@@ -10,6 +11,7 @@ var storageService = &StorageService{
|
||||
storageRepository,
|
||||
workspaces_services.GetWorkspaceService(),
|
||||
audit_logs.GetAuditLogService(),
|
||||
encryption.GetFieldEncryptor(),
|
||||
}
|
||||
var storageController = &StorageController{
|
||||
storageService,
|
||||
|
||||
@@ -3,20 +3,28 @@ package storages
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type StorageFileSaver interface {
|
||||
SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error
|
||||
SaveFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error
|
||||
|
||||
GetFile(fileID uuid.UUID) (io.ReadCloser, error)
|
||||
GetFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) (io.ReadCloser, error)
|
||||
|
||||
DeleteFile(fileID uuid.UUID) error
|
||||
DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error
|
||||
|
||||
Validate() error
|
||||
Validate(encryptor encryption.FieldEncryptor) error
|
||||
|
||||
TestConnection() error
|
||||
TestConnection(encryptor encryption.FieldEncryptor) error
|
||||
|
||||
HideSensitiveData()
|
||||
|
||||
EncryptSensitiveData(encryptor encryption.FieldEncryptor) error
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
local_storage "postgresus-backend/internal/features/storages/models/local"
|
||||
nas_storage "postgresus-backend/internal/features/storages/models/nas"
|
||||
s3_storage "postgresus-backend/internal/features/storages/models/s3"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -28,8 +29,13 @@ type Storage struct {
|
||||
AzureBlobStorage *azure_blob_storage.AzureBlobStorage `json:"azureBlobStorage" gorm:"foreignKey:StorageID"`
|
||||
}
|
||||
|
||||
func (s *Storage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
|
||||
err := s.getSpecificStorage().SaveFile(logger, fileID, file)
|
||||
func (s *Storage) SaveFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
err := s.getSpecificStorage().SaveFile(encryptor, logger, fileID, file)
|
||||
if err != nil {
|
||||
lastSaveError := err.Error()
|
||||
s.LastSaveError = &lastSaveError
|
||||
@@ -41,15 +47,18 @@ func (s *Storage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
return s.getSpecificStorage().GetFile(fileID)
|
||||
func (s *Storage) GetFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
fileID uuid.UUID,
|
||||
) (io.ReadCloser, error) {
|
||||
return s.getSpecificStorage().GetFile(encryptor, fileID)
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteFile(fileID uuid.UUID) error {
|
||||
return s.getSpecificStorage().DeleteFile(fileID)
|
||||
func (s *Storage) DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error {
|
||||
return s.getSpecificStorage().DeleteFile(encryptor, fileID)
|
||||
}
|
||||
|
||||
func (s *Storage) Validate() error {
|
||||
func (s *Storage) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if s.Type == "" {
|
||||
return errors.New("storage type is required")
|
||||
}
|
||||
@@ -58,17 +67,21 @@ func (s *Storage) Validate() error {
|
||||
return errors.New("storage name is required")
|
||||
}
|
||||
|
||||
return s.getSpecificStorage().Validate()
|
||||
return s.getSpecificStorage().Validate(encryptor)
|
||||
}
|
||||
|
||||
func (s *Storage) TestConnection() error {
|
||||
return s.getSpecificStorage().TestConnection()
|
||||
func (s *Storage) TestConnection(encryptor encryption.FieldEncryptor) error {
|
||||
return s.getSpecificStorage().TestConnection(encryptor)
|
||||
}
|
||||
|
||||
func (s *Storage) HideSensitiveData() {
|
||||
s.getSpecificStorage().HideSensitiveData()
|
||||
}
|
||||
|
||||
func (s *Storage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
return s.getSpecificStorage().EncryptSensitiveData(encryptor)
|
||||
}
|
||||
|
||||
func (s *Storage) Update(incoming *Storage) {
|
||||
s.Name = incoming.Name
|
||||
s.Type = incoming.Type
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
local_storage "postgresus-backend/internal/features/storages/models/local"
|
||||
nas_storage "postgresus-backend/internal/features/storages/models/nas"
|
||||
s3_storage "postgresus-backend/internal/features/storages/models/s3"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
"strconv"
|
||||
"testing"
|
||||
@@ -147,13 +148,15 @@ func Test_Storage_BasicOperations(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
encryptor := encryption.GetFieldEncryptor()
|
||||
|
||||
t.Run("Test_TestConnection_ConnectionSucceeds", func(t *testing.T) {
|
||||
err := tc.storage.TestConnection()
|
||||
err := tc.storage.TestConnection(encryptor)
|
||||
assert.NoError(t, err, "TestConnection should succeed")
|
||||
})
|
||||
|
||||
t.Run("Test_TestValidation_ValidationSucceeds", func(t *testing.T) {
|
||||
err := tc.storage.Validate()
|
||||
err := tc.storage.Validate(encryptor)
|
||||
assert.NoError(t, err, "Validate should succeed")
|
||||
})
|
||||
|
||||
@@ -163,10 +166,15 @@ func Test_Storage_BasicOperations(t *testing.T) {
|
||||
|
||||
fileID := uuid.New()
|
||||
|
||||
err = tc.storage.SaveFile(logger.GetLogger(), fileID, bytes.NewReader(fileData))
|
||||
err = tc.storage.SaveFile(
|
||||
encryptor,
|
||||
logger.GetLogger(),
|
||||
fileID,
|
||||
bytes.NewReader(fileData),
|
||||
)
|
||||
require.NoError(t, err, "SaveFile should succeed")
|
||||
|
||||
file, err := tc.storage.GetFile(fileID)
|
||||
file, err := tc.storage.GetFile(encryptor, fileID)
|
||||
assert.NoError(t, err, "GetFile should succeed")
|
||||
defer file.Close()
|
||||
|
||||
@@ -180,13 +188,18 @@ func Test_Storage_BasicOperations(t *testing.T) {
|
||||
require.NoError(t, err, "Should be able to read test file")
|
||||
|
||||
fileID := uuid.New()
|
||||
err = tc.storage.SaveFile(logger.GetLogger(), fileID, bytes.NewReader(fileData))
|
||||
err = tc.storage.SaveFile(
|
||||
encryptor,
|
||||
logger.GetLogger(),
|
||||
fileID,
|
||||
bytes.NewReader(fileData),
|
||||
)
|
||||
require.NoError(t, err, "SaveFile should succeed")
|
||||
|
||||
err = tc.storage.DeleteFile(fileID)
|
||||
err = tc.storage.DeleteFile(encryptor, fileID)
|
||||
assert.NoError(t, err, "DeleteFile should succeed")
|
||||
|
||||
file, err := tc.storage.GetFile(fileID)
|
||||
file, err := tc.storage.GetFile(encryptor, fileID)
|
||||
assert.Error(t, err, "GetFile should fail for non-existent file")
|
||||
if file != nil {
|
||||
file.Close()
|
||||
@@ -196,7 +209,7 @@ func Test_Storage_BasicOperations(t *testing.T) {
|
||||
t.Run("Test_TestDeleteNonExistentFile_DoesNotError", func(t *testing.T) {
|
||||
// Try to delete a non-existent file
|
||||
nonExistentID := uuid.New()
|
||||
err := tc.storage.DeleteFile(nonExistentID)
|
||||
err := tc.storage.DeleteFile(encryptor, nonExistentID)
|
||||
assert.NoError(t, err, "DeleteFile should not error for non-existent file")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,8 +38,13 @@ func (s *AzureBlobStorage) TableName() string {
|
||||
return "azure_blob_storages"
|
||||
}
|
||||
|
||||
func (s *AzureBlobStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
|
||||
client, err := s.getClient()
|
||||
func (s *AzureBlobStorage) SaveFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
client, err := s.getClient(encryptor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -59,8 +65,11 @@ func (s *AzureBlobStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AzureBlobStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
client, err := s.getClient()
|
||||
func (s *AzureBlobStorage) GetFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
fileID uuid.UUID,
|
||||
) (io.ReadCloser, error) {
|
||||
client, err := s.getClient(encryptor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -80,8 +89,8 @@ func (s *AzureBlobStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
return response.Body, nil
|
||||
}
|
||||
|
||||
func (s *AzureBlobStorage) DeleteFile(fileID uuid.UUID) error {
|
||||
client, err := s.getClient()
|
||||
func (s *AzureBlobStorage) DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error {
|
||||
client, err := s.getClient(encryptor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -105,7 +114,7 @@ func (s *AzureBlobStorage) DeleteFile(fileID uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AzureBlobStorage) Validate() error {
|
||||
func (s *AzureBlobStorage) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if s.ContainerName == "" {
|
||||
return errors.New("container name is required")
|
||||
}
|
||||
@@ -128,16 +137,11 @@ func (s *AzureBlobStorage) Validate() error {
|
||||
return fmt.Errorf("invalid auth method: %s", s.AuthMethod)
|
||||
}
|
||||
|
||||
_, err := s.getClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Azure Blob configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AzureBlobStorage) TestConnection() error {
|
||||
client, err := s.getClient()
|
||||
func (s *AzureBlobStorage) TestConnection(encryptor encryption.FieldEncryptor) error {
|
||||
client, err := s.getClient(encryptor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -192,6 +196,26 @@ func (s *AzureBlobStorage) HideSensitiveData() {
|
||||
s.AccountKey = ""
|
||||
}
|
||||
|
||||
func (s *AzureBlobStorage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
var err error
|
||||
|
||||
if s.ConnectionString != "" {
|
||||
s.ConnectionString, err = encryptor.Encrypt(s.StorageID, s.ConnectionString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt Azure connection string: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if s.AccountKey != "" {
|
||||
s.AccountKey, err = encryptor.Encrypt(s.StorageID, s.AccountKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt Azure account key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AzureBlobStorage) Update(incoming *AzureBlobStorage) {
|
||||
s.AuthMethod = incoming.AuthMethod
|
||||
s.ContainerName = incoming.ContainerName
|
||||
@@ -225,13 +249,18 @@ func (s *AzureBlobStorage) buildBlobName(fileName string) string {
|
||||
return prefix + fileName
|
||||
}
|
||||
|
||||
func (s *AzureBlobStorage) getClient() (*azblob.Client, error) {
|
||||
func (s *AzureBlobStorage) getClient(encryptor encryption.FieldEncryptor) (*azblob.Client, error) {
|
||||
var client *azblob.Client
|
||||
var err error
|
||||
|
||||
switch s.AuthMethod {
|
||||
case AuthMethodConnectionString:
|
||||
client, err = azblob.NewClientFromConnectionString(s.ConnectionString, nil)
|
||||
connectionString, decryptErr := encryptor.Decrypt(s.StorageID, s.ConnectionString)
|
||||
if decryptErr != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt Azure connection string: %w", decryptErr)
|
||||
}
|
||||
|
||||
client, err = azblob.NewClientFromConnectionString(connectionString, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to create Azure Blob client from connection string: %w",
|
||||
@@ -239,8 +268,13 @@ func (s *AzureBlobStorage) getClient() (*azblob.Client, error) {
|
||||
)
|
||||
}
|
||||
case AuthMethodAccountKey:
|
||||
accountKey, decryptErr := encryptor.Decrypt(s.StorageID, s.AccountKey)
|
||||
if decryptErr != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt Azure account key: %w", decryptErr)
|
||||
}
|
||||
|
||||
accountURL := s.buildAccountURL()
|
||||
credential, credErr := azblob.NewSharedKeyCredential(s.AccountName, s.AccountKey)
|
||||
credential, credErr := azblob.NewSharedKeyCredential(s.AccountName, accountKey)
|
||||
if credErr != nil {
|
||||
return nil, fmt.Errorf("failed to create Azure shared key credential: %w", credErr)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -30,11 +31,12 @@ func (s *GoogleDriveStorage) TableName() string {
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) SaveFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
return s.withRetryOnAuth(func(driveService *drive.Service) error {
|
||||
return s.withRetryOnAuth(encryptor, func(driveService *drive.Service) error {
|
||||
ctx := context.Background()
|
||||
filename := fileID.String()
|
||||
|
||||
@@ -68,9 +70,12 @@ func (s *GoogleDriveStorage) SaveFile(
|
||||
})
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
func (s *GoogleDriveStorage) GetFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
fileID uuid.UUID,
|
||||
) (io.ReadCloser, error) {
|
||||
var result io.ReadCloser
|
||||
err := s.withRetryOnAuth(func(driveService *drive.Service) error {
|
||||
err := s.withRetryOnAuth(encryptor, func(driveService *drive.Service) error {
|
||||
folderID, err := s.findBackupsFolder(driveService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find backups folder: %w", err)
|
||||
@@ -93,8 +98,11 @@ func (s *GoogleDriveStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) DeleteFile(fileID uuid.UUID) error {
|
||||
return s.withRetryOnAuth(func(driveService *drive.Service) error {
|
||||
func (s *GoogleDriveStorage) DeleteFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
fileID uuid.UUID,
|
||||
) error {
|
||||
return s.withRetryOnAuth(encryptor, func(driveService *drive.Service) error {
|
||||
ctx := context.Background()
|
||||
folderID, err := s.findBackupsFolder(driveService)
|
||||
if err != nil {
|
||||
@@ -105,7 +113,7 @@ func (s *GoogleDriveStorage) DeleteFile(fileID uuid.UUID) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) Validate() error {
|
||||
func (s *GoogleDriveStorage) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
switch {
|
||||
case s.ClientID == "":
|
||||
return errors.New("client ID is required")
|
||||
@@ -115,7 +123,12 @@ func (s *GoogleDriveStorage) Validate() error {
|
||||
return errors.New("token JSON is required")
|
||||
}
|
||||
|
||||
// Also validate that the token JSON contains a refresh token
|
||||
// Skip JSON validation if token is already encrypted
|
||||
if strings.HasPrefix(s.TokenJSON, "enc:") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate that the token JSON contains a refresh token
|
||||
var token oauth2.Token
|
||||
if err := json.Unmarshal([]byte(s.TokenJSON), &token); err != nil {
|
||||
return fmt.Errorf("invalid token JSON format: %w", err)
|
||||
@@ -128,8 +141,8 @@ func (s *GoogleDriveStorage) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) TestConnection() error {
|
||||
return s.withRetryOnAuth(func(driveService *drive.Service) error {
|
||||
func (s *GoogleDriveStorage) TestConnection(encryptor encryption.FieldEncryptor) error {
|
||||
return s.withRetryOnAuth(encryptor, func(driveService *drive.Service) error {
|
||||
ctx := context.Background()
|
||||
testFilename := "test-connection-" + uuid.New().String()
|
||||
testData := []byte("test")
|
||||
@@ -196,6 +209,26 @@ func (s *GoogleDriveStorage) HideSensitiveData() {
|
||||
s.TokenJSON = ""
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
var err error
|
||||
|
||||
if s.ClientSecret != "" {
|
||||
s.ClientSecret, err = encryptor.Encrypt(s.StorageID, s.ClientSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt Google Drive client secret: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if s.TokenJSON != "" {
|
||||
s.TokenJSON, err = encryptor.Encrypt(s.StorageID, s.TokenJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt Google Drive token JSON: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) Update(incoming *GoogleDriveStorage) {
|
||||
s.ClientID = incoming.ClientID
|
||||
|
||||
@@ -209,8 +242,11 @@ func (s *GoogleDriveStorage) Update(incoming *GoogleDriveStorage) {
|
||||
}
|
||||
|
||||
// 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()
|
||||
func (s *GoogleDriveStorage) withRetryOnAuth(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
fn func(*drive.Service) error,
|
||||
) error {
|
||||
driveService, err := s.getDriveService(encryptor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -220,7 +256,7 @@ func (s *GoogleDriveStorage) withRetryOnAuth(fn func(*drive.Service) error) erro
|
||||
// Try to refresh token and retry once
|
||||
fmt.Printf("Google Drive auth error detected, attempting token refresh: %v\n", err)
|
||||
|
||||
if refreshErr := s.refreshToken(); refreshErr != nil {
|
||||
if refreshErr := s.refreshToken(encryptor); refreshErr != nil {
|
||||
// If refresh fails, return a more helpful error message
|
||||
if strings.Contains(refreshErr.Error(), "invalid_grant") ||
|
||||
strings.Contains(refreshErr.Error(), "refresh token") {
|
||||
@@ -237,7 +273,7 @@ func (s *GoogleDriveStorage) withRetryOnAuth(fn func(*drive.Service) error) erro
|
||||
fmt.Printf("Token refresh successful, retrying operation\n")
|
||||
|
||||
// Get new service with refreshed token
|
||||
driveService, err = s.getDriveService()
|
||||
driveService, err = s.getDriveService(encryptor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create service after token refresh: %w", err)
|
||||
}
|
||||
@@ -268,13 +304,24 @@ func (s *GoogleDriveStorage) isAuthError(err error) bool {
|
||||
}
|
||||
|
||||
// refreshToken refreshes the OAuth2 token and updates the TokenJSON field
|
||||
func (s *GoogleDriveStorage) refreshToken() error {
|
||||
if err := s.Validate(); err != nil {
|
||||
func (s *GoogleDriveStorage) refreshToken(encryptor encryption.FieldEncryptor) error {
|
||||
if err := s.Validate(encryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Decrypt credentials before use
|
||||
clientSecret, err := encryptor.Decrypt(s.StorageID, s.ClientSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt Google Drive client secret: %w", err)
|
||||
}
|
||||
|
||||
tokenJSON, err := encryptor.Decrypt(s.StorageID, s.TokenJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt Google Drive token JSON: %w", err)
|
||||
}
|
||||
|
||||
var token oauth2.Token
|
||||
if err := json.Unmarshal([]byte(s.TokenJSON), &token); err != nil {
|
||||
if err := json.Unmarshal([]byte(tokenJSON), &token); err != nil {
|
||||
return fmt.Errorf("invalid token JSON: %w", err)
|
||||
}
|
||||
|
||||
@@ -289,12 +336,12 @@ func (s *GoogleDriveStorage) refreshToken() error {
|
||||
token.Expiry)
|
||||
|
||||
// Debug: Print the full token JSON structure (sensitive data masked)
|
||||
fmt.Printf("Original token JSON structure: %s\n", maskSensitiveData(s.TokenJSON))
|
||||
fmt.Printf("Original token JSON structure: %s\n", maskSensitiveData(tokenJSON))
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := &oauth2.Config{
|
||||
ClientID: s.ClientID,
|
||||
ClientSecret: s.ClientSecret,
|
||||
ClientSecret: clientSecret,
|
||||
Endpoint: google.Endpoint,
|
||||
Scopes: []string{"https://www.googleapis.com/auth/drive.file"},
|
||||
}
|
||||
@@ -330,7 +377,7 @@ func (s *GoogleDriveStorage) refreshToken() error {
|
||||
newToken.RefreshToken = token.RefreshToken
|
||||
}
|
||||
|
||||
// Update the stored token JSON
|
||||
// Update the stored token JSON (keep as plaintext in memory, encryption happens on save)
|
||||
newTokenJSON, err := json.Marshal(newToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal refreshed token: %w", err)
|
||||
@@ -368,13 +415,26 @@ func truncateString(s string, maxLen int) string {
|
||||
return s[:maxLen]
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) getDriveService() (*drive.Service, error) {
|
||||
if err := s.Validate(); err != nil {
|
||||
func (s *GoogleDriveStorage) getDriveService(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
) (*drive.Service, error) {
|
||||
if err := s.Validate(encryptor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt credentials before use
|
||||
clientSecret, err := encryptor.Decrypt(s.StorageID, s.ClientSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt Google Drive client secret: %w", err)
|
||||
}
|
||||
|
||||
tokenJSON, err := encryptor.Decrypt(s.StorageID, s.TokenJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt Google Drive token JSON: %w", err)
|
||||
}
|
||||
|
||||
var token oauth2.Token
|
||||
if err := json.Unmarshal([]byte(s.TokenJSON), &token); err != nil {
|
||||
if err := json.Unmarshal([]byte(tokenJSON), &token); err != nil {
|
||||
return nil, fmt.Errorf("invalid token JSON: %w", err)
|
||||
}
|
||||
|
||||
@@ -382,7 +442,7 @@ func (s *GoogleDriveStorage) getDriveService() (*drive.Service, error) {
|
||||
|
||||
cfg := &oauth2.Config{
|
||||
ClientID: s.ClientID,
|
||||
ClientSecret: s.ClientSecret,
|
||||
ClientSecret: clientSecret,
|
||||
Endpoint: google.Endpoint,
|
||||
Scopes: []string{"https://www.googleapis.com/auth/drive.file"},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"postgresus-backend/internal/config"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
files_utils "postgresus-backend/internal/util/files"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -23,7 +24,12 @@ func (l *LocalStorage) TableName() string {
|
||||
return "local_storages"
|
||||
}
|
||||
|
||||
func (l *LocalStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
|
||||
func (l *LocalStorage) SaveFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
logger.Info("Starting to save file to local storage", "fileId", fileID.String())
|
||||
|
||||
err := files_utils.EnsureDirectories([]string{
|
||||
@@ -107,7 +113,10 @@ func (l *LocalStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.R
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
func (l *LocalStorage) GetFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
fileID uuid.UUID,
|
||||
) (io.ReadCloser, error) {
|
||||
filePath := filepath.Join(config.GetEnv().DataFolder, fileID.String())
|
||||
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
@@ -122,7 +131,7 @@ func (l *LocalStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) DeleteFile(fileID uuid.UUID) error {
|
||||
func (l *LocalStorage) DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error {
|
||||
filePath := filepath.Join(config.GetEnv().DataFolder, fileID.String())
|
||||
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
@@ -136,11 +145,11 @@ func (l *LocalStorage) DeleteFile(fileID uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Validate() error {
|
||||
func (l *LocalStorage) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) TestConnection() error {
|
||||
func (l *LocalStorage) TestConnection(encryptor encryption.FieldEncryptor) error {
|
||||
testFile := filepath.Join(config.GetEnv().TempFolder, "test_connection")
|
||||
f, err := os.Create(testFile)
|
||||
if err != nil {
|
||||
@@ -160,5 +169,9 @@ func (l *LocalStorage) TestConnection() error {
|
||||
func (l *LocalStorage) HideSensitiveData() {
|
||||
}
|
||||
|
||||
func (l *LocalStorage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Update(incoming *LocalStorage) {
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,10 +32,15 @@ func (n *NASStorage) TableName() string {
|
||||
return "nas_storages"
|
||||
}
|
||||
|
||||
func (n *NASStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
|
||||
func (n *NASStorage) SaveFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
logger.Info("Starting to save file to NAS storage", "fileId", fileID.String(), "host", n.Host)
|
||||
|
||||
session, err := n.createSession()
|
||||
session, err := n.createSession(encryptor)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create NAS session", "fileId", fileID.String(), "error", err)
|
||||
return fmt.Errorf("failed to create NAS session: %w", err)
|
||||
@@ -131,8 +137,11 @@ func (n *NASStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Rea
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NASStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
session, err := n.createSession()
|
||||
func (n *NASStorage) GetFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
fileID uuid.UUID,
|
||||
) (io.ReadCloser, error) {
|
||||
session, err := n.createSession(encryptor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create NAS session: %w", err)
|
||||
}
|
||||
@@ -168,8 +177,8 @@ func (n *NASStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (n *NASStorage) DeleteFile(fileID uuid.UUID) error {
|
||||
session, err := n.createSession()
|
||||
func (n *NASStorage) DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error {
|
||||
session, err := n.createSession(encryptor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create NAS session: %w", err)
|
||||
}
|
||||
@@ -202,7 +211,7 @@ func (n *NASStorage) DeleteFile(fileID uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NASStorage) Validate() error {
|
||||
func (n *NASStorage) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if n.Host == "" {
|
||||
return errors.New("NAS host is required")
|
||||
}
|
||||
@@ -219,12 +228,11 @@ func (n *NASStorage) Validate() error {
|
||||
return errors.New("NAS port must be between 1 and 65535")
|
||||
}
|
||||
|
||||
// Test the configuration by creating a session
|
||||
return n.TestConnection()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NASStorage) TestConnection() error {
|
||||
session, err := n.createSession()
|
||||
func (n *NASStorage) TestConnection(encryptor encryption.FieldEncryptor) error {
|
||||
session, err := n.createSession(encryptor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to NAS: %w", err)
|
||||
}
|
||||
@@ -255,6 +263,18 @@ func (n *NASStorage) HideSensitiveData() {
|
||||
n.Password = ""
|
||||
}
|
||||
|
||||
func (n *NASStorage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
if n.Password != "" {
|
||||
encrypted, err := encryptor.Encrypt(n.StorageID, n.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt NAS password: %w", err)
|
||||
}
|
||||
n.Password = encrypted
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NASStorage) Update(incoming *NASStorage) {
|
||||
n.Host = incoming.Host
|
||||
n.Port = incoming.Port
|
||||
@@ -269,18 +289,25 @@ func (n *NASStorage) Update(incoming *NASStorage) {
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NASStorage) createSession() (*smb2.Session, error) {
|
||||
func (n *NASStorage) createSession(encryptor encryption.FieldEncryptor) (*smb2.Session, error) {
|
||||
// Create connection with timeout
|
||||
conn, err := n.createConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt password before use
|
||||
password, err := encryptor.Decrypt(n.StorageID, n.Password)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("failed to decrypt NAS password: %w", err)
|
||||
}
|
||||
|
||||
// Create SMB2 dialer
|
||||
d := &smb2.Dialer{
|
||||
Initiator: &smb2.NTLMInitiator{
|
||||
User: n.Username,
|
||||
Password: n.Password,
|
||||
Password: password,
|
||||
Domain: n.Domain,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,8 +32,13 @@ func (s *S3Storage) TableName() string {
|
||||
return "s3_storages"
|
||||
}
|
||||
|
||||
func (s *S3Storage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
|
||||
client, err := s.getClient()
|
||||
func (s *S3Storage) SaveFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
client, err := s.getClient(encryptor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -55,8 +61,11 @@ func (s *S3Storage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Read
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
client, err := s.getClient()
|
||||
func (s *S3Storage) GetFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
fileID uuid.UUID,
|
||||
) (io.ReadCloser, error) {
|
||||
client, err := s.getClient(encryptor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -91,8 +100,8 @@ func (s *S3Storage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) DeleteFile(fileID uuid.UUID) error {
|
||||
client, err := s.getClient()
|
||||
func (s *S3Storage) DeleteFile(encryptor encryption.FieldEncryptor, fileID uuid.UUID) error {
|
||||
client, err := s.getClient(encryptor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -113,7 +122,7 @@ func (s *S3Storage) DeleteFile(fileID uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Validate() error {
|
||||
func (s *S3Storage) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if s.S3Bucket == "" {
|
||||
return errors.New("S3 bucket is required")
|
||||
}
|
||||
@@ -124,17 +133,11 @@ func (s *S3Storage) Validate() error {
|
||||
return errors.New("S3 secret key is required")
|
||||
}
|
||||
|
||||
// Try to create a client to validate the configuration
|
||||
_, err := s.getClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid S3 configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) TestConnection() error {
|
||||
client, err := s.getClient()
|
||||
func (s *S3Storage) TestConnection(encryptor encryption.FieldEncryptor) error {
|
||||
client, err := s.getClient(encryptor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -195,6 +198,26 @@ func (s *S3Storage) HideSensitiveData() {
|
||||
s.S3SecretKey = ""
|
||||
}
|
||||
|
||||
func (s *S3Storage) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
var err error
|
||||
|
||||
if s.S3AccessKey != "" {
|
||||
s.S3AccessKey, err = encryptor.Encrypt(s.StorageID, s.S3AccessKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt S3 access key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if s.S3SecretKey != "" {
|
||||
s.S3SecretKey, err = encryptor.Encrypt(s.StorageID, s.S3SecretKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt S3 secret key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Update(incoming *S3Storage) {
|
||||
s.S3Bucket = incoming.S3Bucket
|
||||
s.S3Region = incoming.S3Region
|
||||
@@ -228,7 +251,7 @@ func (s *S3Storage) buildObjectKey(fileName string) string {
|
||||
return prefix + fileName
|
||||
}
|
||||
|
||||
func (s *S3Storage) getClient() (*minio.Client, error) {
|
||||
func (s *S3Storage) getClient(encryptor encryption.FieldEncryptor) (*minio.Client, error) {
|
||||
endpoint := s.S3Endpoint
|
||||
useSSL := true
|
||||
|
||||
@@ -244,6 +267,17 @@ func (s *S3Storage) getClient() (*minio.Client, error) {
|
||||
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", s.S3Region)
|
||||
}
|
||||
|
||||
// Decrypt credentials before use
|
||||
accessKey, err := encryptor.Decrypt(s.StorageID, s.S3AccessKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt S3 access key: %w", err)
|
||||
}
|
||||
|
||||
secretKey, err := encryptor.Decrypt(s.StorageID, s.S3SecretKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt S3 secret key: %w", err)
|
||||
}
|
||||
|
||||
// Configure bucket lookup strategy
|
||||
bucketLookup := minio.BucketLookupAuto
|
||||
if s.S3UseVirtualHostedStyle {
|
||||
@@ -252,7 +286,7 @@ func (s *S3Storage) getClient() (*minio.Client, error) {
|
||||
|
||||
// Initialize the MinIO client
|
||||
minioClient, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(s.S3AccessKey, s.S3SecretKey, ""),
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: useSSL,
|
||||
Region: s.S3Region,
|
||||
BucketLookup: bucketLookup,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
audit_logs "postgresus-backend/internal/features/audit_logs"
|
||||
users_models "postgresus-backend/internal/features/users/models"
|
||||
workspaces_services "postgresus-backend/internal/features/workspaces/services"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -15,6 +16,7 @@ type StorageService struct {
|
||||
storageRepository *StorageRepository
|
||||
workspaceService *workspaces_services.WorkspaceService
|
||||
auditLogService *audit_logs.AuditLogService
|
||||
fieldEncryptor encryption.FieldEncryptor
|
||||
}
|
||||
|
||||
func (s *StorageService) SaveStorage(
|
||||
@@ -44,7 +46,11 @@ func (s *StorageService) SaveStorage(
|
||||
|
||||
existingStorage.Update(storage)
|
||||
|
||||
if err := existingStorage.Validate(); err != nil {
|
||||
if err := existingStorage.EncryptSensitiveData(s.fieldEncryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := existingStorage.Validate(s.fieldEncryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -61,7 +67,11 @@ func (s *StorageService) SaveStorage(
|
||||
} else {
|
||||
storage.WorkspaceID = workspaceID
|
||||
|
||||
if err := storage.Validate(); err != nil {
|
||||
if err := storage.EncryptSensitiveData(s.fieldEncryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := storage.Validate(s.fieldEncryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -174,7 +184,7 @@ func (s *StorageService) TestStorageConnection(
|
||||
return errors.New("insufficient permissions to test storage in this workspace")
|
||||
}
|
||||
|
||||
err = storage.TestConnection()
|
||||
err = storage.TestConnection(s.fieldEncryptor)
|
||||
if err != nil {
|
||||
lastSaveError := err.Error()
|
||||
storage.LastSaveError = &lastSaveError
|
||||
@@ -207,7 +217,7 @@ func (s *StorageService) TestStorageConnectionDirect(
|
||||
|
||||
existingStorage.Update(storage)
|
||||
|
||||
if err := existingStorage.Validate(); err != nil {
|
||||
if err := existingStorage.Validate(s.fieldEncryptor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -216,7 +226,7 @@ func (s *StorageService) TestStorageConnectionDirect(
|
||||
usingStorage = storage
|
||||
}
|
||||
|
||||
return usingStorage.TestConnection()
|
||||
return usingStorage.TestConnection(s.fieldEncryptor)
|
||||
}
|
||||
|
||||
func (s *StorageService) GetStorageByID(
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"postgresus-backend/internal/config"
|
||||
"postgresus-backend/internal/features/backups/backups"
|
||||
usecases_postgresql_backup "postgresus-backend/internal/features/backups/backups/usecases/postgresql"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/intervals"
|
||||
"postgresus-backend/internal/features/restores/models"
|
||||
usecases_postgresql_restore "postgresus-backend/internal/features/restores/usecases/postgresql"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
local_storage "postgresus-backend/internal/features/storages/models/local"
|
||||
"postgresus-backend/internal/util/period"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"postgresus-backend/internal/config"
|
||||
"postgresus-backend/internal/features/backups/backups"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/restores"
|
||||
restores_enums "postgresus-backend/internal/features/restores/enums"
|
||||
restores_models "postgresus-backend/internal/features/restores/models"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
users_enums "postgresus-backend/internal/features/users/enums"
|
||||
users_testing "postgresus-backend/internal/features/users/testing"
|
||||
workspaces_controllers "postgresus-backend/internal/features/workspaces/controllers"
|
||||
workspaces_testing "postgresus-backend/internal/features/workspaces/testing"
|
||||
test_utils "postgresus-backend/internal/util/testing"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
)
|
||||
|
||||
const createAndFillTableQuery = `
|
||||
@@ -61,7 +66,6 @@ type TestDataItem struct {
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
// Main test functions for each PostgreSQL version
|
||||
func Test_BackupAndRestorePostgresql_RestoreIsSuccesful(t *testing.T) {
|
||||
env := config.GetEnv()
|
||||
cases := []struct {
|
||||
@@ -110,143 +114,7 @@ func Test_BackupAndRestorePostgresqlWithEncryption_RestoreIsSuccessful(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func testBackupRestoreWithEncryptionForVersion(t *testing.T, pgVersion string, port string) {
|
||||
// Connect to pre-configured PostgreSQL container
|
||||
container, err := connectToPostgresContainer(pgVersion, port)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
if container.DB != nil {
|
||||
container.DB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = container.DB.Exec(createAndFillTableQuery)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Prepare data for backup
|
||||
backupID := uuid.New()
|
||||
pgVersionEnum := tools.GetPostgresqlVersionEnum(pgVersion)
|
||||
|
||||
backupDb := &databases.Database{
|
||||
ID: uuid.New(),
|
||||
Type: databases.DatabaseTypePostgres,
|
||||
Name: "Test Database",
|
||||
Postgresql: &pgtypes.PostgresqlDatabase{
|
||||
Version: pgVersionEnum,
|
||||
Host: container.Host,
|
||||
Port: container.Port,
|
||||
Username: container.Username,
|
||||
Password: container.Password,
|
||||
Database: &container.Database,
|
||||
IsHttps: false,
|
||||
},
|
||||
}
|
||||
|
||||
storageID := uuid.New()
|
||||
backupConfig := &backups_config.BackupConfig{
|
||||
DatabaseID: backupDb.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodDay,
|
||||
BackupInterval: &intervals.Interval{Interval: intervals.IntervalDaily},
|
||||
StorageID: &storageID,
|
||||
CpuCount: 1,
|
||||
Encryption: backups_config.BackupEncryptionEncrypted,
|
||||
}
|
||||
|
||||
storage := &storages.Storage{
|
||||
WorkspaceID: uuid.New(),
|
||||
Type: storages.StorageTypeLocal,
|
||||
Name: "Test Storage",
|
||||
LocalStorage: &local_storage.LocalStorage{},
|
||||
}
|
||||
|
||||
// Make backup
|
||||
progressTracker := func(completedMBs float64) {}
|
||||
metadata, err := usecases_postgresql_backup.GetCreatePostgresqlBackupUsecase().Execute(
|
||||
context.Background(),
|
||||
backupID,
|
||||
backupConfig,
|
||||
backupDb,
|
||||
storage,
|
||||
progressTracker,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, metadata)
|
||||
|
||||
// Verify encryption metadata is set
|
||||
assert.Equal(t, backups_config.BackupEncryptionEncrypted, metadata.Encryption)
|
||||
assert.NotNil(t, metadata.EncryptionSalt)
|
||||
assert.NotNil(t, metadata.EncryptionIV)
|
||||
|
||||
// Create new database
|
||||
newDBName := "restoreddb_encrypted"
|
||||
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Connect to the new database
|
||||
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
container.Host, container.Port, container.Username, container.Password, newDBName)
|
||||
newDB, err := sqlx.Connect("postgres", newDSN)
|
||||
assert.NoError(t, err)
|
||||
defer newDB.Close()
|
||||
|
||||
// Setup data for restore with encryption metadata
|
||||
completedBackup := &backups.Backup{
|
||||
ID: backupID,
|
||||
DatabaseID: backupDb.ID,
|
||||
StorageID: storage.ID,
|
||||
Status: backups.BackupStatusCompleted,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
EncryptionSalt: metadata.EncryptionSalt,
|
||||
EncryptionIV: metadata.EncryptionIV,
|
||||
Encryption: metadata.Encryption,
|
||||
}
|
||||
|
||||
restoreID := uuid.New()
|
||||
restore := models.Restore{
|
||||
ID: restoreID,
|
||||
Backup: completedBackup,
|
||||
Postgresql: &pgtypes.PostgresqlDatabase{
|
||||
Version: pgVersionEnum,
|
||||
Host: container.Host,
|
||||
Port: container.Port,
|
||||
Username: container.Username,
|
||||
Password: container.Password,
|
||||
Database: &newDBName,
|
||||
IsHttps: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Restore the encrypted backup
|
||||
restoreBackupUC := usecases_postgresql_restore.GetRestorePostgresqlBackupUsecase()
|
||||
err = restoreBackupUC.Execute(backupDb, backupConfig, restore, completedBackup, storage)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify restored table exists
|
||||
var tableExists bool
|
||||
err = newDB.Get(
|
||||
&tableExists,
|
||||
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'test_data')",
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, tableExists, "Table 'test_data' should exist in restored database")
|
||||
|
||||
// Verify data integrity
|
||||
verifyDataIntegrity(t, container.DB, newDB)
|
||||
|
||||
// Clean up the backup file after the test
|
||||
err = os.Remove(filepath.Join(config.GetEnv().DataFolder, backupID.String()))
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to delete backup file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run a test for a specific PostgreSQL version
|
||||
func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
|
||||
// Connect to pre-configured PostgreSQL container
|
||||
container, err := connectToPostgresContainer(pgVersion, port)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
@@ -258,55 +126,30 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
|
||||
_, err = container.DB.Exec(createAndFillTableQuery)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Prepare data for backup
|
||||
backupID := uuid.New()
|
||||
router := createTestRouter()
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", user, router)
|
||||
|
||||
storage := storages.CreateTestStorage(workspace.ID)
|
||||
|
||||
pgVersionEnum := tools.GetPostgresqlVersionEnum(pgVersion)
|
||||
|
||||
backupDb := &databases.Database{
|
||||
ID: uuid.New(),
|
||||
Type: databases.DatabaseTypePostgres,
|
||||
Name: "Test Database",
|
||||
Postgresql: &pgtypes.PostgresqlDatabase{
|
||||
Version: pgVersionEnum,
|
||||
Host: container.Host,
|
||||
Port: container.Port,
|
||||
Username: container.Username,
|
||||
Password: container.Password,
|
||||
Database: &container.Database,
|
||||
IsHttps: false,
|
||||
},
|
||||
}
|
||||
|
||||
storageID := uuid.New()
|
||||
backupConfig := &backups_config.BackupConfig{
|
||||
DatabaseID: backupDb.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodDay,
|
||||
BackupInterval: &intervals.Interval{Interval: intervals.IntervalDaily},
|
||||
StorageID: &storageID,
|
||||
CpuCount: 1,
|
||||
}
|
||||
|
||||
storage := &storages.Storage{
|
||||
WorkspaceID: uuid.New(),
|
||||
Type: storages.StorageTypeLocal,
|
||||
Name: "Test Storage",
|
||||
LocalStorage: &local_storage.LocalStorage{},
|
||||
}
|
||||
|
||||
// Make backup
|
||||
progressTracker := func(completedMBs float64) {}
|
||||
_, err = usecases_postgresql_backup.GetCreatePostgresqlBackupUsecase().Execute(
|
||||
context.Background(),
|
||||
backupID,
|
||||
backupConfig,
|
||||
backupDb,
|
||||
storage,
|
||||
progressTracker,
|
||||
database := createDatabaseViaAPI(
|
||||
t, router, "Test Database", workspace.ID,
|
||||
pgVersionEnum, container.Host, container.Port,
|
||||
container.Username, container.Password, container.Database,
|
||||
user.Token,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create new database
|
||||
enableBackupsViaAPI(
|
||||
t, router, database.ID, storage.ID,
|
||||
backups_config.BackupEncryptionNone, user.Token,
|
||||
)
|
||||
|
||||
createBackupViaAPI(t, router, database.ID, user.Token)
|
||||
|
||||
backup := waitForBackupCompletion(t, router, database.ID, user.Token, 5*time.Minute)
|
||||
assert.Equal(t, backups.BackupStatusCompleted, backup.Status)
|
||||
|
||||
newDBName := "restoreddb"
|
||||
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
|
||||
assert.NoError(t, err)
|
||||
@@ -314,43 +157,22 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
|
||||
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Connect to the new database
|
||||
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
container.Host, container.Port, container.Username, container.Password, newDBName)
|
||||
newDB, err := sqlx.Connect("postgres", newDSN)
|
||||
assert.NoError(t, err)
|
||||
defer newDB.Close()
|
||||
|
||||
// Setup data for restore
|
||||
completedBackup := &backups.Backup{
|
||||
ID: backupID,
|
||||
DatabaseID: backupDb.ID,
|
||||
StorageID: storage.ID,
|
||||
Status: backups.BackupStatusCompleted,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
createRestoreViaAPI(
|
||||
t, router, backup.ID, pgVersionEnum,
|
||||
container.Host, container.Port,
|
||||
container.Username, container.Password, newDBName,
|
||||
user.Token,
|
||||
)
|
||||
|
||||
restoreID := uuid.New()
|
||||
restore := models.Restore{
|
||||
ID: restoreID,
|
||||
Backup: completedBackup,
|
||||
Postgresql: &pgtypes.PostgresqlDatabase{
|
||||
Version: pgVersionEnum,
|
||||
Host: container.Host,
|
||||
Port: container.Port,
|
||||
Username: container.Username,
|
||||
Password: container.Password,
|
||||
Database: &newDBName,
|
||||
IsHttps: false,
|
||||
},
|
||||
}
|
||||
restore := waitForRestoreCompletion(t, router, backup.ID, user.Token, 5*time.Minute)
|
||||
assert.Equal(t, restores_enums.RestoreStatusCompleted, restore.Status)
|
||||
|
||||
// Restore the backup
|
||||
restoreBackupUC := usecases_postgresql_restore.GetRestorePostgresqlBackupUsecase()
|
||||
err = restoreBackupUC.Execute(backupDb, backupConfig, restore, completedBackup, storage)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify restored table exists
|
||||
var tableExists bool
|
||||
err = newDB.Get(
|
||||
&tableExists,
|
||||
@@ -359,17 +181,329 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, tableExists, "Table 'test_data' should exist in restored database")
|
||||
|
||||
// Verify data integrity
|
||||
verifyDataIntegrity(t, container.DB, newDB)
|
||||
|
||||
// Clean up the backup file after the test
|
||||
err = os.Remove(filepath.Join(config.GetEnv().DataFolder, backupID.String()))
|
||||
err = os.Remove(filepath.Join(config.GetEnv().DataFolder, backup.ID.String()))
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to delete backup file: %v", err)
|
||||
}
|
||||
|
||||
test_utils.MakeDeleteRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/databases/"+database.ID.String(),
|
||||
"Bearer "+user.Token,
|
||||
http.StatusNoContent,
|
||||
)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func testBackupRestoreWithEncryptionForVersion(t *testing.T, pgVersion string, port string) {
|
||||
container, err := connectToPostgresContainer(pgVersion, port)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
if container.DB != nil {
|
||||
container.DB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = container.DB.Exec(createAndFillTableQuery)
|
||||
assert.NoError(t, err)
|
||||
|
||||
router := createTestRouter()
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", user, router)
|
||||
|
||||
storage := storages.CreateTestStorage(workspace.ID)
|
||||
|
||||
pgVersionEnum := tools.GetPostgresqlVersionEnum(pgVersion)
|
||||
database := createDatabaseViaAPI(
|
||||
t, router, "Test Database", workspace.ID,
|
||||
pgVersionEnum, container.Host, container.Port,
|
||||
container.Username, container.Password, container.Database,
|
||||
user.Token,
|
||||
)
|
||||
|
||||
enableBackupsViaAPI(
|
||||
t, router, database.ID, storage.ID,
|
||||
backups_config.BackupEncryptionEncrypted, user.Token,
|
||||
)
|
||||
|
||||
createBackupViaAPI(t, router, database.ID, user.Token)
|
||||
|
||||
backup := waitForBackupCompletion(t, router, database.ID, user.Token, 5*time.Minute)
|
||||
assert.Equal(t, backups.BackupStatusCompleted, backup.Status)
|
||||
assert.Equal(t, backups_config.BackupEncryptionEncrypted, backup.Encryption)
|
||||
|
||||
newDBName := "restoreddb_encrypted"
|
||||
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
|
||||
assert.NoError(t, err)
|
||||
|
||||
newDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
container.Host, container.Port, container.Username, container.Password, newDBName)
|
||||
newDB, err := sqlx.Connect("postgres", newDSN)
|
||||
assert.NoError(t, err)
|
||||
defer newDB.Close()
|
||||
|
||||
createRestoreViaAPI(
|
||||
t, router, backup.ID, pgVersionEnum,
|
||||
container.Host, container.Port,
|
||||
container.Username, container.Password, newDBName,
|
||||
user.Token,
|
||||
)
|
||||
|
||||
restore := waitForRestoreCompletion(t, router, backup.ID, user.Token, 5*time.Minute)
|
||||
assert.Equal(t, restores_enums.RestoreStatusCompleted, restore.Status)
|
||||
|
||||
var tableExists bool
|
||||
err = newDB.Get(
|
||||
&tableExists,
|
||||
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'test_data')",
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, tableExists, "Table 'test_data' should exist in restored database")
|
||||
|
||||
verifyDataIntegrity(t, container.DB, newDB)
|
||||
|
||||
err = os.Remove(filepath.Join(config.GetEnv().DataFolder, backup.ID.String()))
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to delete backup file: %v", err)
|
||||
}
|
||||
|
||||
test_utils.MakeDeleteRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/databases/"+database.ID.String(),
|
||||
"Bearer "+user.Token,
|
||||
http.StatusNoContent,
|
||||
)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}
|
||||
|
||||
func createTestRouter() *gin.Engine {
|
||||
router := workspaces_testing.CreateTestRouter(
|
||||
workspaces_controllers.GetWorkspaceController(),
|
||||
workspaces_controllers.GetMembershipController(),
|
||||
databases.GetDatabaseController(),
|
||||
backups_config.GetBackupConfigController(),
|
||||
backups.GetBackupController(),
|
||||
restores.GetRestoreController(),
|
||||
)
|
||||
return router
|
||||
}
|
||||
|
||||
func waitForBackupCompletion(
|
||||
t *testing.T,
|
||||
router *gin.Engine,
|
||||
databaseID uuid.UUID,
|
||||
token string,
|
||||
timeout time.Duration,
|
||||
) *backups.Backup {
|
||||
startTime := time.Now()
|
||||
pollInterval := 500 * time.Millisecond
|
||||
|
||||
for {
|
||||
if time.Since(startTime) > timeout {
|
||||
t.Fatalf("Timeout waiting for backup completion after %v", timeout)
|
||||
}
|
||||
|
||||
var response backups.GetBackupsResponse
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/backups?database_id=%s&limit=1", databaseID.String()),
|
||||
"Bearer "+token,
|
||||
http.StatusOK,
|
||||
&response,
|
||||
)
|
||||
|
||||
if len(response.Backups) > 0 {
|
||||
backup := response.Backups[0]
|
||||
if backup.Status == backups.BackupStatusCompleted {
|
||||
return backup
|
||||
}
|
||||
if backup.Status == backups.BackupStatusFailed {
|
||||
t.Fatalf("Backup failed: %v", backup.FailMessage)
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRestoreCompletion(
|
||||
t *testing.T,
|
||||
router *gin.Engine,
|
||||
backupID uuid.UUID,
|
||||
token string,
|
||||
timeout time.Duration,
|
||||
) *restores_models.Restore {
|
||||
startTime := time.Now()
|
||||
pollInterval := 500 * time.Millisecond
|
||||
|
||||
for {
|
||||
if time.Since(startTime) > timeout {
|
||||
t.Fatalf("Timeout waiting for restore completion after %v", timeout)
|
||||
}
|
||||
|
||||
var restores []*restores_models.Restore
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/restores/%s", backupID.String()),
|
||||
"Bearer "+token,
|
||||
http.StatusOK,
|
||||
&restores,
|
||||
)
|
||||
|
||||
for _, restore := range restores {
|
||||
if restore.Status == restores_enums.RestoreStatusCompleted {
|
||||
return restore
|
||||
}
|
||||
if restore.Status == restores_enums.RestoreStatusFailed {
|
||||
t.Fatalf("Restore failed: %v", restore.FailMessage)
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func createDatabaseViaAPI(
|
||||
t *testing.T,
|
||||
router *gin.Engine,
|
||||
name string,
|
||||
workspaceID uuid.UUID,
|
||||
pgVersion tools.PostgresqlVersion,
|
||||
host string,
|
||||
port int,
|
||||
username string,
|
||||
password string,
|
||||
database string,
|
||||
token string,
|
||||
) *databases.Database {
|
||||
request := databases.Database{
|
||||
Name: name,
|
||||
WorkspaceID: &workspaceID,
|
||||
Type: databases.DatabaseTypePostgres,
|
||||
Postgresql: &pgtypes.PostgresqlDatabase{
|
||||
Version: pgVersion,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: &database,
|
||||
},
|
||||
}
|
||||
|
||||
w := workspaces_testing.MakeAPIRequest(
|
||||
router,
|
||||
"POST",
|
||||
"/api/v1/databases/create",
|
||||
"Bearer "+token,
|
||||
request,
|
||||
)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("Failed to create database. Status: %d, Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var createdDatabase databases.Database
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &createdDatabase); err != nil {
|
||||
t.Fatalf("Failed to unmarshal database response: %v", err)
|
||||
}
|
||||
|
||||
return &createdDatabase
|
||||
}
|
||||
|
||||
func enableBackupsViaAPI(
|
||||
t *testing.T,
|
||||
router *gin.Engine,
|
||||
databaseID uuid.UUID,
|
||||
storageID uuid.UUID,
|
||||
encryption backups_config.BackupEncryption,
|
||||
token string,
|
||||
) {
|
||||
var backupConfig backups_config.BackupConfig
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/backup-configs/database/%s", databaseID.String()),
|
||||
"Bearer "+token,
|
||||
http.StatusOK,
|
||||
&backupConfig,
|
||||
)
|
||||
|
||||
storage := &storages.Storage{ID: storageID}
|
||||
backupConfig.IsBackupsEnabled = true
|
||||
backupConfig.Storage = storage
|
||||
backupConfig.Encryption = encryption
|
||||
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backup-configs/save",
|
||||
"Bearer "+token,
|
||||
backupConfig,
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
|
||||
func createBackupViaAPI(
|
||||
t *testing.T,
|
||||
router *gin.Engine,
|
||||
databaseID uuid.UUID,
|
||||
token string,
|
||||
) {
|
||||
request := backups.MakeBackupRequest{DatabaseID: databaseID}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/backups",
|
||||
"Bearer "+token,
|
||||
request,
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
|
||||
func createRestoreViaAPI(
|
||||
t *testing.T,
|
||||
router *gin.Engine,
|
||||
backupID uuid.UUID,
|
||||
pgVersion tools.PostgresqlVersion,
|
||||
host string,
|
||||
port int,
|
||||
username string,
|
||||
password string,
|
||||
database string,
|
||||
token string,
|
||||
) {
|
||||
request := restores.RestoreBackupRequest{
|
||||
PostgresqlDatabase: &pgtypes.PostgresqlDatabase{
|
||||
Version: pgVersion,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: &database,
|
||||
},
|
||||
}
|
||||
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/restores/%s/restore", backupID.String()),
|
||||
"Bearer "+token,
|
||||
request,
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
|
||||
// verifyDataIntegrity compares data in the original and restored databases
|
||||
func verifyDataIntegrity(t *testing.T, originalDB *sqlx.DB, restoredDB *sqlx.DB) {
|
||||
var originalData []TestDataItem
|
||||
var restoredData []TestDataItem
|
||||
@@ -382,7 +516,6 @@ func verifyDataIntegrity(t *testing.T, originalDB *sqlx.DB, restoredDB *sqlx.DB)
|
||||
|
||||
assert.Equal(t, len(originalData), len(restoredData), "Should have same number of rows")
|
||||
|
||||
// Only compare data if both slices have elements (to avoid panic)
|
||||
if len(originalData) > 0 && len(restoredData) > 0 {
|
||||
for i := range originalData {
|
||||
assert.Equal(t, originalData[i].ID, restoredData[i].ID, "ID should match")
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
package users_repositories
|
||||
|
||||
var secretKeyRepository = &SecretKeyRepository{}
|
||||
var userRepository = &UserRepository{}
|
||||
var usersSettingsRepository = &UsersSettingsRepository{}
|
||||
|
||||
func GetSecretKeyRepository() *SecretKeyRepository {
|
||||
return secretKeyRepository
|
||||
}
|
||||
|
||||
func GetUserRepository() *UserRepository {
|
||||
return userRepository
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package users_repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
user_models "postgresus-backend/internal/features/users/models"
|
||||
"postgresus-backend/internal/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SecretKeyRepository struct{}
|
||||
|
||||
func (r *SecretKeyRepository) GetSecretKey() (string, error) {
|
||||
var secretKey user_models.SecretKey
|
||||
|
||||
if err := storage.GetDb().First(&secretKey).Error; err != nil {
|
||||
// create a new secret key if not found
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
newSecretKey := user_models.SecretKey{
|
||||
Secret: uuid.New().String() + uuid.New().String(),
|
||||
}
|
||||
if err := storage.GetDb().Create(&newSecretKey).Error; err != nil {
|
||||
return "", errors.New("failed to create new secret key")
|
||||
}
|
||||
|
||||
return newSecretKey.Secret, nil
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
return secretKey.Secret, nil
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package users_services
|
||||
|
||||
import users_repositories "postgresus-backend/internal/features/users/repositories"
|
||||
import (
|
||||
"postgresus-backend/internal/features/encryption/secrets"
|
||||
users_repositories "postgresus-backend/internal/features/users/repositories"
|
||||
)
|
||||
|
||||
var userService = &UserService{
|
||||
users_repositories.GetUserRepository(),
|
||||
users_repositories.GetSecretKeyRepository(),
|
||||
secrets.GetSecretKeyService(),
|
||||
settingsService,
|
||||
nil,
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"golang.org/x/oauth2/google"
|
||||
|
||||
"postgresus-backend/internal/config"
|
||||
"postgresus-backend/internal/features/encryption/secrets"
|
||||
users_dto "postgresus-backend/internal/features/users/dto"
|
||||
users_enums "postgresus-backend/internal/features/users/enums"
|
||||
users_interfaces "postgresus-backend/internal/features/users/interfaces"
|
||||
@@ -25,10 +26,10 @@ import (
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
userRepository *users_repositories.UserRepository
|
||||
secretKeyRepository *users_repositories.SecretKeyRepository
|
||||
settingsService *SettingsService
|
||||
auditLogWriter users_interfaces.AuditLogWriter
|
||||
userRepository *users_repositories.UserRepository
|
||||
secretKeyService *secrets.SecretKeyService
|
||||
settingsService *SettingsService
|
||||
auditLogWriter users_interfaces.AuditLogWriter
|
||||
}
|
||||
|
||||
func (s *UserService) SetAuditLogWriter(writer users_interfaces.AuditLogWriter) {
|
||||
@@ -162,7 +163,7 @@ func (s *UserService) SignIn(
|
||||
}
|
||||
|
||||
func (s *UserService) GetUserFromToken(token string) (*users_models.User, error) {
|
||||
secretKey, err := s.secretKeyRepository.GetSecretKey()
|
||||
secretKey, err := s.secretKeyService.GetSecretKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get secret key: %w", err)
|
||||
}
|
||||
@@ -221,7 +222,7 @@ func (s *UserService) GetUserFromToken(token string) (*users_models.User, error)
|
||||
func (s *UserService) GenerateAccessToken(
|
||||
user *users_models.User,
|
||||
) (*users_dto.SignInResponseDTO, error) {
|
||||
secretKey, err := s.secretKeyRepository.GetSecretKey()
|
||||
secretKey, err := s.secretKeyService.GetSecretKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get secret key: %w", err)
|
||||
}
|
||||
@@ -309,15 +310,6 @@ func (s *UserService) ChangeUserPasswordByEmail(email string, newPassword string
|
||||
}
|
||||
|
||||
func (s *UserService) ChangeUserPassword(userID uuid.UUID, newPassword string) error {
|
||||
user, err := s.userRepository.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
if !user.HasPassword() {
|
||||
return errors.New("user has no password set")
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash new password: %w", err)
|
||||
|
||||
11
backend/internal/util/encryption/di.go
Normal file
11
backend/internal/util/encryption/di.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package encryption
|
||||
|
||||
import "postgresus-backend/internal/features/encryption/secrets"
|
||||
|
||||
var fieldEncryptor = &SecretKeyFieldEncryptor{
|
||||
secrets.GetSecretKeyService(),
|
||||
}
|
||||
|
||||
func GetFieldEncryptor() FieldEncryptor {
|
||||
return fieldEncryptor
|
||||
}
|
||||
15
backend/internal/util/encryption/field_encryptor.go
Normal file
15
backend/internal/util/encryption/field_encryptor.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package encryption
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type FieldEncryptor interface {
|
||||
// Encrypt encrypts a plaintext string and returns an encrypted string.
|
||||
// If the string is already encrypted, returns it as-is.
|
||||
// Empty strings are returned unchanged.
|
||||
Encrypt(itemID uuid.UUID, plaintext string) (string, error)
|
||||
|
||||
// Decrypt decrypts an encrypted string and returns a plaintext string.
|
||||
// If the string is not encrypted, returns it as-is.
|
||||
// Empty strings are returned unchanged.
|
||||
Decrypt(itemID uuid.UUID, ciphertext string) (string, error)
|
||||
}
|
||||
120
backend/internal/util/encryption/secret_key_field_encryptor.go
Normal file
120
backend/internal/util/encryption/secret_key_field_encryptor.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"postgresus-backend/internal/features/encryption/secrets"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const encryptedPrefix = "enc:"
|
||||
|
||||
type SecretKeyFieldEncryptor struct {
|
||||
secretKeyService *secrets.SecretKeyService
|
||||
}
|
||||
|
||||
func (e *SecretKeyFieldEncryptor) Encrypt(itemID uuid.UUID, plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if e.isEncrypted(plaintext) {
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
masterKey, err := e.secretKeyService.GetSecretKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get master key: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher([]byte(masterKey)[:32])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonce := e.deriveNonce(itemID, masterKey, gcm.NonceSize())
|
||||
|
||||
ciphertext := gcm.Seal(nil, nonce, []byte(plaintext), nil)
|
||||
|
||||
nonceBase64 := base64.StdEncoding.EncodeToString(nonce)
|
||||
ciphertextBase64 := base64.StdEncoding.EncodeToString(ciphertext)
|
||||
|
||||
return fmt.Sprintf("%s%s:%s", encryptedPrefix, nonceBase64, ciphertextBase64), nil
|
||||
}
|
||||
|
||||
func (e *SecretKeyFieldEncryptor) Decrypt(itemID uuid.UUID, ciphertext string) (string, error) {
|
||||
if ciphertext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if !e.isEncrypted(ciphertext) {
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(ciphertext, ":", 3)
|
||||
if len(parts) != 3 {
|
||||
return "", errors.New("invalid encrypted format")
|
||||
}
|
||||
|
||||
nonceBase64 := parts[1]
|
||||
ciphertextBase64 := parts[2]
|
||||
|
||||
nonce, err := base64.StdEncoding.DecodeString(nonceBase64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode nonce: %w", err)
|
||||
}
|
||||
|
||||
encryptedData, err := base64.StdEncoding.DecodeString(ciphertextBase64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode ciphertext: %w", err)
|
||||
}
|
||||
|
||||
masterKey, err := e.secretKeyService.GetSecretKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get master key: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher([]byte(masterKey)[:32])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := gcm.Open(nil, nonce, encryptedData, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func (e *SecretKeyFieldEncryptor) isEncrypted(value string) bool {
|
||||
return strings.HasPrefix(value, encryptedPrefix)
|
||||
}
|
||||
|
||||
func (e *SecretKeyFieldEncryptor) deriveNonce(
|
||||
itemID uuid.UUID,
|
||||
masterKey string,
|
||||
nonceSize int,
|
||||
) []byte {
|
||||
h := hmac.New(sha256.New, []byte(masterKey))
|
||||
h.Write(itemID[:])
|
||||
hash := h.Sum(nil)
|
||||
return hash[:nonceSize]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_Encrypt_Decrypt_RoundTrip(t *testing.T) {
|
||||
encryptor := GetFieldEncryptor()
|
||||
itemID := uuid.New()
|
||||
plaintext := "my-secret-password"
|
||||
|
||||
encrypted, err := encryptor.Encrypt(itemID, plaintext)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, encrypted)
|
||||
assert.NotEqual(t, plaintext, encrypted)
|
||||
assert.Contains(t, encrypted, "enc:")
|
||||
|
||||
decrypted, err := encryptor.Decrypt(itemID, encrypted)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
}
|
||||
|
||||
func Test_Encrypt_EmptyString_ReturnsEmpty(t *testing.T) {
|
||||
encryptor := GetFieldEncryptor()
|
||||
itemID := uuid.New()
|
||||
|
||||
encrypted, err := encryptor.Encrypt(itemID, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, encrypted)
|
||||
}
|
||||
|
||||
func Test_Decrypt_EmptyString_ReturnsEmpty(t *testing.T) {
|
||||
encryptor := GetFieldEncryptor()
|
||||
itemID := uuid.New()
|
||||
|
||||
decrypted, err := encryptor.Decrypt(itemID, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, decrypted)
|
||||
}
|
||||
|
||||
func Test_Decrypt_PlaintextValue_ReturnsAsIs(t *testing.T) {
|
||||
encryptor := GetFieldEncryptor()
|
||||
itemID := uuid.New()
|
||||
plaintext := "not-encrypted-password"
|
||||
|
||||
decrypted, err := encryptor.Decrypt(itemID, plaintext)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
}
|
||||
|
||||
func Test_Encrypt_DetectsAlreadyEncryptedFormat(t *testing.T) {
|
||||
encryptor := GetFieldEncryptor()
|
||||
itemID := uuid.New()
|
||||
alreadyEncrypted := "enc:nonce:ciphertext"
|
||||
|
||||
result, err := encryptor.Encrypt(itemID, alreadyEncrypted)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, alreadyEncrypted, result)
|
||||
}
|
||||
|
||||
func Test_Encrypt_SamePlaintext_DifferentItemIDs_ProducesDifferentCiphertext(t *testing.T) {
|
||||
encryptor := GetFieldEncryptor()
|
||||
plaintext := "shared-secret"
|
||||
itemID1 := uuid.New()
|
||||
itemID2 := uuid.New()
|
||||
|
||||
encrypted1, err := encryptor.Encrypt(itemID1, plaintext)
|
||||
assert.NoError(t, err)
|
||||
|
||||
encrypted2, err := encryptor.Encrypt(itemID2, plaintext)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, encrypted1, encrypted2)
|
||||
|
||||
decrypted1, err := encryptor.Decrypt(itemID1, encrypted1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted1)
|
||||
|
||||
decrypted2, err := encryptor.Decrypt(itemID2, encrypted2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted2)
|
||||
}
|
||||
|
||||
func Test_Encrypt_AlreadyEncrypted_ReturnsAsIs(t *testing.T) {
|
||||
encryptor := GetFieldEncryptor()
|
||||
itemID := uuid.New()
|
||||
plaintext := "my-password"
|
||||
|
||||
encrypted1, err := encryptor.Encrypt(itemID, plaintext)
|
||||
assert.NoError(t, err)
|
||||
|
||||
encrypted2, err := encryptor.Encrypt(itemID, encrypted1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, encrypted1, encrypted2)
|
||||
}
|
||||
|
||||
func Test_Decrypt_MalformedData_ReturnsError(t *testing.T) {
|
||||
encryptor := GetFieldEncryptor()
|
||||
itemID := uuid.New()
|
||||
|
||||
_, err := encryptor.Decrypt(itemID, "enc:invalid")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = encryptor.Decrypt(itemID, "enc:invalid:invalid-base64")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_EncryptedFormat_ContainsPrefix(t *testing.T) {
|
||||
encryptor := GetFieldEncryptor()
|
||||
itemID := uuid.New()
|
||||
plaintext := "test-secret"
|
||||
|
||||
encrypted, err := encryptor.Encrypt(itemID, plaintext)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, encrypted, "enc:")
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
env_utils "postgresus-backend/internal/util/env"
|
||||
)
|
||||
@@ -151,6 +152,24 @@ func VerifyPostgresesInstallation(
|
||||
logger.Info("All PostgreSQL version-specific client tools verification completed successfully!")
|
||||
}
|
||||
|
||||
// EscapePgpassField escapes special characters in a field value for .pgpass file format.
|
||||
// According to PostgreSQL documentation, the .pgpass file format requires:
|
||||
// - Backslash (\) must be escaped as \\
|
||||
// - Colon (:) must be escaped as \:
|
||||
// Additionally, newlines and carriage returns are removed to prevent format corruption.
|
||||
func EscapePgpassField(field string) string {
|
||||
// Remove newlines and carriage returns that would break .pgpass format
|
||||
field = strings.ReplaceAll(field, "\r", "")
|
||||
field = strings.ReplaceAll(field, "\n", "")
|
||||
|
||||
// Escape backslashes first (order matters!)
|
||||
// Then escape colons
|
||||
field = strings.ReplaceAll(field, "\\", "\\\\")
|
||||
field = strings.ReplaceAll(field, ":", "\\:")
|
||||
|
||||
return field
|
||||
}
|
||||
|
||||
func getPostgresqlBasePath(
|
||||
version PostgresqlVersion,
|
||||
envMode env_utils.EnvMode,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE postgresql_databases
|
||||
DROP CONSTRAINT IF EXISTS fk_postgresql_databases_restore_id;
|
||||
|
||||
DROP INDEX IF EXISTS idx_postgresql_databases_restore_id;
|
||||
|
||||
ALTER TABLE postgresql_databases
|
||||
DROP COLUMN IF EXISTS restore_id;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE postgresql_databases
|
||||
ADD COLUMN restore_id UUID;
|
||||
|
||||
CREATE INDEX idx_postgresql_databases_restore_id ON postgresql_databases (restore_id);
|
||||
|
||||
ALTER TABLE postgresql_databases
|
||||
ADD CONSTRAINT fk_postgresql_databases_restore_id
|
||||
FOREIGN KEY (restore_id)
|
||||
REFERENCES restores (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -1,4 +1,4 @@
|
||||
import { App as AntdApp, ConfigProvider } from 'antd';
|
||||
import { App as AntdApp, ConfigProvider, theme } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Route } from 'react-router';
|
||||
import { Routes } from 'react-router';
|
||||
@@ -7,10 +7,12 @@ import { userApi } from './entity/users';
|
||||
import { AuthPageComponent } from './pages/AuthPageComponent';
|
||||
import { OAuthCallbackPage } from './pages/OAuthCallbackPage';
|
||||
import { OauthStorageComponent } from './pages/OauthStorageComponent';
|
||||
import { ThemeProvider, useTheme } from './shared/theme';
|
||||
import { MainScreenComponent } from './widgets/main/MainScreenComponent';
|
||||
|
||||
function App() {
|
||||
function AppContent() {
|
||||
const [isAuthorized, setIsAuthorized] = useState(false);
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const isAuthorized = userApi.isAuthorized();
|
||||
@@ -24,6 +26,7 @@ function App() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: resolvedTheme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#155dfc', // Tailwind blue-600
|
||||
},
|
||||
@@ -45,4 +48,12 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getApplicationServer } from '../../../constants';
|
||||
import RequestOptions from '../../../shared/api/RequestOptions';
|
||||
import { apiHelper } from '../../../shared/api/apiHelper';
|
||||
import type { CreateReadOnlyUserResponse } from '../model/CreateReadOnlyUserResponse';
|
||||
import type { Database } from '../model/Database';
|
||||
import type { IsReadOnlyResponse } from '../model/IsReadOnlyResponse';
|
||||
|
||||
export const databaseApi = {
|
||||
async createDatabase(database: Database) {
|
||||
@@ -85,4 +87,22 @@ export const databaseApi = {
|
||||
)
|
||||
.then((res) => res.isUsing);
|
||||
},
|
||||
|
||||
async isUserReadOnly(database: Database) {
|
||||
const requestOptions: RequestOptions = new RequestOptions();
|
||||
requestOptions.setBody(JSON.stringify(database));
|
||||
return apiHelper.fetchPostJson<IsReadOnlyResponse>(
|
||||
`${getApplicationServer()}/api/v1/databases/is-readonly`,
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
|
||||
async createReadOnlyUser(database: Database) {
|
||||
const requestOptions: RequestOptions = new RequestOptions();
|
||||
requestOptions.setBody(JSON.stringify(database));
|
||||
return apiHelper.fetchPostJson<CreateReadOnlyUserResponse>(
|
||||
`${getApplicationServer()}/api/v1/databases/create-readonly-user`,
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,3 +4,5 @@ export { DatabaseType } from './model/DatabaseType';
|
||||
export { Period } from './model/Period';
|
||||
export { type PostgresqlDatabase } from './model/postgresql/PostgresqlDatabase';
|
||||
export { PostgresqlVersion } from './model/postgresql/PostgresqlVersion';
|
||||
export { type IsReadOnlyResponse } from './model/IsReadOnlyResponse';
|
||||
export { type CreateReadOnlyUserResponse } from './model/CreateReadOnlyUserResponse';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface CreateReadOnlyUserResponse {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IsReadOnlyResponse {
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
@@ -281,6 +281,163 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [hasMore, isLoadingMore, currentLimit, scrollContainerRef]);
|
||||
|
||||
const renderStatus = (status: BackupStatus, record: Backup) => {
|
||||
if (status === BackupStatus.FAILED) {
|
||||
return (
|
||||
<Tooltip title="Click to see error details">
|
||||
<div
|
||||
className="flex cursor-pointer items-center text-red-600 underline"
|
||||
onClick={() => setShowingBackupError(record)}
|
||||
>
|
||||
<ExclamationCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
|
||||
<div>Failed</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === BackupStatus.COMPLETED) {
|
||||
return (
|
||||
<div className="flex items-center text-green-600">
|
||||
<CheckCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
|
||||
<div>Successful</div>
|
||||
{record.encryption === BackupEncryption.ENCRYPTED && (
|
||||
<Tooltip title="Encrypted">
|
||||
<LockOutlined className="ml-1" style={{ fontSize: 14 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === BackupStatus.DELETED) {
|
||||
return (
|
||||
<div className="flex items-center text-gray-600">
|
||||
<DeleteOutlined className="mr-2" style={{ fontSize: 16 }} />
|
||||
<div>Deleted</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === BackupStatus.IN_PROGRESS) {
|
||||
return (
|
||||
<div className="flex items-center font-bold text-blue-600">
|
||||
<SyncOutlined spin />
|
||||
<span className="ml-2">In progress</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === BackupStatus.CANCELED) {
|
||||
return (
|
||||
<div className="flex items-center text-gray-600">
|
||||
<CloseCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
|
||||
<div>Canceled</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="font-bold">{status}</span>;
|
||||
};
|
||||
|
||||
const renderActions = (record: Backup) => {
|
||||
return (
|
||||
<div className="flex gap-2 text-lg">
|
||||
{record.status === BackupStatus.IN_PROGRESS && isCanManageDBs && (
|
||||
<div className="flex gap-2">
|
||||
{cancellingBackupId === record.id ? (
|
||||
<SyncOutlined spin />
|
||||
) : (
|
||||
<Tooltip title="Cancel backup">
|
||||
<CloseCircleOutlined
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (cancellingBackupId) return;
|
||||
cancelBackup(record.id);
|
||||
}}
|
||||
style={{ color: '#ff0000', opacity: cancellingBackupId ? 0.2 : 1 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{record.status === BackupStatus.COMPLETED && (
|
||||
<div className="flex gap-2">
|
||||
{deletingBackupId === record.id ? (
|
||||
<SyncOutlined spin />
|
||||
) : (
|
||||
<>
|
||||
{isCanManageDBs && (
|
||||
<Tooltip title="Delete backup">
|
||||
<DeleteOutlined
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (deletingBackupId) return;
|
||||
setDeleteConfimationId(record.id);
|
||||
}}
|
||||
style={{ color: '#ff0000', opacity: deletingBackupId ? 0.2 : 1 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title="Restore from backup">
|
||||
<CloudUploadOutlined
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setShowingRestoresBackupId(record.id);
|
||||
}}
|
||||
style={{
|
||||
color: '#155dfc',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Download backup file. It can be restored manually via pg_restore (from custom format)">
|
||||
{downloadingBackupId === record.id ? (
|
||||
<SyncOutlined spin style={{ color: '#155dfc' }} />
|
||||
) : (
|
||||
<DownloadOutlined
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (downloadingBackupId) return;
|
||||
setDownloadingBackupId(record.id);
|
||||
}}
|
||||
style={{
|
||||
opacity: downloadingBackupId ? 0.2 : 1,
|
||||
color: '#155dfc',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatSize = (sizeMb: number) => {
|
||||
if (sizeMb >= 1024) {
|
||||
const sizeGb = sizeMb / 1024;
|
||||
return `${Number(sizeGb.toFixed(2)).toLocaleString()} GB`;
|
||||
}
|
||||
return `${Number(sizeMb?.toFixed(2)).toLocaleString()} MB`;
|
||||
};
|
||||
|
||||
const formatDuration = (durationMs: number) => {
|
||||
const hours = Math.floor(durationMs / 3600000);
|
||||
const minutes = Math.floor((durationMs % 3600000) / 60000);
|
||||
const seconds = Math.floor((durationMs % 60000) / 1000);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
return `${minutes}m ${seconds}s`;
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Backup> = [
|
||||
{
|
||||
title: 'Created at',
|
||||
@@ -289,7 +446,9 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
|
||||
render: (createdAt: string) => (
|
||||
<div>
|
||||
{dayjs.utc(createdAt).local().format(getUserTimeFormat().format)} <br />
|
||||
<span className="text-gray-500">({dayjs.utc(createdAt).local().fromNow()})</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
({dayjs.utc(createdAt).local().fromNow()})
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
sorter: (a, b) => dayjs(a.createdAt).unix() - dayjs(b.createdAt).unix(),
|
||||
@@ -299,66 +458,7 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: BackupStatus, record: Backup) => {
|
||||
if (status === BackupStatus.FAILED) {
|
||||
return (
|
||||
<Tooltip title="Click to see error details">
|
||||
<div
|
||||
className="flex cursor-pointer items-center text-red-600 underline"
|
||||
onClick={() => setShowingBackupError(record)}
|
||||
>
|
||||
<ExclamationCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
|
||||
|
||||
<div>Failed</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === BackupStatus.COMPLETED) {
|
||||
return (
|
||||
<div className="flex items-center text-green-600">
|
||||
<CheckCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
|
||||
<div>Successful</div>
|
||||
|
||||
{record.encryption === BackupEncryption.ENCRYPTED && (
|
||||
<Tooltip title="Encrypted">
|
||||
<LockOutlined className="ml-1" style={{ fontSize: 14 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === BackupStatus.DELETED) {
|
||||
return (
|
||||
<div className="flex items-center text-gray-600">
|
||||
<DeleteOutlined className="mr-2" style={{ fontSize: 16 }} />
|
||||
<div>Deleted</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === BackupStatus.IN_PROGRESS) {
|
||||
return (
|
||||
<div className="flex items-center font-bold text-blue-600">
|
||||
<SyncOutlined spin />
|
||||
<span className="ml-2">In progress</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === BackupStatus.CANCELED) {
|
||||
return (
|
||||
<div className="flex items-center text-gray-600">
|
||||
<CloseCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
|
||||
<div>Canceled</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="font-bold">{status}</span>;
|
||||
},
|
||||
render: (status: BackupStatus, record: Backup) => renderStatus(status, record),
|
||||
filters: [
|
||||
{
|
||||
value: BackupStatus.IN_PROGRESS,
|
||||
@@ -398,112 +498,20 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
|
||||
dataIndex: 'backupSizeMb',
|
||||
key: 'backupSizeMb',
|
||||
width: 150,
|
||||
render: (sizeMb: number) => {
|
||||
if (sizeMb >= 1024) {
|
||||
const sizeGb = sizeMb / 1024;
|
||||
return `${Number(sizeGb.toFixed(2)).toLocaleString()} GB`;
|
||||
}
|
||||
return `${Number(sizeMb?.toFixed(2)).toLocaleString()} MB`;
|
||||
},
|
||||
render: (sizeMb: number) => formatSize(sizeMb),
|
||||
},
|
||||
{
|
||||
title: 'Duration',
|
||||
dataIndex: 'backupDurationMs',
|
||||
key: 'backupDurationMs',
|
||||
width: 150,
|
||||
render: (durationMs: number) => {
|
||||
const hours = Math.floor(durationMs / 3600000);
|
||||
const minutes = Math.floor((durationMs % 3600000) / 60000);
|
||||
const seconds = Math.floor((durationMs % 60000) / 1000);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
return `${minutes}m ${seconds}s`;
|
||||
},
|
||||
render: (durationMs: number) => formatDuration(durationMs),
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
dataIndex: '',
|
||||
key: '',
|
||||
render: (_, record: Backup) => {
|
||||
return (
|
||||
<div className="flex gap-2 text-lg">
|
||||
{record.status === BackupStatus.IN_PROGRESS && isCanManageDBs && (
|
||||
<div className="flex gap-2">
|
||||
{cancellingBackupId === record.id ? (
|
||||
<SyncOutlined spin />
|
||||
) : (
|
||||
<Tooltip title="Cancel backup">
|
||||
<CloseCircleOutlined
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (cancellingBackupId) return;
|
||||
cancelBackup(record.id);
|
||||
}}
|
||||
style={{ color: '#ff0000', opacity: cancellingBackupId ? 0.2 : 1 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{record.status === BackupStatus.COMPLETED && (
|
||||
<div className="flex gap-2">
|
||||
{deletingBackupId === record.id ? (
|
||||
<SyncOutlined spin />
|
||||
) : (
|
||||
<>
|
||||
{isCanManageDBs && (
|
||||
<Tooltip title="Delete backup">
|
||||
<DeleteOutlined
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (deletingBackupId) return;
|
||||
setDeleteConfimationId(record.id);
|
||||
}}
|
||||
style={{ color: '#ff0000', opacity: deletingBackupId ? 0.2 : 1 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title="Restore from backup">
|
||||
<CloudUploadOutlined
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setShowingRestoresBackupId(record.id);
|
||||
}}
|
||||
style={{
|
||||
color: '#155dfc',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Download backup file. It can be restored manually via pg_restore (from custom format)">
|
||||
{downloadingBackupId === record.id ? (
|
||||
<SyncOutlined spin style={{ color: '#155dfc' }} />
|
||||
) : (
|
||||
<DownloadOutlined
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (downloadingBackupId) return;
|
||||
setDownloadingBackupId(record.id);
|
||||
}}
|
||||
style={{
|
||||
opacity: downloadingBackupId ? 0.2 : 1,
|
||||
color: '#155dfc',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
render: (_, record: Backup) => renderActions(record),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -516,11 +524,11 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 w-full rounded-md bg-white p-5 shadow">
|
||||
<h2 className="text-xl font-bold">Backups</h2>
|
||||
<div className="mt-5 w-full rounded-md bg-white p-3 shadow md:p-5 dark:bg-gray-800">
|
||||
<h2 className="text-lg font-bold md:text-xl dark:text-white">Backups</h2>
|
||||
|
||||
{!isBackupConfigLoading && !backupConfig?.isBackupsEnabled && (
|
||||
<div className="text-red-600">
|
||||
<div className="text-sm text-red-600 md:text-base">
|
||||
Scheduled backups are disabled (you can enable it back in the backup configuration)
|
||||
</div>
|
||||
)}
|
||||
@@ -535,30 +543,98 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
|
||||
disabled={isMakeBackupRequestLoading}
|
||||
loading={isMakeBackupRequestLoading}
|
||||
>
|
||||
Make backup right now
|
||||
<span className="md:hidden">Backup now</span>
|
||||
<span className="hidden md:inline">Make backup right now</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 max-w-[850px]">
|
||||
<Table
|
||||
bordered
|
||||
columns={columns}
|
||||
dataSource={backups}
|
||||
rowKey="id"
|
||||
loading={isBackupsLoading}
|
||||
size="small"
|
||||
pagination={false}
|
||||
/>
|
||||
{isLoadingMore && (
|
||||
<div className="mt-2 flex justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && backups.length > 0 && (
|
||||
<div className="mt-2 text-center text-gray-500">
|
||||
All backups loaded ({totalBackups} total)
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 w-full md:max-w-[850px]">
|
||||
{/* Mobile card view */}
|
||||
<div className="md:hidden">
|
||||
{isBackupsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="mb-2 rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Created at</div>
|
||||
<div className="text-sm font-medium">
|
||||
{dayjs.utc(backup.createdAt).local().format(getUserTimeFormat().format)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
({dayjs.utc(backup.createdAt).local().fromNow()})
|
||||
</div>
|
||||
</div>
|
||||
<div>{renderStatus(backup.status, backup)}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Size</div>
|
||||
<div className="text-sm font-medium">{formatSize(backup.backupSizeMb)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Duration</div>
|
||||
<div className="text-sm font-medium">
|
||||
{formatDuration(backup.backupDurationMs)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end border-t border-gray-200 pt-3">
|
||||
{renderActions(backup)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoadingMore && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && backups.length > 0 && (
|
||||
<div className="mt-3 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
All backups loaded ({totalBackups} total)
|
||||
</div>
|
||||
)}
|
||||
{!isBackupsLoading && backups.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500 dark:text-gray-400">No backups yet</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop table view */}
|
||||
<div className="hidden md:block">
|
||||
<Table
|
||||
bordered
|
||||
columns={columns}
|
||||
dataSource={backups}
|
||||
rowKey="id"
|
||||
loading={isBackupsLoading}
|
||||
size="small"
|
||||
pagination={false}
|
||||
/>
|
||||
{isLoadingMore && (
|
||||
<div className="mt-2 flex justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && backups.length > 0 && (
|
||||
<div className="mt-2 text-center text-gray-500 dark:text-gray-400">
|
||||
All backups loaded ({totalBackups} total)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteConfimationId && (
|
||||
|
||||
@@ -204,8 +204,8 @@ export const EditBackupConfigComponent = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backups enabled</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Backups enabled</div>
|
||||
<Switch
|
||||
checked={backupConfig.isBackupsEnabled}
|
||||
onChange={(checked) => {
|
||||
@@ -217,13 +217,13 @@ export const EditBackupConfigComponent = ({
|
||||
|
||||
{backupConfig.isBackupsEnabled && (
|
||||
<>
|
||||
<div className="mt-4 mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup interval</div>
|
||||
<div className="mt-4 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Backup interval</div>
|
||||
<Select
|
||||
value={backupInterval?.interval}
|
||||
onChange={(v) => saveInterval({ interval: v })}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
className="w-full max-w-[200px] grow"
|
||||
options={[
|
||||
{ label: 'Hourly', value: IntervalType.HOURLY },
|
||||
{ label: 'Daily', value: IntervalType.DAILY },
|
||||
@@ -234,8 +234,8 @@ export const EditBackupConfigComponent = ({
|
||||
</div>
|
||||
|
||||
{backupInterval?.interval === IntervalType.WEEKLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup weekday</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Backup weekday</div>
|
||||
<Select
|
||||
value={displayedWeekday}
|
||||
onChange={(localWeekday) => {
|
||||
@@ -244,15 +244,15 @@ export const EditBackupConfigComponent = ({
|
||||
saveInterval({ weekday: getUtcWeekday(localWeekday, ref) });
|
||||
}}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
className="w-full max-w-[200px] grow"
|
||||
options={weekdayOptions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupInterval?.interval === IntervalType.MONTHLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup day of month</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Backup day of month</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={31}
|
||||
@@ -263,21 +263,21 @@ export const EditBackupConfigComponent = ({
|
||||
saveInterval({ dayOfMonth: getUtcDayOfMonth(localDom, ref) });
|
||||
}}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
className="w-full max-w-[200px] grow"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupInterval?.interval !== IntervalType.HOURLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup time of day</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Backup time of day</div>
|
||||
<TimePicker
|
||||
value={localTime}
|
||||
format={timeFormat.format}
|
||||
use12Hours={timeFormat.use12Hours}
|
||||
allowClear={false}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
className="w-full max-w-[200px] grow"
|
||||
onChange={(t) => {
|
||||
if (!t) return;
|
||||
const patch: Partial<Interval> = { timeOfDay: t.utc().format('HH:mm') };
|
||||
@@ -295,156 +295,168 @@ export const EditBackupConfigComponent = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Retry backup if failed</div>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={backupConfig.isRetryIfFailed}
|
||||
onChange={(checked) => updateBackupConfig({ isRetryIfFailed: checked })}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Automatically retry failed backups. Backups can fail due to network failures, storage issues or temporary database unavailability."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{backupConfig.isRetryIfFailed && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Max failed tries count</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={backupConfig.maxFailedTriesCount}
|
||||
onChange={(value) => updateBackupConfig({ maxFailedTriesCount: value || 1 })}
|
||||
<div className="mt-4 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Retry backup if failed</div>
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
checked={backupConfig.isRetryIfFailed}
|
||||
onChange={(checked) => updateBackupConfig({ isRetryIfFailed: checked })}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Maximum number of retry attempts for failed backups. You will receive a notification when all tries have failed."
|
||||
title="Automatically retry failed backups. Backups can fail due to network failures, storage issues or temporary database unavailability."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">CPU count</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={16}
|
||||
value={backupConfig.cpuCount}
|
||||
onChange={(value) => updateBackupConfig({ cpuCount: value || 1 })}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Number of CPU cores to use for restore processing. Higher values may speed up restores, but use more resources."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Store period</div>
|
||||
<Select
|
||||
value={backupConfig.storePeriod}
|
||||
onChange={(v) => updateBackupConfig({ storePeriod: v })}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
options={[
|
||||
{ label: '1 day', value: Period.DAY },
|
||||
{ label: '1 week', value: Period.WEEK },
|
||||
{ label: '1 month', value: Period.MONTH },
|
||||
{ label: '3 months', value: Period.THREE_MONTH },
|
||||
{ label: '6 months', value: Period.SIX_MONTH },
|
||||
{ label: '1 year', value: Period.YEAR },
|
||||
{ label: '2 years', value: Period.TWO_YEARS },
|
||||
{ label: '3 years', value: Period.THREE_YEARS },
|
||||
{ label: '4 years', value: Period.FOUR_YEARS },
|
||||
{ label: '5 years', value: Period.FIVE_YEARS },
|
||||
{ label: 'Forever', value: Period.FOREVER },
|
||||
]}
|
||||
/>
|
||||
{backupConfig.isRetryIfFailed && (
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Max failed tries count</div>
|
||||
<div className="flex items-center">
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={backupConfig.maxFailedTriesCount}
|
||||
onChange={(value) => updateBackupConfig({ maxFailedTriesCount: value || 1 })}
|
||||
size="small"
|
||||
className="w-full max-w-[200px] grow"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="How long to keep the backups? Make sure you have enough storage space."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Maximum number of retry attempts for failed backups. You will receive a notification when all tries have failed."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">CPU count</div>
|
||||
<div className="flex items-center">
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={16}
|
||||
value={backupConfig.cpuCount}
|
||||
onChange={(value) => updateBackupConfig({ cpuCount: value || 1 })}
|
||||
size="small"
|
||||
className="w-full max-w-[200px] grow"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Number of CPU cores to use for restore processing. Higher values may speed up restores, but use more resources."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Store period</div>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={backupConfig.storePeriod}
|
||||
onChange={(v) => updateBackupConfig({ storePeriod: v })}
|
||||
size="small"
|
||||
className="w-full max-w-[200px] grow"
|
||||
options={[
|
||||
{ label: '1 day', value: Period.DAY },
|
||||
{ label: '1 week', value: Period.WEEK },
|
||||
{ label: '1 month', value: Period.MONTH },
|
||||
{ label: '3 months', value: Period.THREE_MONTH },
|
||||
{ label: '6 months', value: Period.SIX_MONTH },
|
||||
{ label: '1 year', value: Period.YEAR },
|
||||
{ label: '2 years', value: Period.TWO_YEARS },
|
||||
{ label: '3 years', value: Period.THREE_YEARS },
|
||||
{ label: '4 years', value: Period.FOUR_YEARS },
|
||||
{ label: '5 years', value: Period.FIVE_YEARS },
|
||||
{ label: 'Forever', value: Period.FOREVER },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="How long to keep the backups? Make sure you have enough storage space."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-2 mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Storage</div>
|
||||
<Select
|
||||
value={backupConfig.storage?.id}
|
||||
onChange={(storageId) => {
|
||||
if (storageId.includes('create-new-storage')) {
|
||||
setShowCreateStorage(true);
|
||||
return;
|
||||
}
|
||||
<div className="mt-2 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Storage</div>
|
||||
<div className="flex w-full items-center">
|
||||
<Select
|
||||
value={backupConfig.storage?.id}
|
||||
onChange={(storageId) => {
|
||||
if (storageId.includes('create-new-storage')) {
|
||||
setShowCreateStorage(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedStorage = storages.find((s) => s.id === storageId);
|
||||
updateBackupConfig({ storage: selectedStorage });
|
||||
const selectedStorage = storages.find((s) => s.id === storageId);
|
||||
updateBackupConfig({ storage: selectedStorage });
|
||||
|
||||
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 && (
|
||||
<img
|
||||
src={getStorageLogoFromType(backupConfig.storage.type)}
|
||||
alt="storageIcon"
|
||||
className="ml-1 h-4 w-4"
|
||||
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 && (
|
||||
<img
|
||||
src={getStorageLogoFromType(backupConfig.storage.type)}
|
||||
alt="storageIcon"
|
||||
className="ml-1 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Encryption</div>
|
||||
<Select
|
||||
value={backupConfig.encryption}
|
||||
onChange={(v) => updateBackupConfig({ encryption: v })}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
options={[
|
||||
{ label: 'None', value: BackupEncryption.NONE },
|
||||
{ label: 'Encrypt backup files', value: BackupEncryption.ENCRYPTED },
|
||||
]}
|
||||
/>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Encryption</div>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={backupConfig.encryption}
|
||||
onChange={(v) => updateBackupConfig({ encryption: v })}
|
||||
size="small"
|
||||
className="w-full max-w-[200px] grow"
|
||||
options={[
|
||||
{ label: 'None', value: BackupEncryption.NONE },
|
||||
{ label: 'Encrypt backup files', value: BackupEncryption.ENCRYPTED },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Postgresus or download them unencrypted via the 'Download' button."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Postgresus or download them unencrypted via the 'Download' button."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{backupConfig.isBackupsEnabled && (
|
||||
<>
|
||||
<div className="mt-4 mb-1 flex w-full items-start">
|
||||
<div className="mt-1 min-w-[150px]">Notifications</div>
|
||||
<div className="mt-4 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-start">
|
||||
<div className="mt-0 mb-1 min-w-[150px] sm:mt-1 sm:mb-0">Notifications</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Checkbox
|
||||
checked={backupConfig.sendNotificationsOn.includes(
|
||||
@@ -517,7 +529,7 @@ export const EditBackupConfigComponent = ({
|
||||
open={isShowCreateStorage}
|
||||
onCancel={() => setShowCreateStorage(false)}
|
||||
>
|
||||
<div className="my-3 max-w-[275px] text-gray-500">
|
||||
<div className="my-3 max-w-[275px] text-gray-500 dark:text-gray-400">
|
||||
Storage - is a place where backups will be stored (local disk, S3, Google Drive, etc.)
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
databaseApi,
|
||||
} from '../../../entity/databases';
|
||||
import { EditBackupConfigComponent } from '../../backups';
|
||||
import { CreateReadOnlyComponent } from './edit/CreateReadOnlyComponent';
|
||||
import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent';
|
||||
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
|
||||
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
|
||||
@@ -41,9 +42,9 @@ export const CreateDatabaseComponent = ({ workspaceId, onCreated, onClose }: Pro
|
||||
sendNotificationsOn: [],
|
||||
} as Database);
|
||||
|
||||
const [step, setStep] = useState<'base-info' | 'db-settings' | 'backup-config' | 'notifiers'>(
|
||||
'base-info',
|
||||
);
|
||||
const [step, setStep] = useState<
|
||||
'base-info' | 'db-settings' | 'create-readonly-user' | 'backup-config' | 'notifiers'
|
||||
>('base-info');
|
||||
|
||||
const createDatabase = async (database: Database, backupConfig: BackupConfig) => {
|
||||
setIsCreating(true);
|
||||
@@ -97,12 +98,25 @@ export const CreateDatabaseComponent = ({ workspaceId, onCreated, onClose }: Pro
|
||||
isSaveToApi={false}
|
||||
onSaved={(database) => {
|
||||
setDatabase({ ...database });
|
||||
setStep('backup-config');
|
||||
setStep('create-readonly-user');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'create-readonly-user') {
|
||||
return (
|
||||
<CreateReadOnlyComponent
|
||||
database={database}
|
||||
onReadOnlyUserUpdated={(database) => {
|
||||
setDatabase({ ...database });
|
||||
}}
|
||||
onGoBack={() => setStep('db-settings')}
|
||||
onContinue={() => setStep('backup-config')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'backup-config') {
|
||||
return (
|
||||
<EditBackupConfigComponent
|
||||
|
||||
@@ -29,7 +29,7 @@ export const DatabaseCardComponent = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedDatabaseId === database.id ? 'bg-blue-100' : 'bg-white'}`}
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedDatabaseId === database.id ? 'bg-blue-100 dark:bg-blue-800' : 'bg-white dark:bg-gray-800'}`}
|
||||
onClick={() => setSelectedDatabaseId(database.id)}
|
||||
>
|
||||
<div className="flex">
|
||||
@@ -49,7 +49,7 @@ export const DatabaseCardComponent = ({
|
||||
</div>
|
||||
|
||||
{storage && (
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Storage: </span>
|
||||
<span className="inline-flex items-center">
|
||||
{storage.name}{' '}
|
||||
@@ -65,11 +65,13 @@ export const DatabaseCardComponent = ({
|
||||
)}
|
||||
|
||||
{database.lastBackupTime && (
|
||||
<div className="text-gray-500">Last backup {dayjs(database.lastBackupTime).fromNow()}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
Last backup {dayjs(database.lastBackupTime).fromNow()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{database.lastBackupErrorMessage && (
|
||||
<div className="mt-1 flex items-center text-sm text-red-600 underline">
|
||||
<div className="mt-1 flex items-center text-sm text-red-600 underline dark:text-red-400">
|
||||
<InfoCircleOutlined className="mr-1" style={{ color: 'red' }} />
|
||||
Has backup error
|
||||
</div>
|
||||
|
||||
@@ -51,14 +51,14 @@ export const DatabaseComponent = ({
|
||||
>
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'config' ? 'bg-white' : 'bg-gray-200'}`}
|
||||
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'config' ? 'bg-white dark:bg-gray-800' : 'bg-gray-200 dark:bg-gray-700'}`}
|
||||
onClick={() => setCurrentTab('config')}
|
||||
>
|
||||
Config
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'backups' ? 'bg-white' : 'bg-gray-200'}`}
|
||||
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'backups' ? 'bg-white dark:bg-gray-800' : 'bg-gray-200 dark:bg-gray-700'}`}
|
||||
onClick={() => setCurrentTab('backups')}
|
||||
>
|
||||
Backups
|
||||
|
||||
@@ -147,9 +147,9 @@ export const DatabaseConfigComponent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-5 shadow">
|
||||
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-3 shadow sm:p-5 dark:bg-gray-800">
|
||||
{!isEditName ? (
|
||||
<div className="mb-5 flex items-center text-2xl font-bold">
|
||||
<div className="mb-5 flex items-center text-xl font-bold sm:text-2xl">
|
||||
{database.name}
|
||||
|
||||
{isCanManageDBs && (
|
||||
@@ -162,7 +162,7 @@ export const DatabaseConfigComponent = ({
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
className="max-w-[250px]"
|
||||
className="max-w-full sm:max-w-[250px]"
|
||||
value={editDatabase?.name}
|
||||
onChange={(e) => {
|
||||
if (!editDatabase) return;
|
||||
@@ -174,7 +174,7 @@ export const DatabaseConfigComponent = ({
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<div className="ml-1 flex items-center">
|
||||
<div className="ml-1 flex flex-shrink-0 items-center">
|
||||
<Button
|
||||
type="text"
|
||||
className="flex h-6 w-6 items-center justify-center p-0"
|
||||
@@ -184,7 +184,7 @@ export const DatabaseConfigComponent = ({
|
||||
setEditDatabase(undefined);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined className="text-gray-500" />
|
||||
<CloseOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,7 +204,7 @@ export const DatabaseConfigComponent = ({
|
||||
)}
|
||||
|
||||
{database.lastBackupErrorMessage && (
|
||||
<div className="max-w-[400px] rounded border border-red-600 px-3 py-3">
|
||||
<div className="mb-4 max-w-full rounded border border-red-600 px-3 py-3 sm:max-w-[400px]">
|
||||
<div className="mt-1 flex items-center text-sm font-bold text-red-600">
|
||||
<InfoCircleOutlined className="mr-2" style={{ color: 'red' }} />
|
||||
Last backup error
|
||||
@@ -216,7 +216,7 @@ export const DatabaseConfigComponent = ({
|
||||
{database.lastBackupErrorMessage}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-gray-500">
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
To clean this error (choose any):
|
||||
<ul>
|
||||
<li>- test connection via button below (even if you updated settings);</li>
|
||||
@@ -226,8 +226,8 @@ export const DatabaseConfigComponent = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-10">
|
||||
<div className="w-[400px]">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:gap-10">
|
||||
<div className="w-full lg:w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Database settings</div>
|
||||
|
||||
@@ -260,7 +260,7 @@ export const DatabaseConfigComponent = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[400px]">
|
||||
<div className="w-full lg:w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Backup config</div>
|
||||
|
||||
@@ -299,8 +299,8 @@ export const DatabaseConfigComponent = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-10">
|
||||
<div className="w-[400px]">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:gap-10">
|
||||
<div className="w-full lg:w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Healthcheck settings</div>
|
||||
|
||||
@@ -328,7 +328,7 @@ export const DatabaseConfigComponent = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[400px]">
|
||||
<div className="w-full lg:w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Notifiers settings</div>
|
||||
|
||||
@@ -366,11 +366,10 @@ export const DatabaseConfigComponent = ({
|
||||
</div>
|
||||
|
||||
{!isEditDatabaseSpecificDataSettings && (
|
||||
<div className="mt-10">
|
||||
<div className="mt-10 flex flex-col gap-2 sm:flex-row sm:gap-0">
|
||||
<Button
|
||||
type="primary"
|
||||
className="mr-1"
|
||||
ghost
|
||||
className="w-full sm:mr-1 sm:w-auto"
|
||||
onClick={testConnection}
|
||||
loading={isTestingConnection}
|
||||
disabled={isTestingConnection}
|
||||
@@ -380,8 +379,7 @@ export const DatabaseConfigComponent = ({
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="mr-1"
|
||||
ghost
|
||||
className="w-full sm:mr-1 sm:w-auto"
|
||||
onClick={copyDatabase}
|
||||
loading={isCopying}
|
||||
disabled={isCopying}
|
||||
@@ -391,6 +389,7 @@ export const DatabaseConfigComponent = ({
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="w-full sm:w-auto"
|
||||
danger
|
||||
onClick={() => setIsShowRemoveConfirm(true)}
|
||||
ghost
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { databaseApi } from '../../../entity/databases';
|
||||
import type { Database } from '../../../entity/databases';
|
||||
import type { WorkspaceResponse } from '../../../entity/workspaces';
|
||||
import { useIsMobile } from '../../../shared/hooks';
|
||||
import { CreateDatabaseComponent } from './CreateDatabaseComponent';
|
||||
import { DatabaseCardComponent } from './DatabaseCardComponent';
|
||||
import { DatabaseComponent } from './DatabaseComponent';
|
||||
@@ -17,6 +18,7 @@ interface Props {
|
||||
const SELECTED_DATABASE_STORAGE_KEY = 'selectedDatabaseId';
|
||||
|
||||
export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }: Props) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [databases, setDatabases] = useState<Database[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -44,7 +46,8 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
|
||||
setDatabases(databases);
|
||||
if (selectDatabaseId) {
|
||||
updateSelectedDatabaseId(selectDatabaseId);
|
||||
} else if (!selectedDatabaseId && !isSilent) {
|
||||
} else if (!selectedDatabaseId && !isSilent && !isMobile) {
|
||||
// On desktop, auto-select a database; on mobile, keep it unselected
|
||||
const savedDatabaseId = localStorage.getItem(
|
||||
`${SELECTED_DATABASE_STORAGE_KEY}_${workspace.id}`,
|
||||
);
|
||||
@@ -87,66 +90,86 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
|
||||
database.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
// On mobile, show either the list or the database details
|
||||
const showDatabaseList = !isMobile || !selectedDatabaseId;
|
||||
const showDatabaseDetails = selectedDatabaseId && (!isMobile || selectedDatabaseId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex grow">
|
||||
<div
|
||||
className="mx-3 w-[250px] min-w-[250px] overflow-y-auto pr-2"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
{databases.length >= 5 && (
|
||||
<>
|
||||
{isCanManageDBs && addDatabaseButton}
|
||||
{showDatabaseList && (
|
||||
<div
|
||||
className="w-full overflow-y-auto md:mx-3 md:w-[250px] md:min-w-[250px] md:pr-2"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
{databases.length >= 5 && (
|
||||
<>
|
||||
{isCanManageDBs && addDatabaseButton}
|
||||
|
||||
<div className="mb-2">
|
||||
<input
|
||||
placeholder="Search database"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full border-b border-gray-300 p-1 text-gray-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredDatabases.length > 0
|
||||
? filteredDatabases.map((database) => (
|
||||
<DatabaseCardComponent
|
||||
key={database.id}
|
||||
database={database}
|
||||
selectedDatabaseId={selectedDatabaseId}
|
||||
setSelectedDatabaseId={updateSelectedDatabaseId}
|
||||
/>
|
||||
))
|
||||
: searchQuery && (
|
||||
<div className="mb-4 text-center text-sm text-gray-500">
|
||||
No databases found matching "{searchQuery}"
|
||||
<div className="mb-2">
|
||||
<input
|
||||
placeholder="Search database"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full border-b border-gray-300 p-1 text-gray-500 outline-none dark:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{databases.length < 5 && isCanManageDBs && addDatabaseButton}
|
||||
{filteredDatabases.length > 0
|
||||
? filteredDatabases.map((database) => (
|
||||
<DatabaseCardComponent
|
||||
key={database.id}
|
||||
database={database}
|
||||
selectedDatabaseId={selectedDatabaseId}
|
||||
setSelectedDatabaseId={updateSelectedDatabaseId}
|
||||
/>
|
||||
))
|
||||
: searchQuery && (
|
||||
<div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No databases found matching "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-3 text-center text-xs text-gray-500">
|
||||
Database - is a thing we are backing up
|
||||
{databases.length < 5 && isCanManageDBs && addDatabaseButton}
|
||||
|
||||
<div className="mx-3 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
Database - is a thing we are backing up
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDatabaseId && (
|
||||
<DatabaseComponent
|
||||
contentHeight={contentHeight}
|
||||
databaseId={selectedDatabaseId}
|
||||
onDatabaseChanged={() => {
|
||||
loadDatabases();
|
||||
}}
|
||||
onDatabaseDeleted={() => {
|
||||
const remainingDatabases = databases.filter(
|
||||
(database) => database.id !== selectedDatabaseId,
|
||||
);
|
||||
updateSelectedDatabaseId(remainingDatabases[0]?.id);
|
||||
loadDatabases();
|
||||
}}
|
||||
isCanManageDBs={isCanManageDBs}
|
||||
/>
|
||||
{showDatabaseDetails && (
|
||||
<div className="flex w-full flex-col md:flex-1">
|
||||
{isMobile && (
|
||||
<div className="mb-2">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => updateSelectedDatabaseId(undefined)}
|
||||
className="w-full"
|
||||
>
|
||||
← Back to databases
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DatabaseComponent
|
||||
contentHeight={isMobile ? contentHeight - 50 : contentHeight}
|
||||
databaseId={selectedDatabaseId}
|
||||
onDatabaseChanged={() => {
|
||||
loadDatabases();
|
||||
}}
|
||||
onDatabaseDeleted={() => {
|
||||
const remainingDatabases = databases.filter(
|
||||
(database) => database.id !== selectedDatabaseId,
|
||||
);
|
||||
updateSelectedDatabaseId(remainingDatabases[0]?.id);
|
||||
loadDatabases();
|
||||
}}
|
||||
isCanManageDBs={isCanManageDBs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Button, Modal, Spin } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { type Database, databaseApi } from '../../../../entity/databases';
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
onReadOnlyUserUpdated: (database: Database) => void;
|
||||
|
||||
onGoBack: () => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export const CreateReadOnlyComponent = ({
|
||||
database,
|
||||
onReadOnlyUserUpdated,
|
||||
onGoBack,
|
||||
onContinue,
|
||||
}: Props) => {
|
||||
const [isCheckingReadOnlyUser, setIsCheckingReadOnlyUser] = useState(false);
|
||||
const [isCreatingReadOnlyUser, setIsCreatingReadOnlyUser] = useState(false);
|
||||
const [isShowSkipConfirmation, setShowSkipConfirmation] = useState(false);
|
||||
|
||||
const checkReadOnlyUser = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await databaseApi.isUserReadOnly(database);
|
||||
return response.isReadOnly;
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const createReadOnlyUser = async () => {
|
||||
setIsCreatingReadOnlyUser(true);
|
||||
|
||||
try {
|
||||
const response = await databaseApi.createReadOnlyUser(database);
|
||||
database.postgresql!.username = response.username;
|
||||
database.postgresql!.password = response.password;
|
||||
onReadOnlyUserUpdated(database);
|
||||
onContinue();
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
}
|
||||
|
||||
setIsCreatingReadOnlyUser(false);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
setShowSkipConfirmation(true);
|
||||
};
|
||||
|
||||
const handleSkipConfirmed = () => {
|
||||
setShowSkipConfirmation(false);
|
||||
onContinue();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
setIsCheckingReadOnlyUser(true);
|
||||
|
||||
const isReadOnly = await checkReadOnlyUser();
|
||||
if (isReadOnly) {
|
||||
// already has a read-only user
|
||||
onContinue();
|
||||
}
|
||||
|
||||
setIsCheckingReadOnlyUser(false);
|
||||
};
|
||||
run();
|
||||
}, []);
|
||||
|
||||
if (isCheckingReadOnlyUser) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Spin />
|
||||
<span className="ml-3">Checking read-only user...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5">
|
||||
<p className="mb-3 text-lg font-bold">Create a read-only user for Postgresus?</p>
|
||||
|
||||
<p className="mb-2">
|
||||
A read-only user is a PostgreSQL user with limited permissions that can only read data
|
||||
from your database, not modify it. This is recommended for backup operations because:
|
||||
</p>
|
||||
|
||||
<ul className="mb-2 ml-5 list-disc">
|
||||
<li>it prevents accidental data modifications during backup</li>
|
||||
<li>it follows the principle of least privilege</li>
|
||||
<li>it's a security best practice</li>
|
||||
</ul>
|
||||
|
||||
<p className="mb-2">
|
||||
Postgresus enforce enterprise-grade security (
|
||||
<a
|
||||
href="https://postgresus.com/security"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="!text-blue-600 dark:!text-blue-400"
|
||||
>
|
||||
read in details here
|
||||
</a>
|
||||
). However, it is not possible to be covered from all possible risks.
|
||||
</p>
|
||||
|
||||
<p className="mt-3">
|
||||
<b>Read-only user allows to avoid storing credentials with write access at all</b>. Even
|
||||
in the worst case of hacking, nobody will be able to corrupt your data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex">
|
||||
<Button className="mr-auto" type="primary" ghost onClick={() => onGoBack()}>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button className="mr-2 ml-auto" danger ghost onClick={handleSkip}>
|
||||
Skip
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={createReadOnlyUser}
|
||||
loading={isCreatingReadOnlyUser}
|
||||
disabled={isCreatingReadOnlyUser}
|
||||
>
|
||||
Yes, create read-only user
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="Skip read-only user creation?"
|
||||
open={isShowSkipConfirmation}
|
||||
onCancel={() => setShowSkipConfirmation(false)}
|
||||
footer={null}
|
||||
width={450}
|
||||
>
|
||||
<div className="mb-5">
|
||||
<p className="mb-2">Are you sure you want to skip creating a read-only user?</p>
|
||||
|
||||
<p className="mb-2">
|
||||
Using a user with full permissions for backups is not recommended and may pose security
|
||||
risks. Postgresus is highly recommending you to not skip this step.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
100% protection is never possible. It's better to be safe in case of 0.01% risk of
|
||||
full hacking. So it is better to follow the secure way with read-only user.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button className="mr-2" danger onClick={handleSkipConfirmed}>
|
||||
Yes, I accept risks
|
||||
</Button>
|
||||
|
||||
<Button type="primary" onClick={() => setShowSkipConfirmation(false)}>
|
||||
Let's continue with the secure way
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -93,7 +93,7 @@ export const EditDatabaseNotifiersComponent = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5 max-w-[275px] text-gray-500">
|
||||
<div className="mb-5 max-w-[275px] text-gray-500 dark:text-gray-400">
|
||||
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
|
||||
<br />
|
||||
<br />
|
||||
@@ -162,7 +162,7 @@ export const EditDatabaseNotifiersComponent = ({
|
||||
open={isShowCreateNotifier}
|
||||
onCancel={() => setShowCreateNotifier(false)}
|
||||
>
|
||||
<div className="my-3 max-w-[275px] text-gray-500">
|
||||
<div className="my-3 max-w-[275px] text-gray-500 dark:text-gray-400">
|
||||
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
|
||||
</div>
|
||||
|
||||
|
||||
@@ -177,12 +177,13 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
{isLocalhostDb && (
|
||||
<div className="mb-1 flex">
|
||||
<div className="min-w-[150px]" />
|
||||
<div className="max-w-[200px] text-xs text-gray-500">
|
||||
<div className="max-w-[200px] text-xs text-gray-500 dark:text-gray-400">
|
||||
Please{' '}
|
||||
<a
|
||||
href="https://postgresus.com/faq#how-to-backup-localhost"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="!text-blue-600 dark:!text-blue-400"
|
||||
>
|
||||
read this document
|
||||
</a>{' '}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ShowDatabaseNotifiersComponent = ({ database }: Props) => {
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-gray-500">No notifiers configured</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">No notifiers configured</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,16 +118,16 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-5 shadow">
|
||||
<h2 className="text-xl font-bold">Healthcheck attempts</h2>
|
||||
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-3 shadow sm:p-5 dark:bg-gray-800">
|
||||
<h2 className="text-lg font-bold sm:text-xl">Healthcheck attempts</h2>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<span className="mr-2 text-sm font-medium">Period</span>
|
||||
<div className="mt-3 flex flex-col gap-2 sm:mt-4 sm:flex-row sm:items-center">
|
||||
<span className="text-sm font-medium sm:mr-2">Period</span>
|
||||
<Select
|
||||
size="small"
|
||||
value={period}
|
||||
onChange={(value) => setPeriod(value)}
|
||||
style={{ width: 120 }}
|
||||
className="w-full sm:w-[120px]"
|
||||
options={[
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
@@ -137,7 +137,7 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5" />
|
||||
<div className="mt-4 sm:mt-5" />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center">
|
||||
|
||||
@@ -41,31 +41,31 @@ export const ShowHealthcheckConfigComponent = ({ databaseId }: Props) => {
|
||||
<div className="space-y-4">
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Is health check enabled</div>
|
||||
<div className="w-[250px]">{healthcheckConfig.isHealthcheckEnabled ? 'Yes' : 'No'}</div>
|
||||
<div>{healthcheckConfig.isHealthcheckEnabled ? 'Yes' : 'No'}</div>
|
||||
</div>
|
||||
|
||||
{healthcheckConfig.isHealthcheckEnabled && (
|
||||
<>
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Notify when unavailable</div>
|
||||
<div className="w-[250px]">
|
||||
<div className="lg:w-[200px]">
|
||||
{healthcheckConfig.isSentNotificationWhenUnavailable ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Check interval (minutes)</div>
|
||||
<div className="w-[250px]">{healthcheckConfig.intervalMinutes}</div>
|
||||
<div className="lg:w-[200px]">{healthcheckConfig.intervalMinutes}</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Attempts before down</div>
|
||||
<div className="w-[250px]">{healthcheckConfig.attemptsBeforeConcideredAsDown}</div>
|
||||
<div className="lg:w-[200px]">{healthcheckConfig.attemptsBeforeConcideredAsDown}</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[180px]">Store attempts (days)</div>
|
||||
<div className="w-[250px]">{healthcheckConfig.storeAttemptsDays}</div>
|
||||
<div className="lg:w-[200px]">{healthcheckConfig.storeAttemptsDays}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -17,13 +17,13 @@ export const NotifierCardComponent = ({
|
||||
}: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedNotifierId === notifier.id ? 'bg-blue-100' : 'bg-white'}`}
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedNotifierId === notifier.id ? 'bg-blue-100 dark:bg-blue-800' : 'bg-white dark:bg-gray-800'}`}
|
||||
onClick={() => setSelectedNotifierId(notifier.id)}
|
||||
>
|
||||
<div className="mb-1 font-bold">{notifier.name}</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Notify to {getNotifierNameFromType(notifier.notifierType)}
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ export const NotifierCardComponent = ({
|
||||
</div>
|
||||
|
||||
{notifier.lastSendError && (
|
||||
<div className="mt-1 flex items-center text-sm text-red-600 underline">
|
||||
<div className="mt-1 flex items-center text-sm text-red-600 underline dark:text-red-400">
|
||||
<InfoCircleOutlined className="mr-1" style={{ color: 'red' }} />
|
||||
Has send error
|
||||
</div>
|
||||
|
||||
@@ -124,7 +124,7 @@ export const NotifierComponent = ({
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="grow overflow-y-auto rounded bg-white p-5 shadow">
|
||||
<div className="grow overflow-y-auto rounded bg-white p-5 shadow dark:bg-gray-800">
|
||||
{!notifier ? (
|
||||
<div className="mt-10 flex justify-center">
|
||||
<Spin />
|
||||
@@ -166,7 +166,7 @@ export const NotifierComponent = ({
|
||||
setEditNotifier(undefined);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined className="text-gray-500" />
|
||||
<CloseOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +198,7 @@ export const NotifierComponent = ({
|
||||
{notifier.lastSendError}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm break-words whitespace-pre-wrap text-gray-500">
|
||||
<div className="mt-3 text-sm break-words whitespace-pre-wrap text-gray-500 dark:text-gray-400">
|
||||
To clean this error (choose any):
|
||||
<ul>
|
||||
<li>
|
||||
@@ -246,7 +246,6 @@ export const NotifierComponent = ({
|
||||
<Button
|
||||
type="primary"
|
||||
className="mr-1"
|
||||
ghost
|
||||
onClick={sendTestNotification}
|
||||
loading={isSendingTestNotification}
|
||||
disabled={isSendingTestNotification}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { notifierApi } from '../../../entity/notifiers';
|
||||
import type { Notifier } from '../../../entity/notifiers';
|
||||
import type { WorkspaceResponse } from '../../../entity/workspaces';
|
||||
import { useIsMobile } from '../../../shared/hooks';
|
||||
import { NotifierCardComponent } from './NotifierCardComponent';
|
||||
import { NotifierComponent } from './NotifierComponent';
|
||||
import { EditNotifierComponent } from './edit/EditNotifierComponent';
|
||||
@@ -14,21 +15,47 @@ interface Props {
|
||||
isCanManageNotifiers: boolean;
|
||||
}
|
||||
|
||||
const SELECTED_NOTIFIER_STORAGE_KEY = 'selectedNotifierId';
|
||||
|
||||
export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifiers }: Props) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [notifiers, setNotifiers] = useState<Notifier[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const [isShowAddNotifier, setIsShowAddNotifier] = useState(false);
|
||||
const [selectedNotifierId, setSelectedNotifierId] = useState<string | undefined>(undefined);
|
||||
const loadNotifiers = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const updateSelectedNotifierId = (notifierId: string | undefined) => {
|
||||
setSelectedNotifierId(notifierId);
|
||||
if (notifierId) {
|
||||
localStorage.setItem(`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`, notifierId);
|
||||
} else {
|
||||
localStorage.removeItem(`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadNotifiers = (isSilent = false, selectNotifierId?: string) => {
|
||||
if (!isSilent) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
notifierApi
|
||||
.getNotifiers(workspace.id)
|
||||
.then((notifiers) => {
|
||||
setNotifiers(notifiers);
|
||||
if (!selectedNotifierId) {
|
||||
setSelectedNotifierId(notifiers[0]?.id);
|
||||
if (selectNotifierId) {
|
||||
updateSelectedNotifierId(selectNotifierId);
|
||||
} else if (!selectedNotifierId && !isSilent && !isMobile) {
|
||||
// On desktop, auto-select a notifier; on mobile, keep it unselected
|
||||
const savedNotifierId = localStorage.getItem(
|
||||
`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`,
|
||||
);
|
||||
const notifierToSelect =
|
||||
savedNotifierId && notifiers.some((n) => n.id === savedNotifierId)
|
||||
? savedNotifierId
|
||||
: notifiers[0]?.id;
|
||||
updateSelectedNotifierId(notifierToSelect);
|
||||
}
|
||||
})
|
||||
.catch((e) => alert(e.message))
|
||||
@@ -37,6 +64,12 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifiers();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
loadNotifiers(true);
|
||||
}, 5 * 60_000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -53,45 +86,89 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
|
||||
</Button>
|
||||
);
|
||||
|
||||
const filteredNotifiers = notifiers.filter((notifier) =>
|
||||
notifier.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
// On mobile, show either the list or the notifier details
|
||||
const showNotifierList = !isMobile || !selectedNotifierId;
|
||||
const showNotifierDetails = selectedNotifierId && (!isMobile || selectedNotifierId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex grow">
|
||||
<div
|
||||
className="mx-3 w-[250px] min-w-[250px] overflow-y-auto"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
{notifiers.length >= 5 && isCanManageNotifiers && addNotifierButton}
|
||||
{showNotifierList && (
|
||||
<div
|
||||
className="w-full overflow-y-auto md:mx-3 md:w-[250px] md:min-w-[250px] md:pr-2"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
{notifiers.length >= 5 && (
|
||||
<>
|
||||
{isCanManageNotifiers && addNotifierButton}
|
||||
|
||||
{notifiers.map((notifier) => (
|
||||
<NotifierCardComponent
|
||||
key={notifier.id}
|
||||
notifier={notifier}
|
||||
selectedNotifierId={selectedNotifierId}
|
||||
setSelectedNotifierId={setSelectedNotifierId}
|
||||
/>
|
||||
))}
|
||||
<div className="mb-2">
|
||||
<input
|
||||
placeholder="Search notifier"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full border-b border-gray-300 p-1 text-gray-500 outline-none dark:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{notifiers.length < 5 && isCanManageNotifiers && addNotifierButton}
|
||||
{filteredNotifiers.length > 0
|
||||
? filteredNotifiers.map((notifier) => (
|
||||
<NotifierCardComponent
|
||||
key={notifier.id}
|
||||
notifier={notifier}
|
||||
selectedNotifierId={selectedNotifierId}
|
||||
setSelectedNotifierId={updateSelectedNotifierId}
|
||||
/>
|
||||
))
|
||||
: searchQuery && (
|
||||
<div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No notifiers found matching "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-3 text-center text-xs text-gray-500">
|
||||
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
|
||||
{notifiers.length < 5 && isCanManageNotifiers && addNotifierButton}
|
||||
|
||||
<div className="mx-3 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNotifierId && (
|
||||
<NotifierComponent
|
||||
notifierId={selectedNotifierId}
|
||||
onNotifierChanged={() => {
|
||||
loadNotifiers();
|
||||
}}
|
||||
onNotifierDeleted={() => {
|
||||
loadNotifiers();
|
||||
setSelectedNotifierId(
|
||||
notifiers.filter((notifier) => notifier.id !== selectedNotifierId)[0]?.id,
|
||||
);
|
||||
}}
|
||||
isCanManageNotifiers={isCanManageNotifiers}
|
||||
/>
|
||||
{showNotifierDetails && (
|
||||
<div className="flex w-full flex-col md:flex-1">
|
||||
{isMobile && (
|
||||
<div className="mb-2">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => updateSelectedNotifierId(undefined)}
|
||||
className="w-full"
|
||||
>
|
||||
← Back to notifiers
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NotifierComponent
|
||||
notifierId={selectedNotifierId}
|
||||
onNotifierChanged={() => {
|
||||
loadNotifiers();
|
||||
}}
|
||||
onNotifierDeleted={() => {
|
||||
const remainingNotifiers = notifiers.filter(
|
||||
(notifier) => notifier.id !== selectedNotifierId,
|
||||
);
|
||||
updateSelectedNotifierId(remainingNotifiers[0]?.id);
|
||||
loadNotifiers();
|
||||
}}
|
||||
isCanManageNotifiers={isCanManageNotifiers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -102,7 +179,7 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
|
||||
open={isShowAddNotifier}
|
||||
onCancel={() => setIsShowAddNotifier(false)}
|
||||
>
|
||||
<div className="my-3 max-w-[250px] text-gray-500">
|
||||
<div className="my-3 max-w-[250px] text-gray-500 dark:text-gray-400">
|
||||
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
|
||||
</div>
|
||||
|
||||
@@ -111,8 +188,8 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
|
||||
isShowName
|
||||
isShowClose={false}
|
||||
onClose={() => setIsShowAddNotifier(false)}
|
||||
onChanged={() => {
|
||||
loadNotifiers();
|
||||
onChanged={(notifier) => {
|
||||
loadNotifiers(false, notifier.id);
|
||||
setIsShowAddNotifier(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -208,8 +208,8 @@ export function EditNotifierComponent({
|
||||
return (
|
||||
<div>
|
||||
{isShowName && (
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[130px]">Name</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Name</div>
|
||||
|
||||
<Input
|
||||
value={notifier?.name || ''}
|
||||
@@ -224,28 +224,30 @@ export function EditNotifierComponent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Type</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Type</div>
|
||||
|
||||
<Select
|
||||
value={notifier?.notifierType}
|
||||
options={[
|
||||
{ label: 'Telegram', value: NotifierType.TELEGRAM },
|
||||
{ label: 'Email', value: NotifierType.EMAIL },
|
||||
{ label: 'Webhook', value: NotifierType.WEBHOOK },
|
||||
{ label: 'Slack', value: NotifierType.SLACK },
|
||||
{ label: 'Discord', value: NotifierType.DISCORD },
|
||||
{ label: 'Teams', value: NotifierType.TEAMS },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setNotifierType(value);
|
||||
setIsUnsaved(true);
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={notifier?.notifierType}
|
||||
options={[
|
||||
{ label: 'Telegram', value: NotifierType.TELEGRAM },
|
||||
{ label: 'Email', value: NotifierType.EMAIL },
|
||||
{ label: 'Webhook', value: NotifierType.WEBHOOK },
|
||||
{ label: 'Slack', value: NotifierType.SLACK },
|
||||
{ label: 'Discord', value: NotifierType.DISCORD },
|
||||
{ label: 'Teams', value: NotifierType.TEAMS },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setNotifierType(value);
|
||||
setIsUnsaved(true);
|
||||
}}
|
||||
size="small"
|
||||
className="w-[250px] max-w-[250px]"
|
||||
/>
|
||||
|
||||
<img src={getNotifierLogoFromType(notifier?.notifierType)} className="ml-2 h-4 w-4" />
|
||||
<img src={getNotifierLogoFromType(notifier?.notifierType)} className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5" />
|
||||
|
||||
@@ -11,32 +11,29 @@ interface Props {
|
||||
export function EditDiscordNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex">
|
||||
<div className="w-[130px] max-w-[130px] min-w-[130px] pr-3">Channel webhook URL</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<Input
|
||||
value={notifier?.discordNotifier?.channelWebhookUrl || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.discordNotifier) return;
|
||||
setNotifier({
|
||||
...notifier,
|
||||
discordNotifier: {
|
||||
...notifier.discordNotifier,
|
||||
channelWebhookUrl: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Channel webhook URL</div>
|
||||
<Input
|
||||
value={notifier?.discordNotifier?.channelWebhookUrl || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.discordNotifier) return;
|
||||
setNotifier({
|
||||
...notifier,
|
||||
discordNotifier: {
|
||||
...notifier.discordNotifier,
|
||||
channelWebhookUrl: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-[130px] max-w-[250px]">
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="max-w-[250px] sm:ml-[150px]">
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<strong>How to get Discord webhook URL:</strong>
|
||||
<br />
|
||||
<br />
|
||||
|
||||
@@ -12,34 +12,39 @@ interface Props {
|
||||
export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Target email</div>
|
||||
<Input
|
||||
value={notifier?.emailNotifier?.targetEmail || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.emailNotifier) return;
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Target email</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={notifier?.emailNotifier?.targetEmail || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.emailNotifier) return;
|
||||
|
||||
setNotifier({
|
||||
...notifier,
|
||||
emailNotifier: {
|
||||
...notifier.emailNotifier,
|
||||
targetEmail: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="example@gmail.com"
|
||||
/>
|
||||
setNotifier({
|
||||
...notifier,
|
||||
emailNotifier: {
|
||||
...notifier.emailNotifier,
|
||||
targetEmail: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="example@gmail.com"
|
||||
/>
|
||||
|
||||
<Tooltip className="cursor-pointer" title="The email where you want to receive the message">
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The email where you want to receive the message"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">SMTP host</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">SMTP host</div>
|
||||
<Input
|
||||
value={notifier?.emailNotifier?.smtpHost || ''}
|
||||
onChange={(e) => {
|
||||
@@ -60,8 +65,8 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">SMTP port</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">SMTP port</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={notifier?.emailNotifier?.smtpPort || ''}
|
||||
@@ -83,8 +88,8 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">SMTP user</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">SMTP user</div>
|
||||
<Input
|
||||
value={notifier?.emailNotifier?.smtpUser || ''}
|
||||
onChange={(e) => {
|
||||
@@ -105,8 +110,8 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">SMTP password</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">SMTP password</div>
|
||||
<Input
|
||||
type="password"
|
||||
value={notifier?.emailNotifier?.smtpPassword || ''}
|
||||
@@ -128,33 +133,35 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">From</div>
|
||||
<Input
|
||||
value={notifier?.emailNotifier?.from || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.emailNotifier) return;
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">From</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={notifier?.emailNotifier?.from || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.emailNotifier) return;
|
||||
|
||||
setNotifier({
|
||||
...notifier,
|
||||
emailNotifier: {
|
||||
...notifier.emailNotifier,
|
||||
from: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="example@example.com"
|
||||
/>
|
||||
setNotifier({
|
||||
...notifier,
|
||||
emailNotifier: {
|
||||
...notifier.emailNotifier,
|
||||
from: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="example@example.com"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Optional. Email address to use as sender. If empty, will use SMTP user or auto-generate from host"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Optional. Email address to use as sender. If empty, will use SMTP user or auto-generate from host"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ interface Props {
|
||||
export function EditSlackNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 ml-[130px] max-w-[200px]" style={{ lineHeight: 1 }}>
|
||||
<div className="mb-1 max-w-[250px] sm:ml-[150px]" style={{ lineHeight: 1 }}>
|
||||
<a
|
||||
className="text-xs !text-blue-600"
|
||||
href="https://postgresus.com/notifiers/slack"
|
||||
@@ -22,54 +22,48 @@ export function EditSlackNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Bot token</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Bot token</div>
|
||||
<Input
|
||||
value={notifier?.slackNotifier?.botToken || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.slackNotifier) return;
|
||||
|
||||
<div className="w-[250px]">
|
||||
<Input
|
||||
value={notifier?.slackNotifier?.botToken || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.slackNotifier) return;
|
||||
|
||||
setNotifier({
|
||||
...notifier,
|
||||
slackNotifier: {
|
||||
...notifier.slackNotifier,
|
||||
botToken: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="xoxb-..."
|
||||
/>
|
||||
</div>
|
||||
setNotifier({
|
||||
...notifier,
|
||||
slackNotifier: {
|
||||
...notifier.slackNotifier,
|
||||
botToken: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="xoxb-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Target chat ID</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Target chat ID</div>
|
||||
<Input
|
||||
value={notifier?.slackNotifier?.targetChatId || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.slackNotifier) return;
|
||||
|
||||
<div className="w-[250px]">
|
||||
<Input
|
||||
value={notifier?.slackNotifier?.targetChatId || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.slackNotifier) return;
|
||||
|
||||
setNotifier({
|
||||
...notifier,
|
||||
slackNotifier: {
|
||||
...notifier.slackNotifier,
|
||||
targetChatId: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="C1234567890"
|
||||
/>
|
||||
</div>
|
||||
setNotifier({
|
||||
...notifier,
|
||||
slackNotifier: {
|
||||
...notifier.slackNotifier,
|
||||
targetChatId: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="C1234567890"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ export function EditTeamsNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 ml-[130px] max-w-[200px]" style={{ lineHeight: 1 }}>
|
||||
<div className="mb-1 max-w-[250px] sm:ml-[150px]" style={{ lineHeight: 1 }}>
|
||||
<a
|
||||
className="text-xs !text-blue-600"
|
||||
href="https://postgresus.com/notifiers/teams"
|
||||
@@ -38,25 +38,24 @@ export function EditTeamsNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Power Automate URL</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Power Automate URL</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
size="small"
|
||||
className="w-full"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="https://prod-00.westeurope.logic.azure.com:443/workflows/....."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="HTTP endpoint from your Power Automate flow (When an HTTP request is received)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="HTTP endpoint from your Power Automate flow (When an HTTP request is received)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,31 +27,28 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Bot token</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<Input
|
||||
value={notifier?.telegramNotifier?.botToken || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.telegramNotifier) return;
|
||||
setNotifier({
|
||||
...notifier,
|
||||
telegramNotifier: {
|
||||
...notifier.telegramNotifier,
|
||||
botToken: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Bot token</div>
|
||||
<Input
|
||||
value={notifier?.telegramNotifier?.botToken || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.telegramNotifier) return;
|
||||
setNotifier({
|
||||
...notifier,
|
||||
telegramNotifier: {
|
||||
...notifier.telegramNotifier,
|
||||
botToken: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 ml-[130px]">
|
||||
<div className="mb-1 sm:ml-[150px]">
|
||||
<a
|
||||
className="text-xs !text-blue-600"
|
||||
href="https://www.siteguarding.com/en/how-to-get-telegram-bot-api-token"
|
||||
@@ -62,10 +59,9 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Target chat ID</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Target chat ID</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={notifier?.telegramNotifier?.targetChatId || ''}
|
||||
onChange={(e) => {
|
||||
@@ -81,20 +77,20 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="-1001234567890"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The chat where you want to receive the message (it can be your private chat or a group)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The chat where you want to receive the message (it can be your private chat or a group)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-[130px] max-w-[250px]">
|
||||
<div className="max-w-[250px] sm:ml-[150px]">
|
||||
{!isShowHowToGetChatId ? (
|
||||
<div
|
||||
className="mt-1 cursor-pointer text-xs text-blue-600"
|
||||
@@ -103,7 +99,7 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
How to get Telegram chat ID?
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
To get your chat ID, message{' '}
|
||||
<a href="https://t.me/getmyid_bot" target="_blank" rel="noreferrer">
|
||||
@getmyid_bot
|
||||
@@ -120,42 +116,42 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px] break-all">Send to group topic</div>
|
||||
<div className="mt-4 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Send to group topic</div>
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={notifier?.telegramNotifier?.isSendToThreadEnabled || false}
|
||||
onChange={(checked) => {
|
||||
if (!notifier?.telegramNotifier) return;
|
||||
|
||||
<Switch
|
||||
checked={notifier?.telegramNotifier?.isSendToThreadEnabled || false}
|
||||
onChange={(checked) => {
|
||||
if (!notifier?.telegramNotifier) return;
|
||||
setNotifier({
|
||||
...notifier,
|
||||
telegramNotifier: {
|
||||
...notifier.telegramNotifier,
|
||||
isSendToThreadEnabled: checked,
|
||||
// Clear thread ID if disabling
|
||||
threadId: checked ? notifier.telegramNotifier.threadId : undefined,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
setNotifier({
|
||||
...notifier,
|
||||
telegramNotifier: {
|
||||
...notifier.telegramNotifier,
|
||||
isSendToThreadEnabled: checked,
|
||||
// Clear thread ID if disabling
|
||||
threadId: checked ? notifier.telegramNotifier.threadId : undefined,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Enable this to send messages to a specific thread in a group chat"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Enable this to send messages to a specific thread in a group chat"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notifier?.telegramNotifier?.isSendToThreadEnabled && (
|
||||
<>
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Thread ID</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Thread ID</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={notifier?.telegramNotifier?.threadId?.toString() || ''}
|
||||
onChange={(e) => {
|
||||
@@ -174,23 +170,23 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="3"
|
||||
type="number"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The ID of the thread where messages should be sent"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The ID of the thread where messages should be sent"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-[130px] max-w-[250px]">
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="max-w-[250px] sm:ml-[150px]">
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
To get the thread ID, go to the thread in your Telegram group, tap on the thread name
|
||||
at the top, then tap “Thread Info”. Copy the thread link and take the last
|
||||
number from the URL.
|
||||
|
||||
@@ -13,33 +13,29 @@ interface Props {
|
||||
export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Webhook URL</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<Input
|
||||
value={notifier?.webhookNotifier?.webhookUrl || ''}
|
||||
onChange={(e) => {
|
||||
setNotifier({
|
||||
...notifier,
|
||||
webhookNotifier: {
|
||||
...(notifier.webhookNotifier || { webhookMethod: WebhookMethod.POST }),
|
||||
webhookUrl: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="https://example.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Webhook URL</div>
|
||||
<Input
|
||||
value={notifier?.webhookNotifier?.webhookUrl || ''}
|
||||
onChange={(e) => {
|
||||
setNotifier({
|
||||
...notifier,
|
||||
webhookNotifier: {
|
||||
...(notifier.webhookNotifier || { webhookMethod: WebhookMethod.POST }),
|
||||
webhookUrl: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="https://example.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Method</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<div className="mt-1 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Method</div>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={notifier?.webhookNotifier?.webhookMethod || WebhookMethod.POST}
|
||||
onChange={(value) => {
|
||||
@@ -53,20 +49,20 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
className="w-full max-w-[250px]"
|
||||
options={[
|
||||
{ value: WebhookMethod.POST, label: 'POST' },
|
||||
{ value: WebhookMethod.GET, label: 'GET' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The HTTP method that will be used to call the webhook"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The HTTP method that will be used to call the webhook"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notifier?.webhookNotifier?.webhookUrl && (
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ShowDiscordNotifierComponent({ notifier }: Props) {
|
||||
<div className="flex">
|
||||
<div className="max-w-[110px] min-w-[110px] pr-3">Channel webhook URL</div>
|
||||
|
||||
<div className="w-[250px]">{notifier.webhookNotifier?.webhookUrl.slice(0, 10)}*******</div>
|
||||
<div>{notifier.webhookNotifier?.webhookUrl.slice(0, 10)}*******</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ShowSlackNotifierComponent({ notifier }: Props) {
|
||||
<div className="flex items-center">
|
||||
<div className="min-w-[110px]">Bot token</div>
|
||||
|
||||
<div className="w-[250px]">*********</div>
|
||||
<div>*********</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ShowTeamsNotifierComponent({ notifier }: Props) {
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="min-w-[110px]">Power Automate URL: </div>
|
||||
<div className="w-[250px] break-all">
|
||||
<div className="w-[50px] break-all md:w-[250px]">
|
||||
{url ? (
|
||||
<>
|
||||
<span title={url}>{display}</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ShowTelegramNotifierComponent({ notifier }: Props) {
|
||||
<div className="flex items-center">
|
||||
<div className="min-w-[110px]">Bot token</div>
|
||||
|
||||
<div className="w-[250px]">*********</div>
|
||||
<div>*********</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExclamationCircleOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { CopyOutlined, ExclamationCircleOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { CheckCircleOutlined } from '@ant-design/icons';
|
||||
import { Button, Modal, Spin, Tooltip } from 'antd';
|
||||
import { App, Button, Modal, Spin, Tooltip } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
@@ -16,11 +16,14 @@ interface Props {
|
||||
}
|
||||
|
||||
export const RestoresComponent = ({ database, backup }: Props) => {
|
||||
const { message } = App.useApp();
|
||||
|
||||
const [editingDatabase, setEditingDatabase] = useState<Database>({
|
||||
...database,
|
||||
postgresql: database.postgresql
|
||||
? ({
|
||||
...database.postgresql,
|
||||
username: undefined,
|
||||
host: undefined,
|
||||
port: undefined,
|
||||
password: undefined,
|
||||
@@ -208,7 +211,7 @@ export const RestoresComponent = ({ database, backup }: Props) => {
|
||||
<div className="w-[75px] min-w-[75px]">Duration</div>
|
||||
<div>
|
||||
<div>{duration}</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Expected restoration time usually 3x-5x longer than the backup duration
|
||||
(sometimes less, sometimes more depending on data type)
|
||||
<br />
|
||||
@@ -231,9 +234,21 @@ export const RestoresComponent = ({ database, backup }: Props) => {
|
||||
title="Restore error details"
|
||||
open={!!showingRestoreError}
|
||||
onCancel={() => setShowingRestoreError(undefined)}
|
||||
footer={null}
|
||||
footer={
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(showingRestoreError.failMessage || '');
|
||||
message.success('Error message copied to clipboard');
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="text-sm">{showingRestoreError.failMessage}</div>
|
||||
<div className="overflow-y-auto text-sm whitespace-pre-wrap" style={{ height: '400px' }}>
|
||||
{showingRestoreError.failMessage}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { auditLogApi } from '../../../entity/audit-logs/api/auditLogApi';
|
||||
import type { AuditLog } from '../../../entity/audit-logs/model/AuditLog';
|
||||
import type { GetAuditLogsRequest } from '../../../entity/audit-logs/model/GetAuditLogsRequest';
|
||||
import { useIsMobile } from '../../../shared/hooks';
|
||||
import { getUserTimeFormat } from '../../../shared/time';
|
||||
|
||||
interface Props {
|
||||
@@ -15,6 +16,7 @@ interface Props {
|
||||
|
||||
export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Props) {
|
||||
const { message } = App.useApp();
|
||||
const isMobile = useIsMobile();
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
@@ -103,7 +105,7 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
render: (_, record: AuditLog) => {
|
||||
if (!record.userEmail && !record.userName) {
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
System
|
||||
</span>
|
||||
);
|
||||
@@ -114,7 +116,7 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
: record.userEmail;
|
||||
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
@@ -124,7 +126,9 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
title: 'Message',
|
||||
dataIndex: 'message',
|
||||
key: 'message',
|
||||
render: (message: string) => <span className="text-xs text-gray-900">{message}</span>,
|
||||
render: (message: string) => (
|
||||
<span className="text-xs text-gray-900 dark:text-gray-100">{message}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
@@ -134,7 +138,9 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
render: (workspaceId: string | undefined) => (
|
||||
<span
|
||||
className={`inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${
|
||||
workspaceId ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-600'
|
||||
workspaceId
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{workspaceId || '-'}
|
||||
@@ -150,7 +156,7 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
const date = dayjs(createdAt);
|
||||
const timeFormat = getUserTimeFormat();
|
||||
return (
|
||||
<span className="text-xs text-gray-700">
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">
|
||||
{`${date.format(timeFormat.format)} (${date.fromNow()})`}
|
||||
</span>
|
||||
);
|
||||
@@ -158,11 +164,57 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
},
|
||||
];
|
||||
|
||||
const renderAuditLogCard = (log: AuditLog) => {
|
||||
const date = dayjs(log.createdAt);
|
||||
const timeFormat = getUserTimeFormat();
|
||||
|
||||
const getUserDisplay = () => {
|
||||
if (!log.userEmail && !log.userName) {
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
System
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const displayText = log.userName ? `${log.userName} (${log.userEmail})` : log.userEmail;
|
||||
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={log.id}
|
||||
className="mb-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">{getUserDisplay()}</div>
|
||||
<div className="text-right text-xs text-gray-500 dark:text-gray-400">
|
||||
<div>{date.format(timeFormat.format)}</div>
|
||||
<div className="text-gray-400 dark:text-gray-500">{date.fromNow()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-900 dark:text-gray-100">{log.message}</div>
|
||||
{log.workspaceName && (
|
||||
<div className="mt-2">
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{log.workspaceName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[1200px]">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">Audit Logs</h2>
|
||||
<div className="text-sm text-gray-500">
|
||||
<h2 className="text-xl font-bold dark:text-white">Audit Logs</h2>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{isLoading ? (
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
) : (
|
||||
@@ -175,27 +227,37 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
</div>
|
||||
) : auditLogs.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
No audit logs found.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={auditLogs}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
className="mb-4"
|
||||
/>
|
||||
{isMobile ? (
|
||||
<div>{auditLogs.map(renderAuditLogCard)}</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={auditLogs}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
<span className="ml-2 text-sm text-gray-500">Loading more logs...</span>
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading more logs...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMore && auditLogs.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-gray-500">
|
||||
All logs loaded ({total} total)
|
||||
<div className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
All logs loaded ({auditLogs.length} total)
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -91,14 +91,14 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
console.log(`isCloud = ${IS_CLOUD}`);
|
||||
|
||||
return (
|
||||
<div className="flex grow pl-3">
|
||||
<div className="flex grow sm:pl-5">
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="grow overflow-y-auto rounded bg-white p-5 shadow"
|
||||
className="grow overflow-y-auto rounded bg-white p-5 shadow dark:bg-gray-800"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">Postgresus Settings</h1>
|
||||
<h1 className="text-2xl font-bold dark:text-white">Postgresus settings</h1>
|
||||
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
@@ -109,10 +109,12 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
<div className="max-w-lg text-sm">
|
||||
<div className="space-y-6">
|
||||
{/* External Registrations Setting */}
|
||||
<div className="flex items-start justify-between border-b border-gray-200 pb-4">
|
||||
<div className="flex items-start justify-between border-b border-gray-200 pb-4 dark:border-gray-700">
|
||||
<div className="flex-1 pr-20">
|
||||
<div className="font-medium text-gray-900">Allow external registrations</div>
|
||||
<div className="mt-1 text-gray-500">
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
Allow external registrations
|
||||
</div>
|
||||
<div className="mt-1 text-gray-500 dark:text-gray-400">
|
||||
When enabled, new users can register accounts in Postgresus. If disabled,
|
||||
new users can only register via invitation
|
||||
</div>
|
||||
@@ -135,11 +137,13 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
|
||||
{/* Member Invitations Setting */}
|
||||
{!formSettings.isAllowExternalRegistrations && (
|
||||
<div className="flex items-start justify-between border-b border-gray-200 pb-4">
|
||||
<div className="flex items-start justify-between border-b border-gray-200 pb-4 dark:border-gray-700">
|
||||
<div className="flex-1 pr-20">
|
||||
<div className="font-medium text-gray-900">Allow member invitations</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
Allow member invitations
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-gray-500">
|
||||
<div className="mt-1 text-gray-500 dark:text-gray-400">
|
||||
When enabled, existing members can invite new users to join Postgresus. If
|
||||
not - only admins can invite users.
|
||||
</div>
|
||||
@@ -162,11 +166,13 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
)}
|
||||
|
||||
{/* Member Workspace Creation Setting */}
|
||||
<div className="flex items-start justify-between border-b border-gray-200 pb-4">
|
||||
<div className="flex items-start justify-between border-b border-gray-200 pb-4 dark:border-gray-700">
|
||||
<div className="flex-1 pr-20">
|
||||
<div className="font-medium text-gray-900">Members can create workspaces</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
Members can create workspaces
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-gray-500">
|
||||
<div className="mt-1 text-gray-500 dark:text-gray-400">
|
||||
When enabled, members (non-admin users) can create new workspaces. If not -
|
||||
only admins can create workspaces.
|
||||
</div>
|
||||
@@ -209,7 +215,7 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-gray-500">
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
Read more about settings you can{' '}
|
||||
<a
|
||||
href="https://postgresus.com/access-management/#global-settings"
|
||||
@@ -223,12 +229,12 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
|
||||
{/* Health-check Information */}
|
||||
<div className="my-8 max-w-2xl">
|
||||
<h2 className="mb-3 text-xl font-bold">Health-check</h2>
|
||||
<h2 className="mb-3 text-xl font-bold dark:text-white">Health-check</h2>
|
||||
|
||||
<div className="group relative">
|
||||
<div className="flex items-center rounded-md border border-gray-300 bg-gray-50 px-3 py-2 !font-mono text-sm text-gray-700">
|
||||
<div className="flex items-center rounded-md border border-gray-300 bg-gray-50 px-3 py-2 !font-mono text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||
<code
|
||||
className="flex-1 cursor-pointer transition-colors select-all hover:text-blue-600"
|
||||
className="flex-1 cursor-pointer break-all transition-colors select-all hover:text-blue-600"
|
||||
onClick={() => {
|
||||
window.open(`${getApplicationServer()}/api/v1/system/health`, '_blank');
|
||||
}}
|
||||
@@ -248,7 +254,7 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
📋
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Use this endpoint to monitor your Postgresus system's availability
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,13 +17,15 @@ export const StorageCardComponent = ({
|
||||
}: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedStorageId === storage.id ? 'bg-blue-100' : 'bg-white'}`}
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedStorageId === storage.id ? 'bg-blue-100 dark:bg-blue-800' : 'bg-white dark:bg-gray-800'}`}
|
||||
onClick={() => setSelectedStorageId(storage.id)}
|
||||
>
|
||||
<div className="mb-1 font-bold">{storage.name}</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm text-gray-500">Type: {getStorageNameFromType(storage.type)}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Type: {getStorageNameFromType(storage.type)}
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={getStorageLogoFromType(storage.type)}
|
||||
@@ -33,7 +35,7 @@ export const StorageCardComponent = ({
|
||||
</div>
|
||||
|
||||
{storage.lastSaveError && (
|
||||
<div className="mt-1 flex items-center text-sm text-red-600 underline">
|
||||
<div className="mt-1 flex items-center text-sm text-red-600 underline dark:text-red-400">
|
||||
<InfoCircleOutlined className="mr-1" style={{ color: 'red' }} />
|
||||
Has save error
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user