From 2ebc593f887c3bdc977a2a7b316637ffea39a622 Mon Sep 17 00:00:00 2001 From: Adrian PETERCA Date: Wed, 3 Jun 2026 15:37:27 +0300 Subject: [PATCH 1/7] Updated base URL response link --- README.md | 8 +++----- handlers.go | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a98a43e..9771b4c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A small Go server for sharing files across the internet. ## How to setup -### Docker environment +### Docker You can download the Dockerfile and build the image yourself, or simply pull it from the repo: ```bash @@ -12,13 +12,11 @@ docker pull ghcr.io/adipeterca/curier:latest docker run -p 39800:39800 ghcr.io/adipeterca/curier:latest ``` -### Linux environment - -**I strongly recommend using Docker, as it simplifies the configuration a lot**. +### Linux If you want to use a precompiled binary, please refer to the [Release](https://github.com/adipeterca/curier/releases) section. -### Windows environment +### Windows Because not many servers run Windows, the support I can provide for this platform is limited. You can download a precompiled binary from the [Release](https://github.com/adipeterca/curier/releases) section or use a Docker container. diff --git a/handlers.go b/handlers.go index d5e1b58..e43ade7 100644 --- a/handlers.go +++ b/handlers.go @@ -122,7 +122,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `{"url": "%s:%s/share/%s"}`, host, port, id) + fmt.Fprintf(w, `{"url": "%s/share/%s"}`, urlBasePath, id) } func downloadHandler(w http.ResponseWriter, r *http.Request) { From 120a9a0233dfbd6efc4c9e4083c219b49174d007 Mon Sep 17 00:00:00 2001 From: Adrian PETERCA Date: Wed, 3 Jun 2026 16:56:38 +0300 Subject: [PATCH 2/7] Added client side error handling for failed uploads --- handlers.go | 2 +- static/index.html | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/handlers.go b/handlers.go index e43ade7..a81e8a6 100644 --- a/handlers.go +++ b/handlers.go @@ -122,7 +122,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `{"url": "%s/share/%s"}`, urlBasePath, id) + fmt.Fprintf(w, `{"path": "/share/%s"}`, id) } func downloadHandler(w http.ResponseWriter, r *http.Request) { diff --git a/static/index.html b/static/index.html index f1f7dab..43033bc 100644 --- a/static/index.html +++ b/static/index.html @@ -67,9 +67,23 @@

Max file size: ...

