From 35af4bab99da94845ebd6e9b2efae6a544d39b35 Mon Sep 17 00:00:00 2001 From: Collin Williams <96917990+bluedragon1221@users.noreply.github.com> Date: Fri, 22 May 2026 10:23:58 -0500 Subject: LOTS OF TEMP STUFF --- modules/services/nixos/ganyupload/AGENTS.md | 65 +++++++++ modules/services/nixos/ganyupload/main.go | 212 +++++++++++++++++++++++++++- 2 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 modules/services/nixos/ganyupload/AGENTS.md (limited to 'modules/services/nixos/ganyupload') diff --git a/modules/services/nixos/ganyupload/AGENTS.md b/modules/services/nixos/ganyupload/AGENTS.md new file mode 100644 index 0000000..c4d9192 --- /dev/null +++ b/modules/services/nixos/ganyupload/AGENTS.md @@ -0,0 +1,65 @@ +# Ganyupload - Agent Instructions + +## Quick Context +- Simple Go file upload service using standard `net/http`. +- Single `main.go`, two Nix files (`default.nix`, `pkg.nix`), one README. +- Embedded README is served on GET /. +- Used as a NixOS module: `collinux.services.ganyupload` enables it, configures port and upload directory via systemd environment. + +## How to Build and Test + +### Build +```bash +nix build +``` + +Alternatively, from the repo root: +```bash +nix build .#nixosConfigurations..config.system.build.toplevel +``` + +### Test +No dedicated test file. Verify locally by running with environment variables: +```bash +PORT=8080 UPLOAD_DIR=/tmp/upload go run main.go +``` + +Then test uploads: +```bash +curl -X PUT --data-binary @file.txt http://localhost:8080/file.txt +curl http://localhost:8080/ # Read embedded README +``` + +## Code Style + +**Go:** +- Keep `main.go` clean; use `gofmt -w .`. +- Match existing error handling (early return, `log.Printf` for warnings, `log.Fatalf` for fatal errors). +- Path traversal prevention is critical; do not weaken `filepath.Clean` + `..` prefix checks. + +**Nix:** +- Follow `/home/collin/nixos/AGENTS.md` conventions (2-space indent, common arg pattern, `lib.mkIf` for gating). +- `default.nix` imports `mkCaddyCfg.nix` to integrate with Caddy reverse proxy; do not remove that import. + +## Key Implementation Notes + +1. **Embedded README:** The README file is embedded at compile time using `//go:embed README`. If you update README, rebuild to reflect changes. +2. **Environment Variables:** + - `UPLOAD_DIR`: Defaults to `.`, overridden by systemd service to `/media/ganyupload`. + - `PORT`: Defaults to `8080`, set by systemd to the configured `cfg.port`. +3. **NixOS Integration:** The service runs as user/group `ganyupload` with home `/var/lib/ganyupload`. Ensure the service has write permission to `UPLOAD_DIR`. +4. **Caddy Integration:** `default.nix` imports `mkCaddyCfg.nix` to configure Caddy as a reverse proxy. Changes to hostname or port must be reflected in both Nix config and the Caddy rule. + +## Common Tasks + +- **Update Go code:** Edit `main.go`, test locally, then nix build to verify. +- **Change upload directory or port:** Update `default.nix` (systemd environment or `cfg.port`). +- **Update README:** Edit README file, then rebuild (`nix build`) so the embedded version updates. +- **Debug NixOS module:** Check `/etc/systemd/system/ganyupload.service` for actual service config after rebuild. + +## Validation + +After changes: +1. Run `nix build` from this directory or `nix build .#nixosConfigurations..config.system.build.toplevel` from repo root. +2. If applicable, test via `PORT=8080 UPLOAD_DIR=/tmp go run main.go`. +3. Verify no Nix lint issues (use repo-level `yo test` or `nix run nixpkgs#alejandra -- .` for formatting). diff --git a/modules/services/nixos/ganyupload/main.go b/modules/services/nixos/ganyupload/main.go index 6aa219e..75852c2 100644 --- a/modules/services/nixos/ganyupload/main.go +++ b/modules/services/nixos/ganyupload/main.go @@ -2,11 +2,14 @@ package main import ( _ "embed" + "fmt" + "html" "io" "log" "net/http" "os" "path/filepath" + "sort" "strings" ) @@ -15,6 +18,42 @@ var readme string var uploadDir = "." +// cleanPath safely cleans and validates a path to prevent traversal attacks +func cleanPath(filename string) (string, error) { + // Remove leading slash + filename = strings.TrimPrefix(filename, "/") + if filename == "" { + return "", nil + } + + // Clean the path + filename = filepath.Clean(filename) + + // Reject if it tries to go up + if strings.HasPrefix(filename, "..") || strings.Contains(filename, "/../") { + return "", fmt.Errorf("invalid filename: path traversal not allowed") + } + + return filename, nil +} + +// isPathSafe checks that the resolved fullPath stays within uploadDir +func isPathSafe(uploadDir, fullPath string) bool { + // Resolve both paths to absolute to catch symlink attacks + absUploadDir, err := filepath.Abs(uploadDir) + if err != nil { + return false + } + absFullPath, err := filepath.Abs(fullPath) + if err != nil { + return false + } + + // Ensure the resolved path starts with uploadDir + return strings.HasPrefix(absFullPath, absUploadDir+string(filepath.Separator)) || + absFullPath == absUploadDir +} + func handleRoot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -32,27 +71,37 @@ func handleUpload(w http.ResponseWriter, r *http.Request) { return } + // Handle GET requests for downloads and directory listing + if r.Method == http.MethodGet { + handleDownload(w, r) + return + } + if r.Method != http.MethodPut { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Extract filename from URL path, preventing path traversal - filename := strings.TrimPrefix(r.URL.Path, "/") - if filename == "" { - http.Error(w, "filename required", http.StatusBadRequest) + filename, err := cleanPath(r.URL.Path) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } - // Prevent path traversal - filename = filepath.Clean(filename) - if strings.HasPrefix(filename, "..") { - http.Error(w, "invalid filename", http.StatusBadRequest) + if filename == "" { + http.Error(w, "filename required", http.StatusBadRequest) return } fullPath := filepath.Join(uploadDir, filename) + // Verify the path is safe + if !isPathSafe(uploadDir, fullPath) { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + // Ensure the directory exists dir := filepath.Dir(fullPath) if err := os.MkdirAll(dir, 0755); err != nil { @@ -85,6 +134,155 @@ func handleUpload(w http.ResponseWriter, r *http.Request) { log.Printf("uploaded %s (%d bytes)", filename, written) } +func handleDownload(w http.ResponseWriter, r *http.Request) { + // Extract filename from URL path + filename, err := cleanPath(r.URL.Path) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + fullPath := filepath.Join(uploadDir, filename) + + // Verify the path is safe + if !isPathSafe(uploadDir, fullPath) { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + + fileInfo, err := os.Stat(fullPath) + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "not found", http.StatusNotFound) + } else { + log.Printf("failed to stat: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } + return + } + + // If it's a directory, list its contents + if fileInfo.IsDir() { + handleDirList(w, r, uploadDir, filename, fullPath) + return + } + + // Serve the file + http.ServeFile(w, r, fullPath) +} + +func handleDirList(w http.ResponseWriter, r *http.Request, uploadDir, relPath, fullPath string) { + entries, err := os.ReadDir(fullPath) + if err != nil { + log.Printf("failed to read directory: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // Sort entries by name + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + // Build breadcrumb navigation + pathParts := strings.Split(strings.Trim(relPath, "/"), "/") + if relPath == "" { + pathParts = []string{} + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, ` + + + + + Directory: %s + + + +

