/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package app import ( "fmt" "io" "os" "strings" "unicode/utf8" ) type DbAttr struct { Key string Preview string Val []byte Args []string Tags []string File string Encrypted bool Binary bool // conf flags, needed for incoming rest requests Fulltext bool } // check if value is to be read from a file or stdin, setup preview // text according to flags, lowercase key func (attr *DbAttr) ParseKV() error { attr.Key = strings.ToLower(attr.Args[0]) switch len(attr.Args) { case 1: // 1 arg = key + read from file or stdin if attr.File == "" { attr.File = "-" } case 2: attr.Val = []byte(attr.Args[1]) if attr.Args[1] == "-" { attr.File = "-" } } if attr.File != "" { if err := attr.GetFileValue(); err != nil { return err } } switch { case attr.Binary: attr.Preview = "<binary-content>" case attr.Encrypted: attr.Preview = "<encrypted-content>" default: if len(attr.Val) > MaxValueWidth { attr.Preview = string(attr.Val)[0:MaxValueWidth] + "..." if strings.Contains(attr.Preview, "\n") { parts := strings.Split(attr.Preview, "\n") if len(parts) > 0 { attr.Preview = parts[0] } } } else { attr.Preview = string(attr.Val) } } return nil } func (attr *DbAttr) GetFileValue() error { var fd io.Reader if attr.File == "-" { stat, _ := os.Stdin.Stat() if (stat.Mode() & os.ModeCharDevice) == 0 { fd = os.Stdin } } else { filehandle, err := os.OpenFile(attr.File, os.O_RDONLY, 0600) if err != nil { return fmt.Errorf("failed to open file %s: %w", attr.File, err) } fd = filehandle } if fd != nil { // read from file or stdin pipe data, err := io.ReadAll(fd) if err != nil { return fmt.Errorf("failed to read from pipe: %w", err) } // poor man's text file test attr.Val = data if utf8.ValidString(string(data)) { attr.Binary = false } else { attr.Binary = true } } else { // read from console stdin var input string var data string for { _, err := fmt.Scanln(&input) if err != nil { break } data += input + "\n" } attr.Val = []byte(data) } return nil }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package app import ( "crypto/rand" "errors" "fmt" "log/slog" "os" "syscall" "golang.org/x/crypto/argon2" "golang.org/x/crypto/chacha20poly1305" "golang.org/x/term" ) const ( ArgonMem uint32 = 64 * 1024 ArgonIter uint32 = 5 ArgonParallel uint8 = 2 ArgonSaltLen int = 16 ArgonKeyLen uint32 = 32 B64SaltLen int = 16 //22 ) type Key struct { Salt []byte Key []byte } // called from interactive thread, hides input and returns clear text // password func AskForPassword() ([]byte, error) { fmt.Fprint(os.Stderr, "Password: ") pass, err := term.ReadPassword(int(syscall.Stdin)) if err != nil { return nil, fmt.Errorf("failed to read password: %w", err) } fmt.Fprintln(os.Stderr) return pass, nil } // We're using the Argon2id key derivation algorithm to derive a // secure key from the given password. This is important, because // users might use unsecure passwords. The resulting encrypted data // might of course easily be decrypted using brute force methods if a // weak password was used, but that would cost, because of the key // derivation. It does several rounds of hash calculations which take // a considerable amount of cpu time. For our legal user that's no // problem because it's being executed only once, but an attacker has // to do it in a forever loop, which will take a lot of time. func DeriveKey(password []byte, salt []byte) (*Key, error) { if salt == nil { // none given, new password newsalt, err := GetRandom(ArgonSaltLen, ArgonSaltLen) if err != nil { return nil, err } salt = newsalt } hash := argon2.IDKey( []byte(password), salt, ArgonIter, ArgonMem, ArgonParallel, ArgonKeyLen, ) key := &Key{Key: hash, Salt: salt} slog.Debug("derived key", "key", string(key.Key), "salt", string(key.Salt)) return key, nil } // Retrieve a random chunk of given size func GetRandom(size int, capacity int) ([]byte, error) { buf := make([]byte, size, capacity) _, err := rand.Read(buf) if err != nil { return nil, fmt.Errorf("failed to retrieve random bytes: %w", err) } return buf, nil } // Encrypt clear text given in attr using ChaCha20 and auhtenticate // using the mac Poly1305. The cipher text will be put into attr, thus // modifying it. // // The cipher text consists of: // password-salt) + (12 byte nonce + ciphertext + 16 byte mac) func Encrypt(pass []byte, attr *DbAttr) error { key, err := DeriveKey(pass, nil) if err != nil { return err } aead, err := chacha20poly1305.New(key.Key) if err != nil { return fmt.Errorf("failed to create AEAD cipher: %w", err) } total := aead.NonceSize() + len(attr.Val) + aead.Overhead() nonce, err := GetRandom(aead.NonceSize(), total) if err != nil { return err } cipher := aead.Seal(nonce, nonce, attr.Val, nil) attr.Val = key.Salt attr.Val = append(attr.Val, cipher...) attr.Encrypted = true attr.Preview = "<encrypted-content>" slog.Debug("encrypted attr", "salt", string(key.Salt), "cipher", string(attr.Val)) return nil } // Do the reverse func Decrypt(pass []byte, cipherb []byte) ([]byte, error) { if len(cipherb) < B64SaltLen { return nil, fmt.Errorf("encrypted cipher block too small") } key, err := DeriveKey(pass, cipherb[0:B64SaltLen]) if err != nil { return nil, err } cipher := cipherb[B64SaltLen:] aead, err := chacha20poly1305.New(key.Key) if err != nil { return nil, fmt.Errorf("failed to create AEAD cipher: %w", err) } if len(cipher) < aead.NonceSize() { return nil, errors.New("ciphertext too short") } nonce, ciphertext := cipher[:aead.NonceSize()], cipher[aead.NonceSize():] clear, err := aead.Open(nil, nonce, ciphertext, nil) if err != nil { return nil, err } slog.Debug("decrypted attr", "salt", string(key.Salt), "clear", string(clear)) return clear, err }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package app import ( "encoding/json" "errors" "fmt" "log/slog" "os" "path/filepath" "regexp" "strings" "time" common "github.com/tlinden/anydb/common" bolt "go.etcd.io/bbolt" "google.golang.org/protobuf/proto" timestamppb "google.golang.org/protobuf/types/known/timestamppb" ) const MaxValueWidth int = 60 type DB struct { Debug bool Dbfile string Bucket string DB *bolt.DB } type BucketInfo struct { Name string Keys int Size int Sequence uint64 Stats bolt.BucketStats } type DbInfo struct { Buckets []BucketInfo Path string } type DbEntries []*DbEntry type DbTag struct { Keys []string `json:"key"` } func (entry *DbEntry) Taglist() string { return strings.Join(entry.Tags, ",") } const BucketData string = "data" func GetDbFile(file string) string { if file != "" { return file } file = os.Getenv("ANYDB_DB") if file != "" { return file } home, err := os.UserHomeDir() if err != nil { panic(err) } return filepath.Join(home, ".config", "anydb", "default.db") } func New(file string, bucket string, debug bool) (*DB, error) { return &DB{Debug: debug, Dbfile: file, Bucket: bucket}, nil } func (db *DB) Open() error { slog.Debug("opening DB", "dbfile", db.Dbfile) if _, err := os.Stat(filepath.Dir(db.Dbfile)); os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(db.Dbfile), 0700); err != nil { return err } } var opts *bolt.Options if db.Debug { log := common.Slogger{Logger: slog.Default()} opts = &bolt.Options{Logger: log} } b, err := bolt.Open(db.Dbfile, 0600, opts) if err != nil { return fmt.Errorf("failed to open DB %s: %w", db.Dbfile, err) } db.DB = b return nil } func (db *DB) Close() error { return db.DB.Close() } func (db *DB) List(attr *DbAttr, fulltext bool) (DbEntries, error) { if err := db.Open(); err != nil { return nil, err } defer db.Close() var entries DbEntries var filter *regexp.Regexp if len(attr.Args) > 0 { // via cli filter = regexp.MustCompile(attr.Args[0]) } if len(attr.Key) > 0 { // via api filter = regexp.MustCompile(attr.Key) } err := db.DB.View(func(tx *bolt.Tx) error { root := tx.Bucket([]byte(db.Bucket)) if root == nil { return nil } slog.Debug("opened root bucket", "root", root) bucket := root.Bucket([]byte("meta")) if bucket == nil { return nil } slog.Debug("opened buckets", "root", root, "data", bucket) databucket := root.Bucket([]byte("data")) if databucket == nil { return fmt.Errorf("failed to retrieve data sub bucket") } err := bucket.ForEach(func(key, pbentry []byte) error { var entry DbEntry if err := proto.Unmarshal(pbentry, &entry); err != nil { return fmt.Errorf("failed to unmarshal from protobuf: %w", err) } if fulltext { // avoid crash due to access fault value := databucket.Get([]byte(entry.Key)) // empty is ok vc := make([]byte, len(value)) copy(vc, value) entry.Value = string(vc) } var include bool switch { case filter != nil: if filter.MatchString(entry.Key) || filter.MatchString(strings.Join(entry.Tags, " ")) { include = true } if !entry.Binary && !include && fulltext { if filter.MatchString(string(entry.Value)) { include = true } } case len(attr.Tags) > 0: for _, search := range attr.Tags { for _, tag := range entry.Tags { if tag == search { include = true break } } if include { break } } default: include = true } if include { entries = append(entries, &entry) } return nil }) return err }) return entries, err } func (db *DB) Set(attr *DbAttr) error { if err := db.Open(); err != nil { return err } defer db.Close() entry := DbEntry{ Key: attr.Key, Binary: attr.Binary, Tags: attr.Tags, Encrypted: attr.Encrypted, Created: timestamppb.Now(), Size: uint64(len(attr.Val)), Preview: attr.Preview, } // check if the entry already exists and if yes, check if it has // any tags. if so, we initialize our update struct with these // tags unless it has new tags configured. // FIXME: use Get() err := db.DB.View(func(tx *bolt.Tx) error { root := tx.Bucket([]byte(db.Bucket)) if root == nil { return nil } bucket := root.Bucket([]byte("meta")) if bucket == nil { return nil } slog.Debug("opened buckets", "root", root, "data", bucket) pbentry := bucket.Get([]byte(entry.Key)) if pbentry == nil { return nil } var oldentry DbEntry if err := proto.Unmarshal(pbentry, &oldentry); err != nil { return fmt.Errorf("failed to unmarshal from protobuf: %w", err) } if len(oldentry.Tags) > 0 && len(entry.Tags) == 0 { // initialize update entry with tags from old entry entry.Tags = oldentry.Tags } return nil }) if err != nil { return err } // marshall our data pbentry, err := proto.Marshal(&entry) if err != nil { return fmt.Errorf("failed to marshall protobuf: %w", err) } err = db.DB.Update(func(tx *bolt.Tx) error { // create root bucket root, err := tx.CreateBucketIfNotExists([]byte(db.Bucket)) if err != nil { return fmt.Errorf("failed to create DB bucket: %w", err) } // create meta bucket bucket, err := root.CreateBucketIfNotExists([]byte("meta")) if err != nil { return fmt.Errorf("failed to create DB meta sub bucket: %w", err) } slog.Debug("opened/created buckets", "root", root, "data", bucket) // write meta data err = bucket.Put([]byte(entry.Key), []byte(pbentry)) if err != nil { return fmt.Errorf("failed to insert data: %w", err) } // create data bucket databucket, err := root.CreateBucketIfNotExists([]byte("data")) if err != nil { return fmt.Errorf("failed to create DB data sub bucket: %w", err) } // write value err = databucket.Put([]byte(entry.Key), attr.Val) if err != nil { return fmt.Errorf("failed to insert data: %w", err) } return nil }) if err != nil { return err } return nil } func (db *DB) Get(attr *DbAttr) (*DbEntry, error) { if err := db.Open(); err != nil { return nil, err } defer db.Close() entry := DbEntry{} err := db.DB.View(func(tx *bolt.Tx) error { // root bucket root := tx.Bucket([]byte(db.Bucket)) if root == nil { return nil } // get meta sub bucket bucket := root.Bucket([]byte("meta")) if bucket == nil { return nil } slog.Debug("opened buckets", "root", root, "data", bucket) // retrieve meta data pbentry := bucket.Get([]byte(attr.Key)) if pbentry == nil { return fmt.Errorf("no such key: %s", attr.Key) } // put into struct if err := proto.Unmarshal(pbentry, &entry); err != nil { return fmt.Errorf("failed to unmarshal from protobuf: %w", err) } // get data sub bucket databucket := root.Bucket([]byte("data")) if databucket == nil { return fmt.Errorf("failed to retrieve data sub bucket") } // retrieve actual data value value := databucket.Get([]byte(attr.Key)) if len(value) == 0 { return fmt.Errorf("no such key: %s", attr.Key) } // we need to make a copy of it, otherwise we'll get an // "unexpected fault address" error vc := make([]byte, len(value)) copy(vc, value) entry.Value = string(vc) return nil }) if err != nil { return nil, fmt.Errorf("failed to read from DB: %w", err) } return &entry, nil } func (db *DB) Del(attr *DbAttr) error { if err := db.Open(); err != nil { return err } defer db.Close() err := db.DB.Update(func(tx *bolt.Tx) error { // root bucket root := tx.Bucket([]byte(db.Bucket)) if root == nil { return nil } // get data sub bucket bucket := root.Bucket([]byte("meta")) if bucket == nil { return nil } slog.Debug("opened buckets", "data", bucket) return bucket.Delete([]byte(attr.Key)) }) return err } func (db *DB) Import(attr *DbAttr) (string, error) { // open json file into attr.Val if err := attr.GetFileValue(); err != nil { return "", err } if len(attr.Val) == 0 { return "", errors.New("empty json file") } var entries DbEntries now := time.Now() newfile := db.Dbfile + now.Format("-02.01.2006T03:04.05") if err := json.Unmarshal([]byte(attr.Val), &entries); err != nil { return "", cleanError(newfile, fmt.Errorf("failed to unmarshal json: %w", err)) } if fileExists(db.Dbfile) { // backup the old file err := os.Rename(db.Dbfile, newfile) if err != nil { return "", fmt.Errorf("failed to rename file %s to %s: %w", db.Dbfile, newfile, err) } } // should now be a new db file if err := db.Open(); err != nil { return "", cleanError(newfile, err) } defer db.Close() err := db.DB.Update(func(tx *bolt.Tx) error { // create root bucket root, err := tx.CreateBucketIfNotExists([]byte(db.Bucket)) if err != nil { return fmt.Errorf("failed to create DB bucket: %w", err) } // create meta bucket bucket, err := root.CreateBucketIfNotExists([]byte("meta")) if err != nil { return fmt.Errorf("failed to create DB meta sub bucket: %w", err) } slog.Debug("opened buckets", "root", root, "data", bucket) for _, entry := range entries { pbentry, err := proto.Marshal(entry) if err != nil { return fmt.Errorf("failed to marshall protobuf: %w", err) } // write meta data err = bucket.Put([]byte(entry.Key), []byte(pbentry)) if err != nil { return fmt.Errorf("failed to insert data into DB: %w", err) } // create data bucket databucket, err := root.CreateBucketIfNotExists([]byte("data")) if err != nil { return fmt.Errorf("failed to create DB data sub bucket: %w", err) } // write value err = databucket.Put([]byte(entry.Key), []byte(entry.Value)) if err != nil { return fmt.Errorf("failed to insert data: %w", err) } } return nil }) if err != nil { return "", cleanError(newfile, err) } return fmt.Sprintf("backed up database file to %s\nimported %d database entries\n", newfile, len(entries)), nil } func (db *DB) Info() (*DbInfo, error) { if err := db.Open(); err != nil { return nil, err } defer db.Close() info := &DbInfo{Path: db.Dbfile} err := db.DB.View(func(tx *bolt.Tx) error { err := tx.ForEach(func(name []byte, bucket *bolt.Bucket) error { stats := bucket.Stats() binfo := BucketInfo{ Name: string(name), Sequence: bucket.Sequence(), Keys: stats.KeyN, Stats: bucket.Stats(), } err := bucket.ForEach(func(key, entry []byte) error { binfo.Size += len(entry) + len(key) return nil }) if err != nil { return fmt.Errorf("failed to read keys: %w", err) } info.Buckets = append(info.Buckets, binfo) return nil }) if err != nil { return fmt.Errorf("failed to read from DB: %w", err) } return nil }) return info, err } func (db *DB) Getall(attr *DbAttr) (DbEntries, error) { if err := db.Open(); err != nil { return nil, err } defer db.Close() var entries DbEntries err := db.DB.View(func(tx *bolt.Tx) error { // root bucket root := tx.Bucket([]byte(db.Bucket)) if root == nil { return nil } // get meta sub bucket bucket := root.Bucket([]byte("meta")) if bucket == nil { return nil } // get data sub bucket databucket := root.Bucket([]byte("data")) if databucket == nil { return fmt.Errorf("failed to retrieve data sub bucket") } slog.Debug("opened buckets", "root", root, "data", bucket) // iterate over all db entries in meta sub bucket err := bucket.ForEach(func(key, pbentry []byte) error { var entry DbEntry if err := proto.Unmarshal(pbentry, &entry); err != nil { return fmt.Errorf("failed to unmarshal from protobuf: %w", err) } // retrieve the value from the data sub bucket value := databucket.Get([]byte(entry.Key)) // we need to make a copy of it, otherwise we'll get an // "unexpected fault address" error vc := make([]byte, len(value)) copy(vc, value) entry.Value = string(vc) entries = append(entries, &entry) return nil }) return err }) return entries, err }
// -*-c++-*- // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.5 // protoc v4.24.4 // source: app/dbentry.proto package app import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type DbEntry struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=Id,proto3" json:"Id,omitempty"` Key string `protobuf:"bytes,2,opt,name=Key,proto3" json:"Key,omitempty"` Preview string `protobuf:"bytes,3,opt,name=Preview,proto3" json:"Preview,omitempty"` Tags []string `protobuf:"bytes,4,rep,name=Tags,proto3" json:"Tags,omitempty"` Created *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=Created,proto3" json:"Created,omitempty"` Size uint64 `protobuf:"varint,6,opt,name=Size,proto3" json:"Size,omitempty"` Encrypted bool `protobuf:"varint,7,opt,name=Encrypted,proto3" json:"Encrypted,omitempty"` Binary bool `protobuf:"varint,8,opt,name=Binary,proto3" json:"Binary,omitempty"` Value string `protobuf:"bytes,9,opt,name=Value,proto3" json:"Value,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DbEntry) Reset() { *x = DbEntry{} mi := &file_app_dbentry_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DbEntry) String() string { return protoimpl.X.MessageStringOf(x) } func (*DbEntry) ProtoMessage() {} func (x *DbEntry) ProtoReflect() protoreflect.Message { mi := &file_app_dbentry_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DbEntry.ProtoReflect.Descriptor instead. func (*DbEntry) Descriptor() ([]byte, []int) { return file_app_dbentry_proto_rawDescGZIP(), []int{0} } func (x *DbEntry) GetId() string { if x != nil { return x.Id } return "" } func (x *DbEntry) GetKey() string { if x != nil { return x.Key } return "" } func (x *DbEntry) GetPreview() string { if x != nil { return x.Preview } return "" } func (x *DbEntry) GetTags() []string { if x != nil { return x.Tags } return nil } func (x *DbEntry) GetCreated() *timestamppb.Timestamp { if x != nil { return x.Created } return nil } func (x *DbEntry) GetSize() uint64 { if x != nil { return x.Size } return 0 } func (x *DbEntry) GetEncrypted() bool { if x != nil { return x.Encrypted } return false } func (x *DbEntry) GetBinary() bool { if x != nil { return x.Binary } return false } func (x *DbEntry) GetValue() string { if x != nil { return x.Value } return "" } var File_app_dbentry_proto protoreflect.FileDescriptor var file_app_dbentry_proto_rawDesc = string([]byte{ 0x0a, 0x11, 0x61, 0x70, 0x70, 0x2f, 0x64, 0x62, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x70, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xef, 0x01, 0x0a, 0x07, 0x44, 0x62, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x61, 0x67, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x54, 0x61, 0x67, 0x73, 0x12, 0x34, 0x0a, 0x07, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x53, 0x69, 0x7a, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x42, 0x69, 0x6e, 0x61, 0x72, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x42, 0x69, 0x6e, 0x61, 0x72, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x1e, 0x5a, 0x1c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x6c, 0x69, 0x6e, 0x64, 0x65, 0x6e, 0x2f, 0x61, 0x6e, 0x79, 0x64, 0x62, 0x2f, 0x61, 0x70, 0x70, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( file_app_dbentry_proto_rawDescOnce sync.Once file_app_dbentry_proto_rawDescData []byte ) func file_app_dbentry_proto_rawDescGZIP() []byte { file_app_dbentry_proto_rawDescOnce.Do(func() { file_app_dbentry_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_app_dbentry_proto_rawDesc), len(file_app_dbentry_proto_rawDesc))) }) return file_app_dbentry_proto_rawDescData } var file_app_dbentry_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_app_dbentry_proto_goTypes = []any{ (*DbEntry)(nil), // 0: app.DbEntry (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp } var file_app_dbentry_proto_depIdxs = []int32{ 1, // 0: app.DbEntry.Created:type_name -> google.protobuf.Timestamp 1, // [1:1] is the sub-list for method output_type 1, // [1:1] is the sub-list for method input_type 1, // [1:1] is the sub-list for extension type_name 1, // [1:1] is the sub-list for extension extendee 0, // [0:1] is the sub-list for field type_name } func init() { file_app_dbentry_proto_init() } func file_app_dbentry_proto_init() { if File_app_dbentry_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_app_dbentry_proto_rawDesc), len(file_app_dbentry_proto_rawDesc)), NumEnums: 0, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, GoTypes: file_app_dbentry_proto_goTypes, DependencyIndexes: file_app_dbentry_proto_depIdxs, MessageInfos: file_app_dbentry_proto_msgTypes, }.Build() File_app_dbentry_proto = out.File file_app_dbentry_proto_goTypes = nil file_app_dbentry_proto_depIdxs = nil }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package app // look if a key in a map exists, generic variant func Exists[K comparable, V any](m map[K]V, v K) bool { if _, ok := m[v]; ok { return true } return false }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package app import "os" func cleanError(file string, err error) error { // remove given [backup] file and forward the given error os.Remove(file) return err } func fileExists(filename string) bool { info, err := os.Stat(filename) if err != nil { // return false on any error return false } return !info.IsDir() }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package cfg import ( "fmt" "io" "os" "github.com/pelletier/go-toml" "github.com/tlinden/anydb/app" "github.com/tlinden/anydb/common" ) var Version string = "v0.2.1" type BucketConfig struct { Encrypt bool } type Config struct { Debug bool Dbfile string Dbbucket string Template string Mode string // wide, table, yaml, json NoHeaders bool NoHumanize bool Encrypt bool // one entry CaseInsensitive bool Fulltext bool Listen string Buckets map[string]BucketConfig // config file only Tags []string // internal DB *app.DB // internal File string // internal } func (conf *Config) GetConfig(files []string) error { for _, file := range files { if err := conf.ParseConfigFile(file); err != nil { return err } } return nil } func (conf *Config) ParseConfigFile(file string) error { if !common.FileExists(file) { return nil } fd, err := os.OpenFile(file, os.O_RDONLY, 0600) if err != nil { return fmt.Errorf("failed to open config file %s: %w", file, err) } data, err := io.ReadAll(fd) if err != nil { return fmt.Errorf("failed to read from config file: %w", err) } add := Config{} err = toml.Unmarshal(data, &add) if err != nil { return fmt.Errorf("failed to unmarshall toml: %w", err) } // merge new values into existing config switch { case add.Debug != conf.Debug: conf.Debug = add.Debug case add.Dbfile != "": conf.Dbfile = add.Dbfile case add.Dbbucket != "": conf.Dbbucket = add.Dbbucket case add.Template != "": conf.Template = add.Template case add.NoHeaders != conf.NoHeaders: conf.NoHeaders = add.NoHeaders case add.NoHumanize != conf.NoHumanize: conf.NoHumanize = add.NoHumanize case add.Encrypt != conf.Encrypt: conf.Encrypt = add.Encrypt case add.Listen != "": conf.Listen = add.Listen } // only supported in config files conf.Buckets = add.Buckets // determine bucket encryption mode for name, bucket := range conf.Buckets { if name == conf.Dbbucket { conf.Encrypt = bucket.Encrypt } } return nil }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package cmd import ( "errors" "os" "strings" "github.com/spf13/cobra" "github.com/tlinden/anydb/app" "github.com/tlinden/anydb/cfg" "github.com/tlinden/anydb/output" ) func Set(conf *cfg.Config) *cobra.Command { var ( attr app.DbAttr ) var cmd = &cobra.Command{ Use: "set <key> [<value> | -r <file>] [-t <tag>]", Short: "Insert key/value pair", Long: `Insert key/value pair`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return errors.New("no key/value pair specified") } // errors at this stage do not cause the usage to be shown cmd.SilenceUsage = true if len(args) > 0 { attr.Args = args } // turn comma list into slice, if needed if len(attr.Tags) == 1 && strings.Contains(attr.Tags[0], ",") { attr.Tags = strings.Split(attr.Tags[0], ",") } // check if value given as file or via stdin and fill attr accordingly if err := attr.ParseKV(); err != nil { return err } // encrypt? if conf.Encrypt { pass, err := getPassword() if err != nil { return err } err = app.Encrypt(pass, &attr) if err != nil { return err } } return conf.DB.Set(&attr) }, } cmd.PersistentFlags().BoolVarP(&conf.Encrypt, "encrypt", "e", false, "encrypt value") cmd.PersistentFlags().StringVarP(&attr.File, "file", "r", "", "Filename or - for STDIN") cmd.PersistentFlags().StringArrayVarP(&attr.Tags, "tags", "t", nil, "tags, multiple allowed") cmd.Aliases = append(cmd.Aliases, "add") cmd.Aliases = append(cmd.Aliases, "s") cmd.Aliases = append(cmd.Aliases, "+") return cmd } func Get(conf *cfg.Config) *cobra.Command { var ( attr app.DbAttr ) var cmd = &cobra.Command{ Use: "get <key> [-o <file>] [-m <mode>] [-n -N] [-T <tpl>]", Short: "Retrieve value for a key", Long: `Retrieve value for a key`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return errors.New("no key specified") } // errors at this stage do not cause the usage to be shown cmd.SilenceUsage = true if len(args) > 0 { attr.Key = args[0] } entry, err := conf.DB.Get(&attr) if err != nil { return err } if entry.Encrypted { pass, err := getPassword() if err != nil { return err } clear, err := app.Decrypt(pass, []byte(entry.Value)) if err != nil { return err } entry.Value = string(clear) entry.Size = uint64(len(entry.Value)) entry.Encrypted = false } return output.Print(os.Stdout, conf, &attr, entry) }, } cmd.PersistentFlags().StringVarP(&attr.File, "output", "o", "", "output value to file (ignores -m)") cmd.PersistentFlags().StringVarP(&conf.Mode, "mode", "m", "", "output format (simple|wide|json|template) (default 'simple')") cmd.PersistentFlags().BoolVarP(&conf.NoHeaders, "no-headers", "n", false, "omit headers in tables") cmd.PersistentFlags().BoolVarP(&conf.NoHumanize, "no-human", "N", false, "do not translate to human readable values") cmd.PersistentFlags().StringVarP(&conf.Template, "template", "T", "", "go template for '-m template'") cmd.Aliases = append(cmd.Aliases, "show") cmd.Aliases = append(cmd.Aliases, "g") cmd.Aliases = append(cmd.Aliases, ".") return cmd } func Del(conf *cfg.Config) *cobra.Command { var ( attr app.DbAttr ) var cmd = &cobra.Command{ Use: "del <key>", Short: "Delete key", Long: `Delete key and value matching key`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return errors.New("No key specified") } // errors at this stage do not cause the usage to be shown cmd.SilenceUsage = true if len(args) > 0 { attr.Key = args[0] } return conf.DB.Del(&attr) }, } cmd.Aliases = append(cmd.Aliases, "d") cmd.Aliases = append(cmd.Aliases, "rm") return cmd } func List(conf *cfg.Config) *cobra.Command { var ( attr app.DbAttr wide bool ) var cmd = &cobra.Command{ Use: "list [<filter-regex> | -t <tag> ] [-m <mode>] [-nNis] [-T <tpl>]", Short: "List database contents", Long: `List database contents`, RunE: func(cmd *cobra.Command, args []string) error { // errors at this stage do not cause the usage to be shown cmd.SilenceUsage = true if len(args) > 0 { if conf.CaseInsensitive { attr.Args = []string{"(?i)" + args[0]} } else { attr.Args = args } } // turn comma list into slice, if needed if len(attr.Tags) == 1 && strings.Contains(attr.Tags[0], ",") { attr.Tags = strings.Split(attr.Tags[0], ",") } if wide { conf.Mode = "wide" } entries, err := conf.DB.List(&attr, conf.Fulltext) if err != nil { return err } return output.List(os.Stdout, conf, entries) }, } cmd.PersistentFlags().StringVarP(&conf.Mode, "mode", "m", "", "output format (table|wide|json|template), wide is a verbose table. (default 'table')") cmd.PersistentFlags().StringVarP(&conf.Template, "template", "T", "", "go template for '-m template'") cmd.PersistentFlags().BoolVarP(&wide, "wide-output", "l", false, "output mode: wide") cmd.PersistentFlags().BoolVarP(&conf.NoHeaders, "no-headers", "n", false, "omit headers in tables") cmd.PersistentFlags().BoolVarP(&conf.NoHumanize, "no-human", "N", false, "do not translate to human readable values") cmd.PersistentFlags().BoolVarP(&conf.CaseInsensitive, "case-insensitive", "i", false, "filter case insensitive") cmd.PersistentFlags().BoolVarP(&conf.Fulltext, "search-fulltext", "s", false, "perform a full text search") cmd.PersistentFlags().StringArrayVarP(&attr.Tags, "tags", "t", nil, "tags, multiple allowed") cmd.Aliases = append(cmd.Aliases, "ls") cmd.Aliases = append(cmd.Aliases, "/") cmd.Aliases = append(cmd.Aliases, "find") cmd.Aliases = append(cmd.Aliases, "search") return cmd } func getPassword() ([]byte, error) { var pass []byte envpass := os.Getenv("ANYDB_PASSWORD") if envpass == "" { readpass, err := app.AskForPassword() if err != nil { return nil, err } pass = readpass } else { pass = []byte(envpass) } return pass, nil }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package cmd import ( "bytes" "errors" "fmt" "io" "os" "os/exec" "github.com/spf13/cobra" "github.com/tlinden/anydb/app" "github.com/tlinden/anydb/cfg" "github.com/tlinden/anydb/output" "github.com/tlinden/anydb/rest" ) func Export(conf *cfg.Config) *cobra.Command { var ( attr app.DbAttr ) var cmd = &cobra.Command{ Use: "export -o <json filename>", Short: "Export database to json file", Long: `Export database to json file`, RunE: func(cmd *cobra.Command, args []string) error { // errors at this stage do not cause the usage to be shown cmd.SilenceUsage = true conf.Mode = "json" entries, err := conf.DB.Getall(&attr) if err != nil { return err } return output.WriteJSON(&attr, conf, entries) }, } cmd.PersistentFlags().StringVarP(&attr.File, "output-file", "o", "", "filename or - for STDIN") if err := cmd.MarkPersistentFlagRequired("output-file"); err != nil { panic(err) } cmd.Aliases = append(cmd.Aliases, "dump") cmd.Aliases = append(cmd.Aliases, "backup") return cmd } func Import(conf *cfg.Config) *cobra.Command { var ( attr app.DbAttr ) var cmd = &cobra.Command{ Use: "import -i <json file>", Short: "Import database dump", Long: `Import database dump`, RunE: func(cmd *cobra.Command, args []string) error { // errors at this stage do not cause the usage to be shown cmd.SilenceUsage = true out, err := conf.DB.Import(&attr) if err != nil { return err } fmt.Print(out) return nil }, } cmd.PersistentFlags().StringVarP(&attr.File, "import-file", "i", "", "filename or - for STDIN") cmd.PersistentFlags().StringArrayVarP(&attr.Tags, "tags", "t", nil, "tags, multiple allowed") if err := cmd.MarkPersistentFlagRequired("import-file"); err != nil { panic(err) } cmd.Aliases = append(cmd.Aliases, "restore") return cmd } func Help(conf *cfg.Config) *cobra.Command { return nil } func Man(conf *cfg.Config) *cobra.Command { var cmd = &cobra.Command{ Use: "man", Short: "show manual page", Long: `show manual page`, RunE: func(cmd *cobra.Command, args []string) error { // errors at this stage do not cause the usage to be shown cmd.SilenceUsage = true man := exec.Command("less", "-") var b bytes.Buffer b.WriteString(manpage) man.Stdout = os.Stdout man.Stdin = &b man.Stderr = os.Stderr err := man.Run() if err != nil { return fmt.Errorf("failed to execute 'less': %w", err) } return nil }, } return cmd } func Serve(conf *cfg.Config) *cobra.Command { var cmd = &cobra.Command{ Use: "serve [-l host:port]", Short: "run REST API listener", Long: `run REST API listener`, RunE: func(cmd *cobra.Command, args []string) error { // errors at this stage do not cause the usage to be shown cmd.SilenceUsage = true return rest.Runserver(conf, nil) }, } cmd.PersistentFlags().StringVarP(&conf.Listen, "listen", "l", "localhost:8787", "host:port") return cmd } func Info(conf *cfg.Config) *cobra.Command { var cmd = &cobra.Command{ Use: "info", Short: "info", Long: `show info about database`, RunE: func(cmd *cobra.Command, args []string) error { // errors at this stage do not cause the usage to be shown cmd.SilenceUsage = true info, err := conf.DB.Info() if err != nil { return err } return output.Info(os.Stdout, conf, info) }, } cmd.PersistentFlags().BoolVarP(&conf.NoHumanize, "no-human", "N", false, "do not translate to human readable values") return cmd } func Edit(conf *cfg.Config) *cobra.Command { var ( attr app.DbAttr ) var cmd = &cobra.Command{ Use: "edit <key>", Short: "Edit a key", Long: `Edit a key`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return errors.New("no key specified") } // errors at this stage do not cause the usage to be shown cmd.SilenceUsage = true password := []byte{} if len(args) > 0 { attr.Key = args[0] } // fetch entry entry, err := conf.DB.Get(&attr) if err != nil { return err } if len(entry.Value) == 0 && entry.Binary { return errors.New("key contains binary uneditable content") } // decrypt if needed if entry.Encrypted { pass, err := getPassword() if err != nil { return err } password = pass clear, err := app.Decrypt(pass, []byte(entry.Value)) if err != nil { return err } entry.Value = string(clear) entry.Encrypted = false } // determine editor, vi is default editor := getEditor() // save file to a temp file, call the editor with it, read // it back in and compare the content with the original // one newcontent, err := editContent(editor, string(entry.Value)) if err != nil { return err } // all is valid, fill our DB feeder newattr := app.DbAttr{ Key: attr.Key, Tags: attr.Tags, Encrypted: attr.Encrypted, Val: []byte(newcontent), } // encrypt if needed if conf.Encrypt { err = app.Encrypt(password, &attr) if err != nil { return err } } // done return conf.DB.Set(&newattr) }, } cmd.Aliases = append(cmd.Aliases, "modify") cmd.Aliases = append(cmd.Aliases, "mod") cmd.Aliases = append(cmd.Aliases, "ed") cmd.Aliases = append(cmd.Aliases, "vi") return cmd } func getEditor() string { editor := "vi" enveditor, present := os.LookupEnv("EDITOR") if present { if editor != "" { editor = enveditor } } return editor } // taken from github.com/tlinden/rpn/ (my own program) func editContent(editor string, content string) (string, error) { // create a temp file tmp, err := os.CreateTemp("", "stack") if err != nil { return "", fmt.Errorf("failed to create templ file: %w", err) } defer os.Remove(tmp.Name()) // put the content into a tmp file _, err = tmp.WriteString(content) if err != nil { return "", fmt.Errorf("failed to write value to temp file: %w", err) } // execute editor with our tmp file containing current stack cmd := exec.Command(editor, tmp.Name()) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err = cmd.Run() if err != nil { return "", fmt.Errorf("failed to run editor command %s: %w", editor, err) } // read the file back in modified, err := os.Open(tmp.Name()) if err != nil { return "", fmt.Errorf("failed to open temp file: %w", err) } defer modified.Close() newcontent, err := io.ReadAll(modified) if err != nil { return "", fmt.Errorf("failed to read from temp file: %w", err) } newcontentstr := string(newcontent) if content == newcontentstr { return "", fmt.Errorf("content not modified, aborting") } return newcontentstr, nil }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package cmd import ( "errors" "fmt" "log/slog" "os" "path/filepath" "runtime/debug" "github.com/spf13/cobra" "github.com/tlinden/anydb/app" "github.com/tlinden/anydb/cfg" "github.com/tlinden/yadu" ) func completion(cmd *cobra.Command, mode string) error { switch mode { case "bash": return cmd.Root().GenBashCompletion(os.Stdout) case "zsh": return cmd.Root().GenZshCompletion(os.Stdout) case "fish": return cmd.Root().GenFishCompletion(os.Stdout, true) case "powershell": return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) default: return errors.New("invalid shell parameter! Valid ones: bash|zsh|fish|powershell") } } func Execute() { var ( conf cfg.Config configfile string ShowVersion bool ShowCompletion string ) home, err := os.UserHomeDir() if err != nil { panic(err) } SearchConfigs := []string{ filepath.Join(home, ".config", "anydb", "anydb.toml"), filepath.Join(home, ".anydb.toml"), "anydb.toml", } var rootCmd = &cobra.Command{ Use: "anydb <command> [options]", Short: "anydb", Long: `A personal key value store`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { var configs []string if configfile != "" { configs = []string{configfile} } else { configs = SearchConfigs } if err := conf.GetConfig(configs); err != nil { return err } if conf.Debug { buildInfo, _ := debug.ReadBuildInfo() opts := &yadu.Options{ Level: slog.LevelDebug, AddSource: true, } slog.SetLogLoggerLevel(slog.LevelDebug) handler := yadu.NewHandler(os.Stdout, opts) debuglogger := slog.New(handler).With( slog.Group("program_info", slog.Int("pid", os.Getpid()), slog.String("go_version", buildInfo.GoVersion), ), ) slog.SetDefault(debuglogger) slog.Debug("parsed config", "conf", conf) } dbfile := app.GetDbFile(conf.Dbfile) db, err := app.New(dbfile, conf.Dbbucket, conf.Debug) if err != nil { return err } conf.DB = db return nil }, RunE: func(cmd *cobra.Command, args []string) error { if ShowVersion { fmt.Printf("This is anydb version %s\n", cfg.Version) return nil } if len(ShowCompletion) > 0 { return completion(cmd, ShowCompletion) } if len(args) == 0 { return errors.New("no command specified") } return nil }, } // options rootCmd.PersistentFlags().BoolVarP(&ShowVersion, "version", "v", false, "Print program version") rootCmd.PersistentFlags().BoolVarP(&conf.Debug, "debug", "d", false, "Enable debugging") rootCmd.PersistentFlags().StringVarP(&conf.Dbfile, "dbfile", "f", "", "DB file to use (default: ~/.config/anydb/default.db)") rootCmd.PersistentFlags().StringVarP(&conf.Dbbucket, "bucket", "b", app.BucketData, "use other bucket (default: "+app.BucketData+")") rootCmd.PersistentFlags().StringVarP(&configfile, "config", "c", "", "toml config file") // CRUD rootCmd.AddCommand(Set(&conf)) rootCmd.AddCommand(List(&conf)) rootCmd.AddCommand(Get(&conf)) rootCmd.AddCommand(Del(&conf)) // backup rootCmd.AddCommand(Export(&conf)) rootCmd.AddCommand(Import(&conf)) // REST API rootCmd.AddCommand(Serve(&conf)) // auxiliary rootCmd.AddCommand(Man(&conf)) rootCmd.AddCommand(Info(&conf)) rootCmd.AddCommand(Edit(&conf)) err = rootCmd.Execute() if err != nil { os.Exit(1) } }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package common import "os" func CleanError(file string, err error) error { // remove given [backup] file and forward the given error os.Remove(file) return err } func FileExists(filename string) bool { info, err := os.Stat(filename) if err != nil { // return false on any error return false } return !info.IsDir() }
package common import ( "fmt" "log/slog" ) type Slogger struct { *slog.Logger } func (l Slogger) Debug(v ...interface{}) {} func (l Slogger) Debugf(format string, v ...interface{}) { l.Logger.Debug(fmt.Sprintf(format, v...)) } func (l Slogger) Error(v ...interface{}) {} func (l Slogger) Errorf(format string, v ...interface{}) { l.Logger.Error(fmt.Sprintf(format, v...)) } func (l Slogger) Info(v ...interface{}) {} func (l Slogger) Infof(format string, v ...interface{}) { l.Logger.Info(fmt.Sprintf(format, v...)) } func (l Slogger) Warning(v ...interface{}) {} func (l Slogger) Warningf(format string, v ...interface{}) { l.Logger.Warn(fmt.Sprintf(format, v...)) } func (l Slogger) Fatal(v ...interface{}) {} func (l Slogger) Fatalf(format string, v ...interface{}) { l.Logger.Error(fmt.Sprintf(format, v...)) } func (l Slogger) Panic(v ...interface{}) {} func (l Slogger) Panicf(format string, v ...interface{}) { l.Logger.Error(fmt.Sprintf(format, v...)) }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package main import ( "bufio" "fmt" "log/slog" "os" "runtime" "github.com/inconshreveable/mousetrap" "github.com/tlinden/anydb/cmd" ) func main() { const NoLogsLevel = 100 slog.SetLogLoggerLevel(NoLogsLevel) Main() } func init() { // if we're running on Windows AND if the user double clicked the // exe file from explorer, we tell them and then wait until any // key has been hit, which will make the cmd window disappear and // thus give the user time to read it. if runtime.GOOS == "windows" { if mousetrap.StartedByExplorer() { fmt.Println("Do no double click kleingebaeck.exe!") fmt.Println("Please open a command shell and run it from there.") fmt.Println() fmt.Print("Press any key to quit: ") _, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil { panic(err) } } } } func Main() int { cmd.Execute() return 0 } func init() { // if we're running on Windows AND if the user double clicked the // exe file from explorer, we tell them and then wait until any // key has been hit, which will make the cmd window disappear and // thus give the user time to read it. if runtime.GOOS == "windows" { if mousetrap.StartedByExplorer() { fmt.Println("Please do no double click anydb.exe!") fmt.Println("Please open a command shell and run it from there.") fmt.Println() fmt.Print("Press any key to quit: ") _, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil { panic(err) } } } }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package output import ( "encoding/json" "fmt" "os" "github.com/tlinden/anydb/app" "github.com/tlinden/anydb/cfg" ) func WriteJSON(attr *app.DbAttr, conf *cfg.Config, entries app.DbEntries) error { jsonentries, err := json.Marshal(entries) if err != nil { return fmt.Errorf("failed to marshall json: %w", err) } if attr.File == "-" { fmt.Println(string(jsonentries)) } else { fd, err := os.OpenFile(attr.File, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return fmt.Errorf("failed to open file %s for writing: %w", attr.File, err) } if _, err := fd.Write(jsonentries); err != nil { return fmt.Errorf("failed writing to file %s: %w", attr.File, err) } fmt.Printf("database contents exported to %s\n", attr.File) } return nil }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package output import ( "bytes" "encoding/json" "errors" "fmt" "io" "strconv" "strings" tpl "text/template" "github.com/dustin/go-humanize" "github.com/olekukonko/tablewriter" "github.com/tlinden/anydb/app" "github.com/tlinden/anydb/cfg" ) func List(writer io.Writer, conf *cfg.Config, entries app.DbEntries) error { switch conf.Mode { case "wide", "", "table": return ListTable(writer, conf, entries) case "json": return ListJson(writer, conf, entries) case "template": return ListTemplate(writer, conf, entries) default: return errors.New("unsupported mode") } } func ListJson(writer io.Writer, conf *cfg.Config, entries app.DbEntries) error { jsonentries, err := json.Marshal(entries) if err != nil { return fmt.Errorf("failed marshall json: %s", err) } fmt.Println(string(jsonentries)) return nil } func ListTemplate(writer io.Writer, conf *cfg.Config, entries app.DbEntries) error { tmpl, err := tpl.New("list").Parse(conf.Template) if err != nil { return fmt.Errorf("failed to parse output template: %w", err) } buf := bytes.Buffer{} for _, row := range entries { buf.Reset() err = tmpl.Execute(&buf, row) if err != nil { return fmt.Errorf("failed to execute output template: %w", err) } if buf.Len() > 0 { fmt.Fprintln(writer, buf.String()) } } return nil } func ListTable(writer io.Writer, conf *cfg.Config, entries app.DbEntries) error { tableString := &strings.Builder{} table := tablewriter.NewWriter(tableString) if !conf.NoHeaders { if conf.Mode == "wide" { table.SetHeader([]string{"KEY", "TAGS", "SIZE", "UPDATED", "VALUE"}) } else { table.SetHeader([]string{"KEY", "VALUE"}) } } for _, row := range entries { if conf.Mode == "wide" { switch conf.NoHumanize { case true: table.Append([]string{ row.Key, strings.Join(row.Tags, ","), strconv.FormatUint(row.Size, 10), row.Created.AsTime().Format("02.01.2006T03:04.05"), row.Preview, }) default: table.Append([]string{ row.Key, strings.Join(row.Tags, ","), humanize.Bytes(uint64(row.Size)), humanize.Time(row.Created.AsTime()), row.Preview, }) } } else { table.Append([]string{row.Key, row.Preview}) } } table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) table.SetAutoWrapText(false) table.SetAutoFormatHeaders(true) table.SetAlignment(tablewriter.ALIGN_LEFT) table.SetCenterSeparator("") table.SetColumnSeparator("") table.SetRowSeparator("") table.SetHeaderLine(false) table.SetBorder(false) table.SetNoWhiteSpace(true) table.SetTablePadding("\t") // pad with tabs table.Render() fmt.Fprint(writer, tableString.String()) return nil }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package output import ( "encoding/json" "fmt" "io" "os" "reflect" "github.com/dustin/go-humanize" "github.com/tlinden/anydb/app" "github.com/tlinden/anydb/cfg" "golang.org/x/term" //"github.com/alecthomas/repr" ) func Print(writer io.Writer, conf *cfg.Config, attr *app.DbAttr, entry *app.DbEntry) error { if attr.File != "" { return WriteFile(writer, conf, attr, entry) } isatty := term.IsTerminal(int(os.Stdout.Fd())) switch conf.Mode { case "simple", "": if entry.Binary { if isatty { fmt.Println("binary data omitted") } else { os.Stdout.WriteString(entry.Value) } } else { fmt.Print(string(entry.Value)) if entry.Value[entry.Size-1] != '\n' { // always add a terminal newline fmt.Println() } } case "json": jsonentry, err := json.Marshal(entry) if err != nil { return fmt.Errorf("failed to marshall json: %s", err) } fmt.Println(string(jsonentry)) case "wide": return ListTable(writer, conf, app.DbEntries{entry}) case "template": return ListTemplate(writer, conf, app.DbEntries{entry}) } return nil } func WriteFile(writer io.Writer, conf *cfg.Config, attr *app.DbAttr, entry *app.DbEntry) error { var fileHandle *os.File var err error if attr.File == "-" { fileHandle = os.Stdout } else { fd, err := os.OpenFile(attr.File, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) if err != nil { return fmt.Errorf("failed to open file %s for writing: %w", attr.File, err) } defer fd.Close() fileHandle = fd } // actually write file content _, err = fileHandle.WriteString(entry.Value) if !entry.Binary { if entry.Value[entry.Size-1] != '\n' { // always add a terminal newline _, err = fileHandle.Write([]byte{'\n'}) } } if err != nil { return fmt.Errorf("failed to write to file %s: %w", attr.File, err) } return nil } func Info(writer io.Writer, conf *cfg.Config, info *app.DbInfo) error { fmt.Fprintf(writer, "Database: %s\n", info.Path) for _, bucket := range info.Buckets { if conf.NoHumanize { fmt.Fprintf( writer, "%19s: %s\n%19s: %d\n%19s: %d\n%19s: %t\n", "Bucket", bucket.Name, "Size", bucket.Size, "Keys", bucket.Keys, "Encrypted", conf.Encrypt) } else { fmt.Fprintf( writer, "%19s: %s\n%19s: %s\n%19s: %d\n", "Bucket", bucket.Name, "Size", humanize.Bytes(uint64(bucket.Size)), "Keys", bucket.Keys) } if conf.Debug { val := reflect.ValueOf(&bucket.Stats).Elem() for i := 0; i < val.NumField(); i++ { fmt.Fprintf(writer, "%19s: %v\n", val.Type().Field(i).Name, val.Field(i)) } } fmt.Fprintln(writer) } return nil }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package rest import ( //"github.com/alecthomas/repr" "github.com/gofiber/fiber/v2" "github.com/tlinden/anydb/app" "github.com/tlinden/anydb/cfg" ) type SetContext struct { Query string `json:"query" form:"query"` } type ListResponse struct { Success bool Code int Entries app.DbEntries } type SingleResponse struct { Success bool Code int Entry *app.DbEntry } func RestList(c *fiber.Ctx, conf *cfg.Config) error { attr := new(app.DbAttr) if len(c.Body()) > 0 { if err := c.BodyParser(attr); err != nil { return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{ "errors": err.Error(), }) } } // get list entries, err := conf.DB.List(attr, attr.Fulltext) if err != nil { return JsonStatus(c, fiber.StatusForbidden, "Unable to list keys: "+err.Error()) } return c.Status(fiber.StatusOK).JSON( ListResponse{ Success: true, Code: fiber.StatusOK, Entries: entries, }, ) } func RestGet(c *fiber.Ctx, conf *cfg.Config) error { if c.Params("key") == "" { return JsonStatus(c, fiber.StatusForbidden, "key not provided") } // get list entry, err := conf.DB.Get(&app.DbAttr{Key: c.Params("key")}) if err != nil { return JsonStatus(c, fiber.StatusForbidden, "Unable to get key: "+err.Error()) } if entry.Key == "" { return JsonStatus(c, fiber.StatusForbidden, "Key does not exist") } return c.Status(fiber.StatusOK).JSON( SingleResponse{ Success: true, Code: fiber.StatusOK, Entry: entry, }, ) } func RestDelete(c *fiber.Ctx, conf *cfg.Config) error { if c.Params("key") == "" { return JsonStatus(c, fiber.StatusForbidden, "key not provided") } // get list err := conf.DB.Del(&app.DbAttr{Key: c.Params("key")}) if err != nil { return JsonStatus(c, fiber.StatusForbidden, "Unable to delete key: "+err.Error()) } return c.Status(fiber.StatusOK).JSON( Result{ Success: true, Code: fiber.StatusOK, Message: "key deleted", }, ) } func RestSet(c *fiber.Ctx, conf *cfg.Config) error { attr := new(app.DbAttr) if err := c.BodyParser(attr); err != nil { return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{ "errors": err.Error(), }) } err := conf.DB.Set(attr) if err != nil { return JsonStatus(c, fiber.StatusForbidden, "Unable to set key: "+err.Error()) } return c.Status(fiber.StatusOK).JSON( Result{ Success: true, Code: fiber.StatusOK, }, ) }
/* Copyright © 2024 Thomas von Dein This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package rest import ( "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/compress" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/tlinden/anydb/cfg" ) // used to return to the api client type Result struct { Success bool `json:"success"` Message string `json:"message"` Code int `json:"code"` } func Runserver(conf *cfg.Config, args []string) error { // setup api server router := SetupServer(conf) // public rest api routes api := router.Group("/anydb/v1") { api.Get("/", func(c *fiber.Ctx) error { return RestList(c, conf) }) api.Post("/", func(c *fiber.Ctx) error { // same thing as above but allows to supply parameters, see app.Dbattr{} return RestList(c, conf) }) api.Get("/:key", func(c *fiber.Ctx) error { return RestGet(c, conf) }) api.Delete("/:key", func(c *fiber.Ctx) error { return RestDelete(c, conf) }) api.Put("/", func(c *fiber.Ctx) error { return RestSet(c, conf) }) } // public routes { router.Get("/", func(c *fiber.Ctx) error { return c.Send([]byte("Use the REST API")) }) } return router.Listen(conf.Listen) } func SetupServer(conf *cfg.Config) *fiber.App { // disable colors fiber.DefaultColors = fiber.Colors{} router := fiber.New(fiber.Config{ CaseSensitive: true, StrictRouting: true, Immutable: true, ServerHeader: "anydb serve", AppName: "anydb", }) router.Use(logger.New(logger.Config{ Format: "${pid} ${ip}:${port} ${status} - ${method} ${path}\n", DisableColors: true, })) router.Use(cors.New(cors.Config{ AllowMethods: "GET,PUT,POST,DELETE", ExposeHeaders: "Content-Type,Accept", })) router.Use(compress.New(compress.Config{ Level: compress.LevelBestSpeed, })) return router } /* Wrapper to respond with proper json status, message and code, shall be prepared and called by the handlers directly. */ func JsonStatus(c *fiber.Ctx, code int, msg string) error { success := true if code != fiber.StatusOK { success = false } return c.Status(code).JSON(Result{ Code: code, Message: msg, Success: success, }) }