initial commit
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
AWS_REGION=eu-west-2
|
||||
S3_BUCKET=your-bucket-name
|
||||
S3_PREFIX=
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
SERVER_ADDR=:8080
|
||||
AWS_ENDPOINT_URL=
|
||||
AWS_USE_PATH_STYLE=false
|
||||
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
frontend/dist/
|
||||
.DS_Store
|
||||
.env
|
||||
coverage/
|
||||
|
||||
56
README.md
Normal file
56
README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# S3 Mover
|
||||
|
||||
A small web app with a Go backend and React frontend for listing, uploading, downloading, and deleting files in an S3 bucket.
|
||||
|
||||
## Structure
|
||||
|
||||
- `backend`: Go HTTP API for S3 operations
|
||||
- `frontend`: React + Vite client
|
||||
|
||||
## Backend configuration
|
||||
|
||||
Set these environment variables before starting the API:
|
||||
|
||||
- `AWS_REGION`: AWS region for the bucket
|
||||
- `S3_BUCKET`: target bucket name
|
||||
- `S3_PREFIX`: optional folder prefix inside the bucket
|
||||
- `FRONTEND_ORIGIN`: allowed browser origin for CORS, for example `http://localhost:5173`
|
||||
- `SERVER_ADDR`: HTTP bind address, default `:8080`
|
||||
- `AWS_ENDPOINT_URL`: optional custom S3-compatible endpoint
|
||||
- `AWS_USE_PATH_STYLE`: set to `true` for MinIO and some S3-compatible providers
|
||||
- `AWS_ACCESS_KEY_ID`: access key for S3-compatible storage
|
||||
- `AWS_SECRET_ACCESS_KEY`: secret key for S3-compatible storage
|
||||
|
||||
Standard AWS credentials are supported through the normal SDK chain. You can also set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN`.
|
||||
|
||||
For DigitalOcean Spaces, a typical configuration looks like:
|
||||
|
||||
```bash
|
||||
AWS_REGION=nyc3
|
||||
S3_BUCKET=your-space-name
|
||||
AWS_ENDPOINT_URL=https://nyc3.digitaloceanspaces.com
|
||||
AWS_ACCESS_KEY_ID=your-access-key
|
||||
AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||
AWS_USE_PATH_STYLE=false
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
```
|
||||
|
||||
## Run locally
|
||||
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go mod tidy
|
||||
go run .
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
VITE_API_BASE_URL=http://localhost:8080 npm run dev
|
||||
```
|
||||
|
||||
Then open `http://localhost:5173`.
|
||||
BIN
backend/backend
Executable file
BIN
backend/backend
Executable file
Binary file not shown.
28
backend/go.mod
Normal file
28
backend/go.mod
Normal file
@@ -0,0 +1,28 @@
|
||||
module s3mover/backend
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.13
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.17
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.13
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 // indirect
|
||||
github.com/aws/smithy-go v1.23.1 // indirect
|
||||
)
|
||||
38
backend/go.sum
Normal file
38
backend/go.sum
Normal file
@@ -0,0 +1,38 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.3 h1:h7xSsanJ4EQJXG5iuW4UqgP7qBopLpj84mpkNx3wPjM=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.3/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 h1:t9yYsydLYNBk9cJ73rgPhPWqOh/52fcWDQB5b1JsKSY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2/go.mod h1:IusfVNTmiSN3t4rhxWFaBAqn+mcNdwKtPcV16eYdgko=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.13 h1:wcqQB3B0PgRPUF5ZE/QL1JVOyB0mbPevHFoAMpemR9k=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.13/go.mod h1:ySB5D5ybwqGbT6c3GszZ+u+3KvrlYCUQNo62+hkKOFk=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.17 h1:skpEwzN/+H8cdrrtT8y+rvWJGiWWv0DeNAe+4VTf+Vs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.17/go.mod h1:Ed+nXsaYa5uBINovJhcAWkALvXw2ZLk36opcuiSZfJM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.13 h1:9XV2TkOvCs6Fis10b4scQbv/eDPhklhU/65GikPxXAA=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.13/go.mod h1:X5gq64GsjuOIJRIUzR3x3Du96zUF+U1if3Qw/qNx1k8=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10 h1:mj/bdWleWEh81DtpdHKkw41IrS+r3uw1J/VQtbwYYp8=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10/go.mod h1:7+oEMxAZWP8gZCyjcm9VicI0M61Sx4DJtcGfKYv2yKQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10 h1:wh+/mn57yhUrFtLIxyFPh2RgxgQz/u+Yrf7hiHGHqKY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10/go.mod h1:7zirD+ryp5gitJJ2m1BBux56ai8RIRDykXZrJSp540w=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.10 h1:FHw90xCTsofzk6vjU808TSuDtDfOOKPNdz5Weyc3tUI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.10/go.mod h1:n8jdIE/8F3UYkg8O4IGkQpn2qUmapg/1K1yl29/uf/c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.1 h1:ne+eepnDB2Wh5lHKzELgEncIqeVlQ1rSF9fEa4r5I+A=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.1/go.mod h1:u0Jkg0L+dcG1ozUq21uFElmpbmjBnhHR5DELHIme4wg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.10 h1:DA+Hl5adieRyFvE7pCvBWm3VOZTRexGVkXw33SUqNoY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.10/go.mod h1:L+A89dH3/gr8L4ecrdzuXUYd1znoko6myzndVGZx/DA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.5 h1:FlGScxzCGNzT+2AvHT1ZGMvxTwAMa6gsooFb1pO/AiM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.5/go.mod h1:N/iojY+8bW3MYol9NUMuKimpSbPEur75cuI1SmtonFM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.7 h1:VEO5dqFkMsl8QZ2yHsFDJAIZLAkEbaYDB+xdKi0Feic=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.7/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o=
|
||||
github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
|
||||
github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
388
backend/main.go
Normal file
388
backend/main.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
)
|
||||
|
||||
type appConfig struct {
|
||||
Addr string
|
||||
Region string
|
||||
Bucket string
|
||||
Prefix string
|
||||
FrontendOrigin string
|
||||
EndpointURL string
|
||||
UsePathStyle bool
|
||||
}
|
||||
|
||||
type fileEntry struct {
|
||||
Key string `json:"key"`
|
||||
Size int64 `json:"size"`
|
||||
LastModified time.Time `json:"lastModified"`
|
||||
}
|
||||
|
||||
type s3Store struct {
|
||||
client *s3.Client
|
||||
uploader *manager.Uploader
|
||||
bucket string
|
||||
prefix string
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
store, err := newS3Store(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
server := newServer(cfg, store)
|
||||
log.Printf(
|
||||
"storage config bucket=%q region=%q endpoint=%q path_style=%t prefix=%q",
|
||||
cfg.Bucket,
|
||||
cfg.Region,
|
||||
cfg.EndpointURL,
|
||||
cfg.UsePathStyle,
|
||||
cfg.Prefix,
|
||||
)
|
||||
log.Printf("listening on %s", cfg.Addr)
|
||||
if err := http.ListenAndServe(cfg.Addr, server); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() (appConfig, error) {
|
||||
cfg := appConfig{
|
||||
Addr: envOrDefault("SERVER_ADDR", ":8080"),
|
||||
Region: strings.TrimSpace(os.Getenv("AWS_REGION")),
|
||||
Bucket: strings.TrimSpace(os.Getenv("S3_BUCKET")),
|
||||
Prefix: strings.Trim(strings.TrimSpace(os.Getenv("S3_PREFIX")), "/"),
|
||||
FrontendOrigin: strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN")),
|
||||
EndpointURL: strings.TrimSpace(os.Getenv("AWS_ENDPOINT_URL")),
|
||||
UsePathStyle: strings.EqualFold(strings.TrimSpace(os.Getenv("AWS_USE_PATH_STYLE")), "true"),
|
||||
}
|
||||
|
||||
if cfg.Region == "" {
|
||||
return cfg, errors.New("AWS_REGION is required")
|
||||
}
|
||||
if cfg.Bucket == "" {
|
||||
return cfg, errors.New("S3_BUCKET is required")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func newS3Store(cfg appConfig) (*s3Store, error) {
|
||||
ctx := context.Background()
|
||||
loadOptions := []func(*awsconfig.LoadOptions) error{
|
||||
awsconfig.WithRegion(cfg.Region),
|
||||
}
|
||||
|
||||
accessKey := strings.TrimSpace(os.Getenv("AWS_ACCESS_KEY_ID"))
|
||||
secretKey := strings.TrimSpace(os.Getenv("AWS_SECRET_ACCESS_KEY"))
|
||||
sessionToken := strings.TrimSpace(os.Getenv("AWS_SESSION_TOKEN"))
|
||||
if accessKey != "" && secretKey != "" {
|
||||
loadOptions = append(loadOptions, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, sessionToken)))
|
||||
}
|
||||
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOptions...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load aws config: %w", err)
|
||||
}
|
||||
|
||||
clientOptions := func(o *s3.Options) {
|
||||
o.UsePathStyle = cfg.UsePathStyle
|
||||
if cfg.EndpointURL != "" {
|
||||
o.BaseEndpoint = aws.String(cfg.EndpointURL)
|
||||
}
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(awsCfg, clientOptions)
|
||||
return &s3Store{
|
||||
client: client,
|
||||
uploader: manager.NewUploader(client),
|
||||
bucket: cfg.Bucket,
|
||||
prefix: cfg.Prefix,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newServer(cfg appConfig, store *s3Store) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("/api/files", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
handleListFiles(store, w, r)
|
||||
case http.MethodPost:
|
||||
handleUploadFile(store, w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/files/", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
handleDownloadFile(store, w, r)
|
||||
case http.MethodDelete:
|
||||
handleDeleteFile(store, w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
return withCORS(withLogging(mux), cfg.FrontendOrigin)
|
||||
}
|
||||
|
||||
func handleListFiles(store *s3Store, w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
input := &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(store.bucket),
|
||||
Prefix: prefixValue(store.prefix),
|
||||
}
|
||||
|
||||
var files []fileEntry
|
||||
paginator := s3.NewListObjectsV2Paginator(store.client, input)
|
||||
for paginator.HasMorePages() {
|
||||
page, err := paginator.NextPage(ctx)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadGateway, "list files", err)
|
||||
return
|
||||
}
|
||||
for _, item := range page.Contents {
|
||||
if item.Key == nil {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimPrefix(aws.ToString(item.Key), store.prefix)
|
||||
key = strings.TrimPrefix(key, "/")
|
||||
if key == "" || strings.HasSuffix(key, "/") {
|
||||
continue
|
||||
}
|
||||
files = append(files, fileEntry{
|
||||
Key: key,
|
||||
Size: aws.ToInt64(item.Size),
|
||||
LastModified: aws.ToTime(item.LastModified),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].Key < files[j].Key
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func handleUploadFile(store *s3Store, w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "parse upload payload", err)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "read upload file", errors.New("missing form field 'file'"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
key := sanitizeKey(r.FormValue("key"))
|
||||
if key == "" {
|
||||
key = sanitizeKey(header.Filename)
|
||||
}
|
||||
if key == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "validate upload key", errors.New("file key is required"))
|
||||
return
|
||||
}
|
||||
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = mime.TypeByExtension(filepath.Ext(key))
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
_, err = store.uploader.Upload(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(store.bucket),
|
||||
Key: aws.String(store.objectKey(key)),
|
||||
Body: file,
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadGateway, "upload file", err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"key": key})
|
||||
}
|
||||
|
||||
func handleDownloadFile(store *s3Store, w http.ResponseWriter, r *http.Request) {
|
||||
key := objectKeyFromRequest(r)
|
||||
if key == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "read download key", errors.New("file key is required"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
output, err := store.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(store.bucket),
|
||||
Key: aws.String(store.objectKey(key)),
|
||||
})
|
||||
if err != nil {
|
||||
var noSuchKey *types.NoSuchKey
|
||||
if errors.As(err, &noSuchKey) {
|
||||
writeAPIError(w, http.StatusNotFound, "download file", errors.New("file not found"))
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusBadGateway, "download file", err)
|
||||
return
|
||||
}
|
||||
defer output.Body.Close()
|
||||
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(key)))
|
||||
if output.ContentType != nil {
|
||||
w.Header().Set("Content-Type", aws.ToString(output.ContentType))
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(aws.ToInt64(output.ContentLength), 10))
|
||||
if _, err := io.Copy(w, output.Body); err != nil {
|
||||
log.Printf("stream download %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteFile(store *s3Store, w http.ResponseWriter, r *http.Request) {
|
||||
key := objectKeyFromRequest(r)
|
||||
if key == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "read delete key", errors.New("file key is required"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := store.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(store.bucket),
|
||||
Key: aws.String(store.objectKey(key)),
|
||||
})
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadGateway, "delete file", err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"deleted": key})
|
||||
}
|
||||
|
||||
func (s *s3Store) objectKey(key string) string {
|
||||
cleanKey := sanitizeKey(key)
|
||||
if s.prefix == "" {
|
||||
return cleanKey
|
||||
}
|
||||
return s.prefix + "/" + cleanKey
|
||||
}
|
||||
|
||||
func objectKeyFromRequest(r *http.Request) string {
|
||||
encodedKey := strings.TrimPrefix(r.URL.Path, "/api/files/")
|
||||
decoded, err := url.PathUnescape(encodedKey)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return sanitizeKey(decoded)
|
||||
}
|
||||
|
||||
func sanitizeKey(value string) string {
|
||||
parts := strings.Split(strings.TrimSpace(value), "/")
|
||||
cleaned := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || part == "." || part == ".." {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, part)
|
||||
}
|
||||
return strings.Join(cleaned, "/")
|
||||
}
|
||||
|
||||
func prefixValue(prefix string) *string {
|
||||
if prefix == "" {
|
||||
return nil
|
||||
}
|
||||
value := prefix + "/"
|
||||
return &value
|
||||
}
|
||||
|
||||
func envOrDefault(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
log.Printf("write json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAPIError(w http.ResponseWriter, status int, operation string, err error) {
|
||||
message := fmt.Sprintf("%s: %v", operation, err)
|
||||
log.Printf("error %s", message)
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func withLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
||||
func withCORS(next http.Handler, allowedOrigin string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if allowedOrigin != "" && r.Header.Get("Origin") == allowedOrigin {
|
||||
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
19
frontend/index.html
Normal file
19
frontend/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>S3 Mover</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=Manrope:wght@400;500;700;800&family=Space+Grotesk:wght@500;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1815
frontend/package-lock.json
generated
Normal file
1815
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
frontend/package.json
Normal file
20
frontend/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "s3mover-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"vite": "^7.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
213
frontend/src/App.jsx
Normal file
213
frontend/src/App.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";
|
||||
|
||||
function formatBytes(size) {
|
||||
if (size === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
|
||||
const value = size / 1024 ** index;
|
||||
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
async function readError(response, fallback) {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const payload = await response.json();
|
||||
if (payload?.error) {
|
||||
return payload.error;
|
||||
}
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
return text.trim() || fallback;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [keyName, setKeyName] = useState("");
|
||||
const [status, setStatus] = useState("Loading files...");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
setError("");
|
||||
setStatus("Refreshing bucket contents...");
|
||||
const response = await fetch(`${apiBase}/api/files`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, `Failed to list files (${response.status})`));
|
||||
}
|
||||
const payload = await response.json();
|
||||
setFiles(payload.files || []);
|
||||
setStatus(`Loaded ${payload.files?.length || 0} file${payload.files?.length === 1 ? "" : "s"}.`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setStatus("Unable to load files.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
}, []);
|
||||
|
||||
async function handleUpload(event) {
|
||||
event.preventDefault();
|
||||
if (!selectedFile) {
|
||||
setError("Choose a file before uploading.");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append("file", selectedFile);
|
||||
if (keyName.trim()) {
|
||||
body.append("key", keyName.trim());
|
||||
}
|
||||
|
||||
try {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setStatus(`Uploading ${selectedFile.name}...`);
|
||||
const response = await fetch(`${apiBase}/api/files`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, `Upload failed (${response.status})`));
|
||||
}
|
||||
setSelectedFile(null);
|
||||
setKeyName("");
|
||||
event.target.reset();
|
||||
await loadFiles();
|
||||
setStatus(`Uploaded ${selectedFile.name}.`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setStatus("Upload failed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(fileKey) {
|
||||
if (!window.confirm(`Delete ${fileKey}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setStatus(`Deleting ${fileKey}...`);
|
||||
const response = await fetch(`${apiBase}/api/files/${encodeURIComponent(fileKey)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response, `Delete failed (${response.status})`));
|
||||
}
|
||||
await loadFiles();
|
||||
setStatus(`Deleted ${fileKey}.`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setStatus("Delete failed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(fileKey) {
|
||||
window.location.href = `${apiBase}/api/files/${encodeURIComponent(fileKey)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">React + Go + S3</p>
|
||||
<h1>S3 Mover</h1>
|
||||
<p className="lede">
|
||||
Browse bucket contents and move files in and out of S3 without leaving the browser.
|
||||
</p>
|
||||
</div>
|
||||
<div className="status-card">
|
||||
<span>Status</span>
|
||||
<strong>{status}</strong>
|
||||
{error ? <p>{error}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel upload-panel">
|
||||
<div>
|
||||
<h2>Upload a file</h2>
|
||||
<p>Leave the object key blank to reuse the local filename.</p>
|
||||
</div>
|
||||
<form onSubmit={handleUpload} className="upload-form">
|
||||
<label>
|
||||
<span>File</span>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(event) => setSelectedFile(event.target.files?.[0] || null)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Object key</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="reports/quarterly.csv"
|
||||
value={keyName}
|
||||
onChange={(event) => setKeyName(event.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy || !selectedFile}>
|
||||
{busy ? "Working..." : "Upload"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="table-header">
|
||||
<div>
|
||||
<h2>Bucket contents</h2>
|
||||
<p>{files.length} items visible</p>
|
||||
</div>
|
||||
<button type="button" className="ghost-button" onClick={loadFiles} disabled={busy}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="file-list">
|
||||
{files.length === 0 ? (
|
||||
<div className="empty-state">No files found in the configured bucket or prefix.</div>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<article className="file-row" key={file.key}>
|
||||
<div className="file-meta">
|
||||
<strong>{file.key}</strong>
|
||||
<span>{formatBytes(file.size)}</span>
|
||||
<span>{formatDate(file.lastModified)}</span>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" onClick={() => handleDownload(file.key)} disabled={busy}>
|
||||
Download
|
||||
</button>
|
||||
<button type="button" className="danger" onClick={() => handleDelete(file.key)} disabled={busy}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
11
frontend/src/main.jsx
Normal file
11
frontend/src/main.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
245
frontend/src/styles.css
Normal file
245
frontend/src/styles.css
Normal file
@@ -0,0 +1,245 @@
|
||||
:root {
|
||||
font-family: "Manrope", sans-serif;
|
||||
color: #eff7f5;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(85, 204, 167, 0.28), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(254, 190, 92, 0.16), transparent 24%),
|
||||
linear-gradient(160deg, #081514 0%, #0d2420 52%, #142f29 100%);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--panel: rgba(8, 18, 16, 0.72);
|
||||
--panel-border: rgba(210, 255, 241, 0.12);
|
||||
--accent: #80f1ca;
|
||||
--accent-strong: #3bc899;
|
||||
--danger: #ff7e6b;
|
||||
--muted: #9db8b1;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1120px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 48px 0 64px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
h1,
|
||||
h2,
|
||||
.status-card strong {
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.5rem, 6vw, 5rem);
|
||||
line-height: 0.95;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lede,
|
||||
.panel p,
|
||||
.file-meta span,
|
||||
.status-card p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero .lede {
|
||||
max-width: 40rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 24px;
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-card span {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status-card strong {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 260px) 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.upload-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.upload-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-form span {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #04241b;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: rgba(255, 126, 107, 0.14);
|
||||
color: #ffb6aa;
|
||||
}
|
||||
|
||||
.table-header,
|
||||
.file-row,
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-meta strong {
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero,
|
||||
.upload-panel,
|
||||
.upload-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-row,
|
||||
.actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
11
frontend/vite.config.js
Normal file
11
frontend/vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user