package server import ( "embed" "encoding/json" "fmt" "github.com/gorilla/sessions" "github.com/sirrobot01/debrid-blackhole/internal/config" "github.com/sirrobot01/debrid-blackhole/internal/request" "github.com/sirrobot01/debrid-blackhole/internal/utils" "golang.org/x/crypto/bcrypt" "html/template" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/rs/zerolog" "github.com/sirrobot01/debrid-blackhole/pkg/arr" "github.com/sirrobot01/debrid-blackhole/pkg/debrid" "github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared" "github.com/sirrobot01/debrid-blackhole/pkg/version" ) type AddRequest struct { Url string `json:"url"` Arr string `json:"arr"` File string `json:"file"` NotSymlink bool `json:"notSymlink"` Content string `json:"content"` Seasons []string `json:"seasons"` Episodes []string `json:"episodes"` } type ArrResponse struct { Name string `json:"name"` Url string `json:"url"` } type ContentResponse struct { ID string `json:"id"` Title string `json:"title"` Type string `json:"type"` ArrID string `json:"arr"` } type RepairRequest struct { ArrName string `json:"arr"` MediaIds []string `json:"mediaIds"` Async bool `json:"async"` } //go:embed templates/* var content embed.FS type UIHandler struct { qbit *shared.QBit logger zerolog.Logger debug bool } var ( store = sessions.NewCookieStore([]byte("your-secret-key")) // Change this to a secure key templates *template.Template ) func init() { templates = template.Must(template.ParseFS( content, "templates/layout.html", "templates/index.html", "templates/download.html", "templates/repair.html", "templates/config.html", "templates/login.html", "templates/setup.html", )) store.Options = &sessions.Options{ Path: "/", MaxAge: 86400 * 7, HttpOnly: false, } } func (u *UIHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { data := map[string]interface{}{ "Page": "login", "Title": "Login", } if err := templates.ExecuteTemplate(w, "layout", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } return } var credentials struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } if u.verifyAuth(credentials.Username, credentials.Password) { session, _ := store.Get(r, "auth-session") session.Values["authenticated"] = true session.Values["username"] = credentials.Username session.Save(r, w) http.Redirect(w, r, "/", http.StatusSeeOther) return } http.Error(w, "Invalid credentials", http.StatusUnauthorized) } func (u *UIHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) { session, _ := store.Get(r, "auth-session") session.Values["authenticated"] = false session.Options.MaxAge = -1 session.Save(r, w) http.Redirect(w, r, "/login", http.StatusSeeOther) } func (u *UIHandler) SetupHandler(w http.ResponseWriter, r *http.Request) { cfg := config.GetConfig() authCfg := cfg.GetAuth() if !cfg.NeedsSetup() { http.Redirect(w, r, "/", http.StatusSeeOther) return } if r.Method == "GET" { data := map[string]interface{}{ "Page": "setup", "Title": "Setup", } if err := templates.ExecuteTemplate(w, "layout", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } return } // Handle POST (setup attempt) username := r.FormValue("username") password := r.FormValue("password") confirmPassword := r.FormValue("confirmPassword") if password != confirmPassword { http.Error(w, "Passwords do not match", http.StatusBadRequest) return } // Hash the password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { http.Error(w, "Error processing password", http.StatusInternalServerError) return } // Set the credentials authCfg.Username = username authCfg.Password = string(hashedPassword) if err := cfg.SaveAuth(authCfg); err != nil { http.Error(w, "Error saving credentials", http.StatusInternalServerError) return } // Create a session session, _ := store.Get(r, "auth-session") session.Values["authenticated"] = true session.Values["username"] = username session.Save(r, w) http.Redirect(w, r, "/", http.StatusSeeOther) } func (u *UIHandler) IndexHandler(w http.ResponseWriter, r *http.Request) { data := map[string]interface{}{ "Page": "index", "Title": "Torrents", } if err := templates.ExecuteTemplate(w, "layout", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } func (u *UIHandler) DownloadHandler(w http.ResponseWriter, r *http.Request) { data := map[string]interface{}{ "Page": "download", "Title": "Download", } if err := templates.ExecuteTemplate(w, "layout", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } func (u *UIHandler) RepairHandler(w http.ResponseWriter, r *http.Request) { data := map[string]interface{}{ "Page": "repair", "Title": "Repair", } if err := templates.ExecuteTemplate(w, "layout", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } func (u *UIHandler) ConfigHandler(w http.ResponseWriter, r *http.Request) { data := map[string]interface{}{ "Page": "config", "Title": "Config", } if err := templates.ExecuteTemplate(w, "layout", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } func (u *UIHandler) handleGetArrs(w http.ResponseWriter, r *http.Request) { request.JSONResponse(w, u.qbit.Arrs.GetAll(), http.StatusOK) } func (u *UIHandler) handleAddContent(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(32 << 20); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } results := make([]*ImportRequest, 0) errs := make([]string, 0) arrName := r.FormValue("arr") notSymlink := r.FormValue("notSymlink") == "true" _arr := u.qbit.Arrs.Get(arrName) if _arr == nil { _arr = arr.NewArr(arrName, "", "", arr.Sonarr) } // 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 { importReq := NewImportRequest(url, _arr, !notSymlink) err := importReq.Process(u.qbit) if 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 := NewImportRequest(magnet.Link, _arr, !notSymlink) err = importReq.Process(u.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 []*ImportRequest `json:"results"` Errors []string `json:"errors,omitempty"` }{ Results: results, Errors: errs, }, http.StatusOK) } func (u *UIHandler) handleCheckCached(w http.ResponseWriter, r *http.Request) { _hashes := r.URL.Query().Get("hash") if _hashes == "" { http.Error(w, "No hashes provided", http.StatusBadRequest) return } hashes := strings.Split(_hashes, ",") if len(hashes) == 0 { http.Error(w, "No hashes provided", http.StatusBadRequest) return } db := r.URL.Query().Get("debrid") var deb debrid.Service if db == "" { // use the first debrid deb = u.qbit.Debrid.Get() } else { deb = u.qbit.Debrid.GetByName(db) } if deb == nil { http.Error(w, "Invalid debrid", http.StatusBadRequest) return } res := deb.IsAvailable(hashes) result := make(map[string]bool) for _, h := range hashes { _, exists := res[h] result[h] = exists } request.JSONResponse(w, result, http.StatusOK) } func (u *UIHandler) 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 } _arr := u.qbit.Arrs.Get(req.ArrName) if _arr == nil { http.Error(w, "No Arrs found to repair", http.StatusNotFound) return } if req.Async { go func() { if err := u.qbit.Repair.Repair([]*arr.Arr{_arr}, req.MediaIds); err != nil { u.logger.Error().Err(err).Msg("Failed to repair media") } }() request.JSONResponse(w, "Repair process started", http.StatusOK) return } if err := u.qbit.Repair.Repair([]*arr.Arr{_arr}, req.MediaIds); err != nil { http.Error(w, fmt.Sprintf("Failed to repair: %v", err), http.StatusInternalServerError) return } request.JSONResponse(w, "Repair completed", http.StatusOK) } func (u *UIHandler) handleGetVersion(w http.ResponseWriter, r *http.Request) { v := version.GetInfo() request.JSONResponse(w, v, http.StatusOK) } func (u *UIHandler) handleGetTorrents(w http.ResponseWriter, r *http.Request) { request.JSONResponse(w, u.qbit.Storage.GetAll("", "", nil), http.StatusOK) } func (u *UIHandler) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) { hash := chi.URLParam(r, "hash") if hash == "" { http.Error(w, "No hash provided", http.StatusBadRequest) return } u.qbit.Storage.Delete(hash) w.WriteHeader(http.StatusOK) } func (u *UIHandler) handleGetConfig(w http.ResponseWriter, r *http.Request) { cfg := config.GetConfig() arrCfgs := make([]config.Arr, 0) for _, a := range u.qbit.Arrs.GetAll() { arrCfgs = append(arrCfgs, config.Arr{Host: a.Host, Name: a.Name, Token: a.Token}) } cfg.Arrs = arrCfgs request.JSONResponse(w, cfg, http.StatusOK) }