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}}
{{.Error}}
` var passwordTpl = template.Must(template.New("pw").Parse(passwordPageTmpl)) // ---- Index handler --------------------------------------------------------- const indexHTML = ` HARDFILES
HARDFILES
ephemeral • volatile • gone in 24h
curl -F file=@example.png https://hardfiles.org/
ALL UPLOADS ARE SHREDDED AFTER 24 HOURS
` 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") }