This commit is contained in:
sirir 2025-03-20 22:14:45 +01:00
commit d840597cb8
35 changed files with 6101 additions and 0 deletions

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
SMTP_HOST=mail.toto.fr
SMTP_PORT=587
SMTP_USERNAME=backea@maric.ro
SMTP_PASSWORD=CHANGEME
SMTP_FROM=backea@maric.ro
B2_APPLICATION_KEY=B2
B2_KEY_ID=b2

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.env
kopia.env
.vscode/
bin/
TODO
config/

47
Dockerfile Normal file
View File

@ -0,0 +1,47 @@
FROM golang:1.24-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
RUN go install github.com/a-h/templ/cmd/templ@latest
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN /go/bin/templ generate
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/backea-server ./cmd/server/main.go
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/backup-performer ./cmd/backup_performer/main.go
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/list-backups ./cmd/list-backups/main.go
FROM alpine:latest
RUN echo "@edge http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories
RUN apk add --no-cache \
rsync \
zip \
unzip \
borgbackup \
python3 \
py3-pip \
curl \
fuse \
openssh-client \
ca-certificates \
kopia@edge
WORKDIR /app
COPY --from=builder /app/backea-server .
COPY --from=builder /app/backup-performer .
COPY --from=builder /app/list-backups .
COPY --from=builder /app/static ./static
COPY --from=builder /app/templates ./templates
RUN chmod +x /app/backea-server /app/backup-performer /app/list-backups
CMD ["/app/backea-server"]

View File

@ -0,0 +1,37 @@
package main
import (
"backea/internal/backup"
"context"
"flag"
"log"
"strings"
"github.com/joho/godotenv"
)
func main() {
configPath := flag.String("config", "config.yml", "Path to config file")
serviceFlag := flag.String("service", "", "Service to backup (format: group or group.index)")
flag.Parse()
if err := godotenv.Load(); err != nil {
log.Printf("Warning: Error loading .env file: %v", err)
}
ctx := context.Background()
// Parse the service flag to extract group and index if provided
var serviceName, serviceIndex string
if *serviceFlag != "" {
parts := strings.SplitN(*serviceFlag, "-", 2)
serviceName = parts[0]
if len(parts) > 1 {
serviceIndex = parts[1]
}
}
if err := backup.PerformBackups(ctx, *configPath, serviceName, serviceIndex); err != nil {
log.Fatalf("Backup failed: %v", err)
}
}

98
cmd/list-backups/main.go Normal file
View File

@ -0,0 +1,98 @@
package main
import (
"backea/internal/backup"
"context"
"fmt"
"log"
"os"
"text/tabwriter"
"time"
"github.com/joho/godotenv"
)
func main() {
ctx := context.Background()
configPath := "config.yml"
if err := godotenv.Load(); err != nil {
log.Printf("Warning: Error loading .env file: %v", err)
}
// Create backup factory
factory, err := backup.NewBackupFactory(configPath)
if err != nil {
log.Fatalf("Failed to create backup factory: %v", err)
}
// Process each service group
for groupName, serviceGroup := range factory.Config.Services {
fmt.Printf("Service Group: %s (Directory: %s)\n", groupName, serviceGroup.Source.Path)
// Process each backup config in the group
for configIndex := range serviceGroup.BackupConfigs {
// Format the full service name
serviceName := fmt.Sprintf("%s.%s", groupName, configIndex)
// Create the appropriate backup strategy using the factory
strategy, err := factory.CreateBackupStrategyForService(groupName, configIndex)
if err != nil {
log.Printf("Failed to create backup strategy for service %s: %v", serviceName, err)
continue
}
// List backups for this service
backups, err := strategy.ListBackups(ctx, serviceName)
if err != nil {
log.Printf("Failed to list backups for service %s: %v", serviceName, err)
continue
}
// Display the backups
fmt.Printf("Found %d backups for service %s:\n", len(backups), serviceName)
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "----------------------------------------------------")
fmt.Fprintln(w, "ID | Date | Size | Retention")
fmt.Fprintln(w, "----------------------------------------------------")
for _, backup := range backups {
fmt.Fprintf(w, "%s | %s | %s | %s\n",
backup.ID,
backup.CreationTime.Format(time.RFC3339),
formatSize(backup.Size),
backup.RetentionTag,
)
}
fmt.Fprintln(w, "----------------------------------------------------")
w.Flush()
// Display storage usage
usage, err := strategy.GetStorageUsage(ctx, serviceName)
if err != nil {
log.Printf("Failed to get storage usage for service %s: %v", serviceName, err)
continue
}
fmt.Printf("Storage Usage for %s Repository\n", serviceName)
fmt.Println("---------------------------------------")
fmt.Printf("Provider: %s\n", usage.Provider)
fmt.Printf("Repository ID: %s\n", usage.ProviderID)
fmt.Printf("Physical Size: %s\n", formatSize(usage.TotalBytes))
fmt.Println() // Add a blank line between services for readability
}
}
}
func formatSize(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %cB", float64(size)/float64(div), "KMGTPE"[exp])
}

43
cmd/list-buckets/main.go Normal file
View File

@ -0,0 +1,43 @@
package main
import (
b2_client "backea/internal/client"
"context"
"fmt"
"os"
"time"
)
func main() {
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Create B2 client
client, err := b2_client.NewClientFromEnv()
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating B2 client: %v\n", err)
os.Exit(1)
}
// List buckets
buckets, err := client.ListBuckets(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error listing buckets: %v\n", err)
os.Exit(1)
}
// Print buckets
fmt.Println("B2 Buckets:")
fmt.Println("===========")
if len(buckets) == 0 {
fmt.Println("No buckets found")
} else {
for i, bucket := range buckets {
fmt.Printf("%d. %s (ID: %s)\n",
i+1,
bucket.Name,
bucket.ID)
}
}
}

34
cmd/notify/main.go Normal file
View File

@ -0,0 +1,34 @@
package main
import (
"fmt"
"log"
"backea/internal/mail"
"github.com/joho/godotenv"
)
func main() {
if err := godotenv.Load(); err != nil {
log.Println("Warning: No .env file found")
}
client := mail.NewClient()
// Check if required environment variables are set
if client.Config.Host == "" || client.Config.Username == "" || client.Config.Password == "" {
log.Fatal("Missing required SMTP configuration")
}
// Send notification
recipients := []string{"romaric.sirii@gmail.com"}
subject := "Backup Notification"
body := "Your backup has completed successfully!"
if err := client.SendMail(recipients, subject, body); err != nil {
log.Fatalf("Failed to send email: %v", err)
}
fmt.Println("Notification sent successfully")
}

51
cmd/restore/main.go Normal file
View File

@ -0,0 +1,51 @@
package main
import (
"backea/internal/backup"
b2_client "backea/internal/client"
"context"
"log"
"time"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("Starting Kopia Restore Tool")
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Create B2 client
b2Client, err := b2_client.NewClientFromEnv()
if err != nil {
log.Fatalf("Failed to create B2 client: %v", err)
}
// Create restore manager
restoreManager := backup.NewRestoreManager(b2Client)
// List snapshots for the 'montre' service
serviceName := "montre"
log.Printf("Listing snapshots for service '%s' (bucket: 'backea-%s')", serviceName, serviceName)
err = restoreManager.ListSnapshots(ctx, serviceName)
if err != nil {
log.Fatalf("Failed to list snapshots: %v", err)
}
// Example usage of RestoreFile function
// Use the complete snapshot ID from the output
// Looking at your output, let's use the most recent snapshot for /home/gevo/Documents/backea2
snapshotID := "kdf031570cab920b40d296bda16d27ae7" // The latest snapshot ID
sourcePath := "" // Leave empty to restore the entire snapshot
targetPath := "./restored_files" // Local directory to restore files to
log.Printf("Restoring files from snapshot %s to %s", snapshotID, targetPath)
err = restoreManager.RestoreFile(ctx, serviceName, snapshotID, sourcePath, targetPath)
if err != nil {
log.Fatalf("Failed to restore files: %v", err)
}
log.Println("Restore tool completed successfully")
}

26
cmd/server/main.go Normal file
View File

