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/README.md b/README.md index a98a43e..5df0670 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. @@ -35,8 +33,16 @@ 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_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 1142008..07c286f 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,15 +24,15 @@ 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" // 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 d5e1b58..ca5970a 100644 --- a/handlers.go +++ b/handlers.go @@ -1,24 +1,20 @@ package main import ( - "crypto/rand" - "encoding/hex" "encoding/json" "fmt" - "html/template" "io" - "mime/multipart" + "log" "net/http" "os" "path/filepath" - "strings" "time" ) 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 +35,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 } @@ -48,13 +44,24 @@ 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) { + + 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 +70,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,20 +78,21 @@ 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 } + remoteAddr := getRemoteAddress(&r.Header) meta := FileMeta{ OriginalFilename: fileName, UploadedAt: time.Now(), - UploaderIP: r.RemoteAddr, // needs testing + UploaderIP: remoteAddr, } 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 +100,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 +109,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,17 +120,17 @@ 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) - fmt.Fprintf(w, `{"url": "%s:%s/share/%s"}`, host, port, id) + fmt.Fprintf(w, `{"path": "/share/%s"}`, id) } func downloadHandler(w http.ResponseWriter, r *http.Request) { @@ -130,21 +138,22 @@ 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) + 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) @@ -155,7 +164,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 +173,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 +183,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 +194,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 } @@ -202,68 +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 serveError(w http.ResponseWriter, code int) { var file string switch code { @@ -278,7 +218,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/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 diff --git a/main.go b/main.go index 623fa35..d457106 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,23 +24,23 @@ 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) + + startCleanup() 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) } } func parseEnvVars() { + var err error + if envVar := os.Getenv("CURIER_STORAGE_PATH"); envVar != "" { storagePath = envVar } - if envVar := os.Getenv("CURIER_URL_BASE_PATH"); envVar != "" { - urlBasePath = envVar - } - if envVar := os.Getenv("CURIER_HOST"); envVar != "" { host = envVar } @@ -46,11 +49,23 @@ 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 { - 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 +80,31 @@ 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: ") + 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 { - fmt.Printf("%s ", ext) + exts += fmt.Sprintf("\t\t\t%s\n", ext) + } + fullConfig += fmt.Sprintf("allowedFileExtensions:\n%s", exts) + + log.Println(fullConfig) +} + +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") } 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 @@