mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* fix: only match root-level JSONL files when importing a zip When importing a Mattermost export zip, the code iterated over all files to find the first .jsonl by extension. Exported attachments under data/ could themselves be .jsonl files, causing the import to pick an attachment as the manifest instead of the actual root-level JSONL file. Extract an IsRootJsonlFile helper in the imports package and use it in the import process worker, mmctl validator, and bulk import test to restrict the search to files with no directory component. * test: add integration test for import with nested JSONL decoy file Verify that BulkImportWithPath correctly picks the root-level JSONL manifest and ignores a decoy .jsonl inside a subdirectory, covering the fix from ad7f230f06.
1143 lines
28 KiB
Go
1143 lines
28 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package importer
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif" // image decoder
|
|
_ "image/jpeg" // image decoder
|
|
_ "image/png" // image decoder
|
|
"io"
|
|
"mime"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/v8/channels/app/imports"
|
|
_ "golang.org/x/image/webp" // image decoder
|
|
|
|
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
|
|
)
|
|
|
|
const (
|
|
SourceServer = "<server>"
|
|
SourceAdhoc = "<adhoc>"
|
|
)
|
|
|
|
type ChannelTeam struct {
|
|
Channel string
|
|
Team string
|
|
}
|
|
|
|
type Validator struct { //nolint:govet
|
|
archiveName string
|
|
onError func(*ImportValidationError) error
|
|
ignoreAttachments bool
|
|
createMissingTeams bool
|
|
checkServerDuplicates bool
|
|
|
|
serverTeams map[string]*model.Team
|
|
serverChannels map[ChannelTeam]*model.Channel
|
|
serverUsers map[string]*model.User
|
|
serverEmails map[string]*model.User
|
|
|
|
attachments map[string]*zip.File
|
|
attachmentsUsed map[string]uint64
|
|
allFileNames []string
|
|
|
|
roles map[string]ImportFileInfo
|
|
schemes map[string]ImportFileInfo
|
|
teams map[string]ImportFileInfo
|
|
channels map[ChannelTeam]ImportFileInfo
|
|
users map[string]ImportFileInfo
|
|
posts uint64
|
|
directChannels uint64
|
|
directPosts uint64
|
|
emojis map[string]ImportFileInfo
|
|
|
|
maxPostSize int
|
|
|
|
start time.Time
|
|
end time.Time
|
|
|
|
lines uint64
|
|
}
|
|
|
|
const (
|
|
LineTypeVersion = "version"
|
|
LineTypeRole = "role"
|
|
LineTypeScheme = "scheme"
|
|
LineTypeTeam = "team"
|
|
LineTypeChannel = "channel"
|
|
LineTypeUser = "user"
|
|
LineTypeBot = "bot"
|
|
LineTypePost = "post"
|
|
LineTypeDirectChannel = "direct_channel"
|
|
LineTypeDirectPost = "direct_post"
|
|
LineTypeEmoji = "emoji"
|
|
)
|
|
|
|
func NewValidator(
|
|
name string,
|
|
ignoreAttachments,
|
|
createMissingTeams bool,
|
|
checkServerDuplicates bool,
|
|
serverTeams map[string]*model.Team,
|
|
serverChannels map[ChannelTeam]*model.Channel,
|
|
serverUsers map[string]*model.User,
|
|
serverEmails map[string]*model.User,
|
|
maxPostSize int,
|
|
) *Validator {
|
|
v := &Validator{
|
|
archiveName: name,
|
|
onError: func(ivErr *ImportValidationError) error { return ivErr },
|
|
ignoreAttachments: ignoreAttachments,
|
|
createMissingTeams: createMissingTeams,
|
|
checkServerDuplicates: checkServerDuplicates,
|
|
|
|
serverTeams: serverTeams,
|
|
serverChannels: serverChannels,
|
|
serverUsers: serverUsers,
|
|
serverEmails: serverEmails,
|
|
|
|
attachments: make(map[string]*zip.File),
|
|
attachmentsUsed: make(map[string]uint64),
|
|
|
|
roles: map[string]ImportFileInfo{},
|
|
schemes: map[string]ImportFileInfo{},
|
|
teams: map[string]ImportFileInfo{},
|
|
channels: map[ChannelTeam]ImportFileInfo{},
|
|
users: map[string]ImportFileInfo{},
|
|
emojis: map[string]ImportFileInfo{},
|
|
|
|
maxPostSize: maxPostSize,
|
|
}
|
|
|
|
v.loadFromServer()
|
|
return v
|
|
}
|
|
|
|
func (v *Validator) Roles() uint64 {
|
|
return uint64(len(v.roles))
|
|
}
|
|
|
|
func (v *Validator) Schemes() uint64 {
|
|
return uint64(len(v.schemes))
|
|
}
|
|
|
|
func (v *Validator) CreatedTeams() []string {
|
|
createdTeams := make([]string, 0, len(v.teams))
|
|
for name, team := range v.teams {
|
|
if team.Source == SourceAdhoc {
|
|
createdTeams = append(createdTeams, name)
|
|
}
|
|
}
|
|
return createdTeams
|
|
}
|
|
|
|
func (v *Validator) TeamCount() uint64 {
|
|
return uint64(len(v.teams) - len(v.serverTeams))
|
|
}
|
|
|
|
func (v *Validator) ChannelCount() uint64 {
|
|
return uint64(len(v.channels) - len(v.serverChannels))
|
|
}
|
|
|
|
func (v *Validator) UserCount() uint64 {
|
|
return uint64(len(v.users) - len(v.serverUsers))
|
|
}
|
|
|
|
func (v *Validator) PostCount() uint64 {
|
|
return v.posts
|
|
}
|
|
|
|
func (v *Validator) DirectChannelCount() uint64 {
|
|
return v.directChannels
|
|
}
|
|
|
|
func (v *Validator) DirectPostCount() uint64 {
|
|
return v.directPosts
|
|
}
|
|
|
|
func (v *Validator) Emojis() uint64 {
|
|
return uint64(len(v.emojis))
|
|
}
|
|
|
|
func (v *Validator) StartTime() time.Time {
|
|
return v.start
|
|
}
|
|
|
|
func (v *Validator) EndTime() time.Time {
|
|
return v.end
|
|
}
|
|
|
|
func (v *Validator) Duration() time.Duration {
|
|
return v.end.Sub(v.start)
|
|
}
|
|
|
|
func (v *Validator) Lines() uint64 {
|
|
return v.lines
|
|
}
|
|
|
|
func (v *Validator) OnError(f func(*ImportValidationError) error) {
|
|
if f == nil {
|
|
f = func(ivErr *ImportValidationError) error { return ivErr }
|
|
}
|
|
|
|
v.onError = f
|
|
}
|
|
|
|
func (v *Validator) createTeam(name string) {
|
|
v.teams[name] = ImportFileInfo{
|
|
Source: SourceAdhoc,
|
|
}
|
|
}
|
|
|
|
func (v *Validator) loadFromServer() {
|
|
for name := range v.serverTeams {
|
|
v.teams[name] = ImportFileInfo{
|
|
Source: SourceServer,
|
|
}
|
|
}
|
|
for channelTeam := range v.serverChannels {
|
|
v.channels[channelTeam] = ImportFileInfo{
|
|
Source: SourceServer,
|
|
}
|
|
}
|
|
for name := range v.serverUsers {
|
|
v.users[name] = ImportFileInfo{
|
|
Source: SourceServer,
|
|
}
|
|
}
|
|
}
|
|
|
|
func (v *Validator) Validate() error {
|
|
v.start = time.Now()
|
|
defer func() {
|
|
v.end = time.Now()
|
|
}()
|
|
|
|
f, err := os.Open(v.archiveName)
|
|
if err != nil {
|
|
return fmt.Errorf("error opening the import file %q: %w", v.archiveName, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
stat, err := f.Stat()
|
|
if err != nil {
|
|
return fmt.Errorf("error reading the metadata the input file: %w", err)
|
|
}
|
|
|
|
z, err := zip.NewReader(f, stat.Size())
|
|
if err != nil {
|
|
return fmt.Errorf("error reading the ZIP file: %w", err)
|
|
}
|
|
|
|
var jsonlZip *zip.File
|
|
for _, zfile := range z.File {
|
|
if imports.IsRootJsonlFile(zfile.Name) {
|
|
jsonlZip = zfile
|
|
break
|
|
}
|
|
}
|
|
if jsonlZip == nil {
|
|
return fmt.Errorf("could not find a .jsonl file in the import archive")
|
|
}
|
|
|
|
if !v.ignoreAttachments {
|
|
for _, zfile := range z.File {
|
|
if zfile.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(zfile.Name, "data/") {
|
|
v.attachments[zfile.Name] = zfile
|
|
}
|
|
v.allFileNames = append(v.allFileNames, zfile.Name)
|
|
}
|
|
}
|
|
|
|
v.lines, err = v.countLines(jsonlZip)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
printer.PrintT("The .jsonl file has {{ .Total }} lines\n", struct {
|
|
Total uint64 `json:"total_lines"`
|
|
}{v.lines})
|
|
|
|
info := ImportFileInfo{
|
|
Source: filepath.Base(v.archiveName),
|
|
FileName: jsonlZip.Name,
|
|
TotalLines: v.lines,
|
|
}
|
|
|
|
err = v.validateLines(info, jsonlZip)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (v *Validator) countLines(zf *zip.File) (uint64, error) {
|
|
f, err := zf.Open()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("error counting the lines: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
buffer := make([]byte, 64*1024)
|
|
count := uint64(0)
|
|
|
|
for {
|
|
n, err := f.Read(buffer)
|
|
|
|
for _, c := range buffer[:n] {
|
|
if c == '\n' {
|
|
count++
|
|
}
|
|
}
|
|
|
|
printCount(count)
|
|
|
|
if err != nil {
|
|
if err == io.EOF {
|
|
err = nil
|
|
}
|
|
return count, err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (v *Validator) validateLines(info ImportFileInfo, zf *zip.File) error {
|
|
f, err := zf.Open()
|
|
if err != nil {
|
|
return fmt.Errorf("error validating the lines: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
s := bufio.NewScanner(f)
|
|
buf := make([]byte, 0, 64*1024)
|
|
s.Buffer(buf, 16*1024*1024)
|
|
|
|
for s.Scan() {
|
|
info.CurrentLine++
|
|
|
|
rawLine := s.Bytes()
|
|
|
|
// filter empty lines
|
|
rawLine = bytes.TrimSpace(rawLine)
|
|
if len(rawLine) == 0 {
|
|
if err = v.onError(&ImportValidationError{
|
|
ImportFileInfo: info,
|
|
Err: errors.New("unexpected empty line"),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// decode the line
|
|
var line imports.LineImportData
|
|
err = json.Unmarshal(rawLine, &line)
|
|
if err != nil {
|
|
if err = v.onError(&ImportValidationError{
|
|
ImportFileInfo: info,
|
|
Err: err,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
err = v.validateLine(info, line)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if info.CurrentLine%1024 == 0 {
|
|
printProgress(info.CurrentLine, info.TotalLines)
|
|
}
|
|
}
|
|
if err = s.Err(); err != nil {
|
|
if err = v.onError(&ImportValidationError{
|
|
ImportFileInfo: info,
|
|
Err: err,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
printProgress(info.TotalLines, info.TotalLines)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) validateLine(info ImportFileInfo, line imports.LineImportData) error {
|
|
var err error
|
|
|
|
// make sure the file starts with a version
|
|
if info.CurrentLine == 1 && line.Type != "version" {
|
|
if err = v.onError(&ImportValidationError{
|
|
ImportFileInfo: info,
|
|
Err: fmt.Errorf("first line has the wrong type: expected \"version\", got %q", line.Type),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
switch line.Type {
|
|
case LineTypeVersion:
|
|
err = v.validateVersion(info, line)
|
|
case LineTypeRole:
|
|
err = v.validateRole(info, line)
|
|
case LineTypeScheme:
|
|
err = v.validateScheme(info, line)
|
|
case LineTypeTeam:
|
|
err = v.validateTeam(info, line)
|
|
case LineTypeChannel:
|
|
err = v.validateChannel(info, line)
|
|
case LineTypeUser:
|
|
err = v.validateUser(info, line)
|
|
case LineTypeBot:
|
|
err = v.validateBot(info, line)
|
|
case LineTypePost:
|
|
err = v.validatePost(info, line)
|
|
case LineTypeDirectChannel:
|
|
err = v.validateDirectChannel(info, line)
|
|
case LineTypeDirectPost:
|
|
err = v.validateDirectPost(info, line)
|
|
case LineTypeEmoji:
|
|
err = v.validateEmoji(info, line)
|
|
default:
|
|
err = v.onError(&ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "type",
|
|
Err: fmt.Errorf("unknown import type %q", line.Type),
|
|
})
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (v *Validator) validateVersion(info ImportFileInfo, line imports.LineImportData) (err error) {
|
|
if info.CurrentLine != 1 {
|
|
if err = v.onError(&ImportValidationError{
|
|
ImportFileInfo: info,
|
|
Err: fmt.Errorf("version info must be the first line of the file"),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if line.Version == nil {
|
|
if err = v.onError(&ImportValidationError{
|
|
ImportFileInfo: info,
|
|
Err: fmt.Errorf("version must not be null or missing"),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
} else if *line.Version != 1 {
|
|
if err = v.onError(&ImportValidationError{
|
|
ImportFileInfo: info,
|
|
Err: fmt.Errorf("version must not be 1"),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) validateRole(info ImportFileInfo, line imports.LineImportData) (err error) {
|
|
ivErr := validateNotNil(info, "role", line.Role, func(data imports.RoleImportData) *ImportValidationError {
|
|
appErr := imports.ValidateRoleImportData(&data)
|
|
if appErr != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "role",
|
|
Err: appErr,
|
|
}
|
|
}
|
|
|
|
if data.Name != nil {
|
|
if existing, ok := v.roles[*data.Name]; ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "role",
|
|
Err: fmt.Errorf("duplicate entry, previous was in line: %d", existing.CurrentLine),
|
|
}
|
|
}
|
|
v.roles[*data.Name] = info
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if ivErr != nil {
|
|
return v.onError(ivErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) validateScheme(info ImportFileInfo, line imports.LineImportData) (err error) {
|
|
ivErr := validateNotNil(info, "scheme", line.Scheme, func(data imports.SchemeImportData) *ImportValidationError {
|
|
appErr := imports.ValidateSchemeImportData(&data)
|
|
if appErr != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "scheme",
|
|
Err: appErr,
|
|
}
|
|
}
|
|
|
|
if data.Name != nil {
|
|
if existing, ok := v.schemes[*data.Name]; ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "scheme",
|
|
Err: fmt.Errorf("duplicate entry, previous was in line: %d", existing.CurrentLine),
|
|
}
|
|
}
|
|
v.schemes[*data.Name] = info
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if ivErr != nil {
|
|
return v.onError(ivErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) checkDuplicateTeam(info ImportFileInfo, team string) *ImportValidationError {
|
|
if v.checkServerDuplicates {
|
|
if existing, ok := v.serverTeams[team]; ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "team",
|
|
Err: fmt.Errorf("duplicate entry, server already has a team named %q (display: %q, id: %s)", existing.Name, existing.DisplayName, existing.Id),
|
|
}
|
|
}
|
|
}
|
|
|
|
if existing, ok := v.teams[team]; ok && existing.Source != SourceServer {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "team",
|
|
Err: fmt.Errorf("duplicate entry, previous was in line: %d", existing.CurrentLine),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) validateTeam(info ImportFileInfo, line imports.LineImportData) (err error) {
|
|
ivErr := validateNotNil(info, "team", line.Team, func(data imports.TeamImportData) *ImportValidationError {
|
|
appErr := imports.ValidateTeamImportData(&data)
|
|
if appErr != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "team",
|
|
Err: appErr,
|
|
}
|
|
}
|
|
|
|
if data.Name != nil {
|
|
if ive := v.checkDuplicateTeam(info, *data.Name); ive != nil {
|
|
return ive
|
|
}
|
|
v.teams[*data.Name] = info
|
|
}
|
|
if data.Scheme != nil {
|
|
if _, ok := v.schemes[*data.Scheme]; !ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "team.scheme",
|
|
Err: fmt.Errorf("reference to unknown scheme %q", *data.Scheme),
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if ivErr != nil {
|
|
return v.onError(ivErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) checkDuplicateChannel(info ImportFileInfo, team, channel string) *ImportValidationError {
|
|
if v.checkServerDuplicates {
|
|
if existing, ok := v.serverChannels[ChannelTeam{Channel: channel, Team: team}]; ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "channel",
|
|
Err: fmt.Errorf("duplicate entry, server already has a channel %q (display: %q, id: %s) in team %q", existing.Name, existing.DisplayName, existing.Id, team),
|
|
}
|
|
}
|
|
}
|
|
|
|
if existing, ok := v.channels[ChannelTeam{Channel: channel, Team: team}]; ok && existing.Source != SourceServer {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "channel",
|
|
Err: fmt.Errorf("duplicate entry, previous was in line: %d", existing.CurrentLine),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) validateChannel(info ImportFileInfo, line imports.LineImportData) (err error) {
|
|
ivErr := validateNotNil(info, "channel", line.Channel, func(data imports.ChannelImportData) *ImportValidationError {
|
|
appErr := imports.ValidateChannelImportData(&data)
|
|
if appErr != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "channel",
|
|
Err: appErr,
|
|
}
|
|
}
|
|
|
|
if data.Team != nil {
|
|
if _, ok := v.teams[*data.Team]; !ok {
|
|
if v.createMissingTeams {
|
|
v.createTeam(*data.Team)
|
|
} else {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "channel.team",
|
|
Err: fmt.Errorf("reference to unknown team %q", *data.Team),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if data.Name != nil {
|
|
if ive := v.checkDuplicateChannel(info, *data.Team, *data.Name); ive != nil {
|
|
return ive
|
|
}
|
|
v.channels[ChannelTeam{Channel: *data.Name, Team: *data.Team}] = info
|
|
}
|
|
if data.Scheme != nil {
|
|
if _, ok := v.schemes[*data.Scheme]; !ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "channel.scheme",
|
|
Err: fmt.Errorf("reference to unknown scheme %q", *data.Scheme),
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if ivErr != nil {
|
|
return v.onError(ivErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) checkDuplicateUser(info ImportFileInfo, username, email string) *ImportValidationError {
|
|
if existing, ok := v.serverUsers[username]; ok {
|
|
if emailUser, ok := v.serverEmails[email]; ok {
|
|
if existing.Id != emailUser.Id {
|
|
// there is another user which already has this email address, this will result in
|
|
// an import errors regardless of the user merging.
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "user",
|
|
Err: fmt.Errorf("email address %q for %q is already used by another user %q (id: %s)", email, username, emailUser.Username, emailUser.Id),
|
|
}
|
|
}
|
|
}
|
|
|
|
if v.checkServerDuplicates {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "user",
|
|
Err: fmt.Errorf("duplicate entry, server already has a user %q (email: %q, id: %s)", existing.Username, existing.Email, existing.Id),
|
|
}
|
|
}
|
|
}
|
|
|
|
if existing, ok := v.users[username]; ok && existing.Source != SourceServer {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "user",
|
|
Err: fmt.Errorf("duplicate entry, previous was in line: %d", existing.CurrentLine),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) validateUser(info ImportFileInfo, line imports.LineImportData) (err error) {
|
|
ivErr := validateNotNil(info, "user", line.User, func(data imports.UserImportData) *ImportValidationError {
|
|
appErr := imports.ValidateUserImportData(&data)
|
|
if appErr != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "user",
|
|
Err: appErr,
|
|
}
|
|
}
|
|
|
|
if data.Username != nil {
|
|
if ive := v.checkDuplicateUser(info, *data.Username, *data.Email); ive != nil {
|
|
return ive
|
|
}
|
|
v.users[*data.Username] = info
|
|
}
|
|
if data.Teams != nil {
|
|
for i, team := range *data.Teams {
|
|
if _, ok := v.teams[*team.Name]; !ok {
|
|
if v.createMissingTeams {
|
|
v.createTeam(*team.Name)
|
|
} else {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: fmt.Sprintf("user.teams[%d]", i),
|
|
Err: fmt.Errorf("reference to unknown team %q", *team.Name),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if ivErr != nil {
|
|
return v.onError(ivErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) validateBot(info ImportFileInfo, line imports.LineImportData) (err error) {
|
|
ivErr := validateNotNil(info, "bot", line.Bot, func(data imports.BotImportData) *ImportValidationError {
|
|
appErr := imports.ValidateBotImportData(&data)
|
|
if appErr != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "bot",
|
|
Err: appErr,
|
|
}
|
|
}
|
|
|
|
if data.Username != nil {
|
|
// e-mails are for bots are converted to the the username@localhost format
|
|
// see model.BotFromUser
|
|
botMail := model.NormalizeEmail(fmt.Sprintf("%s@localhost", *data.Username))
|
|
if ive := v.checkDuplicateUser(info, *data.Username, botMail); ive != nil {
|
|
return ive
|
|
}
|
|
v.users[*data.Username] = info
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if ivErr != nil {
|
|
return v.onError(ivErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) validatePost(info ImportFileInfo, line imports.LineImportData) (err error) {
|
|
ivErr := validateNotNil(info, "post", line.Post, func(data imports.PostImportData) *ImportValidationError {
|
|
appErr := imports.ValidatePostImportData(&data, v.maxPostSize)
|
|
if appErr != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "post",
|
|
Err: appErr,
|
|
}
|
|
}
|
|
|
|
if data.Team != nil {
|
|
if _, ok := v.teams[*data.Team]; !ok {
|
|
if v.createMissingTeams {
|
|
v.createTeam(*data.Team)
|
|
} else {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "post.team",
|
|
Err: fmt.Errorf("reference to unknown team %q", *data.Team),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if data.Channel != nil {
|
|
if _, ok := v.channels[ChannelTeam{Channel: *data.Channel, Team: *data.Team}]; !ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "post.channel",
|
|
Err: fmt.Errorf("reference to unknown channel \"%s/%s\"", *data.Team, *data.Channel),
|
|
}
|
|
}
|
|
}
|
|
if data.User != nil {
|
|
if _, ok := v.users[*data.User]; !ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "post.user",
|
|
Err: fmt.Errorf("reference to unknown user %q", *data.User),
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if ivErr != nil {
|
|
if err = v.onError(ivErr); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !v.ignoreAttachments && line.Post != nil && line.Post.Attachments != nil {
|
|
for i, attachment := range *line.Post.Attachments {
|
|
if attachment.Path == nil {
|
|
continue
|
|
}
|
|
|
|
attachmentPath := *attachment.Path
|
|
if _, ok := v.attachments[attachmentPath]; !ok {
|
|
attachmentPath = path.Join("data", *attachment.Path)
|
|
}
|
|
|
|
if _, ok := v.attachments[attachmentPath]; !ok {
|
|
helpful := ""
|
|
candidates := v.findFileNameSuffix(*attachment.Path)
|
|
if len(candidates) != 0 {
|
|
helpful = "; we found a match outside the \"data/\" folder \"" + strings.Join(candidates, "\" or \"") + "\""
|
|
}
|
|
|
|
if err = v.onError(&ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: fmt.Sprintf("post.attachments[%d]", i),
|
|
Err: fmt.Errorf("missing attachment file %q%s", attachmentPath, helpful),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
v.attachmentsUsed[attachmentPath]++
|
|
}
|
|
}
|
|
}
|
|
|
|
v.posts++
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) validateDirectChannel(info ImportFileInfo, line imports.LineImportData) (err error) {
|
|
ivErr := validateNotNil(info, "direct_channel", line.DirectChannel, func(data imports.DirectChannelImportData) *ImportValidationError {
|
|
appErr := imports.ValidateDirectChannelImportData(&data)
|
|
if appErr != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "direct_channel",
|
|
Err: appErr,
|
|
}
|
|
}
|
|
|
|
if data.FavoritedBy != nil {
|
|
for i, favoritedBy := range *data.FavoritedBy {
|
|
if _, ok := v.users[favoritedBy]; !ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: fmt.Sprintf("direct_channel.favorited_by[%d]", i),
|
|
Err: fmt.Errorf("reference to unknown user %q", favoritedBy),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if data.Participants != nil {
|
|
for i, member := range data.Participants {
|
|
if _, ok := v.users[*member.Username]; !ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: fmt.Sprintf("direct_channel.members[%d]", i),
|
|
Err: fmt.Errorf("reference to unknown user %q", *member.Username),
|
|
}
|
|
}
|
|
}
|
|
} else if data.Members != nil {
|
|
for i, member := range *data.Members {
|
|
if _, ok := v.users[member]; !ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: fmt.Sprintf("direct_channel.members[%d]", i),
|
|
Err: fmt.Errorf("reference to unknown user %q", member),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if ivErr != nil {
|
|
if err = v.onError(ivErr); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
v.directChannels++
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) validateDirectPost(info ImportFileInfo, line imports.LineImportData) (err error) {
|
|
ivErr := validateNotNil(info, "direct_post", line.DirectPost, func(data imports.DirectPostImportData) *ImportValidationError {
|
|
appErr := imports.ValidateDirectPostImportData(&data, v.maxPostSize)
|
|
if appErr != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "post",
|
|
Err: appErr,
|
|
}
|
|
}
|
|
|
|
if data.User != nil {
|
|
if _, ok := v.users[*data.User]; !ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "direct_post.user",
|
|
Err: fmt.Errorf("reference to unknown user %q", *data.User),
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if ivErr != nil {
|
|
if err = v.onError(ivErr); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if line.DirectPost != nil && line.DirectPost.ChannelMembers != nil {
|
|
for i, member := range *line.DirectPost.ChannelMembers {
|
|
if _, ok := v.users[member]; !ok {
|
|
if err = v.onError(&ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: fmt.Sprintf("direct_post.channel_members[%d]", i),
|
|
Err: fmt.Errorf("reference to unknown user %q", member),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !v.ignoreAttachments && line.DirectPost != nil && line.DirectPost.Attachments != nil {
|
|
for i, attachment := range *line.DirectPost.Attachments {
|
|
if attachment.Path == nil {
|
|
continue
|
|
}
|
|
|
|
attachmentPath := *attachment.Path
|
|
if _, ok := v.attachments[attachmentPath]; !ok {
|
|
attachmentPath = path.Join("data", *attachment.Path)
|
|
}
|
|
|
|
if _, ok := v.attachments[attachmentPath]; !ok {
|
|
helpful := ""
|
|
candidates := v.findFileNameSuffix(*attachment.Path)
|
|
if len(candidates) != 0 {
|
|
helpful = "; we found a match outside the \"data/\" folder \"" + strings.Join(candidates, "\" or \"") + "\""
|
|
}
|
|
|
|
if err = v.onError(&ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: fmt.Sprintf("direct_post.attachments[%d]", i),
|
|
Err: fmt.Errorf("missing attachment file %q%s", attachmentPath, helpful),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
v.attachmentsUsed[attachmentPath]++
|
|
}
|
|
}
|
|
}
|
|
|
|
v.directPosts++
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) validateEmoji(info ImportFileInfo, line imports.LineImportData) (err error) {
|
|
ivErr := validateNotNil(info, "emoji", line.Emoji, func(data imports.EmojiImportData) *ImportValidationError {
|
|
appErr := imports.ValidateEmojiImportData(&data)
|
|
if appErr != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "emoji",
|
|
Err: appErr,
|
|
}
|
|
}
|
|
|
|
if data.Name != nil {
|
|
if existing, ok := v.emojis[*data.Name]; ok {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "emoji",
|
|
Err: fmt.Errorf("duplicate entry, previous was in line: %d", existing.CurrentLine),
|
|
}
|
|
}
|
|
v.emojis[*data.Name] = info
|
|
}
|
|
|
|
if !v.ignoreAttachments && data.Image != nil {
|
|
attachmentPath := path.Join("data", *data.Image)
|
|
|
|
zfile, ok := v.attachments[attachmentPath]
|
|
if !ok {
|
|
helpful := ""
|
|
candidates := v.findFileNameSuffix(*data.Image)
|
|
if len(candidates) != 0 {
|
|
helpful = "; we found a match outside the \"data/\" folder \"" + strings.Join(candidates, "\" or \"") + "\""
|
|
}
|
|
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "emoji.image",
|
|
Err: fmt.Errorf("missing image file for emoji %s: %q%s", *data.Name, attachmentPath, helpful),
|
|
}
|
|
}
|
|
|
|
return v.validateSupportedImage(info, zfile)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if ivErr != nil {
|
|
return v.onError(ivErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) Attachments() []string {
|
|
used := make([]string, 0, len(v.attachmentsUsed))
|
|
for attachment := range v.attachmentsUsed {
|
|
used = append(used, attachment)
|
|
}
|
|
sort.Strings(used)
|
|
return used
|
|
}
|
|
|
|
func (v *Validator) UnusedAttachments() []string {
|
|
var unused []string
|
|
for attachment := range v.attachments {
|
|
if _, ok := v.attachmentsUsed[attachment]; !ok {
|
|
unused = append(unused, attachment)
|
|
}
|
|
}
|
|
sort.Strings(unused)
|
|
return unused
|
|
}
|
|
|
|
func (v *Validator) validateSupportedImage(info ImportFileInfo, zfile *zip.File) *ImportValidationError {
|
|
f, err := zfile.Open()
|
|
if err != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "emoji.image",
|
|
Err: fmt.Errorf("error opening emoji image: %w", err),
|
|
}
|
|
}
|
|
defer f.Close()
|
|
|
|
if mime.TypeByExtension(strings.ToLower(path.Ext(zfile.Name))) == "image/svg+xml" {
|
|
var svg struct{}
|
|
err = xml.NewDecoder(f).Decode(&svg)
|
|
if err != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "emoji.image",
|
|
Err: fmt.Errorf("error decoding emoji SVG file: %w", err),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
_, _, err = image.Decode(f)
|
|
if err != nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: "emoji.image",
|
|
Err: fmt.Errorf("error decoding emoji image: %w", err),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateNotNil[T any](info ImportFileInfo, name string, value *T, then func(T) *ImportValidationError) *ImportValidationError {
|
|
if value == nil {
|
|
return &ImportValidationError{
|
|
ImportFileInfo: info,
|
|
FieldName: name,
|
|
Err: errors.New("field must not be null or missing"),
|
|
}
|
|
}
|
|
|
|
if then != nil {
|
|
return then(*value)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *Validator) findFileNameSuffix(name string) []string {
|
|
var candidates []string
|
|
for _, fileName := range v.allFileNames {
|
|
if strings.HasSuffix(fileName, name) {
|
|
candidates = append(candidates, fileName)
|
|
}
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
var progressTemplate = template.Must(template.New("").Parse("Progress: {{ .Current }}/{{ .Total }} ({{ printf \"%.2f\" .Percent }}%)\r"))
|
|
|
|
func printProgress(current, total uint64) {
|
|
percent := float64(current) * 100 / float64(total)
|
|
|
|
data := struct {
|
|
Current uint64 `json:"current_line"`
|
|
Total uint64 `json:"total_lines"`
|
|
Percent float64 `json:"percent"`
|
|
}{current, total, percent}
|
|
|
|
printer.PrintPreparedT(progressTemplate, data)
|
|
printer.Flush()
|
|
}
|
|
|
|
var countTemplate = template.Must(template.New("").Parse("Counting lines: {{ .Total }}\r"))
|
|
|
|
func printCount(total uint64) {
|
|
data := struct {
|
|
Total uint64 `json:"total_lines"`
|
|
}{total}
|
|
|
|
printer.PrintPreparedT(countTemplate, data)
|
|
printer.Flush()
|
|
}
|