rewrite html generator with html/template
This commit is contained in:
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
+76
-78
@@ -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(`<tr>`)
|
||||
sb.AddS(`<td><a href="`).AddS(row.url)
|
||||
if row.isDir {
|
||||
// add slash to directory url
|
||||
// because browser remembers password for directory with trailing slash
|
||||
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>`)
|
||||
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(`<!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)
|
||||
// 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(`
|
||||
</table>
|
||||
</body>
|
||||
</html>`)
|
||||
|
||||
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 + "/"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user