focalboard/server/app/import.go

418 lines
11 KiB
Go

package app
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"path/filepath"
"strings"
"github.com/krolaw/zipstream"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/utils"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
const (
archiveVersion = 2
legacyFileBegin = "{\"version\":1"
)
var (
errBlockIsNotABoard = errors.New("block is not a board")
)
// ImportArchive imports an archive containing zero or more boards, plus all
// associated content, including cards, content blocks, views, and images.
//
// Archives are ZIP files containing a `version.json` file and zero or more
// directories, each containing a `board.jsonl` and zero or more image files.
func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error {
// peek at the first bytes to see if this is a legacy archive format
br := bufio.NewReader(r)
peek, err := br.Peek(len(legacyFileBegin))
if err == nil && string(peek) == legacyFileBegin {
a.logger.Debug("importing legacy archive")
_, errImport := a.ImportBoardJSONL(br, opt)
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after importing a legacy file",
mlog.Err(err),
)
}
}()
return errImport
}
a.logger.Debug("importing archive")
zr := zipstream.NewReader(br)
boardMap := make(map[string]string) // maps old board ids to new
for {
hdr, err := zr.Next()
if err != nil {
if errors.Is(err, io.EOF) {
a.logger.Debug("import archive - done", mlog.Int("boards_imported", len(boardMap)))
return nil
}
return err
}
dir, filename := filepath.Split(hdr.Name)
dir = path.Clean(dir)
switch filename {
case "version.json":
ver, errVer := parseVersionFile(zr)
if errVer != nil {
return errVer
}
if ver != archiveVersion {
return model.NewErrUnsupportedArchiveVersion(ver, archiveVersion)
}
case "board.jsonl":
boardID, err := a.ImportBoardJSONL(zr, opt)
if err != nil {
return fmt.Errorf("cannot import board %s: %w", dir, err)
}
boardMap[dir] = boardID
default:
// import file/image; dir is the old board id
boardID, ok := boardMap[dir]
if !ok {
a.logger.Warn("skipping orphan image in archive",
mlog.String("dir", dir),
mlog.String("filename", filename),
)
continue
}
// save file with original filename so it matches name in image block.
filePath := filepath.Join(opt.TeamID, boardID, filename)
_, err := a.filesBackend.WriteFile(zr, filePath)
if err != nil {
return fmt.Errorf("cannot import file %s for board %s: %w", filename, dir, err)
}
}
a.logger.Trace("import archive file",
mlog.String("dir", dir),
mlog.String("filename", filename),
)
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after importing an archive",
mlog.Err(err),
)
}
}()
}
}
// ImportBoardJSONL imports a JSONL file containing blocks for one board. The resulting
// board id is returned.
func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (string, error) {
// TODO: Stream this once `model.GenerateBlockIDs` can take a stream of blocks.
// We don't want to load the whole file in memory, even though it's a single board.
boardsAndBlocks := &model.BoardsAndBlocks{
Blocks: make([]*model.Block, 0, 10),
Boards: make([]*model.Board, 0, 10),
}
lineReader := bufio.NewReader(r)
userID := opt.ModifiedBy
if userID == model.SingleUser {
userID = ""
}
now := utils.GetMillis()
var boardID string
lineNum := 1
firstLine := true
for {
line, errRead := readLine(lineReader)
if len(line) != 0 {
var skip bool
if firstLine {
// first line might be a header tag (old archive format)
if strings.HasPrefix(string(line), legacyFileBegin) {
skip = true
}
}
if !skip {
var archiveLine model.ArchiveLine
if err := json.Unmarshal(line, &archiveLine); err != nil {
return "", fmt.Errorf("error parsing archive line %d: %w", lineNum, err)
}
// first line must be a board
if firstLine && archiveLine.Type == "block" {
archiveLine.Type = "board_block"
}
switch archiveLine.Type {
case "board":
var board model.Board
if err2 := json.Unmarshal(archiveLine.Data, &board); err2 != nil {
return "", fmt.Errorf("invalid board in archive line %d: %w", lineNum, err2)
}
board.ModifiedBy = userID
board.UpdateAt = now
board.TeamID = opt.TeamID
boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, &board)
boardID = board.ID
case "board_block":
// legacy archives encoded boards as blocks; we need to convert them to real boards.
var block *model.Block
if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil {
return "", fmt.Errorf("invalid board block in archive line %d: %w", lineNum, err2)
}
block.ModifiedBy = userID
block.UpdateAt = now
board, err := a.blockToBoard(block, opt)
if err != nil {
return "", fmt.Errorf("cannot convert archive line %d to block: %w", lineNum, err)
}
boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, board)
boardID = board.ID
case "block":
var block *model.Block
if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil {
return "", fmt.Errorf("invalid block in archive line %d: %w", lineNum, err2)
}
block.ModifiedBy = userID
block.UpdateAt = now
block.BoardID = boardID
boardsAndBlocks.Blocks = append(boardsAndBlocks.Blocks, block)
default:
return "", model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type)
}
firstLine = false
}
}
if errRead != nil {
if errors.Is(errRead, io.EOF) {
break
}
return "", fmt.Errorf("error reading archive line %d: %w", lineNum, errRead)
}
lineNum++
}
a.fixBoardsandBlocks(boardsAndBlocks, opt)
var err error
boardsAndBlocks, err = model.GenerateBoardsAndBlocksIDs(boardsAndBlocks, a.logger)
if err != nil {
return "", fmt.Errorf("error generating archive block IDs: %w", err)
}
boardsAndBlocks, err = a.CreateBoardsAndBlocks(boardsAndBlocks, opt.ModifiedBy, false)
if err != nil {
return "", fmt.Errorf("error inserting archive blocks: %w", err)
}
// add user to all the new boards (if not the fake system user).
if opt.ModifiedBy != model.SystemUserID {
for _, board := range boardsAndBlocks.Boards {
boardMember := &model.BoardMember{
BoardID: board.ID,
UserID: opt.ModifiedBy,
SchemeAdmin: true,
}
if _, err := a.AddMemberToBoard(boardMember); err != nil {
return "", fmt.Errorf("cannot add member to board: %w", err)
}
}
}
// find new board id
for _, board := range boardsAndBlocks.Boards {
return board.ID, nil
}
return "", fmt.Errorf("missing board in archive: %w", model.ErrInvalidBoardBlock)
}
// fixBoardsandBlocks allows the caller of `ImportArchive` to modify or filters boards and blocks being
// imported via callbacks.
func (a *App) fixBoardsandBlocks(boardsAndBlocks *model.BoardsAndBlocks, opt model.ImportArchiveOptions) {
if opt.BlockModifier == nil && opt.BoardModifier == nil {
return
}
modInfoCache := make(map[string]interface{})
modBoards := make([]*model.Board, 0, len(boardsAndBlocks.Boards))
modBlocks := make([]*model.Block, 0, len(boardsAndBlocks.Blocks))
for _, board := range boardsAndBlocks.Boards {
b := *board
if opt.BoardModifier != nil && !opt.BoardModifier(&b, modInfoCache) {
a.logger.Debug("skipping insert board per board modifier",
mlog.String("boardID", board.ID),
)
continue
}
modBoards = append(modBoards, &b)
}
for _, block := range boardsAndBlocks.Blocks {
b := block
if opt.BlockModifier != nil && !opt.BlockModifier(b, modInfoCache) {
a.logger.Debug("skipping insert block per block modifier",
mlog.String("blockID", block.ID),
)
continue
}
modBlocks = append(modBlocks, b)
}
boardsAndBlocks.Boards = modBoards
boardsAndBlocks.Blocks = modBlocks
}
// blockToBoard converts a `model.Block` to `model.Board`. Legacy archive formats encode boards as blocks
// and need conversion during import.
func (a *App) blockToBoard(block *model.Block, opt model.ImportArchiveOptions) (*model.Board, error) {
if block.Type != model.TypeBoard {
return nil, errBlockIsNotABoard
}
board := &model.Board{
ID: block.ID,
TeamID: opt.TeamID,
CreatedBy: block.CreatedBy,
ModifiedBy: block.ModifiedBy,
Type: model.BoardTypePrivate,
Title: block.Title,
CreateAt: block.CreateAt,
UpdateAt: block.UpdateAt,
DeleteAt: block.DeleteAt,
Properties: make(map[string]interface{}),
CardProperties: make([]map[string]interface{}, 0),
}
if icon, ok := stringValue(block.Fields, "icon"); ok {
board.Icon = icon
}
if description, ok := stringValue(block.Fields, "description"); ok {
board.Description = description
}
if showDescription, ok := boolValue(block.Fields, "showDescription"); ok {
board.ShowDescription = showDescription
}
if isTemplate, ok := boolValue(block.Fields, "isTemplate"); ok {
board.IsTemplate = isTemplate
}
if templateVer, ok := intValue(block.Fields, "templateVer"); ok {
board.TemplateVersion = templateVer
}
if properties, ok := mapValue(block.Fields, "properties"); ok {
board.Properties = properties
}
if cardProperties, ok := arrayMapsValue(block.Fields, "cardProperties"); ok {
board.CardProperties = cardProperties
}
return board, nil
}
func stringValue(m map[string]interface{}, key string) (string, bool) {
v, ok := m[key]
if !ok {
return "", false
}
s, ok := v.(string)
if !ok {
return "", false
}
return s, true
}
func boolValue(m map[string]interface{}, key string) (bool, bool) {
v, ok := m[key]
if !ok {
return false, false
}
b, ok := v.(bool)
if !ok {
return false, false
}
return b, true
}
func intValue(m map[string]interface{}, key string) (int, bool) {
v, ok := m[key]
if !ok {
return 0, false
}
i, ok := v.(int)
if !ok {
return 0, false
}
return i, true
}
func mapValue(m map[string]interface{}, key string) (map[string]interface{}, bool) {
v, ok := m[key]
if !ok {
return nil, false
}
mm, ok := v.(map[string]interface{})
if !ok {
return nil, false
}
return mm, true
}
func arrayMapsValue(m map[string]interface{}, key string) ([]map[string]interface{}, bool) {
v, ok := m[key]
if !ok {
return nil, false
}
ai, ok := v.([]interface{})
if !ok {
return nil, false
}
arr := make([]map[string]interface{}, 0, len(ai))
for _, mi := range ai {
mm, ok := mi.(map[string]interface{})
if !ok {
return nil, false
}
arr = append(arr, mm)
}
return arr, true
}
func parseVersionFile(r io.Reader) (int, error) {
file, err := io.ReadAll(r)
if err != nil {
return 0, fmt.Errorf("cannot read version.json: %w", err)
}
var header model.ArchiveHeader
if err := json.Unmarshal(file, &header); err != nil {
return 0, fmt.Errorf("cannot parse version.json: %w", err)
}
return header.Version, nil
}
func readLine(r *bufio.Reader) ([]byte, error) {
line, err := r.ReadBytes('\n')
line = bytes.TrimSpace(line)
return line, err
}