update import/export for attachments and new file paths (#22915)

* update import/export for attachments and new file paths

* add unit test

* update templates test

* temp checking

* cleanup

* cleanup

* more cleanup

* lint fixes

* cleanup some functions

* cleanup

* fix build breaks

* fix tests for changes

* move function to different file

* more cleanup, code movement

* unit test fixes

* add unit tests

* fix tests

* more unit test fixes

* test fix

* revert package-lock.json

* fixes from code review

* fix export image

* more fixes to remove attachmentId

* change from removed api

* update test

* lint fixes

* update for review comments

* fix tests

* test fix

* lint fix

* remove sprintf from logging, use mlog

* more lint fixes

* Update server/boards/app/files.go

Co-authored-by: Doug Lauder <wiggin77@warpmail.net>

* remove code

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Doug Lauder <wiggin77@warpmail.net>
This commit is contained in:
Scott Bishel 2023-04-26 10:01:00 -06:00 committed by GitHub
parent ac35bdff68
commit 81ef403230
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 586 additions and 279 deletions

View File

@ -304,7 +304,7 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
// this query param exists when creating template from board, or board from template // this query param exists when creating template from board, or board from template
sourceBoardID := r.URL.Query().Get("sourceBoardID") sourceBoardID := r.URL.Query().Get("sourceBoardID")
if 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) a.errorResponse(w, r, updateFileIDsErr)
return return
} }

View File

