diff --git a/.gitignore b/.gitignore
index 9ba9e76..5081724 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,8 @@ bin/
__debug_bin*
graphify-out/
graphify_out/
+tmp/
+temp/
+TEMP/
+*.tmp
+*.temp
diff --git a/internal/disk_usage/disk_usage.go b/internal/disk_usage/disk_usage.go
new file mode 100644
index 0000000..ec32a09
--- /dev/null
+++ b/internal/disk_usage/disk_usage.go
@@ -0,0 +1,21 @@
+package disk_usage
+
+import "errors"
+
+var ErrNotSupported = errors.New("disk_usage: not supported on this OS")
+
+// Used + Free can be less than Total: space reserved for root/admin
+// is not free for this process, but not used either.
+type Usage struct {
+ // size of the file system (available to this user, if there are quotas)
+ Total int64
+ // Total minus all free space, including reserved
+ Used int64
+ // available to this process
+ Free int64
+}
+
+// DiskUsage returns usage of the file system that contains path.
+func DiskUsage(path string) (Usage, error) {
+ return diskUsage(path)
+}
diff --git a/internal/disk_usage/disk_usage_other.go b/internal/disk_usage/disk_usage_other.go
new file mode 100644
index 0000000..3e5f08a
--- /dev/null
+++ b/internal/disk_usage/disk_usage_other.go
@@ -0,0 +1,9 @@
+//go:build !(linux || darwin || freebsd || windows)
+
+// Fallback, so the program still builds on other OSes.
+
+package disk_usage
+
+func diskUsage(path string) (Usage, error) {
+ return Usage{}, ErrNotSupported
+}
diff --git a/internal/disk_usage/disk_usage_unix.go b/internal/disk_usage/disk_usage_unix.go
new file mode 100644
index 0000000..3295472
--- /dev/null
+++ b/internal/disk_usage/disk_usage_unix.go
@@ -0,0 +1,24 @@
+//go:build linux || darwin || freebsd
+
+// "_unix" is not a special file name suffix for Go, so the OS list is in the //go:build line.
+// Not "unix": openbsd and netbsd have different Statfs_t or no syscall.Statfs.
+
+package disk_usage
+
+import "syscall"
+
+func diskUsage(path string) (Usage, error) {
+ var st syscall.Statfs_t
+ if err := syscall.Statfs(path, &st); err != nil {
+ return Usage{}, err
+ }
+ // Field types differ between OSes, int64() works for all of them.
+ bsize := int64(st.Bsize)
+ return Usage{
+ Total: int64(st.Blocks) * bsize,
+ // Bfree: all free blocks, including reserved for root
+ Used: (int64(st.Blocks) - int64(st.Bfree)) * bsize,
+ // Bavail: free blocks available to unprivileged users
+ Free: int64(st.Bavail) * bsize,
+ }, nil
+}
diff --git a/internal/disk_usage/disk_usage_windows.go b/internal/disk_usage/disk_usage_windows.go
new file mode 100644
index 0000000..4dcad57
--- /dev/null
+++ b/internal/disk_usage/disk_usage_windows.go
@@ -0,0 +1,37 @@
+package disk_usage
+
+import (
+ "syscall"
+ "unsafe"
+)
+
+// Loaded on first call. kernel32.dll is a KnownDLL,
+// Windows always loads it from the system directory.
+var procGetDiskFreeSpaceExW = syscall.NewLazyDLL("kernel32.dll").NewProc("GetDiskFreeSpaceExW")
+
+func diskUsage(path string) (Usage, error) {
+ pathPtr, err := syscall.UTF16PtrFromString(path)
+ if err != nil {
+ return Usage{}, err
+ }
+ // BOOL GetDiskFreeSpaceExW(LPCWSTR lpDirectoryName,
+ // PULARGE_INTEGER lpFreeBytesAvailableToCaller, // respects user disk quotas
+ // PULARGE_INTEGER lpTotalNumberOfBytes, // respects user disk quotas
+ // PULARGE_INTEGER lpTotalNumberOfFreeBytes); // whole disk
+ var freeToCaller, total, totalFree uint64
+ ok, _, callErr := procGetDiskFreeSpaceExW.Call(
+ uintptr(unsafe.Pointer(pathPtr)),
+ uintptr(unsafe.Pointer(&freeToCaller)),
+ uintptr(unsafe.Pointer(&total)),
+ uintptr(unsafe.Pointer(&totalFree)))
+ if ok == 0 {
+ // Call always returns non-nil error, it is meaningful only if the function failed
+ return Usage{}, callErr
+ }
+ return Usage{
+ Total: int64(total),
+ // with quotas totalFree can be > total
+ Used: max(int64(total)-int64(totalFree), 0),
+ Free: int64(freeToCaller),
+ }, nil
+}
diff --git a/internal/html/ListingPage.gohtml b/internal/html/ListingPage.gohtml
new file mode 100644
index 0000000..e7c5162
--- /dev/null
+++ b/internal/html/ListingPage.gohtml
@@ -0,0 +1,181 @@
+{{- /* Directory listing page.
+Shows directory content and has upload form. */ -}}
+
+
+
+
+{{.Title}}
+
+
+
+{{.Path}}
+
+{{- /* upload form, visible only if share has write_password */}}
+{{if .Writable}}
+
+
+{{- /* dialog that appears if file with such name exists in the directory */}}
+
+{{end}}
+
+
+{{- /* listing element is replaced by id after file upload */}}
+
+ Free space: {{.FreeSpace}}
+ | ../ |
+ {{- range .Entries}}
+
+ {{- if .IsDir}}
+ {{- /*Directory links end with '/', because browser remembers password for directory with trailing slash.*/}}
+ | {{.Name}}/ |
+ {{- else}}
+ {{- /* download attribute: clicking a file link saves the file instead of opening it in the browser. */}}
+ {{.Name}} |
+ {{- end}}
+ {{.Size}} |
+ {{.Date}} |
+
+ {{- end}}
+
+
+{{- /*
+Upload: for each file asks the server whether the upload would be accepted
+(OPTIONS with X-Upload-Size), asks before replacing an existing file,
+uploads with progress, then reloads the file table.
+The OPTIONS check comes first, because a PUT rejected while the browser is still sending the file
+ends with a connection reset, and the browser reports only "NetworkError" without reading response error.
+Error messages are the response bodies.
+The file is sent as the raw PUT body, the server doesn't need to parse multipart forms.
+*/}}
+{{if .Writable}}
+
+{{end}}
+
+
diff --git a/internal/html/common.go b/internal/html/common.go
deleted file mode 100644
index 3336d8c..0000000
--- a/internal/html/common.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package html
-
-import 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()
-}
diff --git a/internal/html/html.go b/internal/html/html.go
index 7b5c1dd..4025ef4 100644
--- a/internal/html/html.go
+++ b/internal/html/html.go
@@ -1,108 +1,106 @@
package html
import (
+ "bytes"
+ _ "embed"
+ "html/template"
"os"
"path"
+ "serveg/internal/disk_usage"
"serveg/internal/http"
- "serveg/internal/log"
sh "serveg/internal/string_helper"
)
-type listingRow struct {
- name string // html-escaped
- url string // url-encoded
- mtime string // formatted with DateTimeTZ
- size string // formatted with HumanBytes
- isDir bool
+const DateTimeTZ = "2006-01-02 15:04:05 UTC-07"
+
+// The template file is embedded into the binary at build time.
+//
+//go:embed ListingPage.gohtml
+var listingPageSrc string
+
+// Parsed once at startup, Execute() is safe for concurrent use.
+// Must() panics on syntax error, so a broken template fails at start, not on request.
+var listingPageTemplate = template.Must(template.New("ListingPage").Parse(listingPageSrc))
+
+// Data for ListingPage.gohtml. Fields must be exported to be visible in the template.
+// Values are not escaped: html/template does it.
+type listingPage struct {
+ Title string
+ Path string // directory url, ends with '/'
+ PathUrl string // percent-encoded Path
+ ParentUrl string // percent-encoded, ends with '/'
+ Writable bool // show the upload form
+ FreeSpace string // formatted with HumanBytes
+ Entries []listingEntry
}
-func (row *listingRow) genHtml(sb *sh.StringBuilder) {
- sb.AddS(``)
- sb.AddS(`| `)
- } else {
- // tell browser to download file instead of opening in new tab
- sb.AddS(`" download>`)
- }
- sb.AddS(row.name).AddS(` | `)
- sb.AddS(``).AddS(row.size).AddS(` | `)
- sb.AddS(``).AddS(row.mtime).AddS(` | `)
- sb.AddS(`
`)
+type listingEntry struct {
+ Name string
+ Url string // percent-encoded, without trailing '/'
+ Size string // formatted with HumanBytes, empty for directories
+ Date string // formatted with DateTimeTZ
+ IsDir bool
}
-func GenDirListingHtml(logger *log.LoggerContext, dirPath, reqUrl, pageTitle string) ([]byte, error) {
+// reqUrl is a clean url without trailing slash (except "/"), see sh.ResolveSafePath
+func GenDirListingHtml(dirPath, reqUrl, pageTitle string, writable bool) ([]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
+ dirUrl := withTrailingSlash(reqUrl)
+ page := listingPage{
+ Title: pageTitle + " " + dirUrl,
+ Path: dirUrl,
+ PathUrl: http.PercentEcodeUrl(dirUrl),
+ ParentUrl: http.PercentEcodeUrl(withTrailingSlash(path.Dir(reqUrl))),
+ Writable: writable,
+ Entries: make([]listingEntry, 0, len(dirEntries)),
+ }
+
+ // listing is still useful without free space, so error is shown on the page
+ if usage, err := disk_usage.DiskUsage(dirPath); err == nil {
+ page.FreeSpace = sh.HumanBytes(usage.Free)
+ } else {
+ page.FreeSpace = "unknown (" + err.Error() + ")"
+ }
+
+ // directories first, then files, both sorted by name (os.ReadDir sorts)
+ var files []listingEntry
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(),
+ entry := listingEntry{
+ Name: e.Name(),
+ Url: http.PercentEcodeUrl(dirUrl + e.Name()),
+ Date: einf.ModTime().Format(DateTimeTZ),
+ IsDir: e.IsDir(),
}
- if row.isDir {
- row.size = "DIR"
- dirs = append(dirs, row)
+ if entry.IsDir {
+ page.Entries = append(page.Entries, entry)
} else {
- row.size = sh.HumanBytes(einf.Size())
- files = append(files, row)
+ entry.Size = sh.HumanBytes(einf.Size())
+ files = append(files, entry)
}
-
- estimatedLen += len(row.name) + len(row.url) + len(row.mtime) + len(row.size)
- estimatedLen += 100 // html for row
}
+ page.Entries = append(page.Entries, files...)
- sb := sh.StringBuilder{Buf: make([]byte, 0, estimatedLen)}
- sb.AddS(`
-
-
-
-`)
- sb.AddS(htmlEscape(pageTitle + " " + reqUrl))
- sb.AddS(`
-
-
-
-`)
- sb.AddS("").AddS(htmlEscape(reqUrl)).AddS("
")
- sb.AddS(``)
- sb.AddS(`| ../ |
`)
-
- for i := 0; i < len(dirs); i++ {
- dirs[i].genHtml(&sb)
+ // render to buffer, not to socket: content-length must be known,
+ // and an error in the middle must not send a half page
+ var buf bytes.Buffer
+ buf.Grow(len(listingPageSrc) + len(page.Entries)*200)
+ if err := listingPageTemplate.Execute(&buf, &page); err != nil {
+ return nil, err
}
- for i := 0; i < len(files); i++ {
- files[i].genHtml(&sb)
- }
-
- sb.AddS(`
-
-
-`)
-
- realLen := sb.Len()
- if estimatedLen < realLen {
- logger.Warn("genDirListingHtml(): size prediction failed. estimatedLen: ",
- estimatedLen, " realLen: ", realLen)
- }
- return sb.Buf, nil
+ return buf.Bytes(), nil
+}
+
+func withTrailingSlash(s string) string {
+ if len(s) > 0 && s[len(s)-1] == '/' {
+ return s
+ }
+ return s + "/"
}
diff --git a/internal/http/Connection.go b/internal/http/Connection.go
index 5ab39b2..acc436a 100644
--- a/internal/http/Connection.go
+++ b/internal/http/Connection.go
@@ -41,16 +41,19 @@ func NewConnection(
logger *log.LoggerContext,
callback ConnCallback,
) *Connection {
+ r := bufio.NewReaderSize(socket, maxHeaderBytes)
return &Connection{
Timeout: defaultTimeout,
log: logger,
callback: callback,
socket: socket,
- r: bufio.NewReaderSize(socket, maxHeaderBytes),
+ r: r,
wbuf: sh.StringBuilder{Buf: make([]byte, 0, initialWriteBufCap)},
_req: Request{
QueryParams: make(mapStrStr),
Headers: make(HeadersMap),
+ // body is read from the same buffered reader as headers
+ bodyReader: io.LimitedReader{R: r},
},
_res: Response{
Headers: make(HeadersMap),
diff --git a/internal/server/GET_HEAD.go b/internal/server/GET_HEAD.go
index 24eac4d..23cb409 100644
--- a/internal/server/GET_HEAD.go
+++ b/internal/server/GET_HEAD.go
@@ -26,7 +26,9 @@ func (conn *TcpConnection) GET_HEAD(req *http.Request) *http.Response {
case UrlFile:
return conn.serveFile(req, path)
case UrlDir:
- return conn.serveDirListing(req, path)
+ // show upload form if the share accepts uploads
+ writable := share != nil && share.WritePassword != ""
+ return conn.serveDirListing(req, path, writable)
case UrlShareNotFound, UrlDoesntExistInShare:
return conn.createErrorResponse(req, false, http.StatusNotFound, req.Url)
case UrlForbidden:
@@ -39,9 +41,9 @@ func (conn *TcpConnection) GET_HEAD(req *http.Request) *http.Response {
}
}
-func (conn *TcpConnection) serveDirListing(req *http.Request, dirPath string) *http.Response {
- htmlBytes, err := html.GenDirListingHtml(&conn.log, dirPath,
- req.Url, conn.srv.Conf.HtmlTitle)
+func (conn *TcpConnection) serveDirListing(req *http.Request, dirPath string, writable bool) *http.Response {
+ htmlBytes, err := html.GenDirListingHtml(dirPath,
+ req.Url, conn.srv.Conf.HtmlTitle, writable)
if err != nil {
return conn.createErrorResponse(req, false, http.StatusInternalServerError, err.Error())
}