fix: hydrate meeting note file metadata
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+138
-26
@@ -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 {
|
||||
|
||||
@@ -170,6 +170,7 @@ type MeetingNoteFileInput struct {
|
||||
FileName string
|
||||
MIMEType string
|
||||
FileSize *int64
|
||||
FileETag string
|
||||
}
|
||||
|
||||
type DailyReport struct {
|
||||
|
||||
Reference in New Issue
Block a user