rewrite html generator with html/template

This commit is contained in:
Altair-sh
2026-11-06 22:58:18 +01:00
parent 0bfa5ed35c
commit 509668088f
10 changed files with 363 additions and 110 deletions
+5
View File
@@ -4,3 +4,8 @@ bin/
__debug_bin* __debug_bin*
graphify-out/ graphify-out/
graphify_out/ graphify_out/
tmp/
temp/
TEMP/
*.tmp
*.temp
+21
View File
@@ -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)
}
+9
View File
@@ -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
}
+24
View File
@@ -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
}
+37
View File
@@ -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
}
+181
View File
@@ -0,0 +1,181 @@
{{- /* Directory listing page.
Shows directory content and has upload form. */ -}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{{.Title}}</title>
<style>
body { font-family: sans-serif; margin: 2em; }
td { padding: .2em 1em .2em 0; }
td.size, td.date { color: #666; white-space: nowrap; }
caption { text-align: left; color: #666; padding-bottom: .5em; }
#status p { margin: .3em 0; }
.error { color: #c00; }
</style>
</head>
<body>
<h1>{{.Path}}</h1>
{{- /* upload form, visible only if share has write_password */}}
{{if .Writable}}
<form id="upload" data-base="{{.PathUrl}}">
<input type="file" id="files" multiple required>
<input type="password" id="password" placeholder="write password" autocomplete="current-password" required>
<button>Upload</button>
</form>
<div id="status"></div>
{{- /* dialog that appears if file with such name exists in the directory */}}
<dialog id="replace">
<p></p>
<form method="dialog">
<button value="yes">Yes</button>
<button value="no">No</button>
</form>
</dialog>
{{end}}
{{- /* listing element is replaced by id after file upload */}}
<table id="listing">
<caption>Free space: {{.FreeSpace}}</caption>
<tr><td><a href="{{.ParentUrl}}">../</a></td></tr>
{{- range .Entries}}
<tr>
{{- if .IsDir}}
{{- /*Directory links end with '/', because browser remembers password for directory with trailing slash.*/}}
<td><a href="{{.Url}}/">{{.Name}}/</a></td>
{{- else}}
{{- /* download attribute: clicking a file link saves the file instead of opening it in the browser. */}}
<td><a href="{{.Url}}" download>{{.Name}}</a></td>
{{- end}}
<td class="size">{{.Size}}</td>
<td class="date">{{.Date}}</td>
</tr>
{{- end}}
</table>
{{- /*
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}}
<script>
const form = document.getElementById("upload");
const filesInput = document.getElementById("files");
const passwordInput = document.getElementById("password");
const uploadButton = form.querySelector("button");
const statusBox = document.getElementById("status");
const replaceDialog = document.getElementById("replace");
const base = form.dataset.base;
function formatSize(n) {
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
for (; n >= 1024 && i < units.length - 1; i++)
n /= 1024;
return (i === 0 ? n : n.toFixed(1)) + units[i];
}
function authorization() {
const bytes = new TextEncoder().encode(":" + passwordInput.value);
return "Basic " + btoa(String.fromCharCode(...bytes));
}
// the server explains errors in the response body
function errorText(status, body) {
return body.trim() || "HTTP " + status;
}
function askReplace(name) {
replaceDialog.querySelector("p").textContent = 'File "' + name + '" already exists. Replace it?';
replaceDialog.returnValue = "";
replaceDialog.showModal();
return new Promise(resolve => replaceDialog.addEventListener("close",
() => resolve(replaceDialog.returnValue === "yes"), { once: true }));
}
function put(url, file, line) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Authorization", authorization());
const start = performance.now();
xhr.upload.onprogress = (e) => {
const seconds = (performance.now() - start) / 1000;
const speed = seconds > 0 ? e.loaded / seconds : 0;
line.textContent = 'Uploading file "' + file.name + '" '
+ formatSize(e.loaded) + "/" + formatSize(e.total) + " (" + formatSize(speed) + "/s)";
};
xhr.onload = () => xhr.status >= 200 && xhr.status < 300
? resolve() : reject(new Error(errorText(xhr.status, xhr.responseText)));
xhr.onerror = () => reject(new Error("connection failed"));
xhr.send(file);
});
}
async function upload(file, line) {
const url = base + encodeURIComponent(file.name);
line.textContent = 'Checking file "' + file.name + '"...';
const check = await fetch(url, {
method: "OPTIONS",
headers: { "Authorization": authorization(), "X-Upload-Size": String(file.size) },
});
// 200: the file exists and would be replaced, 201: it would be created,
// anything else: the upload would be rejected
if (check.status === 200) {
if (!await askReplace(file.name)) {
line.textContent = 'File "' + file.name + '" skipped';
return false;
}
}
else if (check.status !== 201) {
throw new Error(errorText(check.status, await check.text()));
}
await put(url, file, line);
line.textContent = 'File "' + file.name + '" uploaded';
return true;
}
async function refreshListing() {
const res = await fetch(location.href, { cache: "no-store" });
if (!res.ok)
return;
const page = new DOMParser().parseFromString(await res.text(), "text/html");
const table = page.getElementById("listing");
if (table)
document.getElementById("listing").replaceWith(table);
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
uploadButton.disabled = true;
statusBox.replaceChildren();
const notUploaded = new DataTransfer();
for (const file of filesInput.files) {
const line = document.createElement("p");
statusBox.append(line);
try {
if (!await upload(file, line))
notUploaded.items.add(file);
}
catch (err) {
line.textContent = 'Error: file "' + file.name + '": ' + err.message;
line.className = "error";
notUploaded.items.add(file);
}
}
// uploaded files are removed from the selection, the rest stays for another try
filesInput.files = notUploaded.files;
uploadButton.disabled = false;
await refreshListing();
});
</script>
{{end}}
</body>
</html>
-27
View File
@@ -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("&amp;")
case '<':
sb.AddS("&lt;")
case '>':
sb.AddS("&gt;")
case '"':
sb.AddS("&quot;")
case '\'':
sb.AddS("&#39;")
default:
sb.AddB(b)
}
}
return sb.StringUnsafe()
}
+77 -79
View File
@@ -1,108 +1,106 @@
package html package html
import ( import (
"bytes"
_ "embed"
"html/template"
"os" "os"
"path" "path"
"serveg/internal/disk_usage"
"serveg/internal/http" "serveg/internal/http"
"serveg/internal/log"
sh "serveg/internal/string_helper" sh "serveg/internal/string_helper"
) )
type listingRow struct { const DateTimeTZ = "2006-01-02 15:04:05 UTC-07"
name string // html-escaped
url string // url-encoded // The template file is embedded into the binary at build time.
mtime string // formatted with DateTimeTZ //
size string // formatted with HumanBytes //go:embed ListingPage.gohtml
isDir bool 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) { type listingEntry struct {
sb.AddS(`<tr>`) Name string
sb.AddS(`<td><a href="`).AddS(row.url) Url string // percent-encoded, without trailing '/'
if row.isDir { Size string // formatted with HumanBytes, empty for directories
// add slash to directory url Date string // formatted with DateTimeTZ
// because browser remembers password for directory with trailing slash IsDir bool
sb.AddS(`/">`)
} else {
// tell browser to download file instead of opening in new tab
sb.AddS(`" download>`)
}
sb.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>`)
} }
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) dirEntries, err := os.ReadDir(dirPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var dirs, files []listingRow dirUrl := withTrailingSlash(reqUrl)
var estimatedLen = 460 // html for page without entries 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 { for _, e := range dirEntries {
einf, err := e.Info() einf, err := e.Info()
if err != nil { if err != nil {
return nil, err return nil, err
} }
entry := listingEntry{
row := listingRow{ Name: e.Name(),
name: htmlEscape(e.Name()), Url: http.PercentEcodeUrl(dirUrl + e.Name()),
url: http.PercentEcodeUrl(path.Join(reqUrl, e.Name())), Date: einf.ModTime().Format(DateTimeTZ),
mtime: einf.ModTime().Format(DateTimeTZ), IsDir: e.IsDir(),
isDir: e.IsDir(),
} }
if row.isDir { if entry.IsDir {
row.size = "DIR" page.Entries = append(page.Entries, entry)
dirs = append(dirs, row)
} else { } else {
row.size = sh.HumanBytes(einf.Size()) entry.Size = sh.HumanBytes(einf.Size())
files = append(files, row) files = append(files, entry)
}
}
page.Entries = append(page.Entries, files...)
// 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
}
return buf.Bytes(), nil
} }
estimatedLen += len(row.name) + len(row.url) + len(row.mtime) + len(row.size) func withTrailingSlash(s string) string {
estimatedLen += 100 // html for row if len(s) > 0 && s[len(s)-1] == '/' {
return s
} }
return s + "/"
sb := sh.StringBuilder{Buf: make([]byte, 0, estimatedLen)}
sb.AddS(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>`)
sb.AddS(htmlEscape(pageTitle + " " + reqUrl))
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(htmlEscape(reqUrl)).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++ {
dirs[i].genHtml(&sb)
}
for i := 0; i < len(files); i++ {
files[i].genHtml(&sb)
}
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
} }
+4 -1
View File
@@ -41,16 +41,19 @@ func NewConnection(
logger *log.LoggerContext, logger *log.LoggerContext,
callback ConnCallback, callback ConnCallback,
) *Connection { ) *Connection {
r := bufio.NewReaderSize(socket, maxHeaderBytes)
return &Connection{ return &Connection{
Timeout: defaultTimeout, Timeout: defaultTimeout,
log: logger, log: logger,
callback: callback, callback: callback,
socket: socket, socket: socket,
r: bufio.NewReaderSize(socket, maxHeaderBytes), r: r,
wbuf: sh.StringBuilder{Buf: make([]byte, 0, initialWriteBufCap)}, wbuf: sh.StringBuilder{Buf: make([]byte, 0, initialWriteBufCap)},
_req: Request{ _req: Request{
QueryParams: make(mapStrStr), QueryParams: make(mapStrStr),
Headers: make(HeadersMap), Headers: make(HeadersMap),
// body is read from the same buffered reader as headers
bodyReader: io.LimitedReader{R: r},
}, },
_res: Response{ _res: Response{
Headers: make(HeadersMap), Headers: make(HeadersMap),
+6 -4
View File
@@ -26,7 +26,9 @@ func (conn *TcpConnection) GET_HEAD(req *http.Request) *http.Response {
case UrlFile: case UrlFile:
return conn.serveFile(req, path) return conn.serveFile(req, path)
case UrlDir: 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: case UrlShareNotFound, UrlDoesntExistInShare:
return conn.createErrorResponse(req, false, http.StatusNotFound, req.Url) return conn.createErrorResponse(req, false, http.StatusNotFound, req.Url)
case UrlForbidden: 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 { func (conn *TcpConnection) serveDirListing(req *http.Request, dirPath string, writable bool) *http.Response {
htmlBytes, err := html.GenDirListingHtml(&conn.log, dirPath, htmlBytes, err := html.GenDirListingHtml(dirPath,
req.Url, conn.srv.Conf.HtmlTitle) req.Url, conn.srv.Conf.HtmlTitle, writable)
if err != nil { if err != nil {
return conn.createErrorResponse(req, false, http.StatusInternalServerError, err.Error()) return conn.createErrorResponse(req, false, http.StatusInternalServerError, err.Error())
} }