~gbmor/getwtxt

57dfee62f097eed8f5e4a3429d7ea850ce07c570 — Benjamin Morrison 2 years ago d2cbd7b + dd3d87b
Merge pull request #15 from getwtxt/delete-user

Delete user endpoint with admin pass
M .gitignore => .gitignore +1 -0
@@ 4,3 4,4 @@ logs/
local/
*.db/
*.db
/.idea

M Makefile => Makefile +2 -1
@@ 28,7 28,8 @@ install:

	@printf "\n%s\n" "Copying files..."
	install -m755 getwtxt $(BINDIR)
	@if [ -f "$(BINDIR)/getwtxt.yml" ]; then printf "%s\n" "getwtxt.yml exists. Skipping ..."; else printf "%s\n" "getwtxt.yml ..." && install -m644 getwtxt.yml "$(BINDIR)"; fi
	@if [ -f "$(BINDIR)/getwtxt.yml" ]; then printf "%s\n" "getwtxt.yml exists. Skipping ..."; else printf "%s\n" "getwtxt.yml ..." && install -m600 getwtxt.yml "$(BINDIR)"; fi
	chmod 600 $(BINDIR)/getwtxt.yml
	@if [ -f "$(BINDIR)/assets/style.css" ]; then printf "%s\n" "style.css exists. Skipping ..."; else printf "%s\n" "style.css ..." && install -m644 assets/style.css "$(BINDIR)/assets/style.css"; fi
	@if [ -f "$(BINDIR)/assets/tmpl/index.html" ]; then printf "%s\n" "tmpl/index.html exists. Skipping ..."; else printf "%s\n" "tmpl/index.html ..." && install -m644 assets/tmpl/index.html "$(BINDIR)/assets/tmpl/index.html"; fi
	install -m644 static/kognise.water.css.dark.min.css $(BINDIR)/static

M README.md => README.md +8 -0
@@ 263,6 263,14 @@ $ curl 'https://twtxt.example.com/api/plain/tags/programming'
foo    https://example.com/twtxt.txt    2019-03-01T09:31:02.000Z    I love #programming!
```

### Delete a User

```
$ curl -X DELETE -H 'X-Auth: password_in_getwtxt.yml' 'https://twtxt.example.com/api/admin/users?url=https://example.com/twtxt.txt'

