package handler import ( "errors" "fmt" "net/http" "strconv" "github.com/agentmail/gateway/internal/blob" "github.com/agentmail/gateway/internal/config" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/models" "github.com/agentmail/gateway/internal/repo" ) /* 用户外观(主题 + 壁纸)—— /api/v1/me/appearance # 为什么要有这套端点 2026-09-13 用户的质问:「为什么背景是保存在本地而不是服务器!」 当时的实情:主题与壁纸只写浏览器 localStorage,于是换设备/换浏览器就没了, 更糟的是**多账号共用一份**(存储键是全局常量)—— 同一台机器换账号,背景不跟着走。 而 localStorage 只有 ~5MB 配额,客户端不得不把手机照片压到 2.4MB 以内(那套限制 本身就是"为本地存储而设计"的痕迹)。 放服务端之后:账号级、跟设备无关、多账号各自一份;客户端保留本地缓存用于秒开与离线。 # 图片为什么不塞进 JSON 图片走**内容寻址的 blob 存储**(与附件同一套),库里只存 sha256 —— 这样 `repo.SweepUnreferencedBlobs` 能一眼看出这张图还有没有人用(它读的就是这张表)。 塞进 JSON 的话 GC 就得在 SQL 里解析 JSON,而两种方言写法还不一样。 # 鉴权 与其余 /me/* 一样要求登录(cookie 或 Bearer)。图片 GET 也要求 —— 客户端用 带认证的 fetch 取回来再转 object URL,**不接受 `?token=`**(那会进日志与历史记录)。 */ // GET /api/v1/me/appearance func GetAppearance(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } a, exists, err := repo.GetAppearance(r.Context(), user.Username) if err != nil { Error(w, http.StatusInternalServerError, "Failed to load appearance") return } JSON(w, http.StatusOK, appearanceResponse(a, exists)) } func appearanceResponse(a models.Appearance, exists bool) map[string]any { resp := map[string]any{ "theme": a.Theme, "bg_kind": a.BgKind, "bg_preset_id": a.BgPresetID, "bg_dim": a.BgDim, "bg_blur": a.BgBlur, "has_image": a.ImageSHA256 != "", "image_bytes": a.ImageBytes, "saved": exists, } if a.UpdatedAt != "" { resp["updated_at"] = a.UpdatedAt } if a.ImageSHA256 != "" { // 给 URL 而不是把图塞进 JSON:壁纸最大几 MB,塞进去每次读设置都要传一遍。 resp["image_url"] = "/api/v1/me/appearance/image" } return resp } // PUT /api/v1/me/appearance —— 主题与背景档(不含图片) func PutAppearance(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } var req models.Appearance if !DecodeBody(w, r, &req) { return } a := models.NormalizeAppearance(req) if err := repo.UpsertAppearance(r.Context(), user.Username, a); err != nil { Error(w, http.StatusInternalServerError, "Failed to save appearance") return } saved, _, err := repo.GetAppearance(r.Context(), user.Username) if err != nil { Error(w, http.StatusInternalServerError, "Failed to reload appearance") return } JSON(w, http.StatusOK, appearanceResponse(saved, true)) } // POST /api/v1/me/appearance/image —— 上传壁纸(multipart,字段名 file) func UploadAppearanceImage(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } max := appearanceMaxBytes() // 双层限制:外层卡整个请求体(含 multipart 边界),blob.Put 卡文件内容本身。 // 少了外层,超大 multipart 头就能把内存拖满(与附件上传同一套做法)。 r.Body = http.MaxBytesReader(w, r.Body, max+1<<20) if err := r.ParseMultipartForm(8 << 20); err != nil { Error(w, http.StatusBadRequest, "解析 multipart 失败(是否超过大小上限?)") return } defer func() { if r.MultipartForm != nil { r.MultipartForm.RemoveAll() } }() file, header, err := r.FormFile("file") if err != nil { Error(w, http.StatusBadRequest, "缺少 file 字段") return } defer file.Close() name := sanitizeFilename(header.Filename) ctype := detectContentType(header.Header.Get("Content-Type"), name) // 只收图片:这个端点不是通用文件柜,而浏览器会把非图片当壁纸渲染成空白, // 用户只会看到"设置了却什么也没变"。 if !isImageContentType(ctype) { Error(w, http.StatusUnsupportedMediaType, "壁纸必须是图片(image/png、image/jpeg、image/webp、image/gif)") return } // 先落盘再入库(顺序不能反,否则会出现"库里有记录、磁盘没文件"的 404) sum, size, err := Blobs.Put(file, max) if errors.Is(err, blob.ErrTooLarge) { Error(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("壁纸超过上限 %.1f MB(客户端会先压缩,这个上限是兜底)", float64(max)/(1<<20))) return } if err != nil { Error(w, http.StatusInternalServerError, "保存壁纸失败") return } if err := repo.SetAppearanceImage(r.Context(), user.Username, sum, ctype, size); err != nil { Error(w, http.StatusInternalServerError, "登记壁纸失败") return } a, _, err := repo.GetAppearance(r.Context(), user.Username) if err != nil { Error(w, http.StatusInternalServerError, "Failed to reload appearance") return } JSON(w, http.StatusOK, appearanceResponse(a, true)) } // GET /api/v1/me/appearance/image —— 取壁纸本体 func GetAppearanceImage(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } a, _, err := repo.GetAppearance(r.Context(), user.Username) if err != nil { Error(w, http.StatusInternalServerError, "Failed to load appearance") return } if a.ImageSHA256 == "" { Error(w, http.StatusNotFound, "尚未设置壁纸") return } f, err := Blobs.Open(a.ImageSHA256) if err != nil { // 库里说有条目、磁盘上却没有:这不该发生(GC 会把这张表当引用源)。 // 明确报错而不是回空图,否则客户端只会显示"设置了但没效果"。 Error(w, http.StatusNotFound, "壁纸文件缺失") return } defer f.Close() w.Header().Set("Content-Type", a.ImageType) w.Header().Set("Content-Length", strconv.FormatInt(a.ImageBytes, 10)) // 内容寻址 ⇒ 同一 sha256 的内容永不改变,可以长缓存;换图会换 URL 语义 // (客户端拿到的 image_url 不变,所以这里只做短缓存,避免缓存穿透到"旧图")。 w.Header().Set("Cache-Control", "private, max-age=60") _, _ = f.WriteTo(w) } // DELETE /api/v1/me/appearance/image func DeleteAppearanceImage(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } if err := repo.ClearAppearanceImage(r.Context(), user.Username); err != nil { Error(w, http.StatusInternalServerError, "Failed to clear image") return } // blob 文件不在这里删:内容寻址可能被别的记录引用,交给 SweepUnreferencedBlobs。 JSON(w, http.StatusOK, map[string]any{"has_image": false}) } func appearanceMaxBytes() int64 { if config.C != nil && config.C.MaxAppearanceBytes > 0 { return config.C.MaxAppearanceBytes } return 4 << 20 } func isImageContentType(ct string) bool { switch ct { case "image/png", "image/jpeg", "image/webp", "image/gif": return true } return false }