first commit

This commit is contained in:
2025-08-26 21:54:30 +02:00
commit 4478d70d15
21 changed files with 1617 additions and 0 deletions

122
app/app.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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"}`))
}