implemented http server

This commit is contained in:
Altair-sh
2026-11-02 21:47:34 +01:00
parent e0cf8a12a9
commit bac4ccb34e
14 changed files with 775 additions and 178 deletions
+64 -21
View File
@@ -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
+17 -96
View File
@@ -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))
}
+120
View File
@@ -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
}
+196
View File
@@ -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")
}
+139
View File
@@ -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("&amp;")
case '<':
sb.AddS("&lt;")
case '>':
sb.AddS("&gt;")
case '"':
sb.AddS("&quot;")
case '\'':
sb.AddS("&#39;")
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>`)
}