Skip to content

Commit 9ac4377

Browse files
committed
copy supports copying to primary storage with different dest key
1 parent 57bf1f0 commit 9ac4377

File tree

5 files changed

+356
-45
lines changed

5 files changed

+356
-45
lines changed

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ zipserver <command> --help # Show help for a specific command
5454
|---------|-------------|---------|---------------|
5555
| `server` | Start HTTP server (default) | n/a | |
5656
| `extract` | Extract a zip file to storage | Source read, optional target write | `/extract` |
57-
| `copy` | Copy a file to target storage | Source read, target write | `/copy` |
57+
| `copy` | Copy a file to target storage or different key | Source read, target or source write | `/copy` |
5858
| `delete` | Delete files from storage | Target write | `/delete` |
5959
| `list` | List files in a zip archive | Source read, URL, or local file | `/list` |
6060
| `slurp` | Download a URL and store it | Source write, or optional target write | `/slurp` |
@@ -157,12 +157,19 @@ curl -X POST "http://localhost:8090/extract" \
157157

158158
## Copy
159159

160-
Copy a file from primary storage to a target storage (e.g., S3).
160+
Copy a file from primary storage to a target storage, or to a different key within primary storage.
161161

162162
**CLI:**
163163
```bash
164+
# Copy to a target storage
164165
zipserver copy --key path/to/file.zip --target s3backup
165166

167+
# Copy to a different key within primary storage (rename/move)
168+
zipserver copy --key path/to/file.zip --dest-key archive/file.zip
169+
170+
# Copy to target storage with a different destination key
171+
zipserver copy --key path/to/file.zip --target s3backup --dest-key renamed.zip
172+
166173
# With HTML footer injection
167174
zipserver copy --key games/123/index.html --target s3backup \
168175
--html-footer '<script src="/analytics.js"></script>'
@@ -173,6 +180,12 @@ zipserver copy --key games/123/index.html --target s3backup \
173180
# Sync mode (waits for completion)
174181
curl "http://localhost:8090/copy?key=path/to/file.zip&target=s3backup"
175182

183+
# Copy within primary storage to a different key
184+
curl "http://localhost:8090/copy?key=path/to/file.zip&dest_key=archive/file.zip"
185+
186+
# Copy to target with different destination key
187+
curl "http://localhost:8090/copy?key=path/to/file.zip&target=s3backup&dest_key=renamed.zip"
188+
176189
# Async mode (returns immediately, notifies callback when done)
177190
curl "http://localhost:8090/copy?key=path/to/file.zip&target=s3backup&callback=http://example.com/done"
178191

@@ -183,6 +196,8 @@ curl -X POST "http://localhost:8090/copy" \
183196
-d "html_footer=<script src=\"/analytics.js\"></script>"
184197
```
185198

199+
Either `target` or `dest_key` (or both) must be provided. When copying within the same storage (no `target`), `dest_key` must differ from `key`.
200+
186201
### HTML Footer Injection for Copy
187202

188203
When copying files, you can inject an HTML snippet at the end of the file using the `html_footer` parameter.

