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 {
|
if err := httpapi.SetUIDKey(uidKey); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := store.SetUIDKey(uidKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
st, err := store.Open(cfg.MySQL)
|
st, err := store.Open(cfg.MySQL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -566,6 +566,8 @@ func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.P
|
|||||||
MIME string `json:"mime"`
|
MIME string `json:"mime"`
|
||||||
FileSize *int64 `json:"file_size"`
|
FileSize *int64 `json:"file_size"`
|
||||||
Size *int64 `json:"size"`
|
Size *int64 `json:"size"`
|
||||||
|
FileETag string `json:"file_etag"`
|
||||||
|
ETag string `json:"etag"`
|
||||||
}
|
}
|
||||||
if !decodeBody(w, r, &body) {
|
if !decodeBody(w, r, &body) {
|
||||||
return
|
return
|
||||||
@@ -576,7 +578,7 @@ func (a *API) meetingNoteFiles(w http.ResponseWriter, r *http.Request, p *auth.P
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, err := st.CreateMeetingNoteFile(r.Context(), p.UserID, meetingID, "mnote_"+randomToken(12), store.MeetingNoteFileInput{
|
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)
|
}, idempotencyKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreError(w, r, err)
|
writeStoreError(w, r, err)
|
||||||
@@ -605,6 +607,8 @@ func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p
|
|||||||
MIME string `json:"mime"`
|
MIME string `json:"mime"`
|
||||||
FileSize *int64 `json:"file_size"`
|
FileSize *int64 `json:"file_size"`
|
||||||
Size *int64 `json:"size"`
|
Size *int64 `json:"size"`
|
||||||
|
FileETag string `json:"file_etag"`
|
||||||
|
ETag string `json:"etag"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
}
|
}
|
||||||
if !decodeBody(w, r, &body) {
|
if !decodeBody(w, r, &body) {
|
||||||
@@ -616,7 +620,7 @@ func (a *API) meetingNoteFileResource(w http.ResponseWriter, r *http.Request, p
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, err := st.ReplaceMeetingNoteFile(r.Context(), p.UserID, meetingID, noteFileID, store.MeetingNoteFileInput{
|
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)
|
}, idempotencyKey, body.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreError(w, r, err)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"mime"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -14,6 +18,7 @@ import (
|
|||||||
"git.118im.com/im_team/im_workbench/internal/config"
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
"golang.org/x/crypto/xtea"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
@@ -23,6 +28,32 @@ type Store struct {
|
|||||||
tenantStoreMu sync.Mutex
|
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) {
|
func Open(cfg config.MySQLConfig) (*Store, error) {
|
||||||
st, err := openDSN(cfg.DSN)
|
st, err := openDSN(cfg.DSN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -603,19 +634,8 @@ func (s *Store) CreateMeetingNoteFile(ctx context.Context, userID int64, meeting
|
|||||||
}
|
}
|
||||||
now := time.Now().UTC().Round(time.Millisecond)
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
fileID := strings.TrimSpace(input.FileID)
|
fileID := strings.TrimSpace(input.FileID)
|
||||||
fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileName(input.FileName, fileID), strings.TrimSpace(input.MIMEType), int64(0), ""
|
meta := s.fileUploadMetadata(ctx, fileID)
|
||||||
if input.FileSize != nil && *input.FileSize > 0 {
|
fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileMetadata(input, meta, fileID)
|
||||||
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
|
|
||||||
}
|
|
||||||
_, 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)
|
_, 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)
|
VALUES(?,?,?,?,?,?,?,?,'active','processing','native','',1,?,?,?)`, noteFileID, meetingID, fileID, userID, fileName, mimeType, fileSize, fileETag, idempotencyKey, now, now)
|
||||||
if isDuplicate(err) && idempotencyKey != "" {
|
if isDuplicate(err) && idempotencyKey != "" {
|
||||||
@@ -646,19 +666,8 @@ func (s *Store) ReplaceMeetingNoteFile(ctx context.Context, userID int64, meetin
|
|||||||
return nil, ErrForbidden
|
return nil, ErrForbidden
|
||||||
}
|
}
|
||||||
fileID := strings.TrimSpace(input.FileID)
|
fileID := strings.TrimSpace(input.FileID)
|
||||||
fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileName(input.FileName, fileID), strings.TrimSpace(input.MIMEType), int64(0), ""
|
meta := s.fileUploadMetadata(ctx, fileID)
|
||||||
if input.FileSize != nil && *input.FileSize > 0 {
|
fileName, mimeType, fileSize, fileETag := normalizeMeetingNoteFileMetadata(input, meta, fileID)
|
||||||
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
|
|
||||||
}
|
|
||||||
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=?
|
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=?`,
|
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)
|
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)
|
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 {
|
func normalizeMeetingNoteFileName(fileName, fileID string) string {
|
||||||
fileName = strings.TrimSpace(strings.ReplaceAll(fileName, "\\", "/"))
|
fileName = strings.TrimSpace(strings.ReplaceAll(fileName, "\\", "/"))
|
||||||
fileName = filepath.Base(fileName)
|
fileName = filepath.Base(fileName)
|
||||||
@@ -689,6 +768,39 @@ func normalizeMeetingNoteFileName(fileName, fileID string) string {
|
|||||||
return string(runes[:255-len(extRunes)]) + ext
|
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) {
|
func (s *Store) DeleteMeetingNoteFile(ctx context.Context, userID int64, meetingID, noteFileID, idempotencyKey string, version int) (*MeetingNoteFile, error) {
|
||||||
meeting, err := s.Meeting(ctx, userID, meetingID)
|
meeting, err := s.Meeting(ctx, userID, meetingID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ type MeetingNoteFileInput struct {
|
|||||||
FileName string
|
FileName string
|
||||||
MIMEType string
|
MIMEType string
|
||||||
FileSize *int64
|
FileSize *int64
|
||||||
|
FileETag string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyReport struct {
|
type DailyReport struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user