200 OK
```

## Benchmarks

* [bombardier](https://github.com/codesenberg/bombardier)

M assets/tmpl/index.html => assets/tmpl/index.html +4 -0
@@ 43,6 43,10 @@
      <pre><code>$ curl '{{.URL}}/api/plain/version'
getwtxt {{.Vers}}
        </code></pre>
     <p>Delete a user by issuing a <code>DELETE</code> request to the <code>/api/admin/users</code> endpoint. This
     must include the <code>X-Auth</code> header with the password specified during configuration.</p>
     <pre><code>$ curl -X DELETE -H 'X-Auth: mypassword' '{{.URL}}/api/admin/users?url=https://foo.ext/twtxt.txt'
200 OK</code></pre>
      <p>Add new user by submitting a <code>POST</code> request to the <code>/api/plain/users</code> endpoint.
        If both <code>?url=X</code> and <code>?nickname=X</code> are not passed, or the user already exists in
        this registry, you will receive <code>400 Bad Request</code> as a response. If you are unsure what went

M getwtxt.yml => getwtxt.yml +3 -0
@@ 40,6 40,9 @@ DatabasePath: "getwtxt.db"
##  changes are detected.                                  ##
#############################################################

# Administrator password for certain destructive actions
AdminPassword: "please_change_me"

# The path to the assets directory, which contains:
#     style.css
#     tmpl/index.html

M go.mod => go.mod +2 -1
@@ 10,5 10,6 @@ require (
	github.com/spf13/pflag v1.0.5
	github.com/spf13/viper v1.7.0
	github.com/syndtr/goleveldb v1.0.0
	golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1
	golang.org/x/crypto v0.0.0-20210921155107-089bfa567519
	golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1
)

M go.sum => go.sum +10 -4
@@ 203,6 203,8 @@ golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnf
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=


@@ 235,8 237,9 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=


@@ 260,12 263,15 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1 h1:ogLJMz+qpzav7lGMh10LMvAkM/fAoGlaiiHYiFYdm80=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

A svc/common.go => svc/common.go +16 -0
@@ 0,0 1,16 @@
package svc

import "golang.org/x/crypto/bcrypt"

// HashPass returns the bcrypt hash of the provided string.
// If an empty string is provided, return an empty string.
func HashPass(s string) (string, error) {
	if s == "" {
		return "", nil
	}
	h, err := bcrypt.GenerateFromPassword([]byte(s), 14)
	if err != nil {
		return "", err
	}
	return string(h), nil
}

A svc/common_test.go => svc/common_test.go +34 -0
@@ 0,0 1,34 @@
package svc

import (
	"testing"
)

func TestHashPass(t *testing.T) {
	cases := []struct {
		in, name   string
		shouldFail bool
	}{
		{
			in:         "foo",
			name:       "non-empty password",
			shouldFail: false,
		},
		{
			in:         "",
			name:       "empty password",
			shouldFail: true,
		},
	}
	for _, v := range cases {
		t.Run(v.name, func(t *testing.T) {
			out, err := HashPass(v.in)
			if err != nil && !v.shouldFail {
				t.Errorf("Shouldn't have failed: Case %s, Error: %s", v.name, err)
			}
			if out == "" && v.in != "" {
				t.Errorf("Got empty out for case %s input %s", v.name, v.in)
			}
		})
	}
}

M svc/conf.go => svc/conf.go +13 -0
@@ 20,6 20,7 @@ along with Getwtxt.  If not, see <https://www.gnu.org/licenses/>.
package svc // import "git.sr.ht/~gbmor/getwtxt/svc"

import (
	"fmt"
	"log"
	"os"
	"path/filepath"


@@ 43,6 44,7 @@ type Configuration struct {
	DBPath        string        `yaml:"DatabasePath"`
	AssetsDir     string        `yaml:"AssetsDirectory"`
	StaticDir     string        `yaml:"StaticFilesDirectory"`
	AdminPassHash string        `yaml:"-"`
	StdoutLogging bool          `yaml:"StdoutLogging"`
	CacheInterval time.Duration `yaml:"StatusFetchInterval"`
	DBInterval    time.Duration `yaml:"DatabasePushInterval"`


@@ 126,6 128,7 @@ func setConfigDefaults() {
	viper.SetDefault("StdoutLogging", false)
	viper.SetDefault("ReCacheInterval", "1h")
	viper.SetDefault("DatabasePushInterval", "5m")
	viper.SetDefault("AdminPassword", "please_change_me")

	viper.SetDefault("Instance.SiteName", "getwtxt")
	viper.SetDefault("Instance.OwnerName", "Anonymous Microblogger")


@@ 173,6 176,16 @@ func bindConfig() {
	confObj.StdoutLogging = viper.GetBool("StdoutLogging")
	confObj.CacheInterval = viper.GetDuration("StatusFetchInterval")
	confObj.DBInterval = viper.GetDuration("DatabasePushInterval")
	txtPass := viper.GetString("AdminPassword")
	if txtPass == "please_change_me" {
		fmt.Println("Please set AdminPassword in getwtxt.yml")
		os.Exit(1)
	}
	passHash, err := HashPass(txtPass)
	if err != nil {
		errFatal("Failed to hash administrator password: ", err)
	}
	confObj.AdminPassHash = passHash

	confObj.Instance.Vers = Vers
	confObj.Instance.Name = viper.GetString("Instance.SiteName")

M svc/db.go => svc/db.go +11 -0
@@ 39,6 39,7 @@ import (
type dbase interface {
	push() error
	pull()
	delUser(string) error
}

// Opens a new connection to the specified


@@ 96,3 97,13 @@ func pullDB() {
	dbChan <- db
	log.Printf("Database pull took: %v\n", time.Since(start))
}

func delUser(userURL string) error {
	db := <-dbChan
	err := db.delUser(userURL)
	dbChan <- db
	if err != nil {
		return err
	}
	return twtxtCache.DelUser(userURL)
}

M svc/handlers.go => svc/handlers.go +38 -0
@@ 20,15 20,18 @@ along with Getwtxt.  If not, see <https://www.gnu.org/licenses/>.
package svc // import "git.sr.ht/~gbmor/getwtxt/svc"

import (
	"errors"
	"fmt"
	"hash/fnv"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"git.sr.ht/~gbmor/getwtxt/registry"
	"github.com/gorilla/mux"
	"golang.org/x/crypto/bcrypt"
)

// Takes the modtime of one of the static files, derives


@@ 242,3 245,38 @@ func apiTagsHandler(w http.ResponseWriter, r *http.Request) {
	}
	log200(r)
}

func handleUserDelete(w http.ResponseWriter, r *http.Request) {
	pass := r.Header.Get("X-Auth")
	if pass == "" {
		errHTTP(w, r, errors.New("unauthorized"), http.StatusUnauthorized)
		return
	}
	confObj.Mu.RLock()
	adminHash := []byte(confObj.AdminPassHash)
	confObj.Mu.RUnlock()

	if err := bcrypt.CompareHashAndPassword(adminHash, []byte(pass)); err != nil {
		errHTTP(w, r, errors.New("unauthorized"), http.StatusUnauthorized)
		return
	}

	r.ParseForm()
	userURL := strings.TrimSpace(r.Form.Get("url"))
	if userURL == "" {
		errHTTP(w, r, errors.New("bad request"), http.StatusBadRequest)
		return
	}
	if _, err := url.Parse(userURL); err != nil {
		errHTTP(w, r, errors.New("bad request"), http.StatusBadRequest)
		return
	}

	if err := delUser(userURL); err != nil {
		return
	}

	w.WriteHeader(200)
	w.Write([]byte("200 OK\n"))
	log200(r)
}

M svc/leveldb.go => svc/leveldb.go +21 -0
@@ 33,6 33,27 @@ type dbLevel struct {
	db *leveldb.DB
}

func (lvl *dbLevel) delUser(userURL string) error {
	twtxtCache.Mu.RLock()
	defer twtxtCache.Mu.RUnlock()

	userStatuses := twtxtCache.Users[userURL].Status
	var dbBasket = &leveldb.Batch{}

	dbBasket.Delete([]byte(userURL + "*Nick"))
	dbBasket.Delete([]byte(userURL + "*URL"))
	dbBasket.Delete([]byte(userURL + "*IP"))
	dbBasket.Delete([]byte(userURL + "*Date"))
	dbBasket.Delete([]byte(userURL + "*LastModified"))

	for i := range userStatuses {
		rfc := i.Format(time.RFC3339)
		dbBasket.Delete([]byte(userURL + "*Status*" + rfc))
	}

	return lvl.db.Write(dbBasket, nil)
}

// Called intermittently to commit registry data to
// a LevelDB database.
func (lvl *dbLevel) push() error {

M svc/sqlite.go => svc/sqlite.go +4 -0
@@ 64,6 64,10 @@ func initSqlite() *dbSqlite {
	}
}

func (lite *dbSqlite) delUser(userURL string) error {
	return nil
}

// Commits data from memory to a SQLite database intermittently.
func (lite *dbSqlite) push() error {
	if err := lite.db.Ping(); err != nil {

M svc/svc.go => svc/svc.go +4 -0
@@ 91,6 91,10 @@ func setIndexRouting(index *mux.Router) {
}

func setEndpointRouting(api *mux.Router) {
	api.Path("/admin/users").
		Methods("DELETE").
		HandlerFunc(handleUserDelete)

	// May add support for other formats later.
	// Making this future-proof.
	api.Path("/{format:(?:plain)}").