- Fix symlinks % bug
- A cleaner settings page - More bug fixes
This commit is contained in:
@@ -89,7 +89,6 @@ type Config struct {
|
||||
|
||||
LogLevel string `json:"log_level,omitempty"`
|
||||
Debrids []Debrid `json:"debrids,omitempty"`
|
||||
MaxCacheSize int `json:"max_cache_size,omitempty"`
|
||||
QBitTorrent QBitTorrent `json:"qbittorrent,omitempty"`
|
||||
Arrs []Arr `json:"arrs,omitempty"`
|
||||
Repair Repair `json:"repair,omitempty"`
|
||||
@@ -118,39 +117,19 @@ func (c *Config) loadConfig() error {
|
||||
c.Path = configPath
|
||||
file, err := os.ReadFile(c.JsonFile())
|
||||
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
|
||||
}
|
||||
|
||||
@@ -196,15 +175,15 @@ func ValidateConfig(config *Config) error {
|
||||
// Run validations concurrently
|
||||
|
||||
if err := validateDebrids(config.Debrids); err != nil {
|
||||
return fmt.Errorf("debrids validation error: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateQbitTorrent(&config.QBitTorrent); err != nil {
|
||||
return fmt.Errorf("qbittorrent validation error: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateRepair(&config.Repair); err != nil {
|
||||
return fmt.Errorf("repair validation error: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -287,11 +266,8 @@ func (c *Config) SaveAuth(auth *Auth) error {
|
||||
return os.WriteFile(c.AuthFile(), data, 0644)
|
||||
}
|
||||
|
||||
func (c *Config) NeedsSetup() bool {
|
||||
if err := ValidateConfig(c); err != nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
func (c *Config) NeedsSetup() error {
|
||||
return ValidateConfig(c)
|
||||
}
|
||||
|
||||
func (c *Config) NeedsAuth() bool {
|
||||
@@ -336,6 +312,26 @@ func (c *Config) updateDebrid(d Debrid) Debrid {
|
||||
}
|
||||
|
||||
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, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -347,6 +343,25 @@ func (c *Config) Save() error {
|
||||
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
|
||||
func Reload() {
|
||||
instance = nil
|
||||
|
||||
@@ -200,7 +200,6 @@ func (as *Storage) cleanupArrsQueue() {
|
||||
arrs = append(arrs, arr)
|
||||
}
|
||||
if len(arrs) > 0 {
|
||||
as.logger.Trace().Msgf("Cleaning up %d arrs", len(arrs))
|
||||
for _, arr := range arrs {
|
||||
if err := arr.CleanupQueue(); err != nil {
|
||||
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),
|
||||
}
|
||||
|
||||
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{
|
||||
Name: "realdebrid",
|
||||
Host: "https://api.real-debrid.com/rest/1.0",
|
||||
APIKey: dc.APIKey,
|
||||
DownloadKeys: accounts,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: client,
|
||||
downloadClient: downloadClient,
|
||||
Name: "realdebrid",
|
||||
Host: "https://api.real-debrid.com/rest/1.0",
|
||||
APIKey: dc.APIKey,
|
||||
DownloadKeys: accounts,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: request.New(
|
||||
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,
|
||||
MountPath: dc.Folder,
|
||||
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
|
||||
for _, entry := range entries {
|
||||
if file, exists := remainingFiles[entry.Name()]; exists {
|
||||
fullFilePath := filepath.Join(rclonePath, file.Name)
|
||||
filename := entry.Name()
|
||||
if file, exists := remainingFiles[filename]; exists {
|
||||
fullFilePath := filepath.Join(rclonePath, filename)
|
||||
fileSymlinkPath := filepath.Join(symlinkPath, file.Name)
|
||||
|
||||
if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) {
|
||||
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
||||
} else {
|
||||
q.logger.Info().Msgf("File is ready: %s", file.Name)
|
||||
filePaths = append(filePaths, fileSymlinkPath)
|
||||
delete(remainingFiles, file.Name)
|
||||
delete(remainingFiles, filename)
|
||||
q.logger.Info().Msgf("File is ready: %s", file.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ func (q *QBit) Routes() http.Handler {
|
||||
r.Use(q.CategoryContext)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(q.authContext)
|
||||
r.Post("/auth/login", q.handleLogin)
|
||||
r.Post("/register/login", q.handleLogin)
|
||||
r.Route("/torrents", func(r chi.Router) {
|
||||
r.Use(HashesCtx)
|
||||
r.Get("/info", q.handleTorrentsInfo)
|
||||
|
||||
@@ -250,7 +250,6 @@ func (ts *TorrentStorage) DeleteMultiple(hashes []string, removeFromDebrid bool)
|
||||
if dbClient == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Println("Deleting torrent from debrid:", id)
|
||||
err := dbClient.DeleteTorrent(id)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
|
||||
@@ -83,10 +83,6 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
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) {
|
||||
logFile := logger.GetLogPath()
|
||||
|
||||
|
||||
293
pkg/web/api.go
Normal file
293
pkg/web/api.go
Normal file
@@ -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)
|
||||
}
|
||||
34
pkg/web/auth.go
Normal file
34
pkg/web/auth.go
Normal file
@@ -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)
|
||||
}
|
||||
51
pkg/web/middlewares.go
Normal file
51
pkg/web/middlewares.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -10,16 +10,19 @@ func (ui *Handler) Routes() http.Handler {
|
||||
|
||||
r.Get("/login", ui.LoginHandler)
|
||||
r.Post("/login", ui.LoginHandler)
|
||||
r.Get("/auth", ui.SetupHandler)
|
||||
r.Post("/auth", ui.SetupHandler)
|
||||
r.Get("/register", ui.RegisterHandler)
|
||||
r.Post("/register", ui.RegisterHandler)
|
||||
r.Get("/skip-auth", ui.skipAuthHandler)
|
||||
r.Get("/version", ui.handleGetVersion)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(ui.authMiddleware)
|
||||
r.Use(ui.setupMiddleware)
|
||||
r.Get("/", ui.IndexHandler)
|
||||
r.Get("/download", ui.DownloadHandler)
|
||||
r.Get("/repair", ui.RepairHandler)
|
||||
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.Post("/add", ui.handleAddContent)
|
||||
r.Post("/repair", ui.handleRepairMedia)
|
||||
@@ -31,7 +34,6 @@ func (ui *Handler) Routes() http.Handler {
|
||||
r.Delete("/torrents/", ui.handleDeleteTorrents)
|
||||
r.Get("/config", ui.handleGetConfig)
|
||||
r.Post("/config", ui.handleUpdateConfig)
|
||||
r.Get("/version", ui.handleGetVersion)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,25 +2,11 @@ package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"github.com/goccy/go-json"
|
||||
"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/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/version"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/pkg/qbit"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
var restartFunc func()
|
||||
@@ -88,7 +74,7 @@ func init() {
|
||||
"templates/repair.html",
|
||||
"templates/config.html",
|
||||
"templates/login.html",
|
||||
"templates/setup.html",
|
||||
"templates/register.html",
|
||||
))
|
||||
|
||||
store.Options = &sessions.Options{
|
||||
@@ -97,461 +83,3 @@ func init() {
|
||||
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)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,7 +112,7 @@
|
||||
formData.append('notSymlink', document.getElementById('isSymlink').checked);
|
||||
formData.append('downloadUncached', document.getElementById('downloadUncached').checked);
|
||||
|
||||
const response = await fetch('/internal/add', {
|
||||
const response = await fetcher('/api/add', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
|
||||
async function loadTorrents() {
|
||||
try {
|
||||
const response = await fetch('/internal/torrents');
|
||||
const response = await fetcher('/api/torrents');
|
||||
const torrents = await response.json();
|
||||
|
||||
state.torrents = torrents;
|
||||
@@ -256,7 +256,7 @@
|
||||
if (!confirm('Are you sure you want to delete this torrent?')) return;
|
||||
|
||||
try {
|
||||
await fetch(`/internal/torrents/${category}/${hash}?removeFromDebrid=${removeFromDebrid}`, {
|
||||
await fetcher(`/api/torrents/${category}/${hash}?removeFromDebrid=${removeFromDebrid}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
await loadTorrents();
|
||||
@@ -273,7 +273,7 @@
|
||||
try {
|
||||
// COmma separated list of hashes
|
||||
const hashes = Array.from(state.selectedTorrents).join(',');
|
||||
await fetch(`/internal/torrents/?hashes=${encodeURIComponent(hashes)}`, {
|
||||
await fetcher(`/api/torrents/?hashes=${encodeURIComponent(hashes)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
await loadTorrents();
|
||||
|
||||
@@ -197,8 +197,8 @@
|
||||
{{ template "config" . }}
|
||||
{{ else if eq .Page "login" }}
|
||||
{{ template "login" . }}
|
||||
{{ else if eq .Page "auth" }}
|
||||
{{ template "auth" . }}
|
||||
{{ else if eq .Page "register" }}
|
||||
{{ template "register" . }}
|
||||
{{ else }}
|
||||
{{ end }}
|
||||
|
||||
@@ -206,6 +206,30 @@
|
||||
<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>
|
||||
|
||||
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
|
||||
* @param {string} message - The message to display
|
||||
@@ -243,6 +267,7 @@
|
||||
const toast = new bootstrap.Toast(toastElement, {
|
||||
autohide: true,
|
||||
delay: toastTimeouts[type]
|
||||
|
||||
});
|
||||
|
||||
toast.show();
|
||||
@@ -302,7 +327,7 @@
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
fetch('/internal/version')
|
||||
fetcher('/version')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const versionBadge = document.getElementById('version-badge');
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/login', {
|
||||
const response = await fetcher('/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -55,77 +55,4 @@
|
||||
}
|
||||
});
|
||||
</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 }}
|
||||
92
pkg/web/templates/register.html
Normal file
92
pkg/web/templates/register.html
Normal file
@@ -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>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load Arr instances
|
||||
fetch('/internal/arrs')
|
||||
fetcher('/api/arrs')
|
||||
.then(response => response.json())
|
||||
.then(arrs => {
|
||||
const select = document.getElementById('arrSelect');
|
||||
@@ -175,7 +175,7 @@
|
||||
let mediaIds = document.getElementById('mediaIds').value.split(',').map(id => id.trim());
|
||||
let arr = document.getElementById('arrSelect').value;
|
||||
try {
|
||||
const response = await fetch('/internal/repair', {
|
||||
const response = await fetcher('/api/repair', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -207,8 +207,8 @@
|
||||
// Load jobs function
|
||||
async function loadJobs(page) {
|
||||
try {
|
||||
const response = await fetch('/internal/repair/jobs');
|
||||
if (!response.ok) throw new Error('Failed to fetch jobs');
|
||||
const response = await fetcher('/api/repair/jobs');
|
||||
if (!response.ok) throw new Error('Failed to fetcher jobs');
|
||||
|
||||
allJobs = await response.json();
|
||||
renderJobsTable(page);
|
||||
@@ -403,7 +403,7 @@
|
||||
async function deleteJob(jobId) {
|
||||
if (confirm('Are you sure you want to delete this job?')) {
|
||||
try {
|
||||
const response = await fetch(`/internal/repair/jobs`, {
|
||||
const response = await fetcher(`/api/repair/jobs`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -422,7 +422,7 @@
|
||||
|
||||
async function deleteMultipleJobs(jobIds) {
|
||||
try {
|
||||
const response = await fetch(`/internal/repair/jobs`, {
|
||||
const response = await fetcher(`/api/repair/jobs`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -441,7 +441,7 @@
|
||||
// Process job function
|
||||
async function processJob(jobId) {
|
||||
try {
|
||||
const response = await fetch(`/internal/repair/jobs/${jobId}/process`, {
|
||||
const response = await fetcher(`/api/repair/jobs/${jobId}/process`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'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
pkg/web/ui.go
Normal file
151
pkg/web/ui.go
Normal file
@@ -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(folderName, "/")
|
||||
|
||||
// Only fetch the torrent folders once
|
||||
// Only fetcher the torrent folders once
|
||||
children := h.getTorrentsFolders()
|
||||
|
||||
return &File{
|
||||
|
||||
Reference in New Issue
Block a user