Compare commits

...

7 Commits

Author SHA1 Message Date
Rostislav Dugin
af499396bd FIX (storages): Do not allow to enter NAS path starting from slash 2025-07-24 21:38:28 +03:00
Rostislav Dugin
72a02ad739 FIX (backups): Increase timeout from 1 hour to 23 hours 2025-07-24 21:38:04 +03:00
Rostislav Dugin
5017f38c5f FEATURE (readme): Update readme [skip-release] 2025-07-23 18:58:47 +03:00
Rostislav Dugin
2e7cc1549a FIX (deploy): Add NAS testing to CI \ CD workflow 2025-07-23 17:44:32 +03:00
Rostislav Dugin
62ff3962a1 FEATURE (storages): Add NAS storage 2025-07-23 17:35:10 +03:00
Rostislav Dugin
34afe9a347 FIX (spelling): Fix healthcheck spelling and add website to readme 2025-07-22 11:15:34 +03:00
Rostislav Dugin
4eb7c7a902 FEATURE (contirbute): Update contribute readme [skip-release] 2025-07-22 11:04:33 +03:00
35 changed files with 879 additions and 10 deletions

View File

@@ -135,6 +135,8 @@ jobs:
# testing S3
TEST_MINIO_PORT=9000
TEST_MINIO_CONSOLE_PORT=9001
# testing NAS
TEST_NAS_PORT=5006
EOF
- name: Start test containers

View File

@@ -20,8 +20,14 @@
<a href="#-license">License</a> •
<a href="#-contributing">Contributing</a>
</p>
<p style="margin-top: 20px; margin-bottom: 20px; font-size: 1.2em;">
<a href="https://postgresus.com" target="_blank"><strong>🌐 Postgresus website</strong></a>
</p>
<img src="assets/dashboard.svg" alt="Postgresus Dashboard" width="800"/>
</div>
---
@@ -37,12 +43,12 @@
### 🗄️ **Multiple Storage Destinations**
- **Local storage**: Keep backups on your VPS/server
- **Cloud storage**: S3, Cloudflare R2, Google Drive, Dropbox, and more (coming soon)
- **Cloud storage**: S3, Cloudflare R2, Google Drive, NAS, Dropbox and more
- **Secure**: All data stays under your control
### 📱 **Smart Notifications**
- **Multiple channels**: Email, Telegram, Slack, webhooks (coming soon)
- **Multiple channels**: Email, Telegram, Slack, Discord, webhooks
- **Real-time updates**: Success and failure notifications
- **Team integration**: Perfect for DevOps workflows

View File

@@ -24,4 +24,6 @@ TEST_POSTGRES_16_PORT=5004
TEST_POSTGRES_17_PORT=5005
# testing S3
TEST_MINIO_PORT=9000
TEST_MINIO_CONSOLE_PORT=9001
TEST_MINIO_CONSOLE_PORT=9001
# testing NAS
TEST_NAS_PORT=5006

3
backend/.gitignore vendored
View File

