diff --git a/internal/httpapi/accounts.go b/internal/httpapi/accounts.go new file mode 100644 index 0000000..d86a9a6 --- /dev/null +++ b/internal/httpapi/accounts.go @@ -0,0 +1,68 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/vasyansk/imap-copier/internal/crypto" + "github.com/vasyansk/imap-copier/internal/store" +) + +type AccountView struct { + ID int64 `json:"id"` + SrcLogin string `json:"src_login"` + DstLogin string `json:"dst_login"` + TestSrcStatus string `json:"test_src_status"` + TestDstStatus string `json:"test_dst_status"` + Status string `json:"status"` + Copied int64 `json:"copied"` + Skipped int64 `json:"skipped"` + Errors int64 `json:"errors"` +} + +func accountDTO(a store.Account) AccountView { + return AccountView{ + ID: a.ID, SrcLogin: a.SrcLogin, DstLogin: a.DstLogin, + TestSrcStatus: a.TestSrcStatus, TestDstStatus: a.TestDstStatus, + Status: a.Status, Copied: a.Copied, Skipped: a.Skipped, Errors: a.Errors, + } +} + +func pathID(r *http.Request, name string) (int64, error) { + return strconv.ParseInt(r.PathValue(name), 10, 64) +} + +func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) { + taskID, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + var body struct { + SrcLogin, SrcPass, DstLogin, DstPass string + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + srcEnc, err := crypto.Encrypt(s.cfg.EncKey, []byte(body.SrcPass)) + if err != nil { + http.Error(w, "encrypt", http.StatusInternalServerError) + return + } + dstEnc, err := crypto.Encrypt(s.cfg.EncKey, []byte(body.DstPass)) + if err != nil { + http.Error(w, "encrypt", http.StatusInternalServerError) + return + } + id, err := s.store.CreateAccount(r.Context(), store.Account{ + TaskID: taskID, SrcLogin: body.SrcLogin, SrcPassEnc: srcEnc, + DstLogin: body.DstLogin, DstPassEnc: dstEnc, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, map[string]int64{"id": id}) +} diff --git a/internal/httpapi/dto_test.go b/internal/httpapi/dto_test.go new file mode 100644 index 0000000..35bcf74 --- /dev/null +++ b/internal/httpapi/dto_test.go @@ -0,0 +1,21 @@ +package httpapi + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/vasyansk/imap-copier/internal/store" +) + +func TestAccountDTOHidesPasswords(t *testing.T) { + a := store.Account{ID: 1, SrcLogin: "u", SrcPassEnc: "SECRET_ENC", DstLogin: "v", DstPassEnc: "SECRET2"} + b, _ := json.Marshal(accountDTO(a)) + s := string(b) + if strings.Contains(s, "SECRET_ENC") || strings.Contains(s, "SECRET2") || strings.Contains(strings.ToLower(s), "pass") { + t.Fatalf("DTO leaks password material: %s", s) + } + if !strings.Contains(s, `"src_login":"u"`) { + t.Fatalf("DTO missing login: %s", s) + } +} diff --git a/internal/httpapi/endpoints.go b/internal/httpapi/endpoints.go new file mode 100644 index 0000000..2bbf885 --- /dev/null +++ b/internal/httpapi/endpoints.go @@ -0,0 +1,41 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + + "github.com/vasyansk/imap-copier/internal/store" +) + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func (s *Server) handleCreateEndpoint(w http.ResponseWriter, r *http.Request) { + var e store.Endpoint + if err := json.NewDecoder(r.Body).Decode(&e); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if e.TLSMode != "ssl" && e.TLSMode != "starttls" && e.TLSMode != "plain" { + http.Error(w, "tls_mode must be ssl|starttls|plain", http.StatusBadRequest) + return + } + id, err := s.store.CreateEndpoint(r.Context(), e) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, map[string]int64{"id": id}) +} + +func (s *Server) handleListEndpoints(w http.ResponseWriter, r *http.Request) { + eps, err := s.store.ListEndpoints(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, eps) +} diff --git a/internal/httpapi/run.go b/internal/httpapi/run.go new file mode 100644 index 0000000..45f84f4 --- /dev/null +++ b/internal/httpapi/run.go @@ -0,0 +1,74 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + + "github.com/vasyansk/imap-copier/internal/crypto" + "github.com/vasyansk/imap-copier/internal/csvimport" + "github.com/vasyansk/imap-copier/internal/orchestrator" + "github.com/vasyansk/imap-copier/internal/store" +) + +func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) { + taskID, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + file, _, err := r.FormFile("file") + if err != nil { + http.Error(w, "file required", http.StatusBadRequest) + return + } + defer file.Close() + rows, err := csvimport.Parse(file) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + for _, row := range rows { + srcEnc, _ := crypto.Encrypt(s.cfg.EncKey, []byte(row.SrcPass)) + dstEnc, _ := crypto.Encrypt(s.cfg.EncKey, []byte(row.DstPass)) + if _, err := s.store.CreateAccount(r.Context(), store.Account{ + TaskID: taskID, SrcLogin: row.SrcLogin, SrcPassEnc: srcEnc, + DstLogin: row.DstLogin, DstPassEnc: dstEnc, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + writeJSON(w, http.StatusCreated, map[string]int{"imported": len(rows)}) +} + +func (s *Server) handleTestAccounts(w http.ResponseWriter, r *http.Request) { + taskID, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + // Detach from the request context: the request context is cancelled when + // this handler returns, which would otherwise kill the background test run. + ctx := context.WithoutCancel(r.Context()) + go s.orch.TestAccounts(ctx, taskID) // прогресс через WS + w.WriteHeader(http.StatusAccepted) +} + +func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { + taskID, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + runID, err := s.orch.Run(r.Context(), taskID) + if errors.Is(err, orchestrator.ErrNotTested) { + http.Error(w, "accounts must pass connection tests first", http.StatusConflict) + return + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusAccepted, map[string]int64{"run_id": runID}) +} diff --git a/internal/httpapi/tasks.go b/internal/httpapi/tasks.go new file mode 100644 index 0000000..b1a84fe --- /dev/null +++ b/internal/httpapi/tasks.go @@ -0,0 +1,54 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + + "github.com/vasyansk/imap-copier/internal/store" +) + +func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { + var t store.Task + if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + id, err := s.store.CreateTask(r.Context(), t) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, map[string]int64{"id": id}) +} + +func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) { + tasks, err := s.store.ListTasks(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, tasks) +} + +func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + task, err := s.store.GetTask(r.Context(), id) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + accs, err := s.store.ListAccountsByTask(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + views := make([]AccountView, 0, len(accs)) + for _, a := range accs { + views = append(views, accountDTO(a)) + } + writeJSON(w, http.StatusOK, map[string]any{"task": task, "accounts": views}) +}