feat: add /search?q= endpoint — substring search across all vault fields
This commit is contained in:
1 parent
30ae00d5f4
commit
51a71739c9
3 files changed
+90
No files matched your search
@@ -126,6 +126,7 @@ func main() {
|
|||||||
api.Put("/secret/:name", h.UpdateSecret)
|
api.Put("/secret/:name", h.UpdateSecret)
|
||||||
api.Delete("/secret/:name", h.DeleteSecret)
|
api.Delete("/secret/:name", h.DeleteSecret)
|
||||||
api.Get("/folders", h.ListFolders)
|
api.Get("/folders", h.ListFolders)
|
||||||
|
api.Get("/search", h.SearchSecrets)
|
||||||
api.Post("/refresh", h.RefreshCache)
|
api.Post("/refresh", h.RefreshCache)
|
||||||
|
|
||||||
// Graceful shutdown.
|
// Graceful shutdown.
|
||||||
|
|||||||
@@ -271,6 +271,27 @@ func (h *Handler) RefreshCache(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchSecrets handles GET /search?q=<text>.
|
||||||
|
// Returns every vault item matching the query across ALL fields (name, username,
|
||||||
|
// uri, notes, password, custom fields). Values are masked in the listing.
|
||||||
|
// Requires the q parameter; empty query returns an empty list.
|
||||||
|
func (h *Handler) SearchSecrets(c *fiber.Ctx) error {
|
||||||
|
q := strings.TrimSpace(c.Query("q"))
|
||||||
|
if q == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"error": "query parameter 'q' is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
results := h.vaultClient.SearchSecrets(q)
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"query": q,
|
||||||
|
"count": len(results),
|
||||||
|
"results": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// CreateSecret handles POST /secret. Body (JSON):
|
// CreateSecret handles POST /secret. Body (JSON):
|
||||||
//
|
//
|
||||||
// {
|
// {
|
||||||
|
|||||||
@@ -173,6 +173,74 @@ func (c *Client) getSecretItem(name string, filter SecretFilter) (DecryptedItem,
|
|||||||
return DecryptedItem{}, fmt.Errorf("secret not found")
|
return DecryptedItem{}, fmt.Errorf("secret not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchSecrets returns every decrypted item whose name, username, uri, notes,
|
||||||
|
// password, or any custom field contains the query string (case-insensitive).
|
||||||
|
// It is a substring search across ALL fields — unlike GetSecret which is limited
|
||||||
|
// to the item name. Values are masked so the list can be returned safely.
|
||||||
|
func (c *Client) SearchSecrets(query string) []map[string]interface{} {
|
||||||
|
q := strings.ToLower(strings.TrimSpace(query))
|
||||||
|
if q == "" {
|
||||||
|
return []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
results := make([]map[string]interface{}, 0)
|
||||||
|
for _, item := range c.items {
|
||||||
|
if matchesAllFields(item, q) {
|
||||||
|
m := item.ToMap()
|
||||||
|
// Mask sensitive values in the listing.
|
||||||
|
if v, ok := m["password"].(string); ok && v != "" {
|
||||||
|
m["password"] = maskSecret(v)
|
||||||
|
}
|
||||||
|
if v, ok := m["value"].(string); ok && v != "" {
|
||||||
|
m["value"] = maskSecret(v)
|
||||||
|
}
|
||||||
|
if v, ok := m["username"].(string); ok && v != "" {
|
||||||
|
m["username"] = maskSecret(v)
|
||||||
|
}
|
||||||
|
results = append(results, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesAllFields checks the query against every decryptable field of an item.
|
||||||
|
func matchesAllFields(item DecryptedItem, q string) bool {
|
||||||
|
if strings.Contains(strings.ToLower(item.Name), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(item.Username), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(item.URI), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(item.Notes), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(item.Password), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for k, v := range item.Fields {
|
||||||
|
if strings.Contains(strings.ToLower(k), q) ||
|
||||||
|
strings.Contains(strings.ToLower(v), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskSecret reveals only the first 2 and last 2 characters of a value.
|
||||||
|
func maskSecret(v string) string {
|
||||||
|
r := []rune(v)
|
||||||
|
if len(r) <= 4 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
return string(r[:2]) + "***" + string(r[len(r)-2:])
|
||||||
|
}
|
||||||
|
|
||||||
// ToMap renders the decrypted item as a flat map for JSON output.
|
// ToMap renders the decrypted item as a flat map for JSON output.
|
||||||
// The "value" key holds the same primary value extractSecret() would return,
|
// The "value" key holds the same primary value extractSecret() would return,
|
||||||
// while the other keys expose every decrypted field.
|
// while the other keys expose every decrypted field.
|
||||||
|
|||||||
Reference in new issue
Block a user