package main import ( "encoding/base64" "encoding/json" "fmt" "io" "log" "mime" "mime/multipart" "net/http" "net/url" "os" "path/filepath" "sort" "strings" "time" ) // ── Configuration ──────────────────────────────────────────────────────────── var ( uploadDir string port string serverEnabled bool allowedOrigins []string credentials struct{ username, password string } ) func getDockerSecret(name string) string { p := "/run/secrets/" + name data, err := os.ReadFile(p) if err != nil { return "" } return strings.TrimSpace(string(data)) } func init() { abs, err := filepath.Abs("./uploads") if err != nil { log.Fatalf("Cannot resolve uploads path: %v", err) } uploadDir = abs port = getEnv("PORT", "8080") serverEnabled = os.Getenv("ENABLE_HTTP_SERVER") == "true" // Password: Docker secret > env > default secretFile := getEnv("SERVER_PASSWORD_FILE", "my_secret") password := getDockerSecret(secretFile) source := "Docker Secret" if password == "" { password = os.Getenv("SERVER_PASSWORD") source = "environment variable (less secure)" } if password == "" { password = "securePass123" source = "default" } switch source { case "Docker Secret": log.Println("Using password from Docker Secret") case "environment variable (less secure)": log.Println("Warning: Using password from environment variable (less secure)") default: log.Println("Warning: Using default password. Set Docker Secret or SERVER_PASSWORD environment variable for production.") } credentials.username = getEnv("SERVER_USERNAME", "admin") credentials.password = password if os.Getenv("SERVER_USERNAME") == "" { log.Println("Warning: Using default username. Set SERVER_USERNAME environment variable for production.") } // Allowed origins raw := os.Getenv("ALLOWED_ORIGINS") for _, o := range strings.Split(raw, ",") { o = strings.TrimSpace(o) if o != "" { allowedOrigins = append(allowedOrigins, o) } } if len(allowedOrigins) == 0 { log.Println("Warning: No ALLOWED_ORIGINS configured. All cross-origin requests will be allowed. " + "Set ALLOWED_ORIGINS to a comma-separated list of trusted origins to restrict access.") } } // ── Helpers ─────────────────────────────────────────────────────────────────── func getEnv(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } // applyCorsHeaders mirrors the JS implementation. Returns true when CORS is allowed. func applyCorsHeaders(w http.ResponseWriter, r *http.Request) bool { origin := r.Header.Get("Origin") w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if origin == "" { return false } allowed := len(allowedOrigins) == 0 // empty list ⟹ allow all origins if !allowed { for _, o := range allowedOrigins { if o == origin { allowed = true break } } } if allowed { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Credentials", "true") return true } return false } func getServerOrigin(r *http.Request) string { host := r.Host if host == "" { return "" } scheme := "http" if r.TLS != nil { scheme = "https" } else if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" { scheme = strings.SplitN(fwd, ",", 2)[0] scheme = strings.TrimSpace(scheme) } return scheme + "://" + host } func authenticate(r *http.Request) bool { authHeader := r.Header.Get("Authorization") if authHeader == "" { return false } parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || parts[0] != "Basic" { return false } decoded, err := base64.StdEncoding.DecodeString(parts[1]) if err != nil { return false } pair := strings.SplitN(string(decoded), ":", 2) if len(pair) != 2 { return false } return pair[0] == credentials.username && pair[1] == credentials.password } // sanitizeFilename keeps only the base name and replaces Windows-illegal chars. func sanitizeFilename(name string) string { base := filepath.Base(name) // Replace characters illegal on Windows filesystems illegal := `\/:*?"<>|` for _, c := range illegal { base = strings.ReplaceAll(base, string(c), "_") } return base } // resolveSafePath resolves a path under uploadDir and rejects traversal attempts. func resolveSafePath(segments ...string) (string, error) { args := append([]string{uploadDir}, segments...) target := filepath.Join(args...) rel, err := filepath.Rel(uploadDir, target) if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { return "", fmt.Errorf("Invalid path") } return target, nil } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writePlain(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(status) _, _ = w.Write([]byte(msg)) } // ── Handlers ────────────────────────────────────────────────────────────────── func handleUpload(w http.ResponseWriter, r *http.Request, dirParam string) { ct := r.Header.Get("Content-Type") mediaType, params, err := mime.ParseMediaType(ct) if err != nil || !strings.HasPrefix(mediaType, "multipart/") { writePlain(w, http.StatusBadRequest, "Invalid Content-Type. Expected multipart/form-data") return } boundary := params["boundary"] if boundary == "" { writePlain(w, http.StatusBadRequest, "Missing boundary in Content-Type") return } mr := multipart.NewReader(r.Body, boundary) var fileData []byte var filename string for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { writePlain(w, http.StatusBadRequest, "Error reading multipart data") return } fn := part.FileName() if fn != "" { filename = fn fileData, err = io.ReadAll(part) if err != nil { writePlain(w, http.StatusInternalServerError, "Internal Server Error") return } log.Printf("Found file: %s, size: %d bytes", filename, len(fileData)) } part.Close() } if fileData == nil || filename == "" { writePlain(w, http.StatusBadRequest, "No valid file uploaded") return } safeFilename := sanitizeFilename(filename) if safeFilename == "" || safeFilename == "." || safeFilename == ".." { writePlain(w, http.StatusBadRequest, "Invalid filename") return } targetDir, err := resolveSafePath(dirParam) if err != nil { writePlain(w, http.StatusBadRequest, err.Error()) return } filePath, err := resolveSafePath(dirParam, safeFilename) if err != nil { writePlain(w, http.StatusBadRequest, err.Error()) return } if err := os.MkdirAll(targetDir, 0o755); err != nil { log.Printf("MkdirAll error: %v", err) writePlain(w, http.StatusInternalServerError, "Internal Server Error") return } if err := os.WriteFile(filePath, fileData, 0o644); err != nil { log.Printf("File write error: %v", err) writePlain(w, http.StatusInternalServerError, "Internal Server Error") return } writeJSON(w, http.StatusOK, map[string]any{ "success": true, "filename": safeFilename, "directory": dirParam, "message": "File uploaded successfully", }) } func handleDownload(w http.ResponseWriter, r *http.Request, dirParam string) { filename := r.URL.Query().Get("filename") if filename == "" { writePlain(w, http.StatusBadRequest, "Missing filename parameter") return } safeFilename := sanitizeFilename(filename) if safeFilename == "" || safeFilename == "." || safeFilename == ".." { writePlain(w, http.StatusBadRequest, "Invalid filename") return } filePath, err := resolveSafePath(dirParam, safeFilename) if err != nil { writePlain(w, http.StatusBadRequest, err.Error()) return } info, err := os.Stat(filePath) if os.IsNotExist(err) { writePlain(w, http.StatusNotFound, "File not found") return } if err != nil || info.IsDir() { writePlain(w, http.StatusBadRequest, "Invalid file") return } encoded := url.PathEscape(safeFilename) w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size())) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, encoded, encoded)) f, err := os.Open(filePath) if err != nil { writePlain(w, http.StatusInternalServerError, "Internal Server Error") return } defer f.Close() _, _ = io.Copy(w, f) } func handleDelete(w http.ResponseWriter, r *http.Request, dirParam string) { filename := r.URL.Query().Get("filename") if filename == "" { writePlain(w, http.StatusBadRequest, "Missing filename parameter") return } safeFilename := sanitizeFilename(filename) if safeFilename == "" || safeFilename == "." || safeFilename == ".." { writePlain(w, http.StatusBadRequest, "Invalid filename") return } filePath, err := resolveSafePath(dirParam, safeFilename) if err != nil { writePlain(w, http.StatusBadRequest, err.Error()) return } info, err := os.Stat(filePath) if os.IsNotExist(err) { writePlain(w, http.StatusNotFound, "File not found") return } if err != nil { writePlain(w, http.StatusInternalServerError, "Internal Server Error") return } if !info.Mode().IsRegular() { writePlain(w, http.StatusBadRequest, "Target is not a file") return } if err := os.Remove(filePath); err != nil { log.Printf("File delete error: %v", err) writePlain(w, http.StatusInternalServerError, "Internal Server Error") return } writeJSON(w, http.StatusOK, map[string]any{ "success": true, "filename": safeFilename, "directory": dirParam, "message": "File deleted successfully", }) } type fileEntry struct { Name string `json:"name"` Type string `json:"type"` Size *int64 `json:"size"` ModifiedTime string `json:"modifiedTime"` CreatedTime string `json:"createdTime"` } func handleList(w http.ResponseWriter, r *http.Request, dirParam string) { targetDir, err := resolveSafePath(dirParam) if err != nil { writePlain(w, http.StatusBadRequest, err.Error()) return } info, err := os.Stat(targetDir) if os.IsNotExist(err) { writePlain(w, http.StatusNotFound, "Directory not found") return } if err != nil || !info.IsDir() { writePlain(w, http.StatusBadRequest, "Target is not a directory") return } entries, err := os.ReadDir(targetDir) if err != nil { log.Printf("Directory read error: %v", err) writePlain(w, http.StatusInternalServerError, "Internal Server Error") return } list := make([]fileEntry, 0, len(entries)) for _, e := range entries { fi, err := e.Info() if err != nil { continue } entry := fileEntry{ Name: e.Name(), ModifiedTime: fi.ModTime().UTC().Format(time.RFC3339), } // birthtime: Go's os.FileInfo doesn't expose birthtime cross-platform, // so we fall back to ModTime (same behaviour for Linux containers). entry.CreatedTime = getBirthtime(fi) if e.IsDir() { entry.Type = "directory" } else { entry.Type = "file" sz := fi.Size() entry.Size = &sz } list = append(list, entry) } // Sort: directories first, then alphabetical sortFileList(list) writeJSON(w, http.StatusOK, map[string]any{ "success": true, "directory": dirParam, "files": list, "totalCount": len(list), }) } // getBirthtime returns the best available approximation of file creation time. // Go's os.FileInfo does not expose birthtime in a cross-platform way, so // ModTime is used as a portable fallback. func getBirthtime(fi os.FileInfo) string { return fi.ModTime().UTC().Format(time.RFC3339) } func sortFileList(list []fileEntry) { sort.Slice(list, func(i, j int) bool { if list[i].Type != list[j].Type { return list[i].Type == "directory" } return list[i].Name < list[j].Name }) } // ── Router ──────────────────────────────────────────────────────────────────── func handler(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") serverOrigin := getServerOrigin(r) isCrossOrigin := origin != "" && origin != serverOrigin corsAllowed := applyCorsHeaders(w, r) // Pre-flight if r.Method == http.MethodOptions { if isCrossOrigin && !corsAllowed { writePlain(w, http.StatusForbidden, "Origin not allowed") return } w.WriteHeader(http.StatusNoContent) return } // Block disallowed cross-origin requests if isCrossOrigin && !corsAllowed { writePlain(w, http.StatusForbidden, "Origin not allowed") return } // Basic Auth if !authenticate(r) { w.Header().Set("WWW-Authenticate", `Basic realm="Secure File Server"`) writePlain(w, http.StatusUnauthorized, "Unauthorized") return } dirParam := r.URL.Query().Get("dir") path := r.URL.Path switch { case r.Method == http.MethodPost && path == "/upload": handleUpload(w, r, dirParam) case r.Method == http.MethodGet && path == "/download": handleDownload(w, r, dirParam) case r.Method == http.MethodDelete && path == "/delete": handleDelete(w, r, dirParam) case r.Method == http.MethodGet && path == "/list": handleList(w, r, dirParam) default: writePlain(w, http.StatusNotFound, "Not Found") } } func main() { if !serverEnabled { log.Println("HTTP Server is disabled. Set ENABLE_HTTP_SERVER=true to enable it.") os.Exit(0) } if err := os.MkdirAll(uploadDir, 0o755); err != nil { log.Fatalf("Cannot create uploads directory: %v", err) } addr := ":" + port log.Printf("Secure File Server running at http://localhost%s", addr) log.Printf("Username: %s", credentials.username) log.Println("Password: [HIDDEN FOR SECURITY]") srv := &http.Server{ Addr: addr, Handler: http.HandlerFunc(handler), ReadTimeout: 5 * time.Minute, // allow large uploads WriteTimeout: 5 * time.Minute, IdleTimeout: 60 * time.Second, } log.Fatal(srv.ListenAndServe()) }