@ -312,7 +312,7 @@ func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
auditRec.AddMeta("teamID", board.TeamID) auditRec.AddMeta("teamID", board.TeamID)
auditRec.AddMeta("filename", handle.Filename) 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 { if err != nil {
a.errorResponse(w, r, err) a.errorResponse(w, r, err)
return return

View File

@ -7,11 +7,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings"
"github.com/mattermost/mattermost-server/server/v8/boards/model" "github.com/mattermost/mattermost-server/server/v8/boards/model"
"github.com/mattermost/mattermost-server/server/v8/boards/services/notify" "github.com/mattermost/mattermost-server/server/v8/boards/services/notify"
"github.com/mattermost/mattermost-server/server/v8/boards/utils"
"github.com/mattermost/mattermost-server/server/v8/platform/shared/mlog" "github.com/mattermost/mattermost-server/server/v8/platform/shared/mlog"
) )
@ -39,6 +37,11 @@ func (a *App) DuplicateBlock(boardID string, blockID string, userID string, asTe
return nil, err return nil, err
} }
err = a.CopyAndUpdateCardFiles(boardID, userID, blocks, asTemplate)
if err != nil {
return nil, err
}
a.blockChangeNotifier.Enqueue(func() error { a.blockChangeNotifier.Enqueue(func() error {
for _, block := range blocks { for _, block := range blocks {
a.wsAdapter.BroadcastBlockChange(board.TeamID, block) a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
@ -286,95 +289,6 @@ func (a *App) InsertBlocksAndNotify(blocks []*model.Block, modifiedByID string,
return blocks, nil 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) { func (a *App) GetBlockByID(blockID string) (*model.Block, error) {
return a.store.GetBlock(blockID) return a.store.GetBlock(blockID)
} }

View File

@ -184,8 +184,13 @@ func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*
} }
// copy any file attachments from the duplicated blocks. // copy any file attachments from the duplicated blocks.
if err = a.CopyCardFiles(boardID, bab.Blocks); err != nil { err = a.CopyAndUpdateCardFiles(boardID, userID, bab.Blocks, asTemplate)
a.logger.Error("Could not copy files while duplicating board", mlog.String("BoardID", boardID), mlog.Err(err)) if 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)
} }
if !asTemplate { if !asTemplate {
@ -196,44 +201,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 { a.blockChangeNotifier.Enqueue(func() error {
teamID := "" teamID := ""
for _, board := range bab.Boards { for _, board := range bab.Boards {

View File

@ -95,10 +95,10 @@ func (a *App) writeArchiveBoard(zw *zip.Writer, board model.Board, opt model.Exp
if err = a.writeArchiveBlockLine(w, block); err != nil { if err = a.writeArchiveBlockLine(w, block); err != nil {
return err return err
} }
if block.Type == model.TypeImage { if block.Type == model.TypeImage || block.Type == model.TypeAttachment {
filename, err2 := extractImageFilename(block) filename, err2 := extractFilename(block)
if err2 != nil { if err2 != nil {
return err return err2
} }
files = append(files, filename) files = append(files, filename)
} }
@ -208,7 +208,10 @@ func (a *App) writeArchiveFile(zw *zip.Writer, filename string, boardID string,
return err 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 { if err != nil {
// just log this; image file is missing but we'll still export an equivalent board // just log this; image file is missing but we'll still export an equivalent board
a.logger.Error("image file missing for export", a.logger.Error("image file missing for export",
@ -218,9 +221,9 @@ func (a *App) writeArchiveFile(zw *zip.Writer, filename string, boardID string,
) )
return nil return nil
} }
defer src.Close() defer fileReader.Close()
_, err = io.Copy(dest, src) _, err = io.Copy(dest, fileReader)
return err return err
} }
@ -239,10 +242,13 @@ func (a *App) getBoardsForArchive(boardIDs []string) ([]model.Board, error) {
return boards, nil return boards, nil
} }
func extractImageFilename(imageBlock *model.Block) (string, error) { func extractFilename(block *model.Block) (string, error) {
f, ok := imageBlock.Fields["fileId"] f, ok := block.Fields["fileId"]
if !ok { if !ok {
return "", model.ErrInvalidImageBlock f, ok = block.Fields["attachmentId"]
if !ok {
return "", model.ErrInvalidImageBlock
}
} }
filename, ok := f.(string) filename, ok := f.(string)

View File

@ -18,12 +18,10 @@ import (
"github.com/mattermost/mattermost-server/server/v8/platform/shared/mlog" "github.com/mattermost/mattermost-server/server/v8/platform/shared/mlog"
) )
const emptyString = "empty"
var errEmptyFilename = errors.New("IsFileArchived: empty filename not allowed") var errEmptyFilename = errors.New("IsFileArchived: empty filename not allowed")
var ErrFileNotFound = errors.New("file not found") 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 // NOTE: File extension includes the dot
fileExtension := strings.ToLower(filepath.Ext(filename)) fileExtension := strings.ToLower(filepath.Ext(filename))
if fileExtension == ".jpeg" { if fileExtension == ".jpeg" {
@ -31,44 +29,26 @@ func (a *App) SaveFile(reader io.Reader, teamID, rootID, filename string) (strin
} }
createdFilename := utils.NewID(utils.IDTypeNone) createdFilename := utils.NewID(utils.IDTypeNone)
fullFilename := fmt.Sprintf(`%s%s`, createdFilename, fileExtension) newFileName := fmt.Sprintf(`%s%s`, createdFilename, fileExtension)
filePath := filepath.Join(utils.GetBaseFilePath(), fullFilename) if asTemplate {
newFileName = filename
}
filePath := getDestinationFilePath(asTemplate, teamID, boardID, newFileName)
fileSize, appErr := a.filesBackend.WriteFile(reader, filePath) fileSize, appErr := a.filesBackend.WriteFile(reader, filePath)
if appErr != nil { if appErr != nil {
return "", fmt.Errorf("unable to store the file in the files storage: %w", appErr) return "", fmt.Errorf("unable to store the file in the files storage: %w", appErr)
} }
now := utils.GetMillis() fileInfo := model.NewFileInfo(filename)
fileInfo.Id = getFileInfoID(createdFilename)
fileInfo := &mm_model.FileInfo{ fileInfo.Path = filePath
Id: createdFilename[1:], fileInfo.Size = fileSize
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,
}
err := a.store.SaveFileInfo(fileInfo) err := a.store.SaveFileInfo(fileInfo)
if err != nil { if err != nil {
return "", err return "", err
} }
return newFileName, nil
return fullFilename, nil
} }
func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) { func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) {
@ -79,8 +59,7 @@ func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) {
// filename is in the format 7<some-alphanumeric-string>.<extension> // filename is in the format 7<some-alphanumeric-string>.<extension>
// we want to extract the <some-alphanumeric-string> part of this as this // we want to extract the <some-alphanumeric-string> part of this as this
// will be the fileinfo id. // will be the fileinfo id.
parts := strings.Split(filename, ".") fileInfoID := getFileInfoID(strings.Split(filename, ".")[0])
fileInfoID := parts[0][1:]
fileInfo, err := a.store.GetFileInfo(fileInfoID) fileInfo, err := a.store.GetFileInfo(fileInfoID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -90,10 +69,33 @@ func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) {
} }
func (a *App) GetFile(teamID, rootID, fileName string) (*mm_model.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) fileInfo, err := a.GetFileInfo(fileName)
if err != nil && !model.IsErrNotFound(err) { if err != nil && !model.IsErrNotFound(err) {
a.logger.Error("111") return nil, "", err
return nil, nil, err
} }
var filePath string var filePath string
@ -104,22 +106,23 @@ func (a *App) GetFile(teamID, rootID, fileName string) (*mm_model.FileInfo, file
filePath = filepath.Join(teamID, rootID, fileName) filePath = filepath.Join(teamID, rootID, fileName)
} }
exists, err := a.filesBackend.FileExists(filePath) return fileInfo, filePath, nil
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
}
if !exists { func getDestinationFilePath(isTemplate bool, teamID, boardID, filename string) string {
return nil, nil, ErrFileNotFound // 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) func getFileInfoID(fileName string) string {
if err != nil { // Boards ids are 27 characters long with a prefix character.
a.logger.Error(fmt.Sprintf("GetFile: Failed to get file reader of existing file at path: %s, error: %e", filePath, err)) // removing the prefix, returns the 26 character uuid
return nil, nil, err return fileName[1:]
}
return fileInfo, reader, nil
} }
func (a *App) GetFileReader(teamID, rootID, filename string) (filestore.ReadCloseSeeker, error) { func (a *App) GetFileReader(teamID, rootID, filename string) (filestore.ReadCloseSeeker, error) {
@ -175,3 +178,121 @@ func (a *App) MoveFile(channelID, teamID, boardID, filename string) error {
} }
return nil 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
}

View File

@ -15,6 +15,7 @@ import (
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-server/server/v8/boards/model"
mm_model "github.com/mattermost/mattermost-server/server/v8/model" mm_model "github.com/mattermost/mattermost-server/server/v8/model"
"github.com/mattermost/mattermost-server/server/v8/platform/shared/filestore" "github.com/mattermost/mattermost-server/server/v8/platform/shared/filestore"
"github.com/mattermost/mattermost-server/server/v8/platform/shared/filestore/mocks" "github.com/mattermost/mattermost-server/server/v8/platform/shared/filestore/mocks"
@ -210,7 +211,7 @@ func TestSaveFile(t *testing.T) {
} }
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc) 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.Equal(t, fileName, actual)
assert.NoError(t, err) assert.NoError(t, err)
}) })
@ -234,7 +235,7 @@ func TestSaveFile(t *testing.T) {
} }
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc) 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.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, actual) assert.NotNil(t, actual)
}) })
@ -258,7 +259,7 @@ func TestSaveFile(t *testing.T) {
} }
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc) 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, "", actual)
assert.Equal(t, "unable to store the file in the files storage: Mocked File backend error", err.Error()) assert.Equal(t, "unable to store the file in the files storage: Mocked File backend error", err.Error())
}) })
@ -312,7 +313,7 @@ func TestGetFileInfo(t *testing.T) {
func TestGetFile(t *testing.T) { func TestGetFile(t *testing.T) {
th, _ := SetupTestHelper(t) th, _ := SetupTestHelper(t)
t.Run("when FileInfo exists", func(t *testing.T) { t.Run("happy path, no errors", func(t *testing.T) {
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
Id: "fileInfoID", Id: "fileInfoID",
Path: "/path/to/file/fileName.txt", Path: "/path/to/file/fileName.txt",
@ -337,27 +338,72 @@ func TestGetFile(t *testing.T) {
assert.NotNil(t, seeker) assert.NotNil(t, seeker)
}) })
t.Run("when FileInfo doesn't exist", func(t *testing.T) { t.Run("when GetFilePath() throws error", func(t *testing.T) {
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, nil) 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: "/path/to/file/fileName.txt",
}, nil)
mockedFileBackend := &mocks.FileBackend{} mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend th.App.filesBackend = mockedFileBackend
mockedReadCloseSeek := &mocks.ReadCloseSeeker{} mockedFileBackend.On("FileExists", "/path/to/file/fileName.txt").Return(false, nil)
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, 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: "/path/to/file/fileName.txt",
}, nil)
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedFileBackend.On("Reader", "/path/to/file/fileName.txt").Return(nil, errDummy)
mockedFileBackend.On("FileExists", "/path/to/file/fileName.txt").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) {
path := "/path/to/file/fileName.txt"
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
Id: "fileInfoID",
Path: path,
}, nil)
fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt")
assert.NoError(t, err)
assert.NotNil(t, fileInfo)
assert.Equal(t, path, filePath)
})
t.Run("when FileInfo doesn't exist", func(t *testing.T) {
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, nil)
fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt")
assert.NoError(t, err) assert.NoError(t, err)
assert.Nil(t, fileInfo) 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) { t.Run("when FileInfo exists but FileInfo.Path is not set", func(t *testing.T) {
@ -366,22 +412,158 @@ func TestGetFile(t *testing.T) {
Path: "", Path: "",
}, nil) }, nil)
mockedFileBackend := &mocks.FileBackend{} fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt")
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")
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, fileInfo) 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) {
path := "/path/to/file/fileName.txt"
fileInfo := &mm_model.FileInfo{
Id: "imageBlock",
Path: path,
}
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",
}
path := "/path/to/file/fileName.txt"
fileInfo := &mm_model.FileInfo{
Id: "attachmentBlock",
Path: path,
}
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) {
// path := "/path/to/file/fileName.txt"
// fileInfo := &mm_model.FileInfo{
// Id: "imageBlock",
// Path: path,
// }
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) {
path := "/path/to/file/fileName.txt"
fileInfo := &mm_model.FileInfo{
Id: "imageBlock",
Path: path,
}
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, path, imageBlock.Fields["fileId"])
}) })
} }

