From b00e7fbf0ef26ae874178a15b42eb36d3f09bf70 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Mon, 30 Mar 2026 14:07:57 +0200 Subject: [PATCH] feat(init): add 60s countdown, skip key, and cancel detection to browser auth --- README.md | 66 +++++++++++++++++++++ internal/cmd/auth_browser.go | 109 ++++++++++++++++++++++++++++------- 2 files changed, 155 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 2d2904d..a89473a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,72 @@ irm https://get.torrentclaw.com/install.ps1 | iex brew install torrentclaw/tap/unarr ``` +### Docker + +```bash +docker run -d --name unarr \ + --restart unless-stopped \ + --network host \ + --read-only --memory 512m \ + -v ~/.config/torrentclaw:/config \ + -v ~/Media:/downloads \ + torrentclaw/unarr +``` + +Run setup first to configure your API key: + +```bash +docker run -it --rm \ + -v ~/.config/torrentclaw:/config \ + torrentclaw/unarr setup +``` + +### Docker Compose + +```bash +mkdir -p torrentclaw && cd torrentclaw +curl -fsSL https://raw.githubusercontent.com/torrentclaw/unarr/main/docker-compose.yml -o docker-compose.yml +docker compose up -d +``` + +
+docker-compose.yml + +```yaml +services: + unarr: + image: torrentclaw/unarr:latest + container_name: unarr + restart: unless-stopped + user: "1000:1000" + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + volumes: + - ./config:/config + - ~/Media:/downloads + - unarr-data:/data + environment: + - TZ=${TZ:-UTC} + # - UNARR_API_KEY=tc_your_key_here + deploy: + resources: + limits: + memory: 512M + cpus: "2.0" + # Host network for full P2P performance + network_mode: host + # Or use bridge with ports: + # ports: + # - "6881-6889:6881-6889/tcp" + # - "6881-6889:6881-6889/udp" + +volumes: + unarr-data: +``` + +
+ ### Go install ```bash diff --git a/internal/cmd/auth_browser.go b/internal/cmd/auth_browser.go index a74090f..186813a 100644 --- a/internal/cmd/auth_browser.go +++ b/internal/cmd/auth_browser.go @@ -8,11 +8,12 @@ import ( "net" "net/http" "net/url" + "os" "sync" "time" ) -const browserAuthTimeout = 5 * time.Minute +const browserAuthTimeout = 60 * time.Second // browserAuth opens a browser for the user to authorize the CLI. // Returns the API key on success, or an error if the flow fails/times out. @@ -60,6 +61,14 @@ func browserAuth(apiURL string) (string, error) { return } + // Check if user rejected the authorization + if r.URL.Query().Get("rejected") == "1" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, rejectedHTML) + errCh <- fmt.Errorf("authorization rejected by user") + return + } + token := r.URL.Query().Get("token") if token == "" { http.Error(w, "No token received", http.StatusBadRequest) @@ -88,37 +97,74 @@ func browserAuth(apiURL string) (string, error) { }() // Open browser - authURL := fmt.Sprintf("%s/cli/auth?state=%s&port=%d", apiURL, url.QueryEscape(state), port) + authURL := fmt.Sprintf("%s/unarr/auth?state=%s&port=%d", apiURL, url.QueryEscape(state), port) openBrowser(authURL) - // Wait for callback, error, or timeout + // Listen for Enter key to skip to manual fallback + skipCh := make(chan struct{}, 1) + go func() { + buf := make([]byte, 1) + for { + n, err := os.Stdin.Read(buf) + if err != nil || n == 0 { + return + } + if buf[0] == '\n' || buf[0] == '\r' { + skipCh <- struct{}{} + return + } + } + }() + + // Wait for callback with countdown ctx, cancel := context.WithTimeout(context.Background(), browserAuthTimeout) defer cancel() + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + remaining := int(browserAuthTimeout.Seconds()) + + // Show initial countdown + fmt.Printf("\r Waiting for browser authorization... %ds (Enter to skip) ", remaining) + var token string - select { - case token = <-tokenCh: - // Success - case err := <-errCh: - shutdownCtx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second) - _ = server.Shutdown(shutdownCtx2) - cancel2() - return "", err - case <-ctx.Done(): - shutdownCtx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second) - _ = server.Shutdown(shutdownCtx2) - cancel2() - return "", fmt.Errorf("timed out waiting for browser authorization") + done := false + for !done { + select { + case token = <-tokenCh: + fmt.Print("\r\033[K") // clear countdown line + done = true + case err := <-errCh: + fmt.Print("\r\033[K") + shutdownServer(server) + return "", err + case <-ctx.Done(): + fmt.Print("\r\033[K") + shutdownServer(server) + return "", fmt.Errorf("timed out waiting for browser authorization") + case <-skipCh: + fmt.Print("\r\033[K") + shutdownServer(server) + return "", fmt.Errorf("skipped by user") + case <-ticker.C: + remaining-- + if remaining >= 0 { + fmt.Printf("\r Waiting for browser authorization... %ds (Enter to skip) ", remaining) + } + } } - // Shutdown the server - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second) - defer shutdownCancel() - _ = server.Shutdown(shutdownCtx) + shutdownServer(server) return token, nil } +func shutdownServer(server *http.Server) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(ctx) +} + func generateState() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { @@ -127,6 +173,29 @@ func generateState() (string, error) { return hex.EncodeToString(b), nil } +// rejectedHTML is the page shown in the browser when the user clicks Cancel. +const rejectedHTML = ` + + + + unarr — Cancelled + + + +
+
+

Authorization cancelled

+

You can close this tab. Use unarr init to try again.

+
+ +` + // callbackHTML is the page shown in the browser after successful authorization. const callbackHTML = `