diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 353be3e..b3ad37f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -10,6 +10,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + packages: write steps: - uses: actions/checkout@v6 @@ -34,4 +35,20 @@ jobs: with: files: | curier - curier.exe \ No newline at end of file + curier.exe + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and Push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/${{ github.repository }}:latest + ghcr.io/${{ github.repository }}:${{ github.ref_name }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..330d5ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.24 AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o curier . + +FROM scratch + +COPY --from=builder /app/curier /curier + +EXPOSE 8080 + +ENTRYPOINT ["/curier"] \ No newline at end of file diff --git a/README.md b/README.md index 554ba07..239c265 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,36 @@ A small Go server for sharing files across the internet. ## How to setup -### Linux environment +### Docker environment -_I need to add this part, via a dedicated Bash script_ +You can download the Dockerfile and build the image yourself, or simply pull it from the repo: +```bash +docker pull ghcr.io/adipeterca/curier:latest +docker run -p 8080:8080 ghcr.io/adipeterca/curier:latest +``` -### Windows environment +### Linux environment -_I need to add this part, via a dedicated Powershell script_ +**I strongly recommend using Docker, as it simplifies the configuration a lot**. -### Docker environment +If you want to use a precompiled binary, please refer to the [Release](https://github.com/adipeterca/curier/releases) section. + +### Windows environment -_I need to add this part, either as a Dockerfile or an already built container_ +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. ## Configuration -You can configure some aspects of the service via environment variables prefixed with **CURIER_**. -| Variable name | Default value | Description | -|--|--|--| -|`CURIER_STORAGE_PATH`|`/var/lib/curier/uploads/` (Linux/Docker), `?` (Windows)|Absolute path where the file uploads will be stored on disk| -|`CURIER_BASE_URL`|`http://localhost`|Default prefix for the `/download/{id}` URL| -|`CURIER_HOST`|`127.0.0.1`|Network address to bind to| -|`CURIER_PORT`|`8080`|Port to use| \ No newline at end of file +You can configure some aspects of the service via environment variables prefixed with **CURIER_**. +Some information will be exposed via the `/config/` endpoint for better UX. +For default values, check [config.go](https://github.com/adipeterca/curier/blob/main/config.go). + +| 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| +|`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 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: ...




- +