View File

@ -44,27 +44,19 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error {
a.logger.Debug("importing legacy archive") a.logger.Debug("importing legacy archive")
_, errImport := a.ImportBoardJSONL(br, opt) _, 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 return errImport
} }
a.logger.Debug("importing archive")
zr := zipstream.NewReader(br) 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 { for {
hdr, err := zr.Next() hdr, err := zr.Next()
if err != nil { if err != nil {
if errors.Is(err, io.EOF) { 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))) a.logger.Debug("import archive - done", mlog.Int("boards_imported", len(boardMap)))
return nil return nil
} }
@ -84,14 +76,14 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error {
return model.NewErrUnsupportedArchiveVersion(ver, archiveVersion) return model.NewErrUnsupportedArchiveVersion(ver, archiveVersion)
} }
case "board.jsonl": case "board.jsonl":
boardID, err := a.ImportBoardJSONL(zr, opt) board, err := a.ImportBoardJSONL(zr, opt)
if err != nil { if err != nil {
return fmt.Errorf("cannot import board %s: %w", dir, err) return fmt.Errorf("cannot import board %s: %w", dir, err)
} }
boardMap[dir] = boardID boardMap[dir] = board
default: default:
// import file/image; dir is the old board id // import file/image; dir is the old board id
boardID, ok := boardMap[dir] board, ok := boardMap[dir]
if !ok { if !ok {
a.logger.Warn("skipping orphan image in archive", a.logger.Warn("skipping orphan image in archive",
mlog.String("dir", dir), mlog.String("dir", dir),
@ -99,33 +91,63 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error {
) )
continue continue
} }
// save file with original filename so it matches name in image block.
filePath := filepath.Join(opt.TeamID, boardID, filename) newFileName, err := a.SaveFile(zr, opt.TeamID, board.ID, filename, board.IsTemplate)
_, err := a.filesBackend.WriteFile(zr, filePath)
if err != nil { if err != nil {
return fmt.Errorf("cannot import file %s for board %s: %w", filename, dir, err) 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", opts := model.QueryBlocksOptions{
mlog.String("dir", dir), BoardID: board.ID,
mlog.String("filename", filename), }
) newBlocks, err := a.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() { for _, block := range newBlocks {
if err := a.UpdateCardLimitTimestamp(); err != nil { if block.Type == "image" || block.Type == "attachment" {
a.logger.Error( fieldName := "fileId"
"UpdateCardLimitTimestamp failed after importing an archive", oldId := block.Fields[fieldName]
mlog.Err(err), 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}
a.PatchBlocks(teamID, &blockPatchBatch, userId)
} }
} }
// ImportBoardJSONL imports a JSONL file containing blocks for one board. The resulting // ImportBoardJSONL imports a JSONL file containing blocks for one board. The resulting
// board id is returned. // 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. // 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. // We don't want to load the whole file in memory, even though it's a single board.
boardsAndBlocks := &model.BoardsAndBlocks{ boardsAndBlocks := &model.BoardsAndBlocks{
@ -158,7 +180,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
if !skip { if !skip {
var archiveLine model.ArchiveLine var archiveLine model.ArchiveLine
if err := json.Unmarshal(line, &archiveLine); err != nil { 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 // first line must be a board
@ -170,7 +192,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
case "board": case "board":
var board model.Board var board model.Board
if err2 := json.Unmarshal(archiveLine.Data, &board); err2 != nil { 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.ModifiedBy = userID
board.UpdateAt = now board.UpdateAt = now
@ -181,20 +203,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. // legacy archives encoded boards as blocks; we need to convert them to real boards.
var block *model.Block var block *model.Block
if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil { 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.ModifiedBy = userID
block.UpdateAt = now block.UpdateAt = now
board, err := a.blockToBoard(block, opt) board, err := a.blockToBoard(block, opt)
if err != nil { 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) boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, board)
boardID = board.ID boardID = board.ID
case "block": case "block":
var block *model.Block var block *model.Block
if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil { 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.ModifiedBy = userID
block.UpdateAt = now block.UpdateAt = now
@ -203,11 +225,11 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
case "boardMember": case "boardMember":
var boardMember *model.BoardMember var boardMember *model.BoardMember
if err2 := json.Unmarshal(archiveLine.Data, &boardMember); err2 != nil { 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) boardMembers = append(boardMembers, boardMember)
default: default:
return "", model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type) return nil, model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type)
} }
firstLine = false firstLine = false
} }
@ -217,7 +239,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
if errors.Is(errRead, io.EOF) { if errors.Is(errRead, io.EOF) {
break 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++ lineNum++
} }
@ -234,12 +256,12 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
var err error var err error
boardsAndBlocks, err = model.GenerateBoardsAndBlocksIDs(boardsAndBlocks, a.logger) boardsAndBlocks, err = model.GenerateBoardsAndBlocksIDs(boardsAndBlocks, a.logger)
if err != nil { 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) boardsAndBlocks, err = a.CreateBoardsAndBlocks(boardsAndBlocks, opt.ModifiedBy, false)
if err != nil { 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). // add users to all the new boards (if not the fake system user).
@ -251,7 +273,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
SchemeAdmin: true, SchemeAdmin: true,
} }
if _, err2 := a.AddMemberToBoard(adminMember); err2 != nil { 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 { for _, boardMember := range boardMembers {
bm := &model.BoardMember{ bm := &model.BoardMember{
@ -266,16 +288,16 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
Synthetic: boardMember.Synthetic, Synthetic: boardMember.Synthetic,
} }
if _, err2 := a.AddMemberToBoard(bm); err2 != nil { 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 // find new board id
for _, board := range boardsAndBlocks.Boards { 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 // fixBoardsandBlocks allows the caller of `ImportArchive` to modify or filters boards and blocks being

View File

@ -138,9 +138,76 @@ func TestApp_ImportArchive(t *testing.T) {
th.Store.EXPECT().GetUserByID("hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(user2, nil) th.Store.EXPECT().GetUserByID("hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(user2, nil)
th.Store.EXPECT().GetUserByID("nto73edn5ir6ifimo5a53y1dwa").AnyTimes().Return(user3, nil) th.Store.EXPECT().GetUserByID("nto73edn5ir6ifimo5a53y1dwa").AnyTimes().Return(user3, nil)
boardID, err := th.App.ImportBoardJSONL(r, opts) newBoard, err := th.App.ImportBoardJSONL(r, opts)
require.Equal(t, board.ID, boardID, "Board ID should be same")
require.NoError(t, err, "import archive should not fail") 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")
}) })
} }

View File

@ -53,6 +53,7 @@ func TestApp_initializeTemplates(t *testing.T) {
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{}, nil) th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, 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().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) th.FilesBackend.On("WriteFile", mock.Anything, mock.Anything).Return(int64(1), nil)

