first commit
This commit is contained in:
122
app/app.go
Normal file
122
app/app.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"git.0x0001f346.de/andreas/ablage/config"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
//go:embed assets/index.html
|
||||
var assetIndexHTML []byte
|
||||
|
||||
//go:embed assets/favicon.svg
|
||||
var assetFaviconSVG []byte
|
||||
|
||||
//go:embed assets/script.js
|
||||
var assetScriptJS []byte
|
||||
|
||||
//go:embed assets/style.css
|
||||
var assetStyleCSS []byte
|
||||
|
||||
func Init() {
|
||||
router := httprouter.New()
|
||||
|
||||
router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
router.MethodNotAllowed = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
router.GET(httpPathRoot, httpGetRoot)
|
||||
router.GET(httpPathConfig, httpGetConfig)
|
||||
router.GET(httpPathFaviconICO, httpGetFaviconICO)
|
||||
router.GET(httpPathFaviconSVG, httpGetFaviconSVG)
|
||||
router.GET(httpPathFiles, httpGetFiles)
|
||||
router.GET(httpPathFilesDeleteFilename, httpGetFilesDeleteFilename)
|
||||
router.GET(httpPathFilesGetFilename, httpGetFilesGetFilename)
|
||||
router.GET(httpPathScriptJS, httpGetScriptJS)
|
||||
router.GET(httpPathStyleCSS, httpGetStyleCSS)
|
||||
router.POST(httpPathUpload, httpPostUpload)
|
||||
|
||||
var handler http.Handler = router
|
||||
|
||||
if config.GetBasicAuthMode() {
|
||||
handler = basicAuthMiddleware(handler, config.GetBasicAuthUsername(), config.GetBasicAuthPassword())
|
||||
}
|
||||
|
||||
if config.GetHttpMode() {
|
||||
config.PrintStartupBanner()
|
||||
err := http.ListenAndServe(fmt.Sprintf(":%d", config.GetPortToListenOn()), handler)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Ablage exited with error:\n%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
tlsCert, err := tls.X509KeyPair(config.GetTLSCertificate(), config.GetTLSKey())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Faild to parse PEM encoded public/private key pair:\n%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", config.GetPortToListenOn()),
|
||||
ErrorLog: log.New(io.Discard, "", 0),
|
||||
Handler: handler,
|
||||
TLSConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{tlsCert},
|
||||
},
|
||||
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
|
||||
}
|
||||
|
||||
config.PrintStartupBanner()
|
||||
|
||||
err = server.ListenAndServeTLS("", "")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Ablage exited with error:\n%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func getClientIP(r *http.Request) string {
|
||||
if r.Header.Get("X-Forwarded-For") != "" {
|
||||
return r.Header.Get("X-Forwarded-For")
|
||||
}
|
||||
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func isBrowserDisplayableFileType(extension string) bool {
|
||||
browserDisplayableFileTypes := map[string]struct{}{
|
||||
// audio
|
||||
".mp3": {}, ".ogg": {}, ".wav": {},
|
||||
// pictures
|
||||
".bmp": {}, ".gif": {}, ".ico": {}, ".jpg": {}, ".jpeg": {},
|
||||
".png": {}, ".svg": {}, ".webp": {},
|
||||
// programming
|
||||
".bat": {}, ".cmd": {}, ".c": {}, ".cpp": {}, ".go": {},
|
||||
".h": {}, ".hpp": {}, ".java": {}, ".kt": {}, ".lua": {},
|
||||
".php": {}, ".pl": {}, ".ps1": {}, ".py": {}, ".rb": {},
|
||||
".rs": {}, ".sh": {}, ".swift": {}, ".ts": {}, ".tsx": {},
|
||||
// text
|
||||
".csv": {}, ".log": {}, ".md": {}, ".pdf": {}, ".txt": {},
|
||||
// video
|
||||
".mp4": {}, ".webm": {},
|
||||
// web
|
||||
".css": {}, ".js": {}, ".html": {},
|
||||
}
|
||||
|
||||
_, isBrowserDisplayableFileType := browserDisplayableFileTypes[extension]
|
||||
|
||||
return isBrowserDisplayableFileType
|
||||
}
|
65
app/assets/favicon.svg
Normal file
65
app/assets/favicon.svg
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<polygon style="fill:#D0FBFD;" points="449.362,175.932 449.362,438.468 394.894,460.255 416.681,175.932 "/>
|
||||
<path style="fill:#F0FEFF;" d="M314.281,175.932v51.2H62.638V503.83H384l32.681-54.468v-273.43H314.281z"/>
|
||||
<g>
|
||||
<polygon style="fill:#50D1DD;" points="384,438.468 384,503.83 449.362,438.468 "/>
|
||||
<rect x="62.638" y="175.932" style="fill:#50D1DD;" width="51.2" height="51.2"/>
|
||||
</g>
|
||||
<rect x="146.519" y="175.932" style="fill:#FFEAB5;" width="51.2" height="51.2"/>
|
||||
<rect x="230.4" y="175.932" style="fill:#D0FBFD;" width="51.2" height="51.2"/>
|
||||
<rect x="62.638" y="92.051" style="fill:#FF9269;" width="51.2" height="51.2"/>
|
||||
<rect x="146.519" y="92.051" style="fill:#D0FBFD;" width="51.2" height="51.2"/>
|
||||
<rect x="230.4" y="92.051" style="fill:#FFB082;" width="51.2" height="51.2"/>
|
||||
<rect x="314.281" y="92.051" style="fill:#FFEAB5;" width="51.2" height="51.2"/>
|
||||
<rect x="398.162" y="8.17" style="fill:#D0FBFD;" width="51.2" height="51.2"/>
|
||||
<rect x="146.519" y="259.813" style="fill:#FFDB8A;" width="51.2" height="51.2"/>
|
||||
<rect x="230.4" y="259.813" style="fill:#FFEAB5;" width="51.2" height="51.2"/>
|
||||
<rect x="314.281" y="343.694" style="fill:#FF9269;" width="51.2" height="51.2"/>
|
||||
<rect x="398.162" y="92.051" style="fill:#FFB082;" width="51.2" height="51.2"/>
|
||||
<rect x="146.519" y="8.17" style="fill:#FFEAB5;" width="51.2" height="51.2"/>
|
||||
<rect x="230.4" y="8.17" style="fill:#FFDB8A;" width="51.2" height="51.2"/>
|
||||
<path d="M62.638,151.421h51.2c4.512,0,8.17-3.658,8.17-8.17v-51.2c0-4.512-3.658-8.17-8.17-8.17h-51.2
|
||||
c-4.512,0-8.17,3.658-8.17,8.17v51.2C54.468,147.763,58.126,151.421,62.638,151.421z M70.809,100.221h34.86v34.86h-34.86V100.221z"
|
||||
/>
|
||||
<path d="M146.519,151.421h51.2c4.512,0,8.17-3.658,8.17-8.17v-51.2c0-4.512-3.658-8.17-8.17-8.17h-51.2
|
||||
c-4.512,0-8.17,3.658-8.17,8.17v51.2C138.349,147.763,142.007,151.421,146.519,151.421z M154.689,100.221h34.86v34.86h-34.86
|
||||
V100.221z"/>
|
||||
<path d="M197.719,319.183c4.512,0,8.17-3.658,8.17-8.17v-51.2c0-4.512-3.658-8.17-8.17-8.17h-51.2c-4.512,0-8.17,3.658-8.17,8.17
|
||||
v51.2c0,4.512,3.658,8.17,8.17,8.17H197.719z M154.689,267.983h34.86v34.86h-34.86V267.983z"/>
|
||||
<path d="M281.6,319.183c4.512,0,8.17-3.658,8.17-8.17v-51.2c0-4.512-3.658-8.17-8.17-8.17h-51.2c-4.512,0-8.17,3.658-8.17,8.17v51.2
|
||||
c0,4.512,3.658,8.17,8.17,8.17H281.6z M238.57,267.983h34.86v34.86h-34.86V267.983z"/>
|
||||
<path d="M365.481,403.064c4.512,0,8.17-3.658,8.17-8.17v-51.2c0-4.512-3.658-8.17-8.17-8.17h-51.2c-4.512,0-8.17,3.658-8.17,8.17
|
||||
v51.2c0,4.512,3.658,8.17,8.17,8.17H365.481z M322.451,351.864h34.86v34.86h-34.86V351.864z"/>
|
||||
<path d="M230.4,151.421h51.2c4.512,0,8.17-3.658,8.17-8.17v-51.2c0-4.512-3.658-8.17-8.17-8.17h-51.2c-4.512,0-8.17,3.658-8.17,8.17
|
||||
v51.2C222.23,147.763,225.888,151.421,230.4,151.421z M238.57,100.221h34.86v34.86h-34.86V100.221z"/>
|
||||
<path d="M314.281,151.421h51.2c4.512,0,8.17-3.658,8.17-8.17v-51.2c0-4.512-3.658-8.17-8.17-8.17h-51.2
|
||||
c-4.512,0-8.17,3.658-8.17,8.17v51.2C306.111,147.763,309.769,151.421,314.281,151.421z M322.451,100.221h34.86v34.86h-34.86
|
||||
V100.221z"/>
|
||||
<path d="M449.362,83.881h-51.2c-4.512,0-8.17,3.658-8.17,8.17v51.2c0,4.512,3.658,8.17,8.17,8.17h51.2c4.512,0,8.17-3.658,8.17-8.17
|
||||
v-51.2C457.532,87.539,453.874,83.881,449.362,83.881z M441.191,135.081h-34.86v-34.86h34.86V135.081z"/>
|
||||
<path d="M146.519,67.54h51.2c4.512,0,8.17-3.658,8.17-8.17V8.17c0-4.512-3.658-8.17-8.17-8.17h-51.2c-4.512,0-8.17,3.658-8.17,8.17
|
||||
v51.2C138.349,63.882,142.007,67.54,146.519,67.54z M154.689,16.34h34.86V51.2h-34.86V16.34z"/>
|
||||
<path d="M230.4,67.54h51.2c4.512,0,8.17-3.658,8.17-8.17V8.17c0-4.512-3.658-8.17-8.17-8.17h-51.2c-4.512,0-8.17,3.658-8.17,8.17
|
||||
v51.2C222.23,63.882,225.888,67.54,230.4,67.54z M238.57,16.34h34.86V51.2h-34.86V16.34z"/>
|
||||
<path d="M449.362,0h-51.2c-4.512,0-8.17,3.658-8.17,8.17v51.2c0,4.512,3.658,8.17,8.17,8.17h51.2c4.512,0,8.17-3.658,8.17-8.17V8.17
|
||||
C457.532,3.658,453.874,0,449.362,0z M441.191,51.2h-34.86V16.34h34.86V51.2z"/>
|
||||
<path d="M449.362,167.762H314.281c-4.512,0-8.17,3.658-8.17,8.17v43.03h-16.34v-43.03c0-4.512-3.658-8.17-8.17-8.17h-51.2
|
||||
c-4.512,0-8.17,3.658-8.17,8.17v43.03h-16.34v-43.03c0-4.512-3.658-8.17-8.17-8.17h-51.2c-4.512,0-8.17,3.658-8.17,8.17v43.03
|
||||
h-16.34v-43.03c0-4.512-3.658-8.17-8.17-8.17h-51.2c-4.512,0-8.17,3.658-8.17,8.17v135.081c0,4.512,3.658,8.17,8.17,8.17
|
||||
s8.17-3.658,8.17-8.17v-75.711h243.472c4.512,0,8.17-3.658,8.17-8.17v-43.03h118.74v246.196H384c-4.512,0-8.17,3.658-8.17,8.17
|
||||
v57.191H70.809V343.694c0-4.512-3.658-8.17-8.17-8.17s-8.17,3.658-8.17,8.17V503.83c0,4.512,3.658,8.17,8.17,8.17H384
|
||||
c0.273,0,0.546-0.014,0.816-0.041c0.19-0.019,0.375-0.052,0.561-0.084c0.077-0.013,0.156-0.02,0.233-0.035
|
||||
c0.221-0.045,0.436-0.102,0.65-0.163c0.041-0.012,0.083-0.02,0.124-0.032c0.217-0.066,0.427-0.145,0.636-0.228
|
||||
c0.038-0.015,0.077-0.026,0.115-0.042c0.195-0.082,0.385-0.174,0.572-0.27c0.05-0.025,0.102-0.047,0.153-0.073
|
||||
c0.169-0.09,0.33-0.192,0.491-0.294c0.064-0.04,0.132-0.076,0.196-0.119c0.145-0.098,0.282-0.205,0.42-0.312
|
||||
c0.073-0.057,0.15-0.107,0.221-0.166c0.163-0.134,0.318-0.279,0.471-0.426c0.039-0.037,0.082-0.07,0.119-0.108l65.359-65.361
|
||||
c0.188-0.188,0.368-0.387,0.537-0.594c0.044-0.053,0.081-0.11,0.123-0.164c0.122-0.156,0.243-0.313,0.353-0.477
|
||||
c0.035-0.051,0.063-0.106,0.096-0.158c0.11-0.173,0.219-0.348,0.317-0.529c0.022-0.039,0.038-0.082,0.059-0.122
|
||||
c0.101-0.197,0.199-0.397,0.284-0.601c0.012-0.029,0.021-0.059,0.033-0.088c0.087-0.217,0.169-0.437,0.237-0.662
|
||||
c0.01-0.033,0.016-0.066,0.025-0.099c0.064-0.221,0.123-0.446,0.169-0.674c0.014-0.069,0.02-0.139,0.032-0.208
|
||||
c0.034-0.194,0.068-0.387,0.087-0.585c0.027-0.27,0.041-0.542,0.041-0.816V175.932C457.532,171.42,453.874,167.762,449.362,167.762z
|
||||
M238.57,184.102h34.86v34.86h-34.86V184.102z M154.689,184.102h34.86v34.86h-34.86V184.102z M70.809,184.102h34.86v34.86h-34.86
|
||||
V184.102z M429.638,446.638l-37.468,37.468v-37.468H429.638z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 5.8 KiB |
40
app/assets/index.html
Normal file
40
app/assets/index.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<title>Ablage</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" sizes="any" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
243
app/assets/script.js
Normal file
243
app/assets/script.js
Normal file
@@ -0,0 +1,243 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
let AppConfig = null;
|
||||
let UI = {};
|
||||
|
||||
async function appLoop() {
|
||||
if (AppConfig === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateUI();
|
||||
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);
|
||||
});
|
||||
|
||||
await loadAppConfig();
|
||||
appLoop();
|
||||
|
||||
setInterval(appLoop, 5 * 1000);
|
||||
setInterval(loadAppConfig, 60 * 1000);
|
||||
}
|
||||
|
||||
async function loadAppConfig() {
|
||||
try {
|
||||
const res = await fetch("/config/", { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
console.error("HTTP error:", res.status);
|
||||
}
|
||||
AppConfig = await res.json();
|
||||
} catch (err) {
|
||||
console.error("Failed to load config:", err);
|
||||
AppConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFiles() {
|
||||
if (AppConfig.Modes.Sinkhole) {
|
||||
UI.fileList.innerHTML = "";
|
||||
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);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("fetchFiles failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function humanReadableSize(bytes) {
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
while (bytes >= 1024 && i < units.length - 1) {
|
||||
bytes /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${bytes.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function humanReadableSpeed(bytesPerSec) {
|
||||
if (!isFinite(bytesPerSec) || bytesPerSec <= 0) return "—";
|
||||
if (bytesPerSec < 1024) return bytesPerSec.toFixed(0) + " B/s";
|
||||
if (bytesPerSec < 1024 * 1024)
|
||||
return (bytesPerSec / 1024).toFixed(1) + " KB/s";
|
||||
return (bytesPerSec / (1024 * 1024)).toFixed(2) + " MB/s";
|
||||
}
|
||||
|
||||
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 = "";
|
||||
|
||||
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||
let uploadedBytes = 0;
|
||||
const t0 = Date.now();
|
||||
let idx = 0;
|
||||
|
||||
const uploadNext = () => {
|
||||
if (idx >= files.length) {
|
||||
UI.overallProgressContainer.style.display = "none";
|
||||
UI.overallProgress.value = 0;
|
||||
UI.overallStatus.textContent = "";
|
||||
UI.currentFileName.textContent = "";
|
||||
fetchFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[idx];
|
||||
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…"
|
||||
}`;
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status === 200) {
|
||||
uploadedBytes += file.size;
|
||||
} else {
|
||||
console.error("Upload failed with status", xhr.status);
|
||||
}
|
||||
idx++;
|
||||
uploadNext();
|
||||
});
|
||||
|
||||
xhr.addEventListener("error", () => {
|
||||
console.error("Network/server error during upload.");
|
||||
idx++;
|
||||
uploadNext();
|
||||
});
|
||||
|
||||
xhr.open("POST", AppConfig.Endpoints.Upload);
|
||||
xhr.send(form);
|
||||
};
|
||||
|
||||
fetchFiles();
|
||||
uploadNext();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initApp);
|
||||
})();
|
130
app/assets/style.css
Normal file
130
app/assets/style.css
Normal file
@@ -0,0 +1,130 @@
|
||||
/* Base */
|
||||
body {
|
||||
background-color: #0d1117;
|
||||
color: #fefefe;
|
||||
font-family: monospace, monospace;
|
||||
margin: 20px auto;
|
||||
max-width: 800px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
/* Dropzone */
|
||||
#dropzone {
|
||||
border: 2px dashed #888;
|
||||
border-radius: 10px;
|
||||
color: #fefefe;
|
||||
cursor: pointer;
|
||||
margin-bottom: 20px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#dropzone:hover {
|
||||
color: #0fff50;
|
||||
}
|
||||
|
||||
/* File list */
|
||||
#file-list {
|
||||
list-style: none;
|
||||
margin-top: 20px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
#file-list li {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sinkholeModeInfo {
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.delete-link {
|
||||
color: #fefefe;
|
||||
font-size: 14px;
|
||||
margin-left: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.delete-link:hover {
|
||||
color: #0fff50;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
color: #fefefe;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.download-link:hover {
|
||||
color: #0fff50;
|
||||
}
|
||||
|
||||
.logo {
|
||||
color: #0fff50;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
#currentFileName {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#overallProgress {
|
||||
accent-color: #0fff50;
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#overallProgress::-moz-progress-bar {
|
||||
background-color: #0fff50;
|
||||
}
|
||||
|
||||
#overallProgress::-webkit-progress-bar {
|
||||
background-color: #333;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#overallProgress::-webkit-progress-value {
|
||||
background-color: #0fff50;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#overallProgressContainer {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #fefefe;
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
#dropzone {
|
||||
font-size: 14px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.delete-link {
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
15
app/auth.go
Normal file
15
app/auth.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package app
|
||||
|
||||
import "net/http"
|
||||
|
||||
func basicAuthMiddleware(handler http.Handler, username, password string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != username || pass != password {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
278
app/http.go
Normal file
278
app/http.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.0x0001f346.de/andreas/ablage/config"
|
||||
"git.0x0001f346.de/andreas/ablage/filesystem"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
const httpPathRoot string = "/"
|
||||
const httpPathConfig string = "/config/"
|
||||
const httpPathFaviconICO string = "/favicon.ico"
|
||||
const httpPathFaviconSVG string = "/favicon.svg"
|
||||
const httpPathFiles string = "/files/"
|
||||
const httpPathFilesDeleteFilename string = "/files/delete/:filename"
|
||||
const httpPathFilesGetFilename string = "/files/get/:filename"
|
||||
const httpPathScriptJS string = "/script.js"
|
||||
const httpPathStyleCSS string = "/style.css"
|
||||
const httpPathUpload string = "/upload/"
|
||||
|
||||
func httpGetConfig(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
type Endpoints struct {
|
||||
Files string `json:"Files"`
|
||||
FilesDelete string `json:"FilesDelete"`
|
||||
FilesGet string `json:"FilesGet"`
|
||||
Upload string `json:"Upload"`
|
||||
}
|
||||
|
||||
type Modes struct {
|
||||
Readonly bool `json:"Readonly"`
|
||||
Sinkhole bool `json:"Sinkhole"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Endpoints Endpoints `json:"Endpoints"`
|
||||
Modes Modes `json:"Modes"`
|
||||
}
|
||||
|
||||
var config Config = Config{
|
||||
Endpoints: Endpoints{
|
||||
Files: httpPathFiles,
|
||||
FilesDelete: httpPathFilesDeleteFilename,
|
||||
FilesGet: httpPathFilesGetFilename,
|
||||
Upload: httpPathUpload,
|
||||
},
|
||||
Modes: Modes{
|
||||
Readonly: config.GetReadonlyMode(),
|
||||
Sinkhole: config.GetSinkholeMode(),
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(config)
|
||||
}
|
||||
|
||||
func httpGetFaviconICO(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
http.Redirect(w, r, "/favicon.svg", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func httpGetFaviconSVG(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Write(assetFaviconSVG)
|
||||
}
|
||||
|
||||
func httpGetFiles(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
type FileInfo struct {
|
||||
Name string `json:"Name"`
|
||||
Size int64 `json:"Size"`
|
||||
}
|
||||
|
||||
if config.GetSinkholeMode() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode([]FileInfo{})
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(config.GetPathDataFolder())
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
files := make([]FileInfo, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
files = append(files, FileInfo{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(files)
|
||||
}
|
||||
|
||||
func httpGetFilesDeleteFilename(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
if config.GetReadonlyMode() {
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if config.GetSinkholeMode() {
|
||||
http.Error(w, "404 File Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(config.GetPathDataFolder())
|
||||
if err != nil {
|
||||
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
files := map[string]int64{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
files[info.Name()] = info.Size()
|
||||
}
|
||||
|
||||
filename := ps.ByName("filename")
|
||||
sizeInBytes, fileExists := files[filename]
|
||||
if !fileExists {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
return
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(config.GetPathDataFolder(), filename)
|
||||
err = os.Remove(fullPath)
|
||||
if err != nil {
|
||||
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("| Delete | %-21s | %-10s | %s\n", getClientIP(r), filesystem.GetHumanReadableSize(sizeInBytes), filename)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
func httpGetFilesGetFilename(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
if config.GetSinkholeMode() {
|
||||
http.Error(w, "404 File Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
filename := ps.ByName("filename")
|
||||
filePath := filepath.Join(config.GetPathDataFolder(), filename)
|
||||
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil || info.IsDir() {
|
||||
http.Error(w, "404 File Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("| Download | %-21s | %-10s | %s\n", getClientIP(r), filesystem.GetHumanReadableSize(info.Size()), filename)
|
||||
|
||||
extension := strings.ToLower(filepath.Ext(filename))
|
||||
mimeType := mime.TypeByExtension(extension)
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
|
||||
if isBrowserDisplayableFileType(extension) {
|
||||
w.Header().Set("Content-Disposition", "inline; filename=\""+filename+"\"")
|
||||
} else {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, filePath)
|
||||
}
|
||||
|
||||
func httpGetRoot(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(assetIndexHTML)
|
||||
}
|
||||
|
||||
func httpGetScriptJS(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
w.Write(assetScriptJS)
|
||||
}
|
||||
|
||||
func httpGetStyleCSS(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
w.Write(assetStyleCSS)
|
||||
}
|
||||
|
||||
func httpPostUpload(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
if config.GetReadonlyMode() {
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Could not get multipart reader: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Error reading part: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer part.Close()
|
||||
|
||||
if part.FileName() == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
safeFilename := filesystem.SanitizeFilename(part.FileName())
|
||||
pathToFileInDataFolder := filepath.Join(config.GetPathDataFolder(), safeFilename)
|
||||
pathToFileInUploadFolder := filepath.Join(config.GetPathUploadFolder(), safeFilename)
|
||||
|
||||
if _, err = os.Stat(pathToFileInDataFolder); err == nil {
|
||||
http.Error(w, "File already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if _, err = os.Stat(pathToFileInUploadFolder); err == nil {
|
||||
http.Error(w, "File already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
uploadFile, err := os.Create(pathToFileInUploadFolder)
|
||||
if err != nil {
|
||||
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer uploadFile.Close()
|
||||
|
||||
bytesWritten, err := io.Copy(uploadFile, part)
|
||||
if err != nil {
|
||||
_ = os.Remove(pathToFileInUploadFolder)
|
||||
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err = os.Rename(pathToFileInUploadFolder, pathToFileInDataFolder); err != nil {
|
||||
_ = os.Remove(pathToFileInDataFolder)
|
||||
_ = os.Remove(pathToFileInUploadFolder)
|
||||
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("| Upload | %-21s | %-10s | %s\n",
|
||||
getClientIP(r), filesystem.GetHumanReadableSize(bytesWritten), safeFilename)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
Reference in New Issue
Block a user