implemented http server
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"net"
|
||||
sh "serveg/internal/string_helper"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxHeaderBytes = 16 * 1024
|
||||
@@ -14,16 +15,18 @@ type mapStrStr map[string]string
|
||||
type HeadersMap map[string]string
|
||||
|
||||
type Connection struct {
|
||||
socket *net.TCPConn
|
||||
r *bufio.Reader
|
||||
wbuf sh.StringBuilder
|
||||
socket *net.TCPConn
|
||||
timeout time.Duration
|
||||
r *bufio.Reader
|
||||
wbuf sh.StringBuilder
|
||||
}
|
||||
|
||||
func NewConnection(socket *net.TCPConn) *Connection {
|
||||
func NewConnection(socket *net.TCPConn, timeout time.Duration) *Connection {
|
||||
return &Connection{
|
||||
socket: socket,
|
||||
r: bufio.NewReaderSize(socket, maxHeaderBytes),
|
||||
wbuf: sh.StringBuilder{Buf: make([]byte, 0, initialWriteBufCap)},
|
||||
socket: socket,
|
||||
timeout: timeout,
|
||||
r: bufio.NewReaderSize(socket, maxHeaderBytes),
|
||||
wbuf: sh.StringBuilder{Buf: make([]byte, 0, initialWriteBufCap)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ type Request struct {
|
||||
Method string
|
||||
Protocol string
|
||||
// url-encoded characters (%D0), query params (?v=123)
|
||||
PathRaw string
|
||||
Path string
|
||||
UrlRaw string
|
||||
Url string
|
||||
QueryParams mapStrStr
|
||||
// keys are in lower case
|
||||
// duplicate keys are joined with ", "
|
||||
@@ -20,6 +20,9 @@ type Request struct {
|
||||
WantsKeepAlive bool
|
||||
}
|
||||
|
||||
// TODO: add request body support
|
||||
// TODO: check contentLength <= MaxUpload
|
||||
// TODO: use io.LimitReader(conn.r, contentLength)
|
||||
// Read request (body not supported)
|
||||
// Returns valid request pointer. If err != nil, req is partially parsed.
|
||||
// It doesn't enforce "Host" header because i don't need it in this project.
|
||||
@@ -107,7 +110,7 @@ func (conn *Connection) readFirstLine(req *Request, readN *int) error {
|
||||
}
|
||||
req.Method = parts[0]
|
||||
// url-encoded characters (%D0), query params (?v=123)
|
||||
req.PathRaw = parts[1]
|
||||
req.UrlRaw = parts[1]
|
||||
req.Protocol = parts[2]
|
||||
|
||||
if req.Protocol != "HTTP/1.0" && req.Protocol != "HTTP/1.1" {
|
||||
@@ -115,13 +118,13 @@ func (conn *Connection) readFirstLine(req *Request, readN *int) error {
|
||||
"request protocol not supported: "+req.Protocol)
|
||||
}
|
||||
|
||||
u, err := url.ParseRequestURI(req.PathRaw)
|
||||
u, err := url.ParseRequestURI(req.UrlRaw)
|
||||
if err != nil {
|
||||
return NewHttpError(StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
req.Path = sh.ResolveSafePath(u.Path)
|
||||
if req.Path == "" {
|
||||
req.Url = sh.ResolveSafePath(u.Path)
|
||||
if req.Url == "" {
|
||||
return NewHttpError(StatusForbidden,
|
||||
"unsafe request path '"+u.Path+"'")
|
||||
}
|
||||
|
||||
+75
-47
@@ -2,6 +2,7 @@ package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
sh "serveg/internal/string_helper"
|
||||
@@ -14,19 +15,18 @@ type responseBodyType uint8
|
||||
|
||||
const (
|
||||
responseBodyEmpty = responseBodyType(iota)
|
||||
responseBodySmall
|
||||
responseBodyBig
|
||||
responseBodyBuf
|
||||
responseBodyReader
|
||||
)
|
||||
|
||||
const TextPlain = "text/plain; charset=utf-8"
|
||||
|
||||
type Response struct {
|
||||
Status StatusCode
|
||||
Headers HeadersMap // keys are in lower case
|
||||
omitBody bool // don't add body and content-* headers for HEAD request
|
||||
bodyType responseBodyType
|
||||
bodySmall []byte // small body is sent in one write() call with headers
|
||||
bodyBig io.WriterTo // big body, sent as multiple write() calls to socket
|
||||
Status StatusCode
|
||||
Headers HeadersMap // keys are in lower case
|
||||
omitBody bool // don't add body and content-* headers for HEAD request
|
||||
bodySize int64
|
||||
bodyType responseBodyType
|
||||
bodyBuf []byte // small body is sent in one write() call with headers
|
||||
bodyReader io.Reader // big body, sent as multiple write() calls to socket
|
||||
}
|
||||
|
||||
const TimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
|
||||
@@ -46,7 +46,7 @@ func (conn *Connection) CreateResponse(
|
||||
status >= 200 &&
|
||||
status != StatusNoContent &&
|
||||
status != StatusNotModified {
|
||||
// content-length 0 is mandatory for most responses
|
||||
// content-length is mandatory for most responses
|
||||
res.Headers["content-length"] = "0"
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func (conn *Connection) CreateResponse(
|
||||
return res
|
||||
}
|
||||
|
||||
func (res *Response) AttachBodySmall(body []byte, contentType string) {
|
||||
func (res *Response) AttachBodyBuf(body []byte, contentType string) {
|
||||
if res.omitBody {
|
||||
return
|
||||
}
|
||||
@@ -67,18 +67,21 @@ func (res *Response) AttachBodySmall(body []byte, contentType string) {
|
||||
if res.bodyType != responseBodyEmpty {
|
||||
panic("response already has body")
|
||||
}
|
||||
res.bodyType = responseBodySmall
|
||||
res.bodySmall = body
|
||||
lenStr := strconv.FormatUint(uint64(len(body)), 10)
|
||||
res.bodyType = responseBodyBuf
|
||||
res.bodyBuf = body
|
||||
size := int64(len(body))
|
||||
res.bodySize = size
|
||||
lenStr := strconv.FormatUint(uint64(size), 10)
|
||||
res.Headers["content-length"] = lenStr
|
||||
res.Headers["content-type"] = contentType
|
||||
}
|
||||
func (res *Response) AttachBodySmallStr(body string, contentType string) {
|
||||
res.AttachBodySmall(sh.UnsafeGetStrBytes(body), contentType)
|
||||
func (res *Response) AttachBodyStr(body string, contentType string) {
|
||||
res.AttachBodyBuf(sh.UnsafeGetStrBytes(body), contentType)
|
||||
}
|
||||
|
||||
func (res *Response) AttachBodyBig(body io.WriterTo,
|
||||
size uint64, contentType string) {
|
||||
// body will be closed by SendResponse
|
||||
func (res *Response) AttachBodyReader(body io.Reader,
|
||||
size int64, contentType string) {
|
||||
|
||||
if res.omitBody {
|
||||
return
|
||||
@@ -86,23 +89,31 @@ func (res *Response) AttachBodyBig(body io.WriterTo,
|
||||
if res.bodyType != responseBodyEmpty {
|
||||
panic("response already has body")
|
||||
}
|
||||
res.bodyType = responseBodyBig
|
||||
res.bodyBig = body
|
||||
lenStr := strconv.FormatUint(size, 10)
|
||||
res.bodyType = responseBodyReader
|
||||
res.bodyReader = body
|
||||
res.bodySize = size
|
||||
lenStr := strconv.FormatUint(uint64(size), 10)
|
||||
res.Headers["content-length"] = lenStr
|
||||
res.Headers["content-type"] = contentType
|
||||
}
|
||||
|
||||
var ErrInvalidHeader = errors.New("http: header key or value contains CR or LF")
|
||||
|
||||
func (conn *Connection) SendResponse(res *Response) error {
|
||||
func (conn *Connection) SendResponse(res *Response) (err error) {
|
||||
if res.bodyType == responseBodyReader {
|
||||
if closer, ok := res.bodyReader.(io.Closer); ok {
|
||||
// body is owned by the response
|
||||
defer func() {
|
||||
_ = closer.Close()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
sb := &conn.wbuf
|
||||
sb.Reset()
|
||||
|
||||
// first line
|
||||
// space before \r\n is required by standard
|
||||
sb.AddS("HTTP/1.1 ").AddU(uint64(res.Status)).AddS(" \r\n")
|
||||
|
||||
// headers
|
||||
for k, v := range res.Headers {
|
||||
// prevent response splitting / header injection
|
||||
@@ -116,35 +127,52 @@ func (conn *Connection) SendResponse(res *Response) error {
|
||||
|
||||
switch res.bodyType {
|
||||
case responseBodyEmpty:
|
||||
// net.Conn.Write follows the io.Writer contract: it returns a non-nil
|
||||
// error whenever n < len(p), so no loop is needed.
|
||||
if _, err := conn.socket.Write(conn.wbuf.Buf); err != nil {
|
||||
return err
|
||||
}
|
||||
case responseBodySmall:
|
||||
_, err = conn.socket.Write(conn.wbuf.Buf)
|
||||
case responseBodyBuf:
|
||||
// write headers and body in one socket.Write() call
|
||||
bbb := net.Buffers{sb.Buf, res.bodySmall}
|
||||
if _, err := bbb.WriteTo(conn.socket); err != nil {
|
||||
return err
|
||||
bufs := net.Buffers{sb.Buf, res.bodyBuf}
|
||||
_, err = bufs.WriteTo(conn.socket)
|
||||
case responseBodyReader:
|
||||
// write headers
|
||||
if _, err = conn.socket.Write(conn.wbuf.Buf); err != nil {
|
||||
return
|
||||
}
|
||||
case responseBodyBig:
|
||||
// write headers, then write big body
|
||||
if _, err := conn.socket.Write(conn.wbuf.Buf); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := res.bodyBig.WriteTo(conn.socket); err != nil {
|
||||
return err
|
||||
// Write exactly bodySize bytes from body.
|
||||
// It prevents session corruption if body size changed since
|
||||
// AttachBodyReader was called.
|
||||
remaining := res.bodySize
|
||||
for remaining > 0 {
|
||||
err = conn.socket.SetWriteDeadline(time.Now().Add(conn.timeout))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// don't increase or slow connections will trigger socket deadline
|
||||
// 256 KB / 30 s ≈ 8.7 KB/s
|
||||
const fileReadChunkSize = 256 * 1024
|
||||
n := min(remaining, fileReadChunkSize)
|
||||
_, err = io.CopyN(conn.socket, res.bodyReader, n)
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
// Body ended before bodySize bytes.
|
||||
// Returning just io.EOF would look like a client discnnect
|
||||
err = fmt.Errorf("response body is %d bytes shorter"+
|
||||
" than content-length: %w",
|
||||
remaining, io.ErrUnexpectedEOF)
|
||||
}
|
||||
return
|
||||
}
|
||||
remaining -= n
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
func (conn *Connection) SendErrorResponse(
|
||||
httpErr *HttpError, req *Request, wantsClose bool,
|
||||
) error {
|
||||
func (conn *Connection) CreateErrorResponse(
|
||||
req *Request, wantsClose bool, httpErr *HttpError,
|
||||
) *Response {
|
||||
|
||||
res := conn.CreateResponse(httpErr.Status, req, wantsClose)
|
||||
// attach error message if method allows
|
||||
res.AttachBodySmallStr(httpErr.Error(), TextPlain)
|
||||
return conn.SendResponse(res)
|
||||
res.AttachBodyStr(httpErr.Error(), TextPlain)
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
sh "serveg/internal/string_helper"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ContentDisposition builds a Content-Disposition header value (RFC 6266).
|
||||
// dispositionType is "inline" or "attachment".
|
||||
// filename is an ASCII fallback for old clients,
|
||||
// filename* is the exact UTF-8 name.
|
||||
//
|
||||
// ContentDisposition("inline", "отчёт 1.txt") ->
|
||||
// inline; filename="_____ 1.txt"; filename*=UTF-8''%D0%BE%D1%82%D1%87%D1%91%D1%82%201.txt
|
||||
func ContentDisposition(dispositionType, fileName string) string {
|
||||
var sb sh.StringBuilder
|
||||
sb.Grow(len(dispositionType) + len(fileName)*4 + 32)
|
||||
sb.AddS(dispositionType)
|
||||
sb.AddS(`; filename="`).AddS(AsciiFallbackName(fileName)).AddB('"')
|
||||
sb.AddS(`; filename*=UTF-8''`).AddS(PercentEncodeHeaderValue(fileName))
|
||||
return sb.StringUnsafe()
|
||||
}
|
||||
|
||||
// percent-encodes characters that are not allowed in urls
|
||||
func PercentEcodeUrl(p string) string {
|
||||
u := url.URL{Path: p}
|
||||
return u.EscapedPath()
|
||||
}
|
||||
|
||||
// PercentEncodeHeaderValue percent-encodes characters that are not allowed in header values
|
||||
func PercentEncodeHeaderValue(s string) string {
|
||||
const hexDigits = "0123456789ABCDEF"
|
||||
var sb sh.StringBuilder
|
||||
sb.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if isAttrChar(c) {
|
||||
sb.AddB(c)
|
||||
} else {
|
||||
sb.AddB('%').AddB(hexDigits[c>>4]).AddB(hexDigits[c&0x0F])
|
||||
}
|
||||
}
|
||||
return sb.StringUnsafe()
|
||||
}
|
||||
|
||||
// attr-char = ALPHA / DIGIT / "!" / "#" / "$" / "&" / "+" / "-" / "."
|
||||
// / "^" / "_" / "`" / "|" / "~"
|
||||
func isAttrChar(c byte) bool {
|
||||
switch {
|
||||
case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9':
|
||||
return true
|
||||
}
|
||||
return strings.IndexByte("!#$&+-.^_`|~", c) >= 0
|
||||
}
|
||||
|
||||
// AsciiFallbackName replaces each character that can't be safely put
|
||||
// in a quoted-string with '_'. One '_' per character, not per byte.
|
||||
func AsciiFallbackName(s string) string {
|
||||
var sb sh.StringBuilder
|
||||
sb.Grow(len(s))
|
||||
// range over string iterates runes, invalid UTF-8 gives utf8.RuneError
|
||||
for _, r := range s {
|
||||
if r < 0x20 || r >= 0x7F || r == '"' || r == '\\' || r == '%' {
|
||||
r = '_'
|
||||
}
|
||||
sb.AddB(byte(r))
|
||||
}
|
||||
return sb.StringUnsafe()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"path"
|
||||
)
|
||||
|
||||
const MimeDefault = "application/octet-stream"
|
||||
const TextPlain = "text/plain; charset=utf-8"
|
||||
const TextHtml = "text/html; charset=utf-8"
|
||||
|
||||
func GetMimeType(filePath string) string {
|
||||
t := mime.TypeByExtension(path.Ext(filePath))
|
||||
if t == "" {
|
||||
t = MimeDefault
|
||||
}
|
||||
return t
|
||||
}
|
||||
Reference in New Issue
Block a user