diff --git a/backend/cmd/webui/handlers/libraries.go b/backend/cmd/webui/handlers/libraries.go index 2543444..6c2e6bd 100644 --- a/backend/cmd/webui/handlers/libraries.go +++ b/backend/cmd/webui/handlers/libraries.go @@ -135,23 +135,40 @@ func (h *H) Upload(c *gin.Context) { err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md") return } - dst, e := h.uniquePath(root, name) - if e != nil { - err(c, http.StatusForbidden, "forbidden", e.Error()) + // B12: retry on O_EXCL collision — concurrent uploads with the same name + // can both get the same candidate from uniquePath (stat-then-create race). + var dst, tmp string + var out *os.File + for attempt := 0; attempt < 5; attempt++ { + var e error + dst, e = h.uniquePath(root, name) + if e != nil { + err(c, http.StatusForbidden, "forbidden", e.Error()) + return + } + tmp = dst + ".upload-" + strconv.FormatInt(time.Now().UnixNano(), 36) + out, e = os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if e == nil { + break + } + if !os.IsExist(e) { + err(c, http.StatusInternalServerError, "internal", "create tmp") + return + } + // O_EXCL collision — retry with fresh uniquePath. + } + if out == nil { + err(c, http.StatusConflict, "conflict", "too many concurrent uploads with same name") return } src, e := fh.Open() if e != nil { + out.Close() + os.Remove(tmp) err(c, http.StatusInternalServerError, "internal", "open upload") return } defer src.Close() - tmp := dst + ".upload-" + strconv.FormatInt(time.Now().UnixNano(), 36) - out, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) - if e != nil { - err(c, http.StatusInternalServerError, "internal", "create tmp") - return - } // B6: only MaxBytesError returns 413; other io.Copy failures (disk full, // connection drop) return 500. if _, e := io.Copy(out, src); e != nil {