164 lines
4.7 KiB
Go
164 lines
4.7 KiB
Go
package csvimport
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
type Row struct {
|
|
SrcLogin string
|
|
SrcPass string
|
|
DstLogin string
|
|
DstPass string
|
|
}
|
|
|
|
func Parse(r io.Reader) ([]Row, error) {
|
|
cr := csv.NewReader(r)
|
|
cr.FieldsPerRecord = -1 // проверяем сами
|
|
|
|
var rows []Row
|
|
seen := map[string]bool{}
|
|
for {
|
|
rec, err := cr.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
line, _ := cr.FieldPos(0)
|
|
if len(rec) == 1 && strings.TrimSpace(rec[0]) == "" {
|
|
continue // encoding/csv уже пропускает голые пустые строки; это ветка ловит строки из одних пробелов
|
|
}
|
|
if len(rec) != 4 {
|
|
return nil, fmt.Errorf("line %d: expected 4 columns, got %d", line, len(rec))
|
|
}
|
|
for i := range rec {
|
|
rec[i] = strings.TrimSpace(rec[i])
|
|
if rec[i] == "" {
|
|
return nil, fmt.Errorf("line %d: column %d is empty", line, i+1)
|
|
}
|
|
}
|
|
if seen[rec[0]] {
|
|
return nil, fmt.Errorf("line %d: duplicate src_login %q", line, rec[0])
|
|
}
|
|
seen[rec[0]] = true
|
|
rows = append(rows, Row{SrcLogin: rec[0], SrcPass: rec[1], DstLogin: rec[2], DstPass: rec[3]})
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, fmt.Errorf("no rows parsed")
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// Kerio Connect exports users as a semicolon-separated file whose first line is
|
|
// a header. Only these columns matter; the rest (quotas, last login, …) is
|
|
// ignored. The file carries no domain — the caller supplies it.
|
|
const (
|
|
kerioColName = 0 // login without the domain part
|
|
kerioColDescription = 2 // Kerio keeps the plaintext password here
|
|
kerioColEnable = 3 // "Yes" / "No"
|
|
kerioMinColumns = 4
|
|
)
|
|
|
|
// domainRe accepts a bare DNS domain: labels of alphanumerics/hyphens with at
|
|
// least one dot and no scheme, user part, or whitespace.
|
|
var domainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
|
|
|
|
func normalizeDomain(domain string) (string, error) {
|
|
d := strings.ToLower(strings.TrimSpace(domain))
|
|
if d == "" {
|
|
return "", fmt.Errorf("domain is required")
|
|
}
|
|
if !domainRe.MatchString(d) {
|
|
return "", fmt.Errorf("invalid domain %q", domain)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// skipBOM consumes a leading UTF-8 byte-order mark, which Kerio writes into its
|
|
// exports and encoding/csv would otherwise glue onto the first header field.
|
|
func skipBOM(br *bufio.Reader) error {
|
|
b, err := br.Peek(3)
|
|
if err != nil && err != io.EOF {
|
|
return err
|
|
}
|
|
if len(b) == 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
|
|
_, _ = br.Discard(3)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ParseKerio reads a Kerio Connect user export and maps every enabled, non-admin
|
|
// account onto both sides of a migration: the login and password are identical
|
|
// on source and destination, only the server differs. Rows for disabled accounts
|
|
// and the built-in admin are skipped.
|
|
func ParseKerio(r io.Reader, domain string) ([]Row, error) {
|
|
d, err := normalizeDomain(domain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
br := bufio.NewReader(r)
|
|
if err := skipBOM(br); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cr := csv.NewReader(br)
|
|
cr.Comma = ';'
|
|
cr.FieldsPerRecord = -1 // проверяем сами
|
|
cr.LazyQuotes = true // FullName нередко содержит одиночную кавычку
|
|
|
|
header, err := cr.Read()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot read header: %w", err)
|
|
}
|
|
if len(header) < kerioMinColumns || !strings.EqualFold(strings.TrimSpace(header[kerioColName]), "Name") {
|
|
return nil, fmt.Errorf("not a Kerio export: expected a header starting with Name;FullName;Description;Enable")
|
|
}
|
|
|
|
var rows []Row
|
|
seen := map[string]bool{}
|
|
for {
|
|
rec, err := cr.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
line, _ := cr.FieldPos(0)
|
|
if len(rec) == 1 && strings.TrimSpace(rec[0]) == "" {
|
|
continue
|
|
}
|
|
if len(rec) < kerioMinColumns {
|
|
return nil, fmt.Errorf("line %d: expected at least %d columns, got %d", line, kerioMinColumns, len(rec))
|
|
}
|
|
name := strings.ToLower(strings.TrimSpace(rec[kerioColName]))
|
|
if name == "" {
|
|
return nil, fmt.Errorf("line %d: Name is empty", line)
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(rec[kerioColEnable]), "Yes") || name == "admin" {
|
|
continue
|
|
}
|
|
pass := strings.TrimSpace(rec[kerioColDescription])
|
|
if pass == "" {
|
|
return nil, fmt.Errorf("line %d: no password in the Description column for %q", line, name)
|
|
}
|
|
if seen[name] {
|
|
return nil, fmt.Errorf("line %d: duplicate Name %q", line, name)
|
|
}
|
|
seen[name] = true
|
|
login := name + "@" + d
|
|
rows = append(rows, Row{SrcLogin: login, SrcPass: pass, DstLogin: login, DstPass: pass})
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, fmt.Errorf("no enabled accounts found in the export")
|
|
}
|
|
return rows, nil
|
|
}
|