diff --git a/cmd/serveg/main.go b/cmd/serveg/main.go index 84728e6..9bc79ef 100644 --- a/cmd/serveg/main.go +++ b/cmd/serveg/main.go @@ -31,7 +31,7 @@ func run() int { return 1 } - srv := server.Server{Conf: conf} + srv := server.NewServer(conf) if err := srv.StartGoroutine(); err != nil { logger.Error(err) return 1 diff --git a/internal/http/Connection.go b/internal/http/Connection.go index c19c0b1..fa34a31 100644 --- a/internal/http/Connection.go +++ b/internal/http/Connection.go @@ -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)}, } } diff --git a/internal/http/Request.go b/internal/http/Request.go index c3184cd..03e7379 100644 --- a/internal/http/Request.go +++ b/internal/http/Request.go @@ -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+"'") } diff --git a/internal/http/Response.go b/internal/http/Response.go index e559499..c3f8551 100644 --- a/internal/http/Response.go +++ b/internal/http/Response.go @@ -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 } diff --git a/internal/http/headers.go b/internal/http/headers.go new file mode 100644 index 0000000..d792ac6 --- /dev/null +++ b/internal/http/headers.go @@ -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() +} diff --git a/internal/http/mime.go b/internal/http/mime.go new file mode 100644 index 0000000..5b2defb --- /dev/null +++ b/internal/http/mime.go @@ -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 +} diff --git a/internal/server/Config.go b/internal/server/Config.go index f536227..665e784 100644 --- a/internal/server/Config.go +++ b/internal/server/Config.go @@ -5,17 +5,76 @@ import ( "fmt" "io/fs" "os" + "path/filepath" + "serveg/internal/log" + "slices" + "strings" "github.com/BurntSushi/toml" ) -// TODO: add example config file +type Shares []*SharedLocation + type Config struct { - Addr string `toml:"address"` + Addr string `toml:"address"` + MaxUpload int64 `toml:"max_upload"` + // sorted by Url in reverse to allow this pattern: + // shared/private + // shared/ + Share Shares `toml:"share"` } -func ReadConfig(path string) (*Config, error) { - var config = DefaultConfig() +var confLogger = log.LoggerContext{Name: "config"} + +func defaultConfig() *Config { + return &Config{ + Addr: "127.0.0.1:8080", + MaxUpload: 4 * 1024 * 1024 * 1024, // 4 GB + } +} + +func ParseOrCreateConfig(confPath string) (*Config, error) { + confLogger.Info("reading config file ", confPath) + conf, err := readConfig(confPath) + if errors.Is(err, fs.ErrNotExist) { + confLogger.Warn("config doesn't exist, creating default") + conf = defaultConfig() + err = writeConfig(conf, confPath) + } + if err != nil { + return nil, err + } + + confLogger.Info("address: ", conf.Addr) + confLogger.Info("max_upload: ", conf.MaxUpload) + confDir := filepath.Dir(confPath) + sharesByUrl := make(map[string]*SharedLocation, len(conf.Share)) + for _, sh := range conf.Share { + if err = sh.normalize(confDir); err != nil { + return nil, err + } + + confLogger.Info(sh) + + if other, exists := sharesByUrl[sh.Url]; exists { + return nil, fmt.Errorf("duplicate share url: %v and %v", other, sh) + } + sharesByUrl[sh.Url] = sh + + if err = sh.mkdir(); err != nil { + return nil, err + } + } + // reverse sort by Url + slices.SortFunc(conf.Share, func(a, b *SharedLocation) int { + return strings.Compare(b.Url, a.Url) // reverse + }) + + return conf, nil +} + +func readConfig(path string) (*Config, error) { + var config = defaultConfig() tomlMeta, err := toml.DecodeFile(path, config) if err != nil { return nil, err @@ -25,26 +84,10 @@ func ReadConfig(path string) (*Config, error) { if undecodedKeys := tomlMeta.Undecoded(); len(undecodedKeys) > 0 { return nil, fmt.Errorf("%s: unknown keys: %v", path, undecodedKeys) } - return config, nil } -func DefaultConfig() *Config { - return &Config{ - Addr: "127.0.0.1:8080", - } -} - -func ParseOrCreateConfig(path string) (*Config, error) { - config, err := ReadConfig(path) - if !errors.Is(err, fs.ErrNotExist) { - return config, err // either a parsed config, or a real error - } - config = DefaultConfig() - return config, WriteConfig(config, path) -} - -func WriteConfig(config *Config, path string) (err error) { +func writeConfig(config *Config, path string) (err error) { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return err diff --git a/internal/server/Server.go b/internal/server/Server.go index 6c7d552..bc1c88f 100644 --- a/internal/server/Server.go +++ b/internal/server/Server.go @@ -2,26 +2,32 @@ package server import ( "errors" - "io" "net" - "os" - "serveg/internal/http" "serveg/internal/log" - "strconv" "sync" - "syscall" "time" ) type Server struct { - Conf *Config + Conf *Config + logger log.LoggerContext + // used in sockets to sed read/write deadline + timeout time.Duration listener net.Listener - logger log.LoggerContext wg sync.WaitGroup } +const defaultTimeout = 30 * time.Second + +func NewServer(conf *Config) *Server { + return &Server{ + Conf: conf, + logger: log.LoggerContext{Name: "server"}, + timeout: defaultTimeout, + } +} + func (srv *Server) StartGoroutine() (err error) { - srv.logger = log.LoggerContext{Name: "server"} srv.logger.Info("listening at ", srv.Conf.Addr) srv.listener, err = net.Listen("tcp", srv.Conf.Addr) if err == nil { @@ -33,7 +39,7 @@ func (srv *Server) StartGoroutine() (err error) { func (srv *Server) serve() { srv.logger.Info("server started") for i := int64(0); ; i++ { - conn, err := srv.listener.Accept() + accepted, err := srv.listener.Accept() if err != nil { if errors.Is(err, net.ErrClosed) { srv.logger.Info("listener socket closed") @@ -42,9 +48,8 @@ func (srv *Server) serve() { srv.logger.Warn(err) continue } - // TODO: add connection to map. SetDeadline(Now()) in Stop(). - // srv.wg.Go(func() { handleConnection(conn.(*net.TCPConn), i) }) - go handleConnection(conn.(*net.TCPConn), i) + conn := newConnection(srv, i, accepted.(*net.TCPConn)) + go conn.handle() } } @@ -53,87 +58,3 @@ func (srv *Server) Stop() { _ = srv.listener.Close() srv.wg.Wait() } - -func handleConnection(socket *net.TCPConn, n int64) { - logger := log.LoggerContext{Name: "conn-" + strconv.FormatInt(n, 10)} - defer func() { - if r := recover(); r != nil { - logger.Error("panic: ", r) - } - _ = socket.Close() - logger.Info("connection closed") - }() - - conn := http.NewConnection(socket) - for { - err := socket.SetDeadline(time.Now().Add(30 * time.Second)) - if err != nil { - logger.Warn(err) - return - } - - req, err := conn.ReadRequest() - if err != nil { - logConnError(logger, err) - - if httpErr, ok := errors.AsType[*http.HttpError](err); ok { - // return error code to client - logger.Info("sending error response") - // req is partially parsed, may use fields - if err = conn.SendErrorResponse(httpErr, req, true); err != nil { - logger.Warn("can't send error response") - return - } - } - // close the connection - logger.Info("lingering close") - lingeringClose(socket) - return - } - logger.Info(req.Method, " ", req.Path, " ", req.Protocol, - "\nHeaders: ", req.Headers) - - resBody := "Hi\n" + time.Now().Format(time.DateTime) - - res := conn.CreateResponse(http.StatusOK, req, false) - res.AttachBodySmallStr(resBody, http.TextPlain) - logger.Info("sending response: ", res.Status, - "\nHeaders: ", res.Headers) - err = conn.SendResponse(res) - if err != nil { - logConnError(logger, err) - return - } - - // it is set by CreateResponse - if res.Headers["connection"] == "close" { - logger.Info("lingering close") - lingeringClose(socket) - return - } - } -} - -func logConnError(logger log.LoggerContext, err error) { - if errors.Is(err, os.ErrDeadlineExceeded) { - logger.Info("time out") - } else if errors.Is(err, io.EOF) || - errors.Is(err, syscall.EPIPE) || - errors.Is(err, syscall.ECONNRESET) { - - logger.Info("closed by client") - } else { - logger.Warn(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 lingeringClose(socket *net.TCPConn) { - if socket.CloseWrite() != nil { - return - } - _ = socket.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - _, _ = io.Copy(io.Discard, io.LimitReader(socket, 32*1024)) -} diff --git a/internal/server/SharedLocation.go b/internal/server/SharedLocation.go new file mode 100644 index 0000000..23e49b4 --- /dev/null +++ b/internal/server/SharedLocation.go @@ -0,0 +1,120 @@ +package server + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" +) + +type SharedLocation struct { + // relative to config path at first, + // then [normalize] makes it absolute + Path string `toml:"path"` + // [normalize] removes trailing '/' + // so "/" becomes "" + Url string `toml:"url"` + ReadPassword string `toml:"read_password"` + WritePassword string `toml:"write_password"` +} + +func (sh *SharedLocation) normalize(configDir string) error { + if sh.Path == "" { + return errors.New("share.path is empty") + } + // get absolute path for configDir + configDirAbs, err := filepath.Abs(configDir) + if err != nil { + return err + } + // resolve symlinks + configDirAbs, err = filepath.EvalSymlinks(configDirAbs) + if err != nil { + return err + } + // fix separators + shPathOsSep, err := filepath.Localize(sh.Path) + if err != nil { + return err + } + // join configDir with sh.Path + sh.Path = filepath.Join(configDirAbs, shPathOsSep) + + if sh.Url == "" { + return errors.New("share.url is empty") + } + if sh.Url[0] != '/' { + return errors.New("share.url doesn't start with slash: '" + sh.Url + "'") + } + // remove trailing slash + sh.Url = strings.TrimRight(sh.Url, "/") + + return nil +} + +func (sh *SharedLocation) String() string { + var r, w string + if sh.ReadPassword == "" { + r = "public" + } else { + r = "password" + } + if sh.WritePassword == "" { + w = "no" + } else { + w = "password" + } + url := sh.Url + if url == "" { + url = "/" + } + return "share '" + url + "' r:" + r + " w:" + w + + " -> '" + sh.Path + "'" +} + +func (sh *SharedLocation) mkdir() error { + return os.MkdirAll(sh.Path, 0o755) +} + +type UrlStatus uint32 + +const ( + UrlFile = UrlStatus(iota) + UrlDir + UrlNotFound + UrlForbidden + UrlIncorrect +) + +// returns calculated path, or input url if it doesn't belong to any share +func (shares Shares) MapUrl(url string) (string, *SharedLocation, UrlStatus) { + if url == "" || url[0] != '/' { + return "", nil, UrlIncorrect + } + for _, sh := range shares { + pathInShare, ok := strings.CutPrefix(url, sh.Url) + if !ok { + continue + } + + if pathInShare != "" && pathInShare[0] != '/' { + // url is not sh.Url or child of it + continue + } + pathAbs := filepath.Join(sh.Path, filepath.FromSlash(pathInShare)) + + fi, err := os.Stat(pathAbs) + switch { + case errors.Is(err, fs.ErrNotExist): + return pathAbs, sh, UrlNotFound + case err != nil: + return pathAbs, sh, UrlForbidden + case fi.IsDir(): + return pathAbs, sh, UrlDir + default: + return pathAbs, sh, UrlFile + } + } + return url, nil, UrlNotFound +} diff --git a/internal/server/connection.go b/internal/server/connection.go new file mode 100644 index 0000000..c86098a --- /dev/null +++ b/internal/server/connection.go @@ -0,0 +1,196 @@ +package server + +import ( + "errors" + "fmt" + "io" + "net" + "os" + "path" + "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 + conn.logConnError(err) + if httpErr, ok := errors.AsType[*http.HttpError](err); ok { + conn.log.Info("sending error response") + res = conn.http.CreateErrorResponse(conn.req, true, httpErr) + } else { + conn.log.Info("lingering close") + 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": + 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.logConnError(err) + return + } + + // it is set by CreateResponse + if res.Headers["connection"] == "close" { + conn.log.Info("lingering close") + conn.lingeringClose() + return + } + } +} + +func (conn *connection) logConnError(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.Warn(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() { + 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) 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) createErrorResponse(wantsClose bool, status http.StatusCode, msg string, +) *http.Response { + return conn.http.CreateErrorResponse(conn.req, wantsClose, http.NewHttpError(status, msg)) +} + +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, errTimeParse := time.Parse(http.TimeFormat, conn.req.Headers["if-modified-since"]) + notModified := errTimeParse == 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.AttachBodyReader(f, size, http.GetMimeType(fileName)) + } + 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") +} diff --git a/internal/server/html.go b/internal/server/html.go new file mode 100644 index 0000000..751647b --- /dev/null +++ b/internal/server/html.go @@ -0,0 +1,139 @@ +package server + +import ( + "os" + "path" + "serveg/internal/http" + "serveg/internal/log" + sh "serveg/internal/string_helper" +) + +const DateTimeTZ = "2006-01-02 15:04:05 UTC-07" + +func htmlEscape(s string) string { + sb := sh.StringBuilder{Buf: make([]byte, 0, len(s))} + for i := 0; i < len(s); i++ { + b := s[i] + switch b { + case '&': + sb.AddS("&") + case '<': + sb.AddS("<") + case '>': + sb.AddS(">") + case '"': + sb.AddS(""") + case '\'': + sb.AddS("'") + default: + sb.AddB(b) + } + } + return sb.StringUnsafe() +} + +type listingRow struct { + name string // html-escaped + url string // url-encoded + mtime string // formatted with DateTimeTZ + size string // formatted with HumanBytes + isDir bool +} + +func (conn *connection) serveDirListing(dirPath string) *http.Response { + htmlBytes, err := genDirListingHtml(&conn.log, dirPath, conn.req.Url) + if err != nil { + return conn.createErrorResponse(false, http.StatusInternalServerError, err.Error()) + } + + res := conn.http.CreateResponse(http.StatusOK, conn.req, false) + res.AttachBodyBuf(htmlBytes, http.TextHtml) + return res +} + +func genDirListingHtml(logger *log.LoggerContext, dirPath, reqUrl string) ([]byte, error) { + dirEntries, err := os.ReadDir(dirPath) + if err != nil { + return nil, err + } + + var dirs, files []listingRow + var estimatedLen = 460 // html for page without entries + for _, e := range dirEntries { + einf, err := e.Info() + if err != nil { + return nil, err + } + + row := listingRow{ + name: htmlEscape(e.Name()), + url: http.PercentEcodeUrl(path.Join(reqUrl, e.Name())), + mtime: einf.ModTime().Format(DateTimeTZ), + isDir: e.IsDir(), + } + if row.isDir { + row.size = "DIR" + dirs = append(dirs, row) + } else { + row.size = sh.HumanBytes(einf.Size()) + files = append(files, row) + } + + estimatedLen += len(row.name) + len(row.url) + len(row.mtime) + len(row.size) + estimatedLen += 100 // html for row + } + + sb := sh.StringBuilder{Buf: make([]byte, 0, estimatedLen)} + sb.AddS(` + +
+ +| ../ |