@ -0,0 +1,26 @@
package main
import (
"backea/internal/backup"
"backea/internal/server"
"log"
"os"
)
func main() {
// Get port from environment or use default
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// Create backup factory
backupFactory, _ := backup.NewBackupFactory("config.yml")
// Initialize and start the server
srv := server.New(backupFactory)
log.Printf("Starting server on port %s", port)
if err := srv.Start(":" + port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

34
compose.yaml Normal file
View File

@ -0,0 +1,34 @@
services:
backea:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
volumes:
# Configuration files
- ./.env:/app/.env
- ./config.yml:/app/config.yml
- ./kopia.env:/app/kopia.env
# Source directories to backup - map to identical paths inside container
- /home/gevo/Documents/backea2:/home/gevo/Documents/backea2
- /home/gevo/Images:/home/gevo/Images
# Dedicated repository storage
- /home/gevo/.kopia:/root/.kopia
# SSH keys if needed
- /home/gevo/.ssh:/root/.ssh:ro
- ./config/kopia:/tmp/kopia_configs
environment:
- HOME=/root
cap_add:
- SYS_ADMIN
devices:
- /dev/fuse:/dev/fuse
security_opt:
- apparmor:unconfined
restart: unless-stopped
env_file:
- ./.env

58
config.yml Normal file
View File

@ -0,0 +1,58 @@
defaults:
retention:
keep_latest: 10
keep_hourly: 24
keep_daily: 30
keep_weekly: 8
keep_monthly: 12
keep_yearly: 3
services:
backealocal:
source:
host: "local"
path: "/home/gevo/Documents/backea2"
hooks:
before_hook: "ls"
after_hook: ""
backup_configs:
1:
backup_strategy:
type: "kopia"
provider: "local"
destination:
path: "/home/gevo/Documents/backea2"
2:
backup_strategy:
type: "kopia"
provider: "local"
destination:
path: "/home/gevo/Documents/backea2"
imageslocal:
source:
host: "local"
path: "/home/gevo/Images/"
hooks:
before_hook: "ls"
after_hook: ""
backup_configs:
1:
backup_strategy:
type: "kopia"
provider: "local"
destination:
host: "local"
b2backupstotozzeeeaaaeeee:
source:
host: "local"
path: "/home/gevo/Images/"
hooks:
before_hook: "ls"
after_hook: ""
backup_configs:
1:
backup_strategy:
type: "kopia"
provider: "b2_backblaze"

46
go.mod Normal file
View File

@ -0,0 +1,46 @@
module backea
go 1.24.0
require (
github.com/a-h/templ v0.3.833
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.13.3
github.com/spf13/viper v1.19.0
gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216
)
require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/golang/glog v1.2.4 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/readahead v0.0.0-20161222183148-eaceba169032 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/time v0.9.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

103
go.sum Normal file
View File

@ -0,0 +1,103 @@
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc=
github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/readahead v0.0.0-20161222183148-eaceba169032 h1:6Be3nkuJFyRfCgr6qTIzmRp8y9QwDIbqy/nYr9WDPos=
github.com/google/readahead v0.0.0-20161222183148-eaceba169032/go.mod h1:qYysrqQXuV4tzsizt4oOQ6mrBZQ0xnQXP3ylXX8Jk5Y=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7 h1:xoIK0ctDddBMnc74udxJYBqlo9Ylnsp1waqjLsnef20=
github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7/go.mod h1:YARuvh7BUWHNhzDq2OM5tzR2RiCcN2D7sapiKyCel/M=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216 h1:2TSTkQ8PMvGOD5eeqqRVv6Z9+BYI+bowK97RCr3W+9M=
gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216/go.mod h1:zJ2QpyDCYo1KvLXlmdnFlQAyF/Qfth0fB8239Qg7BIE=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

131
internal/backup/backup.go Normal file
View File

@ -0,0 +1,131 @@
package backup
import (
"backea/internal/mail"
"context"
"fmt"
"log"
"sync"
)
// PerformBackups executes backups for multiple services based on configuration
func PerformBackups(ctx context.Context, configPath string, serviceName string, serviceIndex string) error {
// Create backup factory
factory, err := NewBackupFactory(configPath)
if err != nil {
return err
}
// Initialize mailer
mailer := mail.NewMailer()
// Process services
if serviceName != "" {
// Process single service group or specific service
if serviceIndex != "" {
// Process specific service within a group
return processSpecificService(ctx, factory, serviceName, serviceIndex, mailer)
} else {
// Process all services in the specified group
return processServiceGroup(ctx, factory, serviceName, mailer)
}
} else {
// Process all service groups in parallel
var wg sync.WaitGroup
errs := make(chan error, len(factory.Config.Services))
for groupName := range factory.Config.Services {
wg.Add(1)
go func(group string) {
defer wg.Done()
if err := processServiceGroup(ctx, factory, group, mailer); err != nil {
log.Printf("Failed to backup service group %s: %v", group, err)
errs <- fmt.Errorf("backup failed for group %s: %w", group, err)
}
}(groupName)
}
// Wait for all backups to complete
wg.Wait()
close(errs)
// Check if any errors occurred
var lastErr error
for err := range errs {
lastErr = err
}
return lastErr
}
}
// processServiceGroup handles the backup for all services in a group
func processServiceGroup(ctx context.Context, factory *BackupFactory, groupName string, mailer *mail.Mailer) error {
// Get service group configuration
serviceGroup, exists := factory.Config.Services[groupName]
if !exists {
log.Printf("Service group not found: %s", groupName)
return nil
}
// Execute the before hook once for the entire group
if serviceGroup.Hooks.BeforeHook != "" {
log.Printf("Executing before hook for group %s: %s", groupName, serviceGroup.Hooks.BeforeHook)
if err := RunCommand(serviceGroup.Source.Path, serviceGroup.Hooks.BeforeHook); err != nil {
log.Printf("Failed to execute before hook for group %s: %v", groupName, err)
return err
}
}
// Process all services in the group in parallel
var wg sync.WaitGroup
errs := make(chan error, len(serviceGroup.BackupConfigs))
for configIndex := range serviceGroup.BackupConfigs {
wg.Add(1)
go func(group, index string) {
defer wg.Done()
if err := processSpecificService(ctx, factory, group, index, mailer); err != nil {
log.Printf("Failed to backup service %s.%s: %v", group, index, err)
errs <- fmt.Errorf("backup failed for %s.%s: %w", group, index, err)
}
}(groupName, configIndex)
}
// Wait for all backups to complete
wg.Wait()
close(errs)
// Execute the after hook once for the entire group
if serviceGroup.Hooks.AfterHook != "" {
log.Printf("Executing after hook for group %s: %s", groupName, serviceGroup.Hooks.AfterHook)
if err := RunCommand(serviceGroup.Source.Path, serviceGroup.Hooks.AfterHook); err != nil {
log.Printf("Failed to execute after hook for group %s: %v", groupName, err)
// We don't return here because we want to process the errors from the backups
}
}
// Check if any errors occurred
var lastErr error
for err := range errs {
lastErr = err
}
return lastErr
}
// processSpecificService handles the backup for a specific service in a group
func processSpecificService(ctx context.Context, factory *BackupFactory, groupName string, configIndex string, mailer *mail.Mailer) error {
// Get service configuration
serviceGroup := factory.Config.Services[groupName]
// Create the appropriate backup strategy using the factory
strategy, err := factory.CreateBackupStrategyForService(groupName, configIndex)
if err != nil {
log.Printf("Failed to create backup strategy for service %s.%s: %v", groupName, configIndex, err)
return err
}
// Create and run service
service := NewService(
fmt.Sprintf("%s.%s", groupName, configIndex),
serviceGroup.Source.Path,
strategy,
mailer,
)
return service.Backup(ctx)
}

View File

@ -0,0 +1,187 @@
package backup
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// BackupFactory creates backup strategies and managers
type BackupFactory struct {
Config *Configuration
ConfigPath string
}
// NewBackupFactory creates a new backup factory
func NewBackupFactory(configPath string) (*BackupFactory, error) {
// Load configuration
config, err := LoadConfig(configPath)
if err != nil {
return nil, fmt.Errorf("could not load config: %w", err)
}
return &BackupFactory{
Config: config,
ConfigPath: configPath,
}, nil
}
// CreateKopiaStrategy creates a new Kopia backup strategy with specific retention settings and provider
func (f *BackupFactory) CreateKopiaStrategy(retention Retention, provider KopiaProvider) *KopiaStrategy {
// Create temp directory for Kopia configs if it doesn't exist
tmpConfigDir := filepath.Join(os.TempDir(), "kopia_configs")
if err := os.MkdirAll(tmpConfigDir, 0755); err != nil {
log.Printf("Warning: failed to create temp config directory: %v", err)
// Fall back to default config path
tmpConfigDir = os.TempDir()
}
// Generate a unique config file path for this instance
configPath := filepath.Join(tmpConfigDir, fmt.Sprintf("kopia_%s_%d.config",
provider.GetProviderType(), time.Now().UnixNano()))
// Need to update this line to pass configPath
return NewKopiaStrategy(retention, provider, configPath)
}
// CreateKopiaProvider creates the appropriate Kopia provider based on config
func (f *BackupFactory) CreateKopiaProvider(strategyConfig StrategyConfig) (KopiaProvider, error) {
switch strategyConfig.Provider {
case "b2_backblaze":
return NewKopiaB2Provider(), nil
case "local":
// Extract options for local path if specified
basePath := ""
if strategyConfig.Options != "" {
basePath = strategyConfig.Options
} else {
// Default to ~/.backea/repos if not specified
basePath = filepath.Join(os.Getenv("HOME"), ".backea", "repos")
}
return NewKopiaLocalProvider(basePath), nil
case "sftp":
// Parse options for SFTP - expected format: "user@host:/path/to/backups"
host := strategyConfig.Destination.Host
path := strategyConfig.Destination.Path
sshKey := strategyConfig.Destination.SSHKey
if host == "" {
return nil, fmt.Errorf("SFTP provider requires host in destination config")
}
if path == "" {
return nil, fmt.Errorf("SFTP provider requires path in destination config")
}
// Default SSH key if not specified
if sshKey == "" {
sshKey = filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa")
}
// Extract username from host if in format user@host
username := "root" // default
if hostParts := strings.Split(host, "@"); len(hostParts) > 1 {
username = hostParts[0]
host = hostParts[1]
}
return NewKopiaSFTPProvider(host, path, username, sshKey), nil
default:
return nil, fmt.Errorf("unsupported Kopia provider: %s", strategyConfig.Provider)
}
}
// CreateBackupStrategyForService creates a backup strategy for the specified service within a group
func (f *BackupFactory) CreateBackupStrategyForService(groupName string, serviceIndex string) (Strategy, error) {
// Find service group
serviceGroup, exists := f.Config.Services[groupName]
if !exists {
return nil, fmt.Errorf("service group not found: %s", groupName)
}
// Find specific backup config within the group
backupConfig, exists := serviceGroup.BackupConfigs[serviceIndex]
if !exists {
return nil, fmt.Errorf("backup config not found: %s.%s", groupName, serviceIndex)
}
// Create appropriate strategy based on type
strategyConfig := backupConfig.BackupStrategy
// Extract retention settings once
retention := Retention{
KeepLatest: strategyConfig.Retention.KeepLatest,
KeepHourly: strategyConfig.Retention.KeepHourly,
KeepDaily: strategyConfig.Retention.KeepDaily,
KeepWeekly: strategyConfig.Retention.KeepWeekly,
KeepMonthly: strategyConfig.Retention.KeepMonthly,
KeepYearly: strategyConfig.Retention.KeepYearly,
}
switch strategyConfig.Type {
case "kopia":
// Create appropriate provider based on configuration
provider, err := f.CreateKopiaProvider(strategyConfig)
if err != nil {
return nil, fmt.Errorf("failed to create kopia provider: %w", err)
}
return f.CreateKopiaStrategy(retention, provider), nil
case "rsync":
// Uncomment when rsync implementation is ready
// return NewRsyncStrategy(
// strategyConfig.Options,
// Destination{
// Host: strategyConfig.Destination.Host,
// Path: strategyConfig.Destination.Path,
// SSHKey: strategyConfig.Destination.SSHKey,
// },
// ), nil
fallthrough
default:
// Default to local kopia if type is unknown or rsync (temporarily)
provider, _ := f.CreateKopiaProvider(StrategyConfig{Provider: "local"})
return f.CreateKopiaStrategy(retention, provider), nil
}
}
// ExecuteGroupHooks runs the hooks for a service group
func (f *BackupFactory) ExecuteGroupHooks(groupName string, isBeforeHook bool) error {
serviceGroup, exists := f.Config.Services[groupName]
if !exists {
return fmt.Errorf("service group not found: %s", groupName)
}
var hook string
if isBeforeHook {
hook = serviceGroup.Hooks.BeforeHook
} else {
hook = serviceGroup.Hooks.AfterHook
}
if hook == "" {
return nil
}
// Execute the hook
cmd := exec.Command("sh", "-c", hook)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("hook execution failed: %w, output: %s", err, string(output))
}
log.Printf("Hook executed successfully: %s, output: %s", hook, string(output))
return nil
}
// ReloadConfig refreshes the configuration from the config file
func (f *BackupFactory) ReloadConfig() error {
config, err := LoadConfig(f.ConfigPath)
if err != nil {
return fmt.Errorf("could not reload config: %w", err)
}
f.Config = config
return nil
}

View File

@ -0,0 +1,767 @@
package backup
import (
"archive/zip"
"bufio"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// KopiaStrategy implements the backup strategy using Kopia
type KopiaStrategy struct {
Retention Retention
Provider KopiaProvider
ConfigPath string
}
// NewKopiaStrategy creates a new kopia strategy with specified provider
func NewKopiaStrategy(retention Retention, provider KopiaProvider, configPath string) *KopiaStrategy {
return &KopiaStrategy{
Retention: retention,
Provider: provider,
ConfigPath: configPath,
}
}
// Execute performs the kopia backup
func (s *KopiaStrategy) Execute(ctx context.Context, serviceName, directory string) error {
log.Printf("Performing kopia backup for service %s: %s", serviceName, directory)
// Get or create password for this service
password, err := getOrCreatePassword(serviceName, 48)
if err != nil {
return fmt.Errorf("failed to get or create password: %w", err)
}
// Ensure Kopia config directory exists
kopiaConfigDir := filepath.Join(os.Getenv("HOME"), ".kopia")
if err := os.MkdirAll(kopiaConfigDir, 0755); err != nil {
return fmt.Errorf("failed to create kopia config directory: %w", err)
}
// Repository not connected, connect or create via provider
log.Printf("Connecting to repository for %s", serviceName)
if err := s.Provider.Connect(ctx, serviceName, password, s.ConfigPath); err != nil {
return fmt.Errorf("failed to connect to repository: %w", err)
}
// Create snapshot
log.Printf("Creating snapshot for directory: %s", directory)
snapshotCmd := exec.Command("kopia", "--config-file", s.ConfigPath, "snapshot", "create", directory)
snapshotOutput, err := snapshotCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create snapshot: %w\nOutput: %s", err, snapshotOutput)
}
// Set retention policy
log.Printf("Setting retention policy for %s", serviceName)
args := []string{
"--config-file", s.ConfigPath,
"policy", "set",
"--keep-latest", fmt.Sprintf("%d", s.Retention.KeepLatest),
"--keep-hourly", fmt.Sprintf("%d", s.Retention.KeepHourly),
"--keep-daily", fmt.Sprintf("%d", s.Retention.KeepDaily),
"--keep-weekly", fmt.Sprintf("%d", s.Retention.KeepWeekly),
"--keep-monthly", fmt.Sprintf("%d", s.Retention.KeepMonthly),
"--keep-annual", fmt.Sprintf("%d", s.Retention.KeepYearly),
directory,
}
policyCmd := exec.Command("kopia", args...)
policyOutput, err := policyCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to set policy: %w\nOutput: %s", err, policyOutput)
}
log.Printf("Snapshot and policy set successfully for %s", serviceName)
return nil
}
// ListBackups returns information about existing backups
func (s *KopiaStrategy) ListBackups(ctx context.Context, serviceName string) ([]BackupInfo, error) {
// Parse service group and index from service name (e.g., "backealocal.1")
groupName, _, err := parseServiceName(serviceName)
if err != nil {
return nil, fmt.Errorf("invalid service name format: %w", err)
}
// Get service directory from config
factory, err := NewBackupFactory("config.yml")
if err != nil {
return nil, fmt.Errorf("failed to create factory: %w", err)
}
// Find service group
serviceGroup, exists := factory.Config.Services[groupName]
if !exists {
return nil, fmt.Errorf("service group not found: %s", groupName)
}
// Get directory from the service group level
directoryPath := strings.TrimRight(serviceGroup.Source.Path, "/")
// Ensure we're connected to the repository
err = s.EnsureRepositoryConnected(ctx, serviceName)
if err != nil {
return nil, fmt.Errorf("failed to connect to repository: %w", err)
}
// Run kopia snapshot list command with JSON output
cmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", s.ConfigPath,
"snapshot",
"list",
"--json",
)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to list snapshots: %w", err)
}
// Parse the JSON output
var snapshots []map[string]interface{}
if err := json.Unmarshal(output, &snapshots); err != nil {
return nil, fmt.Errorf("failed to parse snapshot list: %w", err)
}
// Convert to BackupInfo
var result []BackupInfo
for _, snap := range snapshots {
// Get basic info
id, _ := snap["id"].(string)
endTime, _ := snap["endTime"].(string)
// Only include snapshots for the requested service by directory path
source, ok := snap["source"].(map[string]interface{})
if !ok {
continue
}
sourcePath, _ := source["path"].(string)
// Match by exact directory path, not service name
if sourcePath != directoryPath {
continue
}
// Parse time
var creationTime time.Time
if endTime != "" {
t, err := time.Parse(time.RFC3339, endTime)
if err == nil {
creationTime = t
}
}
// Get size information from stats
var size int64
if stats, ok := snap["stats"].(map[string]interface{}); ok {
if totalSize, ok := stats["totalSize"].(float64); ok {
size = int64(totalSize)
}
}
// Determine retention tag
retentionTag := "none"
if reasons, ok := snap["retentionReason"].([]interface{}); ok && len(reasons) > 0 {
// Get the first reason which indicates the highest priority retention
if reason, ok := reasons[0].(string); ok {
parts := strings.SplitN(reason, "-", 2)
if len(parts) > 0 {
retentionTag = parts[0]
}
}
}
result = append(result, BackupInfo{
ID: id,
CreationTime: creationTime,
Size: size,
Source: sourcePath,
Type: "kopia",
RetentionTag: retentionTag,
})
}
return result, nil
}
// GetStorageUsage returns information about the total storage used by the repository
func (s *KopiaStrategy) GetStorageUsage(ctx context.Context, serviceName string) (*StorageUsageInfo, error) {
// Ensure we're connected to the repository
err := s.EnsureRepositoryConnected(ctx, serviceName)
if err != nil {
return nil, fmt.Errorf("failed to connect to repository: %w", err)
}
// Get provider type (b2, local, sftp)
providerType := "unknown"
switch s.Provider.(type) {
case *KopiaB2Provider:
providerType = "b2"
case *KopiaLocalProvider:
providerType = "local"
case *KopiaSFTPProvider:
providerType = "sftp"
}
// Initialize storage info
info := &StorageUsageInfo{
Provider: providerType,
ProviderID: s.Provider.GetBucketName(serviceName),
}
// Calculate logical size by summing up the sizes of all snapshots
backups, err := s.ListBackups(ctx, serviceName)
if err == nil {
var totalLogicalBytes int64
for _, backup := range backups {
totalLogicalBytes += backup.Size
}
info.TotalBytes = totalLogicalBytes
}
// Try to get physical storage stats using blob stats command
blobStatsCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", s.ConfigPath,
"blob",
"stats",
)
blobStatsOutput, err := blobStatsCmd.CombinedOutput()
if err == nil {
// Parse the text output
outputStr := string(blobStatsOutput)
log.Printf("Blob stats output: %s", outputStr)
// Look for the line with "Total:"
lines := strings.Split(outputStr, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Total:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
sizeStr := strings.TrimSpace(parts[1])
size, err := parseHumanSize(sizeStr)
if err == nil {
info.TotalBytes = size
log.Printf("Got physical size from blob stats: %d bytes", size)
break
} else {
log.Printf("Failed to parse size '%s': %v", sizeStr, err)
}
}
}
}
} else {
log.Printf("Blob stats command failed: %v - %s", err, string(blobStatsOutput))
}
return info, nil
}
// Helper method to ensure the repository is connected before operations
func (s *KopiaStrategy) EnsureRepositoryConnected(ctx context.Context, serviceName string) error {
// Check if kopia repository is already connected with config file
cmd := exec.Command("kopia", "--config-file", s.ConfigPath, "repository", "status")
err := cmd.Run()
if err != nil {
// Repository not connected, try to connect
password, err := getOrCreatePassword(serviceName, 48)
if err != nil {
return fmt.Errorf("failed to get password: %w", err)
}
// Connect using provider
if err := s.Provider.Connect(ctx, serviceName, password, s.ConfigPath); err != nil {
return fmt.Errorf("failed to connect to repository: %w", err)
}
}
return nil
}
// parseServiceName splits a service name in the format "group.index" into its components
func parseServiceName(serviceName string) (string, string, error) {
parts := strings.SplitN(serviceName, ".", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("service name must be in format 'group.index', got '%s'", serviceName)
}
return parts[0], parts[1], nil
}
// getOrCreatePassword retrieves a password from kopia.env or creates a new one if it doesn't exist
// Extracted to a package-level function since it's utility functionality
func getOrCreatePassword(serviceName string, length int) (string, error) {
// Define the expected key in the env file
// Replace dots with underscores for environment variable name
safeServiceName := strings.ReplaceAll(serviceName, ".", "_")
passwordKey := fmt.Sprintf("KOPIA_%s_PASSWORD", strings.ToUpper(safeServiceName))
// Try to read from kopia.env first
kopiaEnvPath := "kopia.env"
if _, err := os.Stat(kopiaEnvPath); err == nil {
// File exists, check if the password is already there
file, err := os.Open(kopiaEnvPath)
if err != nil {
return "", fmt.Errorf("failed to open kopia.env: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, passwordKey+"=") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
// Found the password, remove quotes if present
password := parts[1]
password = strings.Trim(password, "\"")
return password, nil
}
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("error reading kopia.env: %w", err)
}
}
// Password not found or file doesn't exist, generate a new password
password, err := generateSecurePassword(length)
if err != nil {
return "", fmt.Errorf("failed to generate password: %w", err)
}
// Create or append to kopia.env
file, err := os.OpenFile(kopiaEnvPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return "", fmt.Errorf("failed to open kopia.env for writing: %w", err)
}
defer file.Close()
// Write the new password entry with quotes
passwordEntry := fmt.Sprintf("%s=\"%s\"\n", passwordKey, password)
if _, err := file.WriteString(passwordEntry); err != nil {
return "", fmt.Errorf("failed to write to kopia.env: %w", err)
}
log.Printf("Created new password for service %s and stored in kopia.env", serviceName)
return password, nil
}
// generateSecurePassword creates a cryptographically secure random password
// using only safe characters that work well with command line tools
func generateSecurePassword(length int) (string, error) {
// Use a more robust but safe character set
// Avoiding characters that might cause shell interpretation issues
// No quotes, backslashes, spaces, or common special chars that need escaping
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
result := make([]byte, length)
// Create a secure source of randomness
randomBytes := make([]byte, length)
if _, err := rand.Read(randomBytes); err != nil {
return "", err
}
// Ensure minimum complexity requirements
// At least 2 uppercase, 2 lowercase, 2 digits
if length >= 6 {
// Force first characters to meet minimum requirements
result[0] = 'A' + byte(randomBytes[0]%26) // Uppercase
result[1] = 'A' + byte(randomBytes[1]%26) // Uppercase
result[2] = 'a' + byte(randomBytes[2]%26) // Lowercase
result[3] = 'a' + byte(randomBytes[3]%26) // Lowercase
result[4] = '0' + byte(randomBytes[4]%10) // Digit
result[5] = '0' + byte(randomBytes[5]%10) // Digit
// Fill the rest with random chars
for i := 6; i < length; i++ {
result[i] = chars[int(randomBytes[i])%len(chars)]
}
// Shuffle the result to avoid predictable pattern
for i := length - 1; i > 0; i-- {
j := int(randomBytes[i]) % (i + 1)
result[i], result[j] = result[j], result[i]
}
} else {
// For very short passwords just use random chars
for i := 0; i < length; i++ {
result[i] = chars[int(randomBytes[i])%len(chars)]
}
}
return string(result), nil
}
// parseHumanSize parses a human-readable size string (e.g., "32.8 MB") into bytes
func parseHumanSize(sizeStr string) (int64, error) {
parts := strings.Fields(sizeStr)
if len(parts) != 2 {
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
}
value, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return 0, fmt.Errorf("invalid size value: %w", err)
}
unit := strings.ToUpper(parts[1])
switch unit {
case "B":
return int64(value), nil
case "KB", "KIB":
return int64(value * 1024), nil
case "MB", "MIB":
return int64(value * 1024 * 1024), nil
case "GB", "GIB":
return int64(value * 1024 * 1024 * 1024), nil
case "TB", "TIB":
return int64(value * 1024 * 1024 * 1024 * 1024), nil
default:
return 0, fmt.Errorf("unknown size unit: %s", unit)
}
}
// RestoreBackup restores a backup with the given ID
func (s *KopiaStrategy) RestoreBackup(ctx context.Context, backupID string, serviceName string) error {
// Ensure repository is connected
err := s.EnsureRepositoryConnected(ctx, serviceName)
if err != nil {
return fmt.Errorf("failed to connect to repository: %w", err)
}
// Parse service group and index from service name
groupName, _, err := parseServiceName(serviceName)
if err != nil {
return fmt.Errorf("invalid service name format: %w", err)
}
// Get service directory from config
factory, err := NewBackupFactory("config.yml")
if err != nil {
return fmt.Errorf("failed to create factory: %w", err)
}
// Find service group
serviceGroup, exists := factory.Config.Services[groupName]
if !exists {
return fmt.Errorf("service group not found: %s", groupName)
}
// Create a temporary directory for restore
restoreDir := filepath.Join(os.TempDir(), fmt.Sprintf("backea-restore-%s-%d", serviceName, time.Now().Unix()))
if err := os.MkdirAll(restoreDir, 0755); err != nil {
return fmt.Errorf("failed to create restore directory: %w", err)
}
log.Printf("Restoring backup %s to temporary directory %s", backupID, restoreDir)
// Run kopia restore command to restore the snapshot to the temporary directory
restoreCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", s.ConfigPath,
"snapshot",
"restore",
backupID,
restoreDir,
)
restoreOutput, err := restoreCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to restore snapshot: %w\nOutput: %s", err, restoreOutput)
}
// Now we need to sync the restored data to the original directory
targetDir := serviceGroup.Source.Path
log.Printf("Syncing restored data from %s to %s", restoreDir, targetDir)
// Use rsync for the final transfer to avoid permissions issues
syncCmd := exec.CommandContext(
ctx,
"rsync",
"-av",
"--delete", // Delete extraneous files from target
restoreDir+"/", // Source directory with trailing slash to copy contents
targetDir, // Target directory
)
syncOutput, err := syncCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to sync restored data: %w\nOutput: %s", err, syncOutput)
}
// Clean up the temporary directory
go func() {
time.Sleep(5 * time.Minute) // Wait 5 minutes before cleaning up
log.Printf("Cleaning up temporary restore directory %s", restoreDir)
os.RemoveAll(restoreDir)
}()
log.Printf("Successfully restored backup %s to %s", backupID, targetDir)
return nil
}
// DownloadBackup provides a reader with backup summary information in text format
func (s *KopiaStrategy) DownloadBackup(ctx context.Context, backupID string, serviceName string) (io.ReadCloser, error) {
// Ensure repository is connected
err := s.EnsureRepositoryConnected(ctx, serviceName)
if err != nil {
return nil, fmt.Errorf("failed to connect to repository: %w", err)
}
// Create temporary directories for restore and ZIP creation
tempDir, err := os.MkdirTemp("", "backea-download-*")
if err != nil {
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
}
restoreDir := filepath.Join(tempDir, "restore")
zipFile := filepath.Join(tempDir, "backup.zip")
if err := os.MkdirAll(restoreDir, 0755); err != nil {
os.RemoveAll(tempDir)
return nil, fmt.Errorf("failed to create restore directory: %w", err)
}
// Restore the snapshot to the temporary directory using the proper snapshot ID
log.Printf("Restoring snapshot %s to temporary directory %s", backupID, restoreDir)
restoreCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", s.ConfigPath,
"snapshot",
"restore",
backupID,
restoreDir,
)
restoreOutput, err := restoreCmd.CombinedOutput()
if err != nil {
os.RemoveAll(tempDir)
return nil, fmt.Errorf("failed to restore snapshot: %w\nOutput: %s", err, restoreOutput)
}
// Create ZIP archive of the restored files
log.Printf("Creating ZIP archive at %s", zipFile)
// Use Go's zip package instead of command line tools
zipWriter, err := os.Create(zipFile)
if err != nil {
os.RemoveAll(tempDir)
return nil, fmt.Errorf("failed to create zip file: %w", err)
}
defer zipWriter.Close()
// Create ZIP writer
archive := zip.NewWriter(zipWriter)
defer archive.Close()
// Walk the restore directory and add files to ZIP
err = filepath.Walk(restoreDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip directories, we only want to add files
if info.IsDir() {
return nil
}
// Create ZIP header
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
// Make the path relative to the restore directory
relPath, err := filepath.Rel(restoreDir, path)
if err != nil {
return err
}
// Set the name in the archive to the relative path
header.Name = relPath
// Create the file in the ZIP
writer, err := archive.CreateHeader(header)
if err != nil {
return err
}
// Open the file
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
// Copy the file content to the ZIP
_, err = io.Copy(writer, file)
return err
})
// Close the ZIP writer
archive.Close()
zipWriter.Close()
if err != nil {
os.RemoveAll(tempDir)
return nil, fmt.Errorf("failed to create zip archive: %w", err)
}
// Open the ZIP file for reading
zipReader, err := os.Open(zipFile)
if err != nil {
os.RemoveAll(tempDir)
return nil, fmt.Errorf("failed to open zip archive: %w", err)
}
// Return a reader that will clean up when closed
return &cleanupReadCloser{
ReadCloser: zipReader,
cleanup: func() {
zipReader.Close()
os.RemoveAll(tempDir)
},
}, nil
}
// GetBackupInfo returns detailed information about a specific backup
func (s *KopiaStrategy) GetBackupInfo(ctx context.Context, backupID string, serviceName string) (*BackupInfo, error) {
err := s.EnsureRepositoryConnected(ctx, serviceName)
if err != nil {
return nil, fmt.Errorf("failed to connect to repository: %w", err)
}
// Run kopia snapshot describe command with JSON output
cmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", s.ConfigPath,
"snapshot",
"describe",
"--json",
backupID,
)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to describe snapshot: %w", err)
}
// Parse the JSON output
var snap map[string]interface{}
if err := json.Unmarshal(output, &snap); err != nil {
return nil, fmt.Errorf("failed to parse snapshot info: %w", err)
}
// Extract the relevant information
id, _ := snap["id"].(string)
endTime, _ := snap["endTime"].(string)
// Parse time
var creationTime time.Time
if endTime != "" {
t, err := time.Parse(time.RFC3339, endTime)
if err == nil {
creationTime = t
}
}
// Get size information from stats
var size int64
if stats, ok := snap["stats"].(map[string]interface{}); ok {
if totalSize, ok := stats["totalSize"].(float64); ok {
size = int64(totalSize)
}
}
// Get source path
sourcePath := ""
if source, ok := snap["source"].(map[string]interface{}); ok {
if path, ok := source["path"].(string); ok {
sourcePath = path
}
}
// Determine retention tag
retentionTag := "none"
if reasons, ok := snap["retentionReason"].([]interface{}); ok && len(reasons) > 0 {
// Get the first reason which indicates the highest priority retention
if reason, ok := reasons[0].(string); ok {
parts := strings.SplitN(reason, "-", 2)
if len(parts) > 0 {
retentionTag = parts[0]
}
}
}
return &BackupInfo{
ID: id,
CreationTime: creationTime,
Size: size,
Source: sourcePath,
Type: "kopia",
RetentionTag: retentionTag,
}, nil
}
// cleanupReadCloser is a wrapper around io.ReadCloser that performs cleanup when closed
type cleanupReadCloser struct {
io.ReadCloser
cleanup func()
}
// Close closes the underlying ReadCloser and performs cleanup
func (c *cleanupReadCloser) Close() error {
err := c.ReadCloser.Close()
c.cleanup()
return err
}
// extractServiceNameFromBackupID extracts the service name from a backup ID
// This is an approximation as the exact format depends on your ID structure
func extractServiceNameFromBackupID(backupID string) string {
// Kopia snapshot IDs don't directly include the service name
// You may need to adjust this based on your actual ID format
// Try to extract the pattern from your backups list
// If you're using ListBackups to find all backups, you might have
// a mapping of IDs to service names that you can use
// For this example, let's assume we're using environment variables to track this
envVar := fmt.Sprintf("BACKEA_SNAPSHOT_%s", backupID)
if serviceName := os.Getenv(envVar); serviceName != "" {
return serviceName
}
// Fallback: For testing, we'll return a default or parse from the first part
// This should be replaced with your actual logic
parts := strings.Split(backupID, "-")
if len(parts) > 0 {
return parts[0]
}
return "unknown"
}

