Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions cmd/rdctl-bot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import (

"github.com/crazyuploader/rdctl-bot/internal/bot"
"github.com/crazyuploader/rdctl-bot/internal/config"
"github.com/crazyuploader/rdctl-bot/internal/db"
"github.com/crazyuploader/rdctl-bot/internal/realdebrid"
"github.com/crazyuploader/rdctl-bot/internal/web"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
Expand Down Expand Up @@ -152,6 +155,12 @@ func runBot(cmd *cobra.Command, args []string) {
return
}

// Initialize database
database, err := db.Init(cfg.Database.GetDSN())
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}

// Log configuration details
log.Printf("Allowed chat IDs: %v", cfg.Telegram.AllowedChatIDs)
log.Printf("Super admin IDs: %v", cfg.Telegram.SuperAdminIDs)
Expand All @@ -169,6 +178,20 @@ func runBot(cmd *cobra.Command, args []string) {
log.Fatalf("Failed to create bot: %v", err)
}

go func() {
// Create dependencies for web handlers
deps := web.Dependencies{
RDClient: realdebrid.NewClient(cfg.RealDebrid.BaseURL, cfg.RealDebrid.APIToken, cfg.RealDebrid.Proxy, time.Duration(cfg.RealDebrid.Timeout)*time.Second),
UserRepo: db.NewUserRepository(database),
Comment on lines +184 to +185
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Avoid os.Exit in NewClient; return an error instead

internal/realdebrid/client.go: NewClient calls os.Exit(1) on invalid proxy URL (per snippet), which can terminate the whole process from here. Change it to return (*Client, error) and handle the error at the call site.

Would you like a patch for internal/realdebrid/client.go and the call sites?


🏁 Script executed:

#!/bin/bash
# Find and examine the NewClient function in internal/realdebrid/client.go
fd -t f "client.go" internal/realdebrid/ && cat internal/realdebrid/client.go

Length of output: 5714


🏁 Script executed:

#!/bin/bash
# Search for all calls to NewClient in the codebase
rg -n "NewClient" --type go -B2 -A2

Length of output: 1090


🏁 Script executed:

#!/bin/bash
# Search for os.Exit calls in internal/realdebrid/client.go
rg -n "os\.Exit" internal/realdebrid/

Length of output: 46


🏁 Script executed:

#!/bin/bash
# Examine lines 180-190 in cmd/rdctl-bot/main.go for context
cat cmd/rdctl-bot/main.go | sed -n '180,190p'

Length of output: 525


🏁 Script executed:

#!/bin/bash
# Check for other os.Exit calls in the codebase that might indicate pattern
rg -n "os\.Exit" --type go -B3 -A1

Length of output: 635


🏁 Script executed:

#!/bin/bash
# Examine internal/bot/bot.go:58 for context and error handling
cat internal/bot/bot.go | sed -n '55,65p'

Length of output: 212


Return error from NewClient instead of calling os.Exit

The NewClient function in internal/realdebrid/client.go calls os.Exit(1) when proxy URL parsing fails, terminating the entire process. This is problematic because:

  1. The caller has no way to handle the error (function returns only *Client, not error)
  2. One call site is inside a goroutine (cmd/rdctl-bot/main.go:184), causing unexpected process termination from a background task
  3. The code comment contradicts the behavior ("proceed without proxy" vs. immediate exit)

Change NewClient signature to func NewClient(...) (*Client, error) and return the error. Update call sites in internal/bot/bot.go:58 and cmd/rdctl-bot/main.go:184 to handle the returned error.

🤖 Prompt for AI Agents
In cmd/rdctl-bot/main.go around lines 184-185 and internal/realdebrid/client.go,
NewClient currently calls os.Exit on proxy parse errors and returns only
*Client; change its signature to func NewClient(...)(*Client, error), remove any
os.Exit usage and return the parse error (or nil, err) so callers can handle it;
update all call sites (at least cmd/rdctl-bot/main.go:184 and
internal/bot/bot.go:58) to check the error, propagate or log/handle it
appropriately (e.g., log and stop initialization or fallback to no-proxy
behavior) instead of letting the client terminate the process.

ActivityRepo: db.NewActivityRepository(database),
TorrentRepo: db.NewTorrentRepository(database),
DownloadRepo: db.NewDownloadRepository(database),
CommandRepo: db.NewCommandRepository(database),
Config: cfg,
}
web.Start(deps)
}()

Comment on lines +181 to +194
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Web server lacks graceful shutdown and error propagation; integrate with context

web.Start(deps) blocks in a goroutine and never stops on SIGTERM, and its failures aren’t observed. Also, DB may be closed while web still serves requests.

Suggested approach:

  • Make web.Start(ctx context.Context, deps Dependencies) error listen for ctx.Done() and call app.Shutdown().
  • Start it with the same ctx you use for the bot, and select on its error channel.

