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
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user