Async-trigger MinerU after CAS store and surface parse status in assets API plus a lightweight preview badge. Co-authored-by: Cursor <cursoragent@cursor.com>
210 lines
5.7 KiB
Go
210 lines
5.7 KiB
Go
package server
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"chat-agent-system/internal/bizdev"
|
|
"chat-agent-system/internal/upload"
|
|
)
|
|
|
|
type projectAssetResponse struct {
|
|
AssetID int64 `json:"asset_id"`
|
|
Filename string `json:"filename"`
|
|
SHA256 string `json:"sha256"`
|
|
MimeType string `json:"mime_type"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
ProjectID int64 `json:"project_id"`
|
|
ParseState string `json:"parse_state,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request) {
|
|
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || projectID <= 0 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
|
|
return
|
|
}
|
|
user, ok := s.requireProjectAccess(w, r, projectID)
|
|
if !ok {
|
|
return
|
|
}
|
|
userID := user.ID
|
|
if err := r.ParseMultipartForm(defaultUploadBodyLimit); err != nil {
|
|
if isRequestBodyTooLarge(err) {
|
|
writeSafeError(w, r, http.StatusRequestEntityTooLarge, "upload too large")
|
|
return
|
|
}
|
|
writeSafeError(w, r, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
|
return
|
|
}
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeSafeError(w, r, http.StatusBadRequest, "file required")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
writeSafeError(w, r, http.StatusBadRequest, "read file failed")
|
|
return
|
|
}
|
|
if len(data) == 0 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "cannot store empty file")
|
|
return
|
|
}
|
|
|
|
filename := header.Filename
|
|
if filename == "" {
|
|
filename = "upload.bin"
|
|
}
|
|
declaredMIME := header.Header.Get("Content-Type")
|
|
mimeType, verr := upload.ValidateProjectMaterial(filename, declaredMIME, data)
|
|
if verr != nil {
|
|
writeSafeError(w, r, http.StatusUnsupportedMediaType, "upload rejected: "+verr.Error())
|
|
return
|
|
}
|
|
|
|
cas := s.cas
|
|
if cas == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "storage unavailable")
|
|
return
|
|
}
|
|
ref, err := cas.StoreFile(r.Context(), projectID, userID, filepath.Base(filename), "material", data, mimeType)
|
|
if err != nil {
|
|
writeSafeError(w, r, http.StatusInternalServerError, "store asset failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
parseState := ""
|
|
if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil {
|
|
matIdx.EnqueueAsync(ref.FileName, ref.BlobSHA256, data, projectID)
|
|
if matIdx.Enabled() {
|
|
parseState = "pending"
|
|
}
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, projectAssetResponse{
|
|
AssetID: ref.ID,
|
|
Filename: ref.FileName,
|
|
SHA256: ref.BlobSHA256,
|
|
MimeType: mimeType,
|
|
SizeBytes: int64(len(data)),
|
|
ProjectID: projectID,
|
|
ParseState: parseState,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request) {
|
|
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || projectID <= 0 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
|
|
return
|
|
}
|
|
if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
|
|
return
|
|
}
|
|
cas := s.cas
|
|
if cas == nil {
|
|
writeJSONResponse(w, http.StatusOK, []projectAssetResponse{})
|
|
return
|
|
}
|
|
refs, err := cas.ListProjectRefs(r.Context(), projectID)
|
|
if err != nil {
|
|
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
shas := make([]string, 0, len(refs))
|
|
for _, ref := range refs {
|
|
if ref != nil && ref.BlobSHA256 != "" {
|
|
shas = append(shas, ref.BlobSHA256)
|
|
}
|
|
}
|
|
states := map[string]string{}
|
|
if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil {
|
|
states = matIdx.GetParseStates(r.Context(), shas)
|
|
}
|
|
|
|
out := make([]projectAssetResponse, 0, len(refs))
|
|
for _, ref := range refs {
|
|
out = append(out, projectAssetResponse{
|
|
AssetID: ref.ID,
|
|
Filename: ref.FileName,
|
|
SHA256: ref.BlobSHA256,
|
|
ProjectID: ref.ProjectID,
|
|
ParseState: states[ref.BlobSHA256],
|
|
})
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) handleGetProjectAssetRaw(w http.ResponseWriter, r *http.Request) {
|
|
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || projectID <= 0 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
|
|
return
|
|
}
|
|
assetID, err := strconv.ParseInt(r.PathValue("asset_id"), 10, 64)
|
|
if err != nil || assetID <= 0 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "invalid asset id")
|
|
return
|
|
}
|
|
if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
|
|
return
|
|
}
|
|
cas := s.cas
|
|
if cas == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "storage unavailable")
|
|
return
|
|
}
|
|
rc, ref, err := cas.OpenFileByRef(r.Context(), assetID)
|
|
if err != nil || ref == nil {
|
|
writeSafeError(w, r, http.StatusNotFound, "asset not found")
|
|
return
|
|
}
|
|
defer rc.Close()
|
|
if ref.ProjectID != projectID {
|
|
writeSafeError(w, r, http.StatusNotFound, "asset not found")
|
|
return
|
|
}
|
|
filename := ref.FileName
|
|
if filename == "" {
|
|
filename = "download.bin"
|
|
}
|
|
mimeType := mimeFromExt(filename)
|
|
w.Header().Set("Content-Type", mimeType)
|
|
w.Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(filename)+"\"")
|
|
w.Header().Set("Cache-Control", "private, max-age=300")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.Copy(w, rc)
|
|
}
|
|
|
|
func parseUserIDHeader(r *http.Request) int64 {
|
|
raw := strings.TrimSpace(r.Header.Get("X-User-ID"))
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
id, _ := strconv.ParseInt(raw, 10, 64)
|
|
return id
|
|
}
|
|
|
|
func mimeFromExt(name string) string {
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
switch ext {
|
|
case ".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".log":
|
|
return "text/plain"
|
|
case ".pdf":
|
|
return "application/pdf"
|
|
case ".docx":
|
|
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
case ".xlsx":
|
|
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|