Apply this diff here (paired with changes in internal/web/server.go):

- go func() {
-   // Create dependencies for web handlers
-   deps := web.Dependencies{
+ // Create dependencies for web handlers
+ deps := web.Dependencies{
      RDClient:     realdebrid.NewClient(cfg.RealDebrid.BaseURL, cfg.RealDebrid.APIToken, cfg.RealDebrid.Proxy, time.Duration(cfg.RealDebrid.Timeout)*time.Second),
      UserRepo:     db.NewUserRepository(database),
      ActivityRepo: db.NewActivityRepository(database),
      TorrentRepo:  db.NewTorrentRepository(database),
      DownloadRepo: db.NewDownloadRepository(database),
      CommandRepo:  db.NewCommandRepository(database),
      Config:       cfg,
-   }
-   web.Start(deps)
- }()
+ }
+ webErrCh := make(chan error, 1)
+ // Create shutdown context before launching web
+ // (Move the signal.NotifyContext block above this call.)
+ go func() { webErrCh <- web.Start(ctx, deps) }()

And extend the wait loop:

 select {
 case <-ctx.Done():
   ...
-case err := <-errCh:
+case err := <-errCh:
   ...
+case err := <-webErrCh:
+  log.Printf("Web server encountered an error: %v", err)
+  log.Println("Initiating shutdown due to web error...")
 }

In internal/web/server.go, implement:

func Start(ctx context.Context, deps Dependencies) error {
  app := fiber.New()
  // ...
  go func() {
    <-ctx.Done()
    _ = app.Shutdown()
  }()
  return app.Listen(deps.Config.Web.ListenAddr)
}
🤖 Prompt for AI Agents
In cmd/rdctl-bot/main.go around lines 181-194, the web server is started in a
goroutine with web.Start(deps) that blocks and neither receives the parent
context nor returns errors, risking unobserved failures and DB closure while
handlers are running; change web.Start to accept ctx (web.Start(ctx, deps)
error), call it in a dedicated goroutine that sends its returned error over a
channel, pass the same ctx you use for the bot when calling Start, select on
ctx.Done() and the web error channel in your main wait logic to propagate
startup/runtime errors and only close the database after the web server has shut
down (or after its error is handled) so shutdown ordering is correct.

// Setup graceful shutdown using context with signal notification
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
Expand Down
4 changes: 4 additions & 0 deletions example-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,7 @@ database:
dbname: "rdctl_bot"
# SSL mode for database connection (e.g., "disable", "require")
sslmode: "disable"

web:
listen_addr: ":8082"
api_key: "random_key"
11 changes: 11 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.25.3

require (
github.com/go-telegram/bot v1.17.0
github.com/gofiber/fiber/v2 v2.52.9
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0
golang.org/x/text v0.30.0
Expand All @@ -13,22 +14,32 @@ require (
)

require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Broken indirect module path: gopkg.in/yaml.v3, not go.yaml.in/yaml/v3

This will break go mod tidy/build. Fix the path.

-go.yaml.in/yaml/v3 v3.0.4 // indirect
+gopkg.in/yaml.v3 v3.0.4 // indirect

After the change, run:

go mod tidy
🤖 Prompt for AI Agents
In go.mod around line 43, the indirect module path is incorrect
("go.yaml.in/yaml/v3 v3.0.4"); replace it with the correct module path
"gopkg.in/yaml.v3 v3.0.4" so the indirect dependency resolves properly, then
save and run "go mod tidy" to update the go.sum and verify the build.

golang.org/x/crypto v0.35.0 // indirect
golang.org/x/sync v0.17.0 // indirect
Expand Down
25 changes: 25 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Expand All @@ -10,8 +12,12 @@ github.com/go-telegram/bot v1.17.0 h1:Hs0kGxSj97QFqOQP0zxduY/4tSx8QDzvNI9uVRS+zm
github.com/go-telegram/bot v1.17.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
Expand All @@ -26,14 +32,25 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
Expand All @@ -59,12 +76,20 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
Expand Down
14 changes: 14 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ type Config struct {
RealDebrid RealDebridConfig `mapstructure:"realdebrid"`
App AppConfig `mapstructure:"app"`
Database DatabaseConfig `mapstructure:"database"`
Web WebConfig `mapstructure:"web"`
}

// WebConfig holds all web server configuration
type WebConfig struct {
ListenAddr string `mapstructure:"listen_addr"`
APIKey string `mapstructure:"api_key"`
}

// TelegramConfig holds Telegram bot settings
Expand Down Expand Up @@ -189,6 +196,13 @@ func (c *Config) Validate() error {
c.Database.SSLMode = "disable"
}

if c.Web.ListenAddr == "" {
c.Web.ListenAddr = ":8080"
}
if c.Web.APIKey == "" {
return fmt.Errorf("web api_key is required for dashboard access")
}

return nil
}