View File

@ -0,0 +1,51 @@
package backup
import (
"context"
"fmt"
"log"
"os"
"os/exec"
)
// RsyncStrategy implements the backup strategy using rsync
type RsyncStrategy struct {
Options string
Destination Destination
}
// NewRsyncStrategy creates a new rsync strategy
func NewRsyncStrategy(options string, destination Destination) *RsyncStrategy {
return &RsyncStrategy{
Options: options,
Destination: destination,
}
}
// Execute performs the rsync backup
func (s *RsyncStrategy) Execute(ctx context.Context, serviceName, directory string) error {
log.Printf("Performing rsync backup for %s", serviceName)
// Build rsync command
dst := s.Destination
sshKeyOption := ""
// Without
if dst.SSHKey != "" {
sshKeyOption = fmt.Sprintf("-e 'ssh -i %s'", dst.SSHKey)
}
destination := fmt.Sprintf("%s:%s", dst.Host, dst.Path)
rsyncCmd := fmt.Sprintf("rsync %s %s %s %s",
s.Options,
sshKeyOption,
directory,
destination)
// Run rsync command
log.Printf("Running: %s", rsyncCmd)
cmd := exec.CommandContext(ctx, "sh", "-c", rsyncCmd)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

112
internal/backup/config.go Normal file
View File

@ -0,0 +1,112 @@
package backup
import (
"log"
"github.com/joho/godotenv"
"github.com/spf13/viper"
)
// Configuration holds the complete application configuration
type Configuration struct {
Defaults DefaultsConfig `mapstructure:"defaults"`
Services map[string]ServiceGroup `mapstructure:"services"`
}
// DefaultsConfig holds the default settings for all services
type DefaultsConfig struct {
Retention RetentionConfig `mapstructure:"retention"`
}
// ServiceGroup represents a service group with a common directory and multiple backup configurations
type ServiceGroup struct {
Directory string `mapstructure:"directory,omitempty"`
Source SourceConfig `mapstructure:"source"`
Hooks HooksConfig `mapstructure:"hooks"`
BackupConfigs map[string]BackupConfig `mapstructure:"backup_configs"`
}
// SourceConfig represents source configuration for backups
type SourceConfig struct {
Host string `mapstructure:"host"`
Path string `mapstructure:"path"`
SSHKey string `mapstructure:"ssh_key"`
}
// HooksConfig contains the hooks to run before and after the backup
type HooksConfig struct {
BeforeHook string `mapstructure:"before_hook"`
AfterHook string `mapstructure:"after_hook"`
}
// BackupConfig represents a specific backup configuration
type BackupConfig struct {
BackupStrategy StrategyConfig `mapstructure:"backup_strategy"`
}
// StrategyConfig represents a backup strategy configuration
type StrategyConfig struct {
Type string `mapstructure:"type"`
Provider string `mapstructure:"provider"`
Options string `mapstructure:"options,omitempty"`
Retention RetentionConfig `mapstructure:"retention,omitempty"`
Destination DestConfig `mapstructure:"destination"`
}
// RetentionConfig represents retention policy configuration
type RetentionConfig struct {
KeepLatest int `mapstructure:"keep_latest"`
KeepHourly int `mapstructure:"keep_hourly"`
KeepDaily int `mapstructure:"keep_daily"`
KeepWeekly int `mapstructure:"keep_weekly"`
KeepMonthly int `mapstructure:"keep_monthly"`
KeepYearly int `mapstructure:"keep_yearly"`
}
// DestConfig represents destination configuration for remote backups
type DestConfig struct {
Host string `mapstructure:"host,omitempty"`
Path string `mapstructure:"path"`
SSHKey string `mapstructure:"ssh_key,omitempty"`
}
// LoadConfig loads the application configuration from a file
func LoadConfig(configPath string) (*Configuration, error) {
if err := godotenv.Load(); err != nil {
log.Printf("Warning: Error loading .env file: %v", err)
}
viper.SetConfigFile(configPath)
if err := viper.ReadInConfig(); err != nil {
return nil, err
}
var config Configuration
if err := viper.Unmarshal(&config); err != nil {
return nil, err
}
// Apply default retention settings where needed
for serviceName, service := range config.Services {
for configID, backupConfig := range service.BackupConfigs {
// If retention is not set, use defaults
if isRetentionEmpty(backupConfig.BackupStrategy.Retention) {
backupConfig.BackupStrategy.Retention = config.Defaults.Retention
service.BackupConfigs[configID] = backupConfig
}
}
config.Services[serviceName] = service
}
return &config, nil
}
// isRetentionEmpty checks if a retention config is empty (all zeros)
func isRetentionEmpty(retention RetentionConfig) bool {
return retention.KeepLatest == 0 &&
retention.KeepHourly == 0 &&
retention.KeepDaily == 0 &&
retention.KeepWeekly == 0 &&
retention.KeepMonthly == 0 &&
retention.KeepYearly == 0
}

View File

@ -0,0 +1,277 @@
package backup
import (
b2_client "backea/internal/client"
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
// KopiaProvider defines the interface for different Kopia storage backends
type KopiaProvider interface {
// Connect connects to an existing repository or creates a new one
Connect(ctx context.Context, serviceName string, password string, configPath string) error
// GetRepositoryParams returns the parameters needed for repository operations
GetRepositoryParams(serviceName string) ([]string, error)
// GetBucketName returns the storage identifier (bucket name or path)
GetBucketName(serviceName string) string
// GetProviderType returns a string identifying the provider type
GetProviderType() string
}
// KopiaB2Provider implements the KopiaProvider interface for Backblaze B2
type KopiaB2Provider struct{}
// NewKopiaB2Provider creates a new B2 provider
func NewKopiaB2Provider() *KopiaB2Provider {
return &KopiaB2Provider{}
}
// Connect connects to a B2 repository
func (p *KopiaB2Provider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
// Load environment variables for B2 credentials
keyID := os.Getenv("B2_KEY_ID")
applicationKey := os.Getenv("B2_APPLICATION_KEY")
if keyID == "" || applicationKey == "" {
return fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
}
bucketName := p.GetBucketName(serviceName)
// Create B2 client and check if bucket exists, create if not
B2Client, _ := b2_client.NewClientFromEnv()
if B2Client == nil {
return fmt.Errorf("B2 client not initialized")
}
_, err := B2Client.GetBucket(ctx, bucketName)
if err != nil {
log.Printf("Bucket %s not found, creating...", bucketName)
_, err = B2Client.CreateBucket(ctx, bucketName, false)
if err != nil {
return fmt.Errorf("failed to create bucket: %w", err)
}
log.Printf("Created bucket: %s", bucketName)
}
// Try to connect to existing repository with config file
connectCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "connect", "b2",
"--bucket", bucketName,
"--key-id", keyID,
"--key", applicationKey,
"--password", password,
)
err = connectCmd.Run()
if err != nil {
// Connection failed, create new repository
log.Printf("Creating new B2 repository for %s", serviceName)
createCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "create", "b2",
"--bucket", bucketName,
"--key-id", keyID,
"--key", applicationKey,
"--password", password,
)
createOutput, err := createCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
}
}
return nil
}
// GetRepositoryParams returns parameters for B2 operations
func (p *KopiaB2Provider) GetRepositoryParams(serviceName string) ([]string, error) {
keyID := os.Getenv("B2_KEY_ID")
applicationKey := os.Getenv("B2_APPLICATION_KEY")
if keyID == "" || applicationKey == "" {
return nil, fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
}
bucketName := p.GetBucketName(serviceName)
return []string{
"b2",
"--bucket", bucketName,
"--key-id", keyID,
"--key", applicationKey,
}, nil
}
// Modify the GetBucketName method in your KopiaB2Provider
func (p *KopiaB2Provider) GetBucketName(serviceName string) string {
// Backblaze dont allow .
sanitized := strings.ReplaceAll(serviceName, ".", "-")
// Add a prefix to make the bucket name unique
return fmt.Sprintf("backea-%s", sanitized)
}
// GetProviderType returns the provider type identifier
func (p *KopiaB2Provider) GetProviderType() string {
return "b2"
}
// KopiaLocalProvider implements the KopiaProvider interface for local storage
type KopiaLocalProvider struct {
BasePath string
}
// NewKopiaLocalProvider creates a new local provider
func NewKopiaLocalProvider(basePath string) *KopiaLocalProvider {
// If basePath is empty, use a default location
if basePath == "" {
basePath = filepath.Join(os.Getenv("HOME"), ".backea", "repos")
}
return &KopiaLocalProvider{
BasePath: basePath,
}
}
// Connect connects to a local repository
func (p *KopiaLocalProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
repoPath := filepath.Join(p.BasePath, serviceName)
log.Printf("Connecting to local repository at %s with config: %s", repoPath, configPath)
// Ensure the directory exists
if err := os.MkdirAll(repoPath, 0755); err != nil {
return fmt.Errorf("failed to create repository directory: %w", err)
}
// Try to connect to existing repository with config file
connectCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "connect", "filesystem",
"--path", repoPath,
"--password", password,
)
err := connectCmd.Run()
if err != nil {
// Connection failed, create new repository
log.Printf("Creating new local repository for %s at destination: %s", serviceName, repoPath)
createCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "create", "filesystem",
"--path", repoPath,
"--password", password,
)
createOutput, err := createCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
}
}
return nil
}
// GetRepositoryParams returns parameters for local filesystem operations
func (p *KopiaLocalProvider) GetRepositoryParams(serviceName string) ([]string, error) {
repoPath := filepath.Join(p.BasePath, serviceName)
return []string{
"filesystem",
"--path", repoPath,
}, nil
}
// GetBucketName returns the path for a service
func (p *KopiaLocalProvider) GetBucketName(serviceName string) string {
return filepath.Join(p.BasePath, serviceName)
}
// GetProviderType returns the provider type identifier
func (p *KopiaLocalProvider) GetProviderType() string {
return "local"
}
// KopiaSFTPProvider implements the KopiaProvider interface for SFTP remote storage
type KopiaSFTPProvider struct {
Host string
BasePath string
Username string
KeyFile string
}
// NewKopiaSFTPProvider creates a new SFTP provider
func NewKopiaSFTPProvider(host, basePath, username, keyFile string) *KopiaSFTPProvider {
return &KopiaSFTPProvider{
Host: host,
BasePath: basePath,
Username: username,
KeyFile: keyFile,
}
}
// Connect connects to an SFTP repository
func (p *KopiaSFTPProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName)
// Try to connect to existing repository with config file
connectCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "connect", "sftp",
"--path", repoPath,
"--keyfile", p.KeyFile,
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
"--password", password,
)
err := connectCmd.Run()
if err != nil {
// Connection failed, create new repository
log.Printf("Creating new SFTP repository for %s at %s", serviceName, repoPath)
createCmd := exec.CommandContext(
ctx,
"kopia",
"--config-file", configPath,
"repository", "create", "sftp",
"--path", repoPath,
"--keyfile", p.KeyFile,
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
"--password", password,
)
createOutput, err := createCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
}
}
return nil
}
// GetRepositoryParams returns parameters for SFTP operations
func (p *KopiaSFTPProvider) GetRepositoryParams(serviceName string) ([]string, error) {
repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName)
return []string{
"sftp",
"--path", repoPath,
"--keyfile", p.KeyFile,
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
}, nil
}
// GetBucketName returns the path for a service
func (p *KopiaSFTPProvider) GetBucketName(serviceName string) string {
return fmt.Sprintf("%s/%s", p.BasePath, serviceName)
}
// GetProviderType returns the provider type identifier
func (p *KopiaSFTPProvider) GetProviderType() string {
return "sftp"
}

