diff --git a/internal/http/Connection.go b/internal/http/Connection.go index bdbb9c4..77a2dda 100644 --- a/internal/http/Connection.go +++ b/internal/http/Connection.go @@ -2,9 +2,13 @@ package http import ( "bufio" + "errors" + "io" "net" + "os" + "serveg/internal/log" sh "serveg/internal/string_helper" - "strings" + "syscall" "time" ) @@ -13,39 +17,124 @@ const initialWriteBufCap = 512 type mapStrStr map[string]string type HeadersMap map[string]string +type ConnCallback func(req *Request) *Response type Connection struct { - socket *net.TCPConn - timeout time.Duration - r *bufio.Reader - wbuf sh.StringBuilder + Timeout time.Duration + + log *log.LoggerContext + callback ConnCallback + socket *net.TCPConn + r *bufio.Reader + wbuf sh.StringBuilder + + // allocated in Connection once // hidden to prevent using before ReadRequest/SendResponse - req Request // reset by ReadRequest - res Response // reset by CreateResponse and ReadRequest + _req Request // reset by ReadRequest + _res Response // reset by CreateResponse and ReadRequest } -func NewConnection(socket *net.TCPConn, timeout time.Duration) *Connection { +const defaultTimeout = 30 * time.Second + +func NewConnection( + socket *net.TCPConn, + logger *log.LoggerContext, + callback ConnCallback, +) *Connection { return &Connection{ - socket: socket, - timeout: timeout, - r: bufio.NewReaderSize(socket, maxHeaderBytes), - wbuf: sh.StringBuilder{Buf: make([]byte, 0, initialWriteBufCap)}, - req: Request{ + Timeout: defaultTimeout, + log: logger, + callback: callback, + socket: socket, + r: bufio.NewReaderSize(socket, maxHeaderBytes), + wbuf: sh.StringBuilder{Buf: make([]byte, 0, initialWriteBufCap)}, + _req: Request{ QueryParams: make(mapStrStr), Headers: make(HeadersMap), }, - res: Response{ + _res: Response{ Headers: make(HeadersMap), }, } } -func HeaderValueContains(sourceSplit []string, target string) bool { - for _, v := range sourceSplit { - v = strings.TrimSpace(v) - if strings.EqualFold(v, target) { - return true +func (conn *Connection) Start() { + defer func() { + if r := recover(); r != nil { + conn.log.Error("panic: ", r) + } + _ = conn.socket.Close() + conn.log.Info("connection closed") + }() + + for { + err := conn.socket.SetDeadline(time.Now().Add(conn.Timeout)) + if err != nil { + conn.log.Warn(err) + return + } + + var res *Response + var req *Request + req, err = conn.ReadRequest() + if err != nil { + // invalid request + if httpErr, ok := errors.AsType[*HttpError](err); ok { + conn.log.Warn(err) + res = conn.CreateErrorResponse(req, true, httpErr) + } else { + conn.logError(err) + conn.lingeringClose() + return + } + } else { + // valid request + conn.log.Info(req.Method, " ", req.Url, " ", req.Protocol) + conn.log.Trace("request headers: ", req.Headers) + res = conn.callback(req) + if req.HasUnreadBody() { + res = conn.CreateErrorResponse(req, true, ErrHasUnreadBody) + } + } + + conn.log.Info("sending response: ", res.Status, + " content-length: ", res.Headers["content-length"]) + conn.log.Trace("response headers: ", res.Headers) + err = conn.sendResponse(res) + if err != nil { + conn.logError(err) + return + } + + // it is set by CreateResponse + if res.Headers["connection"] == "close" { + conn.lingeringClose() + return } } - return false +} + +func (conn *Connection) logError(err error) { + if errors.Is(err, os.ErrDeadlineExceeded) { + conn.log.Info("time out") + } else if errors.Is(err, io.EOF) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ECONNRESET) { + + conn.log.Info("closed by client") + } else { + conn.log.Error(err) + } +} + +// lingeringClose: shut down our side, then drain the client's unread data, +// so close() doesn't send RST and destroy the response before it's read. +// Limits: 500 ms, 32 KB +func (conn *Connection) lingeringClose() { + conn.log.Info("lingering close") + if conn.socket.CloseWrite() != nil { + return + } + _ = conn.socket.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + _, _ = io.Copy(io.Discard, io.LimitReader(conn.socket, 32*1024)) } diff --git a/internal/http/Request.go b/internal/http/Request.go index 77523b3..891b46d 100644 --- a/internal/http/Request.go +++ b/internal/http/Request.go @@ -11,8 +11,9 @@ import ( type Request struct { needsReset bool - Method string - Protocol string + + Method string + Protocol string // url-encoded characters (%D0), query params (?v=123) UrlRaw string Url string @@ -24,6 +25,7 @@ type Request struct { WantsKeepAlive bool // value from header or 0 ContentLength int64 + // wrapper of conn.r bodyReader io.LimitedReader } @@ -49,17 +51,9 @@ func (req *Request) reset() { // Don't forget to read body with GetBodyReader() if HasUnreadBody() or ReadRequest will return error. // See [HttpError] func (conn *Connection) ReadRequest() (req *Request, err error) { - req = &conn.req - // TODO: make this check run automatically between ReadRequest and SendResponse. - // This can be done by moving reading loop from server/connection to http/Connection and handling requestr in callback (req) => res - if req.HasUnreadBody() { - err = ErrHasUnreadBody - return - } + req = &conn._req req.reset() req.needsReset = true - // TODO: remove after moving to callback loop - conn.res.reset() var readN int // METHOD /path HTTP/ver @@ -110,8 +104,7 @@ func (req *Request) HasUnreadBody() bool { return req.bodyReader.N > 0 } -func (conn *Connection) GetBodyReader(maxBodyLen int64) (io.Reader, error) { - req := &conn.req +func (req *Request) GetBodyReader(maxBodyLen int64) (io.Reader, error) { if req.ContentLength > maxBodyLen { return nil, NewHttpError(StatusRequestEntityTooLarge, fmt.Sprintf("content-length %d > maxBodyLen %d", diff --git a/internal/http/Response.go b/internal/http/Response.go index fb02b10..2b6e362 100644 --- a/internal/http/Response.go +++ b/internal/http/Response.go @@ -34,8 +34,6 @@ type Response struct { bodyReader io.Reader } -const TimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT" - func (res *Response) reset() { if !res.needsReset { return @@ -55,14 +53,13 @@ func (res *Response) reset() { res.bodyReader = nil } -func (conn *Connection) CreateResponse(status StatusCode, req *Request, wantsClose bool) *Response { - - res := &conn.res +func (conn *Connection) CreateResponse(status StatusCode, req *Request, wantsClose bool) (res *Response) { + res = &conn._res res.reset() res.needsReset = true res.Status = status - res.Headers["date"] = time.Now().UTC().Format(TimeFormat) + res.Headers["date"] = time.Now().UTC().Format(HeaderTimeFormat) // without it browser may remember response and not resend it res.Headers["cache-control"] = "no-store" @@ -82,7 +79,7 @@ func (conn *Connection) CreateResponse(status StatusCode, req *Request, wantsClo res.Headers["connection"] = "keep-alive" } - return res + return } func (res *Response) WithBodyBuf(body []byte, contentType string) *Response { @@ -125,7 +122,14 @@ func (res *Response) WithBodyReader(body io.Reader, return res } -func (conn *Connection) SendResponse(res *Response) (err error) { +func (conn *Connection) CreateErrorResponse( + req *Request, wantsClose bool, httpErr *HttpError, +) *Response { + return conn.CreateResponse(httpErr.Status, req, wantsClose). + WithBodyStr(httpErr.Error(), TextPlain) +} + +func (conn *Connection) sendResponse(res *Response) (err error) { if closer, ok := res.bodyReader.(io.Closer); ok { // body is owned by the response defer func() { @@ -166,7 +170,7 @@ func (conn *Connection) SendResponse(res *Response) (err error) { // WithBodyReader was called. remaining := res.bodySize for remaining > 0 { - err = conn.socket.SetWriteDeadline(time.Now().Add(conn.timeout)) + err = conn.socket.SetWriteDeadline(time.Now().Add(conn.Timeout)) if err != nil { return } @@ -190,10 +194,3 @@ func (conn *Connection) SendResponse(res *Response) (err error) { } return } - -func (conn *Connection) CreateErrorResponse( - req *Request, wantsClose bool, httpErr *HttpError, -) *Response { - return conn.CreateResponse(httpErr.Status, req, wantsClose). - WithBodyStr(httpErr.Error(), TextPlain) -} diff --git a/internal/http/errors.go b/internal/http/errors.go index c490d9c..2104294 100644 --- a/internal/http/errors.go +++ b/internal/http/errors.go @@ -25,7 +25,7 @@ func NewHttpError(code StatusCode, msg string) *HttpError { } var ErrHasUnreadBody = NewHttpError(StatusRequestEntityTooLarge, - "can't read new request, body of previous request wasn't read") + "server didn't fully read request body, connection corrupted") var ErrBodyHasBeenAlreadyRead = errors.New( "request body has already been read") diff --git a/internal/http/headers.go b/internal/http/headers.go index d792ac6..2242710 100644 --- a/internal/http/headers.go +++ b/internal/http/headers.go @@ -6,6 +6,19 @@ import ( "strings" ) +const HeaderTimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT" + +// search in splitted parts of header value +func HeaderValueContains(sourceSplit []string, target string) bool { + for _, v := range sourceSplit { + v = strings.TrimSpace(v) + if strings.EqualFold(v, target) { + return true + } + } + return false +} + // ContentDisposition builds a Content-Disposition header value (RFC 6266). // dispositionType is "inline" or "attachment". // filename is an ASCII fallback for old clients, diff --git a/internal/server/GET-HEAD.go b/internal/server/GET-HEAD.go new file mode 100644 index 0000000..f5497d3 --- /dev/null +++ b/internal/server/GET-HEAD.go @@ -0,0 +1,89 @@ +package server + +import ( + "fmt" + "os" + "path" + "serveg/internal/html" + "serveg/internal/http" + "time" +) + +func (conn *TcpConnection) serveFileOrDir(req *http.Request) *http.Response { + path, share, urlStatus := conn.srv.Conf.Share.MapUrl(req.Url) + // TODO implement authorization + _ = share + switch urlStatus { + case UrlFile: + return conn.serveFile(req, path) + case UrlDir: + return conn.serveDirListing(req, path) + case UrlNotFound: + return conn.createErrorResponse(req, false, http.StatusNotFound, path) + case UrlForbidden: + return conn.createErrorResponse(req, false, http.StatusForbidden, path) + case UrlIncorrect: + return conn.createErrorResponse(req, false, http.StatusBadRequest, + "invalid url '"+req.Url+"'") + default: + panic(fmt.Sprintf("unsupported UrlStatus: %d", int32(urlStatus))) + } +} + +func (conn *TcpConnection) serveDirListing(req *http.Request, dirPath string) *http.Response { + htmlBytes, err := html.GenDirListingHtml(&conn.log, dirPath, + req.Url, conn.srv.Conf.HtmlTitle) + if err != nil { + return conn.createErrorResponse(req, false, http.StatusInternalServerError, err.Error()) + } + + return conn.http.CreateResponse(http.StatusOK, req, false). + WithBodyBuf(htmlBytes, http.TextHtml) +} + +func (conn *TcpConnection) serveFile(req *http.Request, filePath string) *http.Response { + f, err := os.Open(filePath) + if err != nil { + return conn.createErrorResponse(req, false, http.StatusInternalServerError, err.Error()) + } + var fileOwnershipTransferred bool + defer func() { + if !fileOwnershipTransferred { + _ = f.Close() + } + }() + + finf, err := f.Stat() + if err != nil { + return conn.createErrorResponse(req, false, http.StatusInternalServerError, err.Error()) + } + 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 { + return conn.http.CreateResponse(http.StatusNotModified, req, false) + } + + res := conn.http.CreateResponse(http.StatusOK, req, false) + fileName := path.Base(filePath) + + if req.Method != "HEAD" { + // file will be closed by SendResponse() + fileOwnershipTransferred = true + res.WithBodyReader(f, size, http.GetMimeType(fileName)) + } + // 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["content-disposition"] = http.ContentDisposition("inline", fileName) + //TODO: accept "range" header and return StatusPartialContent + // res.Headers["accept-ranges"] = "bytes" + + return res +} diff --git a/internal/server/PUT-OPTIONS.go b/internal/server/PUT-OPTIONS.go new file mode 100644 index 0000000..f82be39 --- /dev/null +++ b/internal/server/PUT-OPTIONS.go @@ -0,0 +1,12 @@ +package server + +import "serveg/internal/http" + +// 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") +} diff --git a/internal/server/Server.go b/internal/server/Server.go index e613ff1..55a309a 100644 --- a/internal/server/Server.go +++ b/internal/server/Server.go @@ -5,25 +5,19 @@ import ( "net" "serveg/internal/log" "sync" - "time" ) type Server struct { - Conf *Config - logger log.LoggerContext - // used in sockets to sed read/write deadline - timeout time.Duration + Conf *Config + logger log.LoggerContext listener net.Listener wg sync.WaitGroup } -const defaultTimeout = 30 * time.Second - func NewServer(conf *Config) *Server { return &Server{ - Conf: conf, - logger: log.LoggerContext{Name: "server"}, - timeout: defaultTimeout, + Conf: conf, + logger: log.LoggerContext{Name: "server"}, } } @@ -48,8 +42,8 @@ func (srv *Server) serve() { srv.logger.Error(err) continue } - conn := newConnection(srv, i, accepted.(*net.TCPConn)) - go conn.handle() + conn := NewTcpConnection(srv, accepted.(*net.TCPConn), i) + go conn.Start() } } diff --git a/internal/server/TcpConnection.go b/internal/server/TcpConnection.go new file mode 100644 index 0000000..81367f0 --- /dev/null +++ b/internal/server/TcpConnection.go @@ -0,0 +1,48 @@ +package server + +import ( + "net" + "serveg/internal/http" + "serveg/internal/log" + "strconv" +) + +type TcpConnection struct { + srv *Server + socket *net.TCPConn + log log.LoggerContext + http *http.Connection +} + +func NewTcpConnection(srv *Server, socket *net.TCPConn, n int64) *TcpConnection { + conn := &TcpConnection{ + srv: srv, + socket: socket, + log: log.LoggerContext{Name: "conn-" + strconv.FormatInt(n, 10)}, + } + conn.http = http.NewConnection(socket, &conn.log, conn.handleHttpRequest) + return conn +} + +func (conn *TcpConnection) Start() { + conn.http.Start() +} + +func (conn *TcpConnection) handleHttpRequest(req *http.Request) (res *http.Response) { + switch req.Method { + case "GET", "HEAD": + res = conn.serveFileOrDir(req) + case "PUT", "OPTIONS": + res = conn.handleUpload(req) + default: + res = conn.createErrorResponse(req, false, http.StatusMethodNotAllowed, req.Method) + res.Headers["allow"] = "GET, HEAD, PUT, OPTIONS" + } + return +} + +func (conn *TcpConnection) createErrorResponse( + req *http.Request, wantsClose bool, status http.StatusCode, msg string, +) *http.Response { + return conn.http.CreateErrorResponse(req, wantsClose, http.NewHttpError(status, msg)) +} diff --git a/internal/server/connection.go b/internal/server/connection.go deleted file mode 100644 index 8ec69d4..0000000 --- a/internal/server/connection.go +++ /dev/null @@ -1,212 +0,0 @@ -package server - -import ( - "errors" - "fmt" - "io" - "net" - "os" - "path" - "serveg/internal/html" - "serveg/internal/http" - "serveg/internal/log" - "strconv" - "syscall" - "time" -) - -type connection struct { - srv *Server - n int64 - socket *net.TCPConn - log log.LoggerContext - http *http.Connection - req *http.Request -} - -func newConnection(srv *Server, n int64, socket *net.TCPConn) *connection { - return &connection{ - srv: srv, - n: n, - socket: socket, - log: log.LoggerContext{Name: "conn-" + strconv.FormatInt(n, 10)}, - http: http.NewConnection(socket, srv.timeout), - } -} -func (conn *connection) handle() { - defer func() { - if r := recover(); r != nil { - conn.log.Error("panic: ", r) - } - _ = conn.socket.Close() - conn.log.Info("connection closed") - }() - - for { - err := conn.socket.SetDeadline(time.Now().Add(conn.srv.timeout)) - if err != nil { - conn.log.Warn(err) - return - } - - var res *http.Response - conn.req, err = conn.http.ReadRequest() - if err != nil { - // invalid request - if httpErr, ok := errors.AsType[*http.HttpError](err); ok { - conn.log.Warn(err) - res = conn.http.CreateErrorResponse(conn.req, true, httpErr) - } else { - conn.logError(err) - conn.lingeringClose() - return - } - } else { - // valid request - conn.log.Info(conn.req.Method, " ", conn.req.Url, " ", conn.req.Protocol) - conn.log.Trace("request headers: ", conn.req.Headers) - switch conn.req.Method { - case "GET", "HEAD": - res = conn.serveFileOrDir() - case "PUT", "OPTIONS": - res = conn.handleUpload() - default: - res = conn.createErrorResponse(false, http.StatusMethodNotAllowed, conn.req.Method) - } - } - - conn.log.Info("sending response: ", res.Status, - " content-length: ", res.Headers["content-length"]) - conn.log.Trace("response headers: ", res.Headers) - err = conn.http.SendResponse(res) - if err != nil { - conn.logError(err) - return - } - - // it is set by CreateResponse - if res.Headers["connection"] == "close" { - conn.lingeringClose() - return - } - } -} - -func (conn *connection) logError(err error) { - if errors.Is(err, os.ErrDeadlineExceeded) { - conn.log.Info("time out") - } else if errors.Is(err, io.EOF) || - errors.Is(err, syscall.EPIPE) || - errors.Is(err, syscall.ECONNRESET) { - - conn.log.Info("closed by client") - } else { - conn.log.Error(err) - } -} - -// lingeringClose: shut down our side, then drain the client's unread data, -// so close() doesn't send RST and destroy the response before it's read. -// Limits: 500 ms, 32 KB -func (conn *connection) lingeringClose() { - conn.log.Info("lingering close") - if conn.socket.CloseWrite() != nil { - return - } - _ = conn.socket.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - _, _ = io.Copy(io.Discard, io.LimitReader(conn.socket, 32*1024)) -} - -func (conn *connection) createErrorResponse( - wantsClose bool, status http.StatusCode, msg string, -) *http.Response { - return conn.http.CreateErrorResponse(conn.req, wantsClose, http.NewHttpError(status, msg)) -} - -func (conn *connection) serveFileOrDir() *http.Response { - path, share, urlStatus := conn.srv.Conf.Share.MapUrl(conn.req.Url) - // TODO implement authorization - _ = share - switch urlStatus { - case UrlFile: - return conn.serveFile(path) - case UrlDir: - return conn.serveDirListing(path) - case UrlNotFound: - return conn.createErrorResponse(false, http.StatusNotFound, path) - case UrlForbidden: - return conn.createErrorResponse(false, http.StatusForbidden, path) - case UrlIncorrect: - return conn.createErrorResponse(false, http.StatusBadRequest, - "invalid url '"+conn.req.Url+"'") - default: - panic(fmt.Sprintf("unsupported UrlStatus: %d", int32(urlStatus))) - } -} - -func (conn *connection) serveDirListing(dirPath string) *http.Response { - htmlBytes, err := html.GenDirListingHtml(&conn.log, dirPath, - conn.req.Url, conn.srv.Conf.HtmlTitle) - if err != nil { - return conn.createErrorResponse(false, http.StatusInternalServerError, err.Error()) - } - - return conn.http.CreateResponse(http.StatusOK, conn.req, false). - WithBodyBuf(htmlBytes, http.TextHtml) -} - -func (conn *connection) serveFile(filePath string) *http.Response { - f, err := os.Open(filePath) - if err != nil { - return conn.createErrorResponse(false, http.StatusInternalServerError, err.Error()) - } - var fileOwnershipTransferred bool - defer func() { - if !fileOwnershipTransferred { - _ = f.Close() - } - }() - - finf, err := f.Stat() - if err != nil { - return conn.createErrorResponse(false, http.StatusInternalServerError, err.Error()) - } - 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.TimeFormat, conn.req.Headers["if-modified-since"]) - notModified := errTime == nil && !mtime.After(ifModifiedSince) - if notModified { - return conn.http.CreateResponse(http.StatusNotModified, conn.req, false) - } - - res := conn.http.CreateResponse(http.StatusOK, conn.req, false) - fileName := path.Base(filePath) - - if conn.req.Method != "HEAD" { - // file will be closed by SendResponse() - fileOwnershipTransferred = true - res.WithBodyReader(f, size, http.GetMimeType(fileName)) - } - // 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.TimeFormat) - res.Headers["content-disposition"] = http.ContentDisposition("inline", fileName) - //TODO: accept "range" header and return StatusPartialContent - // res.Headers["accept-ranges"] = "bytes" - - return res -} - -// TODO: implement request body and then file uploading -func (conn *connection) handleUpload() *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") -}