Expand Down
139 changes: 139 additions & 0 deletions internal/web/handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package web

import (
"log"
"strconv"

"github.com/crazyuploader/rdctl-bot/internal/realdebrid"
"github.com/gofiber/fiber/v2"
)

// GetStatus retrieves the Real-Debrid account status
func (d *Dependencies) GetStatus(c *fiber.Ctx) error {
user, err := d.RDClient.GetUser()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"success": false, "error": err.Error()})
}
return c.JSON(fiber.Map{"success": true, "data": user})
}

// GetTorrents retrieves the list of active torrents
func (d *Dependencies) GetTorrents(c *fiber.Ctx) error {
limit, _ := strconv.Atoi(c.Query("limit", "50"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))

torrents, err := d.RDClient.GetTorrents(limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"success": false, "error": err.Error()})
}

// Format status and size for frontend convenience
for i := range torrents {
torrents[i].Status = realdebrid.FormatStatus(torrents[i].Status)
}

return c.JSON(fiber.Map{"success": true, "data": torrents})
}
Comment on lines +21 to +36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add validation for pagination parameters.

The handler silently ignores parsing errors and doesn't validate limit/offset ranges, which could lead to resource exhaustion or unexpected behavior.

Add proper validation:

 func (d *Dependencies) GetTorrents(c *fiber.Ctx) error {
-	limit, _ := strconv.Atoi(c.Query("limit", "50"))
-	offset, _ := strconv.Atoi(c.Query("offset", "0"))
+	limit, err := strconv.Atoi(c.Query("limit", "50"))
+	if err != nil || limit < 1 || limit > 100 {
+		limit = 50
+	}
+	offset, err := strconv.Atoi(c.Query("offset", "0"))
+	if err != nil || offset < 0 {
+		offset = 0
+	}

 	torrents, err := d.RDClient.GetTorrents(limit, offset)

This prevents:

  • Negative offset values
  • Excessively large limit values (DoS)
  • Invalid non-numeric inputs falling through silently
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (d *Dependencies) GetTorrents(c *fiber.Ctx) error {
limit, _ := strconv.Atoi(c.Query("limit", "50"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
torrents, err := d.RDClient.GetTorrents(limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"success": false, "error": err.Error()})
}
// Format status and size for frontend convenience
for i := range torrents {
torrents[i].Status = realdebrid.FormatStatus(torrents[i].Status)
}
return c.JSON(fiber.Map{"success": true, "data": torrents})
}
func (d *Dependencies) GetTorrents(c *fiber.Ctx) error {
limit, err := strconv.Atoi(c.Query("limit", "50"))
if err != nil || limit < 1 || limit > 100 {
limit = 50
}
offset, err := strconv.Atoi(c.Query("offset", "0"))
if err != nil || offset < 0 {
offset = 0
}
torrents, err := d.RDClient.GetTorrents(limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"success": false, "error": err.Error()})
}
// Format status and size for frontend convenience
for i := range torrents {
torrents[i].Status = realdebrid.FormatStatus(torrents[i].Status)
}
return c.JSON(fiber.Map{"success": true, "data": torrents})
}
🤖 Prompt for AI Agents
In internal/web/handlers.go around lines 21 to 36, the handler currently ignores
strconv.Atoi errors and does not validate pagination ranges; update parsing to
check errors and return a 400 with a clear message when limit/offset are
non-numeric, enforce offset >= 0, clamp or reject limit (e.g., maxLimit = 100)
and reject limit <= 0, and return a 400 on violations; if values are omitted
keep sensible defaults, and only call d.RDClient.GetTorrents when validated to
prevent negative offsets or excessively large limits causing resource
exhaustion.


// GetTorrentInfo retrieves detailed information about a single torrent
func (d *Dependencies) GetTorrentInfo(c *fiber.Ctx) error {
id := c.Params("id")
torrent, err := d.RDClient.GetTorrentInfo(id)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"success": false, "error": err.Error()})
}
torrent.Status = realdebrid.FormatStatus(torrent.Status)
return c.JSON(fiber.Map{"success": true, "data": torrent})
}

