diff --git a/entries/bitwarden_csv.go b/entries/bitwarden_csv.go new file mode 100644 index 0000000..634af4f --- /dev/null +++ b/entries/bitwarden_csv.go @@ -0,0 +1,50 @@ +package entries + +import ( + "encoding/csv" + "io" +) + +// WriteBitwardenCSV writes Bitwarden's native CSV interchange format. +// CSV is intentionally offered alongside JSON because Bitwarden routes the two +// formats through different importers. +func WriteBitwardenCSV(w io.Writer, secrets []Secret) error { + csvWriter := csv.NewWriter(w) + + if err := csvWriter.Write([]string{ + "folder", + "favorite", + "type", + "name", + "notes", + "fields", + "reprompt", + "login_uri", + "login_username", + "login_password", + "login_totp", + }); err != nil { + return err + } + + for _, secret := range secrets { + if err := csvWriter.Write([]string{ + "", + "0", + "login", + secret.Description, + secret.Notes, + "", + "0", + secret.URL, + secret.Username, + secret.Password, + "", + }); err != nil { + return err + } + } + + csvWriter.Flush() + return csvWriter.Error() +} diff --git a/entries/bitwarden_csv_test.go b/entries/bitwarden_csv_test.go new file mode 100644 index 0000000..565d18a --- /dev/null +++ b/entries/bitwarden_csv_test.go @@ -0,0 +1,61 @@ +package entries + +import ( + "encoding/csv" + "strings" + "testing" +) + +func TestWriteBitwardenCSV(t *testing.T) { + secrets := []Secret{{ + Description: `Example, "personal"`, + URL: "https://example.com/login?a=1,b=2", + Notes: "First line\nSecond line", + Username: "person@example.com", + Password: `correct,"quoted" password`, + }} + + var output strings.Builder + if err := WriteBitwardenCSV(&output, secrets); err != nil { + t.Fatalf("write export: %v", err) + } + + records, err := csv.NewReader(strings.NewReader(output.String())).ReadAll() + if err != nil { + t.Fatalf("read generated CSV: %v", err) + } + if len(records) != 2 { + t.Fatalf("expected header and one item, got %d rows", len(records)) + } + + wantHeader := []string{ + "folder", "favorite", "type", "name", "notes", "fields", "reprompt", + "login_uri", "login_username", "login_password", "login_totp", + } + if strings.Join(records[0], "|") != strings.Join(wantHeader, "|") { + t.Fatalf("unexpected header: %#v", records[0]) + } + + row := records[1] + if row[2] != "login" || row[3] != secrets[0].Description || row[4] != secrets[0].Notes { + t.Fatalf("item metadata was not preserved: %#v", row) + } + if row[7] != secrets[0].URL || row[8] != secrets[0].Username || row[9] != secrets[0].Password { + t.Fatalf("login fields were not preserved: %#v", row) + } +} + +func TestWriteBitwardenCSVWithNoSecrets(t *testing.T) { + var output strings.Builder + if err := WriteBitwardenCSV(&output, nil); err != nil { + t.Fatalf("write export: %v", err) + } + + records, err := csv.NewReader(strings.NewReader(output.String())).ReadAll() + if err != nil { + t.Fatalf("read generated CSV: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected only the header, got %d rows", len(records)) + } +} diff --git a/handlers/app/export_bitwarden.go b/handlers/app/export_bitwarden.go index ff84b49..90fcefb 100644 --- a/handlers/app/export_bitwarden.go +++ b/handlers/app/export_bitwarden.go @@ -1,6 +1,7 @@ package app import ( + "bytes" "encoding/json" "maxwarden/entries" "maxwarden/middleware" @@ -22,8 +23,12 @@ func ExportBitwardenHandler(w http.ResponseWriter, r *http.Request) { renderView := func(errorMessage string) { AppLayout("Export Vault", *identity, session, Card( - Heading("Bitwarden JSON export"), - P(Text("Download your MaxWarden vault as a plaintext JSON file that can be imported into Bitwarden.")), + Heading("Bitwarden export"), + P(Text("Download your MaxWarden vault in one of Bitwarden's native import formats.")), + P( + InlineStyle("$me { margin-top: $3; }"), + Text("In Bitwarden, choose the File format that exactly matches the download: Bitwarden (csv) or Bitwarden (json). CSV is recommended if Bitwarden reports a generic JSON import error."), + ), P( InlineStyle("$me { margin-top: $3; color: $color(red-700); font-weight: var(--font-weight-semibold); }"), Text("Warning: the downloaded file contains all usernames and passwords in plaintext. Store it securely and delete it when you are finished."), @@ -46,7 +51,11 @@ func ExportBitwardenHandler(w http.ResponseWriter, r *http.Request) { Required(), ), Br(), - ButtonUIDanger(Type("submit"), Text("Download plaintext export")), + Div( + InlineStyle("$me { display: flex; flex-wrap: wrap; gap: $3; margin-top: $3; }"), + ButtonUIDanger(Type("submit"), Name("format"), Value("csv"), Text("Download Bitwarden CSV (recommended)")), + ButtonUIOutline(Type("submit"), Name("format"), Value("json"), Text("Download Bitwarden JSON")), + ), ), ), ).Render(w) @@ -92,16 +101,38 @@ func ExportBitwardenHandler(w http.ResponseWriter, r *http.Request) { return } - document, err := json.MarshalIndent(entries.NewBitwardenExport(secretList), "", " ") - if err != nil { - http.Error(w, "Unable to create export", http.StatusInternalServerError) + format := r.Form.Get("format") + var document []byte + var extension string + var contentType string + + switch format { + case "", "csv": + var csvDocument bytes.Buffer + if err := entries.WriteBitwardenCSV(&csvDocument, secretList); err != nil { + http.Error(w, "Unable to create export", http.StatusInternalServerError) + return + } + document = csvDocument.Bytes() + extension = "csv" + contentType = "text/csv; charset=utf-8" + case "json": + document, err = json.MarshalIndent(entries.NewBitwardenExport(secretList), "", " ") + if err != nil { + http.Error(w, "Unable to create export", http.StatusInternalServerError) + return + } + extension = "json" + contentType = "application/json; charset=utf-8" + default: + http.Error(w, "Unsupported export format", http.StatusBadRequest) return } - filename := "maxwarden-bitwarden-export-" + time.Now().UTC().Format("2006-01-02") + ".json" + filename := "maxwarden-bitwarden-export-" + time.Now().UTC().Format("2006-01-02") + "." + extension w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) - w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Content-Type", contentType) w.Header().Set("Pragma", "no-cache") w.Header().Set("X-Content-Type-Options", "nosniff") w.Write(document)