go 57 lines · 9 steps

Resumable chunked uploads in Gin

A Gin handler writes each byte range of a file to its correct offset and finalizes once the whole thing has arrived.

Explained by highlit
1func UploadChunk(c *gin.Context) {
2 uploadID := c.Param("uploadID")
3 if !validUploadID.MatchString(uploadID) {
4 c.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload id"})
5 return
6 }
7 
8 start, end, total, err := parseContentRange(c.GetHeader("Content-Range"))
9 if err != nil {
10 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
11 return
12 }
13 
14 path := filepath.Join(uploadDir, uploadID+".part")
15 f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o644)
16 if err != nil {
17 c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot open upload"})
18 return
19 }
20 defer f.Close()
21 
22 if _, err := f.Seek(start, io.SeekStart); err != nil {
23 c.JSON(http.StatusInternalServerError, gin.H{"error": "seek failed"})
24 return
25 }
26 
27 written, err := io.CopyN(f, c.Request.Body, end-start+1)
28 if err != nil && err != io.EOF {
29 c.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"})
30 return
31 }
32 
33 if start+written >= total {
34 if err := os.Rename(path, filepath.Join(uploadDir, uploadID)); err != nil {
35 c.JSON(http.StatusInternalServerError, gin.H{"error": "finalize failed"})
36 return
37 }
38 c.JSON(http.StatusCreated, gin.H{"uploadID": uploadID, "size": total, "complete": true})
39 return
40 }
41 
42 c.Header("Range", fmt.Sprintf("bytes=0-%d", start+written-1))
43 c.JSON(http.StatusAccepted, gin.H{"uploadID": uploadID, "received": start + written, "total": total})
44}
45 
46func parseContentRange(h string) (start, end, total int64, err error) {
47 if !strings.HasPrefix(h, "bytes ") {
48 return 0, 0, 0, fmt.Errorf("missing Content-Range header")
49 }
50 if _, err = fmt.Sscanf(h, "bytes %d-%d/%d", &start, &end, &total); err != nil {
51 return 0, 0, 0, fmt.Errorf("malformed Content-Range")
52 }
53 if start < 0 || end < start || end >= total {
54 return 0, 0, 0, fmt.Errorf("invalid range bounds")
55 }
56 return start, end, total, nil
57}
01 / 01
STEP 01

Walkthrough

Space play ←→ step click any line
Three takeaways
  1. 1Writing chunks at their declared offset lets uploads resume without buffering the whole file in memory.
  2. 2Staging to a .part file and renaming on completion gives an atomic, all-or-nothing finalize.
  3. 3Every piece of client-supplied input — the id, the range header, the bounds — must be validated before it touches disk.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Resumable chunked uploads in Gin — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code