add csv export
This commit is contained in:
50
entries/bitwarden_csv.go
Normal file
50
entries/bitwarden_csv.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
61
entries/bitwarden_csv_test.go
Normal file
61
entries/bitwarden_csv_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"maxwarden/entries"
|
"maxwarden/entries"
|
||||||
"maxwarden/middleware"
|
"maxwarden/middleware"
|
||||||
@@ -22,8 +23,12 @@ func ExportBitwardenHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
renderView := func(errorMessage string) {
|
renderView := func(errorMessage string) {
|
||||||
AppLayout("Export Vault", *identity, session,
|
AppLayout("Export Vault", *identity, session,
|
||||||
Card(
|
Card(
|
||||||
Heading("Bitwarden JSON export"),
|
Heading("Bitwarden export"),
|
||||||
P(Text("Download your MaxWarden vault as a plaintext JSON file that can be imported into Bitwarden.")),
|
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(
|
P(
|
||||||
InlineStyle("$me { margin-top: $3; color: $color(red-700); font-weight: var(--font-weight-semibold); }"),
|
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."),
|
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(),
|
Required(),
|
||||||
),
|
),
|
||||||
Br(),
|
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)
|
).Render(w)
|
||||||
@@ -92,16 +101,38 @@ func ExportBitwardenHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
document, err := json.MarshalIndent(entries.NewBitwardenExport(secretList), "", " ")
|
format := r.Form.Get("format")
|
||||||
if err != nil {
|
var document []byte
|
||||||
http.Error(w, "Unable to create export", http.StatusInternalServerError)
|
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
|
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("Cache-Control", "no-store")
|
||||||
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
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("Pragma", "no-cache")
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Write(document)
|
w.Write(document)
|
||||||
|
|||||||
Reference in New Issue
Block a user