diff --git a/server/api/blocks.go b/server/api/blocks.go index 89b7127a4..1d9f83e45 100644 --- a/server/api/blocks.go +++ b/server/api/blocks.go @@ -303,7 +303,7 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) { // this query param exists when creating template from board, or board from template sourceBoardID := r.URL.Query().Get("sourceBoardID") if sourceBoardID != "" { - if updateFileIDsErr := a.app.CopyCardFiles(sourceBoardID, blocks); updateFileIDsErr != nil { + if updateFileIDsErr := a.app.CopyAndUpdateCardFiles(sourceBoardID, userID, blocks, false); updateFileIDsErr != nil { a.errorResponse(w, r, updateFileIDsErr) return } diff --git a/server/api/files.go b/server/api/files.go index 2868e5507..609a9e53b 100644 --- a/server/api/files.go +++ b/server/api/files.go @@ -393,7 +393,7 @@ func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) { auditRec.AddMeta("teamID", board.TeamID) auditRec.AddMeta("filename", handle.Filename) - fileID, err := a.app.SaveFile(file, board.TeamID, boardID, handle.Filename) + fileID, err := a.app.SaveFile(file, board.TeamID, boardID, handle.Filename, board.IsTemplate) if err != nil { a.errorResponse(w, r, err) return diff --git a/server/app/blocks.go b/server/app/blocks.go index 3ed9d39d4..41fd233a8 100644 --- a/server/app/blocks.go +++ b/server/app/blocks.go @@ -4,11 +4,9 @@ import ( "errors" "fmt" "path/filepath" - "strings" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/notify" - "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost-server/v6/shared/mlog" ) @@ -45,6 +43,11 @@ func (a *App) DuplicateBlock(boardID string, blockID string, userID string, asTe return nil, err } + err = a.CopyAndUpdateCardFiles(boardID, userID, blocks, asTemplate) + if err != nil { + return nil, err + } + a.blockChangeNotifier.Enqueue(func() error { for _, block := range blocks { a.wsAdapter.BroadcastBlockChange(board.TeamID, block) @@ -292,95 +295,6 @@ func (a *App) InsertBlocksAndNotify(blocks []*model.Block, modifiedByID string, return blocks, nil } -func (a *App) CopyCardFiles(sourceBoardID string, copiedBlocks []*model.Block) error { - // Images attached in cards have a path comprising the card's board ID. - // When we create a template from this board, we need to copy the files - // with the new board ID in path. - // Not doing so causing images in templates (and boards created from this - // template) to fail to load. - - // look up ID of source sourceBoard, which may be different than the blocks. - sourceBoard, err := a.GetBoard(sourceBoardID) - if err != nil || sourceBoard == nil { - return fmt.Errorf("cannot fetch source board %s for CopyCardFiles: %w", sourceBoardID, err) - } - - var destTeamID string - var destBoardID string - - for i := range copiedBlocks { - block := copiedBlocks[i] - fileName := "" - isOk := false - - switch block.Type { - case model.TypeImage: - fileName, isOk = block.Fields["fileId"].(string) - if !isOk || fileName == "" { - continue - } - case model.TypeAttachment: - fileName, isOk = block.Fields["attachmentId"].(string) - if !isOk || fileName == "" { - continue - } - default: - continue - } - - // create unique filename in case we are copying cards within the same board. - ext := filepath.Ext(fileName) - destFilename := utils.NewID(utils.IDTypeNone) + ext - - if destBoardID == "" || block.BoardID != destBoardID { - destBoardID = block.BoardID - destBoard, err := a.GetBoard(destBoardID) - if err != nil { - return fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err) - } - destTeamID = destBoard.TeamID - } - - sourceFilePath := filepath.Join(sourceBoard.TeamID, sourceBoard.ID, fileName) - destinationFilePath := filepath.Join(destTeamID, block.BoardID, destFilename) - - a.logger.Debug( - "Copying card file", - mlog.String("sourceFilePath", sourceFilePath), - mlog.String("destinationFilePath", destinationFilePath), - ) - - if err := a.filesBackend.CopyFile(sourceFilePath, destinationFilePath); err != nil { - a.logger.Error( - "CopyCardFiles failed to copy file", - mlog.String("sourceFilePath", sourceFilePath), - mlog.String("destinationFilePath", destinationFilePath), - mlog.Err(err), - ) - } - if block.Type == model.TypeAttachment { - block.Fields["attachmentId"] = destFilename - parts := strings.Split(fileName, ".") - fileInfoID := parts[0][1:] - fileInfo, err := a.store.GetFileInfo(fileInfoID) - if err != nil { - return fmt.Errorf("CopyCardFiles: cannot retrieve original fileinfo: %w", err) - } - newParts := strings.Split(destFilename, ".") - newFileID := newParts[0][1:] - fileInfo.Id = newFileID - err = a.store.SaveFileInfo(fileInfo) - if err != nil { - return fmt.Errorf("CopyCardFiles: cannot create fileinfo: %w", err) - } - } else { - block.Fields["fileId"] = destFilename - } - } - - return nil -} - func (a *App) GetBlockByID(blockID string) (*model.Block, error) { return a.store.GetBlock(blockID) } diff --git a/server/app/boards.go b/server/app/boards.go index d31bd573c..416e69891 100644 --- a/server/app/boards.go +++ b/server/app/boards.go @@ -185,8 +185,13 @@ func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (* } // copy any file attachments from the duplicated blocks. - if err = a.CopyCardFiles(boardID, bab.Blocks); err != nil { - a.logger.Error("Could not copy files while duplicating board", mlog.String("BoardID", boardID), mlog.Err(err)) + err = a.CopyAndUpdateCardFiles(boardID, userID, bab.Blocks, asTemplate) + if err != nil { + dbab := model.NewDeleteBoardsAndBlocksFromBabs(bab) + if dErr := a.store.DeleteBoardsAndBlocks(dbab, userID); dErr != nil { + a.logger.Error("Cannot delete board after duplication error when updating block's file info", mlog.String("boardID", bab.Boards[0].ID), mlog.Err(dErr)) + } + return nil, nil, fmt.Errorf("could not patch file IDs while duplicating board %s: %w", boardID, err) } if !asTemplate { @@ -197,44 +202,6 @@ func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (* } } - // bab.Blocks now has updated file ids for any blocks containing files. We need to store them. - blockIDs := make([]string, 0) - blockPatches := make([]model.BlockPatch, 0) - - for _, block := range bab.Blocks { - fieldName := "" - if block.Type == model.TypeImage { - fieldName = "fileId" - } else if block.Type == model.TypeAttachment { - fieldName = "attachmentId" - } - if fieldName != "" { - if fieldID, ok := block.Fields[fieldName]; ok { - blockIDs = append(blockIDs, block.ID) - blockPatches = append(blockPatches, model.BlockPatch{ - UpdatedFields: map[string]interface{}{ - fieldName: fieldID, - }, - }) - } - } - } - a.logger.Debug("Duplicate boards patching file IDs", mlog.Int("count", len(blockIDs))) - - if len(blockIDs) != 0 { - patches := &model.BlockPatchBatch{ - BlockIDs: blockIDs, - BlockPatches: blockPatches, - } - if err = a.store.PatchBlocks(patches, userID); err != nil { - dbab := model.NewDeleteBoardsAndBlocksFromBabs(bab) - if err = a.store.DeleteBoardsAndBlocks(dbab, userID); err != nil { - a.logger.Error("Cannot delete board after duplication error when updating block's file info", mlog.String("boardID", bab.Boards[0].ID), mlog.Err(err)) - } - return nil, nil, fmt.Errorf("could not patch file IDs while duplicating board %s: %w", boardID, err) - } - } - a.blockChangeNotifier.Enqueue(func() error { teamID := "" for _, board := range bab.Boards { diff --git a/server/app/export.go b/server/app/export.go index 81cf07e71..bd110ac92 100644 --- a/server/app/export.go +++ b/server/app/export.go @@ -91,10 +91,10 @@ func (a *App) writeArchiveBoard(zw *zip.Writer, board model.Board, opt model.Exp if err = a.writeArchiveBlockLine(w, block); err != nil { return err } - if block.Type == model.TypeImage { - filename, err2 := extractImageFilename(block) + if block.Type == model.TypeImage || block.Type == model.TypeAttachment { + filename, err2 := extractFilename(block) if err2 != nil { - return err + return err2 } files = append(files, filename) } @@ -204,7 +204,10 @@ func (a *App) writeArchiveFile(zw *zip.Writer, filename string, boardID string, return err } - src, err := a.GetFileReader(opt.TeamID, boardID, filename) + _, fileReader, err := a.GetFile(opt.TeamID, boardID, filename) + if err != nil && !model.IsErrNotFound(err) { + return err + } if err != nil { // just log this; image file is missing but we'll still export an equivalent board a.logger.Error("image file missing for export", @@ -214,9 +217,9 @@ func (a *App) writeArchiveFile(zw *zip.Writer, filename string, boardID string, ) return nil } - defer src.Close() + defer fileReader.Close() - _, err = io.Copy(dest, src) + _, err = io.Copy(dest, fileReader) return err } @@ -235,10 +238,13 @@ func (a *App) getBoardsForArchive(boardIDs []string) ([]model.Board, error) { return boards, nil } -func extractImageFilename(imageBlock *model.Block) (string, error) { - f, ok := imageBlock.Fields["fileId"] +func extractFilename(block *model.Block) (string, error) { + f, ok := block.Fields["fileId"] if !ok { - return "", model.ErrInvalidImageBlock + f, ok = block.Fields["attachmentId"] + if !ok { + return "", model.ErrInvalidImageBlock + } } filename, ok := f.(string) diff --git a/server/app/files.go b/server/app/files.go index 1474a291a..b45726756 100644 --- a/server/app/files.go +++ b/server/app/files.go @@ -8,19 +8,17 @@ import ( "strings" "github.com/mattermost/focalboard/server/model" - mmModel "github.com/mattermost/mattermost-server/v6/model" + mm_model "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/mattermost-server/v6/shared/filestore" "github.com/mattermost/mattermost-server/v6/shared/mlog" ) -const emptyString = "empty" - var errEmptyFilename = errors.New("IsFileArchived: empty filename not allowed") var ErrFileNotFound = errors.New("file not found") -func (a *App) SaveFile(reader io.Reader, teamID, rootID, filename string) (string, error) { +func (a *App) SaveFile(reader io.Reader, teamID, boardID, filename string, asTemplate bool) (string, error) { // NOTE: File extension includes the dot fileExtension := strings.ToLower(filepath.Ext(filename)) if fileExtension == ".jpeg" { @@ -28,48 +26,31 @@ func (a *App) SaveFile(reader io.Reader, teamID, rootID, filename string) (strin } createdFilename := utils.NewID(utils.IDTypeNone) - fullFilename := fmt.Sprintf(`%s%s`, createdFilename, fileExtension) - filePath := filepath.Join(utils.GetBaseFilePath(), fullFilename) + newFileName := fmt.Sprintf(`%s%s`, createdFilename, fileExtension) + if asTemplate { + newFileName = filename + } + filePath := getDestinationFilePath(asTemplate, teamID, boardID, newFileName) fileSize, appErr := a.filesBackend.WriteFile(reader, filePath) if appErr != nil { return "", fmt.Errorf("unable to store the file in the files storage: %w", appErr) } - now := utils.GetMillis() - - fileInfo := &mmModel.FileInfo{ - Id: createdFilename[1:], - CreatorId: "boards", - PostId: emptyString, - ChannelId: emptyString, - CreateAt: now, - UpdateAt: now, - DeleteAt: 0, - Path: filePath, - ThumbnailPath: emptyString, - PreviewPath: emptyString, - Name: filename, - Extension: fileExtension, - Size: fileSize, - MimeType: emptyString, - Width: 0, - Height: 0, - HasPreviewImage: false, - MiniPreview: nil, - Content: "", - RemoteId: nil, - } + fileInfo := model.NewFileInfo(filename) + fileInfo.Id = getFileInfoID(createdFilename) + fileInfo.Path = filePath + fileInfo.Size = fileSize err := a.store.SaveFileInfo(fileInfo) if err != nil { return "", err } - return fullFilename, nil + return newFileName, nil } -func (a *App) GetFileInfo(filename string) (*mmModel.FileInfo, error) { +func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) { if len(filename) == 0 { return nil, errEmptyFilename } @@ -77,8 +58,7 @@ func (a *App) GetFileInfo(filename string) (*mmModel.FileInfo, error) { // filename is in the format 7. // we want to extract the part of this as this // will be the fileinfo id. - parts := strings.Split(filename, ".") - fileInfoID := parts[0][1:] + fileInfoID := getFileInfoID(strings.Split(filename, ".")[0]) fileInfo, err := a.store.GetFileInfo(fileInfoID) if err != nil { @@ -88,11 +68,36 @@ func (a *App) GetFileInfo(filename string) (*mmModel.FileInfo, error) { return fileInfo, nil } -func (a *App) GetFile(teamID, rootID, fileName string) (*mmModel.FileInfo, filestore.ReadCloseSeeker, error) { +func (a *App) GetFile(teamID, rootID, fileName string) (*mm_model.FileInfo, filestore.ReadCloseSeeker, error) { + fileInfo, filePath, err := a.GetFilePath(teamID, rootID, fileName) + if err != nil { + a.logger.Error("GetFile: Failed to GetFilePath.", mlog.String("Team", teamID), mlog.String("board", rootID), mlog.String("filename", fileName), mlog.Err(err)) + return nil, nil, err + } + + exists, err := a.filesBackend.FileExists(filePath) + if err != nil { + a.logger.Error("GetFile: Failed to check if file exists as path. ", mlog.String("Path", filePath), mlog.Err(err)) + return nil, nil, err + } + + if !exists { + return nil, nil, ErrFileNotFound + } + + reader, err := a.filesBackend.Reader(filePath) + if err != nil { + a.logger.Error("GetFile: Failed to get file reader of existing file at path", mlog.String("Path", filePath), mlog.Err(err)) + return nil, nil, err + } + + return fileInfo, reader, nil +} + +func (a *App) GetFilePath(teamID, rootID, fileName string) (*mm_model.FileInfo, string, error) { fileInfo, err := a.GetFileInfo(fileName) if err != nil && !model.IsErrNotFound(err) { - a.logger.Error("111") - return nil, nil, err + return nil, "", err } var filePath string @@ -103,23 +108,23 @@ func (a *App) GetFile(teamID, rootID, fileName string) (*mmModel.FileInfo, files filePath = filepath.Join(teamID, rootID, fileName) } - exists, err := a.filesBackend.FileExists(filePath) - if err != nil { - a.logger.Error(fmt.Sprintf("GetFile: Failed to check if file exists as path. Path: %s, error: %e", filePath, err)) - return nil, nil, err - } + return fileInfo, filePath, nil +} - if !exists { - return nil, nil, ErrFileNotFound +func getDestinationFilePath(isTemplate bool, teamID, boardID, filename string) string { + // if saving a file for a template, save using the "old method" that is /teamID/boardID/fileName + // this will prevent template files from being deleted by DataRetention, + // which deletes all files inside the "date" subdirectory + if isTemplate { + return filepath.Join(teamID, boardID, filename) } + return filepath.Join(utils.GetBaseFilePath(), filename) +} - reader, err := a.filesBackend.Reader(filePath) - if err != nil { - a.logger.Error(fmt.Sprintf("GetFile: Failed to get file reader of existing file at path: %s, error: %e", filePath, err)) - return nil, nil, err - } - - return fileInfo, reader, nil +func getFileInfoID(fileName string) string { + // Boards ids are 27 characters long with a prefix character. + // removing the prefix, returns the 26 character uuid + return fileName[1:] } func (a *App) GetFileReader(teamID, rootID, filename string) (filestore.ReadCloseSeeker, error) { @@ -175,3 +180,120 @@ func (a *App) MoveFile(channelID, teamID, boardID, filename string) error { } return nil } +func (a *App) CopyAndUpdateCardFiles(boardID, userID string, blocks []*model.Block, asTemplate bool) error { + newFileNames, err := a.CopyCardFiles(boardID, blocks, asTemplate) + if err != nil { + a.logger.Error("Could not copy files while duplicating board", mlog.String("BoardID", boardID), mlog.Err(err)) + } + + // blocks now has updated file ids for any blocks containing files. We need to update the database for them. + blockIDs := make([]string, 0) + blockPatches := make([]model.BlockPatch, 0) + for _, block := range blocks { + if block.Type == model.TypeImage || block.Type == model.TypeAttachment { + if fileID, ok := block.Fields["fileId"].(string); ok { + blockIDs = append(blockIDs, block.ID) + blockPatches = append(blockPatches, model.BlockPatch{ + UpdatedFields: map[string]interface{}{ + "fileId": newFileNames[fileID], + }, + DeletedFields: []string{"attachmentId"}, + }) + } + } + } + a.logger.Debug("Duplicate boards patching file IDs", mlog.Int("count", len(blockIDs))) + + if len(blockIDs) != 0 { + patches := &model.BlockPatchBatch{ + BlockIDs: blockIDs, + BlockPatches: blockPatches, + } + if err := a.store.PatchBlocks(patches, userID); err != nil { + return fmt.Errorf("could not patch file IDs while duplicating board %s: %w", boardID, err) + } + } + + return nil +} + +func (a *App) CopyCardFiles(sourceBoardID string, copiedBlocks []*model.Block, asTemplate bool) (map[string]string, error) { + // Images attached in cards have a path comprising the card's board ID. + // When we create a template from this board, we need to copy the files + // with the new board ID in path. + // Not doing so causing images in templates (and boards created from this + // template) to fail to load. + + // look up ID of source sourceBoard, which may be different than the blocks. + sourceBoard, err := a.GetBoard(sourceBoardID) + if err != nil || sourceBoard == nil { + return nil, fmt.Errorf("cannot fetch source board %s for CopyCardFiles: %w", sourceBoardID, err) + } + + var destBoard *model.Board + newFileNames := make(map[string]string) + for _, block := range copiedBlocks { + if block.Type != model.TypeImage && block.Type != model.TypeAttachment { + continue + } + + fileID, isOk := block.Fields["fileId"].(string) + if !isOk { + fileID, isOk = block.Fields["attachmentId"].(string) + if !isOk { + continue + } + } + + // create unique filename + ext := filepath.Ext(fileID) + fileInfoID := utils.NewID(utils.IDTypeNone) + destFilename := fileInfoID + ext + + if destBoard == nil || block.BoardID != destBoard.ID { + destBoard = sourceBoard + if block.BoardID != destBoard.ID { + destBoard, err = a.GetBoard(block.BoardID) + if err != nil { + return nil, fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err) + } + } + } + + // GetFilePath will retrieve the correct path + // depending on whether FileInfo table is used for the file. + fileInfo, sourceFilePath, err := a.GetFilePath(sourceBoard.TeamID, sourceBoard.ID, fileID) + if err != nil { + return nil, fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err) + } + destinationFilePath := getDestinationFilePath(asTemplate, destBoard.TeamID, destBoard.ID, destFilename) + + if fileInfo == nil { + fileInfo = model.NewFileInfo(destFilename) + } + fileInfo.Id = getFileInfoID(fileInfoID) + fileInfo.Path = destinationFilePath + err = a.store.SaveFileInfo(fileInfo) + if err != nil { + return nil, fmt.Errorf("CopyCardFiles: cannot create fileinfo: %w", err) + } + + a.logger.Debug( + "Copying card file", + mlog.String("sourceFilePath", sourceFilePath), + mlog.String("destinationFilePath", destinationFilePath), + ) + + if err := a.filesBackend.CopyFile(sourceFilePath, destinationFilePath); err != nil { + a.logger.Error( + "CopyCardFiles failed to copy file", + mlog.String("sourceFilePath", sourceFilePath), + mlog.String("destinationFilePath", destinationFilePath), + mlog.Err(err), + ) + } + newFileNames[fileID] = destFilename + } + + return newFileNames, nil +} diff --git a/server/app/files_test.go b/server/app/files_test.go index b39327f7b..73120b205 100644 --- a/server/app/files_test.go +++ b/server/app/files_test.go @@ -12,7 +12,8 @@ import ( "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" - mmModel "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/focalboard/server/model" + mm_model "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock" "github.com/mattermost/mattermost-server/v6/shared/filestore" "github.com/mattermost/mattermost-server/v6/shared/filestore/mocks" @@ -21,6 +22,7 @@ import ( const ( testFileName = "temp-file-name" testBoardID = "test-board-id" + testPath = "/path/to/file/fileName.txt" ) var errDummy = errors.New("hello") @@ -207,7 +209,7 @@ func TestSaveFile(t *testing.T) { } mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc) - actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", testBoardID, fileName) + actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", testBoardID, fileName, false) assert.Equal(t, fileName, actual) assert.Nil(t, err) }) @@ -231,7 +233,7 @@ func TestSaveFile(t *testing.T) { } mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc) - actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName) + actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName, false) assert.Nil(t, err) assert.NotNil(t, actual) }) @@ -255,7 +257,7 @@ func TestSaveFile(t *testing.T) { } mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc) - actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName) + actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName, false) assert.Equal(t, "", actual) assert.Equal(t, "unable to store the file in the files storage: Mocked File backend error", err.Error()) }) @@ -265,7 +267,7 @@ func TestGetFileInfo(t *testing.T) { th, _ := SetupTestHelper(t) t.Run("should return file info", func(t *testing.T) { - fileInfo := &mmModel.FileInfo{ + fileInfo := &mm_model.FileInfo{ Id: "file_info_id", Archived: false, } @@ -284,7 +286,7 @@ func TestGetFileInfo(t *testing.T) { }) t.Run("should return archived file info", func(t *testing.T) { - fileInfo := &mmModel.FileInfo{ + fileInfo := &mm_model.FileInfo{ Id: "file_info_id", Archived: true, } @@ -308,11 +310,10 @@ func TestGetFileInfo(t *testing.T) { func TestGetFile(t *testing.T) { th, _ := SetupTestHelper(t) - - t.Run("when FileInfo exists", func(t *testing.T) { - th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mmModel.FileInfo{ + t.Run("happy path, no errors", func(t *testing.T) { + th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ Id: "fileInfoID", - Path: "/path/to/file/fileName.txt", + Path: testPath, }, nil) mockedFileBackend := &mocks.FileBackend{} @@ -325,8 +326,8 @@ func TestGetFile(t *testing.T) { readerErrorFunc := func(path string) error { return nil } - mockedFileBackend.On("Reader", "/path/to/file/fileName.txt").Return(readerFunc, readerErrorFunc) - mockedFileBackend.On("FileExists", "/path/to/file/fileName.txt").Return(true, nil) + mockedFileBackend.On("Reader", testPath).Return(readerFunc, readerErrorFunc) + mockedFileBackend.On("FileExists", testPath).Return(true, nil) fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt") assert.NoError(t, err) @@ -334,51 +335,222 @@ func TestGetFile(t *testing.T) { assert.NotNil(t, seeker) }) + t.Run("when GetFilePath() throws error", func(t *testing.T) { + th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, errDummy) + + fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt") + assert.Error(t, err) + assert.Nil(t, fileInfo) + assert.Nil(t, seeker) + }) + + t.Run("when FileExists returns false", func(t *testing.T) { + th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ + Id: "fileInfoID", + Path: testPath, + }, nil) + + mockedFileBackend := &mocks.FileBackend{} + th.App.filesBackend = mockedFileBackend + mockedFileBackend.On("FileExists", testPath).Return(false, nil) + + fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt") + assert.Error(t, err) + assert.Nil(t, fileInfo) + assert.Nil(t, seeker) + }) + t.Run("when FileReader throws error", func(t *testing.T) { + th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ + Id: "fileInfoID", + Path: testPath, + }, nil) + + mockedFileBackend := &mocks.FileBackend{} + th.App.filesBackend = mockedFileBackend + mockedFileBackend.On("Reader", testPath).Return(nil, errDummy) + mockedFileBackend.On("FileExists", testPath).Return(true, nil) + + fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt") + assert.Error(t, err) + assert.Nil(t, fileInfo) + assert.Nil(t, seeker) + }) +} + +func TestGetFilePath(t *testing.T) { + th, _ := SetupTestHelper(t) + + t.Run("when FileInfo exists", func(t *testing.T) { + th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ + Id: "fileInfoID", + Path: testPath, + }, nil) + + fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt") + assert.NoError(t, err) + assert.NotNil(t, fileInfo) + assert.Equal(t, testPath, filePath) + }) + t.Run("when FileInfo doesn't exist", func(t *testing.T) { th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, nil) - mockedFileBackend := &mocks.FileBackend{} - th.App.filesBackend = mockedFileBackend - mockedReadCloseSeek := &mocks.ReadCloseSeeker{} - readerFunc := func(path string) filestore.ReadCloseSeeker { - return mockedReadCloseSeek - } - - readerErrorFunc := func(path string) error { - return nil - } - - mockedFileBackend.On("Reader", "teamID/boardID/7fileInfoID.txt").Return(readerFunc, readerErrorFunc) - mockedFileBackend.On("FileExists", "teamID/boardID/7fileInfoID.txt").Return(true, nil) - - fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt") + fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt") assert.NoError(t, err) assert.Nil(t, fileInfo) - assert.NotNil(t, seeker) + assert.Equal(t, "teamID/boardID/7fileInfoID.txt", filePath) }) t.Run("when FileInfo exists but FileInfo.Path is not set", func(t *testing.T) { - th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mmModel.FileInfo{ + th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ Id: "fileInfoID", Path: "", }, nil) - mockedFileBackend := &mocks.FileBackend{} - th.App.filesBackend = mockedFileBackend - mockedReadCloseSeek := &mocks.ReadCloseSeeker{} - readerFunc := func(path string) filestore.ReadCloseSeeker { - return mockedReadCloseSeek - } - - readerErrorFunc := func(path string) error { - return nil - } - mockedFileBackend.On("Reader", "teamID/boardID/7fileInfoID.txt").Return(readerFunc, readerErrorFunc) - mockedFileBackend.On("FileExists", "teamID/boardID/7fileInfoID.txt").Return(true, nil) - - fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt") + fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt") assert.NoError(t, err) assert.NotNil(t, fileInfo) - assert.NotNil(t, seeker) + assert.Equal(t, "teamID/boardID/7fileInfoID.txt", filePath) + }) +} + +func TestCopyCard(t *testing.T) { + th, _ := SetupTestHelper(t) + imageBlock := &model.Block{ + ID: "imageBlock", + ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske", + CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh", + ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh", + Schema: 1, + Type: "image", + Title: "", + Fields: map[string]interface{}{"fileId": "7fileName.jpg"}, + CreateAt: 1680725585250, + UpdateAt: 1680725585250, + DeleteAt: 0, + BoardID: "boardID", + } + t.Run("Board doesn't exist", func(t *testing.T) { + th.Store.EXPECT().GetBoard("boardID").Return(nil, errDummy) + _, err := th.App.CopyCardFiles("boardID", []*model.Block{}, false) + assert.Error(t, err) + }) + + t.Run("Board exists, image block, with FileInfo", func(t *testing.T) { + fileInfo := &mm_model.FileInfo{ + Id: "imageBlock", + Path: testPath, + } + th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{ + ID: "boardID", + IsTemplate: false, + }, nil) + th.Store.EXPECT().GetFileInfo("fileName").Return(fileInfo, nil) + th.Store.EXPECT().SaveFileInfo(fileInfo).Return(nil) + + mockedFileBackend := &mocks.FileBackend{} + th.App.filesBackend = mockedFileBackend + mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil) + + updatedFileNames, err := th.App.CopyCardFiles("boardID", []*model.Block{imageBlock}, false) + assert.NoError(t, err) + assert.Equal(t, "7fileName.jpg", imageBlock.Fields["fileId"]) + assert.NotNil(t, updatedFileNames["7fileName.jpg"]) + assert.NotNil(t, updatedFileNames[imageBlock.Fields["fileId"].(string)]) + }) + + t.Run("Board exists, attachment block, with FileInfo", func(t *testing.T) { + attachmentBlock := &model.Block{ + ID: "attachmentBlock", + ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske", + CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh", + ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh", + Schema: 1, + Type: "attachment", + Title: "", + Fields: map[string]interface{}{"fileId": "7fileName.jpg"}, + CreateAt: 1680725585250, + UpdateAt: 1680725585250, + DeleteAt: 0, + BoardID: "boardID", + } + + fileInfo := &mm_model.FileInfo{ + Id: "attachmentBlock", + Path: testPath, + } + th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{ + ID: "boardID", + IsTemplate: false, + }, nil) + th.Store.EXPECT().GetFileInfo("fileName").Return(fileInfo, nil) + th.Store.EXPECT().SaveFileInfo(fileInfo).Return(nil) + + mockedFileBackend := &mocks.FileBackend{} + th.App.filesBackend = mockedFileBackend + mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil) + + updatedFileNames, err := th.App.CopyCardFiles("boardID", []*model.Block{attachmentBlock}, false) + assert.NoError(t, err) + assert.NotNil(t, updatedFileNames[imageBlock.Fields["fileId"].(string)]) + }) + + t.Run("Board exists, image block, without FileInfo", func(t *testing.T) { + th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{ + ID: "boardID", + IsTemplate: false, + }, nil) + th.Store.EXPECT().GetFileInfo(gomock.Any()).Return(nil, nil) + th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil) + + mockedFileBackend := &mocks.FileBackend{} + th.App.filesBackend = mockedFileBackend + mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil) + + updatedFileNames, err := th.App.CopyCardFiles("boardID", []*model.Block{imageBlock}, false) + assert.NoError(t, err) + assert.NotNil(t, imageBlock.Fields["fileId"].(string)) + assert.NotNil(t, updatedFileNames[imageBlock.Fields["fileId"].(string)]) + }) +} + +func TestCopyAndUpdateCardFiles(t *testing.T) { + th, _ := SetupTestHelper(t) + imageBlock := &model.Block{ + ID: "imageBlock", + ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske", + CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh", + ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh", + Schema: 1, + Type: "image", + Title: "", + Fields: map[string]interface{}{"fileId": "7fileName.jpg"}, + CreateAt: 1680725585250, + UpdateAt: 1680725585250, + DeleteAt: 0, + BoardID: "boardID", + } + + t.Run("Board exists, image block, with FileInfo", func(t *testing.T) { + fileInfo := &mm_model.FileInfo{ + Id: "imageBlock", + Path: testPath, + } + th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{ + ID: "boardID", + IsTemplate: false, + }, nil) + th.Store.EXPECT().GetFileInfo("fileName").Return(fileInfo, nil) + th.Store.EXPECT().SaveFileInfo(fileInfo).Return(nil) + th.Store.EXPECT().PatchBlocks(gomock.Any(), "userID").Return(nil) + + mockedFileBackend := &mocks.FileBackend{} + th.App.filesBackend = mockedFileBackend + mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil) + + err := th.App.CopyAndUpdateCardFiles("boardID", "userID", []*model.Block{imageBlock}, false) + assert.NoError(t, err) + + assert.NotEqual(t, testPath, imageBlock.Fields["fileId"]) }) } diff --git a/server/app/import.go b/server/app/import.go index 6dc68d9c9..7ea99cd87 100644 --- a/server/app/import.go +++ b/server/app/import.go @@ -41,27 +41,19 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error { a.logger.Debug("importing legacy archive") _, errImport := a.ImportBoardJSONL(br, opt) - go func() { - if err := a.UpdateCardLimitTimestamp(); err != nil { - a.logger.Error( - "UpdateCardLimitTimestamp failed after importing a legacy file", - mlog.Err(err), - ) - } - }() - return errImport } - a.logger.Debug("importing archive") zr := zipstream.NewReader(br) - boardMap := make(map[string]string) // maps old board ids to new + boardMap := make(map[string]*model.Board) // maps old board ids to new + fileMap := make(map[string]string) // maps old fileIds to new for { hdr, err := zr.Next() if err != nil { if errors.Is(err, io.EOF) { + a.fixImagesAttachments(boardMap, fileMap, opt.TeamID, opt.ModifiedBy) a.logger.Debug("import archive - done", mlog.Int("boards_imported", len(boardMap))) return nil } @@ -81,14 +73,14 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error { return model.NewErrUnsupportedArchiveVersion(ver, archiveVersion) } case "board.jsonl": - boardID, err := a.ImportBoardJSONL(zr, opt) + board, err := a.ImportBoardJSONL(zr, opt) if err != nil { return fmt.Errorf("cannot import board %s: %w", dir, err) } - boardMap[dir] = boardID + boardMap[dir] = board default: // import file/image; dir is the old board id - boardID, ok := boardMap[dir] + board, ok := boardMap[dir] if !ok { a.logger.Warn("skipping orphan image in archive", mlog.String("dir", dir), @@ -96,33 +88,65 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error { ) continue } - // save file with original filename so it matches name in image block. - filePath := filepath.Join(opt.TeamID, boardID, filename) - _, err := a.filesBackend.WriteFile(zr, filePath) + newFileName, err := a.SaveFile(zr, opt.TeamID, board.ID, filename, board.IsTemplate) if err != nil { return fmt.Errorf("cannot import file %s for board %s: %w", filename, dir, err) } + fileMap[filename] = newFileName + + a.logger.Debug("import archive file", + mlog.String("TeamID", opt.TeamID), + mlog.String("boardID", board.ID), + mlog.String("filename", filename), + mlog.String("newFileName", newFileName), + ) + } + } +} + +// Update image and attachment blocks. +func (a *App) fixImagesAttachments(boardMap map[string]*model.Board, fileMap map[string]string, teamID string, userID string) { + blockIDs := make([]string, 0) + blockPatches := make([]model.BlockPatch, 0) + for _, board := range boardMap { + if board.IsTemplate { + continue } - a.logger.Trace("import archive file", - mlog.String("dir", dir), - mlog.String("filename", filename), - ) + opts := model.QueryBlocksOptions{ + BoardID: board.ID, + } + newBlocks, err := a.store.GetBlocks(opts) + if err != nil { + a.logger.Info("cannot retrieve imported blocks for board", mlog.String("BoardID", board.ID), mlog.Err(err)) + return + } - go func() { - if err := a.UpdateCardLimitTimestamp(); err != nil { - a.logger.Error( - "UpdateCardLimitTimestamp failed after importing an archive", - mlog.Err(err), - ) + for _, block := range newBlocks { + if block.Type == "image" || block.Type == "attachment" { + fieldName := "fileId" + oldID := block.Fields[fieldName] + blockIDs = append(blockIDs, block.ID) + + blockPatches = append(blockPatches, model.BlockPatch{ + UpdatedFields: map[string]interface{}{ + fieldName: fileMap[oldID.(string)], + }, + }) } - }() + } + + blockPatchBatch := model.BlockPatchBatch{BlockIDs: blockIDs, BlockPatches: blockPatches} + err = a.PatchBlocks(teamID, &blockPatchBatch, userID) + if err != nil { + a.logger.Info("Error patching blocks for image import", mlog.Err(err)) + } } } // ImportBoardJSONL imports a JSONL file containing blocks for one board. The resulting // board id is returned. -func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (string, error) { +func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (*model.Board, error) { // TODO: Stream this once `model.GenerateBlockIDs` can take a stream of blocks. // We don't want to load the whole file in memory, even though it's a single board. boardsAndBlocks := &model.BoardsAndBlocks{ @@ -155,7 +179,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str if !skip { var archiveLine model.ArchiveLine if err := json.Unmarshal(line, &archiveLine); err != nil { - return "", fmt.Errorf("error parsing archive line %d: %w", lineNum, err) + return nil, fmt.Errorf("error parsing archive line %d: %w", lineNum, err) } // first line must be a board @@ -167,7 +191,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str case "board": var board model.Board if err2 := json.Unmarshal(archiveLine.Data, &board); err2 != nil { - return "", fmt.Errorf("invalid board in archive line %d: %w", lineNum, err2) + return nil, fmt.Errorf("invalid board in archive line %d: %w", lineNum, err2) } board.ModifiedBy = userID board.UpdateAt = now @@ -178,20 +202,20 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str // legacy archives encoded boards as blocks; we need to convert them to real boards. var block *model.Block if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil { - return "", fmt.Errorf("invalid board block in archive line %d: %w", lineNum, err2) + return nil, fmt.Errorf("invalid board block in archive line %d: %w", lineNum, err2) } block.ModifiedBy = userID block.UpdateAt = now board, err := a.blockToBoard(block, opt) if err != nil { - return "", fmt.Errorf("cannot convert archive line %d to block: %w", lineNum, err) + return nil, fmt.Errorf("cannot convert archive line %d to block: %w", lineNum, err) } boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, board) boardID = board.ID case "block": var block *model.Block if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil { - return "", fmt.Errorf("invalid block in archive line %d: %w", lineNum, err2) + return nil, fmt.Errorf("invalid block in archive line %d: %w", lineNum, err2) } block.ModifiedBy = userID block.UpdateAt = now @@ -200,11 +224,11 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str case "boardMember": var boardMember *model.BoardMember if err2 := json.Unmarshal(archiveLine.Data, &boardMember); err2 != nil { - return "", fmt.Errorf("invalid board Member in archive line %d: %w", lineNum, err2) + return nil, fmt.Errorf("invalid board Member in archive line %d: %w", lineNum, err2) } boardMembers = append(boardMembers, boardMember) default: - return "", model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type) + return nil, model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type) } firstLine = false } @@ -214,7 +238,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str if errors.Is(errRead, io.EOF) { break } - return "", fmt.Errorf("error reading archive line %d: %w", lineNum, errRead) + return nil, fmt.Errorf("error reading archive line %d: %w", lineNum, errRead) } lineNum++ } @@ -231,12 +255,12 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str var err error boardsAndBlocks, err = model.GenerateBoardsAndBlocksIDs(boardsAndBlocks, a.logger) if err != nil { - return "", fmt.Errorf("error generating archive block IDs: %w", err) + return nil, fmt.Errorf("error generating archive block IDs: %w", err) } boardsAndBlocks, err = a.CreateBoardsAndBlocks(boardsAndBlocks, opt.ModifiedBy, false) if err != nil { - return "", fmt.Errorf("error inserting archive blocks: %w", err) + return nil, fmt.Errorf("error inserting archive blocks: %w", err) } // add users to all the new boards (if not the fake system user). @@ -248,7 +272,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str SchemeAdmin: true, } if _, err2 := a.AddMemberToBoard(adminMember); err2 != nil { - return "", fmt.Errorf("cannot add adminMember to board: %w", err2) + return nil, fmt.Errorf("cannot add adminMember to board: %w", err2) } for _, boardMember := range boardMembers { bm := &model.BoardMember{ @@ -263,16 +287,16 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str Synthetic: boardMember.Synthetic, } if _, err2 := a.AddMemberToBoard(bm); err2 != nil { - return "", fmt.Errorf("cannot add member to board: %w", err2) + return nil, fmt.Errorf("cannot add member to board: %w", err2) } } } // find new board id for _, board := range boardsAndBlocks.Boards { - return board.ID, nil + return board, nil } - return "", fmt.Errorf("missing board in archive: %w", model.ErrInvalidBoardBlock) + return nil, fmt.Errorf("missing board in archive: %w", model.ErrInvalidBoardBlock) } // fixBoardsandBlocks allows the caller of `ImportArchive` to modify or filters boards and blocks being diff --git a/server/app/import_test.go b/server/app/import_test.go index 4755f8254..46cd77608 100644 --- a/server/app/import_test.go +++ b/server/app/import_test.go @@ -134,9 +134,76 @@ func TestApp_ImportArchive(t *testing.T) { th.Store.EXPECT().GetUserByID("hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(user2, nil) th.Store.EXPECT().GetUserByID("nto73edn5ir6ifimo5a53y1dwa").AnyTimes().Return(user3, nil) - boardID, err := th.App.ImportBoardJSONL(r, opts) - require.Equal(t, board.ID, boardID, "Board ID should be same") + newBoard, err := th.App.ImportBoardJSONL(r, opts) require.NoError(t, err, "import archive should not fail") + require.Equal(t, board.ID, newBoard.ID, "Board ID should be same") + }) + + t.Run("fix image and attachment", func(t *testing.T) { + boardMap := map[string]*model.Board{ + "test": board, + } + + fileMap := map[string]string{ + "oldFileName1.jpg": "newFileName1.jpg", + "oldFileName2.jpg": "newFileName2.jpg", + } + + imageBlock := &model.Block{ + ID: "blockID-1", + ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske", + CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh", + ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh", + Schema: 1, + Type: "image", + Title: "", + Fields: map[string]interface{}{"fileId": "oldFileName1.jpg"}, + CreateAt: 1680725585250, + UpdateAt: 1680725585250, + DeleteAt: 0, + BoardID: "board-id", + } + + attachmentBlock := &model.Block{ + ID: "blockID-2", + ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske", + CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh", + ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh", + Schema: 1, + Type: "attachment", + Title: "", + Fields: map[string]interface{}{"fileId": "oldFileName2.jpg"}, + CreateAt: 1680725585250, + UpdateAt: 1680725585250, + DeleteAt: 0, + BoardID: "board-id", + } + + blockIDs := []string{"blockID-1", "blockID-2"} + + blockPatch := model.BlockPatch{ + UpdatedFields: map[string]interface{}{"fileId": "newFileName1.jpg"}, + } + + blockPatch2 := model.BlockPatch{ + UpdatedFields: map[string]interface{}{"fileId": "newFileName2.jpg"}, + } + + blockPatches := []model.BlockPatch{blockPatch, blockPatch2} + + blockPatchesBatch := model.BlockPatchBatch{BlockIDs: blockIDs, BlockPatches: blockPatches} + + opts := model.QueryBlocksOptions{ + BoardID: board.ID, + } + th.Store.EXPECT().GetBlocks(opts).Return([]*model.Block{imageBlock, attachmentBlock}, nil) + th.Store.EXPECT().GetBlocksByIDs(blockIDs).Return([]*model.Block{imageBlock, attachmentBlock}, nil) + th.Store.EXPECT().GetBlock(blockIDs[0]).Return(imageBlock, nil) + th.Store.EXPECT().GetBlock(blockIDs[1]).Return(attachmentBlock, nil) + th.Store.EXPECT().GetMembersForBoard("board-id").AnyTimes().Return([]*model.BoardMember{}, nil) + + th.Store.EXPECT().PatchBlocks(&blockPatchesBatch, "my-userid") + th.App.fixImagesAttachments(boardMap, fileMap, "test-team", "my-userid") }) } diff --git a/server/app/templates_test.go b/server/app/templates_test.go index 9a1105d17..d30eff93e 100644 --- a/server/app/templates_test.go +++ b/server/app/templates_test.go @@ -49,6 +49,7 @@ func TestApp_initializeTemplates(t *testing.T) { th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, nil) th.Store.EXPECT().GetMemberForBoard(gomock.Any(), gomock.Any()).AnyTimes().Return(boardMember, nil) + th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil).AnyTimes() th.FilesBackend.On("WriteFile", mock.Anything, mock.Anything).Return(int64(1), nil) diff --git a/server/integrationtests/permissions_test.go b/server/integrationtests/permissions_test.go index 323ed7a3a..95b5a4727 100644 --- a/server/integrationtests/permissions_test.go +++ b/server/integrationtests/permissions_test.go @@ -3375,7 +3375,7 @@ func TestPermissionsGetFile(t *testing.T) { clients := setupClients(th) testData := setupData(t, th) - newFileID, err := th.Server.App().SaveFile(bytes.NewBuffer([]byte("test")), "test-team", testData.privateBoard.ID, "test.png") + newFileID, err := th.Server.App().SaveFile(bytes.NewBuffer([]byte("test")), "test-team", testData.privateBoard.ID, "test.png", false) require.NoError(t, err) ttCases := ttCasesF() @@ -3390,7 +3390,7 @@ func TestPermissionsGetFile(t *testing.T) { clients := setupLocalClients(th) testData := setupData(t, th) - newFileID, err := th.Server.App().SaveFile(bytes.NewBuffer([]byte("test")), "test-team", testData.privateBoard.ID, "test.png") + newFileID, err := th.Server.App().SaveFile(bytes.NewBuffer([]byte("test")), "test-team", testData.privateBoard.ID, "test.png", false) require.NoError(t, err) ttCases := ttCasesF() diff --git a/server/model/file.go b/server/model/file.go new file mode 100644 index 000000000..a2d435666 --- /dev/null +++ b/server/model/file.go @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +package model + +import ( + "mime" + "path/filepath" + "strings" + + "github.com/mattermost/focalboard/server/utils" + mm_model "github.com/mattermost/mattermost-server/v6/model" +) + +func NewFileInfo(name string) *mm_model.FileInfo { + extension := strings.ToLower(filepath.Ext(name)) + now := utils.GetMillis() + return &mm_model.FileInfo{ + CreatorId: "boards", + CreateAt: now, + UpdateAt: now, + Name: name, + Extension: extension, + MimeType: mime.TypeByExtension(extension), + } +} diff --git a/webapp/src/blocks/attachmentBlock.tsx b/webapp/src/blocks/attachmentBlock.tsx index bf0568505..f84e6d051 100644 --- a/webapp/src/blocks/attachmentBlock.tsx +++ b/webapp/src/blocks/attachmentBlock.tsx @@ -3,7 +3,7 @@ import {Block, createBlock} from './block' type AttachmentBlockFields = { - attachmentId: string + fileId: string } type AttachmentBlock = Block & { @@ -18,7 +18,7 @@ function createAttachmentBlock(block?: Block): AttachmentBlock { ...createBlock(block), type: 'attachment', fields: { - attachmentId: block?.fields.attachmentId || '', + fileId: block?.fields.attachmentId || block?.fields.fileId || '', }, isUploading: false, uploadingPercent: 0, diff --git a/webapp/src/components/cardDialog.tsx b/webapp/src/components/cardDialog.tsx index da13e035d..ec484e668 100644 --- a/webapp/src/components/cardDialog.tsx +++ b/webapp/src/components/cardDialog.tsx @@ -151,7 +151,7 @@ const CardDialog = (props: Props): JSX.Element => { Utils.selectLocalFile(async (attachment) => { const uploadingBlock = createBlock() uploadingBlock.title = attachment.name - uploadingBlock.fields.attachmentId = attachment.name + uploadingBlock.fields.fileId = attachment.name uploadingBlock.boardId = boardId if (card) { uploadingBlock.parentId = card.id @@ -177,11 +177,11 @@ const CardDialog = (props: Props): JSX.Element => { xhr.onload = () => { if (xhr.status === 200 && xhr.readyState === 4) { const json = JSON.parse(xhr.response) - const attachmentId = json.fileId - if (attachmentId) { + const fileId = json.fileId + if (fileId) { removeUploadingAttachment(uploadingBlock) const block = createAttachmentBlock() - block.fields.attachmentId = attachmentId || '' + block.fields.fileId = fileId || '' block.title = attachment.name sendFlashMessage({content: intl.formatMessage({id: 'AttachmentBlock.uploadSuccess', defaultMessage: 'Attachment uploaded successfull.'}), severity: 'normal'}) resolve(block) diff --git a/webapp/src/components/content/attachmentElement.test.tsx b/webapp/src/components/content/attachmentElement.test.tsx index 4a9ab2af2..084f39e08 100644 --- a/webapp/src/components/content/attachmentElement.test.tsx +++ b/webapp/src/components/content/attachmentElement.test.tsx @@ -39,7 +39,7 @@ describe('component/content/FileBlock', () => { type: 'attachment', title: 'test-title', fields: { - attachmentId: 'test.txt', + fileId: 'test.txt', }, createdBy: 'test-user-id', createAt: 0, diff --git a/webapp/src/components/content/attachmentElement.tsx b/webapp/src/components/content/attachmentElement.tsx index 72a51a8a2..1df41dd6c 100644 --- a/webapp/src/components/content/attachmentElement.tsx +++ b/webapp/src/components/content/attachmentElement.tsx @@ -50,7 +50,7 @@ const AttachmentElement = (props: Props): JSX.Element|null => { }) return } - const attachmentInfo = await octoClient.getFileInfo(block.boardId, block.fields.attachmentId) + const attachmentInfo = await octoClient.getFileInfo(block.boardId, block.fields.fileId) setFileInfo(attachmentInfo) } loadFile() @@ -113,7 +113,7 @@ const AttachmentElement = (props: Props): JSX.Element|null => { } const attachmentDownloadHandler = async () => { - const attachment = await octoClient.getFileAsDataUrl(block.boardId, block.fields.attachmentId) + const attachment = await octoClient.getFileAsDataUrl(block.boardId, block.fields.fileId) const anchor = document.createElement('a') anchor.href = attachment.url || '' anchor.download = fileInfo.name || ''