commit 4478d70d151197f4734faa91a9c12781ccf3e639
Author: Andreas Schulte <0x0001f346@pm.me>
Date: Tue Aug 26 21:54:30 2025 +0200
first commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1464c71
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/build/*
+!/build/.gitkeep
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..379ec1c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,18 @@
+MIT License
+
+Copyright (c) 2025 Andreas Schulte
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+associated documentation files (the "Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial
+portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
+LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
+EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..805f233
--- /dev/null
+++ b/README.md
@@ -0,0 +1,82 @@
+# ablage
+
+**A secure, minimal file exchange web application with optional authentication and HTTPS support.**
+
+
+
+## Features
+
+- Drag & drop file upload with real time progress
+- Download and delete uploaded files directly from the web interface
+- Fully responsive web UI for desktop and mobile
+- HTTPS support with self-signed or user-provided certificates
+- Sinkhole mode to hide existing files
+- Optional password protection
+- HTTP mode for local, unencrypted usage
+- No external dependencies on runtime
+- No bullshit
+
+## Installation
+
+1. Clone the repository:
+
+```bash
+git clone https://git.0x0001f346.de/andreas/ablage.git
+cd ablage
+```
+
+2. Build and run:
+
+```bash
+go build -o build/ . && build/ablage [flags]
+```
+
+## Usage & Flags
+
+| Flag | Description |
+| ------------ | ------------------------------------------------------------------------------------------- |
+| `--auth` | Enable Basic Authentication. |
+| `--cert` | Path to a custom TLS certificate file (PEM format). |
+| `--http` | Enable HTTP mode. Nothing will be encrypted. |
+| `--key` | Path to a custom TLS private key file (PEM format). |
+| `--password` | Set password for Basic Authentication (or let ablage generate a random one). |
+| `--path` | Set path to the data folder (default is `data` in the same directory as the ablage binary). |
+| `--port` | Set port to listen on (default is `13692`). |
+| `--readonly` | Enable readonly mode. No files can be uploaded or deleted. |
+| `--sinkhole` | Enable sinkhole mode. Existing files in the storage folder won't be visible. |
+
+## Accessing the Web UI
+
+- Open your browser and navigate to `https://localhost:13692` (or `http://localhost:13692` if using `--http`)
+- If `--auth` is enabled, use the username `ablage` and the auto-generated password or provide your own with `--password`
+
+## File Storage
+
+- Uploaded files are stored in a `data` folder in the same directory as the binary by default (can be changed via `--path`)
+- Sinkhole mode hides these files from the web UI but they remain on disk
+
+## TLS Certificates
+
+- By default, ablage uses an ephemeral, self-signed certificate generated on each start
+- To use your own certificate, pass the paths to your key and certificate with `--key` and `--cert`
+
+### Generating a test certificate
+
+To generate a **test key/certificate pair** for local testing with elliptic curve cryptography (P-256 curve), use:
+
+```bash
+openssl req -x509 \
+ -newkey ec \
+ -pkeyopt ec_paramgen_curve:P-256 \
+ -nodes \
+ -keyout /tmp/test.key \
+ -out /tmp/test.crt \
+ -days 365 \
+ -subj "/CN=localhost"
+```
+
+Then start **ablage** like this:
+
+```bash
+./ablage --cert /tmp/test.crt --key /tmp/test.key
+```
diff --git a/app/app.go b/app/app.go
new file mode 100644
index 0000000..1e924df
--- /dev/null
+++ b/app/app.go
@@ -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
+}
diff --git a/app/assets/favicon.svg b/app/assets/favicon.svg
new file mode 100644
index 0000000..231db7e
--- /dev/null
+++ b/app/assets/favicon.svg
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/assets/index.html b/app/assets/index.html
new file mode 100644
index 0000000..e8cbe9a
--- /dev/null
+++ b/app/assets/index.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Ablage
+
+
+
+
+
+ Ablage
+
+
+ Drag & drop files here or click to select
+
+
+
+
+
+
+
+
+ - Sinkhole mode enabled, no files will get listed -
+
+
+
diff --git a/app/assets/script.js b/app/assets/script.js
new file mode 100644
index 0000000..aa09ed7
--- /dev/null
+++ b/app/assets/script.js
@@ -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);
+})();
diff --git a/app/assets/style.css b/app/assets/style.css
new file mode 100644
index 0000000..69ac15e
--- /dev/null
+++ b/app/assets/style.css
@@ -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;
+ }
+}
diff --git a/app/auth.go b/app/auth.go
new file mode 100644
index 0000000..b3b65ba
--- /dev/null
+++ b/app/auth.go
@@ -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)
+ })
+}
diff --git a/app/http.go b/app/http.go
new file mode 100644
index 0000000..dd7d7a4
--- /dev/null
+++ b/app/http.go
@@ -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"}`))
+}
diff --git a/build/.gitkeep b/build/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/config/certs.go b/config/certs.go
new file mode 100644
index 0000000..1fedc06
--- /dev/null
+++ b/config/certs.go
@@ -0,0 +1,97 @@
+package config
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "fmt"
+ "math/big"
+ "os"
+ "time"
+)
+
+var selfSignedTLSCertificate []byte = []byte{}
+var selfSignedTLSKey []byte = []byte{}
+
+func GetTLSCertificate() []byte {
+ return selfSignedTLSCertificate
+}
+
+func GetTLSKey() []byte {
+ return selfSignedTLSKey
+}
+
+func generateSelfSignedTLSCertificate() error {
+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return err
+ }
+
+ serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
+ if err != nil {
+ return err
+ }
+
+ template := x509.Certificate{
+ SerialNumber: serialNumber,
+ Subject: pkix.Name{
+ CommonName: "ablage",
+ },
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(365 * 24 * time.Hour),
+ KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ BasicConstraintsValid: true,
+ }
+
+ derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
+ if err != nil {
+ return err
+ }
+
+ cert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
+ key, err := x509.MarshalECPrivateKey(privateKey)
+ if err != nil {
+ return err
+ }
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: key})
+
+ selfSignedTLSCertificate = cert
+ selfSignedTLSKey = keyPEM
+
+ return nil
+}
+
+func loadOrGenerateTLSCertificate() error {
+ if GetHttpMode() {
+ return nil
+ }
+
+ if pathTLSCertFile == "" || pathTLSKeyFile == "" {
+ return generateSelfSignedTLSCertificate()
+ }
+
+ _, err := tls.LoadX509KeyPair(pathTLSCertFile, pathTLSKeyFile)
+ if err != nil {
+ return fmt.Errorf("Error: Failed to load TLS certificate or key: %w", err)
+ }
+
+ certData, err := os.ReadFile(pathTLSCertFile)
+ if err != nil {
+ return fmt.Errorf("Error: Failed to read TLS certificate file: %w", err)
+ }
+
+ keyData, err := os.ReadFile(pathTLSKeyFile)
+ if err != nil {
+ return fmt.Errorf("Error: Failed to read TLS key file: %w", err)
+ }
+
+ selfSignedTLSCertificate = certData
+ selfSignedTLSKey = keyData
+
+ return nil
+}
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..b563600
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,68 @@
+package config
+
+import (
+ "fmt"
+ "os"
+)
+
+const DefaultBasicAuthUsername string = "ablage"
+const DefaultNameDataFolder string = "data"
+const DefaultNameUploadFolder string = "upload"
+const DefaultPortToListenOn int = 13692
+const LengthOfRandomBasicAuthPassword int = 16
+const VersionString string = "1.0"
+
+var randomBasicAuthPassword string = generateRandomPassword()
+
+func Init() {
+ err := gatherDefaultPaths()
+ if err != nil {
+ panic(err)
+ }
+
+ parseFlags()
+
+ if GetReadonlyMode() && GetSinkholeMode() {
+ fmt.Println("Error: Cannot enable both readonly and sinkhole modes at the same time.")
+ os.Exit(1)
+ }
+
+ err = loadOrGenerateTLSCertificate()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%v\n", err)
+ 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("")
+}
diff --git a/config/filesystem.go b/config/filesystem.go
new file mode 100644
index 0000000..20c60af
--- /dev/null
+++ b/config/filesystem.go
@@ -0,0 +1,22 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+var defaultPathDataFolder string = ""
+var defaultPathUploadFolder string = ""
+
+func gatherDefaultPaths() error {
+ execPath, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("[Error] Could not determine binary path: %v", err)
+ }
+
+ defaultPathDataFolder = filepath.Join(filepath.Dir(execPath), DefaultNameDataFolder)
+ defaultPathUploadFolder = filepath.Join(defaultPathDataFolder, DefaultNameUploadFolder)
+
+ return nil
+}
diff --git a/config/flags.go b/config/flags.go
new file mode 100644
index 0000000..2952e71
--- /dev/null
+++ b/config/flags.go
@@ -0,0 +1,165 @@
+package config
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+var basicAuthMode bool = false
+var basicAuthPassword string = ""
+var httpMode bool = false
+var pathDataFolder string = ""
+var pathTLSCertFile string = ""
+var pathTLSKeyFile string = ""
+var pathUploadFolder string = ""
+var portToListenOn int = DefaultPortToListenOn
+var readonlyMode bool = false
+var sinkholeMode bool = false
+
+func GetBasicAuthMode() bool {
+ return basicAuthMode
+}
+
+func GetBasicAuthPassword() string {
+ return basicAuthPassword
+}
+
+func GetBasicAuthUsername() string {
+ return DefaultBasicAuthUsername
+}
+
+func GetHttpMode() bool {
+ return httpMode
+}
+
+func GetPathDataFolder() string {
+ return pathDataFolder
+}
+
+func GetPathTLSCertFile() string {
+ return pathTLSCertFile
+}
+
+func GetPathTLSKeyFile() string {
+ return pathTLSKeyFile
+}
+
+func GetPathUploadFolder() string {
+ return pathUploadFolder
+}
+
+func GetPortToListenOn() int {
+ return portToListenOn
+}
+
+func GetReadonlyMode() bool {
+ return readonlyMode
+}
+
+func GetSinkholeMode() bool {
+ return sinkholeMode
+}
+
+func generateRandomPassword() string {
+ b := make([]byte, LengthOfRandomBasicAuthPassword)
+ _, err := rand.Read(b)
+ if err != nil {
+ panic(err)
+ }
+ return base64.RawURLEncoding.EncodeToString(b)[:LengthOfRandomBasicAuthPassword]
+}
+
+func parseFlags() {
+ flag.BoolVar(&basicAuthMode, "auth", false, "Enable basic authentication.")
+ flag.BoolVar(&httpMode, "http", false, "Enable http mode. Nothing will be encrypted.")
+ flag.BoolVar(&readonlyMode, "readonly", false, "Enable readonly mode. No files can be uploaded or deleted.")
+ flag.BoolVar(&sinkholeMode, "sinkhole", false, "Enable sinkhole mode. Existing files won't be visible.")
+ flag.IntVar(&portToListenOn, "port", DefaultPortToListenOn, "Set Port to listen on.")
+ flag.StringVar(&basicAuthPassword, "password", "", "Set password for basic authentication (or let ablage generate a random one).")
+ flag.StringVar(&pathDataFolder, "path", "", "Set path to data folder (default is 'data' in the same directory as ablage).")
+ flag.StringVar(&pathTLSCertFile, "cert", "", "TLS cert file")
+ flag.StringVar(&pathTLSKeyFile, "key", "", "TLS key file")
+
+ flag.Parse()
+
+ parseFlagValueBasicAuthPassword()
+ parseFlagValuePortToListenOn()
+ parseFlagValuePathDataFolder()
+ parseFlagValuePathTLSCertFile()
+ parseFlagValuePathTLSKeyFile()
+}
+
+func parseFlagValueBasicAuthPassword() {
+ if len(basicAuthPassword) < 1 || len(basicAuthPassword) > 128 {
+ basicAuthPassword = generateRandomPassword()
+ }
+}
+
+func parseFlagValuePathDataFolder() {
+ if pathDataFolder == "" {
+ pathDataFolder = defaultPathDataFolder
+ pathUploadFolder = defaultPathUploadFolder
+ return
+ }
+
+ info, err := os.Stat(pathDataFolder)
+ if err != nil {
+ pathDataFolder = defaultPathDataFolder
+ pathUploadFolder = defaultPathUploadFolder
+ return
+ }
+
+ if !info.IsDir() {
+ pathDataFolder = defaultPathDataFolder
+ pathUploadFolder = defaultPathUploadFolder
+ return
+ }
+
+ pathUploadFolder = filepath.Join(pathDataFolder, DefaultNameUploadFolder)
+}
+
+func parseFlagValuePortToListenOn() {
+ if portToListenOn < 1 || portToListenOn > 65535 {
+ portToListenOn = DefaultPortToListenOn
+ }
+}
+
+func parseFlagValuePathTLSCertFile() {
+ if pathTLSCertFile == "" {
+ pathTLSKeyFile = ""
+ return
+ }
+
+ info, err := os.Stat(pathTLSCertFile)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error: Failed to read cert: %v\n", err)
+ os.Exit(1)
+ }
+
+ if info.IsDir() {
+ fmt.Fprintf(os.Stderr, "Error: Cert must be a file\n")
+ os.Exit(1)
+ }
+}
+
+func parseFlagValuePathTLSKeyFile() {
+ if pathTLSKeyFile == "" {
+ pathTLSCertFile = ""
+ return
+ }
+
+ info, err := os.Stat(pathTLSKeyFile)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error: Failed to read key: %v\n", err)
+ os.Exit(1)
+ }
+
+ if info.IsDir() {
+ fmt.Fprintf(os.Stderr, "Error: Key must be a file\n")
+ os.Exit(1)
+ }
+}
diff --git a/filesystem/filesystem.go b/filesystem/filesystem.go
new file mode 100644
index 0000000..4dee12c
--- /dev/null
+++ b/filesystem/filesystem.go
@@ -0,0 +1,137 @@
+package filesystem
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "git.0x0001f346.de/andreas/ablage/config"
+)
+
+func Init() {
+ err := prepareDataFolder()
+ if err != nil {
+ fmt.Println("err")
+ os.Exit(1)
+ }
+
+ err = prepareUploadDir()
+ if err != nil {
+ fmt.Println("err")
+ os.Exit(1)
+ }
+}
+
+func GetHumanReadableSize(bytes int64) string {
+ const unit int64 = 1024
+
+ if bytes < unit {
+ return fmt.Sprintf("%d Bytes", bytes)
+ }
+
+ div, exp := int64(unit), 0
+
+ for n := bytes / unit; n >= unit; n /= unit {
+ div *= unit
+ exp++
+ }
+
+ return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
+}
+
+func SanitizeFilename(dirtyFilename string) string {
+ if dirtyFilename == "" {
+ return "upload.bin"
+ }
+
+ filenameWithoutPath := filepath.Base(dirtyFilename)
+
+ extension := filepath.Ext(filenameWithoutPath)
+ filenameWithoutPathAndExtension := filenameWithoutPath[:len(filenameWithoutPath)-len(extension)]
+
+ cleanedFilename := strings.ReplaceAll(filenameWithoutPathAndExtension, " ", "_")
+ cleanedFilename = strings.ReplaceAll(cleanedFilename, "Ä", "Ae")
+ cleanedFilename = strings.ReplaceAll(cleanedFilename, "ä", "äe")
+ cleanedFilename = strings.ReplaceAll(cleanedFilename, "Ö", "Oe")
+ cleanedFilename = strings.ReplaceAll(cleanedFilename, "ö", "oe")
+ cleanedFilename = strings.ReplaceAll(cleanedFilename, "Ü", "Ue")
+ cleanedFilename = strings.ReplaceAll(cleanedFilename, "ü", "ue")
+ cleanedFilename = strings.ReplaceAll(cleanedFilename, "ß", "ss")
+
+ var safeNameRegex = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
+ cleanedFilename = safeNameRegex.ReplaceAllString(cleanedFilename, "_")
+
+ for strings.Contains(cleanedFilename, "__") {
+ cleanedFilename = strings.ReplaceAll(cleanedFilename, "__", "_")
+ }
+
+ cleanedFilename = strings.Trim(cleanedFilename, "_")
+
+ const maxLenFilename int = 128
+ if len(cleanedFilename) > maxLenFilename {
+ cleanedFilename = cleanedFilename[:maxLenFilename]
+ }
+
+ return cleanedFilename + extension
+}
+
+func prepareDataFolder() error {
+ info, err := os.Stat(config.GetPathDataFolder())
+ if os.IsNotExist(err) {
+ if err := os.Mkdir(config.GetPathDataFolder(), 0755); err != nil {
+ return fmt.Errorf("Error: Could not create folder '%s': %v", config.GetPathDataFolder(), err)
+ }
+ } else if err != nil {
+ return fmt.Errorf("Error: Could not access '%s': %v", config.GetPathDataFolder(), err)
+ } else if !info.IsDir() {
+ return fmt.Errorf("Error: '%s' exists but is not a directory", config.GetPathDataFolder())
+ }
+
+ pathTestFile := filepath.Join(config.GetPathDataFolder(), ".write_test")
+ err = os.WriteFile(pathTestFile, []byte("test"), 0644)
+ if err != nil {
+ return fmt.Errorf("Error: Could not create test file '%s': %v", pathTestFile, err)
+ }
+
+ err = os.Remove(pathTestFile)
+ if err != nil {
+ return fmt.Errorf("Error: Could not delete test file '%s': %v", pathTestFile, err)
+ }
+
+ return nil
+}
+
+func prepareUploadDir() error {
+ info, err := os.Stat(config.GetPathUploadFolder())
+ if err == nil {
+ if !info.IsDir() {
+ return fmt.Errorf("%s exists, but is not a folder", config.GetPathUploadFolder())
+ }
+
+ err = os.RemoveAll(config.GetPathUploadFolder())
+ if err != nil {
+ return fmt.Errorf("Error: Could not delete upload folder '%s': %v", config.GetPathUploadFolder(), err)
+ }
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("Error: '%s' exists but is somewhat broken", config.GetPathUploadFolder())
+ }
+
+ if err := os.MkdirAll(config.GetPathUploadFolder(), 0755); err != nil {
+ return fmt.Errorf("Error: Could not create upload folder '%s': %v", config.GetPathUploadFolder(), err)
+ }
+
+ pathTestFile := filepath.Join(config.GetPathUploadFolder(), ".write_test")
+ err = os.WriteFile(pathTestFile, []byte("test"), 0644)
+ if err != nil {
+ return fmt.Errorf("Error: Could not create test file '%s': %v", pathTestFile, err)
+ }
+
+ err = os.Remove(pathTestFile)
+ if err != nil {
+ return fmt.Errorf("Error: Could not delete test file '%s': %v", pathTestFile, err)
+ }
+
+ return nil
+}
diff --git a/filesystem/filesystem_test.go b/filesystem/filesystem_test.go
new file mode 100644
index 0000000..c07175f
--- /dev/null
+++ b/filesystem/filesystem_test.go
@@ -0,0 +1,113 @@
+package filesystem
+
+import (
+ "math"
+ "testing"
+)
+
+func Test_sanitizeFilename(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {
+ name: "1",
+ input: "test.png",
+ want: "test.png",
+ },
+ {
+ name: "2",
+ input: "/tmp/test.png",
+ want: "test.png",
+ },
+ {
+ name: "3",
+ input: "../../etc/passwd",
+ want: "passwd",
+ },
+ {
+ name: "4",
+ input: "",
+ want: "upload.bin",
+ },
+ {
+ name: "5",
+ input: "übergrößé.png",
+ want: "uebergroess.png",
+ },
+ {
+ name: "6",
+ input: "my cool file!!.txt",
+ want: "my_cool_file.txt",
+ },
+ {
+ name: "7",
+ input: "so many spaces.txt",
+ want: "so_many_spaces.txt",
+ },
+ {
+ name: "8",
+ input: "/tmp/abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz.txt",
+ want: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx.txt",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := SanitizeFilename(tt.input); got != tt.want {
+ t.Errorf("\nsanitizeFilename()\nname: %v\nwant: %v\ngot: %v", tt.name, tt.want, got)
+ }
+ })
+ }
+}
+
+func Test_getHumanReadableSize(t *testing.T) {
+ tests := []struct {
+ name string
+ input int64
+ want string
+ }{
+ {
+ name: "1",
+ input: 7,
+ want: "7 Bytes",
+ },
+ {
+ name: "2",
+ input: 7 * int64(math.Pow(10, 3)),
+ want: "6.8 KB",
+ },
+ {
+ name: "3",
+ input: 7 * int64(math.Pow(10, 6)),
+ want: "6.7 MB",
+ },
+ {
+ name: "4",
+ input: 7 * int64(math.Pow(10, 9)),
+ want: "6.5 GB",
+ },
+ {
+ name: "5",
+ input: 7 * int64(math.Pow(10, 12)),
+ want: "6.4 TB",
+ },
+ {
+ name: "6",
+ input: 7 * int64(math.Pow(10, 15)),
+ want: "6.2 PB",
+ },
+ {
+ name: "7",
+ input: 7 * int64(math.Pow(10, 18)),
+ want: "6.1 EB",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := GetHumanReadableSize(tt.input); got != tt.want {
+ t.Errorf("\ngetHumanReadableSize()\nname: %v\nwant: %v\ngot: %v", tt.name, tt.want, got)
+ }
+ })
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..7430fe2
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module git.0x0001f346.de/andreas/ablage
+
+go 1.24.6
+
+require github.com/julienschmidt/httprouter v1.3.0
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..096c54e
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,2 @@
+github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
+github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..4e9967d
--- /dev/null
+++ b/main.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+ "git.0x0001f346.de/andreas/ablage/app"
+ "git.0x0001f346.de/andreas/ablage/config"
+ "git.0x0001f346.de/andreas/ablage/filesystem"
+)
+
+func main() {
+ config.Init()
+ filesystem.Init()
+ app.Init()
+}
diff --git a/screenshot.png b/screenshot.png
new file mode 100644
index 0000000..9a6aa16
Binary files /dev/null and b/screenshot.png differ