Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@ a minimal file drop service

---

**EXPERIMENTAL** - This project is provided as-is without any warranties, express or implied. Use at your own risk.

### about
The project is written in go with zero third-party dependencies. Uploads are streamed to disk, which keeps memory low and bounded, and hashed with SHA-256. The hex digest of the hash becomes the file identifier, which is computationally infeasible to enumerate. The service is configured through environment variables.

### installation

b1n supports docker, there is a `compose.yaml` included in the repository and packages are automatically generated from the main branch and hosted on GHCR at `ghcr.io/qazer2687/b1n:latest`.

### endpoints

`POST /upload` - upload a file
Expand Down
14 changes: 7 additions & 7 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import (
)

type Config struct {
Port string
StoragePath string
MaxFileSize int64
MaxTotalSize int64
BaseURL string
MaxUploads int
UploadRate int64
Port string // listen address
StoragePath string // directory for uploaded files
MaxFileSize int64 // per-file upload limit
MaxTotalSize int64 // total storage cap
BaseURL string // public-facing url for generated links
MaxUploads int // max concurrent uploads
UploadRate int64 // bytes per second upload throttle
}

func Load() Config {
Expand Down
58 changes: 57 additions & 1 deletion internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"

Expand All @@ -35,6 +36,7 @@ type throttledWriter struct {
rate int64
}

// limit write speed to the configured rate
func (t *throttledWriter) Write(p []byte) (int, error) {
if t.rate > 0 {
time.Sleep(time.Duration(len(p)) * time.Second / time.Duration(t.rate))
Expand All @@ -58,19 +60,23 @@ func (h *Handler) HandleUpload(
w http.ResponseWriter,
r *http.Request,
) {
// block if too many concurrent uploads
select {
case h.uploadSem <- struct{}{}:
defer func() { <-h.uploadSem }()
default:
http.Error(w, "too many uploads", http.StatusServiceUnavailable)
return
}
// reject bodies larger than the max file size
r.Body = http.MaxBytesReader(w, r.Body, h.cfg.MaxFileSize)
// write to a temp file prefixed with underscore
temp, err := os.CreateTemp(h.cfg.StoragePath, "_*")
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// hash the file contents while writing them
hash := sha256.New()
_, err = io.Copy(&throttledWriter{temp, h.cfg.UploadRate}, io.TeeReader(r.Body, hash))
temp.Close()
Expand All @@ -79,6 +85,7 @@ func (h *Handler) HandleUpload(
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
// derive the file id from its sha256 hash
id := hex.EncodeToString(hash.Sum(nil))
// check total storage before committing the file
if h.cfg.MaxTotalSize > 0 {
Expand Down Expand Up @@ -126,12 +133,12 @@ func (h *Handler) HandleDownload(
r *http.Request,
) {
id := r.PathValue("id")

// block path traversal by requiring the id to be 64 characters to match the hash
if len(id) != 64 {
http.NotFound(w, r)
return
}
// only allow lowercase hex characters
for _, c := range id {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
http.NotFound(w, r)
Expand All @@ -158,5 +165,54 @@ func (h *Handler) HandleDownload(
http.NotFound(w, r)
return
}
// serve rich embed to social media crawlers
if isCrawler(r.UserAgent()) {
buf := make([]byte, 512)
n, _ := file.Read(buf)
ct := http.DetectContentType(buf[:n])
if strings.HasPrefix(ct, "video/") {
serveEmbed(w, r, id, h.cfg.BaseURL, ct)
return
}
}

http.ServeContent(w, r, id, stat.ModTime(), file)
}

// check if the user agent is a known social media crawler
func isCrawler(ua string) bool {
if ua == "" {
return false
}
ua = strings.ToLower(ua)
for _, c := range []string{
"discordbot", "twitterbot", "facebookexternalhit",
"telegrambot", "slack", "slack-imgproxy",
} {
if strings.Contains(ua, c) {
return true
}
}
return false
}

// render og meta tags and twitter card for rich link previews
func serveEmbed(w http.ResponseWriter, _ *http.Request, id, baseURL, contentType string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
url := baseURL + "/" + id
fmt.Fprintf(w, `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta property="og:site_name" content="b1n">
<meta property="og:title" content="video @ b1n">
<meta property="og:type" content="video.other">
<meta property="og:url" content="%s">
<meta property="og:video" content="%s">
<meta property="og:video:type" content="%s">
<meta name="twitter:card" content="player">
<meta http-equiv="refresh" content="0;url=%s">
</head>
<body></body>
</html>`, url, url, contentType, url)
}
16 changes: 0 additions & 16 deletions internal/shortener/shortener.go

This file was deleted.

39 changes: 11 additions & 28 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"embed"
"fmt"
"io/fs"
"net/http"
"os"

Expand All @@ -22,41 +23,23 @@ func main() {
// make sure storage path exists
os.MkdirAll(cfg.StoragePath, 0755)

// set up the request router
mux := http.NewServeMux()
mux.HandleFunc("/", h.HandleRoot)
// serve static assets (fonts, etc)
mux.HandleFunc("GET /fonts/", func(w http.ResponseWriter, r *http.Request) {
data, err := static.ReadFile("static" + r.URL.Path)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "font/woff2")
w.Write(data)
})
mux.HandleFunc("GET /style.css", func(w http.ResponseWriter, r *http.Request) {
data, err := static.ReadFile("static/style.css")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/css")
w.Write(data)
})
mux.HandleFunc("GET /script.js", func(w http.ResponseWriter, r *http.Request) {
data, err := static.ReadFile("static/script.js")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/javascript")
w.Write(data)
})
// serve static assets (fonts, images, etc)
sub, _ := fs.Sub(static, "static")
fileServer := http.FileServer(http.FS(sub))
mux.Handle("GET /fonts/", http.StripPrefix("/fonts", fileServer))
mux.Handle("GET /assets/", http.StripPrefix("/assets", fileServer))
mux.Handle("GET /style.css", fileServer)
mux.Handle("GET /script.js", fileServer)

// take a file to upload to b1n
mux.HandleFunc("POST /upload", h.HandleUpload)
// take an id to fetch a file from the server
mux.HandleFunc("GET /{id}", h.HandleDownload)

// start the http server
fmt.Printf("[INFO] server starting on %s\n", cfg.Port)
if err := http.ListenAndServe(cfg.Port, mux); err != nil {
fmt.Printf("[ERROR] server failed to start: %s\n", err)
Expand Down
Binary file added static/assets/b1n.ico
Binary file not shown.
4 changes: 4 additions & 0 deletions static/assets/b1n.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions static/assets/qazer.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>b1n</title>
<link rel="icon" href="/assets/b1n.ico" />
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div id="drop">UPLOAD</div>
<div id="drop"><img src="/assets/b1n.svg" alt="b1n" /></div>
<div id="bar"></div>
<div id="st"></div>
<div id="dn"><a id="lnk" href="#"></a></div>
<a id="back"><img src="/assets/b1n.svg" alt="b1n" /></a>
<a href="https://qazer.org" target="_blank" rel="noopener noreferrer" id="logo">
<img src="/assets/qazer.svg" alt="qazer" />
</a>
<script src="/script.js"></script>
</body>
</html>
29 changes: 26 additions & 3 deletions static/script.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
// dom element references
let b = document.getElementById("bar"),
st = document.getElementById("st"),
dn = document.getElementById("dn"),
l = document.getElementById("lnk"),
dr = document.getElementById("drop"),
la = 0,
tm = 0,
done = false;
bk = document.getElementById("back"),
la = 0, // last uploaded bytes for speed calc
tm = 0, // timestamp of last progress event
done = false; // prevent duplicate uploads

// upload a file and show progress
function up(f) {
if (!f || done) return;
let x = new XMLHttpRequest();
x.open("POST", "/upload");
// update progress bar and speed on each chunk
x.upload.onprogress = (e) => {
if (e.lengthComputable) {
b.style.width = (e.loaded / e.total) * 100 + "%";
Expand All @@ -19,6 +23,7 @@ function up(f) {
sp = la ? (e.loaded - la) / ((n - tm) / 1000) : 0;
la = e.loaded;
tm = n;
// format bytes into human-readable string
let F = (b) =>
b >= 1e9
? (b / 1e9).toFixed(1) + "GB"
Expand All @@ -30,24 +35,42 @@ function up(f) {
st.textContent = F(e.loaded) + " / " + F(e.total) + " - " + F(sp) + "/s";
}
};
// upload complete — show the result link and back button
x.onload = () => {
done = true;
dr.style.display = "none";
b.style.width = "0";
st.style.display = "none";
l.href = l.textContent = window.location + x.responseText;
dn.style.display = "block";
bk.style.display = "block";
};
// hide the drop zone and start the upload
dr.style.display = "none";
x.send(f);
}

// back button resets to the initial upload state
bk.addEventListener("click", (e) => {
e.stopPropagation();
done = false;
dr.style.display = "flex";
dn.style.display = "none";
bk.style.display = "none";
b.style.width = "0";
st.style.display = "none";
la = 0;
tm = 0;
});
// prevent the browser from opening dragged files
document.addEventListener("dragover", (e) => e.preventDefault());
// handle dropped files
document.addEventListener("drop", (e) => {
e.preventDefault();
if (done) return;
up(e.dataTransfer.files[0]);
});
// clicking anywhere opens a file picker
document.addEventListener("click", () => {
if (done) return;
let i = document.createElement("input");
Expand Down
33 changes: 33 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ body {
height: 100vh;
overflow: hidden;
}
#drop img {
display: block;
height: 40px;
width: auto;
background: #fff;
}
#drop {
position: fixed;
top: 50%;
Expand Down Expand Up @@ -52,3 +58,30 @@ body {
#dn a {
color: #fff;
}
#back {
position: fixed;
top: 12px;
left: 12px;
display: none;
cursor: pointer;
z-index: 10;
}
#back img {
display: block;
height: 40px;
width: auto;
background: #fff;
}
#logo {
position: fixed;
bottom: 12px;
right: 12px;
background: #fff;
padding: 4px;
line-height: 0;
}
#logo img {
display: block;
height: 32px;
width: auto;
}
Loading