package main
import (
"context"
"crypto/rand"
"encoding/json"
"html"
"html/template"
"io"
"log"
"math/big"
"mime"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/BurntSushi/toml"
"github.com/gabriel-vasile/mimetype"
"golang.org/x/crypto/bcrypt"
)
// ---- Config ----------------------------------------------------------------
type Config struct {
Webroot string `toml:"webroot"`
Lport string `toml:"lport"`
Vhost string `toml:"vhost"`
Filelen int `toml:"filelen"`
Folder string `toml:"folder"`
Bgfolder string `toml:"bgfolder"`
MaxUploadMB int64 `toml:"max_upload_mb"`
TTLHours int `toml:"ttl_hours"`
RateLimitPerMin int `toml:"rate_limit_per_min"`
}
// ---- Meta sidecar ----------------------------------------------------------
type FileMeta struct {
PasswordHash string `json:"password_hash"`
}
// ---- Rate limiter ----------------------------------------------------------
type ipRecord struct {
mu sync.Mutex
timestamps []time.Time
}
type RateLimiter struct {
mu sync.Mutex
records map[string]*ipRecord
limit int
}
func NewRateLimiter(limit int) *RateLimiter {
return &RateLimiter{records: make(map[string]*ipRecord), limit: limit}
}
func (rl *RateLimiter) Allow(ip string) bool {
rl.mu.Lock()
rec, ok := rl.records[ip]
if !ok {
rec = &ipRecord{}
rl.records[ip] = rec
}
rl.mu.Unlock()
rec.mu.Lock()
defer rec.mu.Unlock()
now := time.Now()
cutoff := now.Add(-time.Minute)
fresh := rec.timestamps[:0]
for _, t := range rec.timestamps {
if t.After(cutoff) {
fresh = append(fresh, t)
}
}
rec.timestamps = fresh
if len(rec.timestamps) >= rl.limit {
return false
}
rec.timestamps = append(rec.timestamps, now)
return true
}
// Periodically purge empty records to prevent unbounded map growth.
func (rl *RateLimiter) cleanup(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
rl.mu.Lock()
for ip, rec := range rl.records {
rec.mu.Lock()
if len(rec.timestamps) == 0 {
delete(rl.records, ip)
}
rec.mu.Unlock()
}
rl.mu.Unlock()
}
}
}
// ---- Globals ---------------------------------------------------------------
var (
conf Config
activeFiles sync.Map // filename → struct{}{} for active downloads
rateLimiter *RateLimiter
)
// ---- ID generation ---------------------------------------------------------
const idChars = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
func genID(length int) (string, error) {
b := make([]byte, length)
for i := range b {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(idChars))))
if err != nil {
return "", err
}
b[i] = idChars[n.Int64()]
}
return string(b), nil
}
// ---- Shredding -------------------------------------------------------------
const chunkSize = 32 * 1024
func shredFile(path string) error {
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
return err
}
fi, err := f.Stat()
if err != nil {
f.Close()
return err
}
size := fi.Size()
buf := make([]byte, chunkSize)
// 7 random passes
for pass := 0; pass < 7; pass++ {
if _, err := f.Seek(0, io.SeekStart); err != nil {
f.Close()
return err
}
remaining := size
for remaining > 0 {
n := int64(chunkSize)
if remaining < n {
n = remaining
}
if _, err := rand.Read(buf[:n]); err != nil {
f.Close()
return err
}
if _, err := f.Write(buf[:n]); err != nil {
f.Close()
return err
}
remaining -= n
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
}
// Zero fill pass
if _, err := f.Seek(0, io.SeekStart); err != nil {
f.Close()
return err
}
for i := range buf {
buf[i] = 0
}
remaining := size
for remaining > 0 {
n := int64(chunkSize)
if remaining < n {
n = remaining
}
if _, err := f.Write(buf[:n]); err != nil {
f.Close()
return err
}
remaining -= n
}
f.Sync()
f.Close()
return os.Remove(path)
}
// ---- Cleanup goroutine -----------------------------------------------------
func cullLoop(ctx context.Context) {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cullOnce()
}
}
}
func cullOnce() {
entries, err := os.ReadDir(conf.Folder)
if err != nil {
log.Printf("cull: readdir: %v", err)
return
}
ttl := time.Duration(conf.TTLHours) * time.Hour
now := time.Now()
for _, e := range entries {
name := e.Name()
if strings.HasSuffix(name, ".meta") {
continue
}
if _, active := activeFiles.Load(name); active {
continue
}
fi, err := e.Info()
if err != nil {
continue
}
if now.Sub(fi.ModTime()) > ttl {
path := filepath.Join(conf.Folder, name)
log.Printf("cull: shredding %s", name)
if err := shredFile(path); err != nil {
log.Printf("cull: shred %s: %v", name, err)
}
metaPath := path + ".meta"
if _, err := os.Stat(metaPath); err == nil {
os.Remove(metaPath)
}
}
}
}
// ---- Path safety -----------------------------------------------------------
func safePath(baseDir, name string) (string, bool) {
name = filepath.Base(name)
joined := filepath.Join(baseDir, name)
if !strings.HasPrefix(joined, filepath.Clean(baseDir)+string(os.PathSeparator)) {
return "", false
}
return joined, true
}
// ---- MIME inline allowlist -------------------------------------------------
func isInlineMIME(mtype string) bool {
major := strings.SplitN(mtype, "/", 2)[0]
switch major {
case "image", "audio", "video":
return true
}
switch mtype {
case "application/pdf", "text/plain":
return true
}
return false
}
// ---- Meta helpers ----------------------------------------------------------
func readMeta(metaPath string) (*FileMeta, error) {
data, err := os.ReadFile(metaPath)
if err != nil {
return nil, err
}
var m FileMeta
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return &m, nil
}
func writeMeta(metaPath string, m *FileMeta) error {
data, err := json.Marshal(m)
if err != nil {
return err
}
return os.WriteFile(metaPath, data, 0644)
}
// ---- Templates -------------------------------------------------------------
const passwordPageTmpl = `
HARDFILES — PASSWORD REQUIRED
{{.Filename}}
`
var passwordTpl = template.Must(template.New("pw").Parse(passwordPageTmpl))
// ---- Index handler ---------------------------------------------------------
const indexHTML = `
HARDFILES
`
var indexTpl = template.Must(template.New("index").Parse(indexHTML))
// ---- Handlers --------------------------------------------------------------
type indexData struct {
BgURL string
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
bgURL := pickRandomBg()
w.Header().Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := indexTpl.Execute(w, indexData{BgURL: bgURL}); err != nil {
log.Printf("index template: %v", err)
}
}
func pickRandomBg() string {
entries, err := os.ReadDir(conf.Bgfolder)
if err != nil || len(entries) == 0 {
return ""
}
var gifs []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".gif") {
gifs = append(gifs, e.Name())
}
}
if len(gifs) == 0 {
return ""
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(gifs))))
if err != nil {
return "/bg/" + html.EscapeString(gifs[0])
}
return "/bg/" + html.EscapeString(gifs[n.Int64()])
}
func handleUpload(w http.ResponseWriter, r *http.Request) {
// Rate limit by IP
ip := r.RemoteAddr
if idx := strings.LastIndex(ip, ":"); idx != -1 {
ip = ip[:idx]
}
if !rateLimiter.Allow(ip) {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
// Enforce size limit
r.Body = http.MaxBytesReader(w, r.Body, conf.MaxUploadMB*1024*1024)
mr, err := r.MultipartReader()
if err != nil {
http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
return
}
var origFilename string
var password string
var sizeExceeded bool
var tempPath string
// Ensure temp file is cleaned up on any error path.
defer func() {
if tempPath != "" {
os.Remove(tempPath)
}
}()
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
if strings.Contains(err.Error(), "request body too large") || strings.Contains(err.Error(), "http: request body too large") {
sizeExceeded = true
break
}
http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
return
}
fieldName := part.FormName()
if fieldName == "password" {
pwBytes, err := io.ReadAll(io.LimitReader(part, 1024))
if err == nil {
password = string(pwBytes)
}
part.Close()
continue
}
if fieldName == "file" {
origFilename = part.FileName()
// Stream directly to a temp file — never buffer in memory.
tmpFile, err := os.CreateTemp(conf.Folder, ".upload-*")
if err != nil {
part.Close()
http.Error(w, "Storage error", http.StatusInternalServerError)
return
}
tempPath = tmpFile.Name()
_, copyErr := io.Copy(tmpFile, part)
tmpFile.Close()
part.Close()
if copyErr != nil {
if strings.Contains(copyErr.Error(), "request body too large") || strings.Contains(copyErr.Error(), "http: request body too large") {
sizeExceeded = true
break
}
http.Error(w, "Read error", http.StatusInternalServerError)
return
}
} else {
part.Close()
}
}
if sizeExceeded {
http.Error(w, "Payload Too Large", http.StatusRequestEntityTooLarge)
return
}
if tempPath == "" {
http.Error(w, "No file provided", http.StatusBadRequest)
return
}
// Verify the temp file has content.
fi, err := os.Stat(tempPath)
if err != nil || fi.Size() == 0 {
http.Error(w, "No file provided", http.StatusBadRequest)
return
}
// Detect MIME type from the file on disk — no memory buffering.
mtype, err := mimetype.DetectFile(tempPath)
if err != nil {
http.Error(w, "MIME detection failed", http.StatusInternalServerError)
return
}
ext := mtype.Extension()
if ext == "" {
// Fall back to original extension if mime gives nothing.
if origFilename != "" {
ext = filepath.Ext(origFilename)
}
if ext == "" {
ext = ".bin"
}
}
// Generate unique ID and atomically rename temp file to final path.
var finalPath string
var finalName string
for attempts := 0; attempts < 10; attempts++ {
id, err := genID(conf.Filelen)
if err != nil {
http.Error(w, "ID generation failed", http.StatusInternalServerError)
return
}
name := id + ext
path := filepath.Join(conf.Folder, name)
// Check for collision before rename.
if _, statErr := os.Stat(path); statErr == nil {
continue // file exists, retry
}
if err := os.Rename(tempPath, path); err != nil {
http.Error(w, "Storage error", http.StatusInternalServerError)
return
}
// Rename succeeded — clear tempPath so defer does not remove it.
tempPath = ""
finalPath = path
finalName = name
break
}
if finalPath == "" {
http.Error(w, "Could not generate unique ID", http.StatusInternalServerError)
return
}
// Write .meta sidecar if password provided.
if password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
os.Remove(finalPath)
http.Error(w, "Password hashing failed", http.StatusInternalServerError)
return
}
meta := &FileMeta{PasswordHash: string(hash)}
if err := writeMeta(finalPath+".meta", meta); err != nil {
os.Remove(finalPath)
http.Error(w, "Meta write failed", http.StatusInternalServerError)
return
}
}
fileSize := fi.Size()
_ = origFilename // used for ext fallback only
url := "https://" + conf.Vhost + "/f/" + finalName
w.Header().Set("Location", url)
w.WriteHeader(http.StatusOK)
w.Write([]byte(url + "\n"))
log.Printf("upload: %s (%s, %d bytes)", finalName, mtype.String(), fileSize)
}
type pwPageData struct {
BgURL string
FileID string
Filename string
Error string
}
func handleDownload(w http.ResponseWriter, r *http.Request) {
// Extract {id} from /f/{id}
idPart := strings.TrimPrefix(r.URL.Path, "/f/")
if idPart == "" || strings.Contains(idPart, "/") {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Path traversal prevention
safeName := filepath.Base(idPart)
filePath := filepath.Join(conf.Folder, safeName)
if !strings.HasPrefix(filePath, filepath.Clean(conf.Folder)+string(os.PathSeparator)) {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Verify file exists
if _, err := os.Stat(filePath); err != nil {
http.NotFound(w, r)
return
}
metaPath := filePath + ".meta"
hasMeta := false
var meta *FileMeta
if _, err := os.Stat(metaPath); err == nil {
hasMeta = true
m, err := readMeta(metaPath)
if err != nil {
// Corrupt .meta: fail open, log and serve
log.Printf("download: corrupt .meta for %s: %v", safeName, err)
hasMeta = false
} else {
meta = m
}
}
if r.Method == http.MethodGet {
if hasMeta && meta != nil {
// Serve password prompt
bgURL := pickRandomBg()
w.Header().Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
passwordTpl.Execute(w, pwPageData{
BgURL: bgURL,
FileID: safeName,
Filename: safeName,
Error: "",
})
return
}
// Serve file directly
serveFile(w, r, filePath, safeName)
return
}
if r.Method == http.MethodPost {
if !hasMeta || meta == nil {
// No password needed — serve directly
serveFile(w, r, filePath, safeName)
return
}
r.ParseForm()
pw := r.FormValue("password")
if err := bcrypt.CompareHashAndPassword([]byte(meta.PasswordHash), []byte(pw)); err != nil {
// Wrong password
bgURL := pickRandomBg()
w.Header().Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
passwordTpl.Execute(w, pwPageData{
BgURL: bgURL,
FileID: safeName,
Filename: safeName,
Error: "WRONG PASSWORD",
})
return
}
// Correct password — serve file
serveFile(w, r, filePath, safeName)
return
}
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
func serveFile(w http.ResponseWriter, r *http.Request, filePath, name string) {
activeFiles.Store(name, struct{}{})
defer activeFiles.Delete(name)
// Detect MIME for serving
data, err := os.ReadFile(filePath)
if err != nil {
http.Error(w, "File read error", http.StatusInternalServerError)
return
}
mtype := mimetype.Detect(data)
mimeStr := mtype.String()
// Strip params for comparison
mediaType, _, _ := mime.ParseMediaType(mimeStr)
if mediaType == "" {
mediaType = mimeStr
}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Type", mimeStr)
if isInlineMIME(mediaType) {
w.Header().Set("Content-Disposition", "inline; filename=\""+name+"\"")
} else {
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
}
http.ServeContent(w, r, name, time.Time{}, strings.NewReader(string(data)))
}
func handleBg(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/bg/")
path, ok := safePath(conf.Bgfolder, name)
if !ok {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if _, err := os.Stat(path); err != nil {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, path)
}
func handleStatic(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/static/")
path, ok := safePath(conf.Webroot, name)
if !ok {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if _, err := os.Stat(path); err != nil {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, path)
}
// ---- Router ----------------------------------------------------------------
func newMux() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
handleUpload(w, r)
} else {
handleIndex(w, r)
}
})
mux.HandleFunc("/f/", handleDownload)
mux.HandleFunc("/bg/", handleBg)
mux.HandleFunc("/static/", handleStatic)
return mux
}
// ---- Main ------------------------------------------------------------------
func main() {
if _, err := toml.DecodeFile("config.toml", &conf); err != nil {
log.Fatalf("config: %v", err)
}
// Ensure upload dir exists
if err := os.MkdirAll(conf.Folder, 0755); err != nil {
log.Fatalf("mkdir %s: %v", conf.Folder, err)
}
rateLimiter = NewRateLimiter(conf.RateLimitPerMin)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go cullLoop(ctx)
go rateLimiter.cleanup(ctx)
srv := &http.Server{
Addr: ":" + conf.Lport,
Handler: newMux(),
ReadTimeout: 5 * time.Minute,
WriteTimeout: 10 * time.Minute,
IdleTimeout: 2 * time.Minute,
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
go func() {
log.Printf("hardfiles listening on :%s", conf.Lport)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
<-sigCh
log.Println("shutting down...")
cancel()
shutCtx, shutCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutCancel()
srv.Shutdown(shutCtx)
log.Println("done")
}