From c48e91455b1c500969987ae28bf4d22c7d0e41ee Mon Sep 17 00:00:00 2001 From: Adrian PETERCA Date: Mon, 1 Jun 2026 21:21:23 +0300 Subject: [PATCH 1/3] v0.0.3 Improved browser errors, now all browser clients get dedicated error pages (404/500) Next, added a "/config/" endpoint for better UX on the client - JS won't allow the client to upload files with disallowed extensions or bigger than the maximum size limit --- config.go | 30 +++++++++++ handlers.go | 133 +++++++++++++++++++++++++++++++++++++--------- handlers_test.go | 4 +- main.go | 66 ++++++++++++++++++----- static/404.html | 12 +++++ static/500.html | 12 +++++ static/index.html | 51 +++++++++++++++++- static/styles.css | 6 +++ struct.go | 5 ++ 9 files changed, 276 insertions(+), 43 deletions(-) create mode 100644 static/404.html create mode 100644 static/500.html diff --git a/config.go b/config.go index 5ad0374..9b2539f 100644 --- a/config.go +++ b/config.go @@ -14,6 +14,8 @@ var templateFiles embed.FS // Example: // storagePath -> CURIER_STORAGE_PATH +// -- Private variables --- + // Where to save the uploaded files var storagePath = "/var/lib/curier/uploads/" @@ -25,3 +27,31 @@ var host = "127.0.0.1" // Port to listen on - default 8080 var port = "8080" + +// --- Public variables --- +// +// This information can be queried by a GET request to the `/config/` endpoint. + +// Max accepted file size for upload - default 20 GB +var maxFileSize int64 = 20 * 1024 * 1024 * 1024 + +// What file types (based on extension) can be uploaded. +// Env var looks like CURIER_ALLOWED_FILE_EXTENSIONS=jpg;jpeg;md +// +// DO NOT add a '.' for each extension - it will be added automatically. +var allowedFileExtensions = map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".webm": true, + ".mkv": true, + ".mp4": true, + ".mp3": true, + ".avi": true, + ".png": true, + ".pdf": true, + ".zip": true, + ".rar": true, + ".tar.gz": true, + ".txt": true, + ".md": true, +} diff --git a/handlers.go b/handlers.go index 5d1a887..d5e1b58 100644 --- a/handlers.go +++ b/handlers.go @@ -7,9 +7,11 @@ import ( "fmt" "html/template" "io" + "mime/multipart" "net/http" "os" "path/filepath" + "strings" "time" ) @@ -17,43 +19,62 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { data, err := staticFiles.ReadFile("static/index.html") if err != nil { fmt.Printf("ERROR: static/index.html not found in embedded files") - http.Error(w, "Sorry :(", http.StatusInternalServerError) + http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html") w.Write(data) } +func configHandler(w http.ResponseWriter, r *http.Request) { + keys := make([]string, 0, len(allowedFileExtensions)) + for ext := range allowedFileExtensions { + keys = append(keys, ext) + } + + config := Config{ + MaxFileSize: maxFileSize, + AllowedFileExtensions: keys, + } + + configBytes, err := json.Marshal(config) + if err != nil { + fmt.Printf("ERROR: failed to marshal config JSON") + http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(configBytes) +} + +// uploadHandler saves the uploaded files to disk, if it passes the verifications. +// It will only return simple errors (JSON based) func uploadHandler(w http.ResponseWriter, r *http.Request) { file, header, err := r.FormFile("file") if err != nil { - fmt.Printf("WARNING: invalid file upload: %s\n", err) - http.Error(w, "invalid file upload", http.StatusBadRequest) + fmt.Printf("WARNING: no file provided: %s\n", err) + http.Error(w, "no file provided", http.StatusBadRequest) return } defer file.Close() - fileName := filepath.Base(header.Filename) - if fileName == "." || fileName == "" { - fmt.Printf("WARNING: invalid filename provided: %s\n", err) - http.Error(w, "invalid filename", http.StatusBadRequest) + fileName, err := validateFile(header) + if err != nil { + fmt.Printf("WARNING: problem with file upload, reason: %s\n", err) + http.Error(w, "invalid file", http.StatusBadRequest) return } - // Could be made to only accept specific files, based on their magic bytes - // some ideas: ZIP, RAR, PNG, JPG, JPEG, text files (how do I recognise these - maybe by parsing the first 4 bytes?) - // Create a unique filename from 128 random bits -> 32 char string - bytes := make([]byte, 16) - _, err = rand.Read(bytes) + id, err := generateId() if err != nil { fmt.Printf("ERROR: could not generate ID, reason: %s\n", err) - http.Error(w, "Sorry, I did my best :(", http.StatusInternalServerError) + http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } - id := hex.EncodeToString(bytes) meta := FileMeta{ OriginalFilename: fileName, @@ -64,7 +85,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { metaBytes, err := json.Marshal(meta) if err != nil { fmt.Printf("ERROR: failed to marshal JSON: %s\n", err) - http.Error(w, "Sorry, I did my best :(", http.StatusInternalServerError) + http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } @@ -72,7 +93,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { err = os.WriteFile(metaFilePath, metaBytes, 0644) if err != nil { fmt.Printf("ERROR: could not write %s to disk: %s\n", metaFilePath, err) - http.Error(w, "Could not save file", http.StatusInternalServerError) + http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } @@ -81,7 +102,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { if err != nil { fmt.Printf("ERROR: could not create %s on disk: %s\n", filePath, err) - http.Error(w, "Could not save file", http.StatusInternalServerError) + http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } @@ -92,7 +113,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { os.Remove(metaFilePath) fmt.Printf("ERROR: could not write %s to disk: %s\n", filePath, err) - http.Error(w, "Could not write file", http.StatusInternalServerError) + http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } @@ -110,14 +131,14 @@ func downloadHandler(w http.ResponseWriter, r *http.Request) { err := validateId(id) if err != nil { fmt.Printf("WARNING: invalid ID %s, reason: %s\n", id, err) - http.Error(w, "invalid id", http.StatusBadRequest) + serveError(w, http.StatusNotFound) return } meta, err := readMeta(id) if err != nil { fmt.Printf("ERROR: reading meta file for %s: %s\n", id, err) - http.Error(w, "not found", http.StatusNotFound) + serveError(w, http.StatusNotFound) return } @@ -135,17 +156,18 @@ func shareHandler(w http.ResponseWriter, r *http.Request) { err := validateId(id) if err != nil { fmt.Printf("WARNING: invalid ID %s, reason: %s\n", id, err) - http.Error(w, "invalid id", http.StatusBadRequest) + // We return 404-NotFound because this is a browser-client facing application, not a pure API. + // Thus, it makes more sense for the client to get a 404 instead of a 400-BadRequest + serveError(w, http.StatusNotFound) return } meta, err := readMeta(id) if err != nil { fmt.Printf("ERROR: reading meta file for %s, reason: %s\n", id, err) - http.Error(w, "not found", http.StatusNotFound) + serveError(w, http.StatusNotFound) return } - shareData := ShareData{ FileMeta: *meta, ID: id, @@ -156,7 +178,7 @@ func shareHandler(w http.ResponseWriter, r *http.Request) { if err != nil { fmt.Printf("ERROR: could not parse template: %s\n", err) - http.Error(w, "Sorry, I did my best :(", http.StatusInternalServerError) + serveError(w, http.StatusInternalServerError) return } @@ -171,7 +193,7 @@ func cssHandler(w http.ResponseWriter, r *http.Request) { data, err := staticFiles.ReadFile("static/styles.css") if err != nil { fmt.Printf("ERROR: static/styles.css not found in embedded files") - http.Error(w, "Sorry :(", http.StatusInternalServerError) + http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/css; charset=utf-8") @@ -187,9 +209,46 @@ func validateId(id string) error { return fmt.Errorf("ID length is not 32") } + for _, ch := range id { + if !(ch >= 'a' && ch <= 'f' || ch >= '0' && ch <= '9') { + return fmt.Errorf("ID is not a HEX encoded string") + } + } + return nil } +func validateFile(header *multipart.FileHeader) (string, error) { + fileName := filepath.Base(header.Filename) + if fileName == "." || fileName == "" { + return "", fmt.Errorf("Filename is either empty or '.'") + } + + if header.Size > maxFileSize { + return "", fmt.Errorf("File size is bigger than allowed") + } + + ext := strings.ToLower(filepath.Ext(fileName)) + if !allowedFileExtensions[ext] { + return "", fmt.Errorf("file extension %s is not allowed", ext) + } + + return fileName, nil +} + +func generateId() (string, error) { + bytes := make([]byte, 16) + _, err := rand.Read(bytes) + if err != nil { + // Docs say: "It never returns an error, and always fills b entirely." + // If you get here, I dunno what to do + return "", err + } + id := hex.EncodeToString(bytes) + + return id, nil +} + func readMeta(id string) (*FileMeta, error) { metaFilePath := filepath.Join(storagePath, id+".meta") @@ -204,3 +263,27 @@ func readMeta(id string) (*FileMeta, error) { } return &meta, nil } + +func serveError(w http.ResponseWriter, code int) { + var file string + switch code { + case http.StatusNotFound: + file = "static/404.html" + case http.StatusInternalServerError: + file = "static/500.html" + default: + file = "static/404.html" + } + + content, err := staticFiles.ReadFile(file) + if err != nil { + // fallback if even the error page is missing + fmt.Printf("CRITICAL: Missing error page for %d (are you sure the file was embedded?), reason: %s\n", code, err) + http.Error(w, "Something really broke me :(", code) + return + } + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(code) + w.Write(content) +} diff --git a/handlers_test.go b/handlers_test.go index 2c30af9..82b49f6 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -111,8 +111,8 @@ func TestDownloadInvalidID(t *testing.T) { downloadHandler(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400, got %d", w.Code) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) } } diff --git a/main.go b/main.go index 2f5137b..623fa35 100644 --- a/main.go +++ b/main.go @@ -4,10 +4,32 @@ import ( "fmt" "net/http" "os" + "strconv" + "strings" ) func main() { + parseEnvVars() + + mux := http.NewServeMux() + mux.HandleFunc("GET /", rootHandler) + mux.HandleFunc("GET /config", configHandler) + mux.HandleFunc("GET /download/{id}", downloadHandler) + mux.HandleFunc("GET /share/{id}", shareHandler) + mux.HandleFunc("GET /static/style.css", cssHandler) + mux.HandleFunc("POST /upload", uploadHandler) + + var listenAddress = fmt.Sprintf("%s:%s", host, port) + fmt.Printf("Starting and listening on http://%s ...\n", listenAddress) + + err := http.ListenAndServe(listenAddress, mux) + if err != nil { + fmt.Printf("ERROR: server failed at startup: %s\n", err) + } +} + +func parseEnvVars() { if envVar := os.Getenv("CURIER_STORAGE_PATH"); envVar != "" { storagePath = envVar } @@ -24,23 +46,39 @@ func main() { port = envVar } + if envVar := os.Getenv("CURIER_MAX_FILE_SIZE"); envVar != "" { + var err error + maxFileSize, err = strconv.ParseInt(envVar, 10, 64) + if err != nil { + fmt.Printf("CRITICAL: failed to parse maxFileSize, reason: %s\n", err) + os.Exit(1) + } + } + + if envVar := os.Getenv("CURIER_ALLOWED_FILE_EXTENSIONS"); envVar != "" { + allowedFileExtensions = map[string]bool{} + for _, ext := range strings.Split(envVar, ";") { + ext = strings.TrimSpace(ext) + if ext != "" { + allowedFileExtensions["."+ext] = true + } + } + + if len(allowedFileExtensions) == 0 { + fmt.Printf("CRITICAL: parsing allowedFileExtensions did not work. Exiting...\n") + os.Exit(1) + } + } + + fmt.Printf("\n\n --- Environment variables ---\n") fmt.Printf("storagePath : %s\n", storagePath) fmt.Printf("urlBasePath : %s\n", urlBasePath) fmt.Printf("host : %s\n", host) fmt.Printf("port : %s\n", port) - - mux := http.NewServeMux() - mux.HandleFunc("GET /", rootHandler) - mux.HandleFunc("GET /download/{id}", downloadHandler) - mux.HandleFunc("GET /share/{id}", shareHandler) - mux.HandleFunc("GET /static/style.css", cssHandler) - mux.HandleFunc("POST /upload", uploadHandler) - - var listenAddress = fmt.Sprintf("%s:%s", host, port) - fmt.Printf("Starting and listening on http://%s ...\n", listenAddress) - - err := http.ListenAndServe(listenAddress, mux) - if err != nil { - fmt.Printf("ERROR: server failed at startup: %s\n", err) + fmt.Printf("maxFileSize : %d bytes\n", maxFileSize) + fmt.Printf("allowedFileExtensions: ") + for ext := range allowedFileExtensions { + fmt.Printf("%s ", ext) } + fmt.Printf("\n\n") } diff --git a/static/404.html b/static/404.html new file mode 100644 index 0000000..4e27eac --- /dev/null +++ b/static/404.html @@ -0,0 +1,12 @@ + + + + + + Curier 🚚 + + +
+

Sorry, the file does not exist!

+ + \ No newline at end of file diff --git a/static/500.html b/static/500.html new file mode 100644 index 0000000..9ce819e --- /dev/null +++ b/static/500.html @@ -0,0 +1,12 @@ + + + + + + Curier 🚚 + + +
+

Something went terribly wrong, I'm sorry!

+ + \ No newline at end of file diff --git a/static/index.html b/static/index.html index 9ab690e..f1f7dab 100644 --- a/static/index.html +++ b/static/index.html @@ -1,22 +1,69 @@ + Curier 🚚
-

Max file size: 10 MB

+

Max file size: ...




- +