Files
pargar/backend/internal/api/me_export.go
T
ahp 22d92c133d feat: release v0.2.0 profiles, chat, freeze, and bots
Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
2026-10-10 16:42:35 +03:30

605 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"pargar/backend/internal/models"
"pargar/backend/internal/observability"
)
const (
meExportHTMLBase int64 = 8 * 1024
meExportHTMLPerMessage int64 = 512
)
func (s *Server) handleMeExport(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
if u == nil {
writeErr(w, http.StatusUnauthorized, "ابتدا وارد شوید")
return
}
partnerID, ok := meExportChatID(r)
if !ok {
writeErr(w, http.StatusBadRequest, "گفتگو را مشخص کنید")
return
}
if !s.canChatWith(r.Context(), u, partnerID) {
writeErr(w, http.StatusNotFound, "گفتگو پیدا نشد")
return
}
if r.URL.Query().Get("meta") == "1" {
n, last, err := s.estimateMeExportBytes(r.Context(), u.ID, partnerID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "برآورد حجم خروجی ممکن نشد")
return
}
payload := map[string]any{"bytes": n, "chat": partnerID}
if last != nil && !last.IsZero() {
payload["lastAt"] = last.UTC()
}
writeJSON(w, http.StatusOK, payload)
return
}
if err := s.writeMeChatHTMLZip(w, r, u, partnerID); err != nil {
observability.L().Error("me.export_zip", "error", err, "user_id", u.ID, "chat", partnerID)
}
}
func meExportChatID(r *http.Request) (int64, bool) {
raw := strings.TrimSpace(r.URL.Query().Get("chat"))
if raw == "" {
return 0, false
}
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}
func (s *Server) estimateMeExportBytes(ctx context.Context, userID, partnerID int64) (int64, *time.Time, error) {
var messages, media int64
var last *time.Time
err := s.store.Pool().QueryRow(ctx, `
SELECT
count(*)::bigint,
COALESCE(SUM(
CASE
WHEN attachments IS NOT NULL
AND jsonb_typeof(attachments) = 'array'
AND jsonb_array_length(attachments) > 0
THEN COALESCE((
SELECT SUM(GREATEST(COALESCE((elem->>'size')::bigint, 0), 0))
FROM jsonb_array_elements(attachments) AS elem
), 0)
ELSE GREATEST(COALESCE(attachment_size, 0), 0)
END
), 0)::bigint,
MAX(created_at)
FROM chat_messages
WHERE (user_id = $1 AND mentor_id = $2) OR (user_id = $2 AND mentor_id = $1)`, userID, partnerID).Scan(&messages, &media, &last)
if err != nil {
return 0, nil, err
}
return media + meExportHTMLBase + messages*meExportHTMLPerMessage, last, nil
}
type meExportMessage struct {
ID int64
UserID int64
MentorID int64
SenderRole string
Body string
CreatedAt time.Time
StudentName string
MentorName string
Atts []models.Attachment
}
type meExportFile struct {
Path string
Key string
}
type mePlaced struct {
msg meExportMessage
rel []string
}
func (s *Server) writeMeChatHTMLZip(w http.ResponseWriter, r *http.Request, u *models.User, partnerID int64) error {
title := "پیام‌های ذخیره‌شده"
if partnerID != u.ID {
partner, err := s.store.GetUserByID(r.Context(), partnerID)
if err != nil {
writeErr(w, http.StatusNotFound, "گفتگو پیدا نشد")
return err
}
title = strings.TrimSpace(partner.Name)
if title == "" {
title = "گفتگو"
}
}
rows, err := s.store.Pool().Query(r.Context(), `
SELECT m.id, m.user_id, m.mentor_id, m.sender_role, m.body, m.created_at,
m.attachment_type, m.attachment_url, m.attachment_name, m.attachment_size, m.attachments,
COALESCE(su.name, ''), COALESCE(mu.name, '')
FROM chat_messages m
LEFT JOIN users su ON su.id = m.user_id
LEFT JOIN users mu ON mu.id = m.mentor_id
WHERE (m.user_id = $1 AND m.mentor_id = $2) OR (m.user_id = $2 AND m.mentor_id = $1)
ORDER BY m.created_at ASC, m.id ASC`, u.ID, partnerID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
defer rows.Close()
var messages []meExportMessage
for rows.Next() {
var m meExportMessage
var attType, attURL, attName *string
var attSize *int64
var rawAtts []byte
if err := rows.Scan(
&m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.CreatedAt,
&attType, &attURL, &attName, &attSize, &rawAtts,
&m.StudentName, &m.MentorName,
); err != nil {
continue
}
m.Atts = decodeExportAttachments(rawAtts, attType, attURL, attName, attSize)
messages = append(messages, m)
}
if err := rows.Err(); err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
placed := make([]mePlaced, 0, len(messages))
var files []meExportFile
seen := map[string]bool{}
for _, m := range messages {
item := mePlaced{msg: m, rel: make([]string, len(m.Atts))}
for i, att := range m.Atts {
key := mediaKeyFromURL(att.URL)
if key == "" || strings.Contains(key, "..") || !exportKeyAllowed(key, u.ID, m.UserID, m.MentorID) || !s.storage.Exists(key) {
continue
}
label := safeExportFilename(att.Name)
rel := filepath.ToSlash(filepath.Join("media", fmt.Sprintf("%d-%d-%s", m.ID, i+1, label)))
if seen[rel] {
rel = filepath.ToSlash(filepath.Join("media", fmt.Sprintf("%d-%d-b-%s", m.ID, i+1, label)))
}
seen[rel] = true
item.rel[i] = rel
files = append(files, meExportFile{Path: rel, Key: key})
}
placed = append(placed, item)
}
doc := renderMeExportHTML(u, title, placed)
tmp, err := os.CreateTemp("", "pargar-chats-*.zip")
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
tmpName := tmp.Name()
defer func() {
_ = tmp.Close()
_ = os.Remove(tmpName)
}()
zw := zip.NewWriter(tmp)
hw, err := zw.CreateHeader(&zip.FileHeader{
Name: "index.html",
Method: zip.Deflate,
Modified: time.Now().UTC(),
})
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
if _, err := io.WriteString(hw, doc); err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
for _, f := range files {
rc, _, _, err := s.storage.Open(f.Key)
if err != nil {
continue
}
aw, err := zw.CreateHeader(&zip.FileHeader{
Name: f.Path,
Method: zip.Deflate,
Modified: time.Now().UTC(),
})
if err != nil {
_ = rc.Close()
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
_, copyErr := io.Copy(aw, rc)
_ = rc.Close()
if copyErr != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return copyErr
}
}
if err := zw.Close(); err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
info, err := tmp.Stat()
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="pargar-chat-%d.zip"`, partnerID))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, tmp)
return err
}
func exportKeyAllowed(key string, me, studentID, mentorID int64) bool {
return mediaKeyOwnedByUser(key, me) || mediaKeyOwnedByUser(key, studentID) || mediaKeyOwnedByUser(key, mentorID)
}
func decodeExportAttachments(raw []byte, attType, attURL, attName *string, attSize *int64) []models.Attachment {
if len(raw) > 0 && string(raw) != "null" && string(raw) != "[]" {
var list []models.Attachment
if err := json.Unmarshal(raw, &list); err == nil && len(list) > 0 {
return list
}
}
if attURL != nil && *attURL != "" {
size := int64(0)
if attSize != nil {
size = *attSize
}
typ, name := "", ""
if attType != nil {
typ = *attType
}
if attName != nil {
name = *attName
}
return []models.Attachment{{Type: typ, URL: *attURL, Name: name, Size: size}}
}
return nil
}
func renderMeExportHTML(owner *models.User, title string, messages []mePlaced) string {
var b strings.Builder
b.WriteString(`<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>`)
b.WriteString(html.EscapeString(title))
b.WriteString(`</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
background: #eceef8;
color: #2a2745;
font-family: Vazirmatn, "IRANSans", Tahoma, sans-serif;
}
.sheet {
max-width: 440px;
margin: 24px auto 40px;
overflow: hidden;
background: #f7f7fc;
border: 1px solid rgba(255,255,255,.75);
border-radius: 26px;
box-shadow: 0 24px 70px -18px rgba(42,39,69,.35);
}
header.bar {
position: relative;
padding: 16px 16px 12px;
background: rgba(255,255,255,.78);
border-bottom: 1px solid #e3e5f2;
}
header.bar::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(to left, #6d63f0, #ec6fa9, #f2a437);
}
header.bar h1 { margin: 0; font-size: .95rem; font-weight: 800; }
header.bar p { margin: 3px 0 0; color: #6b6c8a; font-size: .75rem; }
.log { min-height: 240px; padding: 12px 12px 18px; }
.day { display: flex; justify-content: center; margin: 10px 0 8px; }
.day span {
border: 1px solid #d9dbe9;
background: rgba(255,255,255,.9);
color: #6b6c8a;
border-radius: 999px;
padding: 3px 10px;
font-size: 10px;
font-weight: 700;
}
article.msg { display: flex; margin: 0 0 6px; }
article.msg.mine { justify-content: flex-start; }
article.msg.theirs { justify-content: flex-end; }
.stack { display: flex; flex-direction: column; max-width: min(78%, 20rem); }
article.mine .stack { align-items: flex-start; }
article.theirs .stack { align-items: flex-end; }
.who { margin: 0 6px 3px; font-size: 11px; font-weight: 800; color: #6b6c8a; }
.bubble {
width: fit-content;
max-width: 100%;
padding: 8px 14px;
font-size: .875rem;
line-height: 1.65;
}
article.mine .bubble {
background: #6d63f0;
color: #fff;
border-radius: 18px 6px 18px 18px;
box-shadow: 0 1px 1px rgba(0,0,0,.12);
}
article.theirs .bubble {
background: #fff;
color: #2a2745;
border: 1px solid #d9dbe9;
border-radius: 6px 18px 18px 18px;
box-shadow: 0 1px 1px rgba(0,0,0,.06);
}
.body {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
unicode-bidi: plaintext;
}
time { display: block; margin-top: 4px; font-size: 10px; line-height: 1; opacity: .78; }
article.mine time { text-align: right; }
article.theirs time { text-align: left; }
img, video {
display: block;
max-width: min(100%, 240px);
height: auto;
border-radius: 12px;
margin-top: 6px;
}
audio { display: block; width: min(100%, 240px); margin-top: 6px; }
.file, .missing { margin: 6px 0 0; font-size: .82rem; }
article.mine a { color: #fff; }
article.theirs a { color: #4c45c6; }
.missing { opacity: .85; }
.empty { margin: 28px 8px; text-align: center; color: #6b6c8a; font-size: .875rem; }
</style>
</head>
<body>
<main class="sheet">
<header class="bar">
<h1>`)
b.WriteString(html.EscapeString(title))
b.WriteString(`</h1>
<p>خروجی پرگار · `)
b.WriteString(html.EscapeString(formatJalaliStamp(time.Now())))
b.WriteString(`</p>
</header>
<div class="log">
`)
if len(messages) == 0 {
b.WriteString(`<p class="empty">هنوز پیامی در این گفتگو نیست.</p>`)
}
var hasPrev bool
var prevMine bool
var prevAt time.Time
for _, item := range messages {
mine := meExportIsMine(owner.ID, item.msg)
if !hasPrev || !sameExportDay(prevAt, item.msg.CreatedAt) {
b.WriteString(`<div class="day"><span>`)
b.WriteString(html.EscapeString(formatJalaliDay(item.msg.CreatedAt)))
b.WriteString(`</span></div>`)
}
showName := !hasPrev || prevMine != mine || !sameExportDay(prevAt, item.msg.CreatedAt)
cls := "theirs"
if mine {
cls = "mine"
}
b.WriteString(`<article class="msg ` + cls + `"><div class="stack">`)
if showName {
b.WriteString(`<p class="who">`)
b.WriteString(html.EscapeString(meExportSenderName(owner, item.msg)))
b.WriteString(`</p>`)
}
b.WriteString(`<div class="bubble">`)
if strings.TrimSpace(item.msg.Body) != "" {
b.WriteString(`<p class="body">` + escapeExportBody(item.msg.Body) + `</p>`)
}
for i, att := range item.msg.Atts {
rel := ""
if i < len(item.rel) {
rel = item.rel[i]
}
b.WriteString(renderExportAttachment(att, rel))
}
b.WriteString(`<time datetime="` + html.EscapeString(item.msg.CreatedAt.UTC().Format(time.RFC3339)) + `">`)
b.WriteString(html.EscapeString(formatJalaliStamp(item.msg.CreatedAt)))
b.WriteString(`</time></div></div></article>`)
hasPrev = true
prevMine = mine
prevAt = item.msg.CreatedAt
}
b.WriteString(`</div>
</main>
</body>
</html>
`)
return b.String()
}
func meExportSenderName(me *models.User, m meExportMessage) string {
if meExportIsMine(me.ID, m) {
name := strings.TrimSpace(me.Name)
if name == "" {
return "شما"
}
return name
}
name := m.StudentName
if me.ID == m.UserID {
name = m.MentorName
}
name = strings.TrimSpace(name)
if name == "" {
return "گفتگو"
}
return name
}
func sameExportDay(a, b time.Time) bool {
al := a.In(tehranLocation())
bl := b.In(tehranLocation())
return al.Year() == bl.Year() && al.Month() == bl.Month() && al.Day() == bl.Day()
}
func formatJalaliDay(t time.Time) string {
if t.IsZero() {
return ""
}
local := t.In(tehranLocation())
_, jm, jd := gregorianToJalali(local.Year(), int(local.Month()), local.Day())
month := "ماه"
if jm >= 1 && jm <= len(jalaliMonthNames) {
month = jalaliMonthNames[jm-1]
}
return persianDigits(jd) + " " + month
}
func meExportIsMine(meID int64, m meExportMessage) bool {
if m.UserID == m.MentorID {
return true
}
role := models.Role(m.SenderRole)
if meID == m.UserID && role == models.RoleStudent {
return true
}
if meID == m.MentorID && (role == models.RoleMentor || role == models.RoleAdmin) {
return true
}
return false
}
func escapeExportBody(s string) string {
return html.EscapeString(s)
}
func renderExportAttachment(att models.Attachment, rel string) string {
name := strings.TrimSpace(att.Name)
if name == "" {
name = "فایل"
}
safe := html.EscapeString(name)
if rel == "" {
return `<p class="missing">فایل همراه موجود نیست: ` + safe + `</p>`
}
src := html.EscapeString(rel)
switch strings.ToLower(strings.TrimSpace(att.Type)) {
case "image":
return `<img src="` + src + `" alt="` + safe + `">`
case "audio":
return `<audio controls preload="metadata" src="` + src + `"></audio>`
case "video":
return `<video controls preload="metadata" src="` + src + `"></video>`
default:
return `<p class="file"><a href="` + src + `">` + safe + `</a></p>`
}
}
func formatJalaliStamp(t time.Time) string {
if t.IsZero() {
return ""
}
local := t.In(tehranLocation())
_, jm, jd := gregorianToJalali(local.Year(), int(local.Month()), local.Day())
month := "ماه"
if jm >= 1 && jm <= len(jalaliMonthNames) {
month = jalaliMonthNames[jm-1]
}
return persianDigits(jd) + " " + month + " · " + persianDigits2(local.Hour()) + ":" + persianDigits2(local.Minute())
}
func tehranLocation() *time.Location {
loc, err := time.LoadLocation("Asia/Tehran")
if err != nil {
return time.FixedZone("IRST", 3*3600+30*60)
}
return loc
}
var jalaliMonthNames = []string{
"فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند",
}
func persianDigits(n int) string {
if n < 0 {
n = 0
}
return strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return '۰' + (r - '0')
}
return r
}, strconv.Itoa(n))
}
func persianDigits2(n int) string {
if n < 0 {
n = 0
}
return persianDigits(n/10) + persianDigits(n%10)
}
func gregorianToJalali(gy, gm, gd int) (jy, jm, jd int) {
gdm := [12]int{0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}
gy2 := gy
if gm > 2 {
gy2 = gy + 1
}
days := 355666 + 365*gy + (gy2+3)/4 - (gy2+99)/100 + (gy2+399)/400 + gd + gdm[gm-1]
jy = -1595 + 33*(days/12053)
days %= 12053
jy += 4 * (days / 1461)
days %= 1461
if days > 365 {
jy += (days - 1) / 365
days = (days - 1) % 365
}
if days < 186 {
jm = 1 + days/31
jd = 1 + days%31
return jy, jm, jd
}
jm = 7 + (days-186)/30
jd = 1 + (days-186)%30
return jy, jm, jd
}