implemented responses for PUT and OPTIONS

This commit is contained in:
Altair-sh
2026-11-06 03:09:01 +01:00
parent 7a43e7f8c9
commit 2098082aa6
11 changed files with 206 additions and 33 deletions
+1
View File
@@ -31,6 +31,7 @@ func run() int {
return 1 return 1
} }
// TODO: cleanup leftover .uploading files on restart
srv := server.NewServer(conf) srv := server.NewServer(conf)
if err := srv.StartGoroutine(); err != nil { if err := srv.StartGoroutine(); err != nil {
logger.Error(err) logger.Error(err)
+2 -2
View File
@@ -92,7 +92,7 @@ func (conn *Connection) Start() {
conn.log.Info(req.Method, " ", req.Url, " ", req.Protocol) conn.log.Info(req.Method, " ", req.Url, " ", req.Protocol)
conn.log.Trace("request headers: ", HeadersToString(req.Headers, SecretHeaders)) conn.log.Trace("request headers: ", HeadersToString(req.Headers, SecretHeaders))
res = conn.callback(req) res = conn.callback(req)
if req.HasUnreadBody() { if !res.CloseConnection && req.HasUnreadBody() {
res = conn.CreateErrorResponse(req, true, ErrHasUnreadBody) res = conn.CreateErrorResponse(req, true, ErrHasUnreadBody)
} }
} }
@@ -107,7 +107,7 @@ func (conn *Connection) Start() {
} }
// it is set by CreateResponse // it is set by CreateResponse
if res.Headers["connection"] == "close" { if res.CloseConnection {
conn.lingeringClose() conn.lingeringClose()
return return
} }
+11 -4
View File
@@ -3,6 +3,7 @@ package http
import ( import (
"fmt" "fmt"
"io" "io"
"math"
"net/url" "net/url"
sh "serveg/internal/string_helper" sh "serveg/internal/string_helper"
"strconv" "strconv"
@@ -104,20 +105,26 @@ func (req *Request) HasUnreadBody() bool {
return req.bodyReader.N > 0 return req.bodyReader.N > 0
} }
// maxBodyLen: pass -1 to disable limit
// returns HttpError
func (req *Request) GetBodyReader(maxBodyLen int64) (io.Reader, error) { func (req *Request) GetBodyReader(maxBodyLen int64) (io.Reader, error) {
if maxBodyLen < 0 {
maxBodyLen = math.MaxInt64
}
if req.ContentLength > maxBodyLen { if req.ContentLength > maxBodyLen {
return nil, NewHttpError(StatusRequestEntityTooLarge, return nil, NewHttpError(StatusRequestEntityTooLarge,
fmt.Sprintf("content-length %d > maxBodyLen %d", fmt.Sprintf("content-length (%d) > maxBodyLen (%d)",
req.ContentLength, maxBodyLen)) req.ContentLength, maxBodyLen))
} }
if req.ContentLength == 0 {
// body reader with N=0 is a valid reader that just returns EOF
return &req.bodyReader, nil
}
if !req.HasUnreadBody() { if !req.HasUnreadBody() {
return nil, ErrBodyHasBeenAlreadyRead return nil, ErrBodyHasBeenAlreadyRead
} }
if req.ContentLength != 0 {
// body reader with N=0 is a valid reader that just returns EOF
req.bodyReader.N = min(req.bodyReader.N, maxBodyLen) req.bodyReader.N = min(req.bodyReader.N, maxBodyLen)
}
return &req.bodyReader, nil return &req.bodyReader, nil
} }
+4
View File
@@ -24,6 +24,8 @@ type Response struct {
Status StatusCode Status StatusCode
// keys are in lower case // keys are in lower case
Headers HeadersMap Headers HeadersMap
// close connection after sending response
CloseConnection bool
// don't add body and content-* headers for HEAD request // don't add body and content-* headers for HEAD request
omitBody bool omitBody bool
bodySize int64 bodySize int64
@@ -42,6 +44,7 @@ func (res *Response) reset() {
res.needsReset = false res.needsReset = false
res.Status = StatusNotInitialized res.Status = StatusNotInitialized
clear(res.Headers) clear(res.Headers)
res.CloseConnection = false
res.omitBody = false res.omitBody = false
res.bodySize = 0 res.bodySize = 0
res.bodyType = responseBodyEmpty res.bodyType = responseBodyEmpty
@@ -74,6 +77,7 @@ func (conn *Connection) CreateResponse(status StatusCode, req *Request, wantsClo
} }
if wantsClose || !req.WantsKeepAlive { if wantsClose || !req.WantsKeepAlive {
res.CloseConnection = true
res.Headers["connection"] = "close" res.Headers["connection"] = "close"
} else if req.Protocol == "HTTP/1.0" { } else if req.Protocol == "HTTP/1.0" {
res.Headers["connection"] = "keep-alive" res.Headers["connection"] = "keep-alive"
+1
View File
@@ -18,6 +18,7 @@ func (c StatusCode) String() string {
// HTTP status codes as registered with IANA. // HTTP status codes as registered with IANA.
// See: https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml // See: https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
// Details: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
const ( const (
StatusNotInitialized StatusCode = 0 // for Response.reset StatusNotInitialized StatusCode = 0 // for Response.reset
StatusContinue StatusCode = 100 // RFC 9110, 15.2.1 StatusContinue StatusCode = 100 // RFC 9110, 15.2.1
+1 -1
View File
@@ -17,7 +17,7 @@ type Shares []*SharedLocation
type Config struct { type Config struct {
Addr string `toml:"address"` Addr string `toml:"address"`
MaxUpload int64 `toml:"max_upload"` MaxUpload uint64 `toml:"max_upload"`
HtmlTitle string `toml:"html_title"` HtmlTitle string `toml:"html_title"`
// sorted by Url in reverse to allow this pattern: // sorted by Url in reverse to allow this pattern:
// shared/private // shared/private
+5 -5
View File
@@ -9,16 +9,16 @@ import (
"time" "time"
) )
func (conn *TcpConnection) serveFileOrDir(req *http.Request) *http.Response { func (conn *TcpConnection) GET_HEAD(req *http.Request) *http.Response {
path, share, urlStatus := conn.srv.Conf.Share.MapUrl(req.Url) path, share, urlStatus := conn.srv.Conf.Share.MapUrl(req.Url)
// handle authorization // handle authorization
if share != nil { if share != nil {
access := checkSharePasswords(share, req) access := checkSharePasswords(share, req)
if access < AccessReadOnly { if access < AccessReadOnly {
return conn.createErrorResponse(req, false, http.StatusUnauthorized, return conn.createErrorResponse(req, false,
"authorization required"). http.StatusUnauthorized, "reader password required").
WithHeader("www-authenticate", WithHeader("www-authenticate",
`Basic realm="`+share.Url+`", charset="UTF-8"`) `Basic realm="`+share.Url+` (read)", charset="UTF-8"`)
} }
} }
@@ -27,7 +27,7 @@ func (conn *TcpConnection) serveFileOrDir(req *http.Request) *http.Response {
return conn.serveFile(req, path) return conn.serveFile(req, path)
case UrlDir: case UrlDir:
return conn.serveDirListing(req, path) return conn.serveDirListing(req, path)
case UrlNotFound: case UrlShareNotFound, UrlDoesntExistInShare:
return conn.createErrorResponse(req, false, http.StatusNotFound, req.Url) return conn.createErrorResponse(req, false, http.StatusNotFound, req.Url)
case UrlForbidden: case UrlForbidden:
return conn.createErrorResponse(req, false, http.StatusForbidden, req.Url) return conn.createErrorResponse(req, false, http.StatusForbidden, req.Url)
+162 -8
View File
@@ -1,12 +1,166 @@
package server package server
import "serveg/internal/http" import (
"errors"
"fmt"
"io"
"os"
"serveg/internal/http"
"strconv"
)
// TODO implement file uploading func DirOrFileExists(path string) (exists bool, fi os.FileInfo, err error) {
func (conn *TcpConnection) handleUpload(req *http.Request) *http.Response { fi, err = os.Stat(path)
// TODO: use os.OpenFile(filePath + ".upload", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if errors.Is(err, os.ErrNotExist) {
// if such file exists, send response like err = nil
// "somebody is uploading such file right now" } else if err == nil {
// TODO: defer { if !renamed { os.Remove(tmp) } } exists = true
panic("unimplemented") }
return
}
var ErrIsUploadingAlready = http.NewHttpError(http.StatusConflict,
"somebody is already uploading such file")
var ErrFileIsDir = http.NewHttpError(http.StatusConflict,
"there is a directory with such name already")
// PUT: accept request body and write it to filePath+".uploading"
// OPTIONS: check if file can be uploaded and return without reading body
// returns StatusCreated if upload is a new file, and StatusOK if it is replacing existing file
func (conn *TcpConnection) PUT_OPTIONS(req *http.Request) *http.Response {
reqHasBody := req.Method == "PUT"
path, share, urlStatus := conn.srv.Conf.Share.MapUrl(req.Url)
// handle authorization
if share != nil {
if share.WritePassword == "" {
return conn.createErrorResponse(req, reqHasBody, http.StatusForbidden, "share is read-only")
}
access := checkSharePasswords(share, req)
if access < AccessReadWrite {
return conn.createErrorResponse(req, reqHasBody,
http.StatusUnauthorized, "writer password required").
WithHeader("www-authenticate",
`Basic realm="`+share.Url+` (write)", charset="UTF-8"`)
}
}
switch urlStatus {
case UrlFile:
return conn.handleUpload(req, path, true)
case UrlDir:
return conn.http.CreateErrorResponse(req, reqHasBody, ErrFileIsDir)
case UrlDoesntExistInShare:
// TODO: allow users to create directories
return conn.handleUpload(req, path, false)
case UrlShareNotFound:
return conn.createErrorResponse(req, reqHasBody, http.StatusNotFound, req.Url)
case UrlForbidden:
return conn.createErrorResponse(req, reqHasBody, http.StatusForbidden, req.Url)
case UrlIncorrect:
return conn.createErrorResponse(req, reqHasBody, http.StatusBadRequest,
"invalid url '"+req.Url+"'")
default:
panic(fmt.Sprintf("unsupported UrlStatus: %d", int32(urlStatus)))
}
}
func (conn *TcpConnection) handleUpload(req *http.Request, filePath string, fileExists bool,
) *http.Response {
reqHasBody := req.Method == "PUT"
maxUpload := conn.srv.Conf.MaxUpload
var resultStatus http.StatusCode
if fileExists {
resultStatus = http.StatusOK
} else {
resultStatus = http.StatusCreated
}
errToResponse := func(err error) *http.Response {
httpErr, ok := errors.AsType[*http.HttpError](err)
if !ok {
httpErr = http.NewHttpError(http.StatusInternalServerError, err.Error())
}
return conn.http.CreateErrorResponse(req, reqHasBody, httpErr)
}
createResponseMsg := func(status http.StatusCode, msg string) *http.Response {
return conn.http.CreateResponse(status, req, false).
WithBodyStr(msg, http.TextPlain)
}
tmpPath := filePath + ".uploading"
tmpFileExists, fi, err := DirOrFileExists(tmpPath)
if err != nil {
return errToResponse(err)
}
if tmpFileExists {
var httpErr *http.HttpError
if fi.IsDir() {
httpErr = ErrFileIsDir
} else {
httpErr = ErrIsUploadingAlready
}
return conn.http.CreateErrorResponse(req, reqHasBody, httpErr)
}
// OPTIONS
if !reqHasBody {
uploadSize, err := strconv.ParseUint(req.Headers["x-upload-size"], 10, 64)
if err != nil {
return createResponseMsg(http.StatusBadRequest,
"OPTIONS request must have valid header x-upload-size")
}
if uploadSize > maxUpload {
return createResponseMsg(http.StatusRequestEntityTooLarge,
fmt.Sprintf("x-upload-size (%v) > maxUpload (%v)",
uploadSize, maxUpload))
}
return conn.http.CreateResponse(resultStatus, req, false)
}
// PUT
f, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if errors.Is(err, os.ErrExist) {
return errToResponse(ErrIsUploadingAlready)
}
if err != nil {
return errToResponse(err)
}
renamed := false
defer func() {
_ = f.Close()
// remove temp file if upload failed
if !renamed {
// TODO: log this error
_ = os.Remove(tmpPath)
}
}()
// read request body
reqBody, err := req.GetBodyReader(int64(maxUpload))
if err != nil {
return errToResponse(err)
}
n, err := io.Copy(f, reqBody)
if err != nil {
return errToResponse(err)
}
if n != req.ContentLength {
return conn.createErrorResponse(req, true, http.StatusBadRequest,
"uploaded file size != content-length")
}
// move tmpPath to filePath
if err = f.Sync(); err != nil {
return errToResponse(err)
}
if err = f.Close(); err != nil {
return errToResponse(err)
}
if err = os.Rename(tmpPath, filePath); err != nil {
return errToResponse(err)
}
renamed = true
// TODO: is it right status?
return conn.http.CreateResponse(resultStatus, req, false)
} }
+4 -3
View File
@@ -90,7 +90,8 @@ type UrlStatus uint32
const ( const (
UrlFile = UrlStatus(iota) UrlFile = UrlStatus(iota)
UrlDir UrlDir
UrlNotFound UrlDoesntExistInShare
UrlShareNotFound
UrlForbidden UrlForbidden
UrlIncorrect UrlIncorrect
) )
@@ -115,7 +116,7 @@ func (shares Shares) MapUrl(url string) (string, *SharedLocation, UrlStatus) {
fi, err := os.Stat(pathAbs) fi, err := os.Stat(pathAbs)
switch { switch {
case errors.Is(err, fs.ErrNotExist): case errors.Is(err, fs.ErrNotExist):
return pathAbs, sh, UrlNotFound return pathAbs, sh, UrlDoesntExistInShare
case err != nil: case err != nil:
return pathAbs, sh, UrlForbidden return pathAbs, sh, UrlForbidden
case fi.IsDir(): case fi.IsDir():
@@ -124,5 +125,5 @@ func (shares Shares) MapUrl(url string) (string, *SharedLocation, UrlStatus) {
return pathAbs, sh, UrlFile return pathAbs, sh, UrlFile
} }
} }
return url, nil, UrlNotFound return url, nil, UrlShareNotFound
} }
+11 -6
View File
@@ -28,17 +28,15 @@ func (conn *TcpConnection) Start() {
conn.http.Start() conn.http.Start()
} }
func (conn *TcpConnection) handleHttpRequest(req *http.Request) (res *http.Response) { func (conn *TcpConnection) handleHttpRequest(req *http.Request) *http.Response {
switch req.Method { switch req.Method {
case "GET", "HEAD": case "GET", "HEAD":
res = conn.serveFileOrDir(req) return conn.GET_HEAD(req)
case "PUT", "OPTIONS": case "PUT", "OPTIONS":
res = conn.handleUpload(req) return conn.PUT_OPTIONS(req)
default: default:
res = conn.createErrorResponse(req, false, http.StatusMethodNotAllowed, req.Method) return conn.responseMethodNotAllowed(req, "GET, HEAD, PUT, OPTIONS")
res.Headers["allow"] = "GET, HEAD, PUT, OPTIONS"
} }
return
} }
func (conn *TcpConnection) createErrorResponse( func (conn *TcpConnection) createErrorResponse(
@@ -46,3 +44,10 @@ func (conn *TcpConnection) createErrorResponse(
) *http.Response { ) *http.Response {
return conn.http.CreateErrorResponse(req, wantsClose, http.NewHttpError(status, msg)) return conn.http.CreateErrorResponse(req, wantsClose, http.NewHttpError(status, msg))
} }
func (conn *TcpConnection) responseMethodNotAllowed(req *http.Request, allowedMethods string,
) *http.Response {
return conn.createErrorResponse(req, false,
http.StatusMethodNotAllowed, req.Method).
WithHeader("allow", allowedMethods)
}
+3 -3
View File
@@ -11,7 +11,7 @@ import (
type ShareAccess uint8 type ShareAccess uint8
const ( const (
AccessDenied ShareAccess = iota AccessIncorrectPassword ShareAccess = iota
AccessReadOnly AccessReadOnly
AccessReadWrite AccessReadWrite
) )
@@ -22,7 +22,7 @@ func checkSharePasswords(share *SharedLocation, req *http.Request,
) ShareAccess { ) ShareAccess {
reqPass := getBasicAuthPassword(req) reqPass := getBasicAuthPassword(req)
canWrite := share.WritePassword != "" && canWrite := share.WritePassword != "" &&
(reqPass != "" && passwordMatches(reqPass, share.WritePassword)) reqPass != "" && passwordMatches(reqPass, share.WritePassword)
if canWrite { if canWrite {
return AccessReadWrite return AccessReadWrite
} }
@@ -31,7 +31,7 @@ func checkSharePasswords(share *SharedLocation, req *http.Request,
if canRead { if canRead {
return AccessReadOnly return AccessReadOnly
} }
return AccessDenied return AccessIncorrectPassword
} }
func getBasicAuthPassword(req *http.Request) string { func getBasicAuthPassword(req *http.Request) string {