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 */}} + + + + {{- range .Entries}} + + {{- if .IsDir}} + {{- /*Directory links end with '/', because browser remembers password for directory with trailing slash.*/}} + + {{- else}} + {{- /* download attribute: clicking a file link saves the file instead of opening it in the browser. */}} + + {{- end}} + + + + {{- end}} +
Free space: {{.FreeSpace}}
../
{{.Name}}/{{.Name}}{{.Size}}{{.Date}}
+ +{{- /* +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()) }