179
internal/backup/restore.go Normal file
View File

@ -0,0 +1,179 @@
package backup
import (
client "backea/internal/client"
"bufio"
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
// RestoreManager handles restoring files from Kopia backups
type RestoreManager struct {
B2Client *client.Client
}
// NewRestoreManager creates a new restore manager
func NewRestoreManager(b2Client *client.Client) *RestoreManager {
return &RestoreManager{
B2Client: b2Client,
}
}
// getStoredPassword retrieves a password from kopia.env file
func (r *RestoreManager) getStoredPassword(serviceName string) (string, error) {
// Define the expected key in the env file
passwordKey := fmt.Sprintf("KOPIA_%s_PASSWORD", strings.ToUpper(serviceName))
// Try to read from kopia.env
kopiaEnvPath := "kopia.env"
if _, err := os.Stat(kopiaEnvPath); err != nil {
return "", fmt.Errorf("kopia.env file not found: %w", err)
}
// File exists, check if the password is there
file, err := os.Open(kopiaEnvPath)
if err != nil {
return "", fmt.Errorf("failed to open kopia.env: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, passwordKey+"=") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
// Found the password, remove quotes if present
password := parts[1]
password = strings.Trim(password, "\"")
return password, nil
}
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("error reading kopia.env: %w", err)
}
return "", fmt.Errorf("password for service %s not found in kopia.env", serviceName)
}
// connectToRepository connects to an existing Kopia repository
func (r *RestoreManager) connectToRepository(ctx context.Context, serviceName string) error {
if r.B2Client == nil {
return fmt.Errorf("B2 client not initialized")
}
// Load environment variables for B2 credentials
keyID := os.Getenv("B2_KEY_ID")
applicationKey := os.Getenv("B2_APPLICATION_KEY")
if keyID == "" || applicationKey == "" {
return fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
}
// Get stored password for this service
password, err := r.getStoredPassword(serviceName)
if err != nil {
return fmt.Errorf("failed to get stored password: %w", err)
}
// Generate bucket name from service name
bucketName := fmt.Sprintf("backea-%s", strings.ToLower(serviceName))
// Check if bucket exists
_, err = r.B2Client.GetBucket(ctx, bucketName)
if err != nil {
return fmt.Errorf("bucket %s not found: %w", bucketName, err)
}
// Check if kopia repository is already connected
cmd := exec.Command("kopia", "repository", "status")
err = cmd.Run()
if err == nil {
// Already connected
log.Printf("Already connected to a repository, disconnecting first")
disconnectCmd := exec.Command("kopia", "repository", "disconnect")
if err := disconnectCmd.Run(); err != nil {
return fmt.Errorf("failed to disconnect from current repository: %w", err)
}
}
// Connect to the repository
log.Printf("Connecting to B2 repository for %s", serviceName)
connectCmd := exec.Command(
"kopia", "repository", "connect", "b2",
"--bucket", bucketName,
"--key-id", keyID,
"--key", applicationKey,
"--password", password,
)
connectOutput, err := connectCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to connect to repository: %w\nOutput: %s", err, connectOutput)
}
log.Printf("Successfully connected to repository for %s", serviceName)
return nil
}
// ListSnapshots lists all snapshots in the repository for a given service
func (r *RestoreManager) ListSnapshots(ctx context.Context, serviceName string) error {
// Connect to the repository
if err := r.connectToRepository(ctx, serviceName); err != nil {
return err
}
// List all snapshots
log.Printf("Listing snapshots for %s", serviceName)
listCmd := exec.Command("kopia", "snapshot", "list")
listOutput, err := listCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to list snapshots: %w\nOutput: %s", err, listOutput)
}
// Print the output
fmt.Println("Available snapshots:")
fmt.Println(string(listOutput))
return nil
}
// RestoreFile restores a file or directory from a specific snapshot to a target location
func (r *RestoreManager) RestoreFile(ctx context.Context, serviceName, snapshotID, sourcePath, targetPath string) error {
// Connect to the repository
if err := r.connectToRepository(ctx, serviceName); err != nil {
return err
}
// Create a subdirectory with the snapshot ID name
snapshotDirPath := filepath.Join(targetPath, snapshotID)
if err := os.MkdirAll(snapshotDirPath, 0755); err != nil {
return fmt.Errorf("failed to create target directory: %w", err)
}
// Construct the kopia restore command
log.Printf("Restoring from snapshot %s to %s", snapshotID, snapshotDirPath)
// Build the command with all required parameters
restoreCmd := exec.Command(
"kopia", "snapshot", "restore",
snapshotID, // Just the snapshot ID
snapshotDirPath, // Target location where files will be restored
)
// Execute the command and capture output
restoreOutput, err := restoreCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to restore files: %w\nOutput: %s", err, restoreOutput)
}
log.Printf("Successfully restored files to %s", snapshotDirPath)
log.Printf("Restore output: %s", string(restoreOutput))
return nil
}

View File

