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(` `) b.WriteString(html.EscapeString(title)) b.WriteString(`

`) b.WriteString(html.EscapeString(title)) b.WriteString(`

خروجی پرگار · `) b.WriteString(html.EscapeString(formatJalaliStamp(time.Now()))) b.WriteString(`

`) if len(messages) == 0 { b.WriteString(`

هنوز پیامی در این گفتگو نیست.

`) } 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(`
`) b.WriteString(html.EscapeString(formatJalaliDay(item.msg.CreatedAt))) b.WriteString(`
`) } showName := !hasPrev || prevMine != mine || !sameExportDay(prevAt, item.msg.CreatedAt) cls := "theirs" if mine { cls = "mine" } b.WriteString(`
`) if showName { b.WriteString(`

`) b.WriteString(html.EscapeString(meExportSenderName(owner, item.msg))) b.WriteString(`

`) } b.WriteString(`
`) if strings.TrimSpace(item.msg.Body) != "" { b.WriteString(`

` + escapeExportBody(item.msg.Body) + `

`) } for i, att := range item.msg.Atts { rel := "" if i < len(item.rel) { rel = item.rel[i] } b.WriteString(renderExportAttachment(att, rel)) } b.WriteString(`
`) hasPrev = true prevMine = mine prevAt = item.msg.CreatedAt } b.WriteString(`
`) 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 `

فایل همراه موجود نیست: ` + safe + `

` } src := html.EscapeString(rel) switch strings.ToLower(strings.TrimSpace(att.Type)) { case "image": return `` + safe + `` case "audio": return `` case "video": return `` default: return `

` + safe + `

` } } 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 }