286 lines
7.5 KiB
Go
286 lines
7.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"booklib/internal/bookfile"
|
|
)
|
|
|
|
// 分片上传:init(指纹→确定性 uploadId,天然支持续传)→ PUT parts → complete 拼接原子落盘。
|
|
// 会话即 <BooksDir>/.uploads/<uid>/(meta.json + parts/N),无独立状态存储;24h 未 complete opportunistic 清扫。
|
|
|
|
const (
|
|
maxChunkBytes = 32 << 20
|
|
defaultChunk = 8 << 20
|
|
uploadSessTTL = 24 * time.Hour
|
|
uploadSessionIn = ".uploads"
|
|
)
|
|
|
|
type uploadMeta struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
ChunkSize int64 `json:"chunkSize"`
|
|
LibraryID int64 `json:"libraryId"`
|
|
}
|
|
|
|
func validUploadID(s string) bool {
|
|
if len(s) != 32 {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func uploadIDFor(libID int64, name string, size, chunk int64) string {
|
|
h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s|%d|%d", libID, name, size, chunk)))
|
|
return hex.EncodeToString(h[:16])
|
|
}
|
|
|
|
func (h *H) uploadDir(uid string) string {
|
|
return filepath.Join(filepath.Clean(h.cfg.BooksDir), uploadSessionIn, uid)
|
|
}
|
|
|
|
func chunkRange(m uploadMeta, i int64) (int64, int64) {
|
|
lo := i * m.ChunkSize
|
|
hi := min(lo+m.ChunkSize, m.Size)
|
|
return lo, hi
|
|
}
|
|
|
|
func (h *H) numParts(m uploadMeta) int64 {
|
|
return (m.Size + m.ChunkSize - 1) / m.ChunkSize
|
|
}
|
|
|
|
// sweepUploads 删除过期会话目录;尽力而为,失败不影响主流程
|
|
func (h *H) sweepUploads() {
|
|
base := filepath.Join(filepath.Clean(h.cfg.BooksDir), uploadSessionIn)
|
|
es, e := os.ReadDir(base)
|
|
if e != nil {
|
|
return
|
|
}
|
|
for _, en := range es {
|
|
if fi, e := en.Info(); e == nil && time.Since(fi.ModTime()) > uploadSessTTL {
|
|
os.RemoveAll(filepath.Join(base, en.Name()))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *H) UploadInit(c *gin.Context) {
|
|
lib, ok := h.getLibrary(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := h.libRoot(c, lib); !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
ChunkSize int64 `json:"chunkSize"`
|
|
}
|
|
if c.ShouldBindJSON(&req) != nil {
|
|
err(c, http.StatusBadRequest, "bad_request", "name, size required")
|
|
return
|
|
}
|
|
name := bookfile.SafeName(req.Name)
|
|
if name == "" {
|
|
err(c, http.StatusBadRequest, "bad_request", "bad name")
|
|
return
|
|
}
|
|
if bookfile.FormatFromExt(name) == "" {
|
|
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
|
|
return
|
|
}
|
|
if req.Size <= 0 {
|
|
err(c, http.StatusBadRequest, "bad_request", "bad size")
|
|
return
|
|
}
|
|
if req.Size > h.cfg.UploadMaxMB<<20 {
|
|
err(c, http.StatusRequestEntityTooLarge, "too_large", "file exceeds upload limit of "+strconv.FormatInt(h.cfg.UploadMaxMB, 10)+"MB")
|
|
return
|
|
}
|
|
if req.ChunkSize == 0 {
|
|
req.ChunkSize = defaultChunk
|
|
}
|
|
if req.ChunkSize > maxChunkBytes {
|
|
err(c, http.StatusBadRequest, "bad_request", "chunkSize must be <= 33554432")
|
|
return
|
|
}
|
|
uid := uploadIDFor(lib.ID, name, req.Size, req.ChunkSize)
|
|
dir := h.uploadDir(uid)
|
|
meta := uploadMeta{Name: name, Size: req.Size, ChunkSize: req.ChunkSize, LibraryID: lib.ID}
|
|
if b, e := os.ReadFile(filepath.Join(dir, "meta.json")); e == nil {
|
|
var old uploadMeta
|
|
if json.Unmarshal(b, &old) == nil && old == meta { // 同指纹 → 复用会话(续传)
|
|
c.JSON(http.StatusOK, gin.H{"uploadId": uid})
|
|
return
|
|
}
|
|
os.RemoveAll(dir) // 指纹撞上但内容不同 → 从头再来
|
|
}
|
|
h.sweepUploads()
|
|
if e := os.MkdirAll(filepath.Join(dir, "parts"), 0o755); e != nil {
|
|
err(c, http.StatusInternalServerError, "internal", "create session")
|
|
return
|
|
}
|
|
b, _ := json.Marshal(meta)
|
|
if e := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); e != nil {
|
|
err(c, http.StatusInternalServerError, "internal", "write meta")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"uploadId": uid})
|
|
}
|
|
|
|
// loadMeta 校验 uid 与路径,404/400 已回复
|
|
func (h *H) loadMeta(c *gin.Context) (uploadMeta, string, bool) {
|
|
uid := c.Param("uid")
|
|
if !validUploadID(uid) {
|
|
err(c, http.StatusBadRequest, "bad_request", "bad upload id")
|
|
return uploadMeta{}, "", false
|
|
}
|
|
dir := h.uploadDir(uid)
|
|
var m uploadMeta
|
|
b, e := os.ReadFile(filepath.Join(dir, "meta.json"))
|
|
if e != nil {
|
|
err(c, http.StatusNotFound, "not_found", "no such upload")
|
|
return uploadMeta{}, "", false
|
|
}
|
|
if json.Unmarshal(b, &m) != nil {
|
|
err(c, http.StatusInternalServerError, "internal", "corrupt session")
|
|
return uploadMeta{}, "", false
|
|
}
|
|
return m, dir, true
|
|
}
|
|
|
|
func (h *H) UploadStatus(c *gin.Context) {
|
|
_, dir, ok := h.loadMeta(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
recv := []int64{}
|
|
es, e := os.ReadDir(filepath.Join(dir, "parts"))
|
|
if e == nil {
|
|
for _, en := range es {
|
|
if i, e := strconv.ParseInt(en.Name(), 10, 64); e == nil {
|
|
recv = append(recv, i)
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(recv, func(i, j int) bool { return recv[i] < recv[j] })
|
|
c.JSON(http.StatusOK, gin.H{"received": recv})
|
|
}
|
|
|
|
func (h *H) UploadPart(c *gin.Context) {
|
|
m, dir, ok := h.loadMeta(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
idx, e := strconv.ParseInt(c.Param("index"), 10, 64)
|
|
if e != nil || idx < 0 || idx >= h.numParts(m) {
|
|
err(c, http.StatusBadRequest, "bad_request", "bad part index")
|
|
return
|
|
}
|
|
lo, hi := chunkRange(m, idx)
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, hi-lo)
|
|
p := filepath.Join(dir, "parts", strconv.FormatInt(idx, 10))
|
|
f, e := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
|
if e != nil {
|
|
err(c, http.StatusInternalServerError, "internal", "create part")
|
|
return
|
|
}
|
|
n, e := io.Copy(f, c.Request.Body)
|
|
f.Close()
|
|
if e != nil || n != hi-lo {
|
|
os.Remove(p)
|
|
var mbe *http.MaxBytesError
|
|
code, msg := "too_large", "part size mismatch"
|
|
if errors.As(e, &mbe) {
|
|
msg = "part exceeds declared size"
|
|
}
|
|
err(c, http.StatusRequestEntityTooLarge, code, msg)
|
|
return
|
|
}
|
|
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
|
|
}
|
|
|
|
func (h *H) UploadComplete(c *gin.Context) {
|
|
m, dir, ok := h.loadMeta(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var total int64
|
|
for i := int64(0); i < h.numParts(m); i++ {
|
|
lo, hi := chunkRange(m, i)
|
|
fi, e := os.Stat(filepath.Join(dir, "parts", strconv.FormatInt(i, 10)))
|
|
if e != nil || fi.Size() != hi-lo {
|
|
err(c, http.StatusBadRequest, "bad_request", "upload incomplete; missing or corrupt parts, re-upload them")
|
|
return
|
|
}
|
|
total += fi.Size()
|
|
}
|
|
if total != m.Size {
|
|
err(c, http.StatusBadRequest, "bad_request", "total size mismatch")
|
|
return
|
|
}
|
|
lib, e := h.st.GetLibrary(c, m.LibraryID)
|
|
if e != nil {
|
|
dbErr(c, e)
|
|
return
|
|
}
|
|
root, ok := h.libRoot(c, lib)
|
|
if !ok {
|
|
return
|
|
}
|
|
dst, e := h.uniquePath(root, m.Name)
|
|
if e != nil {
|
|
err(c, http.StatusForbidden, "forbidden", e.Error())
|
|
return
|
|
}
|
|
tmp := filepath.Join(dir, "assembled")
|
|
out, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
|
if e != nil {
|
|
err(c, http.StatusInternalServerError, "internal", "create tmp")
|
|
return
|
|
}
|
|
for i := int64(0); i < h.numParts(m); i++ {
|
|
pf, e := os.Open(filepath.Join(dir, "parts", strconv.FormatInt(i, 10)))
|
|
if e != nil {
|
|
out.Close()
|
|
err(c, http.StatusInternalServerError, "internal", "open part")
|
|
return
|
|
}
|
|
if _, e := io.Copy(out, pf); e != nil {
|
|
pf.Close()
|
|
out.Close()
|
|
os.Remove(tmp)
|
|
err(c, http.StatusInternalServerError, "internal", "assemble")
|
|
return
|
|
}
|
|
pf.Close()
|
|
}
|
|
out.Close()
|
|
if e := os.Rename(tmp, dst); e != nil { // 原子落盘,scanner 自动收编
|
|
os.Remove(tmp)
|
|
err(c, http.StatusInternalServerError, "internal", "rename")
|
|
return
|
|
}
|
|
os.RemoveAll(dir)
|
|
c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": strings.TrimPrefix(dst, root+string(os.PathSeparator))})
|
|
}
|