@ -0,0 +1,71 @@
package backup
import (
"backea/internal/mail"
"context"
"fmt"
"log"
"os"
"os/exec"
"time"
)
// Service represents a service to be backed up
type Service struct {
Name string
Directory string
Strategy Strategy
Mailer *mail.Mailer
}
// NewService creates a new backup service
func NewService(name string, directory string, strategy Strategy, mailer *mail.Mailer) *Service {
return &Service{
Name: name,
Directory: directory,
Strategy: strategy,
Mailer: mailer,
}
}
// Backup performs the backup for this service
func (s *Service) Backup(ctx context.Context) error {
log.Printf("Backing up service: %s", s.Name)
startTime := time.Now()
// Ensure directory exists
if _, err := os.Stat(s.Directory); os.IsNotExist(err) {
return fmt.Errorf("directory does not exist: %s", s.Directory)
}
// Execute the backup strategy
if err := s.Strategy.Execute(ctx, s.Name, s.Directory); err != nil {
if s.Mailer != nil {
s.Mailer.SendErrorNotification(s.Name, err)
}
return fmt.Errorf("backup failed: %w", err)
}
// Record backup completion
duration := time.Since(startTime)
log.Printf("Backup completed for %s in %v", s.Name, duration)
// Send success notification
if s.Mailer != nil {
s.Mailer.SendSuccessNotification(s.Name, duration)
}
return nil
}
// RunCommand executes a shell command in the specified directory
// This is now a package function rather than a method on Service
func RunCommand(dir, command string) error {
cmd := exec.Command("sh", "-c", command)
if dir != "" {
cmd.Dir = dir
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

View File

@ -0,0 +1,57 @@
package backup
import (
"context"
"io"
"time"
)
// Strategy defines the backup/restore strategy interface
type Strategy interface {
// Execute performs a backup
Execute(ctx context.Context, serviceName, directory string) error
ListBackups(ctx context.Context, serviceName string) ([]BackupInfo, error)
GetStorageUsage(ctx context.Context, serviceName string) (*StorageUsageInfo, error)
RestoreBackup(ctx context.Context, backupID string, serviceName string) error
DownloadBackup(ctx context.Context, backupID, serviceName string) (io.ReadCloser, error)
GetBackupInfo(context.Context, string, string) (*BackupInfo, error)
}
// BackupInfo contains information about a single backup
type BackupInfo struct {
ID string // Unique identifier for the backup
CreationTime time.Time // When the backup was created
Size int64 // Size in bytes
Source string // Original source path
Type string // Type of backup (e.g., "kopia", "restic")
RetentionTag string // Retention policy applied (e.g., "latest", "daily", "weekly")
}
// StorageUsageInfo contains information about backup storage usage
type StorageUsageInfo struct {
TotalBytes int64 // Total bytes stored
Provider string // Storage provider (e.g., "local", "b2", "s3")
ProviderID string // Provider-specific ID (bucket name, path, etc.)
}
// Retention represents retention policy for backups
type Retention struct {
KeepLatest int
KeepHourly int
KeepDaily int
KeepWeekly int
KeepMonthly int
KeepYearly int
}
// Destination represents rsync destination
type Destination struct {
Host string
Path string
SSHKey string
}

View File

@ -0,0 +1,91 @@
package b2_client
import (
"context"
"fmt"
"os"
"github.com/joho/godotenv"
"gopkg.in/kothar/go-backblaze.v0"
)
// Client represents a B2 client
type Client struct {
b2 *backblaze.B2
}
// NewClientFromEnv creates a new B2 client using credentials from .env
func NewClientFromEnv() (*Client, error) {
// Load .env file
if err := godotenv.Load(); err != nil {
return nil, fmt.Errorf("error loading .env file: %w", err)
}
// Get credentials from environment
keyID := os.Getenv("B2_KEY_ID")
applicationKey := os.Getenv("B2_APPLICATION_KEY")
if keyID == "" || applicationKey == "" {
return nil, fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
}
// Create B2 client
b2, err := backblaze.NewB2(backblaze.Credentials{
AccountID: keyID,
ApplicationKey: applicationKey,
})
if err != nil {
return nil, fmt.Errorf("error creating B2 client: %w", err)
}
return &Client{
b2: b2,
}, nil
}
// ListBuckets lists all buckets
func (c *Client) ListBuckets(ctx context.Context) ([]*backblaze.Bucket, error) {
// List all buckets
buckets, err := c.b2.ListBuckets()
if err != nil {
return nil, fmt.Errorf("error listing buckets: %w", err)
}
return buckets, nil
}
// GetBucket gets a bucket by name
func (c *Client) GetBucket(ctx context.Context, name string) (*backblaze.Bucket, error) {
// List all buckets
buckets, err := c.b2.ListBuckets()
if err != nil {
return nil, fmt.Errorf("error listing buckets: %w", err)
}
// Find bucket by name
for _, bucket := range buckets {
if bucket.Name == name {
return bucket, nil
}
}
return nil, fmt.Errorf("bucket not found: %s", name)
}
// CreateBucket creates a new bucket
func (c *Client) CreateBucket(ctx context.Context, name string, public bool) (*backblaze.Bucket, error) {
// Set bucket type
bucketType := backblaze.AllPrivate
if public {
bucketType = backblaze.AllPublic
}
// Create bucket
bucket, err := c.b2.CreateBucket(name, bucketType)
if err != nil {
return nil, fmt.Errorf("error creating bucket: %w", err)
}
return bucket, nil
}

104
internal/mail/mail.go Normal file
View File

@ -0,0 +1,104 @@
package mail
import (
"fmt"
"net/smtp"
"os"
"time"
)
// Config holds SMTP server configuration
type Config struct {
Host string
Port string
Username string
Password string
From string
}
// Client handles email operations
type Client struct {
Config Config
}
// Mailer is an alias for Client to maintain compatibility with backup package
type Mailer struct {
Config Config
}
// NewClient creates a new mail client from environment variables
func NewClient() *Client {
return &Client{
Config: Config{
Host: os.Getenv("SMTP_HOST"),
Port: os.Getenv("SMTP_PORT"),
Username: os.Getenv("SMTP_USERNAME"),
Password: os.Getenv("SMTP_PASSWORD"),
From: os.Getenv("SMTP_FROM"),
},
}
}
// NewMailer creates a new mailer from environment variables
func NewMailer() *Mailer {
client := NewClient()
return &Mailer{
Config: client.Config,
}
}
// SendMail sends an email notification
func (c *Client) SendMail(to []string, subject, body string) error {
addr := fmt.Sprintf("%s:%s", c.Config.Host, c.Config.Port)
// Compose message
message := []byte(fmt.Sprintf("From: %s\r\n"+
"To: %s\r\n"+
"Subject: %s\r\n"+
"\r\n"+
"%s\r\n", c.Config.From, to[0], subject, body))
// Authenticate
auth := smtp.PlainAuth("", c.Config.Username, c.Config.Password, c.Config.Host)
// Send mail
return smtp.SendMail(addr, auth, c.Config.From, to, message)
}
// SendMail sends an email notification (Mailer version)
func (m *Mailer) SendMail(to []string, subject, body string) error {
addr := fmt.Sprintf("%s:%s", m.Config.Host, m.Config.Port)
// Compose message
message := []byte(fmt.Sprintf("From: %s\r\n"+
"To: %s\r\n"+
"Subject: %s\r\n"+
"\r\n"+
"%s\r\n", m.Config.From, to[0], subject, body))
// Authenticate
auth := smtp.PlainAuth("", m.Config.Username, m.Config.Password, m.Config.Host)
// Send mail
return smtp.SendMail(addr, auth, m.Config.From, to, message)
}
// SendSuccessNotification sends a backup success notification
func (m *Mailer) SendSuccessNotification(serviceName string, duration time.Duration) error {
recipients := []string{os.Getenv("NOTIFICATION_EMAIL")}
if recipients[0] == "" {
recipients[0] = m.Config.From // Fallback to sender if no notification email set
}
subject := fmt.Sprintf("Backup Successful: %s", serviceName)
body := fmt.Sprintf("The backup for service %s completed successfully in %v.", serviceName, duration)
return m.SendMail(recipients, subject, body)
}
// SendErrorNotification sends a backup error notification
func (m *Mailer) SendErrorNotification(serviceName string, err error) error {
recipients := []string{os.Getenv("NOTIFICATION_EMAIL")}
if recipients[0] == "" {
recipients[0] = m.Config.From // Fallback to sender if no notification email set
}
subject := fmt.Sprintf("Backup Failed: %s", serviceName)
body := fmt.Sprintf("The backup for service %s failed with error: %v", serviceName, err)
return m.SendMail(recipients, subject, body)
}

View File

@ -0,0 +1,39 @@
package mail
import (
"os"
"testing"
"github.com/joho/godotenv"
)
func TestSendMailWithRealEnv(t *testing.T) {
// Load the real .env file
err := godotenv.Load()
if err != nil {
t.Skip("Skipping test: .env file not found")
}
// Check if SMTP configuration exists
host := os.Getenv("SMTP_HOST")
username := os.Getenv("SMTP_USERNAME")
password := os.Getenv("SMTP_PASSWORD")
if host == "" || username == "" || password == "" {
t.Skip("Skipping test: SMTP configuration not found in .env")
}
// Create a real client using environment variables
client := NewClient()
// Test with a real email address (consider using a test address)
recipients := []string{"romaric.sirii@gmail.com"}
subject := "Test Email from Go Test"
body := "This is a test email sent from the mail package test"
// Send the email
err = client.SendMail(recipients, subject, body)
if err != nil {
t.Errorf("SendMail failed with error: %v", err)
}
}

26
internal/server/server.go Normal file
View File

@ -0,0 +1,26 @@
package server
import (
"backea/internal/backup"
"backea/internal/web/routes"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// New creates a new configured Echo server
func New(factory *backup.BackupFactory) *echo.Echo {
e := echo.New()
// Add middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Setup static file serving
e.Static("/static", "static")
// Register routes
routes.RegisterRoutes(e, factory)
return e
}

View File

@ -0,0 +1,377 @@
package handlers
import (
"backea/internal/backup"
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/labstack/echo/v4"
)
// BackupActionsHandler handles backup-related actions like restore and download
type BackupActionsHandler struct {
backupFactory *backup.BackupFactory
}
// NewBackupActionsHandler creates a new backup actions handler
func NewBackupActionsHandler(factory *backup.BackupFactory) *BackupActionsHandler {
return &BackupActionsHandler{
backupFactory: factory,
}
}
// RestoreRequest represents the request data for a backup restore
type RestoreRequest struct {
BackupID string `json:"backupID" form:"backupID"`
GroupName string `json:"groupName" form:"groupName"`
ServiceIndex string `json:"serviceIndex" form:"serviceIndex"`
DestinationPath string `json:"destinationPath" form:"destinationPath"`
}
// RestoreBackup handles the request to restore a backup with destination path support
func (h *BackupActionsHandler) RestoreBackup(c echo.Context) error {
backupID := c.Param("backupID")
if backupID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "backupID is required")
}
// Try to parse the group and service from query parameters
groupName := c.QueryParam("groupName")
serviceIndex := c.QueryParam("serviceIndex")
destinationPath := c.QueryParam("destinationPath")
// If not in query parameters, check form data
if groupName == "" || serviceIndex == "" {
var req RestoreRequest
if err := c.Bind(&req); err == nil {
groupName = req.GroupName
serviceIndex = req.ServiceIndex
destinationPath = req.DestinationPath
}
}
if groupName == "" || serviceIndex == "" {
return echo.NewHTTPError(http.StatusBadRequest,
"groupName and serviceIndex are required either as query parameters or in the request body")
}
// Construct full service name
serviceName := fmt.Sprintf("%s.%s", groupName, serviceIndex)
log.Printf("Restoring backup ID=%s to service %s", backupID, serviceName)
if destinationPath != "" {
log.Printf("Restoring to custom destination: %s", destinationPath)
}
// Get the appropriate strategy for this service
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, serviceIndex)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Failed to get backup strategy: %v", err))
}
// If destination path is provided, use our custom restore function
if destinationPath != "" {
err = h.restoreBackupToCustomDestination(c.Request().Context(), strategy, backupID, serviceName, destinationPath)
} else {
// Otherwise use the regular restore
err = strategy.RestoreBackup(c.Request().Context(), backupID, serviceName)
}
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Failed to restore backup: %v", err))
}
return c.JSON(http.StatusOK, map[string]string{
"status": "success",
"message": fmt.Sprintf("Backup %s restored successfully", backupID),
})
}
// RestoreBackupForm shows the restore form with directory input
func (h *BackupActionsHandler) RestoreBackupForm(c echo.Context) error {
backupID := c.QueryParam("backupID")
groupName := c.QueryParam("groupName")
serviceIndex := c.QueryParam("serviceIndex")
if backupID == "" || groupName == "" || serviceIndex == "" {
return echo.NewHTTPError(http.StatusBadRequest, "backupID, groupName, and serviceIndex are required")
}
// Build service name for lookup
serviceName := fmt.Sprintf("%s.%s", groupName, serviceIndex)
// Get backup info if available
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, serviceIndex)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Failed to get backup strategy: %v", err))
}
// Try to get backup info
backupInfo, err := strategy.GetBackupInfo(c.Request().Context(), backupID, serviceName)
var backupTimeStr string
var backupSizeStr string
var defaultPath string
if err == nil && backupInfo != nil {
backupTimeStr = backupInfo.CreationTime.Format("Jan 02, 2006 15:04:05")
backupSizeStr = formatSize(backupInfo.Size)
defaultPath = backupInfo.Source // Use original source as default path
} else {
backupTimeStr = "Unknown"
backupSizeStr = "Unknown"
defaultPath = ""
}
// TODO : create a real template
return c.HTML(http.StatusOK, fmt.Sprintf(`
<div class="restore-modal gruvbox-bg1 round padding">
<h3 class="gruvbox-yellow">Restore Backup</h3>
<div class="backup-details">
<p><span class="gruvbox-fg-dim">Backup Date:</span> <span class="gruvbox-yellow">%s</span></p>
<p><span class="gruvbox-fg-dim">Size:</span> <span class="gruvbox-yellow">%s</span></p>
</div>
<form
id="restore-form"
hx-post="/api/backups/%s/restore"
hx-swap="outerHTML"
hx-indicator="#restore-indicator"
>
<input type="hidden" name="backupID" value="%s">
<input type="hidden" name="groupName" value="%s">
<input type="hidden" name="serviceIndex" value="%s">
<div class="form-group">
<label for="destinationPath" class="gruvbox-fg-dim">Destination Path:</label>
<input
type="text"
id="destinationPath"
name="destinationPath"
class="gruvbox-input"
value="%s"
placeholder="Leave empty to restore to original location"
>
</div>
<div class="form-group form-buttons">
<button type="submit" class="gruvbox-button gruvbox-bg2">
Restore
<span id="restore-indicator" class="htmx-indicator">
<span class="loading-spinner inline-spinner"></span>
</span>
</button>
<button
type="button"
class="gruvbox-button gruvbox-bg3"
onclick="document.querySelector('.restore-modal').remove()"
>
Cancel
</button>
</div>
</form>
</div>
`, backupTimeStr, backupSizeStr, backupID, backupID, groupName, serviceIndex, defaultPath))
}
// restoreBackupToCustomDestination handles restoring a backup to a custom directory
func (h *BackupActionsHandler) restoreBackupToCustomDestination(ctx context.Context, strategy backup.Strategy, backupID, serviceName, destinationPath string) error {
// Validate destination path
destinationPath = filepath.Clean(destinationPath)
// Check if destination exists
stat, err := os.Stat(destinationPath)
if err != nil {
if os.IsNotExist(err) {
// Create directory if it doesn't exist
if err := os.MkdirAll(destinationPath, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
} else {
return fmt.Errorf("failed to check destination directory: %w", err)
}
} else if !stat.IsDir() {
return fmt.Errorf("destination path is not a directory")
}
// Create a temporary directory to store the downloaded backup
tempDir, err := os.MkdirTemp("", "backea-restore-download-*")
if err != nil {
return fmt.Errorf("failed to create temporary download directory: %w", err)
}
defer os.RemoveAll(tempDir)
// Create a temporary directory for extraction
extractDir, err := os.MkdirTemp("", "backea-restore-extract-*")
if err != nil {
return fmt.Errorf("failed to create temporary extraction directory: %w", err)
}
defer os.RemoveAll(extractDir)
// Download the backup to a temporary file
log.Printf("Downloading backup %s to temporary location for service %s", backupID, serviceName)
reader, err := strategy.DownloadBackup(ctx, backupID, serviceName)
if err != nil {
return fmt.Errorf("failed to download backup: %w", err)
}
defer reader.Close()
// Create a temporary file to store the zip
zipFile, err := os.CreateTemp(tempDir, "backup-*.zip")
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
zipPath := zipFile.Name()
defer os.Remove(zipPath)
// Copy the zip content to the temp file
log.Printf("Writing backup data to temporary file: %s", zipPath)
_, err = io.Copy(zipFile, reader)
if err != nil {
zipFile.Close()
return fmt.Errorf("failed to write backup to disk: %w", err)
}
// Flush to disk and close the file
zipFile.Sync()
zipFile.Close()
// Extract the zip to the extraction directory
log.Printf("Extracting backup to %s", extractDir)
if err := extractZip(zipPath, extractDir); err != nil {
return fmt.Errorf("failed to extract backup: %w", err)
}
// Sync extracted files to the destination using rsync
log.Printf("Syncing files from %s to %s", extractDir, destinationPath)
if err := syncDirectories(extractDir, destinationPath); err != nil {
return fmt.Errorf("failed to sync files to destination: %w", err)
}
log.Printf("Backup successfully restored to %s", destinationPath)
return nil
}
// extractZip extracts a zip file to a destination directory
func extractZip(zipFile, destDir string) error {
// Use unzip command for extraction
cmd := exec.Command("unzip", "-o", "-q", zipFile, "-d", destDir)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to extract zip: %w, output: %s", err, string(output))
}
return nil
}
// syncDirectories syncs files from source to destination using rsync
func syncDirectories(src, dest string) error {
// Ensure source path ends with a slash to copy contents, not the directory itself
if !strings.HasSuffix(src, "/") {
src = src + "/"
}
cmd := exec.Command("rsync", "-av", "--delete", src, dest)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to sync directories: %w, output: %s", err, string(output))
}
return nil
}
// Helper function to format file size
func formatSize(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %cB", float64(size)/float64(div), "KMGTPE"[exp])
}
// DownloadBackup handles the backup download request
func (h *BackupActionsHandler) DownloadBackup(c echo.Context) error {
// Get query parameters
backupID := c.QueryParam("backupID")
groupName := c.QueryParam("groupName")
serviceIndex := c.QueryParam("serviceIndex")
// Validate required parameters
if backupID == "" || groupName == "" || serviceIndex == "" {
return echo.NewHTTPError(http.StatusBadRequest, "backupID, groupName, and serviceIndex are required")
}
// Create service name for logging
serviceName := fmt.Sprintf("%s.%s", groupName, serviceIndex)
log.Printf("Downloading backup: ID=%s, Service=%s", backupID, serviceName)
// Get the appropriate strategy for this service
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, serviceIndex)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Failed to get backup strategy: %v", err))
}
// Get all backups for this service
backups, err := strategy.ListBackups(c.Request().Context(), serviceName)
if err != nil {
log.Printf("Warning: Failed to list backups: %v. Will try with provided ID directly.", err)
} else {
// Look for a matching backup with the proper snapshot ID
found := false
for _, backup := range backups {
// If we find an exact match, or if the backup ID contains our ID (partial match)
if backup.ID == backupID {
found = true
break
}
}
if !found {
log.Printf("Warning: Backup ID %s not found in service backups. Will try direct download anyway.", backupID)
}
}
// Attempt to download the backup using the provided ID
backupReader, err := strategy.DownloadBackup(c.Request().Context(), backupID, serviceName)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Failed to download backup: %v", err))
}
defer backupReader.Close()
// Set filename for download
filename := fmt.Sprintf("%s_%s_%s.zip",
groupName,
serviceIndex,
time.Now().Format("20060102_150405"),
)
// Set response headers for zip download
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Response().Header().Set("Content-Type", "application/zip")
// Stream the file to the client
log.Printf("Streaming backup as %s", filename)
_, err = io.Copy(c.Response().Writer, backupReader)
if err != nil {
log.Printf("Error streaming backup: %v", err)
return err
}
log.Printf("Backup download for %s completed successfully", backupID)
return nil
}

