- Fix symlinks % bug
- A cleaner settings page - More bug fixes
This commit is contained in:
+56
-41
@@ -89,7 +89,6 @@ type Config struct {
|
|||||||
|
|
||||||
LogLevel string `json:"log_level,omitempty"`
|
LogLevel string `json:"log_level,omitempty"`
|
||||||
Debrids []Debrid `json:"debrids,omitempty"`
|
Debrids []Debrid `json:"debrids,omitempty"`
|
||||||
MaxCacheSize int `json:"max_cache_size,omitempty"`
|
|
||||||
QBitTorrent QBitTorrent `json:"qbittorrent,omitempty"`
|
QBitTorrent QBitTorrent `json:"qbittorrent,omitempty"`
|
||||||
Arrs []Arr `json:"arrs,omitempty"`
|
Arrs []Arr `json:"arrs,omitempty"`
|
||||||
Repair Repair `json:"repair,omitempty"`
|
Repair Repair `json:"repair,omitempty"`
|
||||||
@@ -118,39 +117,19 @@ func (c *Config) loadConfig() error {
|
|||||||
c.Path = configPath
|
c.Path = configPath
|
||||||
file, err := os.ReadFile(c.JsonFile())
|
file, err := os.ReadFile(c.JsonFile())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
if os.IsNotExist(err) {
|
||||||
|
// Create a default config file if it doesn't exist
|
||||||
|
if err := c.createConfig(c.Path); err != nil {
|
||||||
|
return fmt.Errorf("failed to create config file: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("error reading config file: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := json.Unmarshal(file, &c); err != nil {
|
||||||
|
return fmt.Errorf("error unmarshaling config: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(file, &c); err != nil {
|
|
||||||
return fmt.Errorf("error unmarshaling config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, debrid := range c.Debrids {
|
|
||||||
c.Debrids[i] = c.updateDebrid(debrid)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.AllowedExt) == 0 {
|
|
||||||
c.AllowedExt = getDefaultExtensions()
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Port = cmp.Or(c.Port, c.QBitTorrent.Port)
|
|
||||||
|
|
||||||
if c.URLBase == "" {
|
|
||||||
c.URLBase = "/"
|
|
||||||
}
|
|
||||||
// validate url base starts with /
|
|
||||||
if c.URLBase[0] != '/' {
|
|
||||||
c.URLBase = "/" + c.URLBase
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the auth file
|
|
||||||
c.Auth = c.GetAuth()
|
|
||||||
|
|
||||||
//Validate the config
|
|
||||||
if err := ValidateConfig(c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,15 +175,15 @@ func ValidateConfig(config *Config) error {
|
|||||||
// Run validations concurrently
|
// Run validations concurrently
|
||||||
|
|
||||||
if err := validateDebrids(config.Debrids); err != nil {
|
if err := validateDebrids(config.Debrids); err != nil {
|
||||||
return fmt.Errorf("debrids validation error: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateQbitTorrent(&config.QBitTorrent); err != nil {
|
if err := validateQbitTorrent(&config.QBitTorrent); err != nil {
|
||||||
return fmt.Errorf("qbittorrent validation error: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateRepair(&config.Repair); err != nil {
|
if err := validateRepair(&config.Repair); err != nil {
|
||||||
return fmt.Errorf("repair validation error: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -287,11 +266,8 @@ func (c *Config) SaveAuth(auth *Auth) error {
|
|||||||
return os.WriteFile(c.AuthFile(), data, 0644)
|
return os.WriteFile(c.AuthFile(), data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) NeedsSetup() bool {
|
func (c *Config) NeedsSetup() error {
|
||||||
if err := ValidateConfig(c); err != nil {
|
return ValidateConfig(c)
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) NeedsAuth() bool {
|
func (c *Config) NeedsAuth() bool {
|
||||||
@@ -336,6 +312,26 @@ func (c *Config) updateDebrid(d Debrid) Debrid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) Save() error {
|
func (c *Config) Save() error {
|
||||||
|
for i, debrid := range c.Debrids {
|
||||||
|
c.Debrids[i] = c.updateDebrid(debrid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.AllowedExt) == 0 {
|
||||||
|
c.AllowedExt = getDefaultExtensions()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Port = cmp.Or(c.Port, c.QBitTorrent.Port)
|
||||||
|
|
||||||
|
if c.URLBase == "" {
|
||||||
|
c.URLBase = "/"
|
||||||
|
}
|
||||||
|
// validate url base starts with /
|
||||||
|
if c.URLBase[0] != '/' {
|
||||||
|
c.URLBase = "/" + c.URLBase
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the auth file
|
||||||
|
c.Auth = c.GetAuth()
|
||||||
data, err := json.MarshalIndent(c, "", " ")
|
data, err := json.MarshalIndent(c, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -347,6 +343,25 @@ func (c *Config) Save() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) createConfig(path string) error {
|
||||||
|
// Create the directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Path = path
|
||||||
|
c.URLBase = "/"
|
||||||
|
c.Port = "8282"
|
||||||
|
c.LogLevel = "info"
|
||||||
|
c.UseAuth = true
|
||||||
|
c.QBitTorrent = QBitTorrent{
|
||||||
|
DownloadFolder: filepath.Join(path, "downloads"),
|
||||||
|
Categories: []string{"sonarr", "radarr"},
|
||||||
|
RefreshInterval: 15,
|
||||||
|
}
|
||||||
|
return c.Save()
|
||||||
|
}
|
||||||
|
|
||||||
// Reload forces a reload of the configuration from disk
|
// Reload forces a reload of the configuration from disk
|
||||||
func Reload() {
|
func Reload() {
|
||||||
instance = nil
|
instance = nil
|
||||||
|
|||||||
@@ -200,7 +200,6 @@ func (as *Storage) cleanupArrsQueue() {
|
|||||||
arrs = append(arrs, arr)
|
arrs = append(arrs, arr)
|
||||||
}
|
}
|
||||||
if len(arrs) > 0 {
|
if len(arrs) > 0 {
|
||||||
as.logger.Trace().Msgf("Cleaning up %d arrs", len(arrs))
|
|
||||||
for _, arr := range arrs {
|
for _, arr := range arrs {
|
||||||
if err := arr.CleanupQueue(); err != nil {
|
if err := arr.CleanupQueue(); err != nil {
|
||||||
as.logger.Error().Err(err).Msgf("Failed to cleanup arr %s", arr.Name)
|
as.logger.Error().Err(err).Msgf("Failed to cleanup arr %s", arr.Name)
|
||||||
|
|||||||
@@ -64,32 +64,27 @@ func New(dc config.Debrid) *RealDebrid {
|
|||||||
"Authorization": fmt.Sprintf("Bearer %s", currentDownloadKey),
|
"Authorization": fmt.Sprintf("Bearer %s", currentDownloadKey),
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadClient := request.New(
|
|
||||||
request.WithHeaders(downloadHeaders),
|
|
||||||
request.WithRateLimiter(rl),
|
|
||||||
request.WithLogger(_log),
|
|
||||||
request.WithMaxRetries(5),
|
|
||||||
request.WithRetryableStatus(429, 447),
|
|
||||||
request.WithProxy(dc.Proxy),
|
|
||||||
)
|
|
||||||
|
|
||||||
client := request.New(
|
|
||||||
request.WithHeaders(headers),
|
|
||||||
request.WithRateLimiter(rl),
|
|
||||||
request.WithLogger(_log),
|
|
||||||
request.WithMaxRetries(5),
|
|
||||||
request.WithRetryableStatus(429),
|
|
||||||
request.WithProxy(dc.Proxy),
|
|
||||||
)
|
|
||||||
|
|
||||||
return &RealDebrid{
|
return &RealDebrid{
|
||||||
Name: "realdebrid",
|
Name: "realdebrid",
|
||||||
Host: "https://api.real-debrid.com/rest/1.0",
|
Host: "https://api.real-debrid.com/rest/1.0",
|
||||||
APIKey: dc.APIKey,
|
APIKey: dc.APIKey,
|
||||||
DownloadKeys: accounts,
|
DownloadKeys: accounts,
|
||||||
DownloadUncached: dc.DownloadUncached,
|
DownloadUncached: dc.DownloadUncached,
|
||||||
client: client,
|
client: request.New(
|
||||||
downloadClient: downloadClient,
|
request.WithHeaders(headers),
|
||||||
|
request.WithRateLimiter(rl),
|
||||||
|
request.WithLogger(_log),
|
||||||
|
request.WithMaxRetries(5),
|
||||||
|
request.WithRetryableStatus(429),
|
||||||
|
request.WithProxy(dc.Proxy),
|
||||||
|
),
|
||||||
|
downloadClient: request.New(
|
||||||
|
request.WithHeaders(downloadHeaders),
|
||||||
|
request.WithLogger(_log),
|
||||||
|
request.WithMaxRetries(10),
|
||||||
|
request.WithRetryableStatus(429, 447),
|
||||||
|
request.WithProxy(dc.Proxy),
|
||||||
|
),
|
||||||
currentDownloadKey: currentDownloadKey,
|
currentDownloadKey: currentDownloadKey,
|
||||||
MountPath: dc.Folder,
|
MountPath: dc.Folder,
|
||||||
logger: logger.New(dc.Name),
|
logger: logger.New(dc.Name),
|
||||||
|
|||||||
@@ -177,16 +177,17 @@ func (q *QBit) createSymlinksWebdav(debridTorrent *debrid.Torrent, rclonePath, t
|
|||||||
|
|
||||||
// Check which files exist in this batch
|
// Check which files exist in this batch
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if file, exists := remainingFiles[entry.Name()]; exists {
|
filename := entry.Name()
|
||||||
fullFilePath := filepath.Join(rclonePath, file.Name)
|
if file, exists := remainingFiles[filename]; exists {
|
||||||
|
fullFilePath := filepath.Join(rclonePath, filename)
|
||||||
fileSymlinkPath := filepath.Join(symlinkPath, file.Name)
|
fileSymlinkPath := filepath.Join(symlinkPath, file.Name)
|
||||||
|
|
||||||
if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) {
|
if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) {
|
||||||
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
||||||
} else {
|
} else {
|
||||||
q.logger.Info().Msgf("File is ready: %s", file.Name)
|
|
||||||
filePaths = append(filePaths, fileSymlinkPath)
|
filePaths = append(filePaths, fileSymlinkPath)
|
||||||
delete(remainingFiles, file.Name)
|
delete(remainingFiles, filename)
|
||||||
|
q.logger.Info().Msgf("File is ready: %s", file.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ func (q *QBit) Routes() http.Handler {
|
|||||||
r.Use(q.CategoryContext)
|
r.Use(q.CategoryContext)
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(q.authContext)
|
r.Use(q.authContext)
|
||||||
r.Post("/auth/login", q.handleLogin)
|
r.Post("/register/login", q.handleLogin)
|
||||||
r.Route("/torrents", func(r chi.Router) {
|
r.Route("/torrents", func(r chi.Router) {
|
||||||
r.Use(HashesCtx)
|
r.Use(HashesCtx)
|
||||||
r.Get("/info", q.handleTorrentsInfo)
|
r.Get("/info", q.handleTorrentsInfo)
|
||||||
|
|||||||
@@ -250,7 +250,6 @@ func (ts *TorrentStorage) DeleteMultiple(hashes []string, removeFromDebrid bool)
|
|||||||
if dbClient == nil {
|
if dbClient == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Println("Deleting torrent from debrid:", id)
|
|
||||||
err := dbClient.DeleteTorrent(id)
|
err := dbClient.DeleteTorrent(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ func (s *Server) Start(ctx context.Context) error {
|
|||||||
return srv.Shutdown(context.Background())
|
return srv.Shutdown(context.Background())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Mount(pattern string, handler http.Handler) {
|
|
||||||
s.router.Mount(pattern, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
logFile := logger.GetLogPath()
|
logFile := logger.GetLogPath()
|
||||||
|
|
||||||
|
|||||||
+293
@@ -0,0 +1,293 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
"github.com/sirrobot01/decypharr/internal/config"
|
||||||
|
"github.com/sirrobot01/decypharr/internal/request"
|
||||||
|
"github.com/sirrobot01/decypharr/internal/utils"
|
||||||
|
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||||
|
"github.com/sirrobot01/decypharr/pkg/qbit"
|
||||||
|
"github.com/sirrobot01/decypharr/pkg/service"
|
||||||
|
"github.com/sirrobot01/decypharr/pkg/version"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ui *Handler) handleGetArrs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
svc := service.GetService()
|
||||||
|
request.JSONResponse(w, svc.Arr.GetAll(), http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc := service.GetService()
|
||||||
|
|
||||||
|
results := make([]*qbit.ImportRequest, 0)
|
||||||
|
errs := make([]string, 0)
|
||||||
|
|
||||||
|
arrName := r.FormValue("arr")
|
||||||
|
notSymlink := r.FormValue("notSymlink") == "true"
|
||||||
|
downloadUncached := r.FormValue("downloadUncached") == "true"
|
||||||
|
|
||||||
|
_arr := svc.Arr.Get(arrName)
|
||||||
|
if _arr == nil {
|
||||||
|
_arr = arr.New(arrName, "", "", false, false, &downloadUncached)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle URLs
|
||||||
|
if urls := r.FormValue("urls"); urls != "" {
|
||||||
|
var urlList []string
|
||||||
|
for _, u := range strings.Split(urls, "\n") {
|
||||||
|
if trimmed := strings.TrimSpace(u); trimmed != "" {
|
||||||
|
urlList = append(urlList, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, url := range urlList {
|
||||||
|
magnet, err := utils.GetMagnetFromUrl(url)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("Failed to parse URL %s: %v", url, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
importReq := qbit.NewImportRequest(magnet, _arr, !notSymlink, downloadUncached)
|
||||||
|
if err := importReq.Process(ui.qbit); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("URL %s: %v", url, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, importReq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle torrent/magnet files
|
||||||
|
if files := r.MultipartForm.File["files"]; len(files) > 0 {
|
||||||
|
for _, fileHeader := range files {
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("Failed to open file %s: %v", fileHeader.Filename, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
magnet, err := utils.GetMagnetFromFile(file, fileHeader.Filename)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("Failed to parse torrent file %s: %v", fileHeader.Filename, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
importReq := qbit.NewImportRequest(magnet, _arr, !notSymlink, downloadUncached)
|
||||||
|
err = importReq.Process(ui.qbit)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("File %s: %v", fileHeader.Filename, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, importReq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.JSONResponse(w, struct {
|
||||||
|
Results []*qbit.ImportRequest `json:"results"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
}{
|
||||||
|
Results: results,
|
||||||
|
Errors: errs,
|
||||||
|
}, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req RepairRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := service.GetService()
|
||||||
|
|
||||||
|
var arrs []string
|
||||||
|
|
||||||
|
if req.ArrName != "" {
|
||||||
|
_arr := svc.Arr.Get(req.ArrName)
|
||||||
|
if _arr == nil {
|
||||||
|
http.Error(w, "No Arrs found to repair", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
arrs = append(arrs, req.ArrName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Async {
|
||||||
|
go func() {
|
||||||
|
if err := svc.Repair.AddJob(arrs, req.MediaIds, req.AutoProcess, false); err != nil {
|
||||||
|
ui.logger.Error().Err(err).Msg("Failed to repair media")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
request.JSONResponse(w, "Repair process started", http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.Repair.AddJob([]string{req.ArrName}, req.MediaIds, req.AutoProcess, false); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to repair: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
request.JSONResponse(w, "Repair completed", http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v := version.GetInfo()
|
||||||
|
request.JSONResponse(w, v, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleGetTorrents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
request.JSONResponse(w, ui.qbit.Storage.GetAll("", "", nil), http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hash := chi.URLParam(r, "hash")
|
||||||
|
category := r.URL.Query().Get("category")
|
||||||
|
removeFromDebrid := r.URL.Query().Get("removeFromDebrid") == "true"
|
||||||
|
if hash == "" {
|
||||||
|
http.Error(w, "No hash provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.qbit.Storage.Delete(hash, category, removeFromDebrid)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleDeleteTorrents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hashesStr := r.URL.Query().Get("hashes")
|
||||||
|
removeFromDebrid := r.URL.Query().Get("removeFromDebrid") == "true"
|
||||||
|
if hashesStr == "" {
|
||||||
|
http.Error(w, "No hashes provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hashes := strings.Split(hashesStr, ",")
|
||||||
|
ui.qbit.Storage.DeleteMultiple(hashes, removeFromDebrid)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := config.Get()
|
||||||
|
arrCfgs := make([]config.Arr, 0)
|
||||||
|
svc := service.GetService()
|
||||||
|
for _, a := range svc.Arr.GetAll() {
|
||||||
|
arrCfgs = append(arrCfgs, config.Arr{
|
||||||
|
Host: a.Host,
|
||||||
|
Name: a.Name,
|
||||||
|
Token: a.Token,
|
||||||
|
Cleanup: a.Cleanup,
|
||||||
|
SkipRepair: a.SkipRepair,
|
||||||
|
DownloadUncached: a.DownloadUncached,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cfg.Arrs = arrCfgs
|
||||||
|
request.JSONResponse(w, cfg, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Decode the JSON body
|
||||||
|
var updatedConfig config.Config
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updatedConfig); err != nil {
|
||||||
|
ui.logger.Error().Err(err).Msg("Failed to decode config update request")
|
||||||
|
http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current configuration
|
||||||
|
currentConfig := config.Get()
|
||||||
|
|
||||||
|
// Update fields that can be changed
|
||||||
|
currentConfig.LogLevel = updatedConfig.LogLevel
|
||||||
|
currentConfig.MinFileSize = updatedConfig.MinFileSize
|
||||||
|
currentConfig.MaxFileSize = updatedConfig.MaxFileSize
|
||||||
|
currentConfig.AllowedExt = updatedConfig.AllowedExt
|
||||||
|
currentConfig.DiscordWebhook = updatedConfig.DiscordWebhook
|
||||||
|
|
||||||
|
// Should this be added?
|
||||||
|
currentConfig.URLBase = updatedConfig.URLBase
|
||||||
|
currentConfig.BindAddress = updatedConfig.BindAddress
|
||||||
|
currentConfig.Port = updatedConfig.Port
|
||||||
|
|
||||||
|
// Update QBitTorrent config
|
||||||
|
currentConfig.QBitTorrent = updatedConfig.QBitTorrent
|
||||||
|
|
||||||
|
// Update Repair config
|
||||||
|
currentConfig.Repair = updatedConfig.Repair
|
||||||
|
|
||||||
|
// Update Debrids
|
||||||
|
if len(updatedConfig.Debrids) > 0 {
|
||||||
|
currentConfig.Debrids = updatedConfig.Debrids
|
||||||
|
// Clear legacy single debrid if using array
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Arrs through the service
|
||||||
|
svc := service.GetService()
|
||||||
|
svc.Arr.Clear() // Clear existing arrs
|
||||||
|
|
||||||
|
for _, a := range updatedConfig.Arrs {
|
||||||
|
svc.Arr.AddOrUpdate(&arr.Arr{
|
||||||
|
Name: a.Name,
|
||||||
|
Host: a.Host,
|
||||||
|
Token: a.Token,
|
||||||
|
Cleanup: a.Cleanup,
|
||||||
|
SkipRepair: a.SkipRepair,
|
||||||
|
DownloadUncached: a.DownloadUncached,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := currentConfig.Save(); err != nil {
|
||||||
|
http.Error(w, "Error saving config: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if restartFunc != nil {
|
||||||
|
go func() {
|
||||||
|
// Small delay to ensure the response is sent
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
restartFunc()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success
|
||||||
|
request.JSONResponse(w, map[string]string{"status": "success"}, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleGetRepairJobs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
svc := service.GetService()
|
||||||
|
request.JSONResponse(w, svc.Repair.GetJobs(), http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleProcessRepairJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "No job ID provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc := service.GetService()
|
||||||
|
if err := svc.Repair.ProcessJob(id); err != nil {
|
||||||
|
ui.logger.Error().Err(err).Msg("Failed to process repair job")
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleDeleteRepairJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Read ids from body
|
||||||
|
var req struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.IDs) == 0 {
|
||||||
|
http.Error(w, "No job IDs provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := service.GetService()
|
||||||
|
svc.Repair.DeleteJobs(req.IDs)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sirrobot01/decypharr/internal/config"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ui *Handler) verifyAuth(username, password string) bool {
|
||||||
|
// If you're storing hashed password, use bcrypt to compare
|
||||||
|
if username == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
auth := config.Get().GetAuth()
|
||||||
|
if auth == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if username != auth.Username {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(auth.Password), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) skipAuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := config.Get()
|
||||||
|
cfg.UseAuth = false
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
ui.logger.Error().Err(err).Msg("failed to save config")
|
||||||
|
http.Error(w, "failed to save config", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/sirrobot01/decypharr/internal/config"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ui *Handler) setupMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := config.Get()
|
||||||
|
needsAuth := cfg.NeedsSetup()
|
||||||
|
if needsAuth != nil && r.URL.Path != "/config" && r.URL.Path != "/api/config" {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/config?inco=%s", needsAuth.Error()), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// strip inco from URL
|
||||||
|
if inco := r.URL.Query().Get("inco"); inco != "" && needsAuth == nil && r.URL.Path == "/config" {
|
||||||
|
// redirect to the same URL without the inco parameter
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/config"), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) authMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Check if setup is needed
|
||||||
|
cfg := config.Get()
|
||||||
|
if !cfg.UseAuth {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.NeedsAuth() {
|
||||||
|
http.Redirect(w, r, "/register", http.StatusSeeOther)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
+6
-4
@@ -10,16 +10,19 @@ func (ui *Handler) Routes() http.Handler {
|
|||||||
|
|
||||||
r.Get("/login", ui.LoginHandler)
|
r.Get("/login", ui.LoginHandler)
|
||||||
r.Post("/login", ui.LoginHandler)
|
r.Post("/login", ui.LoginHandler)
|
||||||
r.Get("/auth", ui.SetupHandler)
|
r.Get("/register", ui.RegisterHandler)
|
||||||
r.Post("/auth", ui.SetupHandler)
|
r.Post("/register", ui.RegisterHandler)
|
||||||
|
r.Get("/skip-auth", ui.skipAuthHandler)
|
||||||
|
r.Get("/version", ui.handleGetVersion)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(ui.authMiddleware)
|
r.Use(ui.authMiddleware)
|
||||||
|
r.Use(ui.setupMiddleware)
|
||||||
r.Get("/", ui.IndexHandler)
|
r.Get("/", ui.IndexHandler)
|
||||||
r.Get("/download", ui.DownloadHandler)
|
r.Get("/download", ui.DownloadHandler)
|
||||||
r.Get("/repair", ui.RepairHandler)
|
r.Get("/repair", ui.RepairHandler)
|
||||||
r.Get("/config", ui.ConfigHandler)
|
r.Get("/config", ui.ConfigHandler)
|
||||||
r.Route("/internal", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.Get("/arrs", ui.handleGetArrs)
|
r.Get("/arrs", ui.handleGetArrs)
|
||||||
r.Post("/add", ui.handleAddContent)
|
r.Post("/add", ui.handleAddContent)
|
||||||
r.Post("/repair", ui.handleRepairMedia)
|
r.Post("/repair", ui.handleRepairMedia)
|
||||||
@@ -31,7 +34,6 @@ func (ui *Handler) Routes() http.Handler {
|
|||||||
r.Delete("/torrents/", ui.handleDeleteTorrents)
|
r.Delete("/torrents/", ui.handleDeleteTorrents)
|
||||||
r.Get("/config", ui.handleGetConfig)
|
r.Get("/config", ui.handleGetConfig)
|
||||||
r.Post("/config", ui.handleUpdateConfig)
|
r.Post("/config", ui.handleUpdateConfig)
|
||||||
r.Get("/version", ui.handleGetVersion)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+4
-476
@@ -2,25 +2,11 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
|
||||||
"github.com/goccy/go-json"
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/sirrobot01/decypharr/internal/config"
|
|
||||||
"github.com/sirrobot01/decypharr/internal/logger"
|
|
||||||
"github.com/sirrobot01/decypharr/internal/request"
|
|
||||||
"github.com/sirrobot01/decypharr/internal/utils"
|
|
||||||
"github.com/sirrobot01/decypharr/pkg/qbit"
|
|
||||||
"github.com/sirrobot01/decypharr/pkg/service"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
"github.com/sirrobot01/decypharr/internal/logger"
|
||||||
"github.com/sirrobot01/decypharr/pkg/version"
|
"github.com/sirrobot01/decypharr/pkg/qbit"
|
||||||
|
"html/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
var restartFunc func()
|
var restartFunc func()
|
||||||
@@ -88,7 +74,7 @@ func init() {
|
|||||||
"templates/repair.html",
|
"templates/repair.html",
|
||||||
"templates/config.html",
|
"templates/config.html",
|
||||||
"templates/login.html",
|
"templates/login.html",
|
||||||
"templates/setup.html",
|
"templates/register.html",
|
||||||
))
|
))
|
||||||
|
|
||||||
store.Options = &sessions.Options{
|
store.Options = &sessions.Options{
|
||||||
@@ -97,461 +83,3 @@ func init() {
|
|||||||
HttpOnly: false,
|
HttpOnly: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ui *Handler) authMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Check if setup is needed
|
|
||||||
cfg := config.Get()
|
|
||||||
if !cfg.UseAuth {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.NeedsAuth() && r.URL.Path != "/auth" {
|
|
||||||
http.Redirect(w, r, "/auth", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip auth check for setup page
|
|
||||||
if r.URL.Path == "/auth" {
|
|
||||||
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 (ui *Handler) verifyAuth(username, password string) bool {
|
|
||||||
// If you're storing hashed password, use bcrypt to compare
|
|
||||||
if username == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
auth := config.Get().GetAuth()
|
|
||||||
if auth == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if username != auth.Username {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(auth.Password), []byte(password))
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cfg := config.Get()
|
|
||||||
if cfg.NeedsAuth() {
|
|
||||||
http.Redirect(w, r, "/auth", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == "GET" {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"Page": "login",
|
|
||||||
"Title": "Login",
|
|
||||||
}
|
|
||||||
_ = templates.ExecuteTemplate(w, "layout", data)
|
|
||||||
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 ui.verifyAuth(credentials.Username, credentials.Password) {
|
|
||||||
session, _ := store.Get(r, "auth-session")
|
|
||||||
session.Values["authenticated"] = true
|
|
||||||
session.Values["username"] = credentials.Username
|
|
||||||
if err := session.Save(r, w); err != nil {
|
|
||||||
http.Error(w, "Error saving session", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
session, _ := store.Get(r, "auth-session")
|
|
||||||
session.Values["authenticated"] = false
|
|
||||||
session.Options.MaxAge = -1
|
|
||||||
err := session.Save(r, w)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) SetupHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cfg := config.Get()
|
|
||||||
authCfg := cfg.GetAuth()
|
|
||||||
|
|
||||||
if !cfg.NeedsAuth() {
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == "GET" {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"Page": "auth",
|
|
||||||
"Title": "Auth Setup",
|
|
||||||
}
|
|
||||||
_ = templates.ExecuteTemplate(w, "layout", data)
|
|
||||||
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
|
|
||||||
if err := session.Save(r, w); err != nil {
|
|
||||||
http.Error(w, "Error saving session", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"Page": "index",
|
|
||||||
"Title": "Torrents",
|
|
||||||
}
|
|
||||||
_ = templates.ExecuteTemplate(w, "layout", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) DownloadHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"Page": "download",
|
|
||||||
"Title": "Download",
|
|
||||||
}
|
|
||||||
_ = templates.ExecuteTemplate(w, "layout", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) RepairHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"Page": "repair",
|
|
||||||
"Title": "Repair",
|
|
||||||
}
|
|
||||||
_ = templates.ExecuteTemplate(w, "layout", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) ConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"Page": "config",
|
|
||||||
"Title": "Config",
|
|
||||||
}
|
|
||||||
_ = templates.ExecuteTemplate(w, "layout", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) handleGetArrs(w http.ResponseWriter, r *http.Request) {
|
|
||||||
svc := service.GetService()
|
|
||||||
request.JSONResponse(w, svc.Arr.GetAll(), http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
svc := service.GetService()
|
|
||||||
|
|
||||||
results := make([]*qbit.ImportRequest, 0)
|
|
||||||
errs := make([]string, 0)
|
|
||||||
|
|
||||||
arrName := r.FormValue("arr")
|
|
||||||
notSymlink := r.FormValue("notSymlink") == "true"
|
|
||||||
downloadUncached := r.FormValue("downloadUncached") == "true"
|
|
||||||
|
|
||||||
_arr := svc.Arr.Get(arrName)
|
|
||||||
if _arr == nil {
|
|
||||||
_arr = arr.New(arrName, "", "", false, false, &downloadUncached)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle URLs
|
|
||||||
if urls := r.FormValue("urls"); urls != "" {
|
|
||||||
var urlList []string
|
|
||||||
for _, u := range strings.Split(urls, "\n") {
|
|
||||||
if trimmed := strings.TrimSpace(u); trimmed != "" {
|
|
||||||
urlList = append(urlList, trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, url := range urlList {
|
|
||||||
magnet, err := utils.GetMagnetFromUrl(url)
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Sprintf("Failed to parse URL %s: %v", url, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
importReq := qbit.NewImportRequest(magnet, _arr, !notSymlink, downloadUncached)
|
|
||||||
if err := importReq.Process(ui.qbit); err != nil {
|
|
||||||
errs = append(errs, fmt.Sprintf("URL %s: %v", url, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, importReq)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle torrent/magnet files
|
|
||||||
if files := r.MultipartForm.File["files"]; len(files) > 0 {
|
|
||||||
for _, fileHeader := range files {
|
|
||||||
file, err := fileHeader.Open()
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Sprintf("Failed to open file %s: %v", fileHeader.Filename, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
magnet, err := utils.GetMagnetFromFile(file, fileHeader.Filename)
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Sprintf("Failed to parse torrent file %s: %v", fileHeader.Filename, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
importReq := qbit.NewImportRequest(magnet, _arr, !notSymlink, downloadUncached)
|
|
||||||
err = importReq.Process(ui.qbit)
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Sprintf("File %s: %v", fileHeader.Filename, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, importReq)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
request.JSONResponse(w, struct {
|
|
||||||
Results []*qbit.ImportRequest `json:"results"`
|
|
||||||
Errors []string `json:"errors,omitempty"`
|
|
||||||
}{
|
|
||||||
Results: results,
|
|
||||||
Errors: errs,
|
|
||||||
}, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req RepairRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := service.GetService()
|
|
||||||
|
|
||||||
var arrs []string
|
|
||||||
|
|
||||||
if req.ArrName != "" {
|
|
||||||
_arr := svc.Arr.Get(req.ArrName)
|
|
||||||
if _arr == nil {
|
|
||||||
http.Error(w, "No Arrs found to repair", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
arrs = append(arrs, req.ArrName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Async {
|
|
||||||
go func() {
|
|
||||||
if err := svc.Repair.AddJob(arrs, req.MediaIds, req.AutoProcess, false); err != nil {
|
|
||||||
ui.logger.Error().Err(err).Msg("Failed to repair media")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
request.JSONResponse(w, "Repair process started", http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := svc.Repair.AddJob([]string{req.ArrName}, req.MediaIds, req.AutoProcess, false); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to repair: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
request.JSONResponse(w, "Repair completed", http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
|
|
||||||
v := version.GetInfo()
|
|
||||||
request.JSONResponse(w, v, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) handleGetTorrents(w http.ResponseWriter, r *http.Request) {
|
|
||||||
request.JSONResponse(w, ui.qbit.Storage.GetAll("", "", nil), http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
|
|
||||||
hash := chi.URLParam(r, "hash")
|
|
||||||
category := r.URL.Query().Get("category")
|
|
||||||
removeFromDebrid := r.URL.Query().Get("removeFromDebrid") == "true"
|
|
||||||
if hash == "" {
|
|
||||||
http.Error(w, "No hash provided", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ui.qbit.Storage.Delete(hash, category, removeFromDebrid)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) handleDeleteTorrents(w http.ResponseWriter, r *http.Request) {
|
|
||||||
hashesStr := r.URL.Query().Get("hashes")
|
|
||||||
removeFromDebrid := r.URL.Query().Get("removeFromDebrid") == "true"
|
|
||||||
if hashesStr == "" {
|
|
||||||
http.Error(w, "No hashes provided", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hashes := strings.Split(hashesStr, ",")
|
|
||||||
ui.qbit.Storage.DeleteMultiple(hashes, removeFromDebrid)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cfg := config.Get()
|
|
||||||
arrCfgs := make([]config.Arr, 0)
|
|
||||||
svc := service.GetService()
|
|
||||||
for _, a := range svc.Arr.GetAll() {
|
|
||||||
arrCfgs = append(arrCfgs, config.Arr{
|
|
||||||
Host: a.Host,
|
|
||||||
Name: a.Name,
|
|
||||||
Token: a.Token,
|
|
||||||
Cleanup: a.Cleanup,
|
|
||||||
SkipRepair: a.SkipRepair,
|
|
||||||
DownloadUncached: a.DownloadUncached,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
cfg.Arrs = arrCfgs
|
|
||||||
request.JSONResponse(w, cfg, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Decode the JSON body
|
|
||||||
var updatedConfig config.Config
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&updatedConfig); err != nil {
|
|
||||||
ui.logger.Error().Err(err).Msg("Failed to decode config update request")
|
|
||||||
http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the current configuration
|
|
||||||
currentConfig := config.Get()
|
|
||||||
|
|
||||||
// Update fields that can be changed
|
|
||||||
currentConfig.LogLevel = updatedConfig.LogLevel
|
|
||||||
currentConfig.MinFileSize = updatedConfig.MinFileSize
|
|
||||||
currentConfig.MaxFileSize = updatedConfig.MaxFileSize
|
|
||||||
currentConfig.AllowedExt = updatedConfig.AllowedExt
|
|
||||||
currentConfig.DiscordWebhook = updatedConfig.DiscordWebhook
|
|
||||||
|
|
||||||
// Update QBitTorrent config
|
|
||||||
currentConfig.QBitTorrent = updatedConfig.QBitTorrent
|
|
||||||
|
|
||||||
// Update Repair config
|
|
||||||
currentConfig.Repair = updatedConfig.Repair
|
|
||||||
|
|
||||||
// Update Debrids
|
|
||||||
if len(updatedConfig.Debrids) > 0 {
|
|
||||||
currentConfig.Debrids = updatedConfig.Debrids
|
|
||||||
// Clear legacy single debrid if using array
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Arrs through the service
|
|
||||||
svc := service.GetService()
|
|
||||||
svc.Arr.Clear() // Clear existing arrs
|
|
||||||
|
|
||||||
for _, a := range updatedConfig.Arrs {
|
|
||||||
svc.Arr.AddOrUpdate(&arr.Arr{
|
|
||||||
Name: a.Name,
|
|
||||||
Host: a.Host,
|
|
||||||
Token: a.Token,
|
|
||||||
Cleanup: a.Cleanup,
|
|
||||||
SkipRepair: a.SkipRepair,
|
|
||||||
DownloadUncached: a.DownloadUncached,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if err := currentConfig.Save(); err != nil {
|
|
||||||
http.Error(w, "Error saving config: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if restartFunc != nil {
|
|
||||||
go func() {
|
|
||||||
// Small delay to ensure the response is sent
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
restartFunc()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return success
|
|
||||||
request.JSONResponse(w, map[string]string{"status": "success"}, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) handleGetRepairJobs(w http.ResponseWriter, r *http.Request) {
|
|
||||||
svc := service.GetService()
|
|
||||||
request.JSONResponse(w, svc.Repair.GetJobs(), http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) handleProcessRepairJob(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if id == "" {
|
|
||||||
http.Error(w, "No job ID provided", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
svc := service.GetService()
|
|
||||||
if err := svc.Repair.ProcessJob(id); err != nil {
|
|
||||||
ui.logger.Error().Err(err).Msg("Failed to process repair job")
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ui *Handler) handleDeleteRepairJob(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Read ids from body
|
|
||||||
var req struct {
|
|
||||||
IDs []string `json:"ids"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.IDs) == 0 {
|
|
||||||
http.Error(w, "No job IDs provided", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := service.GetService()
|
|
||||||
svc.Repair.DeleteJobs(req.IDs)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|||||||
+597
-462
File diff suppressed because it is too large
Load Diff
@@ -112,7 +112,7 @@
|
|||||||
formData.append('notSymlink', document.getElementById('isSymlink').checked);
|
formData.append('notSymlink', document.getElementById('isSymlink').checked);
|
||||||
formData.append('downloadUncached', document.getElementById('downloadUncached').checked);
|
formData.append('downloadUncached', document.getElementById('downloadUncached').checked);
|
||||||
|
|
||||||
const response = await fetch('/internal/add', {
|
const response = await fetcher('/api/add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -190,7 +190,7 @@
|
|||||||
|
|
||||||
async function loadTorrents() {
|
async function loadTorrents() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/internal/torrents');
|
const response = await fetcher('/api/torrents');
|
||||||
const torrents = await response.json();
|
const torrents = await response.json();
|
||||||
|
|
||||||
state.torrents = torrents;
|
state.torrents = torrents;
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
if (!confirm('Are you sure you want to delete this torrent?')) return;
|
if (!confirm('Are you sure you want to delete this torrent?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(`/internal/torrents/${category}/${hash}?removeFromDebrid=${removeFromDebrid}`, {
|
await fetcher(`/api/torrents/${category}/${hash}?removeFromDebrid=${removeFromDebrid}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
await loadTorrents();
|
await loadTorrents();
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
try {
|
try {
|
||||||
// COmma separated list of hashes
|
// COmma separated list of hashes
|
||||||
const hashes = Array.from(state.selectedTorrents).join(',');
|
const hashes = Array.from(state.selectedTorrents).join(',');
|
||||||
await fetch(`/internal/torrents/?hashes=${encodeURIComponent(hashes)}`, {
|
await fetcher(`/api/torrents/?hashes=${encodeURIComponent(hashes)}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
await loadTorrents();
|
await loadTorrents();
|
||||||
|
|||||||
@@ -197,8 +197,8 @@
|
|||||||
{{ template "config" . }}
|
{{ template "config" . }}
|
||||||
{{ else if eq .Page "login" }}
|
{{ else if eq .Page "login" }}
|
||||||
{{ template "login" . }}
|
{{ template "login" . }}
|
||||||
{{ else if eq .Page "auth" }}
|
{{ else if eq .Page "register" }}
|
||||||
{{ template "auth" . }}
|
{{ template "register" . }}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
@@ -206,6 +206,30 @@
|
|||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
window.urlBase = "{{.URLBase}}";
|
||||||
|
|
||||||
|
function joinUrl (base, path) {
|
||||||
|
if (path.substring(0, 1) === "/") {
|
||||||
|
// path starts with `/`. Thus it is absolute.
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
if (base.substring(base.length-1) === "/") {
|
||||||
|
// base ends with `/`
|
||||||
|
return base + path;
|
||||||
|
}
|
||||||
|
return base + "/" + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetcher(endpoint, options = {}) {
|
||||||
|
// Use the global urlBase or default to empty string
|
||||||
|
let baseUrl = window.urlBase || '';
|
||||||
|
|
||||||
|
let url = joinUrl(baseUrl, endpoint);
|
||||||
|
|
||||||
|
// Return the regular fetcher with the complete URL
|
||||||
|
return fetch(url, options);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Create a toast message
|
* Create a toast message
|
||||||
* @param {string} message - The message to display
|
* @param {string} message - The message to display
|
||||||
@@ -243,6 +267,7 @@
|
|||||||
const toast = new bootstrap.Toast(toastElement, {
|
const toast = new bootstrap.Toast(toastElement, {
|
||||||
autohide: true,
|
autohide: true,
|
||||||
delay: toastTimeouts[type]
|
delay: toastTimeouts[type]
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.show();
|
toast.show();
|
||||||
@@ -302,7 +327,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
fetch('/internal/version')
|
fetcher('/version')
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const versionBadge = document.getElementById('version-badge');
|
const versionBadge = document.getElementById('version-badge');
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/login', {
|
const response = await fetcher('/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -55,77 +55,4 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ define "auth" }}
|
|
||||||
<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="authForm">
|
|
||||||
<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('authForm').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('/auth', {
|
|
||||||
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 }}
|
{{ end }}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{{ define "register" }}
|
||||||
|
<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 Auth Setup</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="authForm">
|
||||||
|
<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="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 gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary mb-2">Save</button>
|
||||||
|
<button type="button" id="skipAuthBtn" class="btn btn-secondary">Skip</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const authForm = document.getElementById('authForm');
|
||||||
|
const skipAuthBtn = document.getElementById('skipAuthBtn');
|
||||||
|
|
||||||
|
authForm.addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Validate passwords match
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
alert('Passwords do not match!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect form data
|
||||||
|
let formData = new FormData();
|
||||||
|
formData.append('username', document.getElementById('username').value);
|
||||||
|
formData.append('password', password);
|
||||||
|
formData.append('confirmPassword', confirmPassword);
|
||||||
|
await fetcher('/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.text().then(errorText => {
|
||||||
|
// Throw an error with the response text
|
||||||
|
createToast(errorText || 'Registration failed', 'error');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.location.href = joinUrl(window.urlBase, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Registration failed: ' + error.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle skip auth button
|
||||||
|
skipAuthBtn.addEventListener('click', function() {
|
||||||
|
fetcher('/skip-auth', { method: 'GET' })
|
||||||
|
.then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = joinUrl(window.urlBase, '/');
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to skip authentication');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Load Arr instances
|
// Load Arr instances
|
||||||
fetch('/internal/arrs')
|
fetcher('/api/arrs')
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(arrs => {
|
.then(arrs => {
|
||||||
const select = document.getElementById('arrSelect');
|
const select = document.getElementById('arrSelect');
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
let mediaIds = document.getElementById('mediaIds').value.split(',').map(id => id.trim());
|
let mediaIds = document.getElementById('mediaIds').value.split(',').map(id => id.trim());
|
||||||
let arr = document.getElementById('arrSelect').value;
|
let arr = document.getElementById('arrSelect').value;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/internal/repair', {
|
const response = await fetcher('/api/repair', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -207,8 +207,8 @@
|
|||||||
// Load jobs function
|
// Load jobs function
|
||||||
async function loadJobs(page) {
|
async function loadJobs(page) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/internal/repair/jobs');
|
const response = await fetcher('/api/repair/jobs');
|
||||||
if (!response.ok) throw new Error('Failed to fetch jobs');
|
if (!response.ok) throw new Error('Failed to fetcher jobs');
|
||||||
|
|
||||||
allJobs = await response.json();
|
allJobs = await response.json();
|
||||||
renderJobsTable(page);
|
renderJobsTable(page);
|
||||||
@@ -403,7 +403,7 @@
|
|||||||
async function deleteJob(jobId) {
|
async function deleteJob(jobId) {
|
||||||
if (confirm('Are you sure you want to delete this job?')) {
|
if (confirm('Are you sure you want to delete this job?')) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/internal/repair/jobs`, {
|
const response = await fetcher(`/api/repair/jobs`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
|
|
||||||
async function deleteMultipleJobs(jobIds) {
|
async function deleteMultipleJobs(jobIds) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/internal/repair/jobs`, {
|
const response = await fetcher(`/api/repair/jobs`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -441,7 +441,7 @@
|
|||||||
// Process job function
|
// Process job function
|
||||||
async function processJob(jobId) {
|
async function processJob(jobId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/internal/repair/jobs/${jobId}/process`, {
|
const response = await fetcher(`/api/repair/jobs/${jobId}/process`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
{{ define "auth" }}
|
|
||||||
<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="authForm" method="POST" action="/auth">
|
|
||||||
<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 }}
|
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
"github.com/sirrobot01/decypharr/internal/config"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ui *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := config.Get()
|
||||||
|
if cfg.NeedsAuth() {
|
||||||
|
http.Redirect(w, r, "/register", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == "GET" {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"URLBase": cfg.URLBase,
|
||||||
|
"Page": "login",
|
||||||
|
"Title": "Login",
|
||||||
|
}
|
||||||
|
_ = templates.ExecuteTemplate(w, "layout", data)
|
||||||
|
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 ui.verifyAuth(credentials.Username, credentials.Password) {
|
||||||
|
session, _ := store.Get(r, "auth-session")
|
||||||
|
session.Values["authenticated"] = true
|
||||||
|
session.Values["username"] = credentials.Username
|
||||||
|
if err := session.Save(r, w); err != nil {
|
||||||
|
http.Error(w, "Error saving session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, _ := store.Get(r, "auth-session")
|
||||||
|
session.Values["authenticated"] = false
|
||||||
|
session.Options.MaxAge = -1
|
||||||
|
err := session.Save(r, w)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := config.Get()
|
||||||
|
authCfg := cfg.GetAuth()
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"URLBase": cfg.URLBase,
|
||||||
|
"Page": "register",
|
||||||
|
"Title": "Register",
|
||||||
|
}
|
||||||
|
_ = templates.ExecuteTemplate(w, "layout", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if err := session.Save(r, w); err != nil {
|
||||||
|
http.Error(w, "Error saving session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := config.Get()
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"URLBase": cfg.URLBase,
|
||||||
|
"Page": "index",
|
||||||
|
"Title": "Torrents",
|
||||||
|
}
|
||||||
|
_ = templates.ExecuteTemplate(w, "layout", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) DownloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := config.Get()
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"URLBase": cfg.URLBase,
|
||||||
|
"Page": "download",
|
||||||
|
"Title": "Download",
|
||||||
|
}
|
||||||
|
_ = templates.ExecuteTemplate(w, "layout", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) RepairHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := config.Get()
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"URLBase": cfg.URLBase,
|
||||||
|
"Page": "repair",
|
||||||
|
"Title": "Repair",
|
||||||
|
}
|
||||||
|
_ = templates.ExecuteTemplate(w, "layout", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) ConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := config.Get()
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"URLBase": cfg.URLBase,
|
||||||
|
"Page": "config",
|
||||||
|
"Title": "Config",
|
||||||
|
}
|
||||||
|
_ = templates.ExecuteTemplate(w, "layout", data)
|
||||||
|
}
|
||||||
@@ -140,7 +140,7 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
|
|||||||
folderName := strings.TrimPrefix(name, rootDir)
|
folderName := strings.TrimPrefix(name, rootDir)
|
||||||
folderName = strings.TrimPrefix(folderName, "/")
|
folderName = strings.TrimPrefix(folderName, "/")
|
||||||
|
|
||||||
// Only fetch the torrent folders once
|
// Only fetcher the torrent folders once
|
||||||
children := h.getTorrentsFolders()
|
children := h.getTorrentsFolders()
|
||||||
|
|
||||||
return &File{
|
return &File{
|
||||||
|
|||||||
Reference in New Issue
Block a user