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