const formData = new FormData() formData.append("file", file) - const response = await fetch("/upload", { method: "POST", body: formData }) - const data = await response.json() - document.getElementById("result").textContent = data.url + try { + const response = await fetch("/upload", { + method: "POST", + body: formData + }); + + if (!response.ok) { + document.getElementById("result").textContent = "Upload failed"; + return; + } + + const data = await response.json(); + + document.getElementById("result").textContent = window.location.origin + data.path; + } catch (err) { + document.getElementById("result").textContent = "Upload failed"; + } } From 4871f178263827d7d95ea1e7c809393949aa383d Mon Sep 17 00:00:00 2001 From: Adrian PETERCA Date: Wed, 3 Jun 2026 17:33:48 +0300 Subject: [PATCH 3/7] fixed handlers_test.go --- handlers_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/handlers_test.go b/handlers_test.go index 82b49f6..dd650a6 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -36,8 +36,8 @@ func TestUploadValidFile(t *testing.T) { var response map[string]string json.Unmarshal(w.Body.Bytes(), &response) - if response["url"] == "" { - t.Error("expected url in response, got empty string") + if response["path"] == "" { + t.Error("expected path in response, got empty string") } } @@ -148,7 +148,7 @@ func uploadTestFile(t *testing.T, filename, content string) string { var response map[string]string json.Unmarshal(w.Body.Bytes(), &response) - return response["url"] + return response["path"] } // extractID pulls the ID from a full download URL From 9545118be611687a613fab87d187eb7216ac26c7 Mon Sep 17 00:00:00 2001 From: Adrian PETERCA Date: Wed, 3 Jun 2026 18:21:14 +0300 Subject: [PATCH 4/7] Removed urlBasePath, fixed big file uploads, moved embedded FS parsing to app startup --- README.md | 1 - config.go | 10 +++++---- handlers.go | 58 ++++++++++++++++++++++++++++------------------------- main.go | 41 +++++++++++++++++++++---------------- 4 files changed, 61 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 9771b4c..72580b6 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,6 @@ For default values, check [config.go](https://github.com/adipeterca/curier/blob/ | Variable name | Description | |--|--| |`CURIER_STORAGE_PATH`|Absolute path where the file uploads will be stored on disk| -|`CURIER_BASE_URL`|Default prefix for the `/download/{id}` URL| |`CURIER_HOST`|Network address to bind to| |`CURIER_PORT`|Port to use (also affects the port used inside the container)| |`CURIER_MAX_FILE_SIZE`|Maximum allowed size for each file upload| diff --git a/config.go b/config.go index 1142008..4588979 100644 --- a/config.go +++ b/config.go @@ -1,6 +1,9 @@ package main -import "embed" +import ( + "embed" + "html/template" +) //go:embed static var staticFiles embed.FS @@ -8,6 +11,8 @@ var staticFiles embed.FS //go:embed templates var templateFiles embed.FS +var shareTemplate *template.Template + // All variables can be overwritten by using environment variables. // All env vars need to start with `CURIER_` followed by the variable name in uppercase, each word separated with an underscore. // @@ -19,9 +24,6 @@ var templateFiles embed.FS // Where to save the uploaded files var storagePath = "uploads/" -// URL base path that will prefix all download links -var urlBasePath = "http://localhost" - // Network address to bind to - default 0.0.0.0 var host = "0.0.0.0" diff --git a/handlers.go b/handlers.go index a81e8a6..e8d366d 100644 --- a/handlers.go +++ b/handlers.go @@ -5,8 +5,8 @@ import ( "encoding/hex" "encoding/json" "fmt" - "html/template" "io" + "log" "mime/multipart" "net/http" "os" @@ -18,7 +18,7 @@ import ( 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") + log.Printf("ERROR: static/index.html not found in embedded files") http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } @@ -39,7 +39,7 @@ func configHandler(w http.ResponseWriter, r *http.Request) { configBytes, err := json.Marshal(config) if err != nil { - fmt.Printf("ERROR: failed to marshal config JSON") + log.Printf("ERROR: failed to marshal config JSON") http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } @@ -51,10 +51,21 @@ func configHandler(w http.ResponseWriter, r *http.Request) { // 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) { + + r.Body = http.MaxBytesReader(w, r.Body, maxFileSize) + + // A memory buffer limit (at most 32MB) - anything over it will be written to disk, up to maxFileSize bytes. + var maxRAMSize int64 = min(maxFileSize, 32*1024*1024) + if err := r.ParseMultipartForm(maxRAMSize); err != nil { + log.Printf("WARNING: could not parse multipart form: %s\n", err) + http.Error(w, "file too big", http.StatusBadRequest) + return + } + file, header, err := r.FormFile("file") if err != nil { - fmt.Printf("WARNING: no file provided: %s\n", err) + log.Printf("WARNING: no file provided: %s\n", err) http.Error(w, "no file provided", http.StatusBadRequest) return } @@ -63,7 +74,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { fileName, err := validateFile(header) if err != nil { - fmt.Printf("WARNING: problem with file upload, reason: %s\n", err) + log.Printf("WARNING: problem with file upload, reason: %s\n", err) http.Error(w, "invalid file", http.StatusBadRequest) return } @@ -71,7 +82,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { // Create a unique filename from 128 random bits -> 32 char string id, err := generateId() if err != nil { - fmt.Printf("ERROR: could not generate ID, reason: %s\n", err) + log.Printf("ERROR: could not generate ID, reason: %s\n", err) http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } @@ -84,7 +95,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) + log.Printf("ERROR: failed to marshal JSON: %s\n", err) http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } @@ -92,7 +103,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { metaFilePath := filepath.Join(storagePath, id+".meta") err = os.WriteFile(metaFilePath, metaBytes, 0644) if err != nil { - fmt.Printf("ERROR: could not write %s to disk: %s\n", metaFilePath, err) + log.Printf("ERROR: could not write %s to disk: %s\n", metaFilePath, err) http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } @@ -101,7 +112,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { dst, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644) if err != nil { - fmt.Printf("ERROR: could not create %s on disk: %s\n", filePath, err) + log.Printf("ERROR: could not create %s on disk: %s\n", filePath, err) http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } @@ -112,13 +123,13 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { if err != nil { os.Remove(metaFilePath) - fmt.Printf("ERROR: could not write %s to disk: %s\n", filePath, err) + log.Printf("ERROR: could not write %s to disk: %s\n", filePath, err) http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } // File was uploaded successfully - fmt.Printf("SUCCESS: file %s was saved to disk %s\n", fileName, filePath) + log.Printf("SUCCESS: file %s was saved to disk %s\n", fileName, filePath) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) @@ -130,21 +141,21 @@ func downloadHandler(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") err := validateId(id) if err != nil { - fmt.Printf("WARNING: invalid ID %s, reason: %s\n", id, err) + log.Printf("WARNING: invalid ID %s, reason: %s\n", id, err) serveError(w, http.StatusNotFound) return } meta, err := readMeta(id) if err != nil { - fmt.Printf("ERROR: reading meta file for %s: %s\n", id, err) + log.Printf("ERROR: reading meta file for %s: %s\n", id, err) serveError(w, http.StatusNotFound) return } filePath := filepath.Join(storagePath, id) - fmt.Printf("INFO: Serving file %s uploaded by %s to %s\n", meta.OriginalFilename, meta.UploaderIP, r.RemoteAddr) + log.Printf("INFO: Serving file %s uploaded by %s to %s\n", meta.OriginalFilename, meta.UploaderIP, r.RemoteAddr) w.Header().Set("Content-Disposition", "attachment; filename=\""+meta.OriginalFilename+"\"") http.ServeFile(w, r, filePath) @@ -155,7 +166,7 @@ func shareHandler(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") err := validateId(id) if err != nil { - fmt.Printf("WARNING: invalid ID %s, reason: %s\n", id, err) + log.Printf("WARNING: invalid ID %s, reason: %s\n", id, err) // 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) @@ -164,7 +175,7 @@ func shareHandler(w http.ResponseWriter, r *http.Request) { meta, err := readMeta(id) if err != nil { - fmt.Printf("ERROR: reading meta file for %s, reason: %s\n", id, err) + log.Printf("ERROR: reading meta file for %s, reason: %s\n", id, err) serveError(w, http.StatusNotFound) return } @@ -174,16 +185,9 @@ func shareHandler(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "text/html") - tmpl, err := template.ParseFS(templateFiles, "templates/share.html") - - if err != nil { - fmt.Printf("ERROR: could not parse template: %s\n", err) - serveError(w, http.StatusInternalServerError) - return - } - if err = tmpl.Execute(w, shareData); err != nil { - fmt.Printf("ERROR: could not execute template: %s\n", err) + if err = shareTemplate.Execute(w, shareData); err != nil { + log.Printf("ERROR: could not execute template: %s\n", err) // We don't return an error to the client, as partial output may already // have been written. } @@ -192,7 +196,7 @@ func shareHandler(w http.ResponseWriter, r *http.Request) { 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") + log.Printf("ERROR: static/styles.css not found in embedded files") http.Error(w, "Something did not work. Contact the administrator.", http.StatusInternalServerError) return } @@ -278,7 +282,7 @@ func serveError(w http.ResponseWriter, code int) { 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) + log.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 } diff --git a/main.go b/main.go index 623fa35..7550cb0 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,8 @@ package main import ( "fmt" + "html/template" + "log" "net/http" "os" "strconv" @@ -11,6 +13,7 @@ import ( func main() { parseEnvVars() + parseFS() mux := http.NewServeMux() mux.HandleFunc("GET /", rootHandler) @@ -21,11 +24,11 @@ func main() { mux.HandleFunc("POST /upload", uploadHandler) var listenAddress = fmt.Sprintf("%s:%s", host, port) - fmt.Printf("Starting and listening on http://%s ...\n", listenAddress) + log.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) + log.Printf("ERROR: server failed at startup: %s\n", err) } } @@ -34,10 +37,6 @@ func parseEnvVars() { storagePath = envVar } - if envVar := os.Getenv("CURIER_URL_BASE_PATH"); envVar != "" { - urlBasePath = envVar - } - if envVar := os.Getenv("CURIER_HOST"); envVar != "" { host = envVar } @@ -50,7 +49,7 @@ func parseEnvVars() { var err error maxFileSize, err = strconv.ParseInt(envVar, 10, 64) if err != nil { - fmt.Printf("CRITICAL: failed to parse maxFileSize, reason: %s\n", err) + log.Printf("CRITICAL: failed to parse maxFileSize, reason: %s\n", err) os.Exit(1) } } @@ -65,20 +64,28 @@ func parseEnvVars() { } if len(allowedFileExtensions) == 0 { - fmt.Printf("CRITICAL: parsing allowedFileExtensions did not work. Exiting...\n") + log.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) - fmt.Printf("maxFileSize : %d bytes\n", maxFileSize) - fmt.Printf("allowedFileExtensions: ") + log.Printf("\n\n --- Environment variables ---\n") + log.Printf("storagePath : %s\n", storagePath) + log.Printf("host : %s\n", host) + log.Printf("port : %s\n", port) + log.Printf("maxFileSize : %d bytes\n", maxFileSize) + log.Printf("allowedFileExtensions: ") for ext := range allowedFileExtensions { - fmt.Printf("%s ", ext) + log.Printf("%s ", ext) + } + log.Printf("\n\n") +} + +func parseFS() { + var err error + shareTemplate, err = template.ParseFS(templateFiles, "templates/share.html") + if err != nil { + log.Printf("CRITICAL: could not parse share template: %s\n", err) + os.Exit(1) } - fmt.Printf("\n\n") } From 6b5ce3dbaafa2a0a0ac1e28799d073a82b23d4b0 Mon Sep 17 00:00:00 2001 From: Adrian PETERCA Date: Wed, 3 Jun 2026 18:54:49 +0300 Subject: [PATCH 5/7] Fixed missing /tmp in Docker image --- Dockerfile | 1 + main.go | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 578c523..f30ffd2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ ENV CURIER_PORT="39800" ENV CURIER_STORAGE_PATH="/uploads/" COPY --from=builder /app/curier /curier +COPY --from=builder /tmp /tmp EXPOSE 39800 diff --git a/main.go b/main.go index 7550cb0..33ae76f 100644 --- a/main.go +++ b/main.go @@ -76,9 +76,8 @@ func parseEnvVars() { log.Printf("maxFileSize : %d bytes\n", maxFileSize) log.Printf("allowedFileExtensions: ") for ext := range allowedFileExtensions { - log.Printf("%s ", ext) + fmt.Printf("\t\t%s ", ext) } - log.Printf("\n\n") } func parseFS() { From ae351b2b719e7ce653217c25c7eb517676f6d7c4 Mon Sep 17 00:00:00 2001 From: Adrian PETERCA Date: Thu, 4 Jun 2026 20:43:01 +0300 Subject: [PATCH 6/7] fixed remote address --- handlers.go | 23 +++++++++++++++++++++-- main.go | 5 +++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/handlers.go b/handlers.go index e8d366d..e37a708 100644 --- a/handlers.go +++ b/handlers.go @@ -87,10 +87,11 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { return } + remoteAddr := getRemoteAddress(&r.Header) meta := FileMeta{ OriginalFilename: fileName, UploadedAt: time.Now(), - UploaderIP: r.RemoteAddr, // needs testing + UploaderIP: remoteAddr, } metaBytes, err := json.Marshal(meta) @@ -155,7 +156,8 @@ func downloadHandler(w http.ResponseWriter, r *http.Request) { filePath := filepath.Join(storagePath, id) - log.Printf("INFO: Serving file %s uploaded by %s to %s\n", meta.OriginalFilename, meta.UploaderIP, r.RemoteAddr) + remoteAddr := getRemoteAddress(&r.Header) + log.Printf("INFO: Serving file %s uploaded by %s to %s\n", meta.OriginalFilename, meta.UploaderIP, remoteAddr) w.Header().Set("Content-Disposition", "attachment; filename=\""+meta.OriginalFilename+"\"") http.ServeFile(w, r, filePath) @@ -268,6 +270,23 @@ func readMeta(id string) (*FileMeta, error) { return &meta, nil } +func getRemoteAddress(h *http.Header) string { + + // Cloudflare proxying traffic + ip := h.Get("Cf-Connecting-Ip") + if ip != "" { + return ip + } + + // Traffic coming from on-host reverse proxies like Caddy + ip = h.Get("X-Forwarded-For") + if ip != "" { + return ip + } + + return "" +} + func serveError(w http.ResponseWriter, code int) { var file string switch code { diff --git a/main.go b/main.go index 33ae76f..4a4e34f 100644 --- a/main.go +++ b/main.go @@ -74,10 +74,11 @@ func parseEnvVars() { log.Printf("host : %s\n", host) log.Printf("port : %s\n", port) log.Printf("maxFileSize : %d bytes\n", maxFileSize) - log.Printf("allowedFileExtensions: ") + exts := "" for ext := range allowedFileExtensions { - fmt.Printf("\t\t%s ", ext) + exts += "\t" + ext + "\n" } + log.Printf("allowedFileExtensions:\n%s", exts) } func parseFS() { From 7ace5b68f099565510607547e9082815a51444b8 Mon Sep 17 00:00:00 2001 From: Adrian PETERCA Date: Thu, 4 Jun 2026 21:49:27 +0300 Subject: [PATCH 7/7] Added cleanup process; added homepage button on /share/ page --- README.md | 11 +++++- cleanup.go | 67 ++++++++++++++++++++++++++++++++ config.go | 3 ++ handlers.go | 85 +--------------------------------------- main.go | 35 +++++++++++++---- templates/share.html | 4 ++ utils.go | 92 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 204 insertions(+), 93 deletions(-) create mode 100644 cleanup.go create mode 100644 utils.go diff --git a/README.md b/README.md index 72580b6..5df0670 100644 --- a/README.md +++ b/README.md @@ -35,5 +35,14 @@ For default values, check [config.go](https://github.com/adipeterca/curier/blob/ |`CURIER_STORAGE_PATH`|Absolute path where the file uploads will be stored on disk| |`CURIER_HOST`|Network address to bind to| |`CURIER_PORT`|Port to use (also affects the port used inside the container)| +|`CURIER_FILE_RETENTION_TIME`|How many hours (minimum 1) to keep the files on disk| |`CURIER_MAX_FILE_SIZE`|Maximum allowed size for each file upload| -|`CURIER_ALLOWED_FILE_EXTENSIONS`|A `;` separated list of file extensions, lowercase only (the last entry needs to have a `;` too)| \ No newline at end of file +|`CURIER_ALLOWED_FILE_EXTENSIONS`|A `;` separated list of file extensions, lowercase only (the last entry needs to have a `;` too)| + +## Technical details + +In no particular order: +- the application uses cryptographically secure IDs (128-bit randomness) to reference uploaded files +- files expire and get deleted automatically +- it comes as a single binary, with no external dependencies +- the frontend has some UX elements that prevent a user from uploading an invalid / unaccepted file (however, **THIS IS DONE PURELY AS A UX FEATURE, NOT A SECURITY FEATURE**) \ No newline at end of file diff --git a/cleanup.go b/cleanup.go new file mode 100644 index 0000000..7d7e409 --- /dev/null +++ b/cleanup.go @@ -0,0 +1,67 @@ +package main + +import ( + "log" + "os" + "path/filepath" + "time" +) + +func startCleanup() { + ticker := time.NewTicker(1 * time.Hour) + go func() { + for range ticker.C { + cleanup() + } + }() +} + +// Will it even panic? If so, it will crash the whole app - need to keep an eye on this +func cleanup() { + + log.Printf("INFO: Started a routine cleanup for expired files at %s\n", time.Now()) + entries, err := os.ReadDir(storagePath) + if err != nil { + log.Printf("ERROR: could not read storage directory: %s\n", err) + return + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if filepath.Ext(entry.Name()) == ".meta" { + continue + } + + id := entry.Name() + filePath := filepath.Join(storagePath, id) + + if isExpired(id) { + err = os.Remove(filePath) + if err != nil { + log.Printf("ERROR: could not remove %s, reason: %s\n", filePath, err) + } + + err = os.Remove(filePath + ".meta") + if err != nil { + log.Printf("ERROR: could not remove %s, reason: %s\n", filePath+".meta", err) + } + } + } + + log.Printf("INFO: Finished a routine cleanup at %s\n", time.Now()) +} + +func isExpired(id string) bool { + + metaFile, err := readMeta(id) + if err != nil { + log.Printf("ERROR: could not delete meta file for %s during cleanup, reason: %s\n", id, err) + // Return 'false' as a "skip this file" + return false + } + + retention := time.Duration(fileRetentionTime) * time.Hour + return time.Since(metaFile.UploadedAt) > retention +} diff --git a/config.go b/config.go index 4588979..07c286f 100644 --- a/config.go +++ b/config.go @@ -30,6 +30,9 @@ var host = "0.0.0.0" // Port to listen on - default 39800 var port = "39800" +// How many hours (minimum 1) to hold the files on disk - default 12 hours +var fileRetentionTime int64 = 12 + // --- Public variables --- // // This information can be queried by a GET request to the `/config/` endpoint. diff --git a/handlers.go b/handlers.go index e37a708..ca5970a 100644 --- a/handlers.go +++ b/handlers.go @@ -1,17 +1,13 @@ package main import ( - "crypto/rand" - "encoding/hex" "encoding/json" "fmt" "io" "log" - "mime/multipart" "net/http" "os" "path/filepath" - "strings" "time" ) @@ -48,7 +44,7 @@ func configHandler(w http.ResponseWriter, r *http.Request) { w.Write(configBytes) } -// uploadHandler saves the uploaded files to disk, if it passes the verifications. +// uploadHandler saves the uploaded file to disk, if it passes the verifications. // It will only return simple errors (JSON based) func uploadHandler(w http.ResponseWriter, r *http.Request) { @@ -208,85 +204,6 @@ func cssHandler(w http.ResponseWriter, r *http.Request) { // --- Helper functions --- -func validateId(id string) error { - - id = filepath.Base(id) // Simple check against path traversal - if len(id) != 32 { - 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") - - metaBytes, err := os.ReadFile(metaFilePath) - if err != nil { - return nil, err - } - var meta FileMeta - if err = json.Unmarshal(metaBytes, &meta); err != nil { - return nil, err - } - return &meta, nil -} - -func getRemoteAddress(h *http.Header) string { - - // Cloudflare proxying traffic - ip := h.Get("Cf-Connecting-Ip") - if ip != "" { - return ip - } - - // Traffic coming from on-host reverse proxies like Caddy - ip = h.Get("X-Forwarded-For") - if ip != "" { - return ip - } - - return "" -} - func serveError(w http.ResponseWriter, code int) { var file string switch code { diff --git a/main.go b/main.go index 4a4e34f..d457106 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,8 @@ func main() { var listenAddress = fmt.Sprintf("%s:%s", host, port) log.Printf("Starting and listening on http://%s ...\n", listenAddress) + startCleanup() + err := http.ListenAndServe(listenAddress, mux) if err != nil { log.Printf("ERROR: server failed at startup: %s\n", err) @@ -33,6 +35,8 @@ func main() { } func parseEnvVars() { + var err error + if envVar := os.Getenv("CURIER_STORAGE_PATH"); envVar != "" { storagePath = envVar } @@ -45,8 +49,20 @@ func parseEnvVars() { port = envVar } + if envVar := os.Getenv("CURIER_FILE_RETENTION_TIME"); envVar != "" { + fileRetentionTime, err = strconv.ParseInt(envVar, 10, 64) + if err != nil { + log.Printf("CRITICAL: failed to parse fileRetentionTime, reason: %s\n", err) + os.Exit(1) + } + if fileRetentionTime < 1 { + log.Printf("WARNING: fileRetentionTime needs to be at least 1 hour - parsed value is %d\n", fileRetentionTime) + log.Printf("WARNING: fileRetentionTime set to 1 hour\n") + fileRetentionTime = 1 + } + } + if envVar := os.Getenv("CURIER_MAX_FILE_SIZE"); envVar != "" { - var err error maxFileSize, err = strconv.ParseInt(envVar, 10, 64) if err != nil { log.Printf("CRITICAL: failed to parse maxFileSize, reason: %s\n", err) @@ -69,16 +85,19 @@ func parseEnvVars() { } } - log.Printf("\n\n --- Environment variables ---\n") - log.Printf("storagePath : %s\n", storagePath) - log.Printf("host : %s\n", host) - log.Printf("port : %s\n", port) - log.Printf("maxFileSize : %d bytes\n", maxFileSize) + fullConfig := "\n\n --- Environment variables ---\n" + fullConfig += fmt.Sprintf("storagePath : %s\n", storagePath) + fullConfig += fmt.Sprintf("host : %s\n", host) + fullConfig += fmt.Sprintf("port : %s\n", port) + fullConfig += fmt.Sprintf("fileRetentionTime : %d\n", fileRetentionTime) + fullConfig += fmt.Sprintf("maxFileSize : %d bytes\n", maxFileSize) exts := "" for ext := range allowedFileExtensions { - exts += "\t" + ext + "\n" + exts += fmt.Sprintf("\t\t\t%s\n", ext) } - log.Printf("allowedFileExtensions:\n%s", exts) + fullConfig += fmt.Sprintf("allowedFileExtensions:\n%s", exts) + + log.Println(fullConfig) } func parseFS() { diff --git a/templates/share.html b/templates/share.html index 44af8e6..82c244f 100644 --- a/templates/share.html +++ b/templates/share.html @@ -14,5 +14,9 @@

File name: {{.OriginalFilename}}

+
+
+

You want to share a file too?

+

Go to the homepage

\ No newline at end of file diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..fcf1d65 --- /dev/null +++ b/utils.go @@ -0,0 +1,92 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" +) + +func readMeta(id string) (*FileMeta, error) { + + metaFilePath := filepath.Join(storagePath, id+".meta") + + metaBytes, err := os.ReadFile(metaFilePath) + if err != nil { + return nil, err + } + var meta FileMeta + if err = json.Unmarshal(metaBytes, &meta); err != nil { + return nil, err + } + return &meta, nil +} + +func getRemoteAddress(h *http.Header) string { + + // Cloudflare proxying traffic + ip := h.Get("Cf-Connecting-Ip") + if ip != "" { + return ip + } + + // Traffic coming from on-host reverse proxies like Caddy + ip = h.Get("X-Forwarded-For") + if ip != "" { + return ip + } + + return "" +} + +func validateId(id string) error { + + id = filepath.Base(id) // Simple check against path traversal + if len(id) != 32 { + 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 +}