diff --git a/.gitignore b/.gitignore index c022eab..ee65d4e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ # local cache of the impeccable design hook .impeccable/ **/.impeccable/ +*.csv \ No newline at end of file diff --git a/internal/csvimport/csvimport.go b/internal/csvimport/csvimport.go index 9d0c342..5b40a57 100644 --- a/internal/csvimport/csvimport.go +++ b/internal/csvimport/csvimport.go @@ -1,9 +1,11 @@ package csvimport import ( + "bufio" "encoding/csv" "fmt" "io" + "regexp" "strings" ) @@ -52,3 +54,110 @@ func Parse(r io.Reader) ([]Row, error) { } 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 +} diff --git a/internal/csvimport/csvimport_test.go b/internal/csvimport/csvimport_test.go index 82279a3..bbb581a 100644 --- a/internal/csvimport/csvimport_test.go +++ b/internal/csvimport/csvimport_test.go @@ -49,3 +49,100 @@ func TestParseZeroRowsErrors(t *testing.T) { t.Fatal("expected error when no rows parsed") } } + +const kerioHeader = "Name;FullName;Description;Enable;DataSource;Authentication;Role;Groups;MailAddress\n" + +func TestParseKerioOK(t *testing.T) { + in := kerioHeader + + "j.doe;Jane Doe;SrcPass11;Yes;Internal;Internal;No rights;all;j.doe\n" + + "k.smith;Kim Smith;SrcPass22;Yes;Internal;Internal;No rights;all;k.smith\n" + rows, err := ParseKerio(strings.NewReader(in), "example.test") + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(rows) != 2 { + t.Fatalf("want 2 rows, got %d: %+v", len(rows), rows) + } + want := Row{ + SrcLogin: "j.doe@example.test", SrcPass: "SrcPass11", + DstLogin: "j.doe@example.test", DstPass: "SrcPass11", + } + if rows[0] != want { + t.Fatalf("row 0: got %+v, want %+v", rows[0], want) + } +} + +func TestParseKerioSkipsDisabledAndAdmin(t *testing.T) { + in := kerioHeader + + "admin;;AdminPass1;Yes;Internal;Internal;Account admin;;admin\n" + + "o.disabled;;OffPass111;No;Internal;Internal;No rights;all;o.disabled\n" + + "info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n" + rows, err := ParseKerio(strings.NewReader(in), "example.test") + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(rows) != 1 || rows[0].SrcLogin != "info@example.test" { + t.Fatalf("only the enabled non-admin row must survive, got %+v", rows) + } +} + +func TestParseKerioStripsBOM(t *testing.T) { + in := "\ufeff" + kerioHeader + "info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n" + rows, err := ParseKerio(strings.NewReader(in), "example.test") + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(rows) != 1 { + t.Fatalf("want 1 row, got %+v", rows) + } +} + +func TestParseKerioRejectsMissingHeader(t *testing.T) { + in := "j.doe;Jane Doe;SrcPass11;Yes;Internal;Internal;No rights;all;j.doe\n" + if _, err := ParseKerio(strings.NewReader(in), "example.test"); err == nil { + t.Fatal("a file without the Kerio header must error") + } +} + +func TestParseKerioRejectsBadDomain(t *testing.T) { + in := kerioHeader + "info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n" + for _, domain := range []string{"", " ", "@example.test", "exam ple.test", "example", "info@example.test"} { + if _, err := ParseKerio(strings.NewReader(in), domain); err == nil { + t.Fatalf("domain %q must be rejected", domain) + } + } +} + +func TestParseKerioTrimsAndLowercasesDomain(t *testing.T) { + in := kerioHeader + "info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n" + rows, err := ParseKerio(strings.NewReader(in), " Example.TEST ") + if err != nil { + t.Fatalf("parse: %v", err) + } + if rows[0].SrcLogin != "info@example.test" { + t.Fatalf("domain must be trimmed and lowercased, got %q", rows[0].SrcLogin) + } +} + +func TestParseKerioRejectsEmptyPassword(t *testing.T) { + in := kerioHeader + "info;;;Yes;Internal;Internal;No rights;all;info\n" + if _, err := ParseKerio(strings.NewReader(in), "example.test"); err == nil { + t.Fatal("an enabled account without a password must error") + } +} + +func TestParseKerioRejectsDuplicateName(t *testing.T) { + in := kerioHeader + + "info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n" + + "info;;aaaaaaAa1;Yes;Internal;Internal;No rights;all;info\n" + if _, err := ParseKerio(strings.NewReader(in), "example.test"); err == nil { + t.Fatal("duplicate Name must error") + } +} + +func TestParseKerioZeroRowsErrors(t *testing.T) { + in := kerioHeader + "admin;;AdminPass1;Yes;Internal;Internal;Account admin;;admin\n" + if _, err := ParseKerio(strings.NewReader(in), "example.test"); err == nil { + t.Fatal("expected error when every row is filtered out") + } +} diff --git a/internal/httpapi/run.go b/internal/httpapi/run.go index 79583df..dbcecaa 100644 --- a/internal/httpapi/run.go +++ b/internal/httpapi/run.go @@ -14,6 +14,17 @@ import ( "github.com/vasyansk/imap-copier/internal/store" ) +// parseImportRows picks the CSV dialect from the "format" form field. A Kerio +// Connect export holds one login/password pair and no domain, so the operator +// supplies the domain alongside the file; anything else is the plain 4-column +// src/dst format. +func parseImportRows(r *http.Request, file io.Reader) ([]csvimport.Row, error) { + if r.FormValue("format") == "kerio" { + return csvimport.ParseKerio(file, r.FormValue("domain")) + } + return csvimport.Parse(file) +} + func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) { taskID, err := pathID(r, "id") if err != nil { @@ -26,7 +37,7 @@ func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) { return } defer file.Close() - rows, err := csvimport.Parse(file) + rows, err := parseImportRows(r, file) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return diff --git a/internal/httpapi/run_test.go b/internal/httpapi/run_test.go index fa23914..8c53ddf 100644 --- a/internal/httpapi/run_test.go +++ b/internal/httpapi/run_test.go @@ -1,7 +1,9 @@ package httpapi import ( + "io" "mime/multipart" + "net/http" "net/http/httptest" "strings" "testing" @@ -27,6 +29,60 @@ func TestImportCSVFailsOnBadEncKey(t *testing.T) { } } +// importReq builds a multipart import request with the given CSV payload and +// extra form fields, then returns it with the uploaded file ready to read. +func importReq(t *testing.T, csv string, fields map[string]string) (*http.Request, io.Reader) { + t.Helper() + body := &strings.Builder{} + mw := multipart.NewWriter(body) + for k, v := range fields { + _ = mw.WriteField(k, v) + } + fw, _ := mw.CreateFormFile("file", "a.csv") + fw.Write([]byte(csv)) + mw.Close() + req := httptest.NewRequest("POST", "/api/tasks/1/import", strings.NewReader(body.String())) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.SetPathValue("id", "1") + file, _, err := req.FormFile("file") + if err != nil { + t.Fatalf("form file: %v", err) + } + return req, file +} + +const kerioCSV = "Name;FullName;Description;Enable;DataSource\n" + + "info;;InfoPass11;Yes;Internal\n" + +func TestParseImportRowsUsesKerioParserWithDomain(t *testing.T) { + req, file := importReq(t, kerioCSV, map[string]string{"format": "kerio", "domain": "example.test"}) + rows, err := parseImportRows(req, file) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(rows) != 1 || rows[0].SrcLogin != "info@example.test" || rows[0].DstLogin != "info@example.test" { + t.Fatalf("kerio rows must carry the supplied domain on both sides, got %+v", rows) + } +} + +func TestParseImportRowsKerioRequiresDomain(t *testing.T) { + req, file := importReq(t, kerioCSV, map[string]string{"format": "kerio"}) + if _, err := parseImportRows(req, file); err == nil { + t.Fatal("kerio import without a domain must error") + } +} + +func TestParseImportRowsDefaultsToPlainFormat(t *testing.T) { + req, file := importReq(t, "a@x,p1,a@y,p2\n", nil) + rows, err := parseImportRows(req, file) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(rows) != 1 || rows[0].SrcLogin != "a@x" || rows[0].DstPass != "p2" { + t.Fatalf("no format field must keep the 4-column parser, got %+v", rows) + } +} + func TestParseRunAccountIDs(t *testing.T) { // empty body => nil (run all) req := httptest.NewRequest("POST", "/api/tasks/1/run", strings.NewReader("")) diff --git a/web/src/api.ts b/web/src/api.ts index 36a0073..db5c1cd 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -177,3 +177,13 @@ export const importCSV = (id: number, file: File) => { fd.append('file', file) return api<{ imported: number }>(`/api/tasks/${id}/import`, { method: 'POST', body: fd }) } + +// A Kerio Connect export carries no domain, so the operator supplies it here; +// the login and password apply to both sides of the migration. +export const importKerioCSV = (id: number, file: File, domain: string) => { + const fd = new FormData() + fd.append('file', file) + fd.append('format', 'kerio') + fd.append('domain', domain) + return api<{ imported: number }>(`/api/tasks/${id}/import`, { method: 'POST', body: fd }) +} diff --git a/web/src/components/KerioImportModal.tsx b/web/src/components/KerioImportModal.tsx new file mode 100644 index 0000000..99c3e6d --- /dev/null +++ b/web/src/components/KerioImportModal.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { Modal } from './Modal' + +// A bare DNS domain: no scheme, no user part, no whitespace. Mirrors the +// server-side check in csvimport.normalizeDomain so bad input is caught here. +const domainRe = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/ + +type Props = { + open: boolean + busy: boolean + onClose: () => void + onSubmit: (file: File, domain: string) => void +} + +export function KerioImportModal({ open, busy, onClose, onSubmit }: Props) { + const [domain, setDomain] = useState('') + const [file, setFile] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + if (!open) return + setDomain('') + setFile(null) + setError(null) + }, [open]) + + function submit(e: FormEvent) { + e.preventDefault() + const d = domain.trim().toLowerCase() + if (!domainRe.test(d)) { + setError('Enter a bare domain, e.g. galaxyhotel.kz') + return + } + if (!file) { + setError('Choose the Kerio export file') + return + } + setError(null) + onSubmit(file, d) + } + + return ( + +
+

