changes
This commit is contained in:
342
main.go
342
main.go
@@ -6,10 +6,15 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -18,31 +23,96 @@ import (
|
||||
var webFS embed.FS
|
||||
|
||||
const (
|
||||
rpcURL = "http://192.168.20.22:3210/transmission/rpc"
|
||||
sessionHeader = "X-Transmission-Session-Id"
|
||||
defaultRPCURL = "http://192.168.20.22:3210/transmission/rpc"
|
||||
envTransmissionURL = "TRANSMISSION_URL"
|
||||
envTransmissionRPCUsername = "TRANSMISSION_RPC_USERNAME"
|
||||
envTransmissionRPCPassword = "TRANSMISSION_RPC_PASSWORD"
|
||||
sessionHeader = "X-Transmission-Session-Id"
|
||||
)
|
||||
|
||||
// Client is a minimal Transmission RPC client with session-id handshake.
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
rpcURL string
|
||||
username string
|
||||
password string
|
||||
mu sync.Mutex
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
func NewClient(rpcURL, username, password string) *Client {
|
||||
return &Client{
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
rpcURL: rpcURL,
|
||||
username: username,
|
||||
password: password,
|
||||
}
|
||||
}
|
||||
|
||||
func NewClientFromEnv() (*Client, error) {
|
||||
rpcURL := configuredRPCURL()
|
||||
if err := validateRPCURL(rpcURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewClient(
|
||||
rpcURL,
|
||||
optionalEnv(envTransmissionRPCUsername),
|
||||
optionalEnv(envTransmissionRPCPassword),
|
||||
), nil
|
||||
}
|
||||
|
||||
func configuredRPCURL() string {
|
||||
rpcURL := strings.TrimSpace(os.Getenv(envTransmissionURL))
|
||||
if rpcURL == "" {
|
||||
return defaultRPCURL
|
||||
}
|
||||
return rpcURL
|
||||
}
|
||||
|
||||
func optionalEnv(name string) string {
|
||||
value := os.Getenv(name)
|
||||
if strings.EqualFold(strings.TrimSpace(value), "null") {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validateRPCURL(raw string) error {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s is invalid: %w", envTransmissionURL, err)
|
||||
}
|
||||
if parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("%s must be an absolute http or https URL", envTransmissionURL)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return fmt.Errorf("%s must use http or https", envTransmissionURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func redactedURL(raw string) string {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
if parsed.User != nil {
|
||||
parsed.User = url.User("redacted")
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// rpc sends a JSON-RPC request, handling the 409 session handshake with one retry.
|
||||
func (c *Client) rpc(ctx context.Context, body []byte) ([]byte, error) {
|
||||
doRequest := func(sessionID string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.rpcURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.username != "" || c.password != "" {
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
}
|
||||
if sessionID != "" {
|
||||
req.Header.Set(sessionHeader, sessionID)
|
||||
}
|
||||
@@ -103,6 +173,7 @@ type rpcRequest struct {
|
||||
|
||||
type rpcArgs struct {
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
IDs []int `json:"ids,omitempty"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
@@ -147,6 +218,30 @@ func (c *Client) Stats(ctx context.Context) (json.RawMessage, error) {
|
||||
return resp.Args, nil
|
||||
}
|
||||
|
||||
func (c *Client) TorrentAction(ctx context.Context, method string, ids []int) error {
|
||||
reqBody, _ := json.Marshal(rpcRequest{
|
||||
Method: method,
|
||||
Arguments: rpcArgs{IDs: ids},
|
||||
})
|
||||
data, err := c.rpc(ctx, reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp rpcResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Result != "success" {
|
||||
return fmt.Errorf("rpc result: %s", resp.Result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Check(ctx context.Context) error {
|
||||
_, err := c.Stats(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, data json.RawMessage) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(append(data, '\n'))
|
||||
@@ -158,13 +253,242 @@ func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func writeConnectionWarningPage(w http.ResponseWriter, target string, err error) {
|
||||
if target == "" {
|
||||
target = "not configured"
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprintf(w, `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Transmission connection warning</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--panel: #12121c;
|
||||
--panel-2: #181826;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--text: #f5f6fa;
|
||||
--muted: #8a8d9b;
|
||||
--accent: #00d4ff;
|
||||
--warn: #fbbf24;
|
||||
--err: #f87171;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: radial-gradient(900px 480px at 82%% -8%%, rgba(139, 92, 246, 0.07), transparent 70%%),
|
||||
radial-gradient(820px 460px at 12%% -4%%, rgba(0, 212, 255, 0.06), transparent 70%%), var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
main {
|
||||
width: min(720px, 100%%);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 18px 55px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--warn);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
color: var(--text);
|
||||
font-size: clamp(26px, 5vw, 40px);
|
||||
line-height: 1.05;
|
||||
}
|
||||
p {
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
.row {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
dt {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
}
|
||||
a {
|
||||
color: var(--bg);
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p class="eyebrow">Connection warning</p>
|
||||
<h1>Transmission is not reachable</h1>
|
||||
<p>The manager is running, but it could not set up a connection to the Transmission RPC endpoint.</p>
|
||||
<dl>
|
||||
<div class="row">
|
||||
<dt>RPC target</dt>
|
||||
<dd>%s</dd>
|
||||
</div>
|
||||
<div class="row">
|
||||
<dt>Error</dt>
|
||||
<dd class="error">%s</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p>Check %s, %s, and %s, then refresh this page.</p>
|
||||
<div class="actions">
|
||||
<a href="/">Retry connection</a>
|
||||
<p class="hint">The app will load once the RPC endpoint responds successfully.</p>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`, html.EscapeString(target), html.EscapeString(err.Error()), envTransmissionURL, envTransmissionRPCUsername, envTransmissionRPCPassword)
|
||||
}
|
||||
|
||||
func requireTransmissionClient(w http.ResponseWriter, client *Client, setupErr error) bool {
|
||||
if setupErr == nil && client != nil {
|
||||
return true
|
||||
}
|
||||
msg := "transmission client is not configured"
|
||||
if setupErr != nil {
|
||||
msg = setupErr.Error()
|
||||
}
|
||||
writeError(w, http.StatusBadGateway, msg)
|
||||
return false
|
||||
}
|
||||
|
||||
func handleTorrentAction(client *Client, setupErr error) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
if !requireTransmissionClient(w, client, setupErr) {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/torrents/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(parts[0])
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid torrent id")
|
||||
return
|
||||
}
|
||||
|
||||
var method string
|
||||
switch parts[1] {
|
||||
case "pause":
|
||||
method = "torrent-stop"
|
||||
case "resume":
|
||||
method = "torrent-start"
|
||||
default:
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := client.TorrentAction(r.Context(), method, []int{id}); err != nil {
|
||||
log.Printf("torrent action %s id=%d: %v", parts[1], id, err)
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, json.RawMessage(`{"ok":true}`))
|
||||
}
|
||||
}
|
||||
|
||||
func handleStaticFiles(fileServer http.Handler, client *Client, setupErr error) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && (r.URL.Path == "/" || r.URL.Path == "/index.html") {
|
||||
target := redactedURL(configuredRPCURL())
|
||||
if setupErr != nil {
|
||||
writeConnectionWarningPage(w, target, setupErr)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
err := client.Check(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("connection check: %v", err)
|
||||
writeConnectionWarningPage(w, target, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
client := NewClient()
|
||||
client, setupErr := NewClientFromEnv()
|
||||
if setupErr != nil {
|
||||
log.Printf("transmission client setup: %v", setupErr)
|
||||
} else {
|
||||
log.Printf("transmission RPC target: %s", redactedURL(client.rpcURL))
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API routes
|
||||
mux.HandleFunc("/api/torrents", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTransmissionClient(w, client, setupErr) {
|
||||
return
|
||||
}
|
||||
args, err := client.Torrents(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("torrents: %v", err)
|
||||
@@ -173,8 +497,12 @@ func main() {
|
||||
}
|
||||
writeJSON(w, args)
|
||||
})
|
||||
mux.HandleFunc("/api/torrents/", handleTorrentAction(client, setupErr))
|
||||
|
||||
mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTransmissionClient(w, client, setupErr) {
|
||||
return
|
||||
}
|
||||
args, err := client.Stats(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("stats: %v", err)
|
||||
@@ -190,7 +518,7 @@ func main() {
|
||||
log.Fatalf("embed sub: %v", err)
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(webRoot))
|
||||
mux.Handle("/", fileServer)
|
||||
mux.Handle("/", handleStaticFiles(fileServer, client, setupErr))
|
||||
|
||||
addr := ":8080"
|
||||
log.Printf("transmission-manager listening on %s", addr)
|
||||
|
||||
Reference in New Issue
Block a user