moved Read/Send code to http.Connection

This commit is contained in:
Altair-sh
2026-11-04 03:32:36 +01:00
parent 59f7d873be
commit 0ecbb629ea
10 changed files with 297 additions and 274 deletions
+109 -20
View File
@@ -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))
}
+6 -13
View File
@@ -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",
+13 -16
View File
@@ -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)
}
+1 -1
View File
@@ -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")
+13
View File
@@ -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,
+89
View File
@@ -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
}
+12
View File
@@ -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")
}
+6 -12
View File
@@ -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()
}
}
+48
View File
@@ -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))
}
-212
View File
@@ -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")
}