package main import ( "archive/zip" "context" "flag" "fmt" "io" "log" "os" "path/filepath" "strings" "time" "github.com/joho/godotenv" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) // Global variables to be set via ldflags var ( BuildEndpoint string BuildAccessKey string BuildSecretKey string BuildBucket string ) func main() { // Load environment variables from .env file err := godotenv.Load() if err != nil { // Just log, don't fail, as we might rely on flags or build defaults } // Parse command line arguments var filePath string var dirPaths string var bucketName string var interval string var objectName string var listBuckets bool var createBucketFlag bool // Credential flags var endpointFlag string var accessKeyFlag string var secretKeyFlag string flag.StringVar(&filePath, "file", "", "Path to the file to upload") flag.StringVar(&dirPaths, "dir", "", "Comma-separated paths to directories to compress and upload") flag.StringVar(&bucketName, "bucket", "", "Bucket name") flag.StringVar(&interval, "interval", "", "Backup interval (e.g., 24h, 60m). If not set, runs once.") flag.StringVar(&objectName, "name", "", "Object name (optional)") flag.BoolVar(&listBuckets, "list", false, "List all available buckets") flag.BoolVar(&createBucketFlag, "create", false, "Create the specified bucket if it doesn't exist") flag.StringVar(&endpointFlag, "endpoint", "", "MinIO endpoint URL") flag.StringVar(&accessKeyFlag, "access-key", "", "MinIO access key") flag.StringVar(&secretKeyFlag, "secret-key", "", "MinIO secret key") flag.Parse() // Resolve Configuration: Flag > Env > Build Default finalEndpoint := resolveConfig(endpointFlag, "MINIO_ENDPOINT", BuildEndpoint) finalAccessKey := resolveConfig(accessKeyFlag, "MINIO_ACCESS_KEY", BuildAccessKey) finalSecretKey := resolveConfig(secretKeyFlag, "MINIO_SECRET_KEY", BuildSecretKey) if bucketName == "" { bucketName = resolveConfig("", "MINIO_BUCKET", BuildBucket) } // Initialize MinIO client minioClient := initMinioClient(finalEndpoint, finalAccessKey, finalSecretKey) if listBuckets { listAllBuckets(minioClient) return } if createBucketFlag { if bucketName == "" { fmt.Println("Please specify a bucket name using -bucket") os.Exit(1) } createBucket(minioClient, bucketName) return } // Handle positional arguments for backward compatibility args := flag.Args() if len(args) > 0 && filePath == "" && dirPaths == "" { // Check if argument is a directory info, err := os.Stat(args[0]) if err == nil && info.IsDir() { dirPaths = args[0] } else { filePath = args[0] } } if len(args) > 1 && bucketName == "" { bucketName = args[1] } if bucketName == "" { // Try to resolve bucket again if not set via flag bucketName = resolveConfig("", "MINIO_BUCKET", BuildBucket) } if (filePath == "" && dirPaths == "") || bucketName == "" { fmt.Println("Usage: uploader -file -bucket ") fmt.Println(" uploader -dir -bucket [-interval 24h]") fmt.Println(" uploader -list") fmt.Println("\nCredentials can be provided via .env file, environment variables, or flags:") fmt.Println(" -endpoint -access-key -secret-key ") os.Exit(1) } // One-time execution if interval == "" { if err := performBackup(minioClient, filePath, dirPaths, bucketName, objectName); err != nil { log.Fatalln(err) } return } // Scheduled execution duration, err := time.ParseDuration(interval) if err != nil { log.Fatalf("Invalid interval format: %v\n", err) } fmt.Printf("Starting backup service every %s...\n", interval) ticker := time.NewTicker(duration) defer ticker.Stop() // Run immediately first if err := performBackup(minioClient, filePath, dirPaths, bucketName, objectName); err != nil { log.Printf("Backup failed: %v\n", err) } for range ticker.C { if err := performBackup(minioClient, filePath, dirPaths, bucketName, objectName); err != nil { log.Printf("Backup failed: %v\n", err) } } } func resolveConfig(flagVal, envKey, buildVal string) string { if flagVal != "" { return flagVal } if envVal := os.Getenv(envKey); envVal != "" { return envVal } return buildVal } func initMinioClient(endpoint, accessKey, secretKey string) *minio.Client { useSSL := true if strings.HasPrefix(endpoint, "http://") { endpoint = strings.TrimPrefix(endpoint, "http://") useSSL = false } else if strings.HasPrefix(endpoint, "https://") { endpoint = strings.TrimPrefix(endpoint, "https://") useSSL = true } if endpoint == "" || accessKey == "" || secretKey == "" { log.Fatal("Error: Credentials must be provided via flags, environment variables, .env file, or build-time defaults.") } minioClient, err := minio.New(endpoint, &minio.Options{ Creds: credentials.NewStaticV4(accessKey, secretKey, ""), Secure: useSSL, }) if err != nil { log.Fatalln(err) } return minioClient } func listAllBuckets(client *minio.Client) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() buckets, err := client.ListBuckets(ctx) if err != nil { log.Fatalf("Failed to list buckets: %v\n", err) } if len(buckets) == 0 { fmt.Println("No buckets found.") return } fmt.Println("Available buckets:") for _, bucket := range buckets { fmt.Printf("- %s (Created: %s)\n", bucket.Name, bucket.CreationDate) } } func createBucket(client *minio.Client, bucketName string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() err := client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{}) if err != nil { // Check to see if we already own this bucket exists, errBucketExists := client.BucketExists(ctx, bucketName) if errBucketExists == nil && exists { log.Printf("We already own %s\n", bucketName) } else { log.Fatalf("Failed to create bucket %s: %v\n", bucketName, err) } } else { log.Printf("Successfully created bucket %s\n", bucketName) } } func performBackup(client *minio.Client, filePath, dirPaths, bucketName, objectName string) error { // Handle single file upload if filePath != "" { if objectName == "" { objectName = filepath.Base(filePath) } fmt.Printf("Uploading %s to %s/%s...\n", filePath, bucketName, objectName) info, err := client.FPutObject(context.Background(), bucketName, objectName, filePath, minio.PutObjectOptions{}) if err != nil { return fmt.Errorf("failed to upload file %s: %v", filePath, err) } fmt.Printf("Successfully uploaded %s. Size: %d bytes\n", filePath, info.Size) } // Handle directory uploads if dirPaths != "" { dirs := strings.Split(dirPaths, ",") for _, dirPath := range dirs { dirPath = strings.TrimSpace(dirPath) if dirPath == "" { continue } // Verify directory exists info, err := os.Stat(dirPath) if err != nil || !info.IsDir() { log.Printf("Warning: Skipping invalid directory: %s\n", dirPath) continue } timestamp := time.Now().Format("20060102_150405") dirName := filepath.Base(dirPath) if dirName == "." || dirName == "/" { // Use parent directory name or absolute path hash/sanitized name could be better, // but for simplicity, let's try to get absolute path base absPath, _ := filepath.Abs(dirPath) dirName = filepath.Base(absPath) } zipName := fmt.Sprintf("%s_%s.zip", dirName, timestamp) // Create zip in temp directory tempFile := filepath.Join(os.TempDir(), zipName) fmt.Printf("Zipping directory %s to %s...\n", dirPath, tempFile) if err := zipSource(dirPath, tempFile); err != nil { log.Printf("Error zipping directory %s: %v\n", dirPath, err) continue } // If object name was specified (and only 1 directory), use it. // Otherwise (multiple directories), use the zip name to avoid overwriting. uploadName := zipName if objectName != "" && len(dirs) == 1 { uploadName = objectName } fmt.Printf("Uploading %s to %s/%s...\n", tempFile, bucketName, uploadName) uploadInfo, err := client.FPutObject(context.Background(), bucketName, uploadName, tempFile, minio.PutObjectOptions{}) // Clean up temp file os.Remove(tempFile) if err != nil { log.Printf("Error uploading directory %s: %v\n", dirPath, err) continue } fmt.Printf("Successfully uploaded %s. Size: %d bytes\n", dirPath, uploadInfo.Size) } } return nil } func zipSource(source, target string) error { f, err := os.Create(target) if err != nil { return err } defer f.Close() writer := zip.NewWriter(f) defer writer.Close() return filepath.Walk(source, func(path string, info os.FileInfo, err error) error { if err != nil { // Instead of failing hard, log the error and skip this file log.Printf("Warning: Skipping file %s due to error: %v\n", path, err) return nil } // Skip the zip file itself if it's inside the source directory if path == target { return nil } // Skip sockets, pipes, devices, etc. Only allow regular files and directories. // info.Mode() & os.ModeType returns the file type bits (excluding permissions) // 0 means regular file. mode := info.Mode() if !mode.IsRegular() && !info.IsDir() { // Skip non-regular files silently (or with debug log) to avoid "no such device" or "open socket" errors // Symlinks (ModeSymlink) are also skipped by IsRegular(), which is usually desired for backup consistency unless we specifically want to follow them. // If we want to support symlinks, we'd need to handle them separately. For now, skipping is safer. return nil } header, err := zip.FileInfoHeader(info) if err != nil { log.Printf("Warning: Failed to create zip header for %s: %v\n", path, err) return nil } // Change header name to be relative to the source relPath, err := filepath.Rel(source, path) if err != nil { return err } // Use forward slashes for zip compatibility header.Name = filepath.ToSlash(relPath) if info.IsDir() { header.Name += "/" } else { header.Method = zip.Deflate } writer, err := writer.CreateHeader(header) if err != nil { return err } if info.IsDir() { return nil } file, err := os.Open(path) if err != nil { // If we can't open the file (permission denied etc), skip it log.Printf("Warning: Failed to open file %s: %v\n", path, err) return nil } defer file.Close() _, err = io.Copy(writer, file) if err != nil { log.Printf("Warning: Failed to write file %s to zip: %v\n", path, err) return nil } return nil }) }