+ The Kerio user export lists a login and its password but no domain. The domain you enter is appended to every + login and used for both the source and the destination. Disabled accounts and admin are skipped. +

+
+ + setDomain(e.target.value)} + placeholder="galaxyhotel.kz" + disabled={busy} + /> +
+
+ + setFile(e.target.files?.[0] ?? null)} + disabled={busy} + /> +
+ {error &&
{error}
} +
+ + +
+
+
+ ) +} diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx index 1ce3f64..ccaed86 100644 --- a/web/src/pages/TaskDetail.tsx +++ b/web/src/pages/TaskDetail.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react' -import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, setTaskSchedule, testAccounts, type TaskDetail as TaskDetailData } from '../api' +import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, importKerioCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, setTaskSchedule, testAccounts, type TaskDetail as TaskDetailData } from '../api' import { connectTaskWS, type TaskEvent } from '../ws' import { StatusBadge } from '../components/StatusBadge' import { useConfirm } from '../components/ConfirmProvider' import { FolderMappingModal } from '../components/FolderMappingModal' import { RunLogModal } from '../components/RunLogModal' import { AccountErrorsModal } from '../components/AccountErrorsModal' +import { KerioImportModal } from '../components/KerioImportModal' const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' } @@ -90,6 +91,7 @@ export function TaskDetail({ id }: { id: number }) { const [live, setLive] = useState>({}) const [showRuns, setShowRuns] = useState(false) const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null) + const [kerioOpen, setKerioOpen] = useState(false) const [selected, setSelected] = useState>(new Set()) const fileInputRef = useRef(null) @@ -288,6 +290,20 @@ export function TaskDetail({ id }: { id: number }) { } } + async function onKerioImport(file: File, domain: string) { + setBusy('import') + setError(null) + try { + await importKerioCSV(id, file, domain) + setKerioOpen(false) + reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'Kerio import failed') + } finally { + setBusy(null) + } + } + async function onDeleteAccount(accId: number, login: string) { const ok = await confirm({ title: 'Remove account', @@ -544,6 +560,9 @@ export function TaskDetail({ id }: { id: number }) { {busy === 'import' ? 'Importing…' : 'Upload CSV'} + @@ -768,6 +787,12 @@ export function TaskDetail({ id }: { id: number }) { )} setShowRuns(false)} /> setErrorsFor(null)} /> + setKerioOpen(false)} + onSubmit={onKerioImport} + /> ) }