feat(avatar): Avatar support for html rendering

This commit is contained in:
Symb0x76
2025-09-23 10:44:00 +08:00
parent 29e406b2f1
commit ef86e77a92
14 changed files with 457 additions and 31 deletions
+19 -6
View File
@@ -58,20 +58,27 @@ build:
buildcross:
@echo "[cross] Interactive cross-compilation selection (Windows/macOS)"
@mkdir -p bin
@printf "\nSelect target platforms to build (multiple choices, space separated):\n 1) windows/amd64\n 2) windows/arm64\n 3) darwin/amd64 (macOS Intel)\n 4) darwin/arm64 (Apple Silicon)\n> "; \
@printf "\nSelect target platforms to build (enter digits, e.g., 134):\n 1) windows/amd64\n 2) windows/arm64\n 3) darwin/amd64 (macOS Intel)\n 4) darwin/arm64 (Apple Silicon)\n> "; \
read choices; \
for ch in $$choices; do \
opts=$$(printf "%s" "$$choices" | tr -cd '1234' | sed 's/./& /g'); \
for ch in $$opts; do \
case $$ch in \
1) \
ccbin=$${CC_WIN_AMD64:-x86_64-w64-mingw32-gcc}; \
: # On Windows host: do not force CC, use local toolchain \
if [ "$(OS)" = "Windows_NT" ]; then \
echo "🧭 Host: Windows"; \
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME)_windows_amd64.exe main.go; \
tmp="bin/$(BINARY_NAME)_windows_amd64.exe.new.$$RANDOM"; \
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o "$$tmp" main.go && \
{ mv -f "$$tmp" "bin/$(BINARY_NAME)_windows_amd64.exe" 2>/dev/null || \
echo "⚠️ Target locked, kept: $$tmp"; }; \
else \
if command -v "$$ccbin" >/dev/null 2>&1; then \
echo "⚙️ Using CC=$$ccbin"; \
env CC="$$ccbin" GOOS=windows GOARCH=amd64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME)_windows_amd64.exe main.go; \
tmp="bin/$(BINARY_NAME)_windows_amd64.exe.new.$$RANDOM"; \
env CC="$$ccbin" GOOS=windows GOARCH=amd64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o "$$tmp" main.go && \
{ mv -f "$$tmp" "bin/$(BINARY_NAME)_windows_amd64.exe" 2>/dev/null || \
echo "⚠️ Target locked, kept: $$tmp"; }; \
else \
echo "⏭️ Skip windows/amd64: $$ccbin not found (Arch: sudo pacman -S --needed mingw-w64-gcc)"; \
fi; \
@@ -83,14 +90,20 @@ buildcross:
if [ "$(OS)" = "Windows_NT" ]; then \
if command -v "$$ccbin" >/dev/null 2>&1; then \
echo "⚙️ Using CC=$$ccbin"; \
env CC="$$ccbin" GOOS=windows GOARCH=arm64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME)_windows_arm64.exe main.go; \
tmp="bin/$(BINARY_NAME)_windows_arm64.exe.new.$$RANDOM"; \
env CC="$$ccbin" GOOS=windows GOARCH=arm64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o "$$tmp" main.go && \
{ mv -f "$$tmp" "bin/$(BINARY_NAME)_windows_arm64.exe" 2>/dev/null || \
echo "⚠️ Target locked, kept: $$tmp"; }; \
else \
echo "⏭️ Skip windows/arm64: $$ccbin not found (MSYS2: pacman -S mingw-w64-aarch64-gcc)"; \
fi; \
else \
if command -v "$$ccbin" >/dev/null 2>&1; then \
echo "⚙️ Using CC=$$ccbin"; \
env CC="$$ccbin" GOOS=windows GOARCH=arm64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME)_windows_arm64.exe main.go; \
tmp="bin/$(BINARY_NAME)_windows_arm64.exe.new.$$RANDOM"; \
env CC="$$ccbin" GOOS=windows GOARCH=arm64 CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o "$$tmp" main.go && \
{ mv -f "$$tmp" "bin/$(BINARY_NAME)_windows_arm64.exe" 2>/dev/null || \
echo "⚠️ Target locked, kept: $$tmp"; }; \
else \
echo "⏭️ Skip windows/arm64: $$ccbin not found (Arch: sudo pacman -S --needed mingw-w64-gcc)"; \
fi; \
+1 -1
View File
@@ -492,7 +492,7 @@ func (a *App) settingSelected(i *menu.Item) {
},
{
name: "切换局域网监听",
description: "在 127.0.0.1 与 0.0.0.0 之间切换,便于开启/关闭局域网访问",
description: "切换局域网访问限制",
action: a.toggleListen,
},
{
+13
View File
@@ -87,6 +87,15 @@ func (s *Service) GetDB() *wechatdb.DB {
return s.db
}
// GetWorkDir exposes the underlying work directory where decrypted DB files are stored.
// This is useful for higher layers (HTTP) to compute DB sizes for summary statistics.
func (s *Service) GetWorkDir() string {
if s == nil || s.conf == nil {
return ""
}
return s.conf.GetWorkDir()
}
func (s *Service) GetMessages(start, end time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
return s.db.GetMessages(start, end, talker, sender, keyword, limit, offset)
}
@@ -108,6 +117,10 @@ func (s *Service) GetMedia(_type string, key string) (*model.Media, error) {
return s.db.GetMedia(_type, key)
}
func (s *Service) GetAvatar(username string, size string) (*model.Avatar, error) {
return s.db.GetAvatar(username, size)
}
func (s *Service) initWebhook() error {
if s.webhook == nil {
return nil
+287 -11
View File
@@ -3,6 +3,7 @@ package http
import (
"embed"
"encoding/csv"
"encoding/json"
"fmt"
"html/template"
"io/fs"
@@ -61,6 +62,7 @@ func (s *Service) initMediaRouter() {
s.router.GET("/file/*key", func(c *gin.Context) { s.handleMedia(c, "file") })
s.router.GET("/voice/*key", func(c *gin.Context) { s.handleMedia(c, "voice") })
s.router.GET("/data/*path", s.handleMediaData)
s.router.GET("/avatar/:username", s.handleAvatar)
}
func (s *Service) initAPIRouter() {
@@ -71,6 +73,7 @@ func (s *Service) initAPIRouter() {
api.GET("/chatroom", s.handleChatRooms)
api.GET("/session", s.handleSessions)
api.GET("/diary", s.handleDiary)
api.GET("/summary", s.handleSummary)
}
}
@@ -80,6 +83,215 @@ func (s *Service) initMCPRouter() {
s.router.Any("/message", func(c *gin.Context) { s.mcpSSEServer.ServeHTTP(c.Writer, c.Request) })
}
// handleSummary outputs a dashboard summary JSON. For now it serves a template JSON
// compatible with tools/json/index.json. In future we can compute real data.
// GET /api/v1/summary
func (s *Service) handleSummary(c *gin.Context) {
// dynamic=1 triggers dynamic summary generation; otherwise fall back to template JSON
dynamic := c.Query("dynamic") == "1"
// Try to load a template JSON from tools/json/index.json if present
// Otherwise, return an empty structure with HTTP 200.
workdir := s.conf.GetDataDir()
candidates := []string{
filepath.Join("tools", "json", "index.json"),
filepath.Join(workdir, "tools", "json", "index.json"),
}
var raw []byte
for _, p := range candidates {
if b, err := os.ReadFile(p); err == nil && len(b) > 0 {
raw = b
break
}
}
var v any
if dynamic {
// compute dynamic summary
sum := s.computeDynamicSummary()
v = sum
} else {
if len(raw) > 0 {
if err := json.Unmarshal(raw, &v); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid summary template", "detail": err.Error()})
return
}
} else {
v = gin.H{"dashboard_report": gin.H{}}
}
}
// Optional: save to file in workdir when save=1
if c.Query("save") == "1" {
// default filename
filename := c.DefaultQuery("filename", "summary.json")
if filename == "" { filename = "summary.json" }
outPath := filepath.Join(s.conf.GetDataDir(), filename)
if dir := filepath.Dir(outPath); dir != "." { _ = os.MkdirAll(dir, 0o755) }
var out []byte
var err error
if !dynamic && len(raw) > 0 { out = raw } else { out, err = json.MarshalIndent(v, "", " "); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal summary", "detail": err.Error()}); return } }
if err := os.WriteFile(outPath, out, 0o644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save summary", "detail": err.Error()})
return
}
// If download=1, stream the file as attachment
if c.Query("download") == "1" {
c.Header("Content-Type", "application/json")
c.Header("Content-Disposition", "attachment; filename="+filepath.Base(outPath))
c.Data(http.StatusOK, "application/json", out)
return
}
c.JSON(http.StatusOK, gin.H{"saved": true, "path": outPath})
return
}
// Optional: direct download when download=1
if c.Query("download") == "1" {
b := raw
if dynamic || len(b) == 0 { var err error; b, err = json.MarshalIndent(v, "", " "); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal summary", "detail": err.Error()}); return } }
c.Header("Content-Type", "application/json")
c.Header("Content-Disposition", "attachment; filename=summary.json")
c.Data(http.StatusOK, "application/json", b)
return
}
c.JSON(http.StatusOK, v)
}
// computeDynamicSummary builds a lightweight dynamic dashboard JSON with basic metrics.
// It avoids heavy full DB scans and uses existing repository APIs for acceptable performance.
func (s *Service) computeDynamicSummary() any {
// Sizes
dataDir := s.conf.GetDataDir()
workDir := dataDir // prefer dataDir for media; if database layer exposes workDir, use it
if s.db != nil {
if wd := s.db.GetWorkDir(); wd != "" { workDir = wd }
}
dirSize := safeDirSize(dataDir)
dbSize := estimateDBSize(workDir)
// Sessions timeline (approximate earliest/latest by NTime)
minTime := time.Time{}
maxTime := time.Time{}
if sessions, err := s.db.GetSessions("", 0, 0); err == nil {
for _, it := range sessions.Items {
t := it.NTime
if t.IsZero() { continue }
if minTime.IsZero() || t.Before(minTime) { minTime = t }
if maxTime.IsZero() || t.After(maxTime) { maxTime = t }
}
}
// Contacts basic stats
totalContacts := 0
friends := 0
nonFriends := 0
if contacts, err := s.db.GetContacts("", 0, 0); err == nil {
totalContacts = len(contacts.Items)
for _, c := range contacts.Items { if c.IsFriend { friends++ } else { nonFriends++ } }
}
// Chatrooms top by member count
roomsList := make([]map[string]any, 0)
if rooms, err := s.db.GetChatRooms("", 0, 0); err == nil {
for _, r := range rooms.Items {
roomsList = append(roomsList, map[string]any{
"name": r.Name,
"nick": r.NickName,
"owner": r.Owner,
"members": len(r.Users),
})
}
// simple sort: descending by members
// do inline selection sort to avoid importing sort for tiny lists
for i := 0; i < len(roomsList); i++ {
maxIdx := i
for j := i + 1; j < len(roomsList); j++ {
if roomsList[j]["members"].(int) > roomsList[maxIdx]["members"].(int) { maxIdx = j }
}
if maxIdx != i { roomsList[i], roomsList[maxIdx] = roomsList[maxIdx], roomsList[i] }
}
if len(roomsList) > 20 { roomsList = roomsList[:20] }
}
// Build JSON structure
dash := map[string]any{
"db_stats": map[string]any{
"db_size_mb": roundMB(dbSize),
"dir_size_mb": roundMB(dirSize),
},
"timeline": map[string]any{
"start": formatTime(minTime),
"end": formatTime(maxTime),
"days": diffDays(minTime, maxTime),
},
"contact_stats": map[string]any{
"total": totalContacts,
"friends": friends,
"non_friends": nonFriends,
},
"group_stats": map[string]any{
"top_member_groups": roomsList,
},
// Placeholders for future dynamic enrichment
"message_stats": map[string]any{
"total": 0,
"by_type": map[string]int{},
},
}
return map[string]any{"dashboard_report": dash}
}
func roundMB(bytes int64) float64 {
if bytes <= 0 { return 0 }
// 1 MB = 1024*1024
mb := float64(bytes) / (1024.0 * 1024.0)
// round to 2 decimals
return float64(int(mb*100+0.5)) / 100.0
}
func diffDays(a, b time.Time) int {
if a.IsZero() || b.IsZero() { return 0 }
if b.Before(a) { a, b = b, a }
d := b.Sub(a).Hours() / 24.0
if d < 0 { return 0 }
return int(d + 0.5)
}
func formatTime(t time.Time) string {
if t.IsZero() { return "" }
return t.Format("2006-01-02 15:04:05")
}
// safeDirSize walks a directory and sums file sizes; returns 0 on error.
func safeDirSize(path string) int64 {
var total int64
if path == "" { return 0 }
_ = filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
if err != nil { return nil }
if info == nil || info.IsDir() { return nil }
total += info.Size()
return nil
})
return total
}
// estimateDBSize sums sizes of common DB files under workDir
func estimateDBSize(workDir string) int64 {
if workDir == "" { return 0 }
var total int64
_ = filepath.Walk(workDir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil || info.IsDir() { return nil }
name := strings.ToLower(info.Name())
if strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".sqlite") || strings.HasSuffix(name, ".sqlite3") || strings.HasSuffix(name, ".db-wal") || strings.HasSuffix(name, ".db-shm") {
total += info.Size()
}
return nil
})
return total
}
func (s *Service) handleChatlog(c *gin.Context) {
q := struct {
Time string `form:"time"`
@@ -116,7 +328,7 @@ func (s *Service) handleChatlog(c *gin.Context) {
switch strings.ToLower(q.Format) {
case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteString("<html><head><meta charset=\"utf-8\"><title>Chatlog</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;}details{margin:8px 0;padding:4px 8px;border:1px solid #ddd;border-radius:4px; background:#fafafa;}summary{cursor:pointer;font-weight:600;} .msg{margin:4px 0;padding:4px 6px;border-left:3px solid #3498db;background:#fff;} .meta{color:#666;font-size:12px;} pre{white-space:pre-wrap;word-break:break-word;margin:2px 0;} .talker{color:#2c3e50;} .sender{color:#8e44ad;} .time{color:#16a085;} .content{margin-left:4px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>")
c.Writer.WriteString("<html><head><meta charset=\"utf-8\"><title>Chatlog</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;}details{margin:8px 0;padding:4px 8px;border:1px solid #ddd;border-radius:4px; background:#fafafa;}summary{cursor:pointer;font-weight:600;} .msg{margin:4px 0;padding:4px 6px;border-left:3px solid #3498db;background:#fff;} .msg-row{display:flex;gap:8px;align-items:flex-start;} .avatar{width:28px;height:28px;border-radius:6px;object-fit:cover;background:#f2f2f2;border:1px solid #eee;flex:0 0 28px} .msg-content{flex:1;min-width:0} .meta{color:#666;font-size:12px;} pre{white-space:pre-wrap;word-break:break-word;margin:2px 0;} .talker{color:#2c3e50;} .sender{color:#8e44ad;} .time{color:#16a085;} .content{margin-left:4px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>")
c.Writer.WriteString(fmt.Sprintf("<h2>All Messages %s ~ %s</h2>", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")))
for _, g := range groups {
title := g.Talker
@@ -127,7 +339,8 @@ func (s *Service) handleChatlog(c *gin.Context) {
senderDisplay := m.Sender
if m.IsSelf { senderDisplay = "我" }
if m.SenderName != "" { senderDisplay = template.HTMLEscapeString(m.SenderName) + "(" + template.HTMLEscapeString(senderDisplay) + ")" } else { senderDisplay = template.HTMLEscapeString(senderDisplay) }
c.Writer.WriteString("<div class=\"msg\"><div class=\"meta\"><span class=\"sender\">"+ senderDisplay +"</span><span class=\"time\">"+ m.Time.Format("2006-01-02 15:04:05") +"</span></div><pre>"+ messageHTMLPlaceholder(m) +"</pre></div>")
aurl := template.HTMLEscapeString(s.composeAvatarURL(m.Sender) + "?size=big")
c.Writer.WriteString("<div class=\"msg\"><div class=\"msg-row\"><img class=\"avatar\" src=\"" + aurl + "\" loading=\"lazy\" alt=\"avatar\" onerror=\"this.style.visibility='hidden'\"/><div class=\"msg-content\"><div class=\"meta\"><span class=\"sender\">"+ senderDisplay +"</span><span class=\"time\">"+ m.Time.Format("2006-01-02 15:04:05") +"</span></div><pre>"+ messageHTMLPlaceholder(m) +"</pre></div></div></div>")
}
c.Writer.WriteString("</details>")
}
@@ -172,17 +385,20 @@ func (s *Service) handleChatlog(c *gin.Context) {
switch strings.ToLower(q.Format) {
case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteString("<html><head><meta charset=\"utf-8\"><title>Chatlog</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;} .msg{margin:8px 0;padding:6px 8px;border-left:3px solid #3498db;background:#fafafa;} .meta{color:#666;font-size:12px;margin-bottom:2px;} pre{white-space:pre-wrap;word-break:break-word;margin:0;} .sender{color:#8e44ad;} .time{color:#16a085;margin-left:6px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>")
c.Writer.WriteString("<html><head><meta charset=\"utf-8\"><title>Chatlog</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;} .msg{margin:8px 0;padding:6px 8px;border-left:3px solid #3498db;background:#fafafa;} .msg-row{display:flex;gap:8px;align-items:flex-start;} .avatar{width:28px;height:28px;border-radius:6px;object-fit:cover;background:#f2f2f2;border:1px solid #eee;flex:0 0 28px} .msg-content{flex:1;min-width:0} .meta{color:#666;font-size:12px;margin-bottom:2px;} pre{white-space:pre-wrap;word-break:break-word;margin:0;} .sender{color:#8e44ad;} .time{color:#16a085;margin-left:6px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>")
c.Writer.WriteString(fmt.Sprintf("<h2>Messages %s ~ %s (%s)</h2>", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"), template.HTMLEscapeString(q.Talker)))
for _, m := range messages {
m.SetContent("host", c.Request.Host)
c.Writer.WriteString("<div class=\"msg\"><div class=\"meta\"><span class=\"sender\">")
c.Writer.WriteString("<div class=\"msg\"><div class=\"msg-row\">")
aurl := template.HTMLEscapeString(s.composeAvatarURL(m.Sender) + "?size=big")
c.Writer.WriteString("<img class=\"avatar\" src=\"" + aurl + "\" loading=\"lazy\" alt=\"avatar\" onerror=\"this.style.visibility='hidden'\"/>")
c.Writer.WriteString("<div class=\"msg-content\"><div class=\"meta\"><span class=\"sender\">")
if m.SenderName != "" { c.Writer.WriteString(template.HTMLEscapeString(m.SenderName)+"(") }
c.Writer.WriteString(template.HTMLEscapeString(m.Sender))
if m.SenderName != "" { c.Writer.WriteString(")") }
c.Writer.WriteString("</span><span class=\"time\">"+m.Time.Format("2006-01-02 15:04:05")+"</span></div><pre>")
c.Writer.WriteString(messageHTMLPlaceholder(m))
c.Writer.WriteString("</pre></div>")
c.Writer.WriteString("</pre></div></div></div>")
}
c.Writer.WriteString(previewHTMLSnippet)
c.Writer.WriteString("</body></html>")
@@ -231,8 +447,37 @@ func (s *Service) handleContacts(c *gin.Context) {
format := strings.ToLower(q.Format)
switch format {
case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteHeader(http.StatusOK)
c.Writer.Write([]byte(`<style>
.contacts{font-family:Arial,Helvetica,sans-serif;font-size:14px;}
.c-item{display:flex;align-items:center;gap:10px;border:1px solid #ddd;border-radius:6px;padding:6px 8px;margin:6px 0;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.04);}
.c-avatar{width:36px;height:36px;border-radius:50%;object-fit:cover;background:#f2f2f2;border:1px solid #eee}
.c-name{font-weight:600;color:#2c3e50}
.c-sub{color:#666;font-size:12px}
</style><div class="contacts">`))
for _, contact := range list.Items {
uname := template.HTMLEscapeString(contact.UserName)
nick := template.HTMLEscapeString(contact.NickName)
remark := template.HTMLEscapeString(contact.Remark)
alias := template.HTMLEscapeString(contact.Alias)
// compose avatar URL
aurl := template.HTMLEscapeString(s.composeAvatarURL(contact.UserName))
c.Writer.WriteString(`<div class="c-item">`)
c.Writer.WriteString(`<img class="c-avatar" src="` + aurl + `" loading="lazy" onerror="this.style.visibility='hidden'"/>`)
c.Writer.WriteString(`<div>`)
c.Writer.WriteString(`<div class="c-name">` + nick + `</div>`)
c.Writer.WriteString(`<div class="c-sub">` + uname)
if remark != "" { c.Writer.WriteString(` · ` + remark) }
if alias != "" { c.Writer.WriteString(` · alias:` + alias) }
c.Writer.WriteString(`</div></div></div>`)
}
c.Writer.WriteString(`</div>`)
return
case "json":
// json
// fill avatar urls
for _, item := range list.Items { item.AvatarURL = s.composeAvatarURL(item.UserName) }
c.JSON(http.StatusOK, list)
default:
// csv
@@ -245,15 +490,45 @@ func (s *Service) handleContacts(c *gin.Context) {
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
c.Writer.WriteString("UserName,Alias,Remark,NickName\n")
c.Writer.WriteString("UserName,Alias,Remark,NickName,AvatarURL\n")
for _, contact := range list.Items {
c.Writer.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
avatarURL := s.composeAvatarURL(contact.UserName)
c.Writer.WriteString(fmt.Sprintf("%s,%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName, avatarURL))
}
c.Writer.Flush()
}
}
// composeAvatarURL builds a relative URL that the server can serve for any username
func (s *Service) composeAvatarURL(username string) string {
if username == "" { return "" }
return "/avatar/" + username
}
// handleAvatar serves avatar by username. For v3 returns redirect to remote URL; for v4 streams bytes.
func (s *Service) handleAvatar(c *gin.Context) {
username := c.Param("username")
size := c.Query("size") // optional: small|big
avatar, err := s.db.GetAvatar(username, size)
if err != nil {
errors.Err(c, err)
return
}
if avatar == nil {
errors.Err(c, errors.ErrAvatarNotFound)
return
}
if avatar.URL != "" {
// external URL, redirect
c.Redirect(http.StatusFound, avatar.URL)
return
}
// inline bytes
ct := avatar.ContentType
if ct == "" { ct = "image/jpeg" }
c.Data(http.StatusOK, ct, avatar.Data)
}
func (s *Service) handleChatRooms(c *gin.Context) {
q := struct {
@@ -413,7 +688,7 @@ func (s *Service) handleDiary(c *gin.Context) {
switch format {
case "html":
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteString(`<html><head><meta charset="utf-8"><title>Diary</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;}details{margin:8px 0;padding:6px 8px;border:1px solid #ddd;border-radius:6px;background:#fafafa;}summary{cursor:pointer;font-weight:600;} .msg{margin:4px 0;padding:4px 6px;border-left:3px solid #2ecc71;background:#fff;} .meta{color:#666;font-size:12px;margin-bottom:2px;} pre{white-space:pre-wrap;word-break:break-word;margin:0;} .sender{color:#27ae60;} .time{color:#16a085;margin-left:6px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>`)
c.Writer.WriteString(`<html><head><meta charset="utf-8"><title>Diary</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;}details{margin:8px 0;padding:6px 8px;border:1px solid #ddd;border-radius:6px;background:#fafafa;}summary{cursor:pointer;font-weight:600;} .msg{margin:4px 0;padding:4px 6px;border-left:3px solid #2ecc71;background:#fff;} .msg-row{display:flex;gap:8px;align-items:flex-start;} .avatar{width:28px;height:28px;border-radius:6px;object-fit:cover;background:#f2f2f2;border:1px solid #eee;flex:0 0 28px} .msg-content{flex:1;min-width:0} .meta{color:#666;font-size:12px;margin-bottom:2px;} pre{white-space:pre-wrap;word-break:break-word;margin:0;} .sender{color:#27ae60;} .time{color:#16a085;margin-left:6px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>`)
c.Writer.WriteString(fmt.Sprintf("<h2>最近%dh我参与过的会话全部消息(%s ~ %s)</h2>", hours, start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")))
for _, g := range groups {
title := g.Talker
@@ -428,7 +703,8 @@ func (s *Service) handleDiary(c *gin.Context) {
} else {
senderDisplay = template.HTMLEscapeString(senderDisplay)
}
c.Writer.WriteString("<div class=\"msg\"><div class=\"meta\"><span class=\"sender\">" + senderDisplay + "</span><span class=\"time\">" + m.Time.Format("2006-01-02 15:04:05") + "</span></div><pre>" + messageHTMLPlaceholder(m) + "</pre></div>")
aurl := template.HTMLEscapeString(s.composeAvatarURL(m.Sender) + "?size=big")
c.Writer.WriteString("<div class=\"msg\"><div class=\"msg-row\"><img class=\"avatar\" src=\"" + aurl + "\" loading=\"lazy\" alt=\"avatar\" onerror=\"this.style.visibility='hidden'\"/><div class=\"msg-content\"><div class=\"meta\"><span class=\"sender\">" + senderDisplay + "</span><span class=\"time\">" + m.Time.Format("2006-01-02 15:04:05") + "</span></div><pre>" + messageHTMLPlaceholder(m) + "</pre></div></div></div>")
}
c.Writer.WriteString("</details>")
}
+56 -13
View File
@@ -903,20 +903,63 @@
copyToClipboard(urlText, this, "已复制URL!");
});
// 通用复制功能
// 通用复制功能(支持非安全上下文的回退方案)
function copyToClipboard(text, button, successMessage) {
navigator.clipboard
.writeText(text)
.then(() => {
const originalText = button.textContent;
button.textContent = successMessage;
setTimeout(() => {
button.textContent = originalText;
}, 2000);
})
.catch((err) => {
console.error("复制失败:", err);
});
const originalText = button.textContent;
const toast = (msg) => {
button.textContent = msg;
setTimeout(() => {
button.textContent = originalText;
}, 2000);
};
// 无可复制内容
if (!text || text.trim() === "") {
toast("无可复制内容");
return;
}
// 现代 API,要求安全上下文(https 或 localhost)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(text)
.then(() => {
toast(successMessage);
})
.catch((err) => {
// 失败时尝试回退
const ok = fallbackCopyTextToClipboard(text);
toast(ok ? successMessage : "复制失败");
if (!ok) console.error("复制失败:", err);
});
return;
}
// 非安全上下文或不支持 navigator.clipboard 时,使用回退方案
const ok = fallbackCopyTextToClipboard(text);
toast(ok ? successMessage : "复制失败");
}
// 回退复制方案:创建隐藏 textarea,使用 execCommand('copy')
function fallbackCopyTextToClipboard(text) {
try {
const textarea = document.createElement("textarea");
textarea.value = text;
// 避免页面滚动抖动
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
textarea.style.left = "-9999px";
textarea.setAttribute("readonly", "");
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
} catch (e) {
console.error("回退复制失败:", e);
return false;
}
}
</script>
</body>
+1
View File
@@ -9,6 +9,7 @@ var (
ErrTalkerEmpty = New(nil, http.StatusBadRequest, "talker empty").WithStack()
ErrKeyEmpty = New(nil, http.StatusBadRequest, "key empty").WithStack()
ErrMediaNotFound = New(nil, http.StatusNotFound, "media not found").WithStack()
ErrAvatarNotFound = New(nil, http.StatusNotFound, "avatar not found").WithStack()
ErrKeyLengthMust32 = New(nil, http.StatusBadRequest, "key length must be 32 bytes").WithStack()
)
+9
View File
@@ -0,0 +1,9 @@
package model
// Avatar represents a user's avatar, either as a remote URL (v3) or inline bytes (v4)
type Avatar struct {
Username string `json:"username"`
URL string `json:"url,omitempty"`
ContentType string `json:"-"`
Data []byte `json:"-"`
}
+2
View File
@@ -6,6 +6,8 @@ type Contact struct {
Remark string `json:"remark"`
NickName string `json:"nickName"`
IsFriend bool `json:"isFriend"`
// AvatarURL is a service-composed URL to fetch avatar image
AvatarURL string `json:"avatarUrl,omitempty"`
}
// CREATE TABLE Contact(
@@ -652,3 +652,8 @@ WHERE
func (ds *DataSource) Close() error {
return ds.dbm.Close()
}
// GetAvatar returns not found for darwin v3 (no head image source known here)
func (ds *DataSource) GetAvatar(ctx context.Context, username string, size string) (*model.Avatar, error) {
return nil, errors.ErrAvatarNotFound
}
@@ -30,6 +30,9 @@ type DataSource interface {
// 媒体
GetMedia(ctx context.Context, _type string, key string) (*model.Media, error)
// 头像
GetAvatar(ctx context.Context, username string, size string) (*model.Avatar, error)
// 设置回调函数
SetCallback(group string, callback func(event fsnotify.Event) error) error
@@ -55,6 +55,11 @@ var Groups = []*dbm.Group{
Pattern: `^media_([0-9]?[0-9])?\.db$`,
BlackList: []string{},
},
{
Name: "headimg",
Pattern: `^head_image\.db$`,
BlackList: []string{},
},
}
// MessageDBInfo 存储消息数据库的信息
@@ -750,3 +755,22 @@ func (ds *DataSource) GetVoice(ctx context.Context, key string) (*model.Media, e
func (ds *DataSource) Close() error {
return ds.dbm.Close()
}
// GetAvatar for v4: read head_image.db -> head_image(username, image_buffer)
func (ds *DataSource) GetAvatar(ctx context.Context, username string, size string) (*model.Avatar, error) {
if username == "" {
return nil, errors.ErrKeyEmpty
}
// open head_image db
db, err := ds.dbm.GetDB("headimg")
if err != nil {
return nil, errors.ErrAvatarNotFound
}
// table may be head_image, columns: username, image_buffer (jfif), update_time, md5
row := db.QueryRowContext(ctx, `SELECT image_buffer FROM head_image WHERE username = ?`, username)
var buf []byte
if err := row.Scan(&buf); err != nil || len(buf) == 0 {
return nil, errors.ErrAvatarNotFound
}
return &model.Avatar{Username: username, ContentType: "image/jpeg", Data: buf}, nil
}
@@ -776,3 +776,31 @@ func (ds *DataSource) GetVoice(ctx context.Context, key string) (*model.Media, e
func (ds *DataSource) Close() error {
return ds.dbm.Close()
}
// GetAvatar returns avatar info for a username on Windows v3 (MicroMsg.db -> ContactHeadImgUrl)
func (ds *DataSource) GetAvatar(ctx context.Context, username string, size string) (*model.Avatar, error) {
if username == "" {
return nil, errors.ErrKeyEmpty
}
db, err := ds.dbm.GetDB(Contact)
if err != nil {
return nil, err
}
query := `SELECT IFNULL(smallHeadImgUrl, ''), IFNULL(bigHeadImgUrl, '') FROM ContactHeadImgUrl WHERE usrName = ?`
row := db.QueryRowContext(ctx, query, username)
var small, big string
if err := row.Scan(&small, &big); err != nil {
return nil, errors.ErrAvatarNotFound
}
url := small
if strings.ToLower(size) == "big" && big != "" {
url = big
}
if url == "" {
url = big
}
if url == "" {
return nil, errors.ErrAvatarNotFound
}
return &model.Avatar{Username: username, URL: url}, nil
}
@@ -109,3 +109,8 @@ func (r *Repository) chatroomCallback(event fsnotify.Event) error {
func (r *Repository) Close() error {
return r.ds.Close()
}
// GetAvatar proxies to datasource
func (r *Repository) GetAvatar(ctx context.Context, username string, size string) (*model.Avatar, error) {
return r.ds.GetAvatar(ctx, username, size)
}
+4
View File
@@ -129,3 +129,7 @@ func (w *DB) GetMedia(_type string, key string) (*model.Media, error) {
func (w *DB) SetCallback(group string, callback func(event fsnotify.Event) error) error {
return w.ds.SetCallback(group, callback)
}
func (w *DB) GetAvatar(username string, size string) (*model.Avatar, error) {
return w.repo.GetAvatar(context.Background(), username, size)
}