feat(init): add 60s countdown, skip key, and cancel detection to browser auth
This commit is contained in:
parent
7a655b6e86
commit
b00e7fbf0e
2 changed files with 155 additions and 20 deletions
66
README.md
66
README.md
|
|
@ -34,6 +34,72 @@ irm https://get.torrentclaw.com/install.ps1 | iex
|
||||||
brew install torrentclaw/tap/unarr
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>docker-compose.yml</summary>
|
||||||
|
|
||||||
|
```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:
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Go install
|
### Go install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,12 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const browserAuthTimeout = 5 * time.Minute
|
const browserAuthTimeout = 60 * time.Second
|
||||||
|
|
||||||
// browserAuth opens a browser for the user to authorize the CLI.
|
// 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.
|
// 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
|
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")
|
token := r.URL.Query().Get("token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
http.Error(w, "No token received", http.StatusBadRequest)
|
http.Error(w, "No token received", http.StatusBadRequest)
|
||||||
|
|
@ -88,37 +97,74 @@ func browserAuth(apiURL string) (string, error) {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Open browser
|
// 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)
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), browserAuthTimeout)
|
||||||
defer cancel()
|
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
|
var token string
|
||||||
select {
|
done := false
|
||||||
case token = <-tokenCh:
|
for !done {
|
||||||
// Success
|
select {
|
||||||
case err := <-errCh:
|
case token = <-tokenCh:
|
||||||
shutdownCtx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
|
fmt.Print("\r\033[K") // clear countdown line
|
||||||
_ = server.Shutdown(shutdownCtx2)
|
done = true
|
||||||
cancel2()
|
case err := <-errCh:
|
||||||
return "", err
|
fmt.Print("\r\033[K")
|
||||||
case <-ctx.Done():
|
shutdownServer(server)
|
||||||
shutdownCtx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
|
return "", err
|
||||||
_ = server.Shutdown(shutdownCtx2)
|
case <-ctx.Done():
|
||||||
cancel2()
|
fmt.Print("\r\033[K")
|
||||||
return "", fmt.Errorf("timed out waiting for browser authorization")
|
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
|
shutdownServer(server)
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer shutdownCancel()
|
|
||||||
_ = server.Shutdown(shutdownCtx)
|
|
||||||
|
|
||||||
return token, nil
|
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) {
|
func generateState() (string, error) {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
|
@ -127,6 +173,29 @@ func generateState() (string, error) {
|
||||||
return hex.EncodeToString(b), nil
|
return hex.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rejectedHTML is the page shown in the browser when the user clicks Cancel.
|
||||||
|
const rejectedHTML = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>unarr — Cancelled</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #0a0a0a; color: #fafafa; }
|
||||||
|
.card { text-align: center; padding: 3rem; }
|
||||||
|
.icon { font-size: 4rem; margin-bottom: 1rem; }
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||||
|
p { color: #888; font-size: 0.95rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon">—</div>
|
||||||
|
<h1>Authorization cancelled</h1>
|
||||||
|
<p>You can close this tab. Use <code>unarr init</code> to try again.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
// callbackHTML is the page shown in the browser after successful authorization.
|
// callbackHTML is the page shown in the browser after successful authorization.
|
||||||
const callbackHTML = `<!DOCTYPE html>
|
const callbackHTML = `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue