From 89a5013fb2d2e9e9ded2a5b85850064f5f8a2de8 Mon Sep 17 00:00:00 2001 From: Bryan Bailey Date: Thu, 12 Mar 2026 09:41:03 -0400 Subject: [PATCH] fix(web): add comic delete UI and fix container Cloudflare bypass for #6 - Add delete button (SVG X, hover-reveal) and confirmation modal to comic cards - Add DELETE /api/comics/delete endpoint with path traversal protection - Fix container downloads: delegate Cloudflare-blocked requests to FlareSolverr (headless Chrome sidecar) instead of retrying with Go HTTP client, whose Linux TCP fingerprint is flagged by Cloudflare even with network_mode: host - Add FlareSolverr service to docker-compose; inject FLARESOLVERR_URL env var - Add diagnostic logging to BatcaveBizMarkup request flow - Trim URL whitespace before storing in download job - Guard Archive() against empty filelist; fix runJob error-check ordering --- comic/archive.go | 3 ++ comic/archive_test.go | 12 +++--- comic/parser.go | 94 +++++++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 7 ++++ go.mod | 1 - go.sum | 4 -- web/server.go | 50 +++++++++++++++++++++-- web/static/index.html | 94 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 248 insertions(+), 17 deletions(-) diff --git a/comic/archive.go b/comic/archive.go index 0ce5f48..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) 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/parser.go b/comic/parser.go index d64a405..b20e264 100644 --- a/comic/parser.go +++ b/comic/parser.go @@ -1,10 +1,15 @@ package comic import ( + "bytes" + "encoding/json" + "fmt" "io" + "log" "net/http" "net/http/cookiejar" "net/url" + "os" "regexp" "strings" "time" @@ -51,6 +56,63 @@ func Markup(url string, c chan *goquery.Document) *goquery.Document { return markup } +// 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 { @@ -72,12 +134,12 @@ func BatcaveBizMarkup(referer string, c chan *goquery.Document, clientChan chan } 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 { return sendErr() @@ -88,8 +150,30 @@ func BatcaveBizMarkup(referer string, c chan *goquery.Document, clientChan chan res, err := client.Do(req) if err != nil { + 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() + } + if c != nil { + c <- doc + } + if clientChan != nil { + clientChan <- client + } + return doc + } defer res.Body.Close() body, err := io.ReadAll(res.Body) @@ -121,7 +205,7 @@ func BatcaveBizMarkup(referer string, c chan *goquery.Document, clientChan chan 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") @@ -145,9 +229,11 @@ func BatcaveBizMarkup(referer string, c chan *goquery.Document, clientChan chan postRes, err := client.Do(postReq) if err != nil { + 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 @@ -161,8 +247,10 @@ func BatcaveBizMarkup(referer string, c chan *goquery.Document, clientChan chan realRes, err := client.Do(realReq) if err != nil { + 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) 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 @@
+ + + @@ -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 = ''; + 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');