View File

@ -3379,7 +3379,7 @@ func TestPermissionsGetFile(t *testing.T) {
clients := setupClients(th) clients := setupClients(th)
testData := setupData(t, 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) require.NoError(t, err)
ttCases := ttCasesF() ttCases := ttCasesF()
@ -3394,7 +3394,7 @@ func TestPermissionsGetFile(t *testing.T) {
clients := setupLocalClients(th) clients := setupLocalClients(th)
testData := setupData(t, 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) require.NoError(t, err)
ttCases := ttCasesF() ttCases := ttCasesF()

View File

@ -0,0 +1,27 @@
// 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/mattermost-server/server/v8/boards/utils"
mm_model "github.com/mattermost/mattermost-server/server/v8/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),
}
}

View File

@ -3,7 +3,7 @@
import {Block, createBlock} from './block' import {Block, createBlock} from './block'
type AttachmentBlockFields = { type AttachmentBlockFields = {
attachmentId: string fileId: string
} }
type AttachmentBlock = Block & { type AttachmentBlock = Block & {
@ -18,7 +18,7 @@ function createAttachmentBlock(block?: Block): AttachmentBlock {
...createBlock(block), ...createBlock(block),
type: 'attachment', type: 'attachment',
fields: { fields: {
attachmentId: block?.fields.attachmentId || '', fileId: block?.fields.attachmentId || block?.fields.fileId || '',
}, },
isUploading: false, isUploading: false,
uploadingPercent: 0, uploadingPercent: 0,

View File

@ -151,7 +151,7 @@ const CardDialog = (props: Props): JSX.Element => {
Utils.selectLocalFile(async (attachment) => { Utils.selectLocalFile(async (attachment) => {
const uploadingBlock = createBlock() const uploadingBlock = createBlock()
uploadingBlock.title = attachment.name uploadingBlock.title = attachment.name
uploadingBlock.fields.attachmentId = attachment.name uploadingBlock.fields.fileId = attachment.name
uploadingBlock.boardId = boardId uploadingBlock.boardId = boardId
if (card) { if (card) {
uploadingBlock.parentId = card.id uploadingBlock.parentId = card.id
@ -177,11 +177,11 @@ const CardDialog = (props: Props): JSX.Element => {
xhr.onload = () => { xhr.onload = () => {
if (xhr.status === 200 && xhr.readyState === 4) { if (xhr.status === 200 && xhr.readyState === 4) {
const json = JSON.parse(xhr.response) const json = JSON.parse(xhr.response)
const attachmentId = json.fileId const fileId = json.fileId
if (attachmentId) { if (fileId) {
removeUploadingAttachment(uploadingBlock) removeUploadingAttachment(uploadingBlock)
const block = createAttachmentBlock() const block = createAttachmentBlock()
block.fields.attachmentId = attachmentId || '' block.fields.fileId = fileId || ''
block.title = attachment.name block.title = attachment.name
sendFlashMessage({content: intl.formatMessage({id: 'AttachmentBlock.uploadSuccess', defaultMessage: 'Attachment uploaded.'}), severity: 'normal'}) sendFlashMessage({content: intl.formatMessage({id: 'AttachmentBlock.uploadSuccess', defaultMessage: 'Attachment uploaded.'}), severity: 'normal'})
resolve(block) resolve(block)

View File

@ -39,7 +39,7 @@ describe('component/content/FileBlock', () => {
type: 'attachment', type: 'attachment',
title: 'test-title', title: 'test-title',
fields: { fields: {
attachmentId: 'test.txt', fileId: 'test.txt',
}, },
createdBy: 'test-user-id', createdBy: 'test-user-id',
createAt: 0, createAt: 0,

View File

@ -50,7 +50,7 @@ const AttachmentElement = (props: Props): JSX.Element|null => {
}) })
return return
} }
const attachmentInfo = await octoClient.getFileInfo(block.boardId, block.fields.attachmentId) const attachmentInfo = await octoClient.getFileInfo(block.boardId, block.fields.fileId)
setFileInfo(attachmentInfo) setFileInfo(attachmentInfo)
} }
loadFile() loadFile()
@ -113,7 +113,7 @@ const AttachmentElement = (props: Props): JSX.Element|null => {
} }
const attachmentDownloadHandler = async () => { 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') const anchor = document.createElement('a')
anchor.href = attachment.url || '' anchor.href = attachment.url || ''
anchor.download = fileInfo.name || '' anchor.download = fileInfo.name || ''