15 Commits
v1.0 ... v1.1

7 changed files with 444 additions and 206 deletions

View File

@@ -12,29 +12,11 @@
<script src="/script.js"></script>
</head>
<body>
<a href="/" class="logo"><h1>Ablage</h1></a>
<div id="dropzone" style="display: none">
Drag & drop files here or click to select
</div>
<input
type="file"
id="fileInput"
name="uploadfile"
multiple
style="display: none"
/>
<div id="overallProgressContainer" style="display: none">
<div id="currentFileName"></div>
<progress id="overallProgress" value="0" max="100"></progress>
<div id="overallStatus" class="status"></div>
</div>
<ul id="file-list"></ul>
<div id="sinkholeModeInfo" class="sinkholeModeInfo" style="display: none">
- Sinkhole mode enabled, no files will get listed -
</div>
<a href="/" class="logo">
<h1>Ablage</h1>
</a>
<h3 style="color: red; text-align: center; margin-top: 100px">
We will need JavaScript from here on, sorry...
</h3>
</body>
</html>

View File

@@ -1,11 +1,16 @@
(() => {
"use strict";
let AppConfig = null;
let UI = {};
const DEFAULT_DROPZONE_TEXT = "Drag & drop files here or click to select";
const state = {
config: null,
files: {},
ui: {},
errorTimeout: null,
};
async function appLoop() {
if (AppConfig === null) {
if (state.config === null) {
return;
}
@@ -13,50 +18,10 @@
fetchFiles();
}
function updateUI() {
if (AppConfig.Modes.Readonly) {
UI.dropzone.style.display = "none";
} else {
UI.dropzone.style.display = "block";
}
if (AppConfig.Modes.Sinkhole) {
UI.fileList.style.display = "none";
UI.sinkholeModeInfo.style.display = "block";
} else {
UI.fileList.style.display = "block";
UI.sinkholeModeInfo.style.display = "none";
}
}
async function initApp() {
UI.currentFileName = document.getElementById("currentFileName");
UI.dropzone = document.getElementById("dropzone");
UI.fileInput = document.getElementById("fileInput");
UI.fileList = document.getElementById("file-list");
UI.overallProgress = document.getElementById("overallProgress");
UI.overallStatus = document.getElementById("overallStatus");
UI.overallProgressContainer = document.getElementById(
"overallProgressContainer"
);
UI.sinkholeModeInfo = document.getElementById("sinkholeModeInfo");
UI.dropzone.addEventListener("click", () => UI.fileInput.click());
UI.fileInput.addEventListener("change", () => {
if (UI.fileInput.files.length > 0) uploadFiles(UI.fileInput.files);
});
UI.dropzone.addEventListener("dragover", (e) => {
e.preventDefault();
UI.dropzone.style.borderColor = "#0fff50";
});
UI.dropzone.addEventListener("dragleave", () => {
UI.dropzone.style.borderColor = "#888";
});
UI.dropzone.addEventListener("drop", (e) => {
e.preventDefault();
UI.dropzone.style.borderColor = "#888";
if (e.dataTransfer.files.length > 0) uploadFiles(e.dataTransfer.files);
});
addUIElementsToBody();
getUIElements();
addEventListeners();
await loadAppConfig();
appLoop();
@@ -71,76 +36,190 @@
if (!res.ok) {
console.error("HTTP error:", res.status);
}
AppConfig = await res.json();
state.config = await res.json();
} catch (err) {
console.error("Failed to load config:", err);
AppConfig = null;
state.config = null;
}
}
async function fetchFiles() {
if (AppConfig.Modes.Sinkhole) {
UI.fileList.innerHTML = "";
if (state.config.Modes.Sinkhole) {
clearFileList();
return;
}
try {
const res = await fetch(AppConfig.Endpoints.Files, { cache: "no-store" });
if (!res.ok) throw new Error("HTTP " + res.status);
const files = await res.json();
if (!UI.fileList) return;
UI.fileList.innerHTML = "";
files.forEach((file) => {
const size = humanReadableSize(file.Size);
const li = document.createElement("li");
const downloadLink = document.createElement("a");
downloadLink.className = "download-link";
downloadLink.href = AppConfig.Endpoints.FilesGet.replace(
":filename",
encodeURIComponent(file.Name)
);
downloadLink.textContent = `${file.Name} (${size})`;
li.appendChild(downloadLink);
if (!AppConfig.Modes.Readonly) {
const deleteLink = document.createElement("a");
deleteLink.className = "delete-link";
deleteLink.href = "#";
deleteLink.textContent = " [Delete]";
deleteLink.title = "Delete file";
deleteLink.addEventListener("click", async (e) => {
e.preventDefault();
if (!confirm(`Do you really want to delete "${file.Name}"?`))
return;
try {
const r = await fetch(
AppConfig.Endpoints.FilesDelete.replace(
":filename",
encodeURIComponent(file.Name)
),
{ method: "GET" }
);
if (!r.ok) throw new Error("Delete failed " + r.status);
fetchFiles();
} catch (err) {
console.error(err);
}
});
li.appendChild(deleteLink);
}
UI.fileList.appendChild(li);
});
const files = await fetchFileList();
state.files = {};
clearFileList();
renderFileList(files);
} catch (err) {
console.error("fetchFiles failed:", err);
}
}
async function fetchFileList() {
const res = await fetch(state.config.Endpoints.Files, {
cache: "no-store",
});
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
}
async function handleDeleteClick(event, file) {
event.preventDefault();
if (!confirm(`Do you really want to delete "${file.Name}"?`)) return;
try {
const res = await fetch(
state.config.Endpoints.FilesDelete.replace(
":filename",
encodeURIComponent(file.Name)
),
{ method: "GET" }
);
if (res.ok) {
showSuccess("File deleted: " + file.Name);
} else {
showError("Delete failed");
}
fetchFiles();
} catch (err) {
showError("Delete failed");
}
}
function addEventListeners() {
state.ui.dropzone.addEventListener("click", () =>
state.ui.fileInput.click()
);
state.ui.fileInput.addEventListener("change", () => {
if (state.ui.fileInput.files.length > 0)
uploadFiles(state.ui.fileInput.files);
});
state.ui.dropzone.addEventListener("dragover", (e) => {
e.preventDefault();
state.ui.dropzone.style.borderColor = "#0fff50";
});
state.ui.dropzone.addEventListener("dragleave", () => {
state.ui.dropzone.style.borderColor = "#888";
});
state.ui.dropzone.addEventListener("drop", (e) => {
e.preventDefault();
state.ui.dropzone.style.borderColor = "#888";
if (e.dataTransfer.files.length > 0) uploadFiles(e.dataTransfer.files);
});
}
function addUIElementsToBody() {
document.body.innerHTML = "";
const aLogo = document.createElement("a");
aLogo.href = "/";
aLogo.className = "logo";
const h1Logo = document.createElement("h1");
h1Logo.textContent = "Ablage";
aLogo.appendChild(h1Logo);
document.body.appendChild(aLogo);
const divDropzone = document.createElement("div");
divDropzone.className = "dropzone";
divDropzone.id = "dropzone";
divDropzone.innerHTML = DEFAULT_DROPZONE_TEXT;
divDropzone.style.display = "none";
document.body.appendChild(divDropzone);
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.id = "fileInput";
fileInput.name = "uploadfile";
fileInput.multiple = true;
fileInput.style.display = "none";
document.body.appendChild(fileInput);
const divOverallProgressContainer = document.createElement("div");
divOverallProgressContainer.id = "overallProgressContainer";
divOverallProgressContainer.style.display = "none";
const divCurrentFileName = document.createElement("div");
divCurrentFileName.id = "currentFileName";
const progressOverall = document.createElement("progress");
progressOverall.id = "overallProgress";
progressOverall.value = 0;
progressOverall.max = 100;
const divOverallStatus = document.createElement("div");
divOverallStatus.id = "overallStatus";
divOverallStatus.className = "status";
divOverallProgressContainer.appendChild(divCurrentFileName);
divOverallProgressContainer.appendChild(progressOverall);
divOverallProgressContainer.appendChild(divOverallStatus);
document.body.appendChild(divOverallProgressContainer);
const ulFileList = document.createElement("ul");
ulFileList.id = "file-list";
document.body.appendChild(ulFileList);
const divSinkholeModeInfo = document.createElement("div");
divSinkholeModeInfo.id = "sinkholeModeInfo";
divSinkholeModeInfo.className = "sinkholeModeInfo";
divSinkholeModeInfo.style.display = "none";
divSinkholeModeInfo.textContent =
"- Sinkhole mode enabled, no files will get listed -";
document.body.appendChild(divSinkholeModeInfo);
}
function clearFileList() {
if (state.ui.fileList) state.ui.fileList.innerHTML = "";
}
function createDownloadLink(file) {
const size = humanReadableSize(file.Size);
const link = document.createElement("a");
link.className = "download-link";
link.href = state.config.Endpoints.FilesGet.replace(
":filename",
encodeURIComponent(file.Name)
);
link.textContent = `${file.Name} (${size})`;
return link;
}
function createDeleteLink(file) {
const link = document.createElement("a");
link.className = "delete-link";
link.href = "#";
link.textContent = " [Delete]";
link.title = "Delete file";
link.addEventListener("click", (e) => handleDeleteClick(e, file));
return link;
}
function finishUpload(success) {
state.ui.overallProgressContainer.style.display = "none";
state.ui.overallProgress.value = 0;
state.ui.overallStatus.textContent = "";
state.ui.currentFileName.textContent = "";
fetchFiles();
if (success) {
showSuccess("Upload successful");
}
}
function getUIElements() {
state.ui.currentFileName = document.getElementById("currentFileName");
state.ui.dropzone = document.getElementById("dropzone");
state.ui.fileInput = document.getElementById("fileInput");
state.ui.fileList = document.getElementById("file-list");
state.ui.overallProgress = document.getElementById("overallProgress");
state.ui.overallStatus = document.getElementById("overallStatus");
state.ui.overallProgressContainer = document.getElementById(
"overallProgressContainer"
);
state.ui.sinkholeModeInfo = document.getElementById("sinkholeModeInfo");
}
function humanReadableSize(bytes) {
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
@@ -159,85 +238,215 @@
return (bytesPerSec / (1024 * 1024)).toFixed(2) + " MB/s";
}
function initUIProgress() {
state.ui.overallProgressContainer.style.display = "block";
state.ui.overallProgress.value = 0;
state.ui.overallStatus.textContent = "";
state.ui.currentFileName.textContent = "";
}
function renderFileList(files) {
if (!state.ui.fileList) return;
files.forEach((file) => {
state.files[file.Name] = true;
const li = document.createElement("li");
li.appendChild(createDownloadLink(file));
if (!state.config.Modes.Readonly) {
li.appendChild(createDeleteLink(file));
}
state.ui.fileList.appendChild(li);
});
}
function sanitizeFilename(dirtyFilename) {
if (!dirtyFilename || dirtyFilename.trim() === "") {
return "upload.bin";
}
const filenameWithoutPath = dirtyFilename.split(/[\\/]/).pop();
const lastDot = filenameWithoutPath.lastIndexOf(".");
const extension = lastDot !== -1 ? filenameWithoutPath.slice(lastDot) : "";
let nameOnly =
lastDot !== -1
? filenameWithoutPath.slice(0, lastDot)
: filenameWithoutPath;
const charMap = {
Ä: "Ae",
ä: "ae",
Ö: "Oe",
ö: "oe",
Ü: "Ue",
ü: "ue",
ß: "ss",
};
let cleanedFilename = nameOnly.replace(/./g, (char) => {
if (charMap[char]) {
return charMap[char];
}
if (char === " ") {
return "_";
}
return char;
});
cleanedFilename = cleanedFilename.replace(/[^a-zA-Z0-9._-]+/g, "_");
while (cleanedFilename.includes("__")) {
cleanedFilename = cleanedFilename.replace(/__+/g, "_");
}
cleanedFilename = cleanedFilename.replace(/^_+|_+$/g, "");
const MAX_LEN = 128;
if (cleanedFilename.length > MAX_LEN) {
cleanedFilename = cleanedFilename.slice(0, MAX_LEN);
}
return cleanedFilename + extension;
}
function showError(msg) {
showMessage(msg, "error", 2000);
}
function showMessage(msg, type, duration = 2000) {
state.ui.dropzone.innerHTML = msg;
state.ui.dropzone.classList.add(type);
if (state.errorTimeout) clearTimeout(state.errorTimeout);
state.errorTimeout = setTimeout(() => {
state.ui.dropzone.innerHTML = DEFAULT_DROPZONE_TEXT;
state.ui.dropzone.classList.remove(type);
state.errorTimeout = null;
}, duration);
}
function showSuccess(msg) {
showMessage(msg, "success", 1500);
}
function updateUI() {
if (state.config.Modes.Readonly) {
state.ui.dropzone.style.display = "none";
} else {
state.ui.dropzone.style.display = "block";
}
if (state.config.Modes.Sinkhole) {
state.ui.fileList.style.display = "none";
state.ui.sinkholeModeInfo.style.display = "block";
} else {
state.ui.fileList.style.display = "block";
state.ui.sinkholeModeInfo.style.display = "none";
}
}
function uploadFiles(fileListLike) {
const files = Array.from(fileListLike);
if (files.length === 0) return;
UI.overallProgressContainer.style.display = "block";
UI.overallProgress.value = 0;
UI.overallStatus.textContent = "";
UI.currentFileName.textContent = "";
if (!validateFiles(files)) return;
initUIProgress();
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
let uploadedBytes = 0;
const t0 = Date.now();
let idx = 0;
let currentIndex = 0;
const startTime = Date.now();
let allSuccessful = true;
const uploadNext = () => {
if (idx >= files.length) {
UI.overallProgressContainer.style.display = "none";
UI.overallProgress.value = 0;
UI.overallStatus.textContent = "";
UI.currentFileName.textContent = "";
fetchFiles();
function uploadNext() {
if (currentIndex >= files.length) {
finishUpload(allSuccessful);
return;
}
const file = files[idx];
UI.currentFileName.textContent = file.name;
const file = files[currentIndex];
state.ui.currentFileName.textContent = file.name;
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append("uploadfile", file);
xhr.upload.addEventListener("progress", (e) => {
if (!e.lengthComputable) return;
const totalUploaded = uploadedBytes + e.loaded;
const percent = (totalUploaded / totalSize) * 100;
UI.overallProgress.value = percent;
const elapsed = (Date.now() - t0) / 1000;
const speed = totalUploaded / elapsed;
const speedStr = humanReadableSpeed(speed);
const remainingBytes = totalSize - totalUploaded;
const etaSec = speed > 0 ? remainingBytes / speed : Infinity;
const min = Math.floor(etaSec / 60);
const sec = Math.floor(etaSec % 60);
UI.overallStatus.textContent =
`${percent.toFixed(1)}% (${(totalSize / 1024 / 1024).toFixed(
1
)} MB total) — ` +
`Speed: ${speedStr}, Est. time left: ${
isFinite(etaSec) ? `${min}m ${sec}s` : "calculating…"
}`;
if (e.lengthComputable) {
updateProgressUI(uploadedBytes + e.loaded, totalSize, startTime);
}
});
xhr.addEventListener("load", () => {
if (xhr.status === 200) {
uploadedBytes += file.size;
} else if (xhr.status === 409) {
showError("File already exists: " + file.name);
allSuccessful = false;
} else {
console.error("Upload failed with status", xhr.status);
showError("Upload failed: " + file.name);
allSuccessful = false;
}
idx++;
currentIndex++;
uploadNext();
});
xhr.addEventListener("error", () => {
console.error("Network/server error during upload.");
idx++;
showError("Network or server error during upload.");
allSuccessful = false;
currentIndex++;
uploadNext();
});
xhr.open("POST", AppConfig.Endpoints.Upload);
xhr.open("POST", state.config.Endpoints.Upload);
xhr.send(form);
};
}
fetchFiles();
uploadNext();
}
function updateProgressUI(totalUploaded, totalSize, startTime) {
const percent = (totalUploaded / totalSize) * 100;
state.ui.overallProgress.value = percent;
const elapsed = (Date.now() - startTime) / 1000;
const speed = totalUploaded / elapsed;
const speedStr = humanReadableSpeed(speed);
const remainingBytes = totalSize - totalUploaded;
const etaSec = speed > 0 ? remainingBytes / speed : Infinity;
const min = Math.floor(etaSec / 60);
const sec = Math.floor(etaSec % 60);
state.ui.overallStatus.textContent =
`${percent.toFixed(1)}% (${(totalSize / 1024 / 1024).toFixed(
1
)} MB total) — ` +
`Speed: ${speedStr}, Est. time left: ${
isFinite(etaSec) ? `${min}m ${sec}s` : "calculating…"
}`;
}
function validateFiles(files) {
for (const f of files) {
const safeName = sanitizeFilename(f.name);
if (safeName === ".upload") {
showError("Invalid filename: .upload");
return false;
}
if (safeName in state.files) {
showError("File already exists: " + f.name);
return false;
}
}
return true;
}
document.addEventListener("DOMContentLoaded", initApp);
})();

View File

@@ -9,7 +9,7 @@ body {
}
/* Dropzone */
#dropzone {
.dropzone {
border: 2px dashed #888;
border-radius: 10px;
color: #fefefe;
@@ -20,10 +20,22 @@ body {
transition: all 0.3s ease;
}
#dropzone:hover {
.dropzone:hover {
color: #0fff50;
}
.dropzone.error {
border: 2px solid #ff4d4d;
color: #ff4d4d;
font-weight: bold;
}
.dropzone.success {
border: 2px solid #0fff50;
color: #0fff50;
font-weight: bold;
}
/* File list */
#file-list {
list-style: none;

View File

@@ -253,9 +253,9 @@ func httpPostUpload(w http.ResponseWriter, r *http.Request, ps httprouter.Params
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
return
}
defer uploadFile.Close()
bytesWritten, err := io.Copy(uploadFile, part)
uploadFile.Close()
if err != nil {
_ = os.Remove(pathToFileInUploadFolder)
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)

2
build.sh Executable file
View File

@@ -0,0 +1,2 @@
GOOS=windows GOARCH=amd64 go build -o build/ablage-windows-amd64.exe .
GOOS=linux GOARCH=amd64 go build -o build/ablage-linux-amd64 .

66
config/banner.go Normal file
View File

@@ -0,0 +1,66 @@
package config
import (
"fmt"
"math"
"strings"
"unicode/utf8"
)
func PrintStartupBanner() {
fmt.Println(getBanner() + "\n")
fmt.Printf("Basic Auth mode: %v\n", GetBasicAuthMode())
fmt.Printf("HTTP mode : %v\n", GetHttpMode())
fmt.Printf("Readonly mode : %v\n", GetReadonlyMode())
fmt.Printf("Sinkhole mode : %v\n", GetSinkholeMode())
fmt.Printf("Path : %s\n", GetPathDataFolder())
if GetBasicAuthMode() {
fmt.Printf("Username : %s\n", GetBasicAuthUsername())
fmt.Printf("Password : %s\n", GetBasicAuthPassword())
}
if GetHttpMode() {
fmt.Printf("Listening on : http://0.0.0.0:%d\n", GetPortToListenOn())
} else {
if pathTLSCertFile == "" || pathTLSKeyFile == "" {
fmt.Printf("TLS cert : self-signed\n")
fmt.Printf("TLS key : self-signed\n")
} else {
fmt.Printf("TLS cert : %s\n", pathTLSCertFile)
fmt.Printf("TLS key : %s\n", pathTLSKeyFile)
}
fmt.Printf("Listening on : https://0.0.0.0:%d\n", GetPortToListenOn())
}
fmt.Println("")
}
func centerTextWithWhitespaces(text string, maxWidth int) string {
textLength := utf8.RuneCountInString(text)
if textLength >= maxWidth {
return text
}
totalPadding := maxWidth - textLength
leftPadding := int(math.Ceil((float64(totalPadding) / 2.0)))
rightPadding := totalPadding - leftPadding
return strings.Repeat(" ", leftPadding) + text + strings.Repeat(" ", rightPadding)
}
func getBanner() string {
return strings.Join(
[]string{
"┌──────────────────────────────────────┐",
"│ Ablage │",
fmt.Sprintf(
"│%s│",
centerTextWithWhitespaces("v"+VersionString, 38),
),
"└──────────────────────────────────────┘",
},
"\n",
)
}

View File

@@ -7,10 +7,10 @@ import (
const DefaultBasicAuthUsername string = "ablage"
const DefaultNameDataFolder string = "data"
const DefaultNameUploadFolder string = "upload"
const DefaultNameUploadFolder string = ".upload"
const DefaultPortToListenOn int = 13692
const LengthOfRandomBasicAuthPassword int = 16
const VersionString string = "1.0"
const VersionString string = "1.1"
var randomBasicAuthPassword string = generateRandomPassword()
@@ -33,36 +33,3 @@ func Init() {
os.Exit(1)
}
}
func PrintStartupBanner() {
fmt.Println("****************************************")
fmt.Println("* Ablage *")
fmt.Println("****************************************")
fmt.Printf("Version : %s\n", VersionString)
fmt.Printf("Basic Auth mode: %v\n", GetBasicAuthMode())
fmt.Printf("HTTP mode : %v\n", GetHttpMode())
fmt.Printf("Readonly mode : %v\n", GetReadonlyMode())
fmt.Printf("Sinkhole mode : %v\n", GetSinkholeMode())
fmt.Printf("Path : %s\n", GetPathDataFolder())
if GetBasicAuthMode() {
fmt.Printf("Username : %s\n", GetBasicAuthUsername())
fmt.Printf("Password : %s\n", GetBasicAuthPassword())
}
if GetHttpMode() {
fmt.Printf("Listening on : http://0.0.0.0:%d\n", GetPortToListenOn())
} else {
if pathTLSCertFile == "" || pathTLSKeyFile == "" {
fmt.Printf("TLS cert : self-signed\n")
fmt.Printf("TLS key : self-signed\n")
} else {
fmt.Printf("TLS cert : %s\n", pathTLSCertFile)
fmt.Printf("TLS key : %s\n", pathTLSKeyFile)
}
fmt.Printf("Listening on : https://0.0.0.0:%d\n", GetPortToListenOn())
}
fmt.Println("")
}