implemented responses for PUT and OPTIONS
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user