added range support to continue interrupted download
This commit is contained in:
@@ -2,3 +2,5 @@
|
|||||||
/serveg
|
/serveg
|
||||||
bin/
|
bin/
|
||||||
__debug_bin*
|
__debug_bin*
|
||||||
|
graphify-out/
|
||||||
|
graphify_out/
|
||||||
|
|||||||
@@ -25,3 +25,22 @@ bin/servegp # reads ./serveg.toml
|
|||||||
bin/servegp -config /etc/serveg/serveg.toml
|
bin/servegp -config /etc/serveg/serveg.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
| Request | Result |
|
||||||
|
|---|---|
|
||||||
|
| `GET /share/dir/` | directory listing with an upload form if the share has `write_password` |
|
||||||
|
| `GET /share/file` | download file, supports `If-Modified-Since`, `Range` (single range) and `If-Range` (date) |
|
||||||
|
| `PUT /share/file` | upload: 201 created, 200 replaced |
|
||||||
|
| `OPTIONS /share/file` with `X-Upload-Size: <size>` | upload check, same as PUT but without body |
|
||||||
|
|
||||||
|
Passwords use HTTP Basic auth, the user name is ignored:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -O http://localhost:8080/public/file.zip
|
||||||
|
curl -C - -O http://localhost:8080/public/file.zip # continue interrupted download
|
||||||
|
curl -u :password -T file.zip http://localhost:8080/public/file.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Error responses explain the reason in a plain text body.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RangeResult int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// no header, invalid syntax or multiple ranges: serve the full body with 200
|
||||||
|
RangeNone RangeResult = iota
|
||||||
|
// serve bytes [start, end] (inclusive) with 206
|
||||||
|
RangeOK
|
||||||
|
// respond with 416
|
||||||
|
RangeUnsatisfiable
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseRange parses a "Range" header value for a resource of the given size.
|
||||||
|
// Only a single byte range is supported: "bytes=a-b", "bytes=a-", "bytes=-n".
|
||||||
|
// Anything else is ignored (RangeNone), which RFC 9110 §14.2 allows.
|
||||||
|
func ParseRange(header string, size int64) (start, end int64, r RangeResult) {
|
||||||
|
header = strings.TrimSpace(header)
|
||||||
|
if header == "" {
|
||||||
|
return 0, 0, RangeNone
|
||||||
|
}
|
||||||
|
spec, ok := strings.CutPrefix(header, "bytes=")
|
||||||
|
if !ok || strings.ContainsRune(spec, ',') {
|
||||||
|
return 0, 0, RangeNone
|
||||||
|
}
|
||||||
|
firstStr, lastStr, ok := strings.Cut(spec, "-")
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, RangeNone
|
||||||
|
}
|
||||||
|
firstStr = strings.TrimSpace(firstStr)
|
||||||
|
lastStr = strings.TrimSpace(lastStr)
|
||||||
|
|
||||||
|
if firstStr == "" {
|
||||||
|
// suffix range: last n bytes
|
||||||
|
n, ok := parseNonNegative(lastStr)
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, RangeNone
|
||||||
|
}
|
||||||
|
if n == 0 || size == 0 {
|
||||||
|
return 0, 0, RangeUnsatisfiable
|
||||||
|
}
|
||||||
|
return max(size-n, 0), size - 1, RangeOK
|
||||||
|
}
|
||||||
|
|
||||||
|
first, ok := parseNonNegative(firstStr)
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, RangeNone
|
||||||
|
}
|
||||||
|
last := size - 1
|
||||||
|
if lastStr != "" {
|
||||||
|
last, ok = parseNonNegative(lastStr)
|
||||||
|
if !ok || last < first {
|
||||||
|
return 0, 0, RangeNone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if first >= size {
|
||||||
|
return 0, 0, RangeUnsatisfiable
|
||||||
|
}
|
||||||
|
return first, min(last, size-1), RangeOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNonNegative(s string) (int64, bool) {
|
||||||
|
n, err := strconv.ParseUint(s, 10, 63)
|
||||||
|
return int64(n), err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseRange(t *testing.T) {
|
||||||
|
const size = 1000
|
||||||
|
tests := []struct {
|
||||||
|
header string
|
||||||
|
start, end int64
|
||||||
|
result RangeResult
|
||||||
|
}{
|
||||||
|
{"", 0, 0, RangeNone},
|
||||||
|
{"bytes=0-", 0, 999, RangeOK},
|
||||||
|
{"bytes=500-", 500, 999, RangeOK},
|
||||||
|
{"bytes=0-499", 0, 499, RangeOK},
|
||||||
|
{"bytes=999-999", 999, 999, RangeOK},
|
||||||
|
{"bytes=-100", 900, 999, RangeOK},
|
||||||
|
{"bytes=-5000", 0, 999, RangeOK},
|
||||||
|
{"bytes=900-5000", 900, 999, RangeOK},
|
||||||
|
{" bytes= 10 - 20 ", 10, 20, RangeOK},
|
||||||
|
{"bytes=1000-", 0, 0, RangeUnsatisfiable},
|
||||||
|
{"bytes=1000-2000", 0, 0, RangeUnsatisfiable},
|
||||||
|
{"bytes=-0", 0, 0, RangeUnsatisfiable},
|
||||||
|
{"bytes=5-1", 0, 0, RangeNone},
|
||||||
|
{"bytes=0-1,5-9", 0, 0, RangeNone},
|
||||||
|
{"items=0-1", 0, 0, RangeNone},
|
||||||
|
{"bytes=abc-", 0, 0, RangeNone},
|
||||||
|
{"bytes=+5-", 0, 0, RangeNone},
|
||||||
|
{"bytes=--5", 0, 0, RangeNone},
|
||||||
|
{"bytes=5", 0, 0, RangeNone},
|
||||||
|
{"bytes=-", 0, 0, RangeNone},
|
||||||
|
{"bytes=99999999999999999999-", 0, 0, RangeNone},
|
||||||
|
{"bytes=9223372036854775807-", 0, 0, RangeUnsatisfiable}, // MaxInt64
|
||||||
|
{"bytes=9223372036854775808-", 0, 0, RangeNone}, // MaxInt64+1
|
||||||
|
{"bytes=0-9223372036854775808", 0, 0, RangeNone},
|
||||||
|
{"bytes=1_0-", 0, 0, RangeNone},
|
||||||
|
{"bytes=-+5", 0, 0, RangeNone},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
start, end, r := ParseRange(tt.header, size)
|
||||||
|
if r != tt.result || start != tt.start || end != tt.end {
|
||||||
|
t.Errorf("ParseRange(%q) = (%d, %d, %d), want (%d, %d, %d)",
|
||||||
|
tt.header, start, end, r, tt.start, tt.end, tt.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// empty file: any range is unsatisfiable
|
||||||
|
if _, _, r := ParseRange("bytes=0-", 0); r != RangeUnsatisfiable {
|
||||||
|
t.Errorf("empty file bytes=0-: got %d, want RangeUnsatisfiable", r)
|
||||||
|
}
|
||||||
|
if _, _, r := ParseRange("bytes=-10", 0); r != RangeUnsatisfiable {
|
||||||
|
t.Errorf("empty file bytes=-10: got %d, want RangeUnsatisfiable", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"serveg/internal/html"
|
"serveg/internal/html"
|
||||||
"serveg/internal/http"
|
"serveg/internal/http"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (conn *TcpConnection) GET_HEAD(req *http.Request) *http.Response {
|
func (conn *TcpConnection) GET_HEAD(req *http.Request) *http.Response {
|
||||||
@@ -68,31 +68,59 @@ func (conn *TcpConnection) serveFile(req *http.Request, filePath string) *http.R
|
|||||||
}
|
}
|
||||||
size := finf.Size()
|
size := finf.Size()
|
||||||
|
|
||||||
// time is received with second precision
|
// Clients send back the exact last-modified value they received.
|
||||||
// if not truncate, mtime will always be > ifModifiedSince
|
// Exact match (not "<=") also detects a file replaced by one with older mtime.
|
||||||
mtime := finf.ModTime().UTC().Truncate(time.Second)
|
lastModified := finf.ModTime().UTC().Format(http.HeaderTimeFormat)
|
||||||
|
if req.Headers["if-modified-since"] == lastModified {
|
||||||
ifModifiedSince, errTime := time.Parse(http.HeaderTimeFormat, req.Headers["if-modified-since"])
|
|
||||||
notModified := errTime == nil && !mtime.After(ifModifiedSince)
|
|
||||||
if notModified {
|
|
||||||
return conn.http.CreateResponse(http.StatusNotModified, req, false)
|
return conn.http.CreateResponse(http.StatusNotModified, req, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
res := conn.http.CreateResponse(http.StatusOK, req, false)
|
// Range is used to continue interrupted downloads.
|
||||||
|
// If-Range says "send the range only if the file wasn't changed, otherwise send the whole file".
|
||||||
|
rangeHdr := req.Headers["range"]
|
||||||
|
if ifRange, ok := req.Headers["if-range"]; ok && ifRange != lastModified {
|
||||||
|
rangeHdr = ""
|
||||||
|
}
|
||||||
|
start, end, rangeResult := http.ParseRange(rangeHdr, size)
|
||||||
|
|
||||||
|
status := http.StatusOK
|
||||||
|
bodyLen := size
|
||||||
|
switch rangeResult {
|
||||||
|
case http.RangeUnsatisfiable:
|
||||||
|
return conn.createErrorResponse(req, false, http.StatusRequestedRangeNotSatisfiable,
|
||||||
|
fmt.Sprintf("range '%s' is outside of file size %d", rangeHdr, size)).
|
||||||
|
WithHeader("content-range", fmt.Sprintf("bytes */%d", size)).
|
||||||
|
WithHeader("accept-ranges", "bytes")
|
||||||
|
case http.RangeOK:
|
||||||
|
status = http.StatusPartialContent
|
||||||
|
bodyLen = end - start + 1
|
||||||
|
if _, err = f.Seek(start, io.SeekStart); err != nil {
|
||||||
|
return conn.createErrorResponse(req, false, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res := conn.http.CreateResponse(status, req, false)
|
||||||
fileName := path.Base(filePath)
|
fileName := path.Base(filePath)
|
||||||
|
mimeType := http.GetMimeType(fileName)
|
||||||
|
|
||||||
if req.Method != "HEAD" {
|
if req.Method != "HEAD" {
|
||||||
// file will be closed by SendResponse()
|
// file will be closed by SendResponse()
|
||||||
fileOwnershipTransferred = true
|
fileOwnershipTransferred = true
|
||||||
res.WithBodyReader(f, size, http.GetMimeType(fileName))
|
res.WithBodyReader(f, bodyLen, mimeType)
|
||||||
|
} else {
|
||||||
|
// WithBodyReader does nothing for HEAD, but clients use HEAD to get file size
|
||||||
|
res.WithHeaderI("content-length", bodyLen).
|
||||||
|
WithHeader("content-type", mimeType)
|
||||||
|
}
|
||||||
|
if rangeResult == http.RangeOK {
|
||||||
|
res.Headers["content-range"] = fmt.Sprintf("bytes %d-%d/%d", start, end, size)
|
||||||
}
|
}
|
||||||
// Tell browser to not use its own caching, remember last-modified
|
// Tell browser to not use its own caching, remember last-modified
|
||||||
// On next request browser will send if-modified-since and let server check if file changed.
|
// On next request browser will send if-modified-since and let server check if file changed.
|
||||||
res.Headers["cache-control"] = "no-cache"
|
res.Headers["cache-control"] = "no-cache"
|
||||||
res.Headers["last-modified"] = mtime.Format(http.HeaderTimeFormat)
|
res.Headers["last-modified"] = lastModified
|
||||||
res.Headers["content-disposition"] = http.ContentDisposition("inline", fileName)
|
res.Headers["content-disposition"] = http.ContentDisposition("inline", fileName)
|
||||||
//TODO: accept "range" header and return StatusPartialContent
|
res.Headers["accept-ranges"] = "bytes"
|
||||||
// res.Headers["accept-ranges"] = "bytes"
|
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ func (conn *TcpConnection) handleUpload(req *http.Request, filePath string, file
|
|||||||
|
|
||||||
// OPTIONS
|
// OPTIONS
|
||||||
if !reqHasBody {
|
if !reqHasBody {
|
||||||
uploadSize, err := strconv.ParseUint(req.Headers["x-upload-size"], 10, 64)
|
uploadSize, err := strconv.ParseUint(req.Headers["x-upload-size"], 10, 63)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return createResponseMsg(http.StatusBadRequest,
|
return createResponseMsg(http.StatusBadRequest,
|
||||||
"OPTIONS request must have valid header x-upload-size")
|
"OPTIONS request must have valid header x-upload-size")
|
||||||
Reference in New Issue
Block a user