182 lines
6.4 KiB
Go Template
182 lines
6.4 KiB
Go Template
{{- /* 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>
|