feat: add folderId, custom fields, PUT/DELETE secret, GET /folders
CI / test (push) Has been cancelled
CI / docker (push) Has been cancelled

This commit is contained in:
hermes committed 2026-08-03 22:31:35 +02:00
1 parent 0ede8f145d
commit 30ae00d5f4
4 files changed
+426 -1

No files matched your search

+4 -1
View File
@@ -95,7 +95,7 @@ func main() {
app.Use(cors.New(cors.Config{ app.Use(cors.New(cors.Config{
AllowOrigins: cfg.CORSAllowedOrigins, AllowOrigins: cfg.CORSAllowedOrigins,
AllowMethods: "GET,POST", AllowMethods: "GET,POST,PUT,DELETE",
AllowHeaders: "Authorization,Content-Type", AllowHeaders: "Authorization,Content-Type",
AllowCredentials: false, AllowCredentials: false,
})) }))
@@ -123,6 +123,9 @@ func main() {
api.Get("/secret/:name", h.GetSecret) api.Get("/secret/:name", h.GetSecret)
api.Post("/secret", h.CreateSecret) 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) api.Post("/refresh", h.RefreshCache)
// Graceful shutdown. // Graceful shutdown.
+135
View File
@@ -291,6 +291,7 @@ func (h *Handler) CreateSecret(c *fiber.Ctx) error {
Password string `json:"password"` Password string `json:"password"`
URI string `json:"uri"` URI string `json:"uri"`
Fields map[string]string `json:"fields"` Fields map[string]string `json:"fields"`
FolderID string `json:"folderId"`
// Card (type 3) // Card (type 3)
CardholderName string `json:"cardholderName"` CardholderName string `json:"cardholderName"`
Brand string `json:"brand"` Brand string `json:"brand"`
@@ -344,6 +345,7 @@ func (h *Handler) CreateSecret(c *fiber.Ctx) error {
Password: body.Password, Password: body.Password,
URI: body.URI, URI: body.URI,
Fields: body.Fields, Fields: body.Fields,
FolderID: body.FolderID,
CardholderName: body.CardholderName, CardholderName: body.CardholderName,
Brand: body.Brand, Brand: body.Brand,
Number: body.Number, Number: body.Number,
@@ -380,3 +382,136 @@ func (h *Handler) CreateSecret(c *fiber.Ctx) error {
"type": body.Type, "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,
})
}
+202
View File
@@ -765,6 +765,7 @@ type CipherInput struct {
Password string Password string
URI string URI string
Fields map[string]string // custom fields (login only) Fields map[string]string // custom fields (login only)
FolderID string // optional folder UUID to place the item in
// Card (type 3) // Card (type 3)
CardholderName string CardholderName string
Brand string // plaintext enum string, NOT encrypted Brand string // plaintext enum string, NOT encrypted
@@ -823,6 +824,9 @@ func (ac *APIClient) CreateCipher(in CipherInput) (string, error) {
"organizationId": nil, "organizationId": nil,
"collectionIds": []string{}, "collectionIds": []string{},
} }
if in.FolderID != "" {
cipher["folderId"] = in.FolderID
}
if in.Notes != "" { if in.Notes != "" {
encNotes, e := EncryptStr(in.Notes, key) encNotes, e := EncryptStr(in.Notes, key)
if e == nil { if e == nil {
@@ -973,6 +977,204 @@ func (ac *APIClient) CreateCipher(in CipherInput) (string, error) {
return out.ID, nil 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. // 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. // Used when API key login doesn't return the Key in the token response.
func (ac *APIClient) fetchProfileKey() (string, error) { func (ac *APIClient) fetchProfileKey() (string, error) {
+85
View File
@@ -206,6 +206,7 @@ type CreateSecretInput struct {
Password string Password string
URI string URI string
Fields map[string]string Fields map[string]string
FolderID string // optional folder UUID
// Card (type 3) // Card (type 3)
CardholderName string CardholderName string
Brand string Brand string
@@ -247,6 +248,7 @@ func (c *Client) CreateSecret(in CreateSecretInput) (string, error) {
Password: in.Password, Password: in.Password,
URI: in.URI, URI: in.URI,
Fields: in.Fields, Fields: in.Fields,
FolderID: in.FolderID,
CardholderName: in.CardholderName, CardholderName: in.CardholderName,
Brand: in.Brand, Brand: in.Brand,
Number: in.Number, Number: in.Number,
@@ -279,6 +281,82 @@ func (c *Client) CreateSecret(in CreateSecretInput) (string, error) {
return id, nil 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. // Stop stops the background sync goroutine.
func (c *Client) Stop() { func (c *Client) Stop() {
close(c.stopSync) 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. // syncVault fetches and decrypts all items from the vault.
func (c *Client) syncVault() error { func (c *Client) syncVault() error {
items, nameMaps, err := c.api.Sync() items, nameMaps, err := c.api.Sync()