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.** + +![Screenshot of ablage](./screenshot.png) + +## 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 + + + + + + + + + + + + + + + + + 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