package couchdb import ( "context" "errors" "fmt" "net/http" "net/url" "strings" _ "embed" "edge-infra.dev/pkg/lib/logging" "github.com/go-kivik/kivik/v4" "github.com/go-kivik/kivik/v4/couchdb" // The CouchDB driver ) type CouchDB struct { Client *kivik.Client Logger *logging.EdgeLogger } type Security struct { Admins NameRole Members NameRole } type NameRole struct { Names []string Roles []string } type User struct { Name string `json:"name"` Role []string `json:"roles"` } // ReplicatorConfigDoc TODO add remaining settings in next PR from here https://docs.couchdb.org/en/stable/json-structure.html#replication-settings type ReplicatorConfigDoc struct { ID string `json:"_id"` Rev string `json:"_rev"` Source interface{} `json:"source"` // can be string or struct Target interface{} `json:"target"` // can be string or struct CreateTarget bool `json:"create_target"` Continuous bool `json:"continuous"` } //go:embed readOnly.js var jsFunction string var ( ErrNotFound = errors.New("not found") ErrAuthorizationNeeded = errors.New("authentication required") ErrNotAuthorized = errors.New("you are not authorized") ErrPreconditionFailed = errors.New("cannot create a duplicate database") ErrConflict = errors.New("cannot create a duplicate user") ) // create a new instance of CouchDB func (cdb *CouchDB) New(driver, u, p, uri, port string) error { clientKeepAlive := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}} client, err := kivik.New(driver, FormatURI(u, p, uri, port), couchdb.BasicAuth(u, p), couchdb.OptionHTTPClient(clientKeepAlive)) if err != nil { return err } cdb.Client = client cdb.Logger = logging.NewLogger() return nil } // create a new instance of CouchDB func (cdb *CouchDB) NewFromURL(u, p, url string, options ...kivik.Option) error { fURL, err := FormatURL(u, p, url) if err != nil { return err } clientKeepAlive := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}} options = append(options, couchdb.BasicAuth(u, p), couchdb.OptionHTTPClient(clientKeepAlive)) client, err := kivik.New(Driver, fURL, options...) if err != nil { return err } cdb.Client = client cdb.Logger = logging.NewLogger() return nil } func (cdb *CouchDB) GetReplicatorDB() (*kivik.DB, error) { db := cdb.Client.DB("_replicator") err := db.Err() if err != nil { return nil, fmt.Errorf("fail to get replication database: %w", err) } return db, nil } func (cdb *CouchDB) GetReplicationConfigDoc(ctx context.Context, dbname string) (*ReplicatorConfigDoc, error) { db, err := cdb.GetReplicatorDB() if err != nil { return nil, err } replDoc := &ReplicatorConfigDoc{} row := db.Get(ctx, dbname) err = row.Err() if err != nil { return nil, fmt.Errorf("fail to get replication document %s: %w", dbname, err) } err = row.ScanDoc(replDoc) if err != nil { return nil, fmt.Errorf("fail to scan replication document %s: %w", dbname, err) } return replDoc, err } // ShouldReCreateFn we cannot mutate a replication, we can only delete and re-create type ShouldReCreateFn func(replDoc *ReplicatorConfigDoc) bool func (cdb *CouchDB) CreateOrUpdateReplication(ctx context.Context, targetDSN, sourceDSN string, options map[string]interface{}, shouldRecreate ShouldReCreateFn) error { dbname := options["_id"].(string) if len(dbname) == 0 { return fmt.Errorf("invalid replication settings configuration, id not found") } replDoc, err := cdb.GetReplicationConfigDoc(ctx, dbname) if err != nil { if IsNotFound(err) { _, err = cdb.Client.Replicate(ctx, targetDSN, sourceDSN, kivik.Params(options)) return err } return err } if !shouldRecreate(replDoc) { return nil } db, err := cdb.GetReplicatorDB() if err != nil { return err } _, err = db.Delete(ctx, dbname, replDoc.Rev) if err != nil { return fmt.Errorf("fail to remove old replication: %w", err) } _, err = cdb.Client.Replicate(ctx, targetDSN, sourceDSN, kivik.Params(options)) return err } func (cdb *CouchDB) DeleteReplication(ctx context.Context, dbname string) error { replDoc, err := cdb.GetReplicationConfigDoc(ctx, dbname) if IgnoreNotFound(err) != nil { return err } if replDoc == nil { // not found, no need to delete return nil } db, err := cdb.GetReplicatorDB() if err != nil { return nil } _, err = db.Delete(ctx, dbname, replDoc.Rev) return err } // GetReplicationSetDoc return the replication set document func (cdb *CouchDB) GetReplicationSetDoc(ctx context.Context, dbname string, dest interface{}) error { db := cdb.Client.DB(dbname) row := db.Get(ctx, ReplicationDocument) if row.Err() != nil { return row.Err() } return row.ScanDoc(dest) } func (cdb *CouchDB) CheckReplication(ctx context.Context, docID string) error { repls, err := cdb.Client.GetReplications(ctx) if err != nil { return fmt.Errorf("couldn't list replications: %w", err) } docID = fmt.Sprintf("/%s/", docID) found := false // Note: library does not return replication state for a single replication, we are forced to loop through for _, r := range repls { if strings.HasSuffix(r.Source, docID) && strings.HasSuffix(r.Target, docID) { found = true state := r.State() badReplication := state == kivik.ReplicationError || state == kivik.ReplicationCrashing || state == kivik.ReplicationFailed if badReplication { return fmt.Errorf("replication is in bad state %s: %w", state, r.Err()) } break } } if found { return nil } return fmt.Errorf("could not find replication for %s", docID) } // CreateDB creates a new database // return a bool that indicates a duplication was attempted and the error func (cdb *CouchDB) CreateDB(ctx context.Context, dbname string) error { return cdb.checkStatusCode(cdb.Client.CreateDB(ctx, dbname)) } func (cdb *CouchDB) CheckIfDBExists(ctx context.Context, dbname string) (bool, error) { exists, err := cdb.Client.DBExists(ctx, dbname) return exists, cdb.checkStatusCode(err) } // DBsInfo bulk stats, default 100 // TODO split per 100 DB func (cdb *CouchDB) DBsInfo(ctx context.Context, dbs []string) (map[string]*kivik.DBStats, error) { // RECOVERING FROM DATA NOT FOUND IN COUCHDB. // THE KIVIK LIBRARY FALSY PANICS, RESULT SHOULD BE EMPTY INSTEAD defer func() { _ = recover() }() result := make(map[string]*kivik.DBStats) if len(dbs) > 0 { stats, err := cdb.Client.DBsStats(ctx, dbs) if err != nil { return nil, err } for _, stat := range stats { result[stat.Name] = stat } } return result, nil } // CreateNewUser creates a new user // return the rev key for the new user, a bool for if a duplication was attempted to be inserted, and an error func (cdb *CouchDB) CreateNewUser(ctx context.Context, u, p string, r []string) (string, error) { db := cdb.Client.DB("_users") user := formatUserString(u) doc := map[string]interface{}{ "_id": user, "name": u, "type": "user", "roles": r, "password": p, } rev, err := db.Put(ctx, user, doc) return rev, cdb.checkStatusCode(err) } func (cdb *CouchDB) DeleteUser(ctx context.Context, username string) error { db := cdb.Client.DB("_users") user := formatUserString(username) rev, err := db.GetRev(ctx, user) if err != nil { return err } _, err = db.Delete(ctx, user, rev) return err } func (cdb *CouchDB) CheckUserAndRolesExists(ctx context.Context, u string, roles []string) (bool, error) { db := cdb.Client.DB("_users") username := formatUserString(u) row := db.Get(ctx, username) if row.Err() != nil { return false, row.Err() } var user User if err := row.ScanDoc(&user); err != nil { return false, err } if user.Name != u { return false, nil } for _, role := range roles { if !contains(user.Role, role) { return false, nil } } return true, nil } // AddMemberUserAndRoleToMultipleDBs adds a user to a list of databases func (cdb *CouchDB) AddMemberUserAndRoleToMultipleDBs(ctx context.Context, s Security, dbs []string) error { for _, db := range dbs { err := cdb.AddMemberUserAndRolesToDB(ctx, s, db) if err != nil { return err } } return nil } // AddMemberUserAndRolesToDB adds a user to a given databases Member list // takes in a list of strings for Names (users) and Roles func (cdb *CouchDB) AddMemberUserAndRolesToDB(ctx context.Context, s Security, dbname string) error { m := &kivik.Members{ Names: s.Members.Names, Roles: s.Members.Roles, } a := &kivik.Members{ Names: s.Admins.Names, Roles: s.Admins.Roles, } // connect to the correct db and get the current security details db := cdb.Client.DB(dbname) sec, err := db.Security(ctx) if err != nil { return err } // add the new roles to the security list so we dont overwrite for _, name := range a.Names { if !contains(sec.Admins.Names, name) { sec.Admins.Names = append(sec.Admins.Names, name) } } for _, role := range a.Roles { if !contains(sec.Admins.Roles, role) { sec.Admins.Roles = append(sec.Admins.Roles, role) } } for _, name := range m.Names { if !contains(sec.Members.Names, name) { sec.Members.Names = append(sec.Members.Names, name) } } for _, role := range m.Roles { if !contains(sec.Members.Roles, role) { sec.Members.Roles = append(sec.Members.Roles, role) } } return cdb.checkStatusCode(db.SetSecurity(ctx, sec)) } func (cdb *CouchDB) CheckDBUsersAndRoles(ctx context.Context, s Security, dbname string) (bool, error) { // connect to the correct db and get the current security details db := cdb.Client.DB(dbname) sec, err := db.Security(ctx) if err != nil { return false, err } // check that the database contains the user defined security members and roles exists := securityContains(s, sec) return exists, nil } func (cdb *CouchDB) MakeReadOnly(ctx context.Context, dbname string) error { db := cdb.Client.DB(dbname) _, err := db.GetRev(ctx, AuthDesignDoc) if !IsNotFound(err) { return err } if err == nil { // do not re-create design doc return nil } validUserFunc := strings.ReplaceAll(jsFunction, "\\n", " ") doc := map[string]string{ReadOnlyDesignDoc: validUserFunc} return cdb.makeReadOnly(ctx, db, doc) } func (cdb *CouchDB) makeReadOnly(ctx context.Context, db *kivik.DB, doc map[string]string) error { _, err := db.Put(ctx, AuthDesignDoc, doc) if err == nil { return nil } if kivik.HTTPStatus(err) != 409 { return err } rev, err := db.GetRev(ctx, AuthDesignDoc) if err != nil { return err } doc["_rev"] = rev return cdb.makeReadOnly(ctx, db, doc) } func (cdb *CouchDB) RemoveReadOnly(ctx context.Context, dbname string) error { db := cdb.Client.DB(dbname) rev, err := db.GetRev(ctx, AuthDesignDoc) if IsNotFound(err) { return nil } if err != nil { return err } _, err = db.Delete(ctx, AuthDesignDoc, rev) return err } func securityContains(s Security, sec *kivik.Security) bool { // check the admin names / roles exist for _, name := range s.Admins.Names { if !contains(sec.Admins.Names, name) { return false } } for _, role := range s.Admins.Roles { if !contains(sec.Admins.Roles, role) { return false } } // check the members names / roles exist for _, name := range s.Members.Names { if !contains(sec.Members.Names, name) { return false } } for _, role := range s.Members.Roles { if !contains(sec.Members.Roles, role) { return false } } return true } // :) func contains(arr []string, str string) bool { for _, v := range arr { if v == str { return true } } return false } // Close closes the client connection func (cdb *CouchDB) Close(_ context.Context) error { return cdb.Client.Close() } // wrap some of the well known errors with our own sentinels // this should expand over time func (cdb *CouchDB) checkStatusCode(err error) error { // if there's no error dont create an error if err == nil { return err } switch kivik.HTTPStatus(err) { case http.StatusNotFound: return fmt.Errorf("%w", ErrNotFound) case http.StatusUnauthorized: return fmt.Errorf("%w", ErrAuthorizationNeeded) case http.StatusForbidden: return fmt.Errorf("%w", ErrNotAuthorized) case http.StatusConflict: return fmt.Errorf("%w", ErrConflict) case http.StatusPreconditionFailed: return fmt.Errorf("%w", ErrPreconditionFailed) } return err } func IsConflict(err error) bool { return kivik.HTTPStatus(err) == http.StatusConflict } func IsNotFound(err error) bool { return kivik.HTTPStatus(err) == http.StatusNotFound } func IsBadGateway(err error) bool { return kivik.HTTPStatus(err) == http.StatusBadGateway } func IgnoreNotFound(err error) error { if IsNotFound(err) { return nil } return err } // format username with couchdb mandatory prefix (org.couchdb.user:) func formatUserString(username string) string { return fmt.Sprintf("%s%s", kivik.UserPrefix, username) } func FormatURI(_, _, uri, port string) string { url := url.URL{ Scheme: "http", Host: fmt.Sprintf("%s:%s", uri, port), // User: url.UserPassword(username, password), } return url.String() } func FormatURL(username, password, uri string) (string, error) { parsedURL, err := url.Parse(uri) if err != nil { return "", err } parsedURL.User = url.UserPassword(username, password) return parsedURL.String(), nil } func FormatFinishClusterURI(username, password, uri, port string) string { return FormatClusterURI(username, password, uri, port, "_cluster_setup") } func FormatClusterURI(username, password, uri, port, path string) string { url := url.URL{ Scheme: "http", Host: fmt.Sprintf("%s:%s", uri, port), User: url.UserPassword(username, password), Path: path, } return url.String() }