feat: add folderId, custom fields, PUT/DELETE secret, GET /folders
This commit is contained in:
1 parent
0ede8f145d
commit
30ae00d5f4
4 files changed
+426
-1
No files matched your search
+4
-1
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user