Directory: %s

+`, html.EscapeString(relPath), html.EscapeString(relPath)) + + // Breadcrumb navigation + fmt.Fprint(w, ` `) + + // Directory listing + fmt.Fprint(w, ` +`) + for _, entry := range entries { + name := entry.Name() + safeName := html.EscapeString(name) + + if entry.IsDir() { + // For directories, add trailing slash to the link + linkPath := relPath + "/" + name + if relPath == "" { + linkPath = "/" + name + } + linkPath = strings.TrimPrefix(linkPath, "/") + fmt.Fprintf(w, ` +`, html.EscapeString(linkPath), safeName) + } else { + info, _ := entry.Info() + linkPath := relPath + "/" + name + if relPath == "" { + linkPath = "/" + name + } + linkPath = strings.TrimPrefix(linkPath, "/") + size := formatSize(info.Size()) + fmt.Fprintf(w, ` +`, html.EscapeString(linkPath), safeName, size) + } + } + fmt.Fprint(w, `
%s/-
%s%s
+ + +`) +} + +func formatSize(bytes int64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + + switch { + case bytes < KB: + return fmt.Sprintf("%d B", bytes) + case bytes < MB: + return fmt.Sprintf("%.1f KB", float64(bytes)/KB) + case bytes < GB: + return fmt.Sprintf("%.1f MB", float64(bytes)/MB) + default: + return fmt.Sprintf("%.1f GB", float64(bytes)/GB) + } +} + func main() { if envUploadDir := os.Getenv("UPLOAD_DIR"); envUploadDir != "" { uploadDir = envUploadDir -- cgit v1.3.1