From ef2967b056238733fe6d7eb03773ce83a1020132 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 18 Sep 2026 16:26:06 +0800 Subject: [PATCH] fix: hydrate meeting note file metadata --- internal/app/app.go | 3 + internal/httpapi/api.go | 8 +- internal/store/meeting_note_file_test.go | 52 +++++++ internal/store/mysql.go | 164 +++++++++++++++++++---- internal/store/types.go | 1 + 5 files changed, 200 insertions(+), 28 deletions(-) create mode 100644 internal/store/meeting_note_file_test.go diff --git a/internal/app/app.go b/internal/app/app.go index af7794f..a7ffd52 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -27,6 +27,9 @@ func New(cfg *config.Config) (*App, error) { if err := httpapi.SetUIDKey(uidKey); err != nil { return nil, err } + if err := store.SetUIDKey(uidKey); err != nil { + return nil, err + } st, err := store.Open(cfg.MySQL) if err != nil { return nil, err diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index 4986596..e2bd9fe 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -566,6 +566,8 @@ func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.P MIME string `json:"mime"` FileSize *int64 `json:"file_size"` Size *int64 `json:"size"` + FileETag string `json:"file_etag"` + ETag string `json:"etag"` } if !decodeBody(w, r, &body) { return @@ -576,7 +578,7 @@ func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.P return } file, err := st.CreateMeetingNoteFile(r.Context(), p.UserID, meetingID, "mnote_"+randomToken(12), store.MeetingNoteFileInput{ - FileID: fileID, FileName: firstNonEmpty(body.FileName, body.Name), MIMEType: firstNonEmpty(body.MIMEType, body.MIME), FileSize: firstNonNilInt64(body.FileSize, body.Size), + FileID: fileID, FileName: firstNonEmpty(body.FileName, body.Name), MIMEType: firstNonEmpty(body.MIMEType, body.MIME), FileSize: firstNonNilInt64(body.FileSize, body.Size), FileETag: firstNonEmpty(body.FileETag, body.ETag), }, idempotencyKey) if err != nil { writeStoreError(w, r, err) @@ -605,6 +607,8 @@ func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p MIME string `json:"mime"` FileSize *int64 `json:"file_size"` Size *int64 `json:"size"` + FileETag string `json:"file_etag"` + ETag string `json:"etag"` Version int `json:"version"` } if !decodeBody(w, r, &body) { @@ -616,7 +620,7 @@ func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p return } file, err := st.ReplaceMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, store.MeetingNoteFileInput{ - FileID: fileID, FileName: firstNonEmpty(body.FileName, body.Name), MIMEType: firstNonEmpty(body.MIMEType, body.MIME), FileSize: firstNonNilInt64(body.FileSize, body.Size), + FileID: fileID, FileName: firstNonEmpty(body.FileName, body.Name), MIMEType: firstNonEmpty(body.MIMEType, body.MIME), FileSize: firstNonNilInt64(body.FileSize, body.Size), FileETag: firstNonEmpty(body.FileETag, body.ETag), }, idempotencyKey, body.Version) if err != nil { writeStoreError(w, r, err) diff --git a/internal/store/meeting_note_file_test.go b/internal/store/meeting_note_file_test.go new file mode 100644 index 0000000..312cdd1 --- /dev/null +++ b/internal/store/meeting_note_file_test.go @@ -0,0 +1,52 @@ +package store + +import "testing" + +func TestNormalizeMeetingNoteFileMetadataUsesUploadMetadata(t *testing.T) { + gotName, gotMime, gotSize, gotETag := normalizeMeetingNoteFileMetadata(MeetingNoteFileInput{ + FileID: "lxzvtUzicEo", + }, fileUploadMetadata{ + MIMEType: "application/pdf", + FileSize: 12345, + FileETag: "etag-1", + Location: "uploads/wontuse", + }, "lxzvtUzicEo") + if gotName != "lxzvtUzicEo.pdf" { + t.Fatalf("name=%q, want lxzvtUzicEo.pdf", gotName) + } + if gotMime != "application/pdf" || gotSize != 12345 || gotETag != "etag-1" { + t.Fatalf("metadata mime=%q size=%d etag=%q", gotMime, gotSize, gotETag) + } +} + +func TestNormalizeMeetingNoteFileMetadataKeepsRequestMetadata(t *testing.T) { + size := int64(88) + gotName, gotMime, gotSize, gotETag := normalizeMeetingNoteFileMetadata(MeetingNoteFileInput{ + FileID: "file_1", + FileName: "../会议纪要.docx", + MIMEType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + FileSize: &size, + FileETag: "request-etag", + }, fileUploadMetadata{ + MIMEType: "application/pdf", + FileSize: 12345, + FileETag: "upload-etag", + Location: "uploads/file_1.pdf", + }, "file_1") + if gotName != "会议纪要.docx" { + t.Fatalf("name=%q, want original file name", gotName) + } + if gotMime != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || gotSize != 88 || gotETag != "request-etag" { + t.Fatalf("metadata mime=%q size=%d etag=%q", gotMime, gotSize, gotETag) + } +} + +func TestParseUploadFileIDAcceptsExtension(t *testing.T) { + decodedID, storageName := parseUploadFileID("lxzvtUzicEo.pdf") + if decodedID <= 0 { + t.Fatalf("decodedID=%d, want positive", decodedID) + } + if storageName == "" { + t.Fatal("storageName is empty") + } +} diff --git a/internal/store/mysql.go b/internal/store/mysql.go index b6e3bc7..b4f932e 100644 --- a/internal/store/mysql.go +++ b/internal/store/mysql.go @@ -3,9 +3,13 @@ package store import ( "context" "database/sql" + "encoding/base32" + "encoding/base64" + "encoding/binary" "encoding/json" "errors" "fmt" + "mime" "path/filepath" "strings" "sync" @@ -14,6 +18,7 @@ import ( "git.118im.com/im_team/im_workbench/internal/config" mysqlDriver "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" + "golang.org/x/crypto/xtea" ) type Store struct { @@ -23,6 +28,32 @@ type Store struct { tenantStoreMu sync.Mutex } +var ( + uidCipherMu sync.RWMutex + uidCipher *xtea.Cipher +) + +func init() { + if err := SetUIDKey("dkXxWLwqUNRfYmdXRzOkZw=="); err != nil { + panic(err) + } +} + +func SetUIDKey(encoded string) error { + key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return fmt.Errorf("invalid uid key: %w", err) + } + cipher, err := xtea.NewCipher(key) + if err != nil { + return fmt.Errorf("invalid uid key: %w", err) + } + uidCipherMu.Lock() + uidCipher = cipher + uidCipherMu.Unlock() + return nil +} + func Open(cfg config.MySQLConfig) (*Store, error) { st, err := openDSN(cfg.DSN) if err != nil { @@ -603,19 +634,8 @@ func (s *Store) CreateMeetingNoteFile(ctx context.Context, userID int64, meeting } now := time.Now().UTC().Round(time.Millisecond) fileID := strings.TrimSpace(input.FileID) - fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileName(input.FileName, fileID), strings.TrimSpace(input.MIMEType), int64(0), "" - if input.FileSize != nil && *input.FileSize > 0 { - fileSize = *input.FileSize - } - var uploadMime string - var uploadSize int64 - _ = s.db.QueryRowxContext(ctx, `SELECT IFNULL(mimetype,''),IFNULL(size,0),IFNULL(etag,'') FROM im_fileuploads WHERE id=? LIMIT 1`, fileID).Scan(&uploadMime, &uploadSize, &fileETag) - if mimeType == "" { - mimeType = uploadMime - } - if fileSize <= 0 { - fileSize = uploadSize - } + meta := s.fileUploadMetadata(ctx, fileID) + fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileMetadata(input, meta, fileID) _, err = s.db.ExecContext(ctx, `INSERT INTO im_meeting_note_files(note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,idempotency_key,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,'active','processing','native','',1,?,?,?)`, noteFileID, meetingID, fileID, userID, fileName, mimeType, fileSize, fileETag, idempotencyKey, now, now) if isDuplicate(err) && idempotencyKey != "" { @@ -646,19 +666,8 @@ func (s *Store) ReplaceMeetingNoteFile(ctx context.Context, userID int64, meetin return nil, ErrForbidden } fileID := strings.TrimSpace(input.FileID) - fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileName(input.FileName, fileID), strings.TrimSpace(input.MIMEType), int64(0), "" - if input.FileSize != nil && *input.FileSize > 0 { - fileSize = *input.FileSize - } - var uploadMime string - var uploadSize int64 - _ = s.db.QueryRowxContext(ctx, `SELECT IFNULL(mimetype,''),IFNULL(size,0),IFNULL(etag,'') FROM im_fileuploads WHERE id=? LIMIT 1`, fileID).Scan(&uploadMime, &uploadSize, &fileETag) - if mimeType == "" { - mimeType = uploadMime - } - if fileSize <= 0 { - fileSize = uploadSize - } + meta := s.fileUploadMetadata(ctx, fileID) + fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileMetadata(input, meta, fileID) res, err := s.db.ExecContext(ctx, `UPDATE im_meeting_note_files SET file_id=?,file_name=?,mime_type=?,file_size=?,file_etag=?,preview_status='processing',preview_file_id='',version=version+1,idempotency_key=?,updated_at=? WHERE meeting_id=? AND note_file_id=? AND status='active' AND version=?`, fileID, fileName, mimeType, fileSize, fileETag, idempotencyKey, time.Now().UTC().Round(time.Millisecond), meetingID, noteFileID, version) @@ -671,6 +680,76 @@ func (s *Store) ReplaceMeetingNoteFile(ctx context.Context, userID int64, meetin return s.meetingNoteFile(ctx, meetingID, noteFileID) } +type fileUploadMetadata struct { + MIMEType string `db:"mimetype"` + FileSize int64 `db:"size"` + FileETag string `db:"etag"` + Location string `db:"location"` +} + +func (s *Store) fileUploadMetadata(ctx context.Context, fileID string) fileUploadMetadata { + var meta fileUploadMetadata + decodedID, storageName := parseUploadFileID(fileID) + if decodedID <= 0 && storageName == "" { + return meta + } + if decodedID > 0 { + err := s.db.GetContext(ctx, &meta, `SELECT IFNULL(mimetype,'') AS mimetype,IFNULL(size,0) AS size,IFNULL(etag,'') AS etag,IFNULL(location,'') AS location FROM im_fileuploads WHERE id=? LIMIT 1`, decodedID) + if err == nil { + return meta + } + } + if storageName == "" { + return meta + } + _ = s.db.GetContext(ctx, &meta, `SELECT IFNULL(mimetype,'') AS mimetype,IFNULL(size,0) AS size,IFNULL(etag,'') AS etag,IFNULL(location,'') AS location + FROM im_fileuploads WHERE location=? OR location LIKE ? ORDER BY createdat DESC LIMIT 1`, storageName, "%/"+storageName) + return meta +} + +func parseUploadFileID(fileID string) (int64, string) { + name := strings.TrimSpace(fileID) + if dot := strings.LastIndexByte(name, '.'); dot > 0 { + ext := name[dot+1:] + if len(ext) == 0 || len(ext) > 16 { + return 0, "" + } + for _, r := range ext { + if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && (r < '0' || r > '9') { + return 0, "" + } + } + name = name[:dot] + } + decoded, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(name) + if err != nil || len(decoded) < 8 { + return 0, "" + } + storageData := make([]byte, 8) + binary.LittleEndian.PutUint64(storageData, binary.LittleEndian.Uint64(decoded[:8])) + storageName := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(storageData)) + + plain := make([]byte, 8) + uidCipherMu.RLock() + cipher := uidCipher + uidCipherMu.RUnlock() + cipher.Decrypt(plain, decoded[:8]) + return int64(binary.LittleEndian.Uint64(plain)), storageName +} + +func normalizeMeetingNoteFileMetadata(input MeetingNoteFileInput, meta fileUploadMetadata, fileID string) (string, string, int64, string) { + mimeType := firstNonEmpty(input.MIMEType, meta.MIMEType) + fileSize := int64(0) + if input.FileSize != nil && *input.FileSize > 0 { + fileSize = *input.FileSize + } else if meta.FileSize > 0 { + fileSize = meta.FileSize + } + fileETag := firstNonEmpty(input.FileETag, meta.FileETag) + fileName := normalizeMeetingNoteFileName(firstNonEmpty(input.FileName, fileNameFromLocation(meta.Location), fallbackFileName(fileID, mimeType)), fileID) + return fileName, strings.TrimSpace(mimeType), fileSize, strings.TrimSpace(fileETag) +} + func normalizeMeetingNoteFileName(fileName, fileID string) string { fileName = strings.TrimSpace(strings.ReplaceAll(fileName, "\\", "/")) fileName = filepath.Base(fileName) @@ -689,6 +768,39 @@ func normalizeMeetingNoteFileName(fileName, fileID string) string { return string(runes[:255-len(extRunes)]) + ext } +func fileNameFromLocation(location string) string { + name := strings.TrimSpace(strings.ReplaceAll(location, "\\", "/")) + name = filepath.Base(name) + if name == "." || name == "/" { + return "" + } + if filepath.Ext(name) == "" { + return "" + } + return name +} + +func fallbackFileName(fileID, mimeType string) string { + fileID = strings.TrimSpace(fileID) + if filepath.Ext(fileID) != "" || strings.TrimSpace(mimeType) == "" { + return fileID + } + exts, _ := mime.ExtensionsByType(strings.TrimSpace(mimeType)) + if len(exts) == 0 { + return fileID + } + return fileID + strings.ToLower(exts[0]) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + func (s *Store) DeleteMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID, idempotencyKey string, version int) (*MeetingNoteFile, error) { meeting, err := s.Meeting(ctx, userID, meetingID) if err != nil { diff --git a/internal/store/types.go b/internal/store/types.go index 4965268..475a560 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -170,6 +170,7 @@ type MeetingNoteFileInput struct { FileName string MIMEType string FileSize *int64 + FileETag string } type DailyReport struct {