View File

@ -0,0 +1,317 @@
package handlers
import (
"backea/internal/backup"
"backea/templates"
"context"
"fmt"
"log"
"net/http"
"os"
"sort"
"sync"
"time"
"github.com/labstack/echo/v4"
)
type HomepageHandler struct {
backupFactory *backup.BackupFactory
}
func NewHomepageHandler(factory *backup.BackupFactory) *HomepageHandler {
return &HomepageHandler{
backupFactory: factory,
}
}
// Home handles the homepage request and displays latest backups by service
func (h *HomepageHandler) Home(c echo.Context) error {
// Create the data structures
serviceBackups := make(map[string]map[string][]backup.BackupInfo)
serviceConfigs := make(map[string]map[string]templates.ServiceProviderInfo)
groupDirectories := make(map[string]string) // Store directories by group name
// Process each service group
for groupName, serviceGroup := range h.backupFactory.Config.Services {
// Initialize maps for this group
serviceBackups[groupName] = make(map[string][]backup.BackupInfo)
serviceConfigs[groupName] = make(map[string]templates.ServiceProviderInfo)
// Store the directory at the group level in a separate map
groupDirectories[groupName] = serviceGroup.Source.Path
// Process each backup config in the group
for configIndex, backupConfig := range serviceGroup.BackupConfigs {
// Store service configuration
serviceConfigs[groupName][configIndex] = templates.ServiceProviderInfo{
Type: backupConfig.BackupStrategy.Type,
Provider: backupConfig.BackupStrategy.Provider,
Directory: serviceGroup.Source.Path,
}
}
}
// Get sorted group names for alphabetical ordering
var sortedGroupNames []string
for groupName := range serviceBackups {
sortedGroupNames = append(sortedGroupNames, groupName)
}
sort.Strings(sortedGroupNames)
// Render the template with sorted group names and directories
// We don't load actual backup data on initial page load, it will be loaded via HTMX
component := templates.Home(serviceBackups, serviceConfigs, sortedGroupNames, groupDirectories)
return component.Render(c.Request().Context(), c.Response().Writer)
}
// ServiceGroupHeader returns the header section for a specific service group (HTMX endpoint)
func (h *HomepageHandler) ServiceGroupHeader(c echo.Context) error {
groupName := c.Param("groupName")
// Check if the service group exists
serviceGroup, exists := h.backupFactory.Config.Services[groupName]
if !exists {
return echo.NewHTTPError(404, "Service group not found")
}
// Create data structures
serviceBackups := make(map[string][]backup.BackupInfo)
serviceConfigs := make(map[string]templates.ServiceProviderInfo)
// Setup synchronization
var wg sync.WaitGroup
var mu sync.Mutex
// Process each backup config in the group
for configIndex, backupConfig := range serviceGroup.BackupConfigs {
// Store service configuration
serviceConfigs[configIndex] = templates.ServiceProviderInfo{
Type: backupConfig.BackupStrategy.Type,
Provider: backupConfig.BackupStrategy.Provider,
Directory: serviceGroup.Source.Path,
}
// Fetch backups in parallel
wg.Add(1)
go func(index string) {
defer wg.Done()
// Get backup strategy
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, index)
if err != nil {
log.Printf("Error creating strategy for %s.%s: %v", groupName, index, err)
return
}
// Get backups
backups, err := strategy.ListBackups(context.Background(), groupName+"."+index)
if err != nil {
log.Printf("Error listing backups for %s.%s: %v", groupName, index, err)
return
}
// Sort backups by time (newest first)
sort.Slice(backups, func(i, j int) bool {
return backups[i].CreationTime.After(backups[j].CreationTime)
})
// Store result
mu.Lock()
serviceBackups[index] = backups
mu.Unlock()
}(configIndex)
}
// Wait for all goroutines to finish
wg.Wait()
// Render just the header component
component := templates.GroupHeaderComponent(
groupName,
serviceBackups,
serviceConfigs,
serviceGroup.Source.Path,
)
return component.Render(c.Request().Context(), c.Response().Writer)
}
// ServiceGroupBackups returns just the backups table for a specific service group (HTMX endpoint)
func (h *HomepageHandler) ServiceGroupBackups(c echo.Context) error {
groupName := c.Param("groupName")
// Check if the service group exists
serviceGroup, exists := h.backupFactory.Config.Services[groupName]
if !exists {
return echo.NewHTTPError(404, "Service group not found")
}
// Create data structures
serviceBackups := make(map[string][]backup.BackupInfo)
serviceConfigs := make(map[string]templates.ServiceProviderInfo)
// Setup synchronization
var wg sync.WaitGroup
var mu sync.Mutex
// Process each backup config in the group
for configIndex, backupConfig := range serviceGroup.BackupConfigs {
// Store service configuration
serviceConfigs[configIndex] = templates.ServiceProviderInfo{
Type: backupConfig.BackupStrategy.Type,
Provider: backupConfig.BackupStrategy.Provider,
Directory: serviceGroup.Source.Path,
}
// Fetch backups in parallel
wg.Add(1)
go func(index string) {
defer wg.Done()
// Get backup strategy
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, index)
if err != nil {
log.Printf("Error creating strategy for %s.%s: %v", groupName, index, err)
return
}
// Get backups
backups, err := strategy.ListBackups(context.Background(), groupName+"."+index)
if err != nil {
log.Printf("Error listing backups for %s.%s: %v", groupName, index, err)
return
}
// Sort backups by time (newest first)
sort.Slice(backups, func(i, j int) bool {
return backups[i].CreationTime.After(backups[j].CreationTime)
})
// Store result
mu.Lock()
serviceBackups[index] = backups
mu.Unlock()
}(configIndex)
}
// Wait for all goroutines to finish
wg.Wait()
// Create a map with just the group for the template
groupServiceBackups := make(map[string]map[string][]backup.BackupInfo)
groupServiceBackups[groupName] = serviceBackups
// Create a map with just the group configs for the template
groupServiceConfigs := make(map[string]map[string]templates.ServiceProviderInfo)
groupServiceConfigs[groupName] = serviceConfigs
// Render only the backups table component
component := templates.ServiceGroupBackupsTable(groupName, serviceBackups, groupServiceConfigs)
return component.Render(c.Request().Context(), c.Response().Writer)
}
// ServiceGroupAllBackups returns all backups for a specific service group (HTMX endpoint)
func (h *HomepageHandler) ServiceGroupAllBackups(c echo.Context) error {
groupName := c.Param("groupName")
// Check if the service group exists
serviceGroup, exists := h.backupFactory.Config.Services[groupName]
if !exists {
return echo.NewHTTPError(404, "Service group not found")
}
// Create data structures
serviceBackups := make(map[string][]backup.BackupInfo)
serviceConfigs := make(map[string]templates.ServiceProviderInfo)
// Setup synchronization
var wg sync.WaitGroup
var mu sync.Mutex
// Process each backup config in the group
for configIndex, backupConfig := range serviceGroup.BackupConfigs {
// Store service configuration
serviceConfigs[configIndex] = templates.ServiceProviderInfo{
Type: backupConfig.BackupStrategy.Type,
Provider: backupConfig.BackupStrategy.Provider,
Directory: serviceGroup.Source.Path,
}
// Fetch backups in parallel
wg.Add(1)
go func(index string) {
defer wg.Done()
// Get backup strategy
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, index)
if err != nil {
log.Printf("Error creating strategy for %s.%s: %v", groupName, index, err)
return
}
// Get backups
backups, err := strategy.ListBackups(context.Background(), groupName+"."+index)
if err != nil {
log.Printf("Error listing backups for %s.%s: %v", groupName, index, err)
return
}
// Sort backups by time (newest first)
sort.Slice(backups, func(i, j int) bool {
return backups[i].CreationTime.After(backups[j].CreationTime)
})
// Store result
mu.Lock()
serviceBackups[index] = backups
mu.Unlock()
}(configIndex)
}
// Wait for all goroutines to finish
wg.Wait()
// Create a map with just the group for the template
groupServiceBackups := make(map[string]map[string][]backup.BackupInfo)
groupServiceBackups[groupName] = serviceBackups
// Create a map with just the group configs for the template
groupServiceConfigs := make(map[string]map[string]templates.ServiceProviderInfo)
groupServiceConfigs[groupName] = serviceConfigs
// Also trigger a header update to refresh stats
go func() {
// Create a new context since the original one might be cancelled
ctx := context.Background()
// Sleep briefly to ensure the table loads first
time.Sleep(100 * time.Millisecond)
// Make an HTTP request to refresh the header
url := fmt.Sprintf("http://localhost:%s/api/service-group/%s/header",
os.Getenv("PORT"),
groupName,
)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
log.Printf("Error creating request to refresh header: %v", err)
return
}
// Set HTMX headers to target the correct element
req.Header.Set("HX-Request", "true")
req.Header.Set("HX-Target", fmt.Sprintf("group-header-%s", groupName))
// Make the request
client := &http.Client{}
_, err = client.Do(req)
if err != nil {
log.Printf("Error refreshing header: %v", err)
}
}()
// Render the all backups table component
component := templates.ServiceGroupAllBackupsTable(groupName, serviceBackups, groupServiceConfigs)
return component.Render(c.Request().Context(), c.Response().Writer)
}

View File

@ -0,0 +1,34 @@
package routes
import (
"backea/internal/backup"
"backea/internal/web/handlers"
"fmt"
"github.com/labstack/echo/v4"
)
// RegisterRoutes sets up all the routes for the web application
func RegisterRoutes(e *echo.Echo, factory *backup.BackupFactory) {
// Create handlers with backup factory
homeHandler := handlers.NewHomepageHandler(factory)
actionsHandler := handlers.NewBackupActionsHandler(factory)
// Register main routes
e.GET("/", homeHandler.Home)
// Register HTMX API endpoints for lazy loading
e.GET("/api/service-group/:groupName/header", homeHandler.ServiceGroupHeader)
e.GET("/api/service-group/:groupName/backups", homeHandler.ServiceGroupBackups)
e.GET("/api/service-group/:groupName/all-backups", homeHandler.ServiceGroupAllBackups)
// Register backup action routes - using named parameter for backupID
e.POST("/api/backups/:backupID/restore", actionsHandler.RestoreBackup)
e.GET("/api/backups/restore-form", actionsHandler.RestoreBackupForm)
e.GET("/api/backups/download", actionsHandler.DownloadBackup)
// Add this after routes registration but before server start to see all routes
for _, route := range e.Routes() {
fmt.Printf("Method: %s, Path: %s, Handler: %s\n", route.Method, route.Path, route.Name)
}
}

505
templates/home.templ Normal file
View File