@@ -11,4 +11,5 @@ swagger/swagger.json
swagger/swagger.yaml
postgresus-backend.exe
ui/build/*
pgdata-for-restore/
pgdata-for-restore/
temp/

View File

@@ -86,3 +86,19 @@ services:
- POSTGRES_PASSWORD=testpassword
container_name: test-postgres-17
shm_size: 1gb
# Test NAS server (Samba)
test-nas:
image: dperson/samba:latest
ports:
- "${TEST_NAS_PORT:-445}:445"
environment:
- USERID=1000
- GROUPID=1000
volumes:
- ./temp/nas:/shared
command: >
-u "testuser;testpassword"
-s "backups;/shared;yes;no;no;testuser"
-p
container_name: test-nas

View File

@@ -29,9 +29,11 @@ require (
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
github.com/geoffgarside/ber v1.1.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/hirochachacha/go-smb2 v1.1.0
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect

View File

@@ -35,6 +35,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w=
github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk=
github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0=
github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2U=
@@ -91,6 +93,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE=
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -210,12 +214,14 @@ go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
@@ -230,6 +236,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@@ -41,6 +41,8 @@ type EnvVariables struct {
TestMinioPort string `env:"TEST_MINIO_PORT"`
TestMinioConsolePort string `env:"TEST_MINIO_CONSOLE_PORT"`
TestNASPort string `env:"TEST_NAS_PORT"`
}
var (
@@ -161,6 +163,11 @@ func loadEnvVariables() {
log.Error("TEST_MINIO_CONSOLE_PORT is empty")
os.Exit(1)
}
if env.TestNASPort == "" {
log.Error("TEST_NAS_PORT is empty")
os.Exit(1)
}
}
log.Info("Environment variables loaded successfully!")

View File

@@ -100,7 +100,9 @@ func (uc *CreatePostgresqlBackupUsecase) streamToStorage(
) error {
uc.logger.Info("Streaming PostgreSQL backup to storage", "pgBin", pgBin, "args", args)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
// if backup not fit into 23 hours, Postgresus
// seems not to work for such database size
ctx, cancel := context.WithTimeout(context.Background(), 23*time.Hour)
defer cancel()
// Monitor for shutdown and cancel context if needed

View File

@@ -6,4 +6,5 @@ const (
StorageTypeLocal StorageType = "LOCAL"
StorageTypeS3 StorageType = "S3"
StorageTypeGoogleDrive StorageType = "GOOGLE_DRIVE"
StorageTypeNAS StorageType = "NAS"
)

View File

@@ -6,6 +6,7 @@ import (
"log/slog"
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"
"github.com/google/uuid"
@@ -22,6 +23,7 @@ type Storage struct {
LocalStorage *local_storage.LocalStorage `json:"localStorage" gorm:"foreignKey:StorageID"`
S3Storage *s3_storage.S3Storage `json:"s3Storage" gorm:"foreignKey:StorageID"`
GoogleDriveStorage *google_drive_storage.GoogleDriveStorage `json:"googleDriveStorage" gorm:"foreignKey:StorageID"`
NASStorage *nas_storage.NASStorage `json:"nasStorage" gorm:"foreignKey:StorageID"`
}
func (s *Storage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
@@ -69,6 +71,8 @@ func (s *Storage) getSpecificStorage() StorageFileSaver {
return s.S3Storage
case StorageTypeGoogleDrive:
return s.GoogleDriveStorage
case StorageTypeNAS:
return s.NASStorage
default:
panic("invalid storage type: " + string(s.Type))
}

View File

@@ -10,8 +10,10 @@ import (
"postgresus-backend/internal/config"
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"
"postgresus-backend/internal/util/logger"
"strconv"
"testing"
"time"
@@ -44,6 +46,14 @@ func Test_Storage_BasicOperations(t *testing.T) {
require.NoError(t, err, "Failed to setup test file")
defer os.Remove(testFilePath)
// Setup NAS port
nasPort := 445
if portStr := config.GetEnv().TestNASPort; portStr != "" {
if port, err := strconv.Atoi(portStr); err == nil {
nasPort = port
}
}
// Run tests
testCases := []struct {
name string
@@ -73,6 +83,20 @@ func Test_Storage_BasicOperations(t *testing.T) {
TokenJSON: config.GetEnv().TestGoogleDriveTokenJSON,
},
},
{
name: "NASStorage",
storage: &nas_storage.NASStorage{
StorageID: uuid.New(),
Host: "localhost",
Port: nasPort,
Share: "backups",
Username: "testuser",
Password: "testpassword",
UseSSL: false,
Domain: "",
Path: "test-files",
},
},
}
for _, tc := range testCases {
@@ -201,4 +225,5 @@ func validateEnvVariables(t *testing.T) {
assert.NotEmpty(t, env.TestGoogleDriveClientSecret, "TEST_GOOGLE_DRIVE_CLIENT_SECRET is empty")
assert.NotEmpty(t, env.TestGoogleDriveTokenJSON, "TEST_GOOGLE_DRIVE_TOKEN_JSON is empty")
assert.NotEmpty(t, env.TestMinioPort, "TEST_MINIO_PORT is empty")
assert.NotEmpty(t, env.TestNASPort, "TEST_NAS_PORT is empty")
}

View File

@@ -0,0 +1,401 @@
package nas_storage
import (
"crypto/tls"
"errors"
"fmt"
"io"
"log/slog"
"net"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/hirochachacha/go-smb2"
)
type NASStorage struct {
StorageID uuid.UUID `json:"storageId" gorm:"primaryKey;type:uuid;column:storage_id"`
Host string `json:"host" gorm:"not null;type:text;column:host"`
Port int `json:"port" gorm:"not null;default:445;column:port"`
Share string `json:"share" gorm:"not null;type:text;column:share"`
Username string `json:"username" gorm:"not null;type:text;column:username"`
Password string `json:"password" gorm:"not null;type:text;column:password"`
UseSSL bool `json:"useSsl" gorm:"not null;default:false;column:use_ssl"`
Domain string `json:"domain" gorm:"type:text;column:domain"`
Path string `json:"path" gorm:"type:text;column:path"`
}
func (n *NASStorage) TableName() string {
return "nas_storages"
}
func (n *NASStorage) SaveFile(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()
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)
}
defer func() {
if logoffErr := session.Logoff(); logoffErr != nil {
logger.Error(
"Failed to logoff NAS session",
"fileId",
fileID.String(),
"error",
logoffErr,
)
}
}()
fs, err := session.Mount(n.Share)
if err != nil {
logger.Error(
"Failed to mount NAS share",
"fileId",
fileID.String(),
"share",
n.Share,
"error",
err,
)
return fmt.Errorf("failed to mount share '%s': %w", n.Share, err)
}
defer func() {
if umountErr := fs.Umount(); umountErr != nil {
logger.Error(
"Failed to unmount NAS share",
"fileId",
fileID.String(),
"error",
umountErr,
)
}
}()
// Ensure the directory exists
if n.Path != "" {
if err := n.ensureDirectory(fs, n.Path); err != nil {
logger.Error(
"Failed to ensure directory",
"fileId",
fileID.String(),
"path",
n.Path,
"error",
err,
)
return fmt.Errorf("failed to ensure directory: %w", err)
}
}
filePath := n.getFilePath(fileID.String())
logger.Debug("Creating file on NAS", "fileId", fileID.String(), "filePath", filePath)
nasFile, err := fs.Create(filePath)
if err != nil {
logger.Error(
"Failed to create file on NAS",
"fileId",
fileID.String(),
"filePath",
filePath,
"error",
err,
)
return fmt.Errorf("failed to create file on NAS: %w", err)
}
defer func() {
if closeErr := nasFile.Close(); closeErr != nil {
logger.Error("Failed to close NAS file", "fileId", fileID.String(), "error", closeErr)
}
}()
logger.Debug("Copying file data to NAS", "fileId", fileID.String())
_, err = io.Copy(nasFile, file)
if err != nil {
logger.Error("Failed to write file to NAS", "fileId", fileID.String(), "error", err)
return fmt.Errorf("failed to write file to NAS: %w", err)
}
logger.Info(
"Successfully saved file to NAS storage",
"fileId",
fileID.String(),
"filePath",
filePath,
)
return nil
}
func (n *NASStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
session, err := n.createSession()
if err != nil {
return nil, fmt.Errorf("failed to create NAS session: %w", err)
}
fs, err := session.Mount(n.Share)
if err != nil {
_ = session.Logoff()
return nil, fmt.Errorf("failed to mount share '%s': %w", n.Share, err)
}
filePath := n.getFilePath(fileID.String())
// Check if file exists
_, err = fs.Stat(filePath)
if err != nil {
_ = fs.Umount()
_ = session.Logoff()
return nil, fmt.Errorf("file not found: %s", fileID.String())
}
nasFile, err := fs.Open(filePath)
if err != nil {
_ = fs.Umount()
_ = session.Logoff()
return nil, fmt.Errorf("failed to open file from NAS: %w", err)
}
// Return a wrapped reader that cleans up resources when closed
return &nasFileReader{
file: nasFile,
fs: fs,
session: session,
}, nil
}
func (n *NASStorage) DeleteFile(fileID uuid.UUID) error {
session, err := n.createSession()
if err != nil {
return fmt.Errorf("failed to create NAS session: %w", err)
}
defer func() {
_ = session.Logoff()
}()
fs, err := session.Mount(n.Share)
if err != nil {
return fmt.Errorf("failed to mount share '%s': %w", n.Share, err)
}
defer func() {
_ = fs.Umount()
}()
filePath := n.getFilePath(fileID.String())
// Check if file exists before trying to delete
_, err = fs.Stat(filePath)
if err != nil {
// File doesn't exist, consider it already deleted
return nil
}
err = fs.Remove(filePath)
if err != nil {
return fmt.Errorf("failed to delete file from NAS: %w", err)
}
return nil
}
func (n *NASStorage) Validate() error {
if n.Host == "" {
return errors.New("NAS host is required")
}
if n.Share == "" {
return errors.New("NAS share is required")
}
if n.Username == "" {
return errors.New("NAS username is required")
}
if n.Password == "" {
return errors.New("NAS password is required")
}
if n.Port <= 0 || n.Port > 65535 {
return errors.New("NAS port must be between 1 and 65535")
}
// Test the configuration by creating a session
return n.TestConnection()
}
func (n *NASStorage) TestConnection() error {
session, err := n.createSession()
if err != nil {
return fmt.Errorf("failed to connect to NAS: %w", err)
}
defer func() {
_ = session.Logoff()
}()
// Try to mount the share to verify access
fs, err := session.Mount(n.Share)
if err != nil {
return fmt.Errorf("failed to access share '%s': %w", n.Share, err)
}
defer func() {
_ = fs.Umount()
}()
// If path is specified, check if it exists or can be created
if n.Path != "" {
if err := n.ensureDirectory(fs, n.Path); err != nil {
return fmt.Errorf("failed to access or create path '%s': %w", n.Path, err)
}
}
return nil
}
func (n *NASStorage) createSession() (*smb2.Session, error) {
// Create connection with timeout
conn, err := n.createConnection()
if err != nil {
return nil, err
}
// Create SMB2 dialer
d := &smb2.Dialer{
Initiator: &smb2.NTLMInitiator{
User: n.Username,
Password: n.Password,
Domain: n.Domain,
},
}
// Create session
session, err := d.Dial(conn)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("failed to create SMB session: %w", err)
}
return session, nil
}
func (n *NASStorage) createConnection() (net.Conn, error) {
address := net.JoinHostPort(n.Host, fmt.Sprintf("%d", n.Port))
// Create connection with timeout
dialer := &net.Dialer{
Timeout: 10 * time.Second,
}
if n.UseSSL {
// Use TLS connection
tlsConfig := &tls.Config{
ServerName: n.Host,
InsecureSkipVerify: false, // Change to true if you want to skip cert verification
}
conn, err := tls.DialWithDialer(dialer, "tcp", address, tlsConfig)
if err != nil {
return nil, fmt.Errorf("failed to create SSL connection to %s: %w", address, err)
}
return conn, nil
} else {
// Use regular TCP connection
conn, err := dialer.Dial("tcp", address)
if err != nil {
return nil, fmt.Errorf("failed to create connection to %s: %w", address, err)
}
return conn, nil
}
}
func (n *NASStorage) ensureDirectory(fs *smb2.Share, path string) error {
// Clean and normalize the path
path = filepath.Clean(path)
path = strings.ReplaceAll(path, "\\", "/")
// Check if directory already exists
_, err := fs.Stat(path)
if err == nil {
return nil // Directory exists
}
// Try to create the directory (including parent directories)
parts := strings.Split(path, "/")
currentPath := ""
for _, part := range parts {
if part == "" || part == "." {
continue
}
if currentPath == "" {
currentPath = part
} else {
currentPath = currentPath + "/" + part
}
// Check if this part of the path exists
_, err := fs.Stat(currentPath)
if err != nil {
// Directory doesn't exist, try to create it
err = fs.Mkdir(currentPath, 0755)
if err != nil {
return fmt.Errorf("failed to create directory '%s': %w", currentPath, err)
}
}
}
return nil
}
func (n *NASStorage) getFilePath(filename string) string {
if n.Path == "" {
return filename
}
// Clean path and use forward slashes for SMB
cleanPath := filepath.Clean(n.Path)
cleanPath = strings.ReplaceAll(cleanPath, "\\", "/")
return cleanPath + "/" + filename
}
// nasFileReader wraps the NAS file and handles cleanup of resources
type nasFileReader struct {
file *smb2.File
fs *smb2.Share
session *smb2.Session
}
func (r *nasFileReader) Read(p []byte) (n int, err error) {
return r.file.Read(p)
}
func (r *nasFileReader) Close() error {
// Close resources in reverse order
var errors []error
if r.file != nil {
if err := r.file.Close(); err != nil {
errors = append(errors, fmt.Errorf("failed to close file: %w", err))
}
}
if r.fs != nil {
if err := r.fs.Umount(); err != nil {
errors = append(errors, fmt.Errorf("failed to unmount share: %w", err))
}
}
if r.session != nil {
if err := r.session.Logoff(); err != nil {
errors = append(errors, fmt.Errorf("failed to logoff session: %w", err))
}
}
if len(errors) > 0 {
// Return the first error, but log others if needed
return errors[0]
}
return nil
}

View File

@@ -26,17 +26,21 @@ func (r *StorageRepository) Save(storage *Storage) (*Storage, error) {
if storage.GoogleDriveStorage != nil {
storage.GoogleDriveStorage.StorageID = storage.ID
}
case StorageTypeNAS:
if storage.NASStorage != nil {
storage.NASStorage.StorageID = storage.ID
}
}
if storage.ID == uuid.Nil {
if err := tx.Create(storage).
Omit("LocalStorage", "S3Storage", "GoogleDriveStorage").
Omit("LocalStorage", "S3Storage", "GoogleDriveStorage", "NASStorage").
Error; err != nil {
return err
}
} else {
if err := tx.Save(storage).
Omit("LocalStorage", "S3Storage", "GoogleDriveStorage").
Omit("LocalStorage", "S3Storage", "GoogleDriveStorage", "NASStorage").
Error; err != nil {
return err
}
@@ -64,6 +68,13 @@ func (r *StorageRepository) Save(storage *Storage) (*Storage, error) {
return err
}
}
case StorageTypeNAS:
if storage.NASStorage != nil {
storage.NASStorage.StorageID = storage.ID // Ensure ID is set
if err := tx.Save(storage.NASStorage).Error; err != nil {
return err
}
}
}
return nil
@@ -84,6 +95,7 @@ func (r *StorageRepository) FindByID(id uuid.UUID) (*Storage, error) {
Preload("LocalStorage").
Preload("S3Storage").
Preload("GoogleDriveStorage").
Preload("NASStorage").
Where("id = ?", id).
First(&s).Error; err != nil {
return nil, err
@@ -100,6 +112,7 @@ func (r *StorageRepository) FindByUserID(userID uuid.UUID) ([]*Storage, error) {
Preload("LocalStorage").
Preload("S3Storage").
Preload("GoogleDriveStorage").
Preload("NASStorage").
Where("user_id = ?", userID).
Order("name ASC").
Find(&storages).Error; err != nil {
@@ -131,6 +144,12 @@ func (r *StorageRepository) Delete(s *Storage) error {
return err
}
}
case StorageTypeNAS:
if s.NASStorage != nil {
if err := tx.Delete(s.NASStorage).Error; err != nil {
return err
}
}
}
// Delete the main storage

View File

@@ -0,0 +1,30 @@
-- +goose Up
-- +goose StatementBegin
-- Create NAS storages table
CREATE TABLE nas_storages (
storage_id UUID PRIMARY KEY,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 445,
share TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
use_ssl BOOLEAN NOT NULL DEFAULT FALSE,
domain TEXT,
path TEXT
);
ALTER TABLE nas_storages
ADD CONSTRAINT fk_nas_storages_storage
FOREIGN KEY (storage_id)
REFERENCES storages (id)
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS nas_storages;
-- +goose StatementEnd

View File

@@ -0,0 +1 @@
This is test data for storage testing

View File

@@ -0,0 +1 @@
This is test data for storage testing

View File

@@ -70,6 +70,7 @@ Before taking anything more than a couple of lines of code, please write Rostisl
Backups flow:
- do not remove old backups on backups disable
- add FTP
- add Dropbox
- add OneDrive

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 256 256" id="Flat" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.2">
<rect x="40" y="144" width="176" height="64" rx="8"/>
</g>
<g opacity="0.2">
<rect x="40" y="48" width="176" height="64" rx="8"/>
</g>
<path d="M208,136H48a16.01833,16.01833,0,0,0-16,16v48a16.01833,16.01833,0,0,0,16,16H208a16.01833,16.01833,0,0,0,16-16V152A16.01833,16.01833,0,0,0,208,136Zm0,64H48V152H208l.01025,47.99951Zm0-160H48A16.01833,16.01833,0,0,0,32,56v48a16.01833,16.01833,0,0,0,16,16H208a16.01833,16.01833,0,0,0,16-16V56A16.01833,16.01833,0,0,0,208,40Zm0,64H48V56H208l.01025,47.99951ZM192,80a12,12,0,1,1-12-12A12.01375,12.01375,0,0,1,192,80Zm0,96a12,12,0,1,1-12-12A12.01375,12.01375,0,0,1,192,176Z"/>

After

Width:  |  Height:  |  Size: 892 B

View File

@@ -3,6 +3,7 @@ export { type Storage } from './models/Storage';
export { StorageType } from './models/StorageType';
export { type LocalStorage } from './models/LocalStorage';
export { type S3Storage } from './models/S3Storage';
export { type NASStorage } from './models/NASStorage';
export { getStorageLogoFromType } from './models/getStorageLogoFromType';
export { getStorageNameFromType } from './models/getStorageNameFromType';
export { type GoogleDriveStorage } from './models/GoogleDriveStorage';

View File

@@ -0,0 +1,10 @@
export interface NASStorage {
host: string;
port: number;
share: string;
username: string;
password: string;
useSsl: boolean;
domain?: string;
path?: string;
}

View File

@@ -1,5 +1,6 @@
import type { GoogleDriveStorage } from './GoogleDriveStorage';
import type { LocalStorage } from './LocalStorage';
import type { NASStorage } from './NASStorage';
import type { S3Storage } from './S3Storage';
import type { StorageType } from './StorageType';
@@ -13,4 +14,5 @@ export interface Storage {
localStorage?: LocalStorage;
s3Storage?: S3Storage;
googleDriveStorage?: GoogleDriveStorage;
nasStorage?: NASStorage;
}

View File

@@ -2,4 +2,5 @@ export enum StorageType {
LOCAL = 'LOCAL',
S3 = 'S3',
GOOGLE_DRIVE = 'GOOGLE_DRIVE',
NAS = 'NAS',
}

View File

@@ -8,6 +8,8 @@ export const getStorageLogoFromType = (type: StorageType) => {
return '/icons/storages/s3.svg';
case StorageType.GOOGLE_DRIVE:
return '/icons/storages/google-drive.svg';
case StorageType.NAS:
return '/icons/storages/nas.svg';
default:
return '';
}

View File

@@ -8,6 +8,8 @@ export const getStorageNameFromType = (type: StorageType) => {
return 'S3';
case StorageType.GOOGLE_DRIVE:
return 'Google Drive';
case StorageType.NAS:
return 'NAS';
default:
return '';
}

View File

@@ -190,7 +190,7 @@ export const EditHealthcheckConfigComponent = ({ databaseId, onClose }: Props) =
<Tooltip
className="cursor-pointer"
title="How many days to store healthcheck attempt history"
title="How many days to store health check attempt history"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>

View File

@@ -40,7 +40,7 @@ export const ShowHealthcheckConfigComponent = ({ databaseId }: Props) => {
return (
<div className="space-y-4">
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Is healthcheck enabled</div>
<div className="min-w-[180px]">Is health check enabled</div>
<div className="w-[250px]">{healthcheckConfig.isHealthcheckEnabled ? 'Yes' : 'No'}</div>
</div>

View File

@@ -9,6 +9,7 @@ import {
} from '../../../../entity/storages';
import { ToastHelper } from '../../../../shared/toast';
import { EditGoogleDriveStorageComponent } from './storages/EditGoogleDriveStorageComponent';
import { EditNASStorageComponent } from './storages/EditNASStorageComponent';
import { EditS3StorageComponent } from './storages/EditS3StorageComponent';
interface Props {
@@ -98,6 +99,19 @@ export function EditStorageComponent({
};
}
if (type === StorageType.NAS) {
storage.nasStorage = {
host: '',
port: 0,
share: '',
username: '',
password: '',
useSsl: false,
domain: '',
path: '',
};
}
setStorage(
JSON.parse(
JSON.stringify({
@@ -148,6 +162,16 @@ export function EditStorageComponent({
);
}
if (storage.type === StorageType.NAS) {
return (
storage.nasStorage?.host &&
storage.nasStorage?.port &&
storage.nasStorage?.share &&
storage.nasStorage?.username &&
storage.nasStorage?.password
);
}
return false;
};
@@ -181,6 +205,7 @@ export function EditStorageComponent({
{ label: 'Local storage', value: StorageType.LOCAL },
{ label: 'S3', value: StorageType.S3 },
{ label: 'Google Drive', value: StorageType.GOOGLE_DRIVE },
{ label: 'NAS', value: StorageType.NAS },
]}
onChange={(value) => {
setStorageType(value);
@@ -211,6 +236,14 @@ export function EditStorageComponent({
setIsUnsaved={setIsUnsaved}
/>
)}
{storage?.type === StorageType.NAS && (
<EditNASStorageComponent
storage={storage}
setStorage={setStorage}
setIsUnsaved={setIsUnsaved}
/>
)}
</div>
<div className="mt-3 flex">

View File

@@ -0,0 +1,223 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Input, InputNumber, Switch, Tooltip } from 'antd';
import type { Storage } from '../../../../../entity/storages';
interface Props {
storage: Storage;
setStorage: (storage: Storage) => void;
setIsUnsaved: (isUnsaved: boolean) => void;
}
export function EditNASStorageComponent({ storage, setStorage, setIsUnsaved }: Props) {
return (
<>
<div className="mb-2 flex items-center">
<div className="min-w-[110px]" />
<div className="text-xs text-blue-600">
<a href="https://postgresus.com/nas-storage" target="_blank" rel="noreferrer">
How to connect NAS storage?
</a>
</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Host</div>
<Input
value={storage?.nasStorage?.host || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
host: e.target.value.trim(),
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
placeholder="192.168.1.100"
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Port</div>
<InputNumber
value={storage?.nasStorage?.port || 445}
onChange={(value) => {
if (!storage?.nasStorage || !value) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
port: value,
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
min={1}
max={65535}
placeholder="445"
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Share</div>
<Input
value={storage?.nasStorage?.share || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
share: e.target.value.trim(),
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
placeholder="shared_folder"
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Username</div>
<Input
value={storage?.nasStorage?.username || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
username: e.target.value.trim(),
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
placeholder="username"
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Password</div>
<Input.Password
value={storage?.nasStorage?.password || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
password: e.target.value,
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
placeholder="password"
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Use SSL</div>
<Switch
checked={storage?.nasStorage?.useSsl || false}
onChange={(checked) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
useSsl: checked,
},
});
setIsUnsaved(true);
}}
size="small"
/>
<Tooltip className="cursor-pointer" title="Enable SSL/TLS encryption for secure connection">
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Domain</div>
<Input
value={storage?.nasStorage?.domain || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
domain: e.target.value.trim() || undefined,
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
placeholder="WORKGROUP (optional)"
/>
<Tooltip
className="cursor-pointer"
title="Windows domain name (optional, leave empty if not using domain authentication)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Path</div>
<Input
value={storage?.nasStorage?.path || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
let pathValue = e.target.value.trim();
// Remove leading slash if present
if (pathValue.startsWith('/')) {
pathValue = pathValue.substring(1);
}
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
path: pathValue || undefined,
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
placeholder="backups (optional, no leading slash)"
/>
<Tooltip className="cursor-pointer" title="Subdirectory path within the share (optional)">
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</>
);
}

View File

@@ -2,6 +2,7 @@ import { type Storage, StorageType } from '../../../../entity/storages';
import { getStorageLogoFromType } from '../../../../entity/storages/models/getStorageLogoFromType';
import { getStorageNameFromType } from '../../../../entity/storages/models/getStorageNameFromType';
import { ShowGoogleDriveStorageComponent } from './storages/ShowGoogleDriveStorageComponent';
import { ShowNASStorageComponent } from './storages/ShowNASStorageComponent';
import { ShowS3StorageComponent } from './storages/ShowS3StorageComponent';
interface Props {
@@ -32,6 +33,10 @@ export function ShowStorageComponent({ storage }: Props) {
<ShowGoogleDriveStorageComponent storage={storage} />
)}
</div>
<div>
{storage?.type === StorageType.NAS && <ShowNASStorageComponent storage={storage} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import type { Storage } from '../../../../../entity/storages';
interface Props {
storage: Storage;
}
export function ShowNASStorageComponent({ storage }: Props) {
return (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Host</div>
{storage?.nasStorage?.host || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Port</div>
{storage?.nasStorage?.port || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Share</div>
{storage?.nasStorage?.share || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Username</div>
{storage?.nasStorage?.username || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Password</div>
{storage?.nasStorage?.password ? '*********' : '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Use SSL</div>
{storage?.nasStorage?.useSsl ? 'Yes' : 'No'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Domain</div>
{storage?.nasStorage?.domain || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Path</div>
{storage?.nasStorage?.path || '-'}
</div>
</>
);
}

View File

@@ -60,7 +60,7 @@ export const MainScreenComponent = () => {
target="_blank"
rel="noreferrer"
>
Healthcheck
Health-check
</a>
<a