about summary refs log tree commit diff stats
path: root/registry
diff options
context:
space:
mode:
authorBen Morrison <ben@gbmor.dev>2020-06-20 02:27:31 -0400
committerBen Morrison <ben@gbmor.dev>2020-06-20 02:27:31 -0400
commit538e305925b9b04102ef0a4fb7cca19a6c116142 (patch)
tree8e68b426c40c3151f39da1be874d2938f31ab5f9 /registry
parent0a69c582ec8b88b1d3af70ef43c3eeb1b99f973d (diff)
downloadgetwtxt-538e305925b9b04102ef0a4fb7cca19a6c116142.tar.gz
updating module to live at sourcehut
Also moving the 'registry' library into this repo, rather
than maintaining them separately. It will still be decoupled,
just live in this repository.
Diffstat (limited to 'registry')
-rw-r--r--registry/README.md48
-rw-r--r--registry/fetch.go277
-rw-r--r--registry/fetch_test.go286
-rw-r--r--registry/init_test.go93
-rw-r--r--registry/integ_test.go98
-rw-r--r--registry/query.go196
-rw-r--r--registry/query_test.go459
-rw-r--r--registry/revive.toml30
-rw-r--r--registry/types.go148
-rw-r--r--registry/user.go270
-rw-r--r--registry/user_test.go349
11 files changed, 2254 insertions, 0 deletions
diff --git a/registry/README.md b/registry/README.md
new file mode 100644
index 0000000..34cd37e
--- /dev/null
+++ b/registry/README.md
@@ -0,0 +1,48 @@
+# `getwtxt/registry` 
+
+### twtxt Registry Library for Go
+
+`getwtxt/registry` helps you implement twtxt registries in Go.
+It uses no third-party dependencies whatsoever, only the standard library,
+and has no global state.
+Specifying your own `http.Client` for requests is encouraged, with a sensible
+default available by passing `nil` to the constructor.
+
+## Using the Library
+
+Just add it to your imports list in the file(s) where it's needed.
+
+```go
+import (
+  "git.sr.ht/~gbmor/getwtxt/registry"
+)
+```
+
+## Documentation
+
+The code is commented, so feel free to browse the files themselves. 
+Alternatively, the generated documentation can be found at:
+
+[pkg.go.dev/git.sr.ht/~gbmor/getwtxt/registry](https://pkg.go.dev/git.sr.ht/~gbmor/getwtxt/registry)
+
+## Contributions
+
+All contributions are very welcome! Please specify that you are referring to `getwtxt/registry`
+when using the following:
+
+* Mailing list (patches, discussion)
+  * [https://lists.sr.ht/~gbmor/getwtxt](https://lists.sr.ht/~gbmor/getwtxt)
+* Ticket tracker
+  * [https://todo.sr.ht/~gbmor/getwtxt](https://todo.sr.ht/~gbmor/getwtxt)
+
+## Notes
+
+* getwtxt - parent project:
+  * [sr.ht/~gbmor/getwtxt](https://sr.ht/~gbmor/getwtxt) 
+
+* twtxt repository:
+  * [github.com/buckket/twtxt](https://github.com/buckket/twtxt)
+* twtxt documentation: 
+  * [twtxt.readthedocs.io/en/latest/](https://twtxt.readthedocs.io/en/latest/)
+* twtxt registry documentation:
+  * [twtxt.readthedocs.io/en/latest/user/registry.html](https://twtxt.readthedocs.io/en/latest/user/registry.html)
diff --git a/registry/fetch.go b/registry/fetch.go
new file mode 100644
index 0000000..9adf4ec
--- /dev/null
+++ b/registry/fetch.go
@@ -0,0 +1,277 @@
+/*
+Copyright (c) 2019 Ben Morrison (gbmor)
+
+This file is part of Registry.
+
+Registry 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.
+
+Registry 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 Registry.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+package registry // import "git.sr.ht/~gbmor/getwtxt/registry"
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
+)
+
+// GetTwtxt fetches the raw twtxt file data from the user's
+// provided URL, after validating the URL. If the returned
+// boolean value is false, the fetched URL is a single user's
+// twtxt file. If true, the fetched URL is the output of
+// another registry's /api/plain/tweets. The output of
+// GetTwtxt should be passed to either ParseUserTwtxt or
+// ParseRegistryTwtxt, respectively.
+// Generally, the *http.Client inside a given Registry instance should
+// be passed to GetTwtxt. If the *http.Client passed is nil,
+// Registry will use a preconstructed client with a
+// timeout of 10s and all other values set to default.
+func GetTwtxt(urlKey string, client *http.Client) ([]byte, bool, error) {
+	if !strings.HasPrefix(urlKey, "http://") && !strings.HasPrefix(urlKey, "https://") {
+		return nil, false, fmt.Errorf("invalid URL: %v", urlKey)
+	}
+
+	res, err := doReq(urlKey, "GET", "", client)
+	if err != nil {
+		return nil, false, err
+	}
+	defer res.Body.Close()
+
+	var textPlain bool
+	for _, v := range res.Header["Content-Type"] {
+		if strings.Contains(v, "text/plain") {
+			textPlain = true
+			break
+		}
+	}
+	if !textPlain {
+		return nil, false, fmt.Errorf("received non-text/plain response body from %v", urlKey)
+	}
+
+	if res.StatusCode != http.StatusOK {
+		return nil, false, fmt.Errorf("didn't get 200 from remote server, received %v: %v", res.StatusCode, urlKey)
+	}
+
+	twtxt, err := ioutil.ReadAll(res.Body)
+	if err != nil {
+		return nil, false, fmt.Errorf("error reading response body from %v: %v", urlKey, err)
+	}
+
+	// Signal that we're adding another twtxt registry as a "user"
+	if strings.HasSuffix(urlKey, "/api/plain/tweets") || strings.HasSuffix(urlKey, "/api/plain/tweets/all") {
+		return twtxt, true, nil
+	}
+
+	return twtxt, false, nil
+}
+
+// DiffTwtxt issues a HEAD request on the user's
+// remote twtxt data. It then checks the Content-Length
+// header. If it's different from the stored result of
+// the previous Content-Length header, update the stored
+// value for a given user and return true.
+// Otherwise, return false. In some error conditions,
+// such as the user not being in the registry, it returns true.
+// In other error conditions considered "unrecoverable,"
+// such as the supplied URL being invalid, it returns false.
+func (registry *Registry) DiffTwtxt(urlKey string) (bool, error) {
+	if !strings.HasPrefix(urlKey, "http://") && !strings.HasPrefix(urlKey, "https://") {
+		return false, fmt.Errorf("invalid URL: %v", urlKey)
+	}
+
+	registry.Mu.Lock()
+	user, ok := registry.Users[urlKey]
+	if !ok {
+		return true, fmt.Errorf("user not in registry")
+	}
+
+	user.Mu.Lock()
+
+	defer func() {
+		registry.Users[urlKey] = user
+		user.Mu.Unlock()
+		registry.Mu.Unlock()
+	}()
+
+	res, err := doReq(urlKey, "HEAD", user.LastModified, registry.HTTPClient)
+	if err != nil {
+		return false, err
+	}
+
+	switch res.StatusCode {
+	case http.StatusOK:
+		for _, e := range res.Header["Last-Modified"] {
+			if e != "" {
+				user.LastModified = e
+				break
+			}
+		}
+		return true, nil
+
+	case http.StatusNotModified:
+		return false, nil
+	}
+
+	return false, nil
+}
+
+// internal function. boilerplate for http requests.
+func doReq(urlKey, method, modTime string, client *http.Client) (*http.Response, error) {
+	if client == nil {
+		client = &http.Client{
+			Transport:     nil,
+			CheckRedirect: nil,
+			Jar:           nil,
+			Timeout:       10 * time.Second,
+		}
+	}
+
+	var b []byte
+	buf := bytes.NewBuffer(b)
+	req, err := http.NewRequest(method, urlKey, buf)
+	if err != nil {
+		return nil, err
+	}
+
+	if modTime != "" {
+		req.Header.Set("If-Modified-Since", modTime)
+	}
+
+	res, err := client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't %v %v: %v", method, urlKey, err)
+	}
+
+	return res, nil
+}
+
+// ParseUserTwtxt takes a fetched twtxt file in the form of
+// a slice of bytes, parses it, and returns it as a
+// TimeMap. The output may then be passed to Index.AddUser()
+func ParseUserTwtxt(twtxt []byte, nickname, urlKey string) (TimeMap, error) {
+	var erz []byte
+	if len(twtxt) == 0 {
+		return nil, fmt.Errorf("no data to parse in twtxt file")
+	}
+
+	reader := bytes.NewReader(twtxt)
+	scanner := bufio.NewScanner(reader)
+	timemap := NewTimeMap()
+
+	for scanner.Scan() {
+		nopadding := strings.TrimSpace(scanner.Text())
+		if strings.HasPrefix(nopadding, "#") || nopadding == "" {
+			continue
+		}
+
+		columns := strings.Split(nopadding, "\t")
+		if len(columns) != 2 {
+			return nil, fmt.Errorf("improperly formatted data in twtxt file")
+		}
+
+		normalizedDatestamp := fixTimestamp(columns[0])
+		thetime, err := time.Parse(time.RFC3339, normalizedDatestamp)
+		if err != nil {
+			erz = append(erz, []byte(fmt.Sprintf("unable to retrieve date: %v\n", err))...)
+		}
+
+		timemap[thetime] = nickname + "\t" + urlKey + "\t" + nopadding
+	}
+
+	if len(erz) == 0 {
+		return timemap, nil
+	}
+	return timemap, fmt.Errorf("%v", string(erz))
+}
+
+func fixTimestamp(ts string) string {
+	normalizeTimestamp := regexp.MustCompile(`[\+][\d][\d][:][\d][\d]`)
+	return strings.TrimSpace(normalizeTimestamp.ReplaceAllString(ts, "Z"))
+}
+
+// ParseRegistryTwtxt takes output from a remote registry and outputs
+// the accessible user data via a slice of Users.
+func ParseRegistryTwtxt(twtxt []byte) ([]*User, error) {
+	var erz []byte
+	if len(twtxt) == 0 {
+		return nil, fmt.Errorf("received no data")
+	}
+
+	reader := bytes.NewReader(twtxt)
+	scanner := bufio.NewScanner(reader)
+	userdata := []*User{}
+
+	for scanner.Scan() {
+
+		nopadding := strings.TrimSpace(scanner.Text())
+
+		if strings.HasPrefix(nopadding, "#") || nopadding == "" {
+			continue
+		}
+
+		columns := strings.Split(nopadding, "\t")
+		if len(columns) != 4 {
+			return nil, fmt.Errorf("improperly formatted data")
+		}
+
+		thetime, err := time.Parse(time.RFC3339, columns[2])
+		if err != nil {
+			erz = append(erz, []byte(fmt.Sprintf("%v\n", err))...)
+			continue
+		}
+
+		parsednickname := columns[0]
+		dataIndex := 0
+		parsedurl := columns[1]
+		inIndex := false
+
+		for i, e := range userdata {
+			if e.Nick == parsednickname || e.URL == parsedurl {
+				dataIndex = i
+				inIndex = true
+				break
+			}
+		}
+
+		if inIndex {
+			tmp := userdata[dataIndex]
+			tmp.Status[thetime] = nopadding
+			userdata[dataIndex] = tmp
+		} else {
+			timeNowRFC := time.Now().Format(time.RFC3339)
+			if err != nil {
+				erz = append(erz, []byte(fmt.Sprintf("%v\n", err))...)
+			}
+
+			tmp := &User{
+				Mu:   sync.RWMutex{},
+				Nick: parsednickname,
+				URL:  parsedurl,
+				Date: timeNowRFC,
+				Status: TimeMap{
+					thetime: nopadding,
+				},
+			}
+
+			userdata = append(userdata, tmp)
+		}
+	}
+
+	return userdata, fmt.Errorf("%v", erz)
+}
diff --git a/registry/fetch_test.go b/registry/fetch_test.go
new file mode 100644
index 0000000..4eab2a4
--- /dev/null
+++ b/registry/fetch_test.go
@@ -0,0 +1,286 @@
+/*
+Copyright (c) 2019 Ben Morrison (gbmor)
+
+This file is part of Registry.
+
+Registry 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.
+
+Registry 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 Registry.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+package registry
+
+import (
+	"bufio"
+	"fmt"
+	"net/http"
+	"os"
+	"strings"
+	"testing"
+	"time"
+)
+
+func constructTwtxt() []byte {
+	registry := initTestEnv()
+	var resp []byte
+	// iterates through each mock user's mock statuses
+	for _, v := range registry.Users {
+		for _, e := range v.Status {
+			split := strings.Split(e, "\t")
+			status := []byte(split[2] + "\t" + split[3] + "\n")
+			resp = append(resp, status...)
+		}
+	}
+	return resp
+}
+
+// this is just dumping all the mock statuses.
+// it'll be served under fake paths as
+// "remote" twtxt.txt files
+func twtxtHandler(w http.ResponseWriter, _ *http.Request) {
+	// prepare the response
+	resp := constructTwtxt()
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	n, err := w.Write(resp)
+	if err != nil || n == 0 {
+		fmt.Printf("Got error or wrote zero bytes: %v bytes, %v\n", n, err)
+	}
+}
+
+var getTwtxtCases = []struct {
+	name      string
+	url       string
+	wantErr   bool
+	localOnly bool
+}{
+	{
+		name:      "Constructed Local twtxt.txt",
+		url:       "http://localhost:8080/twtxt.txt",
+		wantErr:   false,
+		localOnly: true,
+	},
+	{
+		name:      "Inaccessible Site With twtxt.txt",
+		url:       "https://example33333333333.com/twtxt.txt",
+		wantErr:   true,
+		localOnly: false,
+	},
+	{
+		name:      "Inaccessible Site Without twtxt.txt",
+		url:       "https://example333333333333.com",
+		wantErr:   true,
+		localOnly: false,
+	},
+	{
+		name:      "Local File Inclusion 1",
+		url:       "file://init_test.go",
+		wantErr:   true,
+		localOnly: false,
+	},
+	{
+		name:      "Local File Inclusion 2",
+		url:       "/etc/passwd",
+		wantErr:   true,
+		localOnly: false,
+	},
+	{
+		name:      "Remote File Inclusion",
+		url:       "https://example.com/file.cgi",
+		wantErr:   true,
+		localOnly: false,
+	},
+	{
+		name:      "Remote Registry",
+		url:       "https://twtxt.tilde.institute/api/plain/tweets/",
+		wantErr:   false,
+		localOnly: false,
+	},
+	{
+		name:      "Garbage Data",
+		url:       "this will be replaced with garbage data",
+		wantErr:   true,
+		localOnly: true,
+	},
+}
+
+// Test the function that yoinks the /twtxt.txt file
+// for a given user.
+func Test_GetTwtxt(t *testing.T) {
+	var buf = make([]byte, 256)
+	// read random data into case 4
+	rando, _ := os.Open("/dev/random")
+	reader := bufio.NewReader(rando)
+	n, err := reader.Read(buf)
+	if err != nil || n == 0 {
+		t.Errorf("Couldn't set up test: %v\n", err)
+	}
+	getTwtxtCases[7].url = string(buf)
+
+	if !getTwtxtCases[0].localOnly {
+		http.Handle("/twtxt.txt", http.HandlerFunc(twtxtHandler))
+		go fmt.Println(http.ListenAndServe(":8080", nil))
+	}
+
+	for _, tt := range getTwtxtCases {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.localOnly {
+				t.Skipf("Local-only test. Skipping ... \n")
+			}
+			out, _, err := GetTwtxt(tt.url, nil)
+			if tt.wantErr && err == nil {
+				t.Errorf("Expected error: %v\n", tt.url)
+			}
+			if !tt.wantErr && err != nil {
+				t.Errorf("Unexpected error: %v %v\n", tt.url, err)
+			}
+			if !tt.wantErr && out == nil {
+				t.Errorf("Incorrect data received: %v\n", out)
+			}
+		})
+	}
+
+}
+
+// running the benchmarks separately for each case
+// as they have different properties (allocs, time)
+func Benchmark_GetTwtxt(b *testing.B) {
+
+	for i := 0; i < b.N; i++ {
+		_, _, err := GetTwtxt("https://gbmor.dev/twtxt.txt", nil)
+		if err != nil {
+			continue
+		}
+	}
+}
+
+var parseTwtxtCases = []struct {
+	name      string
+	data      []byte
+	wantErr   bool
+	localOnly bool
+}{
+	{
+		name:      "Constructed twtxt file",
+		data:      constructTwtxt(),
+		wantErr:   false,
+		localOnly: false,
+	},
+	{
+		name:      "Incorrectly formatted date",
+		data:      []byte("2019 April 23rd\tI love twtxt!!!11"),
+		wantErr:   true,
+		localOnly: false,
+	},
+	{
+		name:      "No data",
+		data:      []byte{},
+		wantErr:   true,
+		localOnly: false,
+	},
+	{
+		name:      "Variant rfc3339 datestamp",
+		data:      []byte("2020-02-04T21:28:21.868659+00:00\tWill this work?"),
+		wantErr:   false,
+		localOnly: false,
+	},
+	{
+		name:      "Random/garbage data",
+		wantErr:   true,
+		localOnly: true,
+	},
+}
+
+// See if we can break ParseTwtxt or get it
+// to throw an unexpected error
+func Test_ParseUserTwtxt(t *testing.T) {
+	var buf = make([]byte, 256)
+	// read random data into case 4
+	rando, _ := os.Open("/dev/random")
+	reader := bufio.NewReader(rando)
+	n, err := reader.Read(buf)
+	if err != nil || n == 0 {
+		t.Errorf("Couldn't set up test: %v\n", err)
+	}
+	parseTwtxtCases[4].data = buf
+
+	for _, tt := range parseTwtxtCases {
+		if tt.localOnly {
+			t.Skipf("Local-only test: Skipping ... \n")
+		}
+		t.Run(tt.name, func(t *testing.T) {
+			timemap, errs := ParseUserTwtxt(tt.data, "testuser", "testurl")
+			if errs == nil && tt.wantErr {
+				t.Errorf("Expected error(s), received none.\n")
+			}
+
+			if !tt.wantErr {
+				if errs != nil {
+					t.Errorf("Unexpected error: %v\n", errs)
+				}
+
+				for k, v := range timemap {
+					if k == (time.Time{}) || v == "" {
+						t.Errorf("Empty status or empty timestamp: %v, %v\n", k, v)
+					}
+				}
+			}
+		})
+	}
+}
+
+func Benchmark_ParseUserTwtxt(b *testing.B) {
+	var buf = make([]byte, 256)
+	// read random data into case 4
+	rando, _ := os.Open("/dev/random")
+	reader := bufio.NewReader(rando)
+	n, err := reader.Read(buf)
+	if err != nil || n == 0 {
+		b.Errorf("Couldn't set up benchmark: %v\n", err)
+	}
+	parseTwtxtCases[3].data = buf
+
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		for _, tt := range parseTwtxtCases {
+			_, _ = ParseUserTwtxt(tt.data, "testuser", "testurl")
+		}
+	}
+}
+
+var timestampCases = []struct {
+	name     string
+	orig     string
+	expected string
+}{
+	{
+		name:     "Timezone appended",
+		orig:     "2020-01-13T16:08:25.544735+00:00",
+		expected: "2020-01-13T16:08:25.544735Z",
+	},
+	{
+		name:     "It's fine already",
+		orig:     "2020-01-14T00:19:45.092344Z",
+		expected: "2020-01-14T00:19:45.092344Z",
+	},
+}
+
+func Test_fixTimestamp(t *testing.T) {
+	for _, tt := range timestampCases {
+		t.Run(tt.name, func(t *testing.T) {
+			tsout := fixTimestamp(tt.orig)
+			if tsout != tt.expected {
+				t.Errorf("Failed :: %s :: got %s expected %s", tt.name, tsout, tt.expected)
+			}
+		})
+	}
+}
diff --git a/registry/init_test.go b/registry/init_test.go
new file mode 100644
index 0000000..ab9d494
--- /dev/null
+++ b/registry/init_test.go
@@ -0,0 +1,93 @@
+/*
+Copyright (c) 2019 Ben Morrison (gbmor)
+
+This file is part of Registry.
+
+Registry 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.
+
+Registry 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 Registry.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+package registry //import "git.sr.ht/~gbmor/getwtxt/registry"
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"time"
+)
+
+func quickErr(err error) {
+	if err != nil {
+		fmt.Printf("%v\n", err)
+	}
+}
+
+// Sets up mock users and statuses
+func initTestEnv() *Registry {
+	hush, err := os.Open("/dev/null")
+	quickErr(err)
+	log.SetOutput(hush)
+
+	// this is a bit tedious, but set up fake dates
+	// for the mock users' join and status timestamps
+	timeMonthPrev := time.Now().AddDate(0, -1, 0)
+	timeMonthPrevRFC := timeMonthPrev.Format(time.RFC3339)
+
+	timeTwoMonthsPrev := time.Now().AddDate(0, -2, 0)
+	timeTwoMonthsPrevRFC := timeTwoMonthsPrev.Format(time.RFC3339)
+
+	timeThreeMonthsPrev := time.Now().AddDate(0, -3, 0)
+	timeThreeMonthsPrevRFC := timeThreeMonthsPrev.Format(time.RFC3339)
+
+	timeFourMonthsPrev := time.Now().AddDate(0, -4, 0)
+	timeFourMonthsPrevRFC := timeFourMonthsPrev.Format(time.RFC3339)
+
+	var mockusers = []struct {
+		url     string
+		nick    string
+		date    string
+		apidate []byte
+		status  TimeMap
+	}{
+		{
+			url:  "https://example3.com/twtxt.txt",
+			nick: "foo_barrington",
+			date: timeTwoMonthsPrevRFC,
+			status: TimeMap{
+				timeTwoMonthsPrev: "foo_barrington\thttps://example3.com/twtxt.txt\t" + timeTwoMonthsPrevRFC + "\tJust got started with #twtxt!",
+				timeMonthPrev:     "foo_barrington\thttps://example3.com/twtxt.txt\t" + timeMonthPrevRFC + "\tHey <@foo https://example.com/twtxt.txt>, I love programming. Just FYI.",
+			},
+		},
+		{
+			url:  "https://example.com/twtxt.txt",
+			nick: "foo",
+			date: timeFourMonthsPrevRFC,
+			status: TimeMap{
+				timeFourMonthsPrev:  "foo\thttps://example.com/twtxt.txt\t" + timeFourMonthsPrevRFC + "\tThis is so much better than #twitter",
+				timeThreeMonthsPrev: "foo\thttps://example.com/twtxt.txt\t" + timeThreeMonthsPrevRFC + "\tI can't wait to start on my next programming #project with <@foo_barrington https://example3.com/twtxt.txt>",
+			},
+		},
+	}
+	registry := New(nil)
+
+	// fill the test registry with the mock users
+	for _, e := range mockusers {
+		data := &User{}
+		data.Nick = e.nick
+		data.Date = e.date
+		data.Status = e.status
+		registry.Users[e.url] = data
+	}
+
+	return registry
+}
diff --git a/registry/integ_test.go b/registry/integ_test.go
new file mode 100644
index 0000000..2cfbb13
--- /dev/null
+++ b/registry/integ_test.go
@@ -0,0 +1,98 @@
+/*
+Copyright (c) 2019 Ben Morrison (gbmor)
+
+This file is part of Registry.
+
+Registry 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.
+
+Registry 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 Registry.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+package registry
+
+import (
+	"strings"
+	"testing"
+)
+
+// This tests all the operations on an registry.
+func Test_Integration(t *testing.T) {
+	var integration = func(t *testing.T) {
+		t.Logf("Creating registry object ...\n")
+		registry := New(nil)
+
+		t.Logf("Fetching remote twtxt file ...\n")
+		mainregistry, _, err := GetTwtxt("https://gbmor.dev/twtxt.txt", nil)
+		if err != nil {
+			t.Errorf("%v\n", err)
+		}
+
+		t.Logf("Parsing remote twtxt file ...\n")
+		parsed, errz := ParseUserTwtxt(mainregistry, "gbmor", "https://gbmor.dev/twtxt.txt")
+		if errz != nil {
+			t.Errorf("%v\n", errz)
+		}
+
+		t.Logf("Adding new user to registry ...\n")
+		err = registry.AddUser("TestRegistry", "https://gbmor.dev/twtxt.txt", nil, parsed)
+		if err != nil {
+			t.Errorf("%v\n", err)
+		}
+
+		t.Logf("Querying user statuses ...\n")
+		queryuser, err := registry.QueryUser("TestRegistry")
+		if err != nil {
+			t.Errorf("%v\n", err)
+		}
+		for _, e := range queryuser {
+			if !strings.Contains(e, "TestRegistry") {
+				t.Errorf("QueryUser() returned incorrect data\n")
+			}
+		}
+
+		t.Logf("Querying for keyword in statuses ...\n")
+		querystatus, err := registry.QueryInStatus("morning")
+		if err != nil {
+			t.Errorf("%v\n", err)
+		}
+		for _, e := range querystatus {
+			if !strings.Contains(e, "morning") {
+				t.Errorf("QueryInStatus() returned incorrect data\n")
+			}
+		}
+
+		t.Logf("Querying for all statuses ...\n")
+		allstatus, err := registry.QueryAllStatuses()
+		if err != nil {
+			t.Errorf("%v\n", err)
+		}
+		if len(allstatus) == 0 || allstatus == nil {
+			t.Errorf("Got nil/zero from QueryAllStatuses")
+		}
+
+		t.Logf("Querying for all users ...\n")
+		allusers, err := registry.QueryUser("")
+		if err != nil {
+			t.Errorf("%v\n", err)
+		}
+		if len(allusers) == 0 || allusers == nil {
+			t.Errorf("Got nil/zero users on empty QueryUser() query")
+		}
+
+		t.Logf("Deleting user ...\n")
+		err = registry.DelUser("https://gbmor.dev/twtxt.txt")
+		if err != nil {
+			t.Errorf("%v\n", err)
+		}
+	}
+	t.Run("Integration Test", integration)
+}
diff --git a/registry/query.go b/registry/query.go
new file mode 100644
index 0000000..604b974
--- /dev/null
+++ b/registry/query.go
@@ -0,0 +1,196 @@
+/*
+Copyright (c) 2019 Ben Morrison (gbmor)
+
+This file is part of Registry.
+
+Registry 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.
+
+Registry 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 Registry.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+package registry // import "git.sr.ht/~gbmor/getwtxt/registry"
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+	"time"
+)
+
+// QueryUser checks the Registry for usernames
+// or user URLs that contain the term provided as an argument. Entries
+// are returned sorted by the date they were added to the Registry. If
+// the argument provided is blank, return all users.
+func (registry *Registry) QueryUser(term string) ([]string, error) {
+	if registry == nil {
+		return nil, fmt.Errorf("can't query empty registry for user")
+	}
+
+	term = strings.ToLower(term)
+	timekey := NewTimeMap()
+	keys := make(TimeSlice, 0)
+	var users []string
+
+	registry.Mu.RLock()
+	defer registry.Mu.RUnlock()
+
+	for k, v := range registry.Users {
+		if registry.Users[k] == nil {
+			continue
+		}
+		v.Mu.RLock()
+		if strings.Contains(strings.ToLower(v.Nick), term) || strings.Contains(strings.ToLower(k), term) {
+			thetime, err := time.Parse(time.RFC3339, v.Date)
+			if err != nil {
+				v.Mu.RUnlock()
+				continue
+			}
+			timekey[thetime] = v.Nick + "\t" + k + "\t" + v.Date + "\n"
+			keys = append(keys, thetime)
+		}
+		v.Mu.RUnlock()
+	}
+
+	sort.Sort(keys)
+	for _, e := range keys {
+		users = append(users, timekey[e])
+	}
+
+	return users, nil
+}
+
+// QueryInStatus returns all statuses in the Registry
+// that contain the provided substring (tag, mention URL, etc).
+func (registry *Registry) QueryInStatus(substring string) ([]string, error) {
+	if substring == "" {
+		return nil, fmt.Errorf("cannot query for empty tag")
+	} else if registry == nil {
+		return nil, fmt.Errorf("can't query statuses of empty registry")
+	}
+
+	statusmap := make([]TimeMap, 0)
+
+	registry.Mu.RLock()
+	defer registry.Mu.RUnlock()
+
+	for _, v := range registry.Users {
+		statusmap = append(statusmap, v.FindInStatus(substring))
+	}
+
+	sorted, err := SortByTime(statusmap...)
+	if err != nil {
+		return nil, err
+	}
+
+	return sorted, nil
+}
+
+// QueryAllStatuses returns all statuses in the Registry
+// as a slice of strings sorted by timestamp.
+func (registry *Registry) QueryAllStatuses() ([]string, error) {
+	if registry == nil {
+		return nil, fmt.Errorf("can't get latest statuses from empty registry")
+	}
+
+	statusmap, err := registry.GetStatuses()
+	if err != nil {
+		return nil, err
+	}
+
+	sorted, err := SortByTime(statusmap)
+	if err != nil {
+		return nil, err
+	}
+
+	if sorted == nil {
+		sorted = make([]string, 1)
+	}
+
+	return sorted, nil
+}
+
+// ReduceToPage returns the passed 'page' worth of output.
+// One page is twenty items. For example, if 2 is passed,
+// it will return data[20:40]. According to the twtxt
+// registry specification, queries should accept a "page"
+// value.
+func ReduceToPage(page int, data []string) []string {
+	end := 20 * page
+	if end > len(data) || end < 1 {
+		end = len(data)
+	}
+
+	beg := end - 20
+	if beg > len(data)-1 || beg < 0 {
+		beg = 0
+	}
+
+	return data[beg:end]
+}
+
+// FindInStatus takes a user's statuses and looks for a given substring.
+// Returns the statuses that include the substring as a TimeMap.
+func (userdata *User) FindInStatus(substring string) TimeMap {
+	if userdata == nil {
+		return nil
+	} else if len(substring) > 140 {
+		return nil
+	}
+
+	substring = strings.ToLower(substring)
+	statuses := NewTimeMap()
+
+	userdata.Mu.RLock()
+	defer userdata.Mu.RUnlock()
+
+	for k, e := range userdata.Status {
+		if _, ok := userdata.Status[k]; !ok {
+			continue
+		}
+
+		parts := strings.Split(strings.ToLower(e), "\t")
+		if strings.Contains(parts[3], substring) {
+			statuses[k] = e
+		}
+	}
+
+	return statuses
+}
+
+// SortByTime returns a string slice of the query results,
+// sorted by timestamp in descending order (newest first).
+func SortByTime(tm ...TimeMap) ([]string, error) {
+	if tm == nil {
+		return nil, fmt.Errorf("can't sort nil TimeMaps")
+	}
+
+	var times = make(TimeSlice, 0)
+	var data []string
+
+	for _, e := range tm {
+		for k := range e {
+			times = append(times, k)
+		}
+	}
+
+	sort.Sort(times)
+
+	for k := range tm {
+		for _, e := range times {
+			if _, ok := tm[k][e]; ok {
+				data = append(data, tm[k][e])
+			}
+		}
+	}
+
+	return data, nil
+}
diff --git a/registry/query_test.go b/registry/query_test.go
new file mode 100644
index 0000000..7eed2cd
--- /dev/null
+++ b/registry/query_test.go
@@ -0,0 +1,459 @@
+/*
+Copyright (c) 2019 Ben Morrison (gbmor)
+
+This file is part of Registry.
+
+Registry 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.
+
+Registry 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 Registry.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+package registry
+
+import (
+	"bufio"
+	"os"
+	"strings"
+	"testing"
+	"time"
+)
+
+var queryUserCases = []struct {
+	name    string
+	term    string
+	wantErr bool
+}{
+	{
+		name:    "Valid User",
+		term:    "foo",
+		wantErr: false,
+	},
+	{
+		name:    "Empty Query",
+		term:    "",
+		wantErr: false,
+	},
+	{
+		name:    "Nonexistent User",
+		term:    "doesntexist",
+		wantErr: true,
+	},
+	{
+		name:    "Garbage Data",
+		term:    "will be replaced with garbage data",
+		wantErr: true,
+	},
+}
+
+// Checks if Registry.QueryUser() returns users that
+// match the provided substring.
+func Test_Registry_QueryUser(t *testing.T) {
+	registry := initTestEnv()
+	var buf = make([]byte, 256)
+	// read random data into case 8
+	rando, _ := os.Open("/dev/random")
+	reader := bufio.NewReader(rando)
+	n, err := reader.Read(buf)
+	if err != nil || n == 0 {
+		t.Errorf("Couldn't set up test: %v\n", err)
+	}
+	queryUserCases[3].term = string(buf)
+
+	for n, tt := range queryUserCases {
+
+		t.Run(tt.name, func(t *testing.T) {
+			out, err := registry.QueryUser(tt.term)
+
+			if out == nil && err != nil && !tt.wantErr {
+				t.Errorf("Received nil output or an error when unexpected. Case %v, %v, %v\n", n, tt.term, err)
+			}
+
+			if out != nil && tt.wantErr {
+				t.Errorf("Received unexpected nil output when an error was expected. Case %v, %v\n", n, tt.term)
+			}
+
+			for _, e := range out {
+				one := strings.Split(e, "\t")
+
+				if !strings.Contains(one[0], tt.term) && !strings.Contains(one[1], tt.term) {
+					t.Errorf("Received incorrect output: %v != %v\n", tt.term, e)
+				}
+			}
+		})
+	}
+}
+func Benchmark_Registry_QueryUser(b *testing.B) {
+	registry := initTestEnv()
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		for _, tt := range queryUserCases {
+			_, err := registry.QueryUser(tt.term)
+			if err != nil {
+				b.Errorf("%v\n", err)
+			}
+		}
+	}
+}
+
+var queryInStatusCases = []struct {
+	name    string
+	substr  string
+	wantNil bool
+	wantErr bool
+}{
+	{
+		name:    "Tag in Status",
+		substr:  "twtxt",
+		wantNil: false,
+		wantErr: false,
+	},
+	{
+		name:    "Valid URL",
+		substr:  "https://example.com/twtxt.txt",
+		wantNil: false,
+		wantErr: false,
+	},
+	{
+		name:    "Multiple Words in Status",
+		substr:  "next programming",
+		wantNil: false,
+		wantErr: false,
+	},
+	{
+		name:    "Multiple Words, Not in Status",
+		substr:  "explosive bananas from antarctica",
+		wantNil: true,
+		wantErr: false,
+	},
+	{
+		name:    "Empty Query",
+		substr:  "",
+		wantNil: true,
+		wantErr: true,
+	},
+	{
+		name:    "Nonsense",
+		substr:  "ahfiurrenkhfkajdhfao",
+		wantNil: true,
+		wantErr: false,
+	},
+	{
+		name:    "Invalid URL",
+		substr:  "https://doesnt.exist/twtxt.txt",
+		wantNil: true,
+		wantErr: false,
+	},
+	{
+		name:    "Garbage Data",
+		substr:  "will be replaced with garbage data",
+		wantNil: true,
+		wantErr: false,
+	},
+}
+
+// This tests whether we can find a substring in all of
+// the known status messages, disregarding the metadata
+// stored with each status.
+func Test_Registry_QueryInStatus(t *testing.T) {
+	registry := initTestEnv()
+	var buf = make([]byte, 256)
+	// read random data into case 8
+	rando, _ := os.Open("/dev/random")
+	reader := bufio.NewReader(rando)
+	n, err := reader.Read(buf)
+	if err != nil || n == 0 {
+		t.Errorf("Couldn't set up test: %v\n", err)
+	}
+	queryInStatusCases[7].substr = string(buf)
+
+	for _, tt := range queryInStatusCases {
+
+		t.Run(tt.name, func(t *testing.T) {
+
+			out, err := registry.QueryInStatus(tt.substr)
+			if err != nil && !tt.wantErr {
+				t.Errorf("Caught unexpected error: %v\n", err)
+			}
+
+			if !tt.wantErr && out == nil && !tt.wantNil {
+				t.Errorf("Got nil when expecting output\n")
+			}
+
+			if err == nil && tt.wantErr {
+				t.Errorf("Expecting error, got nil.\n")
+			}
+
+			for _, e := range out {
+				split := strings.Split(strings.ToLower(e), "\t")
+
+				if e != "" {
+					if !strings.Contains(split[3], strings.ToLower(tt.substr)) {
+						t.Errorf("Status without substring returned\n")
+					}
+				}
+			}
+		})
+	}
+
+}
+func Benchmark_Registry_QueryInStatus(b *testing.B) {
+	registry := initTestEnv()
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		for _, tt := range queryInStatusCases {
+			_, err := registry.QueryInStatus(tt.substr)
+			if err != nil {
+				continue
+			}
+		}
+	}
+}
+
+// Tests whether we can retrieve the 20 most
+// recent statuses in the registry
+func Test_QueryAllStatuses(t *testing.T) {
+	registry := initTestEnv()
+	t.Run("Latest Statuses", func(t *testing.T) {
+		out, err := registry.QueryAllStatuses()
+		if out == nil || err != nil {
+			t.Errorf("Got no statuses, or more than 20: %v, %v\n", len(out), err)
+		}
+	})
+}
+func Benchmark_QueryAllStatuses(b *testing.B) {
+	registry := initTestEnv()
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		_, err := registry.QueryAllStatuses()
+		if err != nil {
+			continue
+		}
+	}
+}
+
+var get20cases = []struct {
+	name    string
+	page    int
+	wantErr bool
+}{
+	{
+		name:    "First Page",
+		page:    1,
+		wantErr: false,
+	},
+	{
+		name:    "High Page Number",
+		page:    256,
+		wantErr: false,
+	},
+	{
+		name:    "Illegal Page Number",
+		page:    -23,
+		wantErr: false,
+	},
+}
+
+func Test_ReduceToPage(t *testing.T) {
+	registry := initTestEnv()
+	for _, tt := range get20cases {
+		t.Run(tt.name, func(t *testing.T) {
+			out, err := registry.QueryAllStatuses()
+			if err != nil && !tt.wantErr {
+				t.Errorf("%v\n", err.Error())
+			}
+			out = ReduceToPage(tt.page, out)
+			if len(out) > 20 || len(out) == 0 {
+				t.Errorf("Page-Reduce Malfunction: length of data %v\n", len(out))
+			}
+		})
+	}
+}
+
+func Benchmark_ReduceToPage(b *testing.B) {
+	registry := initTestEnv()
+	out, _ := registry.QueryAllStatuses()
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		for _, tt := range get20cases {
+			ReduceToPage(tt.page, out)
+		}
+	}
+}
+
+// This tests whether we can find a substring in the
+// given user's status messages, disregarding the metadata
+// stored with each status.
+func Test_User_FindInStatus(t *testing.T) {
+	registry := initTestEnv()
+	var buf = make([]byte, 256)
+	// read random data into case 8
+	rando, _ := os.Open("/dev/random")
+	reader := bufio.NewReader(rando)
+	n, err := reader.Read(buf)
+	if err != nil || n == 0 {
+		t.Errorf("Couldn't set up test: %v\n", err)
+	}
+	queryInStatusCases[7].substr = string(buf)
+
+	data := make([]*User, 0)
+
+	for _, v := range registry.Users {
+		data = append(data, v)
+	}
+
+	for _, tt := range queryInStatusCases {
+		t.Run(tt.name, func(t *testing.T) {
+			for _, e := range data {
+
+				tag := e.FindInStatus(tt.substr)
+				if tag == nil && !tt.wantNil {
+					t.Errorf("Got nil tag\n")
+				}
+			}
+		})
+	}
+
+}
+func Benchmark_User_FindInStatus(b *testing.B) {
+	registry := initTestEnv()
+	data := make([]*User, 0)
+
+	for _, v := range registry.Users {
+		data = append(data, v)
+	}
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		for _, tt := range data {
+			for _, v := range queryInStatusCases {
+				tt.FindInStatus(v.substr)
+			}
+		}
+	}
+}
+
+func Test_SortByTime_Slice(t *testing.T) {
+	registry := initTestEnv()
+
+	statusmap, err := registry.GetStatuses()
+	if err != nil {
+		t.Errorf("Failed to finish test initialization: %v\n", err)
+	}
+
+	t.Run("Sort By Time ([]TimeMap)", func(t *testing.T) {
+		sorted, err := SortByTime(statusmap)
+		if err != nil {
+			t.Errorf("%v\n", err)
+		}
+		split := strings.Split(sorted[0], "\t")
+		firsttime, _ := time.Parse("RFC3339", split[0])
+
+		for i := range sorted {
+			if i < len(sorted)-1 {
+
+				nextsplit := strings.Split(sorted[i+1], "\t")
+				nexttime, _ := time.Parse("RFC3339", nextsplit[0])
+
+				if firsttime.Before(nexttime) {
+					t.Errorf("Timestamps out of order: %v\n", sorted)
+				}
+
+				firsttime = nexttime
+			}
+		}
+	})
+}
+
+// Benchmarking a sort of 1000000 statuses by timestamp.
+// Right now it's at roughly 2000ns per 2 statuses.
+// Set sortMultiplier to be the number of desired
+// statuses divided by four.
+func Benchmark_SortByTime_Slice(b *testing.B) {
+	// I set this to 250,000,000 and it hard-locked
+	// my laptop. Oops.
+	sortMultiplier := 250
+	b.Logf("Benchmarking SortByTime with a constructed slice of %v statuses ...\n", sortMultiplier*4)
+	registry := initTestEnv()
+
+	statusmap, err := registry.GetStatuses()
+	if err != nil {
+		b.Errorf("Failed to finish benchmark initialization: %v\n", err)
+	}
+
+	// Constructed registry has four statuses. This
+	// makes a TimeMapSlice of 1000000 statuses.
+	statusmaps := make([]TimeMap, sortMultiplier*4)
+	for i := 0; i < sortMultiplier; i++ {
+		statusmaps = append(statusmaps, statusmap)
+	}
+
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		_, err := SortByTime(statusmaps...)
+		if err != nil {
+			b.Errorf("%v\n", err)
+		}
+	}
+}
+
+func Test_SortByTime_Single(t *testing.T) {
+	registry := initTestEnv()
+
+	statusmap, err := registry.GetStatuses()
+	if err != nil {
+		t.Errorf("Failed to finish test initialization: %v\n", err)
+	}
+
+	t.Run("Sort By Time (TimeMap)", func(t *testing.T) {
+		sorted, err := SortByTime(statusmap)
+		if err != nil {
+			t.Errorf("%v\n", err)
+		}
+		split := strings.Split(sorted[0], "\t")
+		firsttime, _ := time.Parse("RFC3339", split[0])
+
+		for i := range sorted {
+			if i < len(sorted)-1 {
+
+				nextsplit := strings.Split(sorted[i+1], "\t")
+				nexttime, _ := time.Parse("RFC3339", nextsplit[0])
+
+				if firsttime.Before(nexttime) {
+					t.Errorf("Timestamps out of order: %v\n", sorted)
+				}
+
+				firsttime = nexttime
+			}
+		}
+	})
+}
+
+func Benchmark_SortByTime_Single(b *testing.B) {
+	registry := initTestEnv()
+
+	statusmap, err := registry.GetStatuses()
+	if err != nil {
+		b.Errorf("Failed to finish benchmark initialization: %v\n", err)
+	}
+
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		_, err := SortByTime(statusmap)
+		if err != nil {
+			b.Errorf("%v\n", err)
+		}
+	}
+}
diff --git a/registry/revive.toml b/registry/revive.toml
new file mode 100644
index 0000000..f9e2405
--- /dev/null
+++ b/registry/revive.toml
@@ -0,0 +1,30 @@
+ignoreGeneratedHeader = false
+severity = "warning"
+confidence = 0.8
+errorCode = 0
+warningCode = 0
+
+[rule.blank-imports]
+[rule.context-as-argument]
+[rule.context-keys-type]
+[rule.dot-imports]
+[rule.error-return]
+[rule.error-strings]
+[rule.error-naming]
+[rule.exported]
+[rule.if-return]
+[rule.increment-decrement]
+[rule.var-naming]
+[rule.var-declaration]
+[rule.package-comments]
+[rule.range]
+[rule.receiver-naming]
+[rule.time-naming]
+[rule.unexported-return]
+[rule.indent-error-flow]
+[rule.errorf]
+[rule.empty-block]
+[rule.superfluous-else]
+[rule.unused-parameter]
+[rule.unreachable-code]
+[rule.redefines-builtin-id]
diff --git a/registry/types.go b/registry/types.go
new file mode 100644
index 0000000..eb8eee1
--- /dev/null
+++ b/registry/types.go
@@ -0,0 +1,148 @@
+/*
+Copyright (c) 2019 Ben Morrison (gbmor)
+
+This file is part of Registry.
+
+Registry 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.
+
+Registry 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 Registry.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+// Package registry implements functions and types that assist
+// in the creation and management of a twtxt registry.
+package registry // import "git.sr.ht/~gbmor/getwtxt/registry"
+
+import (
+	"net"
+	"net/http"
+	"sync"
+	"time"
+)
+
+// Registrar implements the minimum amount of methods
+// for a functioning Registry.
+type Registrar interface {
+	Put(user *User) error
+	Get(urlKey string) (*User, error)
+	DelUser(urlKey string) error
+	UpdateUser(urlKey string) error
+	GetUserStatuses(urlKey string) (TimeMap, error)
+	GetStatuses() (TimeMap, error)
+}
+
+// User holds a given user's information
+// and statuses.
+type User struct {
+	// Provided to aid in concurrency-safe
+	// reads and writes. In most cases, the
+	// mutex in the associated Index should be
+	// used instead. This mutex is provided
+	// should the library user need to access
+	// a User independently of an Index.
+	Mu sync.RWMutex
+
+	// Nick is the user-specified nickname.
+	Nick string
+
+	// The URL of the user's twtxt file
+	URL string
+
+	// The reported last modification date
+	// of the user's twtxt.txt file.
+	LastModified string
+
+	// The IP address of the user is optionally
+	// recorded when submitted via POST.
+	IP net.IP
+
+	// The timestamp, in RFC3339 format,
+	// reflecting when the user was added.
+	Date string
+
+	// A TimeMap of the user's statuses
+	// from their twtxt file.
+	Status TimeMap
+}
+
+// Registry enables the bulk of a registry's
+// user data storage and access.
+type Registry struct {
+	// Provided to aid in concurrency-safe
+	// reads and writes to a given registry
+	// Users map.
+	Mu sync.RWMutex
+
+	// The registry's user data is contained
+	// in this map. The functions within this
+	// library expect the key to be the URL of
+	// a given user's twtxt file.
+	Users map[string]*User
+
+	// The client to use for HTTP requests.
+	// If nil is passed to NewIndex(), a
+	// client with a 10 second timeout
+	// and all other values as default is
+	// used.
+	HTTPClient *http.Client
+}
+
+// TimeMap holds extracted and processed user data as a
+// string. A time.Time value is used as the key.
+type TimeMap map[time.Time]string
+
+// TimeSlice is a slice of time.Time used for sorting
+// a TimeMap by timestamp.
+type TimeSlice []time.Time
+
+// NewUser returns a pointer to an initialized User
+func NewUser() *User {
+	return &User{
+		Mu:     sync.RWMutex{},
+		Status: NewTimeMap(),
+	}
+}
+
+// New returns an initialized Registry instance.
+func New(client *http.Client) *Registry {
+	return &Registry{
+		Mu:         sync.RWMutex{},
+		Users:      make(map[string]*User),
+		HTTPClient: client,
+	}
+}
+
+// NewTimeMap returns an initialized TimeMap.
+func NewTimeMap() TimeMap {
+	return make(TimeMap)
+}
+
+// Len returns the length of the TimeSlice to be sorted.
+// This helps satisfy sort.Interface.
+func (t TimeSlice) Len() int {
+	return len(t)
+}
+
+// Less returns true if the timestamp at index i is after
+// the timestamp at index j in a given TimeSlice. This results
+// in a descending (reversed) sort order for timestamps rather
+// than ascending.
+// This helps satisfy sort.Interface.
+func (t TimeSlice) Less(i, j int) bool {
+	return t[i].After(t[j])
+}
+
+// Swap transposes the timestamps at the two given indices
+// for the TimeSlice receiver.
+// This helps satisfy sort.Interface.
+func (t TimeSlice) Swap(i, j int) {
+	t[i], t[j] = t[j], t[i]
+}
diff --git a/registry/user.go b/registry/user.go
new file mode 100644
index 0000000..329b6e3
--- /dev/null
+++ b/registry/user.go
@@ -0,0 +1,270 @@
+/*
+Copyright (c) 2019 Ben Morrison (gbmor)
+
+This file is part of Registry.
+
+Registry 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.
+
+Registry 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 Registry.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+package registry // import "git.sr.ht/~gbmor/getwtxt/registry"
+
+import (
+	"fmt"
+	"net"
+	"strings"
+	"sync"
+	"time"
+)
+
+// AddUser inserts a new user into the Registry.
+func (registry *Registry) AddUser(nickname, urlKey string, ipAddress net.IP, statuses TimeMap) error {
+
+	if registry == nil {
+		return fmt.Errorf("can't add user to uninitialized registry")
+
+	} else if nickname == "" || urlKey == "" {
+		return fmt.Errorf("both URL and Nick must be specified")
+
+	} else if !strings.HasPrefix(urlKey, "http") {
+		return fmt.Errorf("invalid URL: %v", urlKey)
+	}
+
+	registry.Mu.Lock()
+	defer registry.Mu.Unlock()
+
+	if _, ok := registry.Users[urlKey]; ok {
+		return fmt.Errorf("user %v already exists", urlKey)
+	}
+
+	registry.Users[urlKey] = &User{
+		Mu:           sync.RWMutex{},
+		Nick:         nickname,
+		URL:          urlKey,
+		LastModified: "",
+		IP:           ipAddress,
+		Date:         time.Now().Format(time.RFC3339),
+		Status:       statuses}
+
+	return nil
+}
+
+// Put inserts a given User into an Registry. The User
+// being pushed need only have the URL field filled.
+// All other fields may be empty.
+// This can be destructive: an existing User in the
+// Registry will be overwritten if its User.URL is the
+// same as the User.URL being pushed.
+func (registry *Registry) Put(user *User) error {
+	if user == nil {
+		return fmt.Errorf("can't push nil data to registry")
+	}
+	if registry == nil || registry.Users == nil {
+		return fmt.Errorf("can't push data to registry: registry uninitialized")
+	}
+	user.Mu.RLock()
+	if user.URL == "" {
+		user.Mu.RUnlock()
+		return fmt.Errorf("can't push data to registry: missing URL for key")
+	}
+	urlKey := user.URL
+	registry.Mu.Lock()
+	registry.Users[urlKey] = user
+	registry.Mu.Unlock()
+	user.Mu.RUnlock()
+
+	return nil
+}
+
+// Get returns the User associated with the
+// provided URL key in the Registry.
+func (registry *Registry) Get(urlKey string) (*User, error) {
+	if registry == nil {
+		return nil, fmt.Errorf("can't pop from nil registry")
+	}
+	if urlKey == "" {
+		return nil, fmt.Errorf("can't pop unless provided a key")
+	}
+
+	registry.Mu.RLock()
+	defer registry.Mu.RUnlock()
+
+	if _, ok := registry.Users[urlKey]; !ok {
+		return nil, fmt.Errorf("provided url key doesn't exist in registry")
+	}
+
+	registry.Users[urlKey].Mu.RLock()
+	userGot := registry.Users[urlKey]
+	registry.Users[urlKey].Mu.RUnlock()
+
+	return userGot, nil
+}
+
+// DelUser removes a user and all associated data from
+// the Registry.
+func (registry *Registry) DelUser(urlKey string) error {
+
+	if registry == nil {
+		return fmt.Errorf("can't delete user from empty registry")
+
+	} else if urlKey == "" {
+		return fmt.Errorf("can't delete blank user")
+
+	} else if !strings.HasPrefix(urlKey, "http") {
+		return fmt.Errorf("invalid URL: %v", urlKey)
+	}
+
+	registry.Mu.Lock()
+	defer registry.Mu.Unlock()
+
+	if _, ok := registry.Users[urlKey]; !ok {
+		return fmt.Errorf("can't delete user %v, user doesn't exist", urlKey)
+	}
+
+	delete(registry.Users, urlKey)
+
+	return nil
+}
+
+// UpdateUser scrapes an existing user's remote twtxt.txt
+// file. Any new statuses are added to the user's entry
+// in the Registry. If the remote twtxt data's reported
+// Content-Length does not differ from what is stored,
+// an error is returned.
+func (registry *Registry) UpdateUser(urlKey string) error {
+	if urlKey == "" || !strings.HasPrefix(urlKey, "http") {
+		return fmt.Errorf("invalid URL: %v", urlKey)
+	}
+
+	diff, err := registry.DiffTwtxt(urlKey)
+	if err != nil {
+		return err
+	} else if !diff {
+		return fmt.Errorf("no new statuses available for %v", urlKey)
+	}
+
+	out, isRemoteRegistry, err := GetTwtxt(urlKey, registry.HTTPClient)
+	if err != nil {
+		return err
+	}
+
+	if isRemoteRegistry {
+		return fmt.Errorf("attempting to update registry URL - users should be updated individually")
+	}
+
+	registry.Mu.Lock()
+	defer registry.Mu.Unlock()
+	user := registry.Users[urlKey]
+
+	user.Mu.Lock()
+	defer user.Mu.Unlock()
+	nick := user.Nick
+
+	data, err := ParseUserTwtxt(out, nick, urlKey)
+	if err != nil {
+		return err
+	}
+
+	for i, e := range data {
+		user.Status[i] = e
+	}
+
+	registry.Users[urlKey] = user
+
+	return nil
+}
+
+// CrawlRemoteRegistry scrapes all nicknames and user URLs
+// from a provided registry. The urlKey passed to this function
+// must be in the form of https://registry.example.com/api/plain/users
+func (registry *Registry) CrawlRemoteRegistry(urlKey string) error {
+	if urlKey == "" || !strings.HasPrefix(urlKey, "http") {
+		return fmt.Errorf("invalid URL: %v", urlKey)
+	}
+
+	out, isRemoteRegistry, err := GetTwtxt(urlKey, registry.HTTPClient)
+	if err != nil {
+		return err
+	}
+
+	if !isRemoteRegistry {
+		return fmt.Errorf("can't add single user via call to CrawlRemoteRegistry")
+	}
+
+	users, err := ParseRegistryTwtxt(out)
+	if err != nil {
+		return err
+	}
+
+	// only add new users so we don't overwrite data
+	// we already have (and lose statuses, etc)
+	registry.Mu.Lock()
+	defer registry.Mu.Unlock()
+	for _, e := range users {
+		if _, ok := registry.Users[e.URL]; !ok {
+			registry.Users[e.URL] = e
+		}
+	}
+
+	return nil
+}
+
+// GetUserStatuses returns a TimeMap containing single user's statuses
+func (registry *Registry) GetUserStatuses(urlKey string) (TimeMap, error) {
+	if registry == nil {
+		return nil, fmt.Errorf("can't get statuses from an empty registry")
+	} else if urlKey == "" || !strings.HasPrefix(urlKey, "http") {
+		return nil, fmt.Errorf("invalid URL: %v", urlKey)
+	}
+
+	registry.Mu.RLock()
+	defer registry.Mu.RUnlock()
+	if _, ok := registry.Users[urlKey]; !ok {
+		return nil, fmt.Errorf("can't retrieve statuses of nonexistent user")
+	}
+
+	registry.Users[urlKey].Mu.RLock()
+	status := registry.Users[urlKey].Status
+	registry.Users[urlKey].Mu.RUnlock()
+
+	return status, nil
+}
+
+// GetStatuses returns a TimeMap containing all statuses
+// from all users in the Registry.
+func (registry *Registry) GetStatuses() (TimeMap, error) {
+	if registry == nil {
+		return nil, fmt.Errorf("can't get statuses from an empty registry")
+	}
+
+	statuses := NewTimeMap()
+
+	registry.Mu.RLock()
+	defer registry.Mu.RUnlock()
+
+	for _, v := range registry.Users {
+		v.Mu.RLock()
+		if v.Status == nil || len(v.Status) == 0 {
+			v.Mu.RUnlock()
+			continue
+		}
+		for a, b := range v.Status {
+			if _, ok := v.Status[a]; ok {
+				statuses[a] = b
+			}
+		}
+		v.Mu.RUnlock()
+	}
+
+	return statuses, nil
+}
diff --git a/registry/user_test.go b/registry/user_test.go
new file mode 100644
index 0000000..f0c9622
--- /dev/null
+++ b/registry/user_test.go
@@ -0,0 +1,349 @@
+/*
+Copyright (c) 2019 Ben Morrison (gbmor)
+
+This file is part of Registry.
+
+Registry 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.
+
+Registry 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 Registry.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+package registry // import "git.sr.ht/~gbmor/getwtxt/registry"
+
+import (
+	"bufio"
+	"fmt"
+	"net/http"
+	"os"
+	"reflect"
+	"testing"
+)
+
+var addUserCases = []struct {
+	name      string
+	nick      string
+	url       string
+	wantErr   bool
+	localOnly bool
+}{
+	{
+		name:      "Legitimate User (Local Only)",
+		nick:      "testuser1",
+		url:       "http://localhost:8080/twtxt.txt",
+		wantErr:   false,
+		localOnly: true,
+	},
+	{
+		name:      "Empty Query",
+		nick:      "",
+		url:       "",
+		wantErr:   true,
+		localOnly: false,
+	},
+	{
+		name:      "Invalid URL",
+		nick:      "foo",
+		url:       "foobarringtons",
+		wantErr:   true,
+		localOnly: false,
+	},
+	{
+		name:      "Garbage Data",
+		nick:      "",
+		url:       "",
+		wantErr:   true,
+		localOnly: false,
+	},
+}
+
+// Tests if we can successfully add a user to the registry
+func Test_Registry_AddUser(t *testing.T) {
+	registry := initTestEnv()
+	if !addUserCases[0].localOnly {
+		http.Handle("/twtxt.txt", http.HandlerFunc(twtxtHandler))
+		go fmt.Println(http.ListenAndServe(":8080", nil))
+	}
+	var buf = make([]byte, 256)
+	// read random data into case 5
+	rando, _ := os.Open("/dev/random")
+	reader := bufio.NewReader(rando)
+	n, err := reader.Read(buf)
+	if err != nil || n == 0 {
+		t.Errorf("Couldn't set up test: %v\n", err)
+	}
+	addUserCases[3].nick = string(buf)
+	addUserCases[3].url = string(buf)
+
+	statuses, err := registry.GetStatuses()
+	if err != nil {
+		t.Errorf("Error setting up test: %v\n", err)
+	}
+
+	for n, tt := range addUserCases {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.localOnly {
+				t.Skipf("Local-only test. Skipping ... ")
+			}
+
+			err := registry.AddUser(tt.nick, tt.url, nil, statuses)
+
+			// only run some checks if we don't want an error
+			if !tt.wantErr {
+				if err != nil {
+					t.Errorf("Got error: %v\n", err)
+				}
+
+				// make sure we have *something* in the registry
+				if reflect.ValueOf(registry.Users[tt.url]).IsNil() {
+					t.Errorf("Failed to add user %v registry.\n", tt.url)
+				}
+
+				// see if the nick in the registry is the same
+				// as the test case. verifies the URL and the nick
+				// since the URL is used as the key
+				data := registry.Users[tt.url]
+				if data.Nick != tt.nick {
+					t.Errorf("Incorrect user data added to registry for user %v.\n", tt.url)
+				}
+			}
+			// check for the cases that should throw an error
+			if tt.wantErr && err == nil {
+				t.Errorf("Expected error for case %v, got nil\n", n)
+			}
+		})
+	}
+}
+func Benchmark_Registry_AddUser(b *testing.B) {
+	registry := initTestEnv()
+	statuses, err := registry.GetStatuses()
+	if err != nil {
+		b.Errorf("Error setting up test: %v\n", err)
+	}
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		for _, tt := range addUserCases {
+			err := registry.AddUser(tt.nick, tt.url, nil, statuses)
+			if err != nil {
+				continue
+			}
+			registry.Users[tt.url] = &User{}
+		}
+	}
+}
+
+var delUserCases = []struct {
+	name    string
+	url     string
+	wantErr bool
+}{
+	{
+		name:    "Valid User",
+		url:     "https://example.com/twtxt.txt",
+		wantErr: false,
+	},
+	{
+		name:    "Valid User",
+		url:     "https://example3.com/twtxt.txt",
+		wantErr: false,
+	},
+	{
+		name:    "Already Deleted User",
+		url:     "https://example3.com/twtxt.txt",
+		wantErr: true,
+	},
+	{
+		name:    "Empty Query",
+		url:     "",
+		wantErr: true,
+	},
+	{
+		name:    "Garbage Data",
+		url:     "",
+		wantErr: true,
+	},
+}
+
+// Tests if we can successfully delete a user from the registry
+func Test_Registry_DelUser(t *testing.T) {
+	registry := initTestEnv()
+	var buf = make([]byte, 256)
+	// read random data into case 5
+	rando, _ := os.Open("/dev/random")
+	reader := bufio.NewReader(rando)
+	n, err := reader.Read(buf)
+	if err != nil || n == 0 {
+		t.Errorf("Couldn't set up test: %v\n", err)
+	}
+	delUserCases[4].url = string(buf)
+
+	for n, tt := range delUserCases {
+		t.Run(tt.name, func(t *testing.T) {
+
+			err := registry.DelUser(tt.url)
+			if !reflect.ValueOf(registry.Users[tt.url]).IsNil() {
+				t.Errorf("Failed to delete user %v from registry.\n", tt.url)
+			}
+			if tt.wantErr && err == nil {
+				t.Errorf("Expected error but did not receive. Case %v\n", n)
+			}
+			if !tt.wantErr && err != nil {
+				t.Errorf("Unexpected error for case %v: %v\n", n, err)
+			}
+		})
+	}
+}
+func Benchmark_Registry_DelUser(b *testing.B) {
+	registry := initTestEnv()
+
+	data1 := &User{
+		Nick:   registry.Users[delUserCases[0].url].Nick,
+		Date:   registry.Users[delUserCases[0].url].Date,
+		Status: registry.Users[delUserCases[0].url].Status,
+	}
+
+	data2 := &User{
+		Nick:   registry.Users[delUserCases[1].url].Nick,
+		Date:   registry.Users[delUserCases[1].url].Date,
+		Status: registry.Users[delUserCases[1].url].Status,
+	}
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		for _, tt := range delUserCases {
+			err := registry.DelUser(tt.url)
+			if err != nil {
+				continue
+			}
+		}
+
+		registry.Users[delUserCases[0].url] = data1
+		registry.Users[delUserCases[1].url] = data2
+	}
+}
+
+var getUserStatusCases = []struct {
+	name    string
+	url     string
+	wantErr bool
+}{
+	{
+		name:    "Valid User",
+		url:     "https://example.com/twtxt.txt",
+		wantErr: false,
+	},
+	{
+		name:    "Valid User",
+		url:     "https://example3.com/twtxt.txt",
+		wantErr: false,
+	},
+	{
+		name:    "Nonexistent User",
+		url:     "https://doesn't.exist/twtxt.txt",
+		wantErr: true,
+	},
+	{
+		name:    "Empty Query",
+		url:     "",
+		wantErr: true,
+	},
+	{
+		name:    "Garbage Data",
+		url:     "",
+		wantErr: true,
+	},
+}
+
+// Checks if we can retrieve a single user's statuses
+func Test_Registry_GetUserStatuses(t *testing.T) {
+	registry := initTestEnv()
+	var buf = make([]byte, 256)
+	// read random data into case 5
+	rando, _ := os.Open("/dev/random")
+	reader := bufio.NewReader(rando)
+	n, err := reader.Read(buf)
+	if err != nil || n == 0 {
+		t.Errorf("Couldn't set up test: %v\n", err)
+	}
+	getUserStatusCases[4].url = string(buf)
+
+	for n, tt := range getUserStatusCases {
+		t.Run(tt.name, func(t *testing.T) {
+
+			statuses, err := registry.GetUserStatuses(tt.url)
+
+			if !tt.wantErr {
+				if reflect.ValueOf(statuses).IsNil() {
+					t.Errorf("Failed to pull statuses for user %v\n", tt.url)
+				}
+				// see if the function returns the same data
+				// that we already have
+				data := registry.Users[tt.url]
+				if !reflect.DeepEqual(data.Status, statuses) {
+					t.Errorf("Incorrect data retrieved as statuses for user %v.\n", tt.url)
+				}
+			}
+
+			if tt.wantErr && err == nil {
+				t.Errorf("Expected error, received nil for case %v: %v\n", n, tt.url)
+			}
+		})
+	}
+}
+func Benchmark_Registry_GetUserStatuses(b *testing.B) {
+	registry := initTestEnv()
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		for _, tt := range getUserStatusCases {
+			_, err := registry.GetUserStatuses(tt.url)
+			if err != nil {
+				continue
+			}
+		}
+	}
+}
+
+// Tests if we can retrieve all user statuses at once
+func Test_Registry_GetStatuses(t *testing.T) {
+	registry := initTestEnv()
+	t.Run("Registry.GetStatuses()", func(t *testing.T) {
+
+		statuses, err := registry.GetStatuses()
+		if reflect.ValueOf(statuses).IsNil() || err != nil {
+			t.Errorf("Failed to pull all statuses. %v\n", err)
+		}
+
+		// Now do the same query manually to see
+		// if we get the same result
+		unionmap := NewTimeMap()
+		for _, v := range registry.Users {
+			for i, e := range v.Status {
+				unionmap[i] = e
+			}
+		}
+		if !reflect.DeepEqual(statuses, unionmap) {
+			t.Errorf("Incorrect data retrieved as statuses.\n")
+		}
+	})
+}
+func Benchmark_Registry_GetStatuses(b *testing.B) {
+	registry := initTestEnv()
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		_, err := registry.GetStatuses()
+		if err != nil {
+			continue
+		}
+	}
+}