implemented http server
This commit is contained in:
+1
-1
@@ -31,7 +31,7 @@ func run() int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := server.Server{Conf: conf}
|
srv := server.NewServer(conf)
|
||||||
if err := srv.StartGoroutine(); err != nil {
|
if err := srv.StartGoroutine(); err != nil {
|
||||||
logger.Error(err)
|
logger.Error(err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
sh "serveg/internal/string_helper"
|
sh "serveg/internal/string_helper"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxHeaderBytes = 16 * 1024
|
const maxHeaderBytes = 16 * 1024
|
||||||
@@ -14,16 +15,18 @@ type mapStrStr map[string]string
|
|||||||
type HeadersMap map[string]string
|
type HeadersMap map[string]string
|
||||||
|
|
||||||
type Connection struct {
|
type Connection struct {
|
||||||
socket *net.TCPConn
|
socket *net.TCPConn
|
||||||
r *bufio.Reader
|
timeout time.Duration
|
||||||
wbuf sh.StringBuilder
|
r *bufio.Reader
|
||||||
|
wbuf sh.StringBuilder
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConnection(socket *net.TCPConn) *Connection {
|
func NewConnection(socket *net.TCPConn, timeout time.Duration) *Connection {
|
||||||
return &Connection{
|
return &Connection{
|
||||||
socket: socket,
|
socket: socket,
|
||||||
r: bufio.NewReaderSize(socket, maxHeaderBytes),
|
timeout: timeout,
|
||||||
wbuf: sh.StringBuilder{Buf: make([]byte, 0, initialWriteBufCap)},
|
r: bufio.NewReaderSize(socket, maxHeaderBytes),
|
||||||
|
wbuf: sh.StringBuilder{Buf: make([]byte, 0, initialWriteBufCap)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ type Request struct {
|
|||||||
Method string
|
Method string
|
||||||
Protocol string
|
Protocol string
|
||||||
// url-encoded characters (%D0), query params (?v=123)
|
// url-encoded characters (%D0), query params (?v=123)
|
||||||
PathRaw string
|
UrlRaw string
|
||||||
Path string
|
Url string
|
||||||
QueryParams mapStrStr
|
QueryParams mapStrStr
|
||||||
// keys are in lower case
|
// keys are in lower case
|
||||||
// duplicate keys are joined with ", "
|
// duplicate keys are joined with ", "
|
||||||
@@ -20,6 +20,9 @@ type Request struct {
|
|||||||
WantsKeepAlive bool
|
WantsKeepAlive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: add request body support
|
||||||
|
// TODO: check contentLength <= MaxUpload
|
||||||
|
// TODO: use io.LimitReader(conn.r, contentLength)
|
||||||
// Read request (body not supported)
|
// Read request (body not supported)
|
||||||
// Returns valid request pointer. If err != nil, req is partially parsed.
|
// 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.
|
// 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]
|
req.Method = parts[0]
|
||||||
// url-encoded characters (%D0), query params (?v=123)
|
// url-encoded characters (%D0), query params (?v=123)
|
||||||
req.PathRaw = parts[1]
|
req.UrlRaw = parts[1]
|
||||||
req.Protocol = parts[2]
|
req.Protocol = parts[2]
|
||||||
|
|
||||||
if req.Protocol != "HTTP/1.0" && req.Protocol != "HTTP/1.1" {
|
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)
|
"request protocol not supported: "+req.Protocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := url.ParseRequestURI(req.PathRaw)
|
u, err := url.ParseRequestURI(req.UrlRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NewHttpError(StatusBadRequest, err.Error())
|
return NewHttpError(StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Path = sh.ResolveSafePath(u.Path)
|
req.Url = sh.ResolveSafePath(u.Path)
|
||||||
if req.Path == "" {
|
if req.Url == "" {
|
||||||
return NewHttpError(StatusForbidden,
|
return NewHttpError(StatusForbidden,
|
||||||
"unsafe request path '"+u.Path+"'")
|
"unsafe request path '"+u.Path+"'")
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-47
@@ -2,6 +2,7 @@ package http
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
sh "serveg/internal/string_helper"
|
sh "serveg/internal/string_helper"
|
||||||
@@ -14,19 +15,18 @@ type responseBodyType uint8
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
responseBodyEmpty = responseBodyType(iota)
|
responseBodyEmpty = responseBodyType(iota)
|
||||||
responseBodySmall
|
responseBodyBuf
|
||||||
responseBodyBig
|
responseBodyReader
|
||||||
)
|
)
|
||||||
|
|
||||||
const TextPlain = "text/plain; charset=utf-8"
|
|
||||||
|
|
||||||
type Response struct {
|
type Response struct {
|
||||||
Status StatusCode
|
Status StatusCode
|
||||||
Headers HeadersMap // keys are in lower case
|
Headers HeadersMap // keys are in lower case
|
||||||
omitBody bool // don't add body and content-* headers for HEAD request
|
omitBody bool // don't add body and content-* headers for HEAD request
|
||||||
bodyType responseBodyType
|
bodySize int64
|
||||||
bodySmall []byte // small body is sent in one write() call with headers
|
bodyType responseBodyType
|
||||||
bodyBig io.WriterTo // big body, sent as multiple write() calls to socket
|
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"
|
const TimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
|
||||||
@@ -46,7 +46,7 @@ func (conn *Connection) CreateResponse(
|
|||||||
status >= 200 &&
|
status >= 200 &&
|
||||||
status != StatusNoContent &&
|
status != StatusNoContent &&
|
||||||
status != StatusNotModified {
|
status != StatusNotModified {
|
||||||
// content-length 0 is mandatory for most responses
|
// content-length is mandatory for most responses
|
||||||
res.Headers["content-length"] = "0"
|
res.Headers["content-length"] = "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ func (conn *Connection) CreateResponse(
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func (res *Response) AttachBodySmall(body []byte, contentType string) {
|
func (res *Response) AttachBodyBuf(body []byte, contentType string) {
|
||||||
if res.omitBody {
|
if res.omitBody {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -67,18 +67,21 @@ func (res *Response) AttachBodySmall(body []byte, contentType string) {
|
|||||||
if res.bodyType != responseBodyEmpty {
|
if res.bodyType != responseBodyEmpty {
|
||||||
panic("response already has body")
|
panic("response already has body")
|
||||||
}
|
}
|
||||||
res.bodyType = responseBodySmall
|
res.bodyType = responseBodyBuf
|
||||||
res.bodySmall = body
|
res.bodyBuf = body
|
||||||
lenStr := strconv.FormatUint(uint64(len(body)), 10)
|
size := int64(len(body))
|
||||||
|
res.bodySize = size
|
||||||
|
lenStr := strconv.FormatUint(uint64(size), 10)
|
||||||
res.Headers["content-length"] = lenStr
|
res.Headers["content-length"] = lenStr
|
||||||
res.Headers["content-type"] = contentType
|
res.Headers["content-type"] = contentType
|
||||||
}
|
}
|
||||||
func (res *Response) AttachBodySmallStr(body string, contentType string) {
|
func (res *Response) AttachBodyStr(body string, contentType string) {
|
||||||
res.AttachBodySmall(sh.UnsafeGetStrBytes(body), contentType)
|
res.AttachBodyBuf(sh.UnsafeGetStrBytes(body), contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (res *Response) AttachBodyBig(body io.WriterTo,
|
// body will be closed by SendResponse
|
||||||
size uint64, contentType string) {
|
func (res *Response) AttachBodyReader(body io.Reader,
|
||||||
|
size int64, contentType string) {
|
||||||
|
|
||||||
if res.omitBody {
|
if res.omitBody {
|
||||||
return
|
return
|
||||||
@@ -86,23 +89,31 @@ func (res *Response) AttachBodyBig(body io.WriterTo,
|
|||||||
if res.bodyType != responseBodyEmpty {
|
if res.bodyType != responseBodyEmpty {
|
||||||
panic("response already has body")
|
panic("response already has body")
|
||||||
}
|
}
|
||||||
res.bodyType = responseBodyBig
|
res.bodyType = responseBodyReader
|
||||||
res.bodyBig = body
|
res.bodyReader = body
|
||||||
lenStr := strconv.FormatUint(size, 10)
|
res.bodySize = size
|
||||||
|
lenStr := strconv.FormatUint(uint64(size), 10)
|
||||||
res.Headers["content-length"] = lenStr
|
res.Headers["content-length"] = lenStr
|
||||||
res.Headers["content-type"] = contentType
|
res.Headers["content-type"] = contentType
|
||||||
}
|
}
|
||||||
|
|
||||||
var ErrInvalidHeader = errors.New("http: header key or value contains CR or LF")
|
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 := &conn.wbuf
|
||||||
sb.Reset()
|
sb.Reset()
|
||||||
|
|
||||||
// first line
|
// first line
|
||||||
// space before \r\n is required by standard
|
// space before \r\n is required by standard
|
||||||
sb.AddS("HTTP/1.1 ").AddU(uint64(res.Status)).AddS(" \r\n")
|
sb.AddS("HTTP/1.1 ").AddU(uint64(res.Status)).AddS(" \r\n")
|
||||||
|
|
||||||
// headers
|
// headers
|
||||||
for k, v := range res.Headers {
|
for k, v := range res.Headers {
|
||||||
// prevent response splitting / header injection
|
// prevent response splitting / header injection
|
||||||
@@ -116,35 +127,52 @@ func (conn *Connection) SendResponse(res *Response) error {
|
|||||||
|
|
||||||
switch res.bodyType {
|
switch res.bodyType {
|
||||||
case responseBodyEmpty:
|
case responseBodyEmpty:
|
||||||
// net.Conn.Write follows the io.Writer contract: it returns a non-nil
|
_, err = conn.socket.Write(conn.wbuf.Buf)
|
||||||
// error whenever n < len(p), so no loop is needed.
|
case responseBodyBuf:
|
||||||
if _, err := conn.socket.Write(conn.wbuf.Buf); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case responseBodySmall:
|
|
||||||
// write headers and body in one socket.Write() call
|
// write headers and body in one socket.Write() call
|
||||||
bbb := net.Buffers{sb.Buf, res.bodySmall}
|
bufs := net.Buffers{sb.Buf, res.bodyBuf}
|
||||||
if _, err := bbb.WriteTo(conn.socket); err != nil {
|
_, err = bufs.WriteTo(conn.socket)
|
||||||
return err
|
case responseBodyReader:
|
||||||
|
// write headers
|
||||||
|
if _, err = conn.socket.Write(conn.wbuf.Buf); err != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
case responseBodyBig:
|
// Write exactly bodySize bytes from body.
|
||||||
// write headers, then write big body
|
// It prevents session corruption if body size changed since
|
||||||
if _, err := conn.socket.Write(conn.wbuf.Buf); err != nil {
|
// AttachBodyReader was called.
|
||||||
return err
|
remaining := res.bodySize
|
||||||
}
|
for remaining > 0 {
|
||||||
if _, err := res.bodyBig.WriteTo(conn.socket); err != nil {
|
err = conn.socket.SetWriteDeadline(time.Now().Add(conn.timeout))
|
||||||
return err
|
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(
|
func (conn *Connection) CreateErrorResponse(
|
||||||
httpErr *HttpError, req *Request, wantsClose bool,
|
req *Request, wantsClose bool, httpErr *HttpError,
|
||||||
) error {
|
) *Response {
|
||||||
|
|
||||||
res := conn.CreateResponse(httpErr.Status, req, wantsClose)
|
res := conn.CreateResponse(httpErr.Status, req, wantsClose)
|
||||||
// attach error message if method allows
|
// attach error message if method allows
|
||||||
res.AttachBodySmallStr(httpErr.Error(), TextPlain)
|
res.AttachBodyStr(httpErr.Error(), TextPlain)
|
||||||
return conn.SendResponse(res)
|
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
|
||||||
|
}
|
||||||
+64
-21
@@ -5,17 +5,76 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"serveg/internal/log"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: add example config file
|
type Shares []*SharedLocation
|
||||||
|
|
||||||
type Config struct {
|
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 confLogger = log.LoggerContext{Name: "config"}
|
||||||
var config = DefaultConfig()
|
|
||||||
|
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)
|
tomlMeta, err := toml.DecodeFile(path, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -25,26 +84,10 @@ func ReadConfig(path string) (*Config, error) {
|
|||||||
if undecodedKeys := tomlMeta.Undecoded(); len(undecodedKeys) > 0 {
|
if undecodedKeys := tomlMeta.Undecoded(); len(undecodedKeys) > 0 {
|
||||||
return nil, fmt.Errorf("%s: unknown keys: %v", path, undecodedKeys)
|
return nil, fmt.Errorf("%s: unknown keys: %v", path, undecodedKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() *Config {
|
func writeConfig(config *Config, path string) (err error) {
|
||||||
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) {
|
|
||||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+17
-96
@@ -2,26 +2,32 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"os"
|
|
||||||
"serveg/internal/http"
|
|
||||||
"serveg/internal/log"
|
"serveg/internal/log"
|
||||||
"strconv"
|
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Conf *Config
|
Conf *Config
|
||||||
|
logger log.LoggerContext
|
||||||
|
// used in sockets to sed read/write deadline
|
||||||
|
timeout time.Duration
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
logger log.LoggerContext
|
|
||||||
wg sync.WaitGroup
|
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) {
|
func (srv *Server) StartGoroutine() (err error) {
|
||||||
srv.logger = log.LoggerContext{Name: "server"}
|
|
||||||
srv.logger.Info("listening at ", srv.Conf.Addr)
|
srv.logger.Info("listening at ", srv.Conf.Addr)
|
||||||
srv.listener, err = net.Listen("tcp", srv.Conf.Addr)
|
srv.listener, err = net.Listen("tcp", srv.Conf.Addr)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -33,7 +39,7 @@ func (srv *Server) StartGoroutine() (err error) {
|
|||||||
func (srv *Server) serve() {
|
func (srv *Server) serve() {
|
||||||
srv.logger.Info("server started")
|
srv.logger.Info("server started")
|
||||||
for i := int64(0); ; i++ {
|
for i := int64(0); ; i++ {
|
||||||
conn, err := srv.listener.Accept()
|
accepted, err := srv.listener.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, net.ErrClosed) {
|
if errors.Is(err, net.ErrClosed) {
|
||||||
srv.logger.Info("listener socket closed")
|
srv.logger.Info("listener socket closed")
|
||||||
@@ -42,9 +48,8 @@ func (srv *Server) serve() {
|
|||||||
srv.logger.Warn(err)
|
srv.logger.Warn(err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// TODO: add connection to map. SetDeadline(Now()) in Stop().
|
conn := newConnection(srv, i, accepted.(*net.TCPConn))
|
||||||
// srv.wg.Go(func() { handleConnection(conn.(*net.TCPConn), i) })
|
go conn.handle()
|
||||||
go handleConnection(conn.(*net.TCPConn), i)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,87 +58,3 @@ func (srv *Server) Stop() {
|
|||||||
_ = srv.listener.Close()
|
_ = srv.listener.Close()
|
||||||
srv.wg.Wait()
|
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))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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(`<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>`)
|
||||||
|
reqUrlHtmlEscaped := htmlEscape(reqUrl)
|
||||||
|
sb.AddS(reqUrlHtmlEscaped)
|
||||||
|
sb.AddS(`</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; margin: 2em; }
|
||||||
|
td { padding: .2em 1em .2em 0; }
|
||||||
|
td.size, td.date { color: #666; white-space: nowrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>`)
|
||||||
|
sb.AddS("<h1>").AddS(reqUrlHtmlEscaped).AddS("</h1>")
|
||||||
|
sb.AddS(`<table id="listing">`)
|
||||||
|
sb.AddS(`<tr><td><a href="`)
|
||||||
|
sb.AddS(http.PercentEcodeUrl(path.Dir(reqUrl)))
|
||||||
|
sb.AddS(`">../</a></td></tr>`)
|
||||||
|
|
||||||
|
for i := 0; i < len(dirs); i++ {
|
||||||
|
genListingRowHtml(&sb, &dirs[i])
|
||||||
|
}
|
||||||
|
for i := 0; i < len(files); i++ {
|
||||||
|
genListingRowHtml(&sb, &files[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AddS(`
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
|
||||||
|
realLen := sb.Len()
|
||||||
|
if estimatedLen < realLen {
|
||||||
|
logger.Warn("genDirListingHtml(): size prediction failed. estimatedLen: ",
|
||||||
|
estimatedLen, " realLen: ", realLen)
|
||||||
|
}
|
||||||
|
return sb.Buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func genListingRowHtml(sb *sh.StringBuilder, row *listingRow) {
|
||||||
|
sb.AddS(`<tr>`)
|
||||||
|
sb.AddS(`<td><a href="`).AddS(row.url).AddB('"')
|
||||||
|
if !row.isDir {
|
||||||
|
// tell browser to download file instead of opening in new tab
|
||||||
|
sb.AddS(" download")
|
||||||
|
}
|
||||||
|
sb.AddB('>').AddS(row.name).AddS(`</a></td>`)
|
||||||
|
sb.AddS(`<td class="size">`).AddS(row.size).AddS(`</td>`)
|
||||||
|
sb.AddS(`<td class="date">`).AddS(row.mtime).AddS(`</td>`)
|
||||||
|
sb.AddS(`</tr>`)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,6 +66,11 @@ func (b *StringBuilder) AddBool(v bool) *StringBuilder {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *StringBuilder) AddT(v time.Time, fmt string) *StringBuilder {
|
||||||
|
b.Buf = v.AppendFormat(b.Buf, fmt)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
// numberBase: 2 <= base <= 36
|
// numberBase: 2 <= base <= 36
|
||||||
func (b *StringBuilder) AddIExt(v int64, numberBase int) *StringBuilder {
|
func (b *StringBuilder) AddIExt(v int64, numberBase int) *StringBuilder {
|
||||||
b.Buf = strconv.AppendInt(b.Buf, v, numberBase)
|
b.Buf = strconv.AppendInt(b.Buf, v, numberBase)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package string_helper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Format byte size to kilobytes, megabytes, etc.
|
||||||
|
//
|
||||||
|
// 1023 -> "1023"
|
||||||
|
// 1048576 -> "1M"
|
||||||
|
// 4778151117 -> "4.45G"
|
||||||
|
// 1023*1024 -> "1M"
|
||||||
|
func HumanBytes(n int64) string {
|
||||||
|
units := []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"}
|
||||||
|
v := float64(n)
|
||||||
|
i := 0
|
||||||
|
for i < len(units)-1 && math.Round(math.Abs(v)*100)/100 >= 1024 {
|
||||||
|
v /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
s := strconv.FormatFloat(v, 'f', 2, 64)
|
||||||
|
s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
|
||||||
|
return s + units[i]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# serveg config, copy to serveg.toml
|
||||||
|
# The file contains passwords: make it readable only by the user serveg runs as (chmod 600).
|
||||||
|
address = "127.0.0.1:8080"
|
||||||
|
max_upload = 4_294_967_296
|
||||||
|
|
||||||
|
# Each [[share]] makes a directory available under an URL prefix.
|
||||||
|
# Missing read_password: anyone can download.
|
||||||
|
# Missing write_password: read-only, uploads are rejected.
|
||||||
|
# The write password also allows downloading.
|
||||||
|
|
||||||
|
[[share]]
|
||||||
|
url = "/public" # http://address/public/...
|
||||||
|
path = "share/public" # relative paths are relative to this config file
|
||||||
|
write_password = "change-me"
|
||||||
|
|
||||||
|
[[share]]
|
||||||
|
url = "/private"
|
||||||
|
path = "share/private"
|
||||||
|
read_password = "change-me-too"
|
||||||
|
write_password = "change-me-three"
|
||||||
|
|
||||||
|
[[share]]
|
||||||
|
url = "/"
|
||||||
|
path = "share/index.html" # redirect url to file
|
||||||
Reference in New Issue
Block a user