diff --git a/README.md b/README.md index aa1d830..6072e94 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # yoink -A tool for downloading comics from readallcomics.com and packaging them as `.cbz` archives. Available as a CLI command or a self-hosted web application. The web UI also lets you package local image folders into `.cbz` archives directly from your browser. +A tool for downloading comics from [readallcomics.com](https://readallcomics.com) and [batcave.biz](https://batcave.biz), packaging them as `.cbz` archives. Available as a CLI command or a self-hosted web application. The web UI also lets you package local image folders into `.cbz` archives directly from your browser. ## How it works @@ -41,15 +41,16 @@ Download a single comic issue: yoink ``` -**Example:** +**Examples:** ```shell yoink https://readallcomics.com/ultraman-x-avengers-001-2024/ +yoink https://batcave.biz/ultraman-x-avengers-1-2025/ ``` The comic title is extracted from the page and used to name the archive. Output is saved to: -``` +```text //<Title>.cbz ``` @@ -96,6 +97,7 @@ The web UI is then available at `http://localhost:8080`. - **Library grid** — browse your comics as a 150×300 cover grid with title-initial placeholders for missing covers - **Filter & sort** — filter by title and sort by newest, oldest, A–Z, or Z–A - **One-click download** — click any cover to download the `.cbz` archive directly +- **Delete** — remove a comic from your library with the × button on each card (confirmation required) #### Packaging local images @@ -116,6 +118,10 @@ Downloaded comics are stored at the path set by `YOINK_LIBRARY`. When using Dock ```yaml # docker-compose.yml services: + flaresolverr: + image: ghcr.io/flaresolverr/flaresolverr:latest + restart: unless-stopped + yoink: image: git.brizzle.dev/bryan/yoink-go:latest ports: @@ -124,16 +130,20 @@ services: - ./library:/library environment: - YOINK_LIBRARY=/library + - FLARESOLVERR_URL=http://flaresolverr:8191 restart: unless-stopped + depends_on: + - flaresolverr ``` --- ## Configuration -| Variable | Default | Description | -|-----------------|------------|-----------------------------------| +| Variable | Default | Description | +| --- | --- | --- | | `YOINK_LIBRARY` | `~/.yoink` | Directory where comics are stored | +| `FLARESOLVERR_URL` | *(unset)* | URL of a [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) instance for Cloudflare-protected sites (e.g. batcave.biz). Required when running in Docker. | ```shell YOINK_LIBRARY=/mnt/media/comics yoink https://readallcomics.com/some-comic-001/ diff --git a/cli/root.go b/cli/root.go index 55a27a8..9e07056 100644 --- a/cli/root.go +++ b/cli/root.go @@ -40,7 +40,7 @@ var cli = &cobra.Command{ fmt.Println(comic.Title) err := comic.Download(len(comic.Filelist)) - for e := range err { + for _, e := range err { fmt.Println(e) } diff --git a/comic/archive.go b/comic/archive.go index 98d6f69..e3b0dd9 100644 --- a/comic/archive.go +++ b/comic/archive.go @@ -23,6 +23,9 @@ func (a ArchiveError) Error() string { // It takes no parameters. // Returns an error if the operation fails. func (c *Comic) Archive() error { + if len(c.Filelist) == 0 { + return nil + } outputPath := filepath.Join(c.LibraryPath, c.Title, c.Title+".cbz") err := os.MkdirAll(filepath.Dir(outputPath), os.ModePerm) @@ -45,7 +48,7 @@ func (c *Comic) Archive() error { sourcePath := filepath.Join(c.LibraryPath, c.Title) err = filepath.Walk( - filepath.Dir(sourcePath), + sourcePath, func(path string, info os.FileInfo, err error) error { if err != nil { return ArchiveError{ diff --git a/comic/archive_test.go b/comic/archive_test.go index 6f1645e..44f5e32 100644 --- a/comic/archive_test.go +++ b/comic/archive_test.go @@ -29,6 +29,7 @@ func TestArchive(t *testing.T) { c := &Comic{ Title: title, LibraryPath: tmpDir, + Filelist: []string{"TestComic 001.jpg", "TestComic 002.jpg", "TestComic 003.png"}, } err := c.Archive() @@ -67,6 +68,7 @@ func TestArchive(t *testing.T) { c := &Comic{ Title: title, LibraryPath: tmpDir, + Filelist: []string{"page-001.jpg"}, } err := c.Archive() @@ -86,11 +88,9 @@ func TestArchive(t *testing.T) { } }) - t.Run("handles empty directory", func(t *testing.T) { + t.Run("creates nothing when filelist is empty", func(t *testing.T) { tmpDir := t.TempDir() title := "EmptyComic" - comicDir := filepath.Join(tmpDir, title) - os.MkdirAll(comicDir, os.ModePerm) c := &Comic{ Title: title, @@ -102,9 +102,9 @@ func TestArchive(t *testing.T) { t.Fatalf("Archive() unexpected error: %v", err) } - archivePath := filepath.Join(comicDir, title+".cbz") - if _, err := os.Stat(archivePath); os.IsNotExist(err) { - t.Fatalf("expected archive %s to exist even if empty", archivePath) + archivePath := filepath.Join(tmpDir, title, title+".cbz") + if _, err := os.Stat(archivePath); !os.IsNotExist(err) { + t.Fatalf("expected no archive to be created for empty filelist") } }) } diff --git a/comic/comic.go b/comic/comic.go index 16fa617..c45fa0a 100644 --- a/comic/comic.go +++ b/comic/comic.go @@ -1,6 +1,7 @@ package comic import ( + "net/http" "path/filepath" "regexp" "strings" @@ -18,6 +19,7 @@ type Comic struct { Next *Comic Prev *Comic LibraryPath string + Client *http.Client } // extractTitleFromMarkup extracts the title from the comic's markup. @@ -93,18 +95,22 @@ func NewComic( } if strings.Contains(url, "batcave.biz") { - go BatcaveBizMarkup(url, markupChannel) - } else { - go Markup(url, markupChannel) - } - - markup := <-markupChannel - c.Markup = markup - c.Title = extractTitleFromMarkup(*c) - - if strings.Contains(url, "batcave.biz") { + clientChan := make(chan *http.Client, 1) + go BatcaveBizMarkup(url, markupChannel, clientChan) + markup := <-markupChannel + c.Markup = markup + c.Client = <-clientChan + if t := ParseBatcaveBizTitle(markup, url); t != "" { + c.Title = t + } else { + c.Title = extractTitleFromMarkup(*c) + } go ParseBatcaveBizImageLinks(markup, imageChannel) } else { + go Markup(url, markupChannel) + markup := <-markupChannel + c.Markup = markup + c.Title = extractTitleFromMarkup(*c) go ParseImageLinks(markup, imageChannel) } links := <-imageChannel diff --git a/comic/download.go b/comic/download.go index 8fe8cb7..f0923f7 100644 --- a/comic/download.go +++ b/comic/download.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" cloudflarebp "github.com/DaRealFreak/cloudflare-bp-go" @@ -39,13 +40,33 @@ func downloadFile(url string, page int, c *Comic) error { } } - res, err := handleRequest(url) + var res *http.Response + var err error + if c.Client != nil { + req, reqErr := http.NewRequest("GET", url, nil) + if reqErr != nil { + return ComicDownloadError{Message: "invalid request", Code: 1} + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + if strings.Contains(url, "batcave.biz") { + req.Header.Set("Referer", "https://batcave.biz/") + } + res, err = c.Client.Do(req) + } else { + res, err = handleRequest(url) + } if err != nil { return ComicDownloadError{ Message: "invalid request", Code: 1, } } + if res.StatusCode != http.StatusOK { + return ComicDownloadError{ + Message: "bad response", + Code: 1, + } + } defer res.Body.Close() imageFile, err := os.Create(imageFilepath) diff --git a/comic/parser.go b/comic/parser.go index 32b1058..b20e264 100644 --- a/comic/parser.go +++ b/comic/parser.go @@ -1,12 +1,18 @@ package comic import ( + "bytes" + "encoding/json" + "fmt" "io" + "log" "net/http" "net/http/cookiejar" "net/url" + "os" "regexp" "strings" + "time" "github.com/PuerkitoBio/goquery" ) @@ -50,28 +56,93 @@ func Markup(url string, c chan *goquery.Document) *goquery.Document { return markup } -func BatcaveBizMarkup(referer string, c chan *goquery.Document) *goquery.Document { +// fetchViaFlareSolverr fetches a URL through FlareSolverr (headless Chrome), +// returning the final page HTML as a Document. Cookies from the browser session +// are written into jar for use in subsequent requests (e.g. image downloads). +func fetchViaFlareSolverr(targetURL string, jar *cookiejar.Jar) (*goquery.Document, error) { + fsURL := os.Getenv("FLARESOLVERR_URL") + if fsURL == "" { + return nil, fmt.Errorf("FLARESOLVERR_URL not set") + } + + payload, _ := json.Marshal(map[string]interface{}{ + "cmd": "request.get", + "url": targetURL, + "maxTimeout": 60000, + }) + + resp, err := http.Post(fsURL+"/v1", "application/json", bytes.NewReader(payload)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Status string `json:"status"` + Solution struct { + Response string `json:"response"` + Cookies []struct { + Name string `json:"name"` + Value string `json:"value"` + Domain string `json:"domain"` + Path string `json:"path"` + Secure bool `json:"secure"` + } `json:"cookies"` + } `json:"solution"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + if result.Status != "ok" { + return nil, fmt.Errorf("flaresolverr: %s", result.Status) + } + + parsed, _ := url.Parse(targetURL) + var cookies []*http.Cookie + for _, c := range result.Solution.Cookies { + cookies = append(cookies, &http.Cookie{ + Name: c.Name, + Value: c.Value, + Domain: c.Domain, + Path: c.Path, + Secure: c.Secure, + }) + } + jar.SetCookies(parsed, cookies) + + return goquery.NewDocumentFromReader(strings.NewReader(result.Solution.Response)) +} + +func BatcaveBizMarkup(referer string, c chan *goquery.Document, clientChan chan *http.Client) *goquery.Document { + sendErr := func() *goquery.Document { + if c != nil { + c <- &goquery.Document{} + } + if clientChan != nil { + clientChan <- nil + } + return &goquery.Document{} + } + jar, _ := cookiejar.New(nil) client := &http.Client{ - Jar: jar, + Jar: jar, + Timeout: time.Second * 30, CheckRedirect: func(req *http.Request, via []*http.Request) error { return nil }, } headers := map[string]string{ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "en-US,en;q=0.9", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", } - // GET the challange page to obtain cookies and any necessary tokens + // GET the challenge page to obtain cookies and any necessary tokens req, err := http.NewRequest("GET", referer, nil) if err != nil { - if c != nil { - c <- &goquery.Document{} - } - return &goquery.Document{} + return sendErr() } for k, v := range headers { req.Header.Set(k, v) @@ -79,19 +150,35 @@ func BatcaveBizMarkup(referer string, c chan *goquery.Document) *goquery.Documen res, err := client.Do(req) if err != nil { - if c != nil { - c <- &goquery.Document{} + log.Printf("[batcave] initial GET failed: %v", err) + return sendErr() + } + log.Printf("[batcave] initial GET status: %d", res.StatusCode) + + // Cloudflare challenge — use FlareSolverr (headless Chrome) to fetch the + // full page and solve any JS challenges. cf_clearance is stored in jar for + // subsequent image downloads. + if res.StatusCode == 403 || res.StatusCode == 503 { + res.Body.Close() + log.Printf("[batcave] Cloudflare challenge detected, fetching via FlareSolverr") + doc, err := fetchViaFlareSolverr(referer, jar) + if err != nil { + log.Printf("[batcave] FlareSolverr failed: %v", err) + return sendErr() } - return &goquery.Document{} + if c != nil { + c <- doc + } + if clientChan != nil { + clientChan <- client + } + return doc } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { - if c != nil { - c <- &goquery.Document{} - } - return &goquery.Document{} + return sendErr() } tokenRegex := regexp.MustCompile(`token:\s*"([^"]+)"`) @@ -101,14 +188,14 @@ func BatcaveBizMarkup(referer string, c chan *goquery.Document) *goquery.Documen // no challenge, parse directly doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body))) if err != nil { - if c != nil { - c <- &goquery.Document{} - } - return &goquery.Document{} + return sendErr() } if c != nil { c <- doc } + if clientChan != nil { + clientChan <- client + } return doc } @@ -118,7 +205,7 @@ func BatcaveBizMarkup(referer string, c chan *goquery.Document) *goquery.Documen token = encodedToken } - // Step 3: POST to /_v with fake browser metrics + // POST to /_v with fake browser metrics params := url.Values{} params.Set("token", token) params.Set("mode", "modern") @@ -132,10 +219,7 @@ func BatcaveBizMarkup(referer string, c chan *goquery.Document) *goquery.Documen postReq, err := http.NewRequest("POST", "https://batcave.biz/_v", strings.NewReader(params.Encode())) if err != nil { - if c != nil { - c <- &goquery.Document{} - } - return &goquery.Document{} + return sendErr() } for k, v := range headers { postReq.Header.Set(k, v) @@ -145,21 +229,17 @@ func BatcaveBizMarkup(referer string, c chan *goquery.Document) *goquery.Documen postRes, err := client.Do(postReq) if err != nil { - if c != nil { - c <- &goquery.Document{} - } - return &goquery.Document{} + log.Printf("[batcave] POST to /_v failed: %v", err) + return sendErr() } defer postRes.Body.Close() + log.Printf("[batcave] POST to /_v status: %d", postRes.StatusCode) io.ReadAll(postRes.Body) // GET the real page with the set cookie realReq, err := http.NewRequest("GET", referer, nil) if err != nil { - if c != nil { - c <- &goquery.Document{} - } - return &goquery.Document{} + return sendErr() } for k, v := range headers { realReq.Header.Set(k, v) @@ -167,23 +247,22 @@ func BatcaveBizMarkup(referer string, c chan *goquery.Document) *goquery.Documen realRes, err := client.Do(realReq) if err != nil { - if c != nil { - c <- &goquery.Document{} - } - return &goquery.Document{} + log.Printf("[batcave] final GET failed: %v", err) + return sendErr() } + log.Printf("[batcave] final GET status: %d", realRes.StatusCode) defer realRes.Body.Close() doc, err := goquery.NewDocumentFromReader(realRes.Body) if err != nil { - if c != nil { - c <- &goquery.Document{} - } - return &goquery.Document{} + return sendErr() } if c != nil { c <- doc } + if clientChan != nil { + clientChan <- client + } return doc } @@ -228,6 +307,34 @@ func ParseReadAllComicsLinks(markup *goquery.Document, c chan []string) ([]strin return links, ImageParseError{Message: "No images found", Code: 1} } +// ParseBatcaveBizTitle extracts the chapter title from the __DATA__.chapters array +// by matching the chapter id to the last path segment of the provided URL. +func ParseBatcaveBizTitle(markup *goquery.Document, chapterURL string) string { + slug := strings.TrimRight(chapterURL, "/") + if i := strings.LastIndex(slug, "/"); i >= 0 { + slug = slug[i+1:] + } + + var title string + markup.Find("script").Each(func(_ int, s *goquery.Selection) { + if title != "" { + return + } + text := s.Text() + if !strings.Contains(text, "__DATA__") { + return + } + chapterRegex := regexp.MustCompile(`"id"\s*:\s*` + regexp.QuoteMeta(slug) + `[^}]*?"title"\s*:\s*"([^"]+)"`) + m := chapterRegex.FindStringSubmatch(text) + if len(m) >= 2 { + title = strings.ReplaceAll(m[1], `\/`, "/") + title = strings.ReplaceAll(title, "Issue #", "") + title = strings.ReplaceAll(title, "#", "") + } + }) + return title +} + // ParseBatcaveBizImageLinks extracts image URLs from the __DATA__.images JavaScript // variable embedded in a batcave.biz page. func ParseBatcaveBizImageLinks(markup *goquery.Document, c chan []string) ([]string, error) { @@ -248,7 +355,7 @@ func ParseBatcaveBizImageLinks(markup *goquery.Document, c chan []string) ([]str urlRegex := regexp.MustCompile(`"([^"]+)"`) for _, m := range urlRegex.FindAllStringSubmatch(arrayMatch[1], -1) { if len(m) >= 2 { - links = append(links, m[1]) + links = append(links, strings.ReplaceAll(m[1], `\/`, "/")) } } }) diff --git a/comic/parser_test.go b/comic/parser_test.go index 9502639..2e08c29 100644 --- a/comic/parser_test.go +++ b/comic/parser_test.go @@ -24,6 +24,15 @@ func TestParseBatcaveBizImageLinks(t *testing.T) { expectErr: false, expectURLs: []string{"https://cdn.batcave.biz/img/001.jpg", "https://cdn.batcave.biz/img/002.jpg"}, }, + { + name: "unescapes forward slashes in URLs", + html: `<html><body><script> + var __DATA__ = {"images":["https:\/\/cdn.batcave.biz\/img\/001.jpg"]}; + </script></body></html>`, + expectCount: 1, + expectErr: false, + expectURLs: []string{"https://cdn.batcave.biz/img/001.jpg"}, + }, { name: "extracts images with spaces around colon and bracket", html: `<html><body><script> diff --git a/docker-compose.yml b/docker-compose.yml index 0c9cf0b..0e353b1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,8 @@ services: + flaresolverr: + image: ghcr.io/flaresolverr/flaresolverr:latest + restart: unless-stopped + yoink: build: . ports: @@ -7,4 +11,7 @@ services: - ./library:/library environment: - YOINK_LIBRARY=/library + - FLARESOLVERR_URL=http://flaresolverr:8191 restart: unless-stopped + depends_on: + - flaresolverr diff --git a/go.mod b/go.mod index a078973..0d3d77b 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.22.3 require ( github.com/DaRealFreak/cloudflare-bp-go v1.0.4 github.com/PuerkitoBio/goquery v1.9.2 - github.com/andybalholm/brotli v1.2.0 github.com/spf13/cobra v1.8.1 ) diff --git a/go.sum b/go.sum index 7c1d02f..9bd373d 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/EDDYCJY/fake-useragent v0.2.0 h1:Jcnkk2bgXmDpX0z+ELlUErTkoLb/mxFBNd2Y github.com/EDDYCJY/fake-useragent v0.2.0/go.mod h1:5wn3zzlDxhKW6NYknushqinPcAqZcAPHy8lLczCdJdc= github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -22,8 +20,6 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= diff --git a/web/server.go b/web/server.go index 7ff7268..fae410d 100644 --- a/web/server.go +++ b/web/server.go @@ -76,6 +76,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/download", s.handleDownload) mux.HandleFunc("/api/upload", s.handleUpload) mux.HandleFunc("/api/comics", s.handleComics) + mux.HandleFunc("/api/comics/delete", s.handleDeleteComic) mux.HandleFunc("/api/jobs", s.handleJobs) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -117,6 +118,7 @@ func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid request", http.StatusBadRequest) return } + req.URL = strings.TrimSpace(req.URL) job := &Job{ ID: fmt.Sprintf("%d", time.Now().UnixNano()), @@ -149,16 +151,18 @@ func (s *Server) runJob(job *Job) { job.Title = c.Title s.mu.Unlock() - errs := c.Download(len(c.Filelist)) - if len(errs) > 0 { + if len(c.Filelist) == 0 { s.mu.Lock() job.Status = StatusError - job.Error = errs[0].Error() + job.Error = "no images found" s.mu.Unlock() return } + errs := c.Download(len(c.Filelist)) + if err := c.Archive(); err != nil { + c.Cleanup() s.mu.Lock() job.Status = StatusError job.Error = err.Error() @@ -168,6 +172,14 @@ func (s *Server) runJob(job *Job) { c.Cleanup() + if len(errs) > 0 { + s.mu.Lock() + job.Status = StatusError + job.Error = errs[0].Error() + s.mu.Unlock() + return + } + s.mu.Lock() job.Status = StatusComplete s.mu.Unlock() @@ -351,6 +363,38 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"title": title, "status": "complete"}) } +func (s *Server) handleDeleteComic(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Title string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Title) == "" { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + // Sanitize: prevent path traversal + title := filepath.Base(strings.TrimSpace(req.Title)) + comicDir := filepath.Join(s.libraryPath, title) + + // Ensure the resolved path is still under the library + if !strings.HasPrefix(comicDir, filepath.Clean(s.libraryPath)+string(filepath.Separator)) { + http.Error(w, "invalid title", http.StatusBadRequest) + return + } + + if err := os.RemoveAll(comicDir); err != nil { + http.Error(w, "failed to delete comic", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + func Listen(addr string, libraryPath string) error { srv := NewServer(libraryPath) fmt.Printf("Yoink web server listening on %s\n", addr) diff --git a/web/static/index.html b/web/static/index.html index 8244aeb..e168c07 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -419,6 +419,33 @@ .comic-card:hover .comic-download-overlay { opacity: 1; } + .comic-delete-btn { + position: absolute; + top: 6px; + right: 6px; + width: 20px; + height: 20px; + min-width: 20px; + min-height: 20px; + box-sizing: content-box; + border-radius: 50%; + background: rgba(10,12,20,0.75); + border: 1px solid rgba(255,255,255,0.15); + color: var(--text2); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, background 0.15s, color 0.15s; + z-index: 4; + padding: 0; + line-height: 0; + } + + .comic-card:hover .comic-delete-btn { opacity: 1; } + .comic-delete-btn:hover { background: var(--error); color: #fff; border-color: transparent; } + .comic-cover-placeholder { width: 100%; height: 100%; @@ -998,6 +1025,21 @@ <div id="toast"></div> + <!-- Delete confirmation modal --> + <div class="modal-backdrop" id="delete-modal"> + <div class="modal" role="dialog" aria-modal="true" aria-labelledby="delete-modal-title"> + <div class="modal-header"> + <span class="modal-title" id="delete-modal-title">Delete comic?</span> + <button class="modal-close" id="delete-close-btn" aria-label="Close">×</button> + </div> + <p style="color:var(--text2);margin:0 0 20px;">This will permanently remove <strong id="delete-comic-name" style="color:var(--text)"></strong> and all its files from the library.</p> + <div style="display:flex;gap:10px;justify-content:flex-end;"> + <button class="pick-btn" id="delete-cancel-btn" type="button">Cancel</button> + <button class="pick-btn" id="delete-confirm-btn" style="background:var(--error);border-color:var(--error);color:#fff;">Delete</button> + </div> + </div> + </div> + <!-- Hidden file inputs for upload modal --> <input type="file" id="file-input-folder" style="display:none" multiple webkitdirectory /> <input type="file" id="file-input-files" style="display:none" multiple accept="image/*" /> @@ -1243,6 +1285,18 @@ info.append(title); a.append(info); + + const delBtn = document.createElement('button'); + delBtn.className = 'comic-delete-btn'; + delBtn.title = 'Delete comic'; + delBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg"><line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'; + delBtn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + openDeleteModal(comic.title); + }); + a.append(delBtn); + grid.append(a); }); } @@ -1357,6 +1411,46 @@ setInterval(pollJobs, 2000); setInterval(fetchComics, 10000); + // ── Delete modal ─────────────────────────────────────────────────────── + const deleteModal = document.getElementById('delete-modal'); + const deleteCloseBtn = document.getElementById('delete-close-btn'); + const deleteCancelBtn = document.getElementById('delete-cancel-btn'); + const deleteConfirmBtn = document.getElementById('delete-confirm-btn'); + const deleteComicName = document.getElementById('delete-comic-name'); + + let pendingDeleteTitle = null; + + function openDeleteModal(title) { + pendingDeleteTitle = title; + deleteComicName.textContent = title; + deleteModal.classList.add('open'); + } + + function closeDeleteModal() { + deleteModal.classList.remove('open'); + pendingDeleteTitle = null; + } + + deleteCloseBtn.addEventListener('click', closeDeleteModal); + deleteCancelBtn.addEventListener('click', closeDeleteModal); + deleteModal.addEventListener('click', e => { if (e.target === deleteModal) closeDeleteModal(); }); + + deleteConfirmBtn.addEventListener('click', async () => { + if (!pendingDeleteTitle) return; + const title = pendingDeleteTitle; + closeDeleteModal(); + try { + const res = await fetch('/api/comics/delete', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }); + if (res.ok || res.status === 204) { + await fetchComics(); + } + } catch (_) {} + }); + // ── Upload modal ─────────────────────────────────────────────────────── const uploadModal = document.getElementById('upload-modal'); const uploadOpenBtn = document.getElementById('upload-open-btn');