@ -0,0 +1,505 @@
package templates
import (
"backea/internal/backup"
"backea/templates/layouts"
"fmt"
"sort"
"time"
)
// FormatSize formats byte size to human-readable format
func FormatSize(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %cB", float64(size)/float64(div), "KMGTPE"[exp])
}
// FormatTime formats time to a readable format
func FormatTime(t time.Time) string {
return t.Format("Jan 02, 2006 15:04:05")
}
// FormatTimeSince formats the duration since a time in a human-readable way
func FormatTimeSince(t time.Time) string {
timeSince := time.Since(t)
hours := int(timeSince.Hours())
if hours < 1 {
return fmt.Sprintf("%d minutes ago", int(timeSince.Minutes()))
}
return fmt.Sprintf("%d hours ago", hours)
}
// GetStatusClass returns the appropriate status class based on backup age
func GetStatusClass(t time.Time) string {
timeSince := time.Since(t)
hours := int(timeSince.Hours())
if hours > 72 {
return "Failed"
} else if hours > 24 {
return "Warning"
}
return "Healthy"
}
// FormatServiceName returns a display-friendly service name
func FormatServiceName(groupName, serviceIndex string) string {
if serviceIndex == "" {
return groupName
}
return fmt.Sprintf("%s - %s", groupName, serviceIndex)
}
// CalculateTotalSize calculates total size of all backups
func CalculateTotalSize(backups []backup.BackupInfo) int64 {
var total int64
for _, b := range backups {
total += b.Size
}
return total
}
// CalculateGroupTotalSize calculates total size of all backups for a service group
func CalculateGroupTotalSize(serviceGroup map[string][]backup.BackupInfo) int64 {
var total int64
for _, backups := range serviceGroup {
for _, b := range backups {
total += b.Size
}
}
return total
}
// GetGroupTotalBackupCount returns the total number of backups across all services in a group
func GetGroupTotalBackupCount(serviceGroup map[string][]backup.BackupInfo) int {
count := 0
for _, backups := range serviceGroup {
count += len(backups)
}
return count
}
// GetLatestBackupTime returns the most recent backup time for a service group
func GetLatestBackupTime(serviceGroup map[string][]backup.BackupInfo) (time.Time, bool) {
var latestTime time.Time
found := false
for _, backups := range serviceGroup {
if len(backups) > 0 && (latestTime.IsZero() || backups[0].CreationTime.After(latestTime)) {
latestTime = backups[0].CreationTime
found = true
}
}
return latestTime, found
}
// GetGroupStatus returns the status of a service group based on the most recent backup
func GetGroupStatus(serviceGroup map[string][]backup.BackupInfo) string {
latestTime, found := GetLatestBackupTime(serviceGroup)
if !found {
return "No Backups"
}
return GetStatusClass(latestTime)
}
// ServiceProviderInfo holds the backup strategy info for a service
type ServiceProviderInfo struct {
Type string
Provider string
Directory string
}
// BackupWithService represents a backup with its service identifier
type BackupWithService struct {
ServiceIndex string
Backup backup.BackupInfo
}
// GetSortedBackups collects all backups from a service group and sorts them by time
func GetSortedBackups(serviceGroup map[string][]backup.BackupInfo) []BackupWithService {
var allBackups []BackupWithService
// Collect all backups with their service indices
for serviceIndex, backups := range serviceGroup {
for _, b := range backups {
allBackups = append(allBackups, BackupWithService{
ServiceIndex: serviceIndex,
Backup: b,
})
}
}
// Sort by creation time (newest first)
sort.Slice(allBackups, func(i, j int) bool {
return allBackups[i].Backup.CreationTime.After(allBackups[j].Backup.CreationTime)
})
return allBackups
}
// Home renders the homepage with lazy-loaded backup information
templ Home(serviceBackups map[string]map[string][]backup.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo, sortedGroupNames []string, groupDirectories map[string]string) {
@layouts.Base("Backea - Backup Dashboard") {
<div class="responsive gruvbox-dark">
<div class="gruvbox-bg-hard round padding margin-bottom">
<h1 class="extra-large medium gruvbox-aqua">Welcome to Backea</h1>
<p class="large gruvbox-blue">Unified guardians.</p>
</div>
<h2 class="large medium margin-bottom gruvbox-orange">Latest Backups by Service</h2>
<!-- Check if we have any backups -->
if len(serviceBackups) == 0 {
<div id="no-backups" class="gruvbox-bg1 gruvbox-yellow-border round border padding">
<p class="gruvbox-yellow">No backup services configured or no backups found.</p>
</div>
} else {
<div id="has-backups">
<!-- Loop through groups in alphabetical order -->
for _, groupName := range sortedGroupNames {
<div class="margin-bottom-large service-group-container" id={ fmt.Sprintf("group-%s", groupName) }>
<!-- Header section with loading skeleton - will be replaced with actual header -->
<div id={ fmt.Sprintf("group-header-%s", groupName) }
hx-get={ fmt.Sprintf("/api/service-group/%s/header", groupName) }
hx-trigger="load once"
hx-swap="outerHTML">
<div class="gruvbox-bg1 round padding margin-bottom">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 class="medium">
<!-- Display directory from groupDirectories map -->
if directory, exists := groupDirectories[groupName]; exists && directory != "" {
<span class="gruvbox-orange">{ directory }</span>
} else {
<span class="gruvbox-green">Unknown Directory</span>
}
<span class="gruvbox-green">{ groupName }</span>
</h3>
<div>
<!-- Display backup type and provider based on first service in group -->
if len(serviceBackups[groupName]) > 0 {
for serviceIndex := range serviceBackups[groupName] {
if providerInfo, exists := serviceConfigs[groupName][serviceIndex]; exists {
<span class="backup-tool-tag gruvbox-blue">{ providerInfo.Type }</span>
if providerInfo.Provider == "b2" || providerInfo.Provider == "backblaze" {
<span class="backup-tool-tag gruvbox-purple">B2 Backblaze</span>
} else if providerInfo.Provider == "ftp" {
<span class="backup-tool-tag gruvbox-yellow">FTP</span>
} else if providerInfo.Provider == "ssh" || providerInfo.Provider == "sftp" {
<span class="backup-tool-tag gruvbox-orange">SSH</span>
} else if providerInfo.Provider == "s3" {
<span class="backup-tool-tag gruvbox-red">S3</span>
} else {
<span class="backup-tool-tag gruvbox-green">{ providerInfo.Provider }</span>
}
}
}
} else {
<span class="backup-tool-tag gruvbox-blue">Unknown</span>
}
</div>
</div>
<!-- Group Summary stats with skeleton loading -->
<div style="display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.5rem;">
<div>
<p class="gruvbox-fg-dim">Total Size</p>
<p class="gruvbox-yellow skeleton-text">--</p>
</div>
<div>
<p class="gruvbox-fg-dim">Backups</p>
<p class="gruvbox-yellow skeleton-text">--</p>
</div>
<div>
<p class="gruvbox-fg-dim">Last Backup</p>
<p class="gruvbox-yellow skeleton-text">--</p>
</div>
<div>
<p class="gruvbox-fg-dim">Status</p>
<p class="gruvbox-yellow skeleton-text">--</p>
</div>
</div>
</div>
</div>
<!-- Always render the lazy-loading skeleton for backup table -->
<div class="table-skeleton"
id={ fmt.Sprintf("skeleton-%s", groupName) }
hx-get={ fmt.Sprintf("/api/service-group/%s/backups", groupName) }
hx-trigger="load once"
hx-swap="outerHTML"
hx-indicator={ fmt.Sprintf("#loading-indicator-%s", groupName) }>
<div class="skeleton-table gruvbox-bg1 round padding">
<div class="skeleton-header">
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
</div>
<div class="skeleton-row">
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
</div>
<div class="skeleton-row">
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell"></div>
</div>
</div>
<div id={ fmt.Sprintf("loading-indicator-%s", groupName) } class="htmx-indicator">
<div class="loading-spinner"></div>
</div>
</div>
</div>
}
</div>
}
</div>
}
}
// GroupHeaderComponent renders just the group header with up-to-date stats
templ GroupHeaderComponent(groupName string, serviceBackups map[string][]backup.BackupInfo, serviceConfigs map[string]ServiceProviderInfo, directory string) {
<div id={ fmt.Sprintf("group-header-%s", groupName) } class="gruvbox-bg1 round padding margin-bottom">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 class="medium">
if directory != "" {
<span class="gruvbox-orange">{ directory }</span>
} else {
<span class="gruvbox-green">Unknown Directory</span>
}
<span class="gruvbox-green">{ groupName }</span>
</h3>
<div>
<!-- Display backup type and provider based on first service in group -->
if len(serviceBackups) > 0 {
for serviceIndex := range serviceBackups {
if providerInfo, exists := serviceConfigs[serviceIndex]; exists {
<span class="backup-tool-tag gruvbox-blue">{ providerInfo.Type }</span>
if providerInfo.Provider == "b2" || providerInfo.Provider == "backblaze" {
<span class="backup-tool-tag gruvbox-purple">B2 Backblaze</span>
} else if providerInfo.Provider == "ftp" {
<span class="backup-tool-tag gruvbox-yellow">FTP</span>
} else if providerInfo.Provider == "ssh" || providerInfo.Provider == "sftp" {
<span class="backup-tool-tag gruvbox-orange">SSH</span>
} else if providerInfo.Provider == "s3" {
<span class="backup-tool-tag gruvbox-red">S3</span>
} else {
<span class="backup-tool-tag gruvbox-green">{ providerInfo.Provider }</span>
}
}
}
} else {
<span class="backup-tool-tag gruvbox-blue">Unknown</span>
}
</div>
</div>
<!-- Group Summary stats with actual data -->
<div style="display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.5rem;">
<div>
<p class="gruvbox-fg-dim">Total Size</p>
<p class="gruvbox-yellow">{ FormatSize(CalculateGroupTotalSize(serviceBackups)) }</p>
</div>
<div>
<p class="gruvbox-fg-dim">Backups</p>
<p class="gruvbox-yellow">{ fmt.Sprintf("%d", GetGroupTotalBackupCount(serviceBackups)) }</p>
</div>
<div>
<p class="gruvbox-fg-dim">Last Backup</p>
if latestTime, found := GetLatestBackupTime(serviceBackups); found {
<p class="gruvbox-yellow">{ FormatTimeSince(latestTime) }</p>
} else {
<p class="gruvbox-yellow">Never</p>
}
</div>
<div>
<p class="gruvbox-fg-dim">Status</p>
if status := GetGroupStatus(serviceBackups); status == "No Backups" {
<p class="gruvbox-red">No Backups</p>
} else if status == "Failed" {
<p class="gruvbox-red">Failed</p>
} else if status == "Warning" {
<p class="gruvbox-yellow">Warning</p>
} else {
<p class="gruvbox-green">Healthy</p>
}
</div>
</div>
</div>
}
// Updated table templates with action column
templ ServiceGroupBackupsTable(groupName string, serviceGroup map[string][]backup.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo) {
<div class="group-backups-table" id={ fmt.Sprintf("backups-%s", groupName) }>
if GetGroupTotalBackupCount(serviceGroup) > 0 {
<div class="overflow">
<table class="gruvbox-table">
<thead>
<tr class="gruvbox-bg1">
<th class="padding-small border-bottom left-align gruvbox-purple">Service</th>
<th class="padding-small border-bottom left-align gruvbox-purple">Date</th>
<th class="padding-small border-bottom left-align gruvbox-purple">Size</th>
<th class="padding-small border-bottom left-align gruvbox-purple">Type</th>
<th class="padding-small border-bottom left-align gruvbox-purple">Retention</th>
<th class="padding-small border-bottom left-align gruvbox-purple">Location</th>
<th class="padding-small border-bottom left-align gruvbox-purple">Actions</th>
</tr>
</thead>
<tbody>
<!-- Get all backups from all services in this group, sorted by creation time -->
@renderSortedBackups(groupName, GetSortedBackups(serviceGroup), 5, serviceConfigs)
</tbody>
</table>
<!-- "View all" link - now using HTMX to replace the table content -->
if GetGroupTotalBackupCount(serviceGroup) > 5 {
<div class="margin-top right-align">
<button class="gruvbox-button gruvbox-bg2"
hx-get={ fmt.Sprintf("/api/service-group/%s/all-backups", groupName) }
hx-target={ fmt.Sprintf("#backups-%s", groupName) }
hx-swap="innerHTML"
hx-indicator={ fmt.Sprintf("#view-all-indicator-%s", groupName) }>
View all { fmt.Sprintf("%d", GetGroupTotalBackupCount(serviceGroup)) } backups
<span id={ fmt.Sprintf("view-all-indicator-%s", groupName) } class="htmx-indicator">
<span class="loading-spinner inline-spinner"></span>
</span>
</button>
</div>
}
</div>
} else {
<div class="gruvbox-bg1 round padding">
<p class="gruvbox-yellow">No backups found for this service.</p>
<p class="gruvbox-fg-dim">Backups will appear here when created.</p>
</div>
}
</div>
}
// ServiceGroupAllBackupsTable template for showing all backups
templ ServiceGroupAllBackupsTable(groupName string, serviceGroup map[string][]backup.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo) {
<!-- We're returning just the INNER content now, not the whole container -->
if GetGroupTotalBackupCount(serviceGroup) > 0 {
<div class="overflow">
<table class="gruvbox-table">
<thead>
<tr class="gruvbox-bg1">
<th class="padding-small border-bottom left-align gruvbox-purple">Service</th>
<th class="padding-small border-bottom left-align gruvbox-purple">Date</th>
<th class="padding-small border-bottom left-align gruvbox-purple">Size</th>
<th class="padding-small border-bottom left-align gruvbox-purple">Type</th>
<th class="padding-small border-bottom left-align gruvbox-purple">Retention</th>
<th class="padding-small border-bottom left-align gruvbox-purple">Location</th>
<th class="padding-small border-bottom left-align gruvbox-purple">Actions</th>
</tr>
</thead>
<tbody>
<!-- Show ALL backups, without limit -->
@renderSortedBackups(groupName, GetSortedBackups(serviceGroup), 9999, serviceConfigs)
</tbody>
</table>
<!-- "Show fewer" button that reloads original table -->
<div class="margin-top right-align">
<button class="gruvbox-button gruvbox-bg2"
hx-get={ fmt.Sprintf("/api/service-group/%s/backups", groupName) }
hx-target={ fmt.Sprintf("#backups-%s", groupName) }
hx-swap="outerHTML"
hx-indicator={ fmt.Sprintf("#show-fewer-indicator-%s", groupName) }>
Show fewer backups
<span id={ fmt.Sprintf("show-fewer-indicator-%s", groupName) } class="htmx-indicator">
<span class="loading-spinner inline-spinner"></span>
</span>
</button>
</div>
</div>
} else {
<div class="gruvbox-bg1 round padding">
<p class="gruvbox-yellow">No backups found for this service.</p>
<p class="gruvbox-fg-dim">Backups will appear here when created.</p>
</div>
}
<!-- Trigger header refresh with out-of-band swap -->
<div hx-get={ fmt.Sprintf("/api/service-group/%s/header", groupName) }
hx-trigger="load once"
hx-target={ fmt.Sprintf("#group-header-%s", groupName) }
hx-swap="outerHTML"
style="display:none;"></div>
}
// renderSortedBackups with action buttons
templ renderSortedBackups(groupName string, sortedBackups []BackupWithService, limit int, serviceConfigs map[string]map[string]ServiceProviderInfo) {
for i := 0; i < len(sortedBackups) && i < limit; i++ {
<tr class={ templ.Classes(
templ.KV("gruvbox-bg-hard", i % 2 == 0),
templ.KV("gruvbox-bg0", i % 2 != 0),
) }>
<td class="padding-small border-bottom gruvbox-fg">{ FormatServiceName(groupName, sortedBackups[i].ServiceIndex) }</td>
<td class="padding-small border-bottom gruvbox-fg">{ FormatTime(sortedBackups[i].Backup.CreationTime) }</td>
<td class="padding-small border-bottom gruvbox-fg">{ FormatSize(sortedBackups[i].Backup.Size) }</td>
<td class="padding-small border-bottom gruvbox-fg">{ sortedBackups[i].Backup.Type }</td>
<td class="padding-small border-bottom gruvbox-fg">{ sortedBackups[i].Backup.RetentionTag }</td>
<td class="padding-small border-bottom">
if providerInfo, exists := serviceConfigs[groupName][sortedBackups[i].ServiceIndex]; exists {
if providerInfo.Provider == "b2" || providerInfo.Provider == "backblaze" {
<span class="backup-tool-tag gruvbox-purple">{ providerInfo.Type } B2 Backblaze</span>
} else if providerInfo.Provider == "ftp" {
<span class="backup-tool-tag gruvbox-yellow">{ providerInfo.Type } FTP</span>
} else if providerInfo.Provider == "ssh" || providerInfo.Provider == "sftp" {
<span class="backup-tool-tag gruvbox-orange">{ providerInfo.Type } SSH</span>
} else if providerInfo.Provider == "s3" {
<span class="backup-tool-tag gruvbox-red">{ providerInfo.Type } S3</span>
} else {
<span class="backup-tool-tag gruvbox-green">{ providerInfo.Type } { providerInfo.Provider }</span>
}
} else {
<span class="backup-tool-tag gruvbox-gray">Unknown</span>
}
</td>
<td class="padding-small border-bottom action-buttons">
<button class="action-button restore-button"
hx-get={ fmt.Sprintf("/api/backups/restore-form?backupID=%s&groupName=%s&serviceIndex=%s",
sortedBackups[i].Backup.ID,
groupName,
sortedBackups[i].ServiceIndex) }
hx-target="body"
hx-swap="beforeend">
Restore
</button>
<a href={ templ.SafeURL(fmt.Sprintf("/api/backups/download?backupID=%s&groupName=%s&serviceIndex=%s",
sortedBackups[i].Backup.ID,
groupName,
sortedBackups[i].ServiceIndex)) }
class="action-button download-button">
Download
</a>
</td>
</tr>
}
}
// backupsTableRowsSorted renders backup rows sorted by creation time across all services
templ backupsTableRowsSorted(groupName string, serviceGroup map[string][]backup.BackupInfo, limit int, serviceConfigs map[string]map[string]ServiceProviderInfo) {
@renderSortedBackups(groupName, GetSortedBackups(serviceGroup), limit, serviceConfigs)
}

