You've already forked focalboard
							
							
				mirror of
				https://github.com/mattermost/focalboard.git
				synced 2025-10-31 00:17:42 +02:00 
			
		
		
		
	Backport fixes for import/export attachments and fixes (#4741)
* Backport fixes for import/export attachments and fixes * fix bad merge * lint fixes * Update server/app/boards.go Co-authored-by: Miguel de la Cruz <miguel@mcrx.me> --------- Co-authored-by: Miguel de la Cruz <miguel@mcrx.me>
This commit is contained in:
		| @@ -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 | 	// 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 | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -393,7 +393,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 | ||||||
|   | |||||||
| @@ -4,11 +4,9 @@ import ( | |||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"path/filepath" | 	"path/filepath" | ||||||
| 	"strings" |  | ||||||
|  |  | ||||||
| 	"github.com/mattermost/focalboard/server/model" | 	"github.com/mattermost/focalboard/server/model" | ||||||
| 	"github.com/mattermost/focalboard/server/services/notify" | 	"github.com/mattermost/focalboard/server/services/notify" | ||||||
| 	"github.com/mattermost/focalboard/server/utils" |  | ||||||
|  |  | ||||||
| 	"github.com/mattermost/mattermost-server/v6/shared/mlog" | 	"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 | 		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) | ||||||
| @@ -292,95 +295,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) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -185,8 +185,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 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 { | 	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 { | 	a.blockChangeNotifier.Enqueue(func() error { | ||||||
| 		teamID := "" | 		teamID := "" | ||||||
| 		for _, board := range bab.Boards { | 		for _, board := range bab.Boards { | ||||||
|   | |||||||
| @@ -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 { | 		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) | ||||||
| 		} | 		} | ||||||
| @@ -204,7 +204,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", | ||||||
| @@ -214,9 +217,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 | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -235,10 +238,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) | ||||||
|   | |||||||
| @@ -8,19 +8,17 @@ import ( | |||||||
| 	"strings" | 	"strings" | ||||||
|  |  | ||||||
| 	"github.com/mattermost/focalboard/server/model" | 	"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/focalboard/server/utils" | ||||||
| 	"github.com/mattermost/mattermost-server/v6/shared/filestore" | 	"github.com/mattermost/mattermost-server/v6/shared/filestore" | ||||||
| 	"github.com/mattermost/mattermost-server/v6/shared/mlog" | 	"github.com/mattermost/mattermost-server/v6/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" { | ||||||
| @@ -28,48 +26,31 @@ 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 := &mmModel.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 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 { | 	if len(filename) == 0 { | ||||||
| 		return nil, errEmptyFilename | 		return nil, errEmptyFilename | ||||||
| 	} | 	} | ||||||
| @@ -77,8 +58,7 @@ func (a *App) GetFileInfo(filename string) (*mmModel.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 { | ||||||
| @@ -88,11 +68,36 @@ func (a *App) GetFileInfo(filename string) (*mmModel.FileInfo, error) { | |||||||
| 	return fileInfo, nil | 	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) | 	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 | ||||||
| @@ -103,23 +108,23 @@ func (a *App) GetFile(teamID, rootID, fileName string) (*mmModel.FileInfo, files | |||||||
| 		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 +180,120 @@ 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 | ||||||
|  | } | ||||||
|   | |||||||
| @@ -12,7 +12,8 @@ import ( | |||||||
| 	"github.com/golang/mock/gomock" | 	"github.com/golang/mock/gomock" | ||||||
| 	"github.com/stretchr/testify/assert" | 	"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/plugin/plugintest/mock" | ||||||
| 	"github.com/mattermost/mattermost-server/v6/shared/filestore" | 	"github.com/mattermost/mattermost-server/v6/shared/filestore" | ||||||
| 	"github.com/mattermost/mattermost-server/v6/shared/filestore/mocks" | 	"github.com/mattermost/mattermost-server/v6/shared/filestore/mocks" | ||||||
| @@ -21,6 +22,7 @@ import ( | |||||||
| const ( | const ( | ||||||
| 	testFileName = "temp-file-name" | 	testFileName = "temp-file-name" | ||||||
| 	testBoardID  = "test-board-id" | 	testBoardID  = "test-board-id" | ||||||
|  | 	testPath     = "/path/to/file/fileName.txt" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| var errDummy = errors.New("hello") | var errDummy = errors.New("hello") | ||||||
| @@ -207,7 +209,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.Nil(t, err) | 		assert.Nil(t, err) | ||||||
| 	}) | 	}) | ||||||
| @@ -231,7 +233,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.Nil(t, err) | 		assert.Nil(t, err) | ||||||
| 		assert.NotNil(t, actual) | 		assert.NotNil(t, actual) | ||||||
| 	}) | 	}) | ||||||
| @@ -255,7 +257,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()) | ||||||
| 	}) | 	}) | ||||||
| @@ -265,7 +267,7 @@ func TestGetFileInfo(t *testing.T) { | |||||||
| 	th, _ := SetupTestHelper(t) | 	th, _ := SetupTestHelper(t) | ||||||
|  |  | ||||||
| 	t.Run("should return file info", func(t *testing.T) { | 	t.Run("should return file info", func(t *testing.T) { | ||||||
| 		fileInfo := &mmModel.FileInfo{ | 		fileInfo := &mm_model.FileInfo{ | ||||||
| 			Id:       "file_info_id", | 			Id:       "file_info_id", | ||||||
| 			Archived: false, | 			Archived: false, | ||||||
| 		} | 		} | ||||||
| @@ -284,7 +286,7 @@ func TestGetFileInfo(t *testing.T) { | |||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| 	t.Run("should return archived file info", func(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", | 			Id:       "file_info_id", | ||||||
| 			Archived: true, | 			Archived: true, | ||||||
| 		} | 		} | ||||||
| @@ -308,11 +310,10 @@ func TestGetFileInfo(t *testing.T) { | |||||||
|  |  | ||||||
| func TestGetFile(t *testing.T) { | func TestGetFile(t *testing.T) { | ||||||
| 	th, _ := SetupTestHelper(t) | 	th, _ := SetupTestHelper(t) | ||||||
|  | 	t.Run("happy path, no errors", func(t *testing.T) { | ||||||
| 	t.Run("when FileInfo exists", func(t *testing.T) { | 		th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ | ||||||
| 		th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mmModel.FileInfo{ |  | ||||||
| 			Id:   "fileInfoID", | 			Id:   "fileInfoID", | ||||||
| 			Path: "/path/to/file/fileName.txt", | 			Path: testPath, | ||||||
| 		}, nil) | 		}, nil) | ||||||
|  |  | ||||||
| 		mockedFileBackend := &mocks.FileBackend{} | 		mockedFileBackend := &mocks.FileBackend{} | ||||||
| @@ -325,8 +326,8 @@ func TestGetFile(t *testing.T) { | |||||||
| 		readerErrorFunc := func(path string) error { | 		readerErrorFunc := func(path string) error { | ||||||
| 			return nil | 			return nil | ||||||
| 		} | 		} | ||||||
| 		mockedFileBackend.On("Reader", "/path/to/file/fileName.txt").Return(readerFunc, readerErrorFunc) | 		mockedFileBackend.On("Reader", testPath).Return(readerFunc, readerErrorFunc) | ||||||
| 		mockedFileBackend.On("FileExists", "/path/to/file/fileName.txt").Return(true, nil) | 		mockedFileBackend.On("FileExists", testPath).Return(true, nil) | ||||||
|  |  | ||||||
| 		fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt") | 		fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt") | ||||||
| 		assert.NoError(t, err) | 		assert.NoError(t, err) | ||||||
| @@ -334,51 +335,222 @@ func TestGetFile(t *testing.T) { | |||||||
| 		assert.NotNil(t, seeker) | 		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) { | 	t.Run("when FileInfo doesn't exist", func(t *testing.T) { | ||||||
| 		th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, nil) | 		th.Store.EXPECT().GetFileInfo("fileInfoID").Return(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.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) { | ||||||
| 		th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mmModel.FileInfo{ | 		th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{ | ||||||
| 			Id:   "fileInfoID", | 			Id:   "fileInfoID", | ||||||
| 			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) { | ||||||
|  | 		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"]) | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -41,27 +41,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 | ||||||
| 			} | 			} | ||||||
| @@ -81,14 +73,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), | ||||||
| @@ -96,33 +88,65 @@ 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. | 			newFileName, err := a.SaveFile(zr, opt.TeamID, board.ID, filename, board.IsTemplate) | ||||||
| 			filePath := filepath.Join(opt.TeamID, boardID, filename) |  | ||||||
| 			_, 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.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() { | 		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} | ||||||
|  | 		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 | // 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{ | ||||||
| @@ -155,7 +179,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 | ||||||
| @@ -167,7 +191,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 | ||||||
| @@ -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. | 					// 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 | ||||||
| @@ -200,11 +224,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 | ||||||
| 			} | 			} | ||||||
| @@ -214,7 +238,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++ | ||||||
| 	} | 	} | ||||||
| @@ -231,12 +255,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). | ||||||
| @@ -248,7 +272,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{ | ||||||
| @@ -263,16 +287,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 | ||||||
|   | |||||||
| @@ -134,9 +134,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") | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -49,6 +49,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) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3375,7 +3375,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() | ||||||
| @@ -3390,7 +3390,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() | ||||||
|   | |||||||
							
								
								
									
										25
									
								
								server/model/file.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								server/model/file.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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), | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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 successfull.'}), severity: 'normal'}) |                                         sendFlashMessage({content: intl.formatMessage({id: 'AttachmentBlock.uploadSuccess', defaultMessage: 'Attachment uploaded successfull.'}), severity: 'normal'}) | ||||||
|                                         resolve(block) |                                         resolve(block) | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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 || '' | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user