package httpapi import ( "database/sql" "encoding/json" "errors" "fmt" "log/slog" "net/http" "strconv" "time" "commilitia.net/cdrop/internal/clipboard" "commilitia.net/cdrop/internal/jwtauth" ) type clipboardPutReq struct { Content string `json:"content"` ContentType string `json:"content_type"` // OriginTs is the source device's copy time (ms). It's the LWW key: the write // is accepted only if it strictly exceeds the stored one. Clients that omit it // (legacy / transitional) get server-now, so they still sync. OriginTs int64 `json:"origin_ts"` } // handleClipboardPut 接受一次剪贴板写入。请求体最大字节限制(MaxBytesReader) // 给上限 + JSON 信封冗余 (×1.5);服务层会再核一次纯内容字节数。 func (s *Server) handleClipboardPut(w http.ResponseWriter, r *http.Request) { if s.clipboard == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "clipboard service unavailable"}) return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) device, _ := jwtauth.DeviceNameFromContext(r.Context()) maxBody := int64(s.clipboard.MaxBytes() + s.clipboard.MaxBytes()/2 + 256) r.Body = http.MaxBytesReader(w, r.Body, maxBody) var req clipboardPutReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // MaxBytesReader 触发时返回 *http.MaxBytesError;统一映射 413。 var mbe *http.MaxBytesError if errors.As(err, &mbe) { writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "request body too large"}) return } writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } originTs := req.OriginTs if originTs <= 0 { originTs = time.Now().UnixMilli() // client didn't tag a copy time → use now } st, accepted, err := s.clipboard.Put(r.Context(), claims.UserID, req.ContentType, req.Content, device, originTs) switch { case errors.Is(err, clipboard.ErrUnsupportedType): writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": err.Error()}) return case errors.Is(err, clipboard.ErrTooLarge): writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": err.Error()}) return case err != nil: slog.Error("clipboard put failed", "err", err, "user", claims.UserID) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } w.Header().Set("ETag", etagFor(st.Version)) if !accepted { // LWW rejected this write — a newer copy already won. Hand back the current // winner so the client reconciles (pulls) instead of clobbering it. writeJSON(w, http.StatusConflict, st) return } writeJSON(w, http.StatusAccepted, st) } type clipboardMeta struct { Version int64 `json:"version"` SourceDevice string `json:"source_device"` ContentType string `json:"content_type"` UpdatedAt int64 `json:"updated_at"` OriginTs int64 `json:"origin_ts"` } // handleClipboardVersion 返回剪贴板版本元信息(不含 content)。给读不了 HTTP 状态 // 码的轮询客户端(iOS 快捷指令)做廉价变更探测:版本变了、且来源不是自己,才去 // GET 全量内容——避免每轮都全量拉取。 func (s *Server) handleClipboardVersion(w http.ResponseWriter, r *http.Request) { if s.clipboard == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "clipboard service unavailable"}) return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) st, err := s.clipboard.Get(r.Context(), claims.UserID) if errors.Is(err, sql.ErrNoRows) { st = &clipboard.State{ContentType: clipboard.ContentTypeText} } else if err != nil { slog.Error("clipboard version get failed", "err", err, "user", claims.UserID) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } etag := etagFor(st.Version) w.Header().Set("ETag", etag) if match := r.Header.Get("If-None-Match"); match != "" && match == etag { w.WriteHeader(http.StatusNotModified) return } writeJSON(w, http.StatusOK, clipboardMeta{ Version: st.Version, SourceDevice: st.SourceDevice, ContentType: st.ContentType, UpdatedAt: st.UpdatedAt, OriginTs: st.OriginTs, }) } // handleClipboardGet 返回当前剪贴板内容;支持 If-None-Match: // → 304 节省流量。用户尚未写入时返回 200 + 空 State + ETag "0",让"槽位常驻" // 语义对前端更友好(避免 DevTools Network 红色 404)。 func (s *Server) handleClipboardGet(w http.ResponseWriter, r *http.Request) { if s.clipboard == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "clipboard service unavailable"}) return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) st, err := s.clipboard.Get(r.Context(), claims.UserID) if errors.Is(err, sql.ErrNoRows) { st = &clipboard.State{ContentType: clipboard.ContentTypeText} } else if err != nil { slog.Error("clipboard get failed", "err", err, "user", claims.UserID) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } etag := etagFor(st.Version) w.Header().Set("ETag", etag) if match := r.Header.Get("If-None-Match"); match != "" && match == etag { w.WriteHeader(http.StatusNotModified) return } writeJSON(w, http.StatusOK, st) } // etagFor 用版本号的十进制字符串当 ETag——版本是严格单调的,故 ETag 精确反映 // 「内容变了没有」(秒级 updated_at 会在同秒两写时撞号)。RFC 9110 的强 ETag。 func etagFor(version int64) string { return fmt.Sprintf("\"%s\"", strconv.FormatInt(version, 10)) }