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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/build/*
!/build/.gitkeep

18
LICENSE Normal file
View File

@@ -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.

82
README.md Normal file
View File

@@ -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
```

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"}`))
}

0
build/.gitkeep Normal file
View File

97
config/certs.go Normal file
View File

@@ -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
}

68
config/config.go Normal file
View File

@@ -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("")
}

22
config/filesystem.go Normal file
View File

@@ -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
}

165
config/flags.go Normal file
View File

@@ -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)
}
}

137
filesystem/filesystem.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
})
}
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module git.0x0001f346.de/andreas/ablage
go 1.24.6
require github.com/julienschmidt/httprouter v1.3.0

2
go.sum Normal file
View File

@@ -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=

13
main.go Normal file
View File

@@ -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()
}

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB