feat(avatar): Avatar support for html rendering
This commit is contained in:
@@ -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; \
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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>")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user