mirror of
https://github.com/anotherhadi/default-creds-tui.git
synced 2026-10-05 10:58:25 +02:00
@@ -0,0 +1,23 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed default_config.yaml
|
||||
var defaultConfig []byte
|
||||
|
||||
type Config struct {
|
||||
Version string `mapstructure:"-"`
|
||||
|
||||
App struct {
|
||||
Mode string `mapstructure:"mode"`
|
||||
ApiURL string `mapstructure:"api_url"`
|
||||
FocusSearchOnLaunch bool `mapstructure:"focus_search_on_launch"`
|
||||
PrintOnEnter bool `mapstructure:"print_on_enter"`
|
||||
} `mapstructure:"app"`
|
||||
|
||||
Keybindings Keybindings `mapstructure:"keybindings"`
|
||||
}
|
||||
|
||||
var Global *Config
|
||||
@@ -0,0 +1,36 @@
|
||||
app:
|
||||
# Where credentials come from: "auto", "online" or "local".
|
||||
# - auto: use the local data directory if present, otherwise the API
|
||||
# - online: always query the default-creds API
|
||||
# - local: always read from the local data directory
|
||||
mode: "auto"
|
||||
|
||||
# Base URL of the default-creds API (used in "online"/"auto" mode).
|
||||
api_url: "https://default-creds.hadi.icu"
|
||||
|
||||
# Focus the search bar when the app starts.
|
||||
focus_search_on_launch: true
|
||||
|
||||
# When true, pressing enter on a result prints it to stdout and exits
|
||||
print_on_enter: false
|
||||
|
||||
keybindings:
|
||||
global:
|
||||
quit: "ctrl+c,q"
|
||||
escape: "esc"
|
||||
help: "?"
|
||||
up: "up,k"
|
||||
down: "down,j"
|
||||
goto_top: "home,g"
|
||||
goto_bottom: "end,G"
|
||||
|
||||
search:
|
||||
focus: "/"
|
||||
clear: "ctrl+u"
|
||||
|
||||
results:
|
||||
copy_user: "u"
|
||||
copy_pass: "p"
|
||||
enter: "enter"
|
||||
print_and_quit: "P"
|
||||
refresh: "ctrl+r"
|
||||
@@ -0,0 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/spf13/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func DefaultPath() string {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
dir = "."
|
||||
}
|
||||
return filepath.Join(dir, "default-creds-tui", "config.yaml")
|
||||
}
|
||||
|
||||
func Load(path string) error {
|
||||
var defaults map[string]any
|
||||
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
|
||||
return fmt.Errorf("default config: %w", err)
|
||||
}
|
||||
for k, v := range flatten("", defaults) {
|
||||
viper.SetDefault(k, v)
|
||||
}
|
||||
|
||||
viper.SetConfigType("yaml")
|
||||
viper.SetConfigFile(path)
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
Global = &Config{}
|
||||
hook := viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
stringToKeyBindingHook,
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
))
|
||||
if err := viper.Unmarshal(Global, hook); err != nil {
|
||||
return err
|
||||
}
|
||||
fillHelp(reflect.ValueOf(&Global.Keybindings))
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteDefaultConfig(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flatten(prefix string, m map[string]any) map[string]any {
|
||||
out := make(map[string]any)
|
||||
for k, v := range m {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
if nested, ok := v.(map[string]any); ok {
|
||||
maps.Copy(out, flatten(key, nested))
|
||||
} else {
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
)
|
||||
|
||||
func ShortHelp(keymap any) []key.Binding {
|
||||
return bindingsWithHelp(keymap, "short")
|
||||
}
|
||||
|
||||
func pageFullHelp(keymap any) []key.Binding {
|
||||
return bindingsWithHelp(keymap, "short", "full")
|
||||
}
|
||||
|
||||
func bindingsWithHelp(keymap any, levels ...string) []key.Binding {
|
||||
v := reflect.ValueOf(keymap)
|
||||
if v.Kind() == reflect.Pointer {
|
||||
v = v.Elem()
|
||||
}
|
||||
t := v.Type()
|
||||
|
||||
var out []key.Binding
|
||||
for i := range v.NumField() {
|
||||
field := t.Field(i)
|
||||
if field.Type != bindingType {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(levels, field.Tag.Get("help")) {
|
||||
out = append(out, v.Field(i).Interface().(key.Binding))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type HelpKeyMap struct {
|
||||
pages []any
|
||||
}
|
||||
|
||||
func Help(pages ...any) HelpKeyMap {
|
||||
return HelpKeyMap{pages: pages}
|
||||
}
|
||||
|
||||
func (h HelpKeyMap) ShortHelp() []key.Binding {
|
||||
var out []key.Binding
|
||||
for _, p := range h.pages {
|
||||
out = append(out, ShortHelp(p)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h HelpKeyMap) FullHelp() [][]key.Binding {
|
||||
cols := make([][]key.Binding, 0, len(h.pages))
|
||||
for _, p := range h.pages {
|
||||
if col := pageFullHelp(p); len(col) > 0 {
|
||||
cols = append(cols, col)
|
||||
}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
var bindingType = reflect.TypeFor[key.Binding]()
|
||||
|
||||
var validHelpLevels = map[string]bool{"none": true, "short": true, "full": true}
|
||||
|
||||
func stringToKeyBindingHook(from reflect.Type, to reflect.Type, data any) (any, error) {
|
||||
if to != bindingType || from.Kind() != reflect.String {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var keys []string
|
||||
for part := range strings.SplitSeq(data.(string), ",") {
|
||||
if k := strings.TrimSpace(part); k != "" {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
return key.NewBinding(key.WithKeys(keys...)), nil
|
||||
}
|
||||
|
||||
func fillHelp(v reflect.Value) {
|
||||
if v.Kind() == reflect.Pointer {
|
||||
v = v.Elem()
|
||||
}
|
||||
if v.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
|
||||
t := v.Type()
|
||||
for i := range t.NumField() {
|
||||
field := t.Field(i)
|
||||
fv := v.Field(i)
|
||||
|
||||
if field.Type == bindingType {
|
||||
b := fv.Addr().Interface().(*key.Binding)
|
||||
b.SetHelp(strings.Join(b.Keys(), ","), field.Tag.Get("desc"))
|
||||
|
||||
if level := field.Tag.Get("help"); !validHelpLevels[level] {
|
||||
panic(fmt.Sprintf(
|
||||
"config: %s.%s has invalid `help` tag %q, want \"none\", \"short\" or \"full\"",
|
||||
t.Name(), field.Name, level,
|
||||
))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if field.Type.Kind() == reflect.Struct {
|
||||
fillHelp(fv)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package config
|
||||
|
||||
import "charm.land/bubbles/v2/key"
|
||||
|
||||
type GlobalKeys struct {
|
||||
Quit key.Binding `mapstructure:"quit" desc:"quit" help:"short"`
|
||||
Escape key.Binding `mapstructure:"escape" desc:"back" help:"short"`
|
||||
Help key.Binding `mapstructure:"help" desc:"help" help:"none"`
|
||||
|
||||
Up key.Binding `mapstructure:"up" desc:"up" help:"full"`
|
||||
Down key.Binding `mapstructure:"down" desc:"down" help:"full"`
|
||||
|
||||
GotoTop key.Binding `mapstructure:"goto_top" desc:"go to top" help:"full"`
|
||||
GotoBottom key.Binding `mapstructure:"goto_bottom" desc:"go to bottom" help:"full"`
|
||||
}
|
||||
|
||||
type SearchKeys struct {
|
||||
Focus key.Binding `mapstructure:"focus" desc:"search" help:"short"`
|
||||
Clear key.Binding `mapstructure:"clear" desc:"clear search" help:"full"`
|
||||
}
|
||||
|
||||
type ResultsKeys struct {
|
||||
CopyUser key.Binding `mapstructure:"copy_user" desc:"copy user" help:"short"`
|
||||
CopyPass key.Binding `mapstructure:"copy_pass" desc:"copy pass" help:"short"`
|
||||
Enter key.Binding `mapstructure:"enter" desc:"select" help:"short"`
|
||||
PrintAndQuit key.Binding `mapstructure:"print_and_quit" desc:"print and quit" help:"full"`
|
||||
Refresh key.Binding `mapstructure:"refresh" desc:"refresh" help:"full"`
|
||||
}
|
||||
|
||||
type Keybindings struct {
|
||||
Global GlobalKeys `mapstructure:"global"`
|
||||
Search SearchKeys `mapstructure:"search"`
|
||||
Results ResultsKeys `mapstructure:"results"`
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package creds
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Manufacturer string
|
||||
Name string
|
||||
Icon string
|
||||
Tags []string
|
||||
Version string
|
||||
User string
|
||||
Pass string
|
||||
Comment string
|
||||
}
|
||||
|
||||
func (e Entry) Title() string { return e.Name }
|
||||
func (e Entry) Description() string { return fmt.Sprintf("%s %s", e.Manufacturer, e.Version) }
|
||||
func (e Entry) FilterValue() string { return e.searchString() }
|
||||
|
||||
func (e Entry) searchString() string {
|
||||
return strings.ToLower(strings.Join([]string{
|
||||
e.Manufacturer, e.Name, e.Comment, strings.Join(e.Tags, " "),
|
||||
}, " "))
|
||||
}
|
||||
|
||||
func (e Entry) matches(tokens []string) bool {
|
||||
s := e.searchString()
|
||||
for _, t := range tokens {
|
||||
if !strings.Contains(s, t) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func queryTokens(query string) []string {
|
||||
return strings.Fields(strings.ToLower(strings.TrimSpace(query)))
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package creds
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type manufacturerFile struct {
|
||||
Name string `yaml:"name"`
|
||||
Icon string `yaml:"icon"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Entries []struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
User string `yaml:"user"`
|
||||
Pass string `yaml:"pass"`
|
||||
Comment string `yaml:"comment"`
|
||||
} `yaml:"entries"`
|
||||
}
|
||||
|
||||
func LocalDataDir() string {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
cacheDir = "."
|
||||
}
|
||||
return filepath.Join(cacheDir, "default-creds", "data")
|
||||
}
|
||||
|
||||
func HasLocalData() bool {
|
||||
entries, err := os.ReadDir(LocalDataDir())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".yaml") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func LoadLocal(dir string) ([]Entry, error) {
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read local data dir: %w", err)
|
||||
}
|
||||
|
||||
var entries []Entry
|
||||
for _, f := range files {
|
||||
if f.IsDir() || !strings.HasSuffix(f.Name(), ".yaml") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, f.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var doc manufacturerFile
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
continue
|
||||
}
|
||||
if doc.Name == "" {
|
||||
continue
|
||||
}
|
||||
for _, e := range doc.Entries {
|
||||
entries = append(entries, Entry{
|
||||
Manufacturer: doc.Name,
|
||||
Name: e.Name,
|
||||
Icon: doc.Icon,
|
||||
Tags: doc.Tags,
|
||||
Version: e.Version,
|
||||
User: e.User,
|
||||
Pass: e.Pass,
|
||||
Comment: e.Comment,
|
||||
})
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func Filter(entries []Entry, query string) []Entry {
|
||||
tokens := queryTokens(query)
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []Entry
|
||||
for _, e := range entries {
|
||||
if e.matches(tokens) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func LocalStats(entries []Entry) (manufacturers, credentials int) {
|
||||
seen := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
seen[e.Manufacturer] = true
|
||||
}
|
||||
return len(seen), len(entries)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package creds
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
Auto Mode = "auto"
|
||||
Online Mode = "online"
|
||||
Local Mode = "local"
|
||||
)
|
||||
|
||||
func ParseMode(s string) (Mode, error) {
|
||||
switch Mode(s) {
|
||||
case Auto, Online, Local:
|
||||
return Mode(s), nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid mode %q: want \"auto\", \"online\" or \"local\"", s)
|
||||
}
|
||||
}
|
||||
|
||||
func Resolve(mode Mode) Mode {
|
||||
if mode != Auto {
|
||||
return mode
|
||||
}
|
||||
if HasLocalData() {
|
||||
return Local
|
||||
}
|
||||
return Online
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package creds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const httpTimeout = 8 * time.Second
|
||||
|
||||
type apiEntry struct {
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Tags []string `json:"tags"`
|
||||
Version string `json:"version"`
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
type apiSearchResponse struct {
|
||||
Results []apiEntry `json:"results"`
|
||||
Pagination struct {
|
||||
TotalResults int `json:"totalResults"`
|
||||
} `json:"pagination"`
|
||||
}
|
||||
|
||||
type apiStatsResponse struct {
|
||||
TotalApps int `json:"totalApps"`
|
||||
TotalPasswords int `json:"totalPasswords"`
|
||||
}
|
||||
|
||||
func FetchOnline(apiURL, query string, size int) (results []Entry, total int, err error) {
|
||||
u := apiURL + "/api/search?" + url.Values{
|
||||
"q": {query},
|
||||
"size": {strconv.Itoa(size)},
|
||||
}.Encode()
|
||||
|
||||
var resp apiSearchResponse
|
||||
if err := getJSON(u, &resp); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
entries := make([]Entry, len(resp.Results))
|
||||
for i, r := range resp.Results {
|
||||
entries[i] = Entry{
|
||||
Manufacturer: r.Manufacturer,
|
||||
Name: r.Name,
|
||||
Icon: r.Icon,
|
||||
Tags: r.Tags,
|
||||
Version: r.Version,
|
||||
User: r.User,
|
||||
Pass: r.Pass,
|
||||
Comment: r.Comment,
|
||||
}
|
||||
}
|
||||
return entries, resp.Pagination.TotalResults, nil
|
||||
}
|
||||
|
||||
func FetchStats(apiURL string) (manufacturers, credentials int, err error) {
|
||||
var resp apiStatsResponse
|
||||
if err := getJSON(apiURL+"/api/stats", &resp); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return resp.TotalApps, resp.TotalPasswords, nil
|
||||
}
|
||||
|
||||
func getJSON(u string, out any) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: unexpected status %s", u, resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
Reference in New Issue
Block a user