1231
templates/home_templ.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,632 @@
package layouts
// Base provides the basic HTML structure for all pages
templ Base(title string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title }</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
<style>
body {
font-family: 'JetBrains Mono', monospace;
}
/* Storage meter styles */
.storage-meter {
height: 12px;
background-color: var(--gruvbox-bg2);
border-radius: 6px;
overflow: hidden;
margin: 0.25rem 0;
}
.storage-meter.small {
height: 6px;
margin: 0.15rem 0;
}
.storage-progress {
height: 100%;
background-color: var(--gruvbox-blue);
border-radius: 6px;
transition: width 0.5s ease;
}
/* Icon styles */
.storage-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background-color: rgba(255, 255, 255, 0.1);
}
.status-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.status-icon.error {
color: var(--gruvbox-red);
}
.status-icon.warning {
color: var(--gruvbox-yellow);
}
.status-icon.success {
color: var(--gruvbox-green);
}
/* Backup tool tags */
.backup-tool-tag {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
margin-right: 0.5rem;
background-color: rgba(255, 255, 255, 0.1);
}
/* Chart styles */
.donut-chart {
width: 120px;
height: 120px;
}
/* Gruvbox Material Dark Theme for Backea */
:root {
/* Gruvbox Hard Dark Palette */
--gruvbox-bg-hard: #1d2021;
--gruvbox-bg0: #282828;
--gruvbox-bg1: #3c3836;
--gruvbox-bg2: #504945;
--gruvbox-bg3: #665c54;
--gruvbox-bg4: #7c6f64;
--gruvbox-fg: #fbf1c7;
--gruvbox-fg-dim: #d5c4a1;
--gruvbox-red: #fb4934;
--gruvbox-green: #b8bb26;
--gruvbox-yellow: #fabd2f;
--gruvbox-blue: #83a598;
--gruvbox-purple: #d3869b;
--gruvbox-aqua: #8ec07c;
--gruvbox-orange: #fe8019;
--gruvbox-gray: #928374;
}
/* Base dark mode styles */
.gruvbox-dark {
background-color: var(--gruvbox-bg-hard);
color: var(--gruvbox-fg);
min-height: 100vh;
}
/* Background colors */
.gruvbox-bg-hard { background-color: var(--gruvbox-bg-hard); }
.gruvbox-bg0 { background-color: var(--gruvbox-bg0); }
.gruvbox-bg1 { background-color: var(--gruvbox-bg1); }
.gruvbox-bg2 { background-color: var(--gruvbox-bg2); }
/* Text colors */
.gruvbox-fg { color: var(--gruvbox-fg); }
.gruvbox-red { color: var(--gruvbox-red); }
.gruvbox-green { color: var(--gruvbox-green); }
.gruvbox-yellow { color: var(--gruvbox-yellow); }
.gruvbox-blue { color: var(--gruvbox-blue); }
.gruvbox-purple { color: var(--gruvbox-purple); }
.gruvbox-aqua { color: var(--gruvbox-aqua); }
.gruvbox-orange { color: var(--gruvbox-orange); }
.gruvbox-gray { color: var(--gruvbox-gray); }
/* Border colors */
.gruvbox-red-border { border-color: var(--gruvbox-red); }
.gruvbox-green-border { border-color: var(--gruvbox-green); }
.gruvbox-yellow-border { border-color: var(--gruvbox-yellow); }
.gruvbox-blue-border { border-color: var(--gruvbox-blue); }
.gruvbox-purple-border { border-color: var(--gruvbox-purple); }
.gruvbox-aqua-border { border-color: var(--gruvbox-aqua); }
/* Base dark mode styles */
html, body {
background-color: var(--gruvbox-bg-hard);
color: var(--gruvbox-fg);
margin: 0;
padding: 0;
min-height: 100vh;
width: 100%;
}
/* Table Styles */
.gruvbox-table {
width: 100%;
border-collapse: collapse;
border-color: var(--gruvbox-bg3);
}
.gruvbox-table th {
font-weight: bold;
border-bottom: 1px solid var(--gruvbox-bg3);
}
.gruvbox-table tr {
border-bottom: 1px solid var(--gruvbox-bg2);
}
.gruvbox-table td, .gruvbox-table th {
padding: 0.5rem;
}
/* Link styles */
.gruvbox-link {
color: var(--gruvbox-blue);
text-decoration: none;
transition: color 0.2s ease;
}
.gruvbox-link:hover {
color: var(--gruvbox-aqua);
text-decoration: underline;
}
/* Warning container */
.gruvbox-warning {
background-color: var(--gruvbox-bg1);
border: 1px solid var(--gruvbox-yellow);
color: var(--gruvbox-yellow);
padding: 1rem;
border-radius: 0.25rem;
}
/* Add Beer CSS compatibility for spacing and layout classes */
.responsive {
width: 100%;
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.round {
border-radius: 0.25rem;
}
.padding {
padding: 1rem;
}
.padding-small {
padding: 0.5rem;
}
.margin-bottom {
margin-bottom: 1rem;
}
.margin-bottom-large {
margin-bottom: 2rem;
}
.margin-top {
margin-top: 1rem;
}
.margin-top-small {
margin-top: 0.5rem;
}
.border {
border: 1px solid;
}
.border-bottom {
border-bottom: 1px solid var(--gruvbox-bg3);
}
.left-align {
text-align: left;
}
.right-align {
text-align: right;
}
.center-align {
text-align: center;
}
.overflow {
overflow-x: auto;
}
.extra-large {
font-size: 2rem;
}
.large {
font-size: 1.5rem;
}
.medium {
font-weight: 500;
}
/* Skeleton loading styles */
.skeleton-table {
max-width: 95%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.skeleton-header {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid rgba(168, 153, 132, 0.3);
}
.skeleton-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid rgba(168, 153, 132, 0.1);
}
.skeleton-cell {
height: 1.2rem;
background: linear-gradient(90deg, rgba(168, 153, 132, 0.1) 25%, rgba(168, 153, 132, 0.2) 50%, rgba(168, 153, 132, 0.1) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Loading indicator */
.loading-spinner {
width: 30px;
height: 30px;
margin: 20px auto;
border: 3px solid rgba(168, 153, 132, 0.3);
border-top: 3px solid #b8bb26; /* gruvbox green */
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: block;
}
.htmx-request.htmx-indicator {
display: block;
}
/* Improve spacing in tables */
.overflow {
overflow-x: auto;
margin-bottom: 1rem;
}
.table-skeleton {
background-color: var(--gruvbox-bg1);
border-radius: 4px;
padding: 16px;
}
/* Fade in animation for loaded content */
.group-backups-table {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.skeleton-text {
position: relative;
overflow: hidden;
background: linear-gradient(90deg, rgba(168, 153, 132, 0.1) 25%, rgba(168, 153, 132, 0.2) 50%, rgba(168, 153, 132, 0.1) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
color: transparent !important;
width: 40px;
display: inline-block;
}
/* Skeleton table styles remain the same */
.skeleton-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.skeleton-header {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid rgba(168, 153, 132, 0.3);
}
.skeleton-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid rgba(168, 153, 132, 0.1);
}
.skeleton-cell {
height: 1.2rem;
background: linear-gradient(90deg, rgba(168, 153, 132, 0.1) 25%, rgba(168, 153, 132, 0.2) 50%, rgba(168, 153, 132, 0.1) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Loading indicator */
.loading-spinner {
width: 30px;
height: 30px;
margin: 20px auto;
border: 3px solid rgba(168, 153, 132, 0.3);
border-top: 3px solid #b8bb26; /* gruvbox green */
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: block;
}
.htmx-request.htmx-indicator {
display: block;
}
/* Entry animation for both header and content */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.group-backups-table,
#group-header-* {
animation: fadeIn 0.3s ease-out;
}
/* Button styles */
.gruvbox-button {
display: inline-block;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
color: var(--gruvbox-fg);
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
text-decoration: none;
transition: background-color 0.2s ease, transform 0.1s ease;
}
.gruvbox-button:hover {
background-color: var(--gruvbox-bg3);
transform: translateY(-1px);
}
.gruvbox-button:active {
transform: translateY(1px);
}
/* Inline spinner for buttons */
.inline-spinner {
width: 16px;
height: 16px;
margin-left: 8px;
display: inline-block;
vertical-align: middle;
}
/* Animation for table transitions */
.group-backups-table {
transition: opacity 0.2s ease;
}
.htmx-swapping {
opacity: 0.5;
}
/* Make the table header sticky */
.gruvbox-table thead {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--gruvbox-bg1);
}
/* Action buttons styling */
.action-buttons {
display: flex;
gap: 8px;
white-space: nowrap;
}
.action-button {
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
border-radius: 3px;
cursor: pointer;
font-family: inherit;
text-decoration: none;
display: inline-block;
text-align: center;
border: none;
transition: background-color 0.15s ease, transform 0.1s ease;
}
.restore-button {
background-color: var(--gruvbox-blue);
color: var(--gruvbox-bg-hard);
}
.download-button {
background-color: var(--gruvbox-green);
color: var(--gruvbox-bg-hard);
}
.action-button:hover {
transform: translateY(-1px);
filter: brightness(1.1);
}
.action-button:active {
transform: translateY(1px);
}
/* Make sure the table has enough width */
.gruvbox-table {
width: 100%;
table-layout: auto;
}
/* Ensure columns have appropriate widths */
.gruvbox-table th:last-child,
.gruvbox-table td:last-child {
width: 1%;
white-space: nowrap;
}
/* Responsive adjustments for small screens */
@media (max-width: 768px) {
.action-buttons {
flex-direction: column;
gap: 4px;
}
.action-button {
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
}
}
/* Add these styles to your CSS file */
.restore-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 500px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.5);
z-index: 1000;
padding: 2rem;
border-radius: 8px;
}
.restore-modal:before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
.backup-details {
margin-bottom: 1rem;
padding: 0.5rem;
background: rgba(40, 40, 40, 0.4);
border-radius: 4px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
}
.gruvbox-input {
width: 100%;
padding: 0.5rem;
background-color: #1d2021;
border: 1px solid #3c3836;
color: #ebdbb2;
border-radius: 4px;
font-family: inherit;
font-size: 1rem;
}
.gruvbox-input:focus {
outline: none;
border-color: #fabd2f;
}
.form-buttons {
display: flex;
justify-content: space-between;
margin-top: 1.5rem;
}
</style>
</head>
<body class="gruvbox-bg0 gruvbox-fg">
<header class="gruvbox-bg-hard padding">
<div class="responsive">
<div style="display: flex; justify-content: space-between; align-items: center;">
<a href="/" class="gruvbox-yellow no-underline">
<h1 class="large">Backea</h1>
</a>
<nav>
<ul style="display: flex; gap: 1rem;">
<li><a href="/" class="gruvbox-link">Home</a></li>
<li><a href="/about" class="gruvbox-link">About</a></li>
</ul>
</nav>
</div>
</div>
</header>
<!-- Notification area for displaying feedback -->
<div id="notification-area"></div>
<main class="padding">
{ children... }
</main>
<footer class="gruvbox-bg-hard padding margin-top">
<div class="responsive">
<p class="text-center">
<small class="gruvbox-fg-dim">Backea - Unified Backup Dashboard</small>
</p>
</div>
</footer>
</body>
</html>
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,39 @@
package templates
import "fmt"
// Notification displays a temporary notification message
templ Notification(message string, notificationType string) {
<div id="notification"
class={ templ.Classes(
"notification",
templ.KV(notificationType, true),
)}
_="on load wait 4s then add .fade-out wait 1s then remove me">
<p>{ message }</p>
</div>
}
// Success notification
templ SuccessNotification(message string) {
@Notification(message, "success")
}
// Error notification
templ ErrorNotification(message string) {
@Notification(message, "error")
}
// RestoreSuccessResponse returns a response for successful restore operation
templ RestoreSuccessResponse(backupID string) {
<div hx-swap-oob="true:#notification-area">
@SuccessNotification("Backup restoration started successfully")
</div>
}
// RestoreErrorResponse returns a response for failed restore operation
templ RestoreErrorResponse(errorMessage string) {
<div hx-swap-oob="true:#notification-area">
@ErrorNotification(fmt.Sprintf("Backup restoration failed: %s", errorMessage))
</div>
}

View File

@ -0,0 +1,213 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "fmt"
// Notification displays a temporary notification message
func Notification(message string, notificationType string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var2 = []any{templ.Classes(
"notification",
templ.KV(notificationType, true),
)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"notification\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/notifications.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" _=\"on load wait 4s then add .fade-out wait 1s then remove me\"><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/notifications.templ`, Line: 13, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Success notification
func SuccessNotification(message string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Notification(message, "success").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Error notification
func ErrorNotification(message string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Notification(message, "error").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// RestoreSuccessResponse returns a response for successful restore operation
func RestoreSuccessResponse(backupID string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
if templ_7745c5c3_Var7 == nil {
templ_7745c5c3_Var7 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div hx-swap-oob=\"true:#notification-area\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = SuccessNotification("Backup restoration started successfully").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// RestoreErrorResponse returns a response for failed restore operation
func RestoreErrorResponse(errorMessage string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil {
templ_7745c5c3_Var8 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div hx-swap-oob=\"true:#notification-area\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ErrorNotification(fmt.Sprintf("Backup restoration failed: %s", errorMessage)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate