Add auth
This commit is contained in:
@@ -7,3 +7,5 @@ docker-compose.yml
|
|||||||
*.magnet
|
*.magnet
|
||||||
**.torrent
|
**.torrent
|
||||||
torrents.json
|
torrents.json
|
||||||
|
**/dist/
|
||||||
|
*.json
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ dist/
|
|||||||
tmp/**
|
tmp/**
|
||||||
torrents.json
|
torrents.json
|
||||||
logs/**
|
logs/**
|
||||||
|
auth.json
|
||||||
|
|||||||
25
Dockerfile
25
Dockerfile
@@ -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"]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
11
go.mod
@@ -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
12
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
131
pkg/qbit/server/templates/login.html
Normal file
131
pkg/qbit/server/templates/login.html
Normal 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 }}
|
||||||
32
pkg/qbit/server/templates/setup.html
Normal file
32
pkg/qbit/server/templates/setup.html
Normal 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 }}
|
||||||
50
pkg/qbit/server/ui_auth_handlers.go
Normal file
50
pkg/qbit/server/ui_auth_handlers.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user