Files
pargar/backend/internal/api/chat_export.go
T

622 lines
18 KiB
Go
Raw Normal View History

package api
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"pargar/backend/internal/models"
"pargar/backend/internal/observability"
"pargar/backend/internal/store"
)
const (
chatExportPayloadVersion = 4
chatExportSchema = "pargar.chat-export"
)
type chatExportParticipant struct {
ID int64 `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
}
type chatExportParticipants struct {
Student chatExportParticipant `json:"student"`
Mentor chatExportParticipant `json:"mentor"`
}
// chatExportAttachment is the v4 archive attachment descriptor.
// Path is relative inside the ZIP (e.g. attachments/1-2/42-photo.jpg).
type chatExportAttachment struct {
Type string `json:"type"`
Name string `json:"name"`
Size int64 `json:"size"`
Path string `json:"path,omitempty"`
Missing bool `json:"missing,omitempty"`
// URL kept for restore of legacy JSON exports (v2/v3) only.
URL string `json:"url,omitempty"`
}
type chatExportMessage struct {
ID int64 `json:"id,omitempty"`
Body string `json:"body"`
SenderRole string `json:"senderRole"`
CreatedAt time.Time `json:"createdAt"`
ReadAt *time.Time `json:"readAt,omitempty"`
ReplyTo *int64 `json:"replyTo,omitempty"`
Pinned bool `json:"pinned"`
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
EditedAt *time.Time `json:"editedAt,omitempty"`
Attachment *chatExportAttachment `json:"attachment,omitempty"`
}
type chatExportConversation struct {
StudentID int64 `json:"studentId"`
StudentName string `json:"studentName"`
MentorID int64 `json:"mentorId"`
MentorName string `json:"mentorName"`
Participants chatExportParticipants `json:"participants"`
MessageCount int `json:"messageCount"`
Messages []chatExportMessage `json:"messages"`
}
type chatExportPayload struct {
Version int `json:"version"`
Schema string `json:"schema"`
ExportedAt time.Time `json:"exportedAt"`
Format string `json:"format,omitempty"`
ConversationN int `json:"conversationCount"`
Conversations []chatExportConversation `json:"conversations"`
}
type zipAttachmentFile struct {
Path string
Key string
}
var unsafeFilenameChars = regexp.MustCompile(`[^a-zA-Z0-9._\-\p{L}]+`)
func (s *Server) chatExportDownloadURL(rawToken string) string {
base := strings.TrimRight(s.cfg.PublicURL, "/")
path := "/api/exports/chats/" + rawToken
if base == "" {
return path
}
return base + path
}
func (s *Server) decorateChatExportToken(tok *models.ChatExportToken) {
if tok == nil {
return
}
enc := tok.Token
if enc == "" {
return
}
raw, err := store.DecryptInviteToken(s.cfg.JWTSecret, enc)
if err != nil {
tok.Token = ""
return
}
tok.Token = raw
tok.URL = s.chatExportDownloadURL(raw)
tok.Active = tok.RevokedAt == nil && tok.UsedAt == nil && tok.ExpiresAt.After(time.Now().UTC())
}
func safeExportFilename(name string) string {
base := filepath.Base(strings.TrimSpace(name))
if base == "" || base == "." || base == ".." {
base = "file"
}
base = unsafeFilenameChars.ReplaceAllString(base, "_")
base = strings.Trim(base, "._")
if base == "" {
base = "file"
}
if len(base) > 120 {
ext := filepath.Ext(base)
base = base[:120-len(ext)] + ext
}
return base
}
func conversationExportKey(studentID, mentorID int64) string {
return fmt.Sprintf("%d-%d", studentID, mentorID)
}
func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, []zipAttachmentFile, error) {
pairs, err := s.store.ListChatExportPairs(r.Context())
if err != nil {
return nil, nil, err
}
out := &chatExportPayload{
Version: chatExportPayloadVersion,
Schema: chatExportSchema,
ExportedAt: time.Now().UTC(),
Format: "zip",
}
var files []zipAttachmentFile
const pageSize int64 = 2000
for _, p := range pairs {
convKey := conversationExportKey(p.StudentID, p.MentorID)
conv := chatExportConversation{
StudentID: p.StudentID,
StudentName: p.StudentName,
MentorID: p.MentorID,
MentorName: p.MentorName,
Participants: chatExportParticipants{
Student: chatExportParticipant{
ID: p.StudentID, Name: p.StudentName, Role: string(models.RoleStudent),
},
Mentor: chatExportParticipant{
ID: p.MentorID, Name: p.MentorName, Role: string(models.RoleMentor),
},
},
}
var before int64
for {
msgs, err := s.store.ListChatMessages(r.Context(), p.StudentID, p.MentorID, before, pageSize)
if err != nil {
return nil, nil, err
}
if len(msgs) == 0 {
break
}
batch := make([]chatExportMessage, 0, len(msgs))
for _, m := range msgs {
item := chatExportMessage{
ID: m.ID,
Body: m.Body,
SenderRole: string(m.SenderRole),
CreatedAt: m.CreatedAt,
ReadAt: m.ReadAt,
ReplyTo: m.ReplyTo,
Pinned: m.PinnedAt != nil,
PinnedAt: m.PinnedAt,
EditedAt: m.EditedAt,
}
if m.Attachment != nil {
att := &chatExportAttachment{
Type: m.Attachment.Type,
Name: m.Attachment.Name,
Size: m.Attachment.Size,
}
key := mediaKeyFromURL(m.Attachment.URL)
if key == "" {
att.Missing = true
} else {
rel := filepath.ToSlash(filepath.Join(
"attachments",
convKey,
fmt.Sprintf("%d-%s", m.ID, safeExportFilename(m.Attachment.Name)),
))
rc, _, _, err := s.storage.Open(key)
if err != nil {
att.Missing = true
} else {
_ = rc.Close()
att.Path = rel
files = append(files, zipAttachmentFile{Path: rel, Key: key})
}
}
item.Attachment = att
}
batch = append(batch, item)
}
conv.Messages = append(batch, conv.Messages...)
if int64(len(msgs)) < pageSize {
break
}
before = msgs[0].ID
}
if conv.Messages == nil {
conv.Messages = []chatExportMessage{}
}
conv.MessageCount = len(conv.Messages)
out.Conversations = append(out.Conversations, conv)
}
if out.Conversations == nil {
out.Conversations = []chatExportConversation{}
}
out.ConversationN = len(out.Conversations)
return out, files, nil
}
func (s *Server) writeChatExportZip(w http.ResponseWriter, payload *chatExportPayload, files []zipAttachmentFile) error {
tmp, err := os.CreateTemp("", "pargar-chat-export-*.zip")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() {
_ = tmp.Close()
_ = os.Remove(tmpName)
}()
zw := zip.NewWriter(tmp)
manifestBytes, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
mw, err := zw.Create("manifest.json")
if err != nil {
return err
}
if _, err := mw.Write(manifestBytes); err != nil {
return err
}
seen := map[string]bool{}
for _, f := range files {
if f.Path == "" || f.Key == "" || seen[f.Path] {
continue
}
seen[f.Path] = true
rc, _, _, err := s.storage.Open(f.Key)
if err != nil {
continue
}
hdr := &zip.FileHeader{
Name: f.Path,
Method: zip.Deflate,
Modified: time.Now().UTC(),
}
aw, err := zw.CreateHeader(hdr)
if err != nil {
_ = rc.Close()
return err
}
_, copyErr := io.Copy(aw, rc)
_ = rc.Close()
if copyErr != nil {
return copyErr
}
}
if err := zw.Close(); err != nil {
return err
}
if err := tmp.Sync(); err != nil {
return err
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
return err
}
info, err := tmp.Stat()
if err != nil {
return err
}
filename := fmt.Sprintf("chat-export-%s.zip", time.Now().UTC().Format("2006-01-02"))
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
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 (s *Server) handleAdminChatExport(w http.ResponseWriter, r *http.Request) {
out, files, err := s.buildProjectChatExport(r)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return
}
observability.Activity("chat.export", "actor_id", currentUser(r).ID, "conversations", len(out.Conversations), "files", len(files))
if err := s.writeChatExportZip(w, out, files); err != nil {
observability.L().Error("chat.export_zip", "error", err)
}
}
func (s *Server) handleAdminChatExportStats(w http.ResponseWriter, r *http.Request) {
stats, err := s.store.ChatExportStats(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "خواندن آمار آرشیو گفتگوها ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"stats": stats})
}
func (s *Server) handleAdminChatExportRestore(w http.ResponseWriter, r *http.Request) {
payload, zipFiles, err := s.readChatExportRestore(r)
if err != nil {
writeValidationErr(w, err.Error())
return
}
if payload.Version != 0 && payload.Version != 2 && payload.Version != 3 && payload.Version != chatExportPayloadVersion {
writeErr(w, http.StatusBadRequest, "نسخهٔ خروجی گفتگوها پشتیبانی نمی‌شود")
return
}
imported := 0
for _, conv := range payload.Conversations {
studentID := conv.StudentID
mentorID := conv.MentorID
if studentID <= 0 && conv.Participants.Student.ID > 0 {
studentID = conv.Participants.Student.ID
}
if mentorID <= 0 && conv.Participants.Mentor.ID > 0 {
mentorID = conv.Participants.Mentor.ID
}
if studentID <= 0 || mentorID <= 0 {
continue
}
student, err := s.store.GetUserByID(r.Context(), studentID)
if err != nil || student == nil {
continue
}
mentor, err := s.store.GetUserByID(r.Context(), mentorID)
if err != nil || mentor == nil {
continue
}
for _, m := range conv.Messages {
role := models.Role(m.SenderRole)
if role != student.Role && role != mentor.Role {
if student.Role == models.RoleStudent {
role = student.Role
} else {
role = mentor.Role
}
}
created := m.CreatedAt
if created.IsZero() {
created = time.Now().UTC()
}
var att *models.Attachment
if m.Attachment != nil {
att = s.materializeRestoredAttachment(studentID, m.Attachment, zipFiles)
}
if _, err := s.store.ImportChatMessage(r.Context(), studentID, mentorID, role, m.Body, att, created, m.Pinned); err != nil {
continue
}
imported++
}
}
observability.Activity("chat.export_restored", "actor_id", currentUser(r).ID, "imported", imported)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "imported": imported})
}
func (s *Server) readChatExportRestore(r *http.Request) (*chatExportPayload, map[string][]byte, error) {
ct := strings.ToLower(r.Header.Get("Content-Type"))
const maxRestore = 64 << 20
r.Body = http.MaxBytesReader(nil, r.Body, maxRestore)
data, err := io.ReadAll(r.Body)
if err != nil {
return nil, nil, errors.New("خواندن فایل ممکن نشد")
}
if len(data) == 0 {
return nil, nil, errors.New("فایل خالی است")
}
// ZIP magic
if len(data) >= 4 && data[0] == 'P' && data[1] == 'K' {
return s.parseChatExportZip(data)
}
if strings.Contains(ct, "zip") {
return s.parseChatExportZip(data)
}
var payload chatExportPayload
if err := json.Unmarshal(data, &payload); err != nil {
return nil, nil, errors.New("فایل خروجی گفتگوها نامعتبر است")
}
return &payload, nil, nil
}
func (s *Server) parseChatExportZip(data []byte) (*chatExportPayload, map[string][]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, nil, errors.New("آرشیو ZIP نامعتبر است")
}
files := map[string][]byte{}
var manifest []byte
for _, f := range zr.File {
name := filepath.ToSlash(f.Name)
if strings.HasPrefix(name, "__MACOSX/") || strings.HasSuffix(name, "/") {
continue
}
rc, err := f.Open()
if err != nil {
continue
}
body, err := io.ReadAll(io.LimitReader(rc, 32<<20))
_ = rc.Close()
if err != nil {
continue
}
if name == "manifest.json" {
manifest = body
continue
}
if strings.HasPrefix(name, "attachments/") {
files[name] = body
}
}
if len(manifest) == 0 {
return nil, nil, errors.New("manifest.json در آرشیو یافت نشد")
}
var payload chatExportPayload
if err := json.Unmarshal(manifest, &payload); err != nil {
return nil, nil, errors.New("manifest.json نامعتبر است")
}
return &payload, files, nil
}
type bytesFile struct {
*bytes.Reader
}
func (b bytesFile) Close() error { return nil }
func (s *Server) materializeRestoredAttachment(ownerID int64, src *chatExportAttachment, zipFiles map[string][]byte) *models.Attachment {
if src == nil {
return nil
}
name := safeExportFilename(src.Name)
if name == "" {
name = "file"
}
attType := src.Type
if attType == "" {
attType = attachmentType("", filepath.Ext(name))
}
// Prefer embedded zip bytes.
if src.Path != "" && zipFiles != nil {
path := filepath.ToSlash(src.Path)
if raw, ok := zipFiles[path]; ok && len(raw) > 0 {
key := fmt.Sprintf("chats/%d-%d%s", ownerID, time.Now().UnixNano(), filepath.Ext(name))
url, err := s.storage.Save(key, bytesFile{Reader: bytes.NewReader(raw)}, int64(len(raw)))
if err == nil {
return &models.Attachment{
Type: attType,
URL: s.signExistingMediaURL(url),
Name: name,
Size: int64(len(raw)),
}
}
}
}
// Legacy JSON: keep original URL reference if present.
if src.URL != "" {
return &models.Attachment{
Type: attType,
URL: src.URL,
Name: name,
Size: src.Size,
}
}
return nil
}
type createChatExportTokenRequest struct {
ExpiresInHours int `json:"expiresInHours"`
Label string `json:"label"`
}
func (s *Server) handleAdminCreateChatExportToken(w http.ResponseWriter, r *http.Request) {
var req createChatExportTokenRequest
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
hours := req.ExpiresInHours
if hours != 1 && hours != 24 {
writeErr(w, http.StatusBadRequest, "مهلت باید ۱ یا ۲۴ ساعت باشد")
return
}
raw, err := store.NewInviteToken()
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت توکن ممکن نشد")
return
}
enc, err := store.EncryptInviteToken(s.cfg.JWTSecret, raw)
if err != nil {
writeErr(w, http.StatusInternalServerError, "رمزنگاری توکن ممکن نشد")
return
}
expiresAt := time.Now().UTC().Add(time.Duration(hours) * time.Hour)
admin := currentUser(r)
tok, err := s.store.CreateChatExportToken(
r.Context(),
store.HashInviteToken(raw),
enc,
req.Label,
expiresAt,
admin.ID,
)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ذخیره لینک دانلود ممکن نشد")
return
}
tok.Token = raw
tok.URL = s.chatExportDownloadURL(raw)
tok.Active = true
observability.Activity("chat.export_token_create", "token_id", tok.ID, "admin_id", admin.ID, "hours", hours)
writeJSON(w, http.StatusCreated, map[string]any{"token": tok})
}
func (s *Server) handleAdminListChatExportTokens(w http.ResponseWriter, r *http.Request) {
tokens, err := s.store.ListChatExportTokens(r.Context(), 100)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری لینک‌های دانلود ممکن نشد")
return
}
for i := range tokens {
s.decorateChatExportToken(&tokens[i])
}
writeJSON(w, http.StatusOK, map[string]any{"items": tokens})
}
func (s *Server) handleAdminRevokeChatExportToken(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id < 1 {
writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است")
return
}
tok, err := s.store.RevokeChatExportToken(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrChatExportTokenNotFound) {
writeErr(w, http.StatusNotFound, "لینک دانلود یافت نشد")
return
}
if errors.Is(err, store.ErrChatExportTokenInvalid) {
s.decorateChatExportToken(tok)
writeJSON(w, http.StatusOK, map[string]any{"token": tok, "alreadyInactive": true})
return
}
writeErr(w, http.StatusInternalServerError, "لغو لینک ممکن نشد")
return
}
s.decorateChatExportToken(tok)
observability.Activity("chat.export_token_revoke", "token_id", tok.ID, "admin_id", currentUser(r).ID)
writeJSON(w, http.StatusOK, map[string]any{"token": tok})
}
func (s *Server) handleExternalChatExport(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimSpace(r.PathValue("token"))
if raw == "" {
writeErr(w, http.StatusUnauthorized, "توکن لازم است")
return
}
tok, err := s.store.GetChatExportTokenByHash(r.Context(), store.HashInviteToken(raw))
if err != nil {
if errors.Is(err, store.ErrChatExportTokenNotFound) {
writeErr(w, http.StatusUnauthorized, "لینک نامعتبر یا منقضی است")
return
}
writeErr(w, http.StatusInternalServerError, "بررسی توکن ممکن نشد")
return
}
if err := store.ValidateChatExportToken(tok, time.Now().UTC()); err != nil {
writeErr(w, http.StatusUnauthorized, "لینک نامعتبر یا منقضی است")
return
}
// Consume before building the ZIP so a second concurrent hit cannot reuse the link.
if _, err := s.store.ConsumeChatExportToken(r.Context(), tok.ID); err != nil {
writeErr(w, http.StatusUnauthorized, "لینک نامعتبر یا منقضی است")
return
}
out, files, err := s.buildProjectChatExport(r)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return
}
observability.Activity("chat.export_external", "token_id", tok.ID, "conversations", len(out.Conversations), "files", len(files), "ip", clientAddress(r))
if err := s.writeChatExportZip(w, out, files); err != nil {
observability.L().Error("chat.export_zip", "error", err)
}
}