main.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ var (
5151
extractHtmlFooter = extractCmd.Flag("html-footer", "HTML snippet to append to all index.html files").String()
5252

5353
// Copy command
54-
copyCmd = app.Command("copy", "Copy a file to target storage")
54+
copyCmd = app.Command("copy", "Copy a file to target storage or different key")
5555
copyKey = copyCmd.Flag("key", "Storage key to copy").Required().String()
56-
copyTarget = copyCmd.Flag("target", "Target storage name").Required().String()
56+
copyDestKey = copyCmd.Flag("dest-key", "Destination key (defaults to source key)").String()
57+
copyTarget = copyCmd.Flag("target", "Target storage name").String()
5758
copyBucket = copyCmd.Flag("bucket", "Expected bucket (optional verification)").String()
5859
copyHtmlFooter = copyCmd.Flag("html-footer", "HTML snippet to append to copied file").String()
5960

@@ -232,6 +233,7 @@ func runCopy(config *zipserver.Config) {
232233

233234
params := zipserver.CopyParams{
234235
Key: *copyKey,
236+
DestKey: *copyDestKey,
235237
TargetName: *copyTarget,
236238
ExpectedBucket: *copyBucket,
237239
HtmlFooter: *copyHtmlFooter,

zipserver/copy_handler.go

Lines changed: 52 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,31 +13,40 @@ import (
1313

1414
var copyLockTable = NewLockTable()
1515

16-
// Copy copies a file from primary storage to a target storage
16+
// Copy copies a file from primary storage to a target storage, or within primary
17+
// storage if no target is specified. If DestKey is set, the file is written to
18+
// that key; otherwise it uses the source Key.
1719
func (o *Operations) Copy(ctx context.Context, params CopyParams) CopyResult {
18-
storageTargetConfig := o.config.GetStorageTargetByName(params.TargetName)
19-
if storageTargetConfig == nil {
20-
return CopyResult{Err: fmt.Errorf("invalid target: %s", params.TargetName)}
20+
if err := params.Validate(o.config); err != nil {
21+
return CopyResult{Err: err}
2122
}
2223

23-
if storageTargetConfig.Readonly {
24-
return CopyResult{Err: fmt.Errorf("target %s is readonly", params.TargetName)}
25-
}
26-
27-
targetBucket := storageTargetConfig.Bucket
28-
29-
if params.ExpectedBucket != "" && params.ExpectedBucket != targetBucket {
30-
return CopyResult{Err: fmt.Errorf("expected bucket does not match target bucket: %s != %s", params.ExpectedBucket, targetBucket)}
31-
}
24+
destKey := params.DestKeyOrKey()
3225

26+
// Create source storage (always primary)
3327
storage, err := NewGcsStorage(o.config)
3428
if err != nil {
3529
return CopyResult{Err: fmt.Errorf("failed to create source storage: %v", err)}
3630
}
3731

38-
targetStorage, err := storageTargetConfig.NewStorageClient()
39-
if err != nil {
40-
return CopyResult{Err: fmt.Errorf("failed to create target storage: %v", err)}
32+
var targetStorage Storage
33+
var targetBucket string
34+
var targetLabel string
35+
36+
if params.TargetName == "" {
37+
// Same-storage copy: reuse source storage for both read and write
38+
targetStorage = storage
39+
targetBucket = o.config.Bucket
40+
targetLabel = "primary"
41+
} else {
42+
// Cross-storage copy: read from primary, write to target
43+
storageTargetConfig := o.config.GetStorageTargetByName(params.TargetName)
44+
targetBucket = storageTargetConfig.Bucket
45+
targetStorage, err = storageTargetConfig.NewStorageClient()
46+
if err != nil {
47+
return CopyResult{Err: fmt.Errorf("failed to create target storage: %v", err)}
48+
}
49+
targetLabel = params.TargetName
4150
}
4251

4352
startTime := time.Now()
@@ -73,23 +82,23 @@ func (o *Operations) Copy(ctx context.Context, params CopyParams) CopyResult {
7382
}
7483

7584
if injected {
76-
log.Print("Starting transfer (injected): [", params.TargetName, "] ", targetBucket, "/", params.Key, " ", opts)
85+
log.Print("Starting transfer (injected): [", targetLabel, "] ", targetBucket, "/", destKey, " ", opts)
7786
} else {
78-
log.Print("Starting transfer: [", params.TargetName, "] ", targetBucket, "/", params.Key, " ", opts)
87+
log.Print("Starting transfer: [", targetLabel, "] ", targetBucket, "/", destKey, " ", opts)
7988
}
80-
result, err := targetStorage.PutFile(ctx, targetBucket, params.Key, mReader, opts)
89+
result, err := targetStorage.PutFile(ctx, targetBucket, destKey, mReader, opts)
8190
if err != nil {
8291
return CopyResult{Err: fmt.Errorf("failed to copy file: %v", err)}
8392
}
8493

8594
globalMetrics.TotalCopiedFiles.Add(1)
86-
log.Print("Transfer complete: [", params.TargetName, "] ", targetBucket, "/", params.Key,
95+
log.Print("Transfer complete: [", targetLabel, "] ", targetBucket, "/", destKey,
8796
", bytes read: ", formatBytes(float64(mReader.BytesRead)),
8897
", duration: ", mReader.Duration.Seconds(),
8998
", speed: ", formatBytes(mReader.TransferSpeed()), "/s")
9099

91100
return CopyResult{
92-
Key: params.Key,
101+
Key: destKey,
93102
Duration: fmt.Sprintf("%.4fs", time.Since(startTime).Seconds()),
94103
Size: mReader.BytesRead,
95104
Md5: result.MD5,
@@ -153,8 +162,9 @@ func notifyError(callbackURL string, err error) error {
153162
return notifyCallback(callbackURL, message)
154163
}
155164

156-
// The copy handler will asynchronously copy a file from primary storage to the
157-
// storage specified by target
165+
// The copy handler asynchronously copies a file from primary storage to either:
166+
// - A target storage (when target is specified)
167+
// - A different key within primary storage (when only dest_key is specified)
158168
func copyHandler(w http.ResponseWriter, r *http.Request) error {
159169
if err := r.ParseForm(); err != nil {
160170
return fmt.Errorf("failed to parse form: %w", err)
@@ -166,21 +176,30 @@ func copyHandler(w http.ResponseWriter, r *http.Request) error {
166176
}
167177

168178
callbackURL := params.Get("callback")
179+
targetName := params.Get("target")
180+
destKey := params.Get("dest_key")
169181

170-
targetName, err := getParam(params, "target")
171-
if err != nil {
182+
expectedBucket, _ := getParam(params, "bucket")
183+
htmlFooter := params.Get("html_footer")
184+
185+
copyParams := CopyParams{
186+
Key: key,
187+
DestKey: destKey,
188+
TargetName: targetName,
189+
ExpectedBucket: expectedBucket,
190+
HtmlFooter: htmlFooter,
191+
}
192+
if err := copyParams.Validate(globalConfig); err != nil {
172193
return err
173194
}
174195

175-
storageTargetConfig := globalConfig.GetStorageTargetByName(targetName)
176-
if storageTargetConfig == nil {
177-
return fmt.Errorf("invalid target: %s", targetName)
196+
// Use dest_key for lock if provided, otherwise use key
197+
lockDestKey := copyParams.DestKeyOrKey()
198+
lockTargetName := targetName
199+
if lockTargetName == "" {
200+
lockTargetName = "primary"
178201
}
179-
180-
expectedBucket, _ := getParam(params, "bucket")
181-
htmlFooter := params.Get("html_footer")
182-
183-
lockKey := fmt.Sprintf("%s:%s", targetName, key)
202+
lockKey := fmt.Sprintf("%s:%s", lockTargetName, lockDestKey)
184203

185204
hasLock := copyLockTable.tryLockKey(lockKey)
186205

@@ -190,13 +209,6 @@ func copyHandler(w http.ResponseWriter, r *http.Request) error {
190209
}
191210

192211
ops := NewOperations(globalConfig)
193-
copyParams := CopyParams{
194-
Key: key,
195-
TargetName: targetName,
196-
ExpectedBucket: expectedBucket,
197-
HtmlFooter: htmlFooter,
198-
}
199-
200212
// sync codepath
201213
if callbackURL == "" {
202214
defer copyLockTable.releaseKey(lockKey)

0 commit comments

Comments
 (0)