This commit is contained in:
Mukhtar Akere
2025-02-09 23:47:02 +01:00
parent 1614e29f8f
commit c386495d3d
18 changed files with 469 additions and 18 deletions

View File

@@ -7,3 +7,5 @@ docker-compose.yml
*.magnet *.magnet
**.torrent **.torrent
torrents.json torrents.json
**/dist/
*.json

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ dist/
tmp/** tmp/**
torrents.json torrents.json
logs/** logs/**
auth.json

View File

@@ -31,25 +31,34 @@ RUN --mount=type=cache,target=/go/pkg/mod \
# Stage 2: Create directory structure # Stage 2: Create directory structure
FROM alpine:3.19 as dirsetup FROM alpine:3.19 as dirsetup
RUN mkdir -p /logs && \ RUN mkdir -p /data/logs && \
chmod 777 /logs && \ chmod 777 /data/logs && \
touch /logs/decypharr.log && \ touch /data/logs/decypharr.log && \
chmod 666 /logs/decypharr.log chmod 666 /data/logs/decypharr.log
# Stage 3: Final image # Stage 3: Final image
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
LABEL version = "${VERSION}-${CHANNEL}"
LABEL org.opencontainers.image.source = "https://github.com/sirrobot01/debrid-blackhole"
LABEL org.opencontainers.image.title = "debrid-blackhole"
LABEL org.opencontainers.image.authors = "sirrobot01"
LABEL org.opencontainers.image.documentation = "https://github.com/sirrobot01/debrid-blackhole/blob/main/README.md"
# Copy binaries # Copy binaries
COPY --from=builder --chown=nonroot:nonroot /blackhole /blackhole COPY --from=builder --chown=nonroot:nonroot /blackhole /blackhole
COPY --from=builder --chown=nonroot:nonroot /healthcheck /healthcheck COPY --from=builder --chown=nonroot:nonroot /healthcheck /healthcheck
# Copy pre-made directory structure # Copy pre-made directory structure
COPY --from=dirsetup --chown=nonroot:nonroot /logs /logs COPY --from=dirsetup --chown=nonroot:nonroot /data /data
# Metadata # Metadata
ENV LOG_PATH=/logs ENV LOG_PATH=/data/logs
EXPOSE 8181 8282 EXPOSE 8181 8282
VOLUME ["/app"] VOLUME ["/data", "/app"]
USER nonroot:nonroot USER nonroot:nonroot
HEALTHCHECK CMD ["/healthcheck"] HEALTHCHECK CMD ["/healthcheck"]
CMD ["/blackhole", "--config", "/app/config.json"]
CMD ["/blackhole", "--config", "/data"]

View File

@@ -61,7 +61,7 @@ services:
user: "1000:1000" user: "1000:1000"
volumes: volumes:
- /mnt/:/mnt - /mnt/:/mnt
- ~/plex/configs/blackhole/config.json:/app/config.json # Config file, see below - ~/plex/configs/blackhole/:/data # Path to the config file. config.json
environment: environment:
- PUID=1000 - PUID=1000
- PGID=1000 - PGID=1000
@@ -78,7 +78,7 @@ services:
Download the binary from the releases page and run it with the config file. Download the binary from the releases page and run it with the config file.
```bash ```bash
./blackhole --config /path/to/config.json ./blackhole --config /path/to/
``` ```
### Usage ### Usage
@@ -104,7 +104,7 @@ Download the binary from the releases page and run it with the config file.
#### Basic Sample Config #### Basic Sample Config
This is the default config file. You can create a `config.json` file in the root directory of the project or mount it in the docker-compose file. This is the default config file. You can create a `config.json` file in the root directory of the project or mount it to /data in the docker-compose file.
```json ```json
{ {
"debrids": [ "debrids": [
@@ -145,6 +145,7 @@ Full config are [here](doc/config.full.json)
- The `log_level` key is used to set the log level of the application. The default value is `info`. log level can be set to `debug`, `info`, `warn`, `error` - The `log_level` key is used to set the log level of the application. The default value is `info`. log level can be set to `debug`, `info`, `warn`, `error`
- The `max_cache_size` key is used to set the maximum number of infohashes that can be stored in the availability cache. This is used to prevent round trip to the debrid provider when using the proxy/Qbittorrent. The default value is `1000` - The `max_cache_size` key is used to set the maximum number of infohashes that can be stored in the availability cache. This is used to prevent round trip to the debrid provider when using the proxy/Qbittorrent. The default value is `1000`
- The `allowed_file_types` key is an array of allowed file types that can be downloaded. By default, all movie, tv show and music file types are allowed - The `allowed_file_types` key is an array of allowed file types that can be downloaded. By default, all movie, tv show and music file types are allowed
- `use_auth` is used to enable basic authentication for the UI.
##### Debrid Config ##### Debrid Config
- The `debrids` key is an array of debrid providers - The `debrids` key is an array of debrid providers

View File

@@ -3,11 +3,13 @@ package cmd
import ( import (
"context" "context"
"github.com/sirrobot01/debrid-blackhole/internal/config" "github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/pkg/arr" "github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid" "github.com/sirrobot01/debrid-blackhole/pkg/debrid"
"github.com/sirrobot01/debrid-blackhole/pkg/proxy" "github.com/sirrobot01/debrid-blackhole/pkg/proxy"
"github.com/sirrobot01/debrid-blackhole/pkg/qbit" "github.com/sirrobot01/debrid-blackhole/pkg/qbit"
"github.com/sirrobot01/debrid-blackhole/pkg/repair" "github.com/sirrobot01/debrid-blackhole/pkg/repair"
"github.com/sirrobot01/debrid-blackhole/pkg/version"
"log" "log"
"sync" "sync"
) )
@@ -15,6 +17,13 @@ import (
func Start(ctx context.Context) error { func Start(ctx context.Context) error {
cfg := config.GetConfig() cfg := config.GetConfig()
_log := logger.GetLogger(cfg.LogLevel)
_log.Debug().Msgf("Config Loaded: %s", cfg.JsonFile())
_log.Debug().Msgf("Default Log Level: %s", cfg.LogLevel)
_log.Info().Msgf("Version: %s", version.GetInfo().String())
deb := debrid.NewDebrid() deb := debrid.NewDebrid()
arrs := arr.NewStorage() arrs := arr.NewStorage()
_repair := repair.NewRepair(deb.Get(), arrs) _repair := repair.NewRepair(deb.Get(), arrs)

View File

@@ -75,5 +75,6 @@
"log_level": "info", "log_level": "info",
"min_file_size": "", "min_file_size": "",
"max_file_size": "", "max_file_size": "",
"allowed_file_types": [] "allowed_file_types": [],
"use_auth": false
} }

11
go.mod
View File

@@ -1,6 +1,8 @@
module github.com/sirrobot01/debrid-blackhole module github.com/sirrobot01/debrid-blackhole
go 1.22 go 1.23
toolchain go1.23.2
require ( require (
github.com/anacrolix/torrent v1.55.0 github.com/anacrolix/torrent v1.55.0
@@ -12,6 +14,7 @@ require (
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
github.com/valyala/fasthttp v1.55.0 github.com/valyala/fasthttp v1.55.0
github.com/valyala/fastjson v1.6.4 github.com/valyala/fastjson v1.6.4
golang.org/x/crypto v0.33.0
golang.org/x/time v0.8.0 golang.org/x/time v0.8.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
) )
@@ -23,6 +26,8 @@ require (
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-cmp v0.6.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/huandu/xstrings v1.3.2 // indirect github.com/huandu/xstrings v1.3.2 // indirect
github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/compress v1.17.11 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
@@ -32,6 +37,6 @@ require (
github.com/stretchr/testify v1.10.0 // indirect github.com/stretchr/testify v1.10.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/net v0.33.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.22.0 // indirect
) )

12
go.sum
View File

@@ -115,6 +115,10 @@ github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORR
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
@@ -219,6 +223,10 @@ go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -259,10 +267,14 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"sync" "sync"
) )
@@ -57,6 +58,11 @@ type Repair struct {
SkipDeletion bool `json:"skip_deletion"` SkipDeletion bool `json:"skip_deletion"`
} }
type Auth struct {
Username string `json:"username"`
Password string `json:"password"`
}
type Config struct { type Config struct {
LogLevel string `json:"log_level"` LogLevel string `json:"log_level"`
Debrid Debrid `json:"debrid"` Debrid Debrid `json:"debrid"`
@@ -69,6 +75,16 @@ type Config struct {
AllowedExt []string `json:"allowed_file_types"` AllowedExt []string `json:"allowed_file_types"`
MinFileSize string `json:"min_file_size"` // Minimum file size to download, 10MB, 1GB, etc MinFileSize string `json:"min_file_size"` // Minimum file size to download, 10MB, 1GB, etc
MaxFileSize string `json:"max_file_size"` // Maximum file size to download (0 means no limit) MaxFileSize string `json:"max_file_size"` // Maximum file size to download (0 means no limit)
Path string `json:"-"` // Path to save the config file
UseAuth bool `json:"use_auth"`
Auth *Auth `json:"-"`
}
func (c *Config) JsonFile() string {
return filepath.Join(c.Path, "config.json")
}
func (c *Config) AuthFile() string {
return filepath.Join(c.Path, "auth.json")
} }
func (c *Config) loadConfig() error { func (c *Config) loadConfig() error {
@@ -76,7 +92,8 @@ func (c *Config) loadConfig() error {
if configPath == "" { if configPath == "" {
return fmt.Errorf("config path not set") return fmt.Errorf("config path not set")
} }
file, err := os.ReadFile(configPath) c.Path = configPath
file, err := os.ReadFile(c.JsonFile())
if err != nil { if err != nil {
return err return err
} }
@@ -93,6 +110,9 @@ func (c *Config) loadConfig() error {
c.AllowedExt = getDefaultExtensions() c.AllowedExt = getDefaultExtensions()
} }
// Load the auth file
c.Auth = c.GetAuth()
// Validate the config // Validate the config
//if err := validateConfig(c); err != nil { //if err := validateConfig(c); err != nil {
// return nil, err // return nil, err
@@ -178,6 +198,12 @@ func validateConfig(config *Config) error {
} }
func SetConfigPath(path string) { func SetConfigPath(path string) {
// Backward compatibility
// Check if the path is not a dir
if fi, err := os.Stat(path); err == nil && !fi.IsDir() {
// Get the directory of the file
path = filepath.Dir(path)
}
configPath = path configPath = path
} }
@@ -227,3 +253,35 @@ func (c *Config) IsSizeAllowed(size int64) bool {
} }
return true return true
} }
func (c *Config) GetAuth() *Auth {
if !c.UseAuth {
return nil
}
if c.Auth == nil {
c.Auth = &Auth{}
if _, err := os.Stat(c.AuthFile()); err == nil {
file, err := os.ReadFile(c.AuthFile())
if err == nil {
_ = json.Unmarshal(file, c.Auth)
}
}
}
return c.Auth
}
func (c *Config) SaveAuth(auth *Auth) error {
c.Auth = auth
data, err := json.Marshal(auth)
if err != nil {
return err
}
return os.WriteFile(c.AuthFile(), data, 0644)
}
func (c *Config) NeedsSetup() bool {
if c.UseAuth {
return c.GetAuth().Username == ""
}
return false
}

View File

@@ -7,6 +7,12 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
)
var (
once sync.Once
logger zerolog.Logger
) )
func GetLogPath() string { func GetLogPath() string {
@@ -78,3 +84,10 @@ func NewLogger(prefix string, level string, output *os.File) zerolog.Logger {
} }
return logger return logger
} }
func GetLogger(level string) zerolog.Logger {
once.Do(func() {
logger = NewLogger("Decypharr", level, os.Stdout)
})
return logger
}

View File

@@ -112,6 +112,11 @@
{{ template "repair" . }} {{ template "repair" . }}
{{ else if eq .Page "config" }} {{ else if eq .Page "config" }}
{{ template "config" . }} {{ template "config" . }}
{{ else if eq .Page "login" }}
{{ template "login" . }}
{{ else if eq .Page "setup" }}
{{ template "setup" . }}
{{ else }}
{{ end }} {{ end }}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>

View File

@@ -0,0 +1,131 @@
{{ define "login" }}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0 text-center">Login</h4>
</div>
<div class="card-body">
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Login</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
username: document.getElementById('username').value,
password: document.getElementById('password').value
};
try {
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.ok) {
window.location.href = '/';
} else {
createToast('Invalid credentials', 'error');
}
} catch (error) {
console.error('Login error:', error);
createToast('Login failed', 'error');
}
});
</script>
{{ end }}
{{ define "setup" }}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0 text-center">First Time Setup</h4>
</div>
<div class="card-body">
<form id="setupForm">
<div class="mb-3">
<label for="username" class="form-label">Choose Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Choose Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Set Credentials</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (password !== confirmPassword) {
createToast('Passwords do not match', 'error');
return;
}
const formData = {
username: document.getElementById('username').value,
password: password,
confirmPassword: confirmPassword
};
try {
const response = await fetch('/setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.ok) {
window.location.href = '/';
} else {
const error = await response.text();
createToast(error, 'error');
}
} catch (error) {
console.error('Setup error:', error);
createToast('Setup failed', 'error');
}
});
</script>
{{ end }}

View File

@@ -0,0 +1,32 @@
{{ define "setup" }}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0 text-center">First Time Setup</h4>
</div>
<div class="card-body">
<form id="setupForm" method="POST" action="/setup">
<div class="mb-3">
<label for="username" class="form-label">Choose Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Choose Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Set Credentials</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,50 @@
package server
import (
"github.com/sirrobot01/debrid-blackhole/internal/config"
"golang.org/x/crypto/bcrypt"
"net/http"
)
func (u *UIHandler) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if setup is needed
cfg := config.GetConfig()
if cfg.NeedsSetup() && r.URL.Path != "/setup" {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
return
}
// Skip auth check for setup page
if r.URL.Path == "/setup" {
next.ServeHTTP(w, r)
return
}
session, _ := store.Get(r, "auth-session")
auth, ok := session.Values["authenticated"].(bool)
if !ok || !auth {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func (u *UIHandler) verifyAuth(username, password string) bool {
// If you're storing hashed password, use bcrypt to compare
if username == "" {
return false
}
auth := config.GetConfig().GetAuth()
if auth == nil {
return false
}
if username != auth.Username {
return false
}
err := bcrypt.CompareHashAndPassword([]byte(auth.Password), []byte(password))
return err == nil
}

View File

@@ -4,9 +4,11 @@ import (
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/gorilla/sessions"
"github.com/sirrobot01/debrid-blackhole/internal/config" "github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/request" "github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/internal/utils" "github.com/sirrobot01/debrid-blackhole/internal/utils"
"golang.org/x/crypto/bcrypt"
"html/template" "html/template"
"net/http" "net/http"
"strings" "strings"
@@ -56,7 +58,10 @@ type UIHandler struct {
debug bool debug bool
} }
var templates *template.Template var (
store = sessions.NewCookieStore([]byte("your-secret-key")) // Change this to a secure key
templates *template.Template
)
func init() { func init() {
templates = template.Must(template.ParseFS( templates = template.Must(template.ParseFS(
@@ -66,7 +71,112 @@ func init() {
"templates/download.html", "templates/download.html",
"templates/repair.html", "templates/repair.html",
"templates/config.html", "templates/config.html",
"templates/login.html",
"templates/setup.html",
)) ))
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: false,
}
}
func (u *UIHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
data := map[string]interface{}{
"Page": "login",
"Title": "Login",
}
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
var credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if u.verifyAuth(credentials.Username, credentials.Password) {
session, _ := store.Get(r, "auth-session")
session.Values["authenticated"] = true
session.Values["username"] = credentials.Username
session.Save(r, w)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
}
func (u *UIHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "auth-session")
session.Values["authenticated"] = false
session.Options.MaxAge = -1
session.Save(r, w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (u *UIHandler) SetupHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.GetConfig()
authCfg := cfg.GetAuth()
if !cfg.NeedsSetup() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if r.Method == "GET" {
data := map[string]interface{}{
"Page": "setup",
"Title": "Setup",
}
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
// Handle POST (setup attempt)
username := r.FormValue("username")
password := r.FormValue("password")
confirmPassword := r.FormValue("confirmPassword")
if password != confirmPassword {
http.Error(w, "Passwords do not match", http.StatusBadRequest)
return
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Error processing password", http.StatusInternalServerError)
return
}
// Set the credentials
authCfg.Username = username
authCfg.Password = string(hashedPassword)
if err := cfg.SaveAuth(authCfg); err != nil {
http.Error(w, "Error saving credentials", http.StatusInternalServerError)
return
}
// Create a session
session, _ := store.Get(r, "auth-session")
session.Values["authenticated"] = true
session.Values["username"] = username
session.Save(r, w)
http.Redirect(w, r, "/", http.StatusSeeOther)
} }
func (u *UIHandler) IndexHandler(w http.ResponseWriter, r *http.Request) { func (u *UIHandler) IndexHandler(w http.ResponseWriter, r *http.Request) {

View File

@@ -6,7 +6,13 @@ import (
) )
func (u *UIHandler) Routes(r chi.Router) http.Handler { func (u *UIHandler) Routes(r chi.Router) http.Handler {
r.Get("/login", u.LoginHandler)
r.Post("/login", u.LoginHandler)
r.Get("/setup", u.SetupHandler)
r.Post("/setup", u.SetupHandler)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(u.authMiddleware)
r.Get("/", u.IndexHandler) r.Get("/", u.IndexHandler)
r.Get("/download", u.DownloadHandler) r.Get("/download", u.DownloadHandler)
r.Get("/repair", u.RepairHandler) r.Get("/repair", u.RepairHandler)

View File

@@ -37,7 +37,7 @@ func NewQBit(deb *debrid.DebridService, logger zerolog.Logger, arrs *arr.Storage
DownloadFolder: cfg.DownloadFolder, DownloadFolder: cfg.DownloadFolder,
Categories: cfg.Categories, Categories: cfg.Categories,
Debrid: deb, Debrid: deb,
Storage: NewTorrentStorage(cmp.Or(os.Getenv("TORRENT_FILE"), "/app/torrents.json")), Storage: NewTorrentStorage(cmp.Or(os.Getenv("TORRENT_FILE"), "/data/torrents.json")),
Repair: _repair, Repair: _repair,
logger: logger, logger: logger,
Arrs: arrs, Arrs: arrs,

View File

@@ -1,10 +1,16 @@
package version package version
import "fmt"
type Info struct { type Info struct {
Version string `json:"version"` Version string `json:"version"`
Channel string `json:"channel"` Channel string `json:"channel"`
} }
func (i Info) String() string {
return fmt.Sprintf("%s-%s", i.Version, i.Channel)
}
var ( var (
Version = "" Version = ""
Channel = "" Channel = ""