remove couchdb
This commit is contained in:
parent
aec5ff55fd
commit
37d5afd3ed
@ -45,7 +45,6 @@ import (
|
|||||||
physCassandra "github.com/hashicorp/vault/physical/cassandra"
|
physCassandra "github.com/hashicorp/vault/physical/cassandra"
|
||||||
physCockroachDB "github.com/hashicorp/vault/physical/cockroachdb"
|
physCockroachDB "github.com/hashicorp/vault/physical/cockroachdb"
|
||||||
physConsul "github.com/hashicorp/vault/physical/consul"
|
physConsul "github.com/hashicorp/vault/physical/consul"
|
||||||
physCouchDB "github.com/hashicorp/vault/physical/couchdb"
|
|
||||||
physFoundationDB "github.com/hashicorp/vault/physical/foundationdb"
|
physFoundationDB "github.com/hashicorp/vault/physical/foundationdb"
|
||||||
physMySQL "github.com/hashicorp/vault/physical/mysql"
|
physMySQL "github.com/hashicorp/vault/physical/mysql"
|
||||||
physOCI "github.com/hashicorp/vault/physical/oci"
|
physOCI "github.com/hashicorp/vault/physical/oci"
|
||||||
@ -175,8 +174,6 @@ var (
|
|||||||
"cassandra": physCassandra.NewCassandraBackend,
|
"cassandra": physCassandra.NewCassandraBackend,
|
||||||
"cockroachdb": physCockroachDB.NewCockroachDBBackend,
|
"cockroachdb": physCockroachDB.NewCockroachDBBackend,
|
||||||
"consul": physConsul.NewConsulBackend,
|
"consul": physConsul.NewConsulBackend,
|
||||||
"couchdb_transactional": physCouchDB.NewTransactionalCouchDBBackend,
|
|
||||||
"couchdb": physCouchDB.NewCouchDBBackend,
|
|
||||||
"file_transactional": physFile.NewTransactionalFileBackend,
|
"file_transactional": physFile.NewTransactionalFileBackend,
|
||||||
"file": physFile.NewFileBackend,
|
"file": physFile.NewFileBackend,
|
||||||
"foundationdb": physFoundationDB.NewFDBBackend,
|
"foundationdb": physFoundationDB.NewFDBBackend,
|
||||||
|
@ -1,317 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// SPDX-License-Identifier: BUSL-1.1
|
|
||||||
|
|
||||||
package couchdb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
metrics "github.com/armon/go-metrics"
|
|
||||||
cleanhttp "github.com/hashicorp/go-cleanhttp"
|
|
||||||
log "github.com/hashicorp/go-hclog"
|
|
||||||
"github.com/hashicorp/vault/sdk/physical"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CouchDBBackend allows the management of couchdb users
|
|
||||||
type CouchDBBackend struct {
|
|
||||||
logger log.Logger
|
|
||||||
client *couchDBClient
|
|
||||||
permitPool *physical.PermitPool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify CouchDBBackend satisfies the correct interfaces
|
|
||||||
var (
|
|
||||||
_ physical.Backend = (*CouchDBBackend)(nil)
|
|
||||||
_ physical.PseudoTransactional = (*CouchDBBackend)(nil)
|
|
||||||
_ physical.PseudoTransactional = (*TransactionalCouchDBBackend)(nil)
|
|
||||||
)
|
|
||||||
|
|
||||||
type couchDBClient struct {
|
|
||||||
endpoint string
|
|
||||||
username string
|
|
||||||
password string
|
|
||||||
*http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
type couchDBListItem struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
Value struct {
|
|
||||||
Revision string
|
|
||||||
} `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type couchDBList struct {
|
|
||||||
TotalRows int `json:"total_rows"`
|
|
||||||
Offset int `json:"offset"`
|
|
||||||
Rows []couchDBListItem `json:"rows"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *couchDBClient) rev(key string) (string, error) {
|
|
||||||
req, err := http.NewRequest("HEAD", fmt.Sprintf("%s/%s", m.endpoint, key), nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.SetBasicAuth(m.username, m.password)
|
|
||||||
|
|
||||||
resp, err := m.Client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
etag := resp.Header.Get("Etag")
|
|
||||||
if len(etag) < 2 {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return etag[1 : len(etag)-1], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *couchDBClient) put(e couchDBEntry) error {
|
|
||||||
bs, err := json.Marshal(e)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("PUT", fmt.Sprintf("%s/%s", m.endpoint, e.ID), bytes.NewReader(bs))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.SetBasicAuth(m.username, m.password)
|
|
||||||
resp, err := m.Client.Do(req)
|
|
||||||
if err == nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *couchDBClient) get(key string) (*physical.Entry, error) {
|
|
||||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", m.endpoint, url.PathEscape(key)), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.SetBasicAuth(m.username, m.password)
|
|
||||||
resp, err := m.Client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return nil, nil
|
|
||||||
} else if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("GET returned %q", resp.Status)
|
|
||||||
}
|
|
||||||
bs, err := ioutil.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
entry := couchDBEntry{}
|
|
||||||
if err := json.Unmarshal(bs, &entry); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return entry.Entry, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *couchDBClient) list(prefix string) ([]couchDBListItem, error) {
|
|
||||||
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/_all_docs", m.endpoint), nil)
|
|
||||||
req.SetBasicAuth(m.username, m.password)
|
|
||||||
values := req.URL.Query()
|
|
||||||
values.Set("skip", "0")
|
|
||||||
values.Set("include_docs", "false")
|
|
||||||
if prefix != "" {
|
|
||||||
values.Set("startkey", fmt.Sprintf("%q", prefix))
|
|
||||||
values.Set("endkey", fmt.Sprintf("%q", prefix+"{}"))
|
|
||||||
}
|
|
||||||
req.URL.RawQuery = values.Encode()
|
|
||||||
|
|
||||||
resp, err := m.Client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
data, err := ioutil.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
results := couchDBList{}
|
|
||||||
if err := json.Unmarshal(data, &results); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return results.Rows, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCouchDBBackend(conf map[string]string, logger log.Logger) (*CouchDBBackend, error) {
|
|
||||||
endpoint := os.Getenv("COUCHDB_ENDPOINT")
|
|
||||||
if endpoint == "" {
|
|
||||||
endpoint = conf["endpoint"]
|
|
||||||
}
|
|
||||||
if endpoint == "" {
|
|
||||||
return nil, fmt.Errorf("missing endpoint")
|
|
||||||
}
|
|
||||||
|
|
||||||
username := os.Getenv("COUCHDB_USERNAME")
|
|
||||||
if username == "" {
|
|
||||||
username = conf["username"]
|
|
||||||
}
|
|
||||||
|
|
||||||
password := os.Getenv("COUCHDB_PASSWORD")
|
|
||||||
if password == "" {
|
|
||||||
password = conf["password"]
|
|
||||||
}
|
|
||||||
|
|
||||||
maxParStr, ok := conf["max_parallel"]
|
|
||||||
var maxParInt int
|
|
||||||
var err error
|
|
||||||
if ok {
|
|
||||||
maxParInt, err = strconv.Atoi(maxParStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed parsing max_parallel parameter: %w", err)
|
|
||||||
}
|
|
||||||
if logger.IsDebug() {
|
|
||||||
logger.Debug("max_parallel set", "max_parallel", maxParInt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CouchDBBackend{
|
|
||||||
client: &couchDBClient{
|
|
||||||
endpoint: endpoint,
|
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
Client: cleanhttp.DefaultPooledClient(),
|
|
||||||
},
|
|
||||||
logger: logger,
|
|
||||||
permitPool: physical.NewPermitPool(maxParInt),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCouchDBBackend(conf map[string]string, logger log.Logger) (physical.Backend, error) {
|
|
||||||
return buildCouchDBBackend(conf, logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
type couchDBEntry struct {
|
|
||||||
Entry *physical.Entry `json:"entry"`
|
|
||||||
Rev string `json:"_rev,omitempty"`
|
|
||||||
ID string `json:"_id"`
|
|
||||||
Deleted *bool `json:"_deleted,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put is used to insert or update an entry
|
|
||||||
func (m *CouchDBBackend) Put(ctx context.Context, entry *physical.Entry) error {
|
|
||||||
m.permitPool.Acquire()
|
|
||||||
defer m.permitPool.Release()
|
|
||||||
|
|
||||||
return m.PutInternal(ctx, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get is used to fetch an entry
|
|
||||||
func (m *CouchDBBackend) Get(ctx context.Context, key string) (*physical.Entry, error) {
|
|
||||||
m.permitPool.Acquire()
|
|
||||||
defer m.permitPool.Release()
|
|
||||||
|
|
||||||
return m.GetInternal(ctx, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete is used to permanently delete an entry
|
|
||||||
func (m *CouchDBBackend) Delete(ctx context.Context, key string) error {
|
|
||||||
m.permitPool.Acquire()
|
|
||||||
defer m.permitPool.Release()
|
|
||||||
|
|
||||||
return m.DeleteInternal(ctx, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List is used to list all the keys under a given prefix
|
|
||||||
func (m *CouchDBBackend) List(ctx context.Context, prefix string) ([]string, error) {
|
|
||||||
defer metrics.MeasureSince([]string{"couchdb", "list"}, time.Now())
|
|
||||||
|
|
||||||
m.permitPool.Acquire()
|
|
||||||
defer m.permitPool.Release()
|
|
||||||
|
|
||||||
items, err := m.client.list(prefix)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []string
|
|
||||||
seen := make(map[string]interface{})
|
|
||||||
for _, result := range items {
|
|
||||||
trimmed := strings.TrimPrefix(result.ID, prefix)
|
|
||||||
sep := strings.Index(trimmed, "/")
|
|
||||||
if sep == -1 {
|
|
||||||
out = append(out, trimmed)
|
|
||||||
} else {
|
|
||||||
trimmed = trimmed[:sep+1]
|
|
||||||
if _, ok := seen[trimmed]; !ok {
|
|
||||||
out = append(out, trimmed)
|
|
||||||
seen[trimmed] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TransactionalCouchDBBackend creates a couchdb backend that forces all operations to happen
|
|
||||||
// in serial
|
|
||||||
type TransactionalCouchDBBackend struct {
|
|
||||||
CouchDBBackend
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTransactionalCouchDBBackend(conf map[string]string, logger log.Logger) (physical.Backend, error) {
|
|
||||||
backend, err := buildCouchDBBackend(conf, logger)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
backend.permitPool = physical.NewPermitPool(1)
|
|
||||||
|
|
||||||
return &TransactionalCouchDBBackend{
|
|
||||||
CouchDBBackend: *backend,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetInternal is used to fetch an entry
|
|
||||||
func (m *CouchDBBackend) GetInternal(ctx context.Context, key string) (*physical.Entry, error) {
|
|
||||||
defer metrics.MeasureSince([]string{"couchdb", "get"}, time.Now())
|
|
||||||
|
|
||||||
return m.client.get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PutInternal is used to insert or update an entry
|
|
||||||
func (m *CouchDBBackend) PutInternal(ctx context.Context, entry *physical.Entry) error {
|
|
||||||
defer metrics.MeasureSince([]string{"couchdb", "put"}, time.Now())
|
|
||||||
|
|
||||||
revision, _ := m.client.rev(url.PathEscape(entry.Key))
|
|
||||||
|
|
||||||
return m.client.put(couchDBEntry{
|
|
||||||
Entry: entry,
|
|
||||||
Rev: revision,
|
|
||||||
ID: url.PathEscape(entry.Key),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteInternal is used to permanently delete an entry
|
|
||||||
func (m *CouchDBBackend) DeleteInternal(ctx context.Context, key string) error {
|
|
||||||
defer metrics.MeasureSince([]string{"couchdb", "delete"}, time.Now())
|
|
||||||
|
|
||||||
revision, _ := m.client.rev(url.PathEscape(key))
|
|
||||||
deleted := true
|
|
||||||
return m.client.put(couchDBEntry{
|
|
||||||
ID: url.PathEscape(key),
|
|
||||||
Rev: revision,
|
|
||||||
Deleted: &deleted,
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,165 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// SPDX-License-Identifier: BUSL-1.1
|
|
||||||
|
|
||||||
package couchdb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
log "github.com/hashicorp/go-hclog"
|
|
||||||
"github.com/hashicorp/vault/sdk/helper/docker"
|
|
||||||
"github.com/hashicorp/vault/sdk/helper/logging"
|
|
||||||
"github.com/hashicorp/vault/sdk/physical"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCouchDBBackend(t *testing.T) {
|
|
||||||
cleanup, config := prepareCouchdbDBTestContainer(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
logger := logging.NewVaultLogger(log.Debug)
|
|
||||||
|
|
||||||
b, err := NewCouchDBBackend(map[string]string{
|
|
||||||
"endpoint": config.URL().String(),
|
|
||||||
"username": config.username,
|
|
||||||
"password": config.password,
|
|
||||||
}, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("err: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
physical.ExerciseBackend(t, b)
|
|
||||||
physical.ExerciseBackend_ListPrefix(t, b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransactionalCouchDBBackend(t *testing.T) {
|
|
||||||
cleanup, config := prepareCouchdbDBTestContainer(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
logger := logging.NewVaultLogger(log.Debug)
|
|
||||||
|
|
||||||
b, err := NewTransactionalCouchDBBackend(map[string]string{
|
|
||||||
"endpoint": config.URL().String(),
|
|
||||||
"username": config.username,
|
|
||||||
"password": config.password,
|
|
||||||
}, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("err: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
physical.ExerciseBackend(t, b)
|
|
||||||
physical.ExerciseBackend_ListPrefix(t, b)
|
|
||||||
}
|
|
||||||
|
|
||||||
type couchDB struct {
|
|
||||||
baseURL url.URL
|
|
||||||
dbname string
|
|
||||||
username string
|
|
||||||
password string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c couchDB) Address() string {
|
|
||||||
return c.baseURL.Host
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c couchDB) URL() *url.URL {
|
|
||||||
u := c.baseURL
|
|
||||||
u.Path = c.dbname
|
|
||||||
return &u
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ docker.ServiceConfig = &couchDB{}
|
|
||||||
|
|
||||||
func prepareCouchdbDBTestContainer(t *testing.T) (func(), *couchDB) {
|
|
||||||
// If environment variable is set, assume caller wants to target a real
|
|
||||||
// DynamoDB.
|
|
||||||
if os.Getenv("COUCHDB_ENDPOINT") != "" {
|
|
||||||
return func() {}, &couchDB{
|
|
||||||
baseURL: url.URL{Host: os.Getenv("COUCHDB_ENDPOINT")},
|
|
||||||
username: os.Getenv("COUCHDB_USERNAME"),
|
|
||||||
password: os.Getenv("COUCHDB_PASSWORD"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runner, err := docker.NewServiceRunner(docker.RunOptions{
|
|
||||||
ContainerName: "couchdb",
|
|
||||||
ImageRepo: "docker.mirror.hashicorp.services/library/couchdb",
|
|
||||||
ImageTag: "1.6",
|
|
||||||
Ports: []string{"5984/tcp"},
|
|
||||||
DoNotAutoRemove: true,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Could not start local CouchDB: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
svc, err := runner.StartService(context.Background(), setupCouchDB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Could not start local CouchDB: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return svc.Cleanup, svc.Config.(*couchDB)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupCouchDB(ctx context.Context, host string, port int) (docker.ServiceConfig, error) {
|
|
||||||
c := &couchDB{
|
|
||||||
baseURL: url.URL{Scheme: "http", Host: fmt.Sprintf("%s:%d", host, port)},
|
|
||||||
dbname: fmt.Sprintf("vault-test-%d", time.Now().Unix()),
|
|
||||||
username: "admin",
|
|
||||||
password: "admin",
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
resp, err := http.Get(c.baseURL.String())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("expected couchdb to return status code 200, got (%s) instead", resp.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
req, err := http.NewRequest("PUT", c.URL().String(), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not create create database request: %q", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not create database: %q", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
|
||||||
bs, _ := ioutil.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("failed to create database: %s %s\n", resp.Status, string(bs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
u := c.baseURL
|
|
||||||
u.Path = fmt.Sprintf("_config/admins/%s", c.username)
|
|
||||||
req, err := http.NewRequest("PUT", u.String(), strings.NewReader(fmt.Sprintf(`"%s"`, c.password)))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Could not create admin user request: %q", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Could not create admin user: %q", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
bs, _ := ioutil.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("Failed to create admin user: %s %s\n", resp.Status, string(bs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c, nil
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user