diff --git a/.gitignore b/.gitignore index aa32608..9ba9e76 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /serveg bin/ __debug_bin* +graphify-out/ +graphify_out/ diff --git a/README.md b/README.md index cf9fdd2..9c0b472 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,22 @@ bin/servegp # reads ./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: ` | 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. + diff --git a/internal/http/range.go b/internal/http/range.go new file mode 100644 index 0000000..c0a5de5 --- /dev/null +++ b/internal/http/range.go @@ -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 +} diff --git a/internal/http/range_test.go b/internal/http/range_test.go new file mode 100644 index 0000000..a52a37a --- /dev/null +++ b/internal/http/range_test.go @@ -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) + } +} diff --git a/internal/server/GET-HEAD.go b/internal/server/GET_HEAD.go similarity index 59% rename from internal/server/GET-HEAD.go rename to internal/server/GET_HEAD.go index dcf0f78..24eac4d 100644 --- a/internal/server/GET-HEAD.go +++ b/internal/server/GET_HEAD.go @@ -2,11 +2,11 @@ package server import ( "fmt" + "io" "os" "path" "serveg/internal/html" "serveg/internal/http" - "time" ) 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() - // time is received with second precision - // if not truncate, mtime will always be > ifModifiedSince - mtime := finf.ModTime().UTC().Truncate(time.Second) - - ifModifiedSince, errTime := time.Parse(http.HeaderTimeFormat, req.Headers["if-modified-since"]) - notModified := errTime == nil && !mtime.After(ifModifiedSince) - if notModified { + // Clients send back the exact last-modified value they received. + // Exact match (not "<=") also detects a file replaced by one with older mtime. + lastModified := finf.ModTime().UTC().Format(http.HeaderTimeFormat) + if req.Headers["if-modified-since"] == lastModified { 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) + mimeType := http.GetMimeType(fileName) if req.Method != "HEAD" { // file will be closed by SendResponse() 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 // On next request browser will send if-modified-since and let server check if file changed. 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) - //TODO: accept "range" header and return StatusPartialContent - // res.Headers["accept-ranges"] = "bytes" + res.Headers["accept-ranges"] = "bytes" return res } diff --git a/internal/server/PUT-OPTIONS.go b/internal/server/PUT_OPTIONS.go similarity index 99% rename from internal/server/PUT-OPTIONS.go rename to internal/server/PUT_OPTIONS.go index 9e4a72e..889cb52 100644 --- a/internal/server/PUT-OPTIONS.go +++ b/internal/server/PUT_OPTIONS.go @@ -104,7 +104,7 @@ func (conn *TcpConnection) handleUpload(req *http.Request, filePath string, file // OPTIONS 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 { return createResponseMsg(http.StatusBadRequest, "OPTIONS request must have valid header x-upload-size")