feat: add /search?q= endpoint — substring search across all vault fields
CI / test (push) Has been cancelled
CI / docker (push) Has been cancelled

This commit is contained in:
Hermes committed 2026-08-08 02:03:49 +02:00
1 parent 30ae00d5f4
commit 51a71739c9
3 files changed
+90

No files matched your search

+1
View File
@@ -126,6 +126,7 @@ func main() {
api.Put("/secret/:name", h.UpdateSecret)
api.Delete("/secret/:name", h.DeleteSecret)
api.Get("/folders", h.ListFolders)
api.Get("/search", h.SearchSecrets)
api.Post("/refresh", h.RefreshCache)
// Graceful shutdown.
+21
View File
@@ -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):
//
// {
+68
View File
@@ -173,6 +173,74 @@ func (c *Client) getSecretItem(name string, filter SecretFilter) (DecryptedItem,
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.
// The "value" key holds the same primary value extractSecret() would return,
// while the other keys expose every decrypted field.