aboutsummaryrefslogtreecommitdiff
path: root/modules/services/nixos/ganyupload
diff options
context:
space:
mode:
Diffstat (limited to 'modules/services/nixos/ganyupload')
-rw-r--r--modules/services/nixos/ganyupload/AGENTS.md65
-rw-r--r--modules/services/nixos/ganyupload/main.go212
2 files changed, 270 insertions, 7 deletions
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.<hostname>.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.<hostname>.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, `<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Directory: %s</title>
+ <style>
+ body { font-family: monospace; margin: 2rem; }
+ h1 { margin-bottom: 0.5rem; }
+ .breadcrumb { margin-bottom: 2rem; font-size: 0.9rem; }
+ .breadcrumb a { text-decoration: none; color: #0066cc; }
+ .breadcrumb a:hover { text-decoration: underline; }
+ table { border-collapse: collapse; width: 100%%; }
+ td { padding: 0.5rem; border-bottom: 1px solid #ccc; }
+ td:first-child { text-align: left; }
+ td:last-child { text-align: right; color: #666; }
+ a { text-decoration: none; color: #0066cc; }
+ a:hover { text-decoration: underline; }
+ .directory { color: #0066cc; font-weight: bold; }
+ </style>
+</head>
+<body>
+ <h1>Directory: %s</h1>
+`, html.EscapeString(relPath), html.EscapeString(relPath))
+
+ // Breadcrumb navigation
+ fmt.Fprint(w, ` <div class="breadcrumb">`)
+ fmt.Fprintf(w, `<a href="/">home</a>`)
+ if len(pathParts) > 0 {
+ accumulated := ""
+ for _, part := range pathParts {
+ accumulated = accumulated + "/" + part
+ fmt.Fprintf(w, ` / <a href="%s">%s</a>`, html.EscapeString(accumulated), html.EscapeString(part))
+ }
+ }
+ fmt.Fprint(w, `</div>`)
+
+ // Directory listing
+ fmt.Fprint(w, ` <table>
+`)
+ 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, ` <tr><td><a href="/%s" class="directory">%s/</a></td><td>-</td></tr>
+`, 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, ` <tr><td><a href="/%s">%s</a></td><td>%s</td></tr>
+`, html.EscapeString(linkPath), safeName, size)
+ }
+ }
+ fmt.Fprint(w, ` </table>
+</body>
+</html>
+`)
+}
+
+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