diff --git a/cmd/serveg/main.go b/cmd/serveg/main.go index 9bc79ef..c6d0d1e 100644 --- a/cmd/serveg/main.go +++ b/cmd/serveg/main.go @@ -31,6 +31,7 @@ func run() int { return 1 } + // TODO: cleanup leftover .uploading files on restart srv := server.NewServer(conf) if err := srv.StartGoroutine(); err != nil { logger.Error(err) diff --git a/internal/http/Connection.go b/internal/http/Connection.go index 37da983..5ab39b2 100644 --- a/internal/http/Connection.go +++ b/internal/http/Connection.go @@ -92,7 +92,7 @@ func (conn *Connection) Start() { conn.log.Info(req.Method, " ", req.Url, " ", req.Protocol) conn.log.Trace("request headers: ", HeadersToString(req.Headers, SecretHeaders)) res = conn.callback(req) - if req.HasUnreadBody() { + if !res.CloseConnection && req.HasUnreadBody() { res = conn.CreateErrorResponse(req, true, ErrHasUnreadBody) } } @@ -107,7 +107,7 @@ func (conn *Connection) Start() { } // it is set by CreateResponse - if res.Headers["connection"] == "close" { + if res.CloseConnection { conn.lingeringClose() return } diff --git a/internal/http/Request.go b/internal/http/Request.go index 891b46d..b8759ae 100644 --- a/internal/http/Request.go +++ b/internal/http/Request.go @@ -3,6 +3,7 @@ package http import ( "fmt" "io" + "math" "net/url" sh "serveg/internal/string_helper" "strconv" @@ -104,20 +105,26 @@ func (req *Request) HasUnreadBody() bool { return req.bodyReader.N > 0 } +// maxBodyLen: pass -1 to disable limit +// returns HttpError func (req *Request) GetBodyReader(maxBodyLen int64) (io.Reader, error) { + if maxBodyLen < 0 { + maxBodyLen = math.MaxInt64 + } if req.ContentLength > maxBodyLen { return nil, NewHttpError(StatusRequestEntityTooLarge, - fmt.Sprintf("content-length %d > maxBodyLen %d", + fmt.Sprintf("content-length (%d) > maxBodyLen (%d)", 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() { 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 } diff --git a/internal/http/Response.go b/internal/http/Response.go index ec40ede..e343504 100644 --- a/internal/http/Response.go +++ b/internal/http/Response.go @@ -24,6 +24,8 @@ type Response struct { Status StatusCode // keys are in lower case Headers HeadersMap + // close connection after sending response + CloseConnection bool // don't add body and content-* headers for HEAD request omitBody bool bodySize int64 @@ -42,6 +44,7 @@ func (res *Response) reset() { res.needsReset = false res.Status = StatusNotInitialized clear(res.Headers) + res.CloseConnection = false res.omitBody = false res.bodySize = 0 res.bodyType = responseBodyEmpty @@ -74,6 +77,7 @@ func (conn *Connection) CreateResponse(status StatusCode, req *Request, wantsClo } if wantsClose || !req.WantsKeepAlive { + res.CloseConnection = true res.Headers["connection"] = "close" } else if req.Protocol == "HTTP/1.0" { res.Headers["connection"] = "keep-alive" diff --git a/internal/http/StatusCodes.go b/internal/http/StatusCodes.go index 7ae9747..3d59cdc 100644 --- a/internal/http/StatusCodes.go +++ b/internal/http/StatusCodes.go @@ -18,6 +18,7 @@ func (c StatusCode) String() string { // HTTP status codes as registered with IANA. // 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 ( StatusNotInitialized StatusCode = 0 // for Response.reset StatusContinue StatusCode = 100 // RFC 9110, 15.2.1 diff --git a/internal/server/Config.go b/internal/server/Config.go index 711c4b7..469f117 100644 --- a/internal/server/Config.go +++ b/internal/server/Config.go @@ -17,7 +17,7 @@ type Shares []*SharedLocation type Config struct { Addr string `toml:"address"` - MaxUpload int64 `toml:"max_upload"` + MaxUpload uint64 `toml:"max_upload"` HtmlTitle string `toml:"html_title"` // sorted by Url in reverse to allow this pattern: // shared/private diff --git a/internal/server/GET-HEAD.go b/internal/server/GET-HEAD.go index 3b27a4f..dcf0f78 100644 --- a/internal/server/GET-HEAD.go +++ b/internal/server/GET-HEAD.go @@ -9,16 +9,16 @@ import ( "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) // handle authorization if share != nil { access := checkSharePasswords(share, req) if access < AccessReadOnly { - return conn.createErrorResponse(req, false, http.StatusUnauthorized, - "authorization required"). + return conn.createErrorResponse(req, false, + http.StatusUnauthorized, "reader password required"). 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) case UrlDir: return conn.serveDirListing(req, path) - case UrlNotFound: + case UrlShareNotFound, UrlDoesntExistInShare: return conn.createErrorResponse(req, false, http.StatusNotFound, req.Url) case UrlForbidden: return conn.createErrorResponse(req, false, http.StatusForbidden, req.Url) diff --git a/internal/server/PUT-OPTIONS.go b/internal/server/PUT-OPTIONS.go index f82be39..9e4a72e 100644 --- a/internal/server/PUT-OPTIONS.go +++ b/internal/server/PUT-OPTIONS.go @@ -1,12 +1,166 @@ package server -import "serveg/internal/http" +import ( + "errors" + "fmt" + "io" + "os" + "serveg/internal/http" + "strconv" +) -// TODO implement file uploading -func (conn *TcpConnection) handleUpload(req *http.Request) *http.Response { - // TODO: use os.OpenFile(filePath + ".upload", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) - // if such file exists, send response like - // "somebody is uploading such file right now" - // TODO: defer { if !renamed { os.Remove(tmp) } } - panic("unimplemented") +func DirOrFileExists(path string) (exists bool, fi os.FileInfo, err error) { + fi, err = os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + err = nil + } else if err == nil { + exists = true + } + 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) } diff --git a/internal/server/SharedLocation.go b/internal/server/SharedLocation.go index c5674b6..99e41c9 100644 --- a/internal/server/SharedLocation.go +++ b/internal/server/SharedLocation.go @@ -90,7 +90,8 @@ type UrlStatus uint32 const ( UrlFile = UrlStatus(iota) UrlDir - UrlNotFound + UrlDoesntExistInShare + UrlShareNotFound UrlForbidden UrlIncorrect ) @@ -115,7 +116,7 @@ func (shares Shares) MapUrl(url string) (string, *SharedLocation, UrlStatus) { fi, err := os.Stat(pathAbs) switch { case errors.Is(err, fs.ErrNotExist): - return pathAbs, sh, UrlNotFound + return pathAbs, sh, UrlDoesntExistInShare case err != nil: return pathAbs, sh, UrlForbidden case fi.IsDir(): @@ -124,5 +125,5 @@ func (shares Shares) MapUrl(url string) (string, *SharedLocation, UrlStatus) { return pathAbs, sh, UrlFile } } - return url, nil, UrlNotFound + return url, nil, UrlShareNotFound } diff --git a/internal/server/TcpConnection.go b/internal/server/TcpConnection.go index 81367f0..d3feb06 100644 --- a/internal/server/TcpConnection.go +++ b/internal/server/TcpConnection.go @@ -28,17 +28,15 @@ func (conn *TcpConnection) 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 { case "GET", "HEAD": - res = conn.serveFileOrDir(req) + return conn.GET_HEAD(req) case "PUT", "OPTIONS": - res = conn.handleUpload(req) + return conn.PUT_OPTIONS(req) default: - res = conn.createErrorResponse(req, false, http.StatusMethodNotAllowed, req.Method) - res.Headers["allow"] = "GET, HEAD, PUT, OPTIONS" + return conn.responseMethodNotAllowed(req, "GET, HEAD, PUT, OPTIONS") } - return } func (conn *TcpConnection) createErrorResponse( @@ -46,3 +44,10 @@ func (conn *TcpConnection) createErrorResponse( ) *http.Response { 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) +} diff --git a/internal/server/auth.go b/internal/server/auth.go index e93e642..957ca22 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -11,7 +11,7 @@ import ( type ShareAccess uint8 const ( - AccessDenied ShareAccess = iota + AccessIncorrectPassword ShareAccess = iota AccessReadOnly AccessReadWrite ) @@ -22,7 +22,7 @@ func checkSharePasswords(share *SharedLocation, req *http.Request, ) ShareAccess { reqPass := getBasicAuthPassword(req) canWrite := share.WritePassword != "" && - (reqPass != "" && passwordMatches(reqPass, share.WritePassword)) + reqPass != "" && passwordMatches(reqPass, share.WritePassword) if canWrite { return AccessReadWrite } @@ -31,7 +31,7 @@ func checkSharePasswords(share *SharedLocation, req *http.Request, if canRead { return AccessReadOnly } - return AccessDenied + return AccessIncorrectPassword } func getBasicAuthPassword(req *http.Request) string {