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