// AddTorrent adds a new torrent from a magnet link
func (d *Dependencies) AddTorrent(c *fiber.Ctx) error {
var body struct {
Magnet string `json:"magnet"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"success": false, "error": "Invalid request body"})
}

if body.Magnet == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"success": false, "error": "Magnet link is required"})
}

resp, err := d.RDClient.AddMagnet(body.Magnet)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"success": false, "error": err.Error()})
}

// Automatically select all files
if err := d.RDClient.SelectAllFiles(resp.ID); err != nil {
log.Printf("Failed to select files for torrent %s: %v", resp.ID, err)
// Non-fatal, just log it
}

return c.Status(fiber.StatusCreated).JSON(fiber.Map{"success": true, "data": resp})
}

// DeleteTorrent deletes a torrent
func (d *Dependencies) DeleteTorrent(c *fiber.Ctx) error {
id := c.Params("id")
if err := d.RDClient.DeleteTorrent(id); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"success": false, "error": err.Error()})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{"success": true, "message": "Torrent deleted successfully"})
}

// GetDownloads retrieves the download history
func (d *Dependencies) GetDownloads(c *fiber.Ctx) error {
limit, _ := strconv.Atoi(c.Query("limit", "50"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))

downloads, err := d.RDClient.GetDownloads(limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"success": false, "error": err.Error()})
}
return c.JSON(fiber.Map{"success": true, "data": downloads})
}
Comment on lines +86 to +95
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same pagination validation issue as GetTorrents.

This handler has the same lack of input validation for limit and offset parameters.

Apply the same validation fix as recommended for GetTorrents (lines 21-36).

🤖 Prompt for AI Agents
In internal/web/handlers.go around lines 86 to 95, the GetDownloads handler does
not validate query parameters; mirror the GetTorrents fix by parsing limit and
offset with error handling (return a 400 with a clear message on parse failure),
enforce acceptable ranges (e.g., default limit 50, min 1, cap limit to a sane
max like 100; ensure offset is >= 0), and use the validated/clamped values when
calling d.RDClient.GetDownloads; update responses to return a bad request on
invalid input rather than silently using defaults.


// UnrestrictLink unrestricts a hoster link
func (d *Dependencies) UnrestrictLink(c *fiber.Ctx) error {
var body struct {
Link string `json:"link"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"success": false, "error": "Invalid request body"})
}

if body.Link == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"success": false, "error": "Link is required"})
}

unrestricted, err := d.RDClient.UnrestrictLink(body.Link)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"success": false, "error": err.Error()})
}

return c.JSON(fiber.Map{"success": true, "data": unrestricted})
}

// DeleteDownload deletes a download from history
func (d *Dependencies) DeleteDownload(c *fiber.Ctx) error {
id := c.Params("id")
if err := d.RDClient.DeleteDownload(id); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"success": false, "error": err.Error()})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{"success": true, "message": "Download link removed successfully"})
}

// GetUserStats retrieves statistics for a user
func (d *Dependencies) GetUserStats(c *fiber.Ctx) error {
userID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"success": false, "error": "Invalid user ID"})
}

stats, err := d.CommandRepo.GetUserStats(uint(userID))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"success": false, "error": err.Error()})
}
return c.JSON(fiber.Map{"success": true, "data": stats})
}
23 changes: 23 additions & 0 deletions internal/web/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package web

import (
"github.com/gofiber/fiber/v2"
)

// APIKeyAuth is a middleware for simple API key authentication
func APIKeyAuth(apiKey string) fiber.Handler {
return func(c *fiber.Ctx) error {
providedKey := c.Get("X-API-Key")
if providedKey == "" {
providedKey = c.Query("api_key")
}
Comment on lines +10 to +13
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Do not accept API key via query param; use constant‑time compare

Query params can leak via logs, proxies, and browser history. Also, plain string equality is timing‑sensitive. Use header only and constant‑time comparison.

 package web
 
 import (
+  "crypto/subtle"
   "github.com/gofiber/fiber/v2"
 )
 
 // APIKeyAuth is a middleware for simple API key authentication
 func APIKeyAuth(apiKey string) fiber.Handler {
   return func(c *fiber.Ctx) error {
-    providedKey := c.Get("X-API-Key")
-    if providedKey == "" {
-      providedKey = c.Query("api_key")
-    }
+    providedKey := c.Get("X-API-Key")
 
-    if providedKey != apiKey {
+    if subtle.ConstantTimeCompare([]byte(providedKey), []byte(apiKey)) != 1 {
       return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
         "success": false,
         "message": "Unauthorized: Invalid or missing API Key",
       })
     }
     return c.Next()
   }
 }

Optionally, support Authorization: Bearer <key> in addition to X-API-Key.

Also applies to: 15-21

🤖 Prompt for AI Agents
In internal/web/middleware.go around lines 10-13 (and similarly lines 15-21),
the code accepts the API key from a query parameter and uses plain string
equality; change it to only read the key from headers and perform a
constant-time comparison. Specifically, remove the c.Query("api_key") fallback,
read the key from X-API-Key and if absent also accept Authorization: Bearer
<key> by parsing that header, then compare the provided key to the expected key
using crypto/subtle.ConstantTimeCompare (or an equivalent constant-time byte
comparison) to prevent timing attacks; if no header is present or the comparison
fails, return the same unauthorized response as before.


if providedKey != apiKey {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"success": false,
"message": "Unauthorized: Invalid or missing API Key",
})
}
return c.Next()
}
}
Loading