From 30ae00d5f45195973892346e49e9c94ef56eea3c Mon Sep 17 00:00:00 2001 From: hermes Date: Mon, 3 Aug 2026 22:31:35 +0200 Subject: [PATCH] feat: add folderId, custom fields, PUT/DELETE secret, GET /folders --- cmd/api/main.go | 5 +- internal/handlers/handlers.go | 135 +++++++++++++++++++ internal/vaultwarden/api_client.go | 202 +++++++++++++++++++++++++++++ internal/vaultwarden/client.go | 85 ++++++++++++ 4 files changed, 426 insertions(+), 1 deletion(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index 00dd131..f0d01a3 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -95,7 +95,7 @@ func main() { app.Use(cors.New(cors.Config{ AllowOrigins: cfg.CORSAllowedOrigins, - AllowMethods: "GET,POST", + AllowMethods: "GET,POST,PUT,DELETE", AllowHeaders: "Authorization,Content-Type", AllowCredentials: false, })) @@ -123,6 +123,9 @@ func main() { api.Get("/secret/:name", h.GetSecret) api.Post("/secret", h.CreateSecret) + api.Put("/secret/:name", h.UpdateSecret) + api.Delete("/secret/:name", h.DeleteSecret) + api.Get("/folders", h.ListFolders) api.Post("/refresh", h.RefreshCache) // Graceful shutdown. diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 2073ce3..fd8de72 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -291,6 +291,7 @@ func (h *Handler) CreateSecret(c *fiber.Ctx) error { Password string `json:"password"` URI string `json:"uri"` Fields map[string]string `json:"fields"` + FolderID string `json:"folderId"` // Card (type 3) CardholderName string `json:"cardholderName"` Brand string `json:"brand"` @@ -344,6 +345,7 @@ func (h *Handler) CreateSecret(c *fiber.Ctx) error { Password: body.Password, URI: body.URI, Fields: body.Fields, + FolderID: body.FolderID, CardholderName: body.CardholderName, Brand: body.Brand, Number: body.Number, @@ -380,3 +382,136 @@ func (h *Handler) CreateSecret(c *fiber.Ctx) error { "type": body.Type, }) } + +// UpdateSecret handles PUT /secret/:name. Body (JSON) is the same as CreateSecret. +// It edits the existing item in place (same cipher ID). +func (h *Handler) UpdateSecret(c *fiber.Ctx) error { + name, err := decodeSecretPathParam(c.Params("name")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid secret name format"}) + } + if name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "secret name is required"}) + } + + var body struct { + Type int `json:"type"` + Name string `json:"name"` + Notes string `json:"notes"` + Username string `json:"username"` + Password string `json:"password"` + URI string `json:"uri"` + Fields map[string]string `json:"fields"` + FolderID string `json:"folderId"` + // Card (type 3) + CardholderName string `json:"cardholderName"` + Brand string `json:"brand"` + Number string `json:"number"` + ExpMonth string `json:"expMonth"` + ExpYear string `json:"expYear"` + Code string `json:"code"` + // Identity (type 4) + Title string `json:"title"` + FirstName string `json:"firstName"` + MiddleName string `json:"middleName"` + LastName string `json:"lastName"` + Address1 string `json:"address1"` + Address2 string `json:"address2"` + Address3 string `json:"address3"` + City string `json:"city"` + State string `json:"state"` + PostalCode string `json:"postalCode"` + Country string `json:"country"` + Company string `json:"company"` + Email string `json:"email"` + Phone string `json:"phone"` + SSN string `json:"ssn"` + Passport string `json:"passport"` + License string `json:"license"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON body"}) + } + + if body.Type == 0 { + body.Type = 1 + } + if body.Type < 1 || body.Type > 4 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "supported types: 1=Login, 2=SecureNote, 3=Card, 4=Identity"}) + } + + if err := h.vaultClient.UpdateSecret(name, vaultwarden.CreateSecretInput{ + Type: body.Type, + Name: body.Name, + Notes: body.Notes, + Username: body.Username, + Password: body.Password, + URI: body.URI, + Fields: body.Fields, + FolderID: body.FolderID, + CardholderName: body.CardholderName, + Brand: body.Brand, + Number: body.Number, + ExpMonth: body.ExpMonth, + ExpYear: body.ExpYear, + Code: body.Code, + Title: body.Title, + FirstName: body.FirstName, + MiddleName: body.MiddleName, + LastName: body.LastName, + Address1: body.Address1, + Address2: body.Address2, + Address3: body.Address3, + City: body.City, + State: body.State, + PostalCode: body.PostalCode, + Country: body.Country, + Company: body.Company, + Email: body.Email, + Phone: body.Phone, + SSN: body.SSN, + Passport: body.Passport, + License: body.License, + }); err != nil { + logger.Error.Printf("Failed to update secret %q: %v", name, err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{ + "status": "updated", + "name": name, + "type": body.Type, + }) +} + +// ListFolders handles GET /folders and returns decrypted folder name -> id map. +func (h *Handler) ListFolders(c *fiber.Ctx) error { + // Client stores folders as id -> name; invert for easy name lookup by clients. + byID := h.vaultClient.ListFolders() + byName := make(map[string]string, len(byID)) + for id, name := range byID { + byName[name] = id + } + return c.JSON(byName) +} + +// DeleteSecret handles DELETE /secret/:name. +func (h *Handler) DeleteSecret(c *fiber.Ctx) error { + name, err := decodeSecretPathParam(c.Params("name")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid secret name format"}) + } + if name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "secret name is required"}) + } + + if err := h.vaultClient.DeleteSecret(name); err != nil { + logger.Error.Printf("Failed to delete secret %q: %v", name, err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{ + "status": "deleted", + "name": name, + }) +} diff --git a/internal/vaultwarden/api_client.go b/internal/vaultwarden/api_client.go index 3ef929c..654b4cf 100644 --- a/internal/vaultwarden/api_client.go +++ b/internal/vaultwarden/api_client.go @@ -765,6 +765,7 @@ type CipherInput struct { Password string URI string Fields map[string]string // custom fields (login only) + FolderID string // optional folder UUID to place the item in // Card (type 3) CardholderName string Brand string // plaintext enum string, NOT encrypted @@ -823,6 +824,9 @@ func (ac *APIClient) CreateCipher(in CipherInput) (string, error) { "organizationId": nil, "collectionIds": []string{}, } + if in.FolderID != "" { + cipher["folderId"] = in.FolderID + } if in.Notes != "" { encNotes, e := EncryptStr(in.Notes, key) if e == nil { @@ -973,6 +977,204 @@ func (ac *APIClient) CreateCipher(in CipherInput) (string, error) { return out.ID, nil } +// DeleteCipher removes an existing vault item by its cipher ID. +func (ac *APIClient) DeleteCipher(id string) error { + if err := ac.EnsureValidToken(); err != nil { + return fmt.Errorf("ensure valid token: %w", err) + } + ac.mu.RLock() + token := ac.accessToken + ac.mu.RUnlock() + + req, err := http.NewRequest("DELETE", ac.baseURL+"/api/ciphers/"+id, nil) + if err != nil { + return fmt.Errorf("delete request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Bitwarden-Client-Version", "2024.1.0") + req.Header.Set("User-Agent", "Bitwarden/v2024.1.0") + req.Header.Set("Device-Type", "8") + req.Header.Set("Accept", "application/json") + + resp, err := ac.httpClient.Do(req) + if err != nil { + return fmt.Errorf("delete cipher request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("delete cipher failed (HTTP %d): %s", resp.StatusCode, string(b)) + } + return nil +} + +// UpdateCipher edits an existing vault item by its cipher ID. It re-encrypts +// the supplied fields and sends a PUT to /api/ciphers/{id} (same payload shape +// as CreateCipher). Fields left empty in CipherInput keep their existing values +// ONLY when re-fetched; for simplicity callers should supply the full desired state. +func (ac *APIClient) UpdateCipher(id string, in CipherInput) error { + if err := ac.EnsureValidToken(); err != nil { + return fmt.Errorf("ensure valid token: %w", err) + } + ac.mu.RLock() + token := ac.accessToken + key := ac.symKey + ac.mu.RUnlock() + + encName, err := EncryptStr(in.Name, key) + if err != nil { + return fmt.Errorf("encrypt name: %w", err) + } + cipher := map[string]interface{}{ + "type": in.Type, + "name": encName, + "notes": nil, + "login": nil, + "card": nil, + "identity": nil, + "fields": nil, + "reprompt": 0, + "edit": true, + "viewPassword": true, + "favorite": false, + "folderId": nil, + "organizationId": nil, + "collectionIds": []string{}, + } + if in.FolderID != "" { + cipher["folderId"] = in.FolderID + } + if in.Notes != "" { + if encNotes, e := EncryptStr(in.Notes, key); e == nil { + cipher["notes"] = encNotes + } + } + + if in.Type == CipherTypeLogin { + login := map[string]interface{}{} + if in.Username != "" { + if u, e := EncryptStr(in.Username, key); e == nil { + login["username"] = u + } + } + if in.Password != "" { + if pw, e := EncryptStr(in.Password, key); e == nil { + login["password"] = pw + } + } + if in.URI != "" { + if uri, e := EncryptStr(in.URI, key); e == nil { + login["uris"] = []map[string]interface{}{{"uri": uri, "match": nil}} + } + } + cipher["login"] = login + if len(in.Fields) > 0 { + fields := make([]map[string]interface{}, 0, len(in.Fields)) + for k, v := range in.Fields { + fk, e1 := EncryptStr(k, key) + fv, e2 := EncryptStr(v, key) + if e1 == nil && e2 == nil { + fields = append(fields, map[string]interface{}{"name": fk, "value": fv, "type": 0}) + } + } + cipher["fields"] = fields + } + } else if in.Type == CipherTypeSecureNote { + if in.Notes != "" { + if encNotes, e := EncryptStr(in.Notes, key); e == nil { + cipher["secureNote"] = map[string]interface{}{"notes": encNotes} + } + } + } else if in.Type == CipherTypeCard { + card := map[string]interface{}{} + if in.CardholderName != "" { + if s, e := EncryptStr(in.CardholderName, key); e == nil { + card["cardholderName"] = s + } + } + if in.Brand != "" { + card["brand"] = in.Brand + } + if in.Number != "" { + if s, e := EncryptStr(in.Number, key); e == nil { + card["number"] = s + } + } + if in.ExpMonth != "" { + card["expMonth"] = in.ExpMonth + } + if in.ExpYear != "" { + card["expYear"] = in.ExpYear + } + if in.Code != "" { + if s, e := EncryptStr(in.Code, key); e == nil { + card["code"] = s + } + } + cipher["card"] = card + } else if in.Type == CipherTypeIdentity { + idn := map[string]interface{}{} + enc := func(field, val string) { + if val == "" { + return + } + if s, e := EncryptStr(val, key); e == nil { + idn[field] = s + } + } + enc("title", in.Title) + enc("firstName", in.FirstName) + enc("middleName", in.MiddleName) + enc("lastName", in.LastName) + enc("address1", in.Address1) + enc("address2", in.Address2) + enc("address3", in.Address3) + enc("city", in.City) + enc("state", in.State) + enc("postalCode", in.PostalCode) + enc("country", in.Country) + enc("company", in.Company) + enc("email", in.Email) + enc("phone", in.Phone) + enc("username", in.Username) + enc("ssn", in.SSN) + enc("passportNumber", in.Passport) + enc("licenseNumber", in.License) + cipher["identity"] = idn + } else { + return fmt.Errorf("cipher type %d write not supported yet", in.Type) + } + + body, err := json.Marshal(cipher) + if err != nil { + return fmt.Errorf("marshal cipher: %w", err) + } + + req, err := http.NewRequest("PUT", ac.baseURL+"/api/ciphers/"+id, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("update request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Bitwarden-Client-Version", "2024.1.0") + req.Header.Set("User-Agent", "Bitwarden/v2024.1.0") + req.Header.Set("Device-Type", "8") + req.Header.Set("Accept", "application/json") + + resp, err := ac.httpClient.Do(req) + if err != nil { + return fmt.Errorf("update cipher request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("update cipher failed (HTTP %d): %s", resp.StatusCode, string(b)) + } + return nil +} + // fetchProfileKey gets the encrypted symmetric key from the user's profile. // Used when API key login doesn't return the Key in the token response. func (ac *APIClient) fetchProfileKey() (string, error) { diff --git a/internal/vaultwarden/client.go b/internal/vaultwarden/client.go index 0627ba3..ee1e865 100644 --- a/internal/vaultwarden/client.go +++ b/internal/vaultwarden/client.go @@ -206,6 +206,7 @@ type CreateSecretInput struct { Password string URI string Fields map[string]string + FolderID string // optional folder UUID // Card (type 3) CardholderName string Brand string @@ -247,6 +248,7 @@ func (c *Client) CreateSecret(in CreateSecretInput) (string, error) { Password: in.Password, URI: in.URI, Fields: in.Fields, + FolderID: in.FolderID, CardholderName: in.CardholderName, Brand: in.Brand, Number: in.Number, @@ -279,6 +281,82 @@ func (c *Client) CreateSecret(in CreateSecretInput) (string, error) { return id, nil } +// GetSecretID resolves a cipher ID from its name (case-insensitive exact/partial match). +func (c *Client) GetSecretID(name string) (string, error) { + item, err := c.getSecretItem(name, SecretFilter{}) + if err != nil { + return "", err + } + return item.ID, nil +} + +// UpdateSecret edits an existing secret identified by name. It resolves the +// cipher ID, then sends a PUT with the supplied fields. Empty fields in the +// input are NOT merged with existing values — callers should pass the full +// desired state (or fetch-then-merge if partial updates are needed). +func (c *Client) UpdateSecret(name string, in CreateSecretInput) error { + if c.api == nil { + return fmt.Errorf("vault API client not configured") + } + id, err := c.GetSecretID(name) + if err != nil { + return err + } + if err := c.api.UpdateCipher(id, CipherInput{ + Type: in.Type, + Name: in.Name, + Notes: in.Notes, + Username: in.Username, + Password: in.Password, + URI: in.URI, + Fields: in.Fields, + FolderID: in.FolderID, + CardholderName: in.CardholderName, + Brand: in.Brand, + Number: in.Number, + ExpMonth: in.ExpMonth, + ExpYear: in.ExpYear, + Code: in.Code, + Title: in.Title, + FirstName: in.FirstName, + MiddleName: in.MiddleName, + LastName: in.LastName, + Address1: in.Address1, + Address2: in.Address2, + Address3: in.Address3, + City: in.City, + State: in.State, + PostalCode: in.PostalCode, + Country: in.Country, + Company: in.Company, + Email: in.Email, + Phone: in.Phone, + SSN: in.SSN, + Passport: in.Passport, + License: in.License, + }); err != nil { + return err + } + c.ClearCache() + return nil +} + +// DeleteSecret removes a secret identified by name. +func (c *Client) DeleteSecret(name string) error { + if c.api == nil { + return fmt.Errorf("vault API client not configured") + } + id, err := c.GetSecretID(name) + if err != nil { + return err + } + if err := c.api.DeleteCipher(id); err != nil { + return err + } + c.ClearCache() + return nil +} + // Stop stops the background sync goroutine. func (c *Client) Stop() { close(c.stopSync) @@ -296,6 +374,13 @@ func (c *Client) NameMaps() SyncNameMaps { } } +// ListFolders returns decrypted folder name -> id mappings from the last sync. +func (c *Client) ListFolders() map[string]string { + c.mu.RLock() + defer c.mu.RUnlock() + return maps.Clone(c.nameMaps.Folders) +} + // syncVault fetches and decrypts all items from the vault. func (c *Client) syncVault() error { items, nameMaps, err := c.api.Sync()