620 lines
18 KiB
Go
620 lines
18 KiB
Go
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"`
|
||
|
|
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,
|
||
|
|
}
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
}
|