Skip to content

Commit aa7bf1a

Browse files
timtailscalewillnorris
authored andcommitted
add owner based link search
add search endpoint with support for owner filtering, the all page now uses the search template with the expectation that all becomes a "search" once search is fully implemented updates #98 Signed-off-by: Tim Walters <tim@tailscale.com>
1 parent e6795d1 commit aa7bf1a

6 files changed

Lines changed: 168 additions & 3 deletions

File tree

db.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,27 @@ func (s *SQLiteDB) DeleteStats(short string) error {
225225
}
226226
return nil
227227
}
228+
229+
// GetLinksByOwner returns all Links owned by the specified owner.
230+
func (s *SQLiteDB) GetLinksByOwner(owner string) ([]*Link, error) {
231+
s.mu.RLock()
232+
defer s.mu.RUnlock()
233+
234+
var links []*Link
235+
rows, err := s.db.Query("SELECT Short, Long, Created, LastEdit, Owner FROM Links WHERE LOWER(Owner) = LOWER(?)", owner)
236+
if err != nil {
237+
return nil, err
238+
}
239+
for rows.Next() {
240+
link := new(Link)
241+
var created, lastEdit int64
242+
err := rows.Scan(&link.Short, &link.Long, &created, &lastEdit, &link.Owner)
243+
if err != nil {
244+
return nil, err
245+
}
246+
link.Created = time.Unix(created, 0).UTC()
247+
link.LastEdit = time.Unix(lastEdit, 0).UTC()
248+
links = append(links, link)
249+
}
250+
return links, rows.Err()
251+
}

db_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,43 @@ func Test_SQLiteDB_SaveLoadDeleteStats(t *testing.T) {
125125
t.Errorf("db.LoadStats got %v, want %v", got, want)
126126
}
127127
}
128+
129+
// Test GetLinksByOwner functionality
130+
func Test_SQLiteDB_GetLinksByOwner(t *testing.T) {
131+
db, err := NewSQLiteDB(path.Join(t.TempDir(), "links.db"))
132+
if err != nil {
133+
t.Error(err)
134+
}
135+
136+
// preload some links with owner
137+
links := []*Link{
138+
{Short: "a", Owner: "foo@bar.com"},
139+
{Short: "B-c", Owner: "bar@foo.com "},
140+
}
141+
for _, link := range links {
142+
if err := db.Save(link); err != nil {
143+
t.Error(err)
144+
}
145+
}
146+
147+
want := []*Link{
148+
{Short: "a", Owner: "foo@bar.com"},
149+
}
150+
got, err := db.GetLinksByOwner("foo@bar.com")
151+
if err != nil {
152+
t.Error(err)
153+
}
154+
155+
if !cmp.Equal(got, want) {
156+
t.Errorf("db.GetLinksByOwner got %v; want %v", got, want)
157+
}
158+
159+
// confirm empty response for non-existant owner
160+
got, err = db.GetLinksByOwner("foo1@bar.com")
161+
if err != nil {
162+
t.Error(err)
163+
}
164+
if len(got) != 0 {
165+
t.Errorf("db.GetLinksByOwner got %v; want empty slice", got)
166+
}
167+
}

golink.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,9 @@ var (
291291

292292
// opensearchTmpl is the template used by the http://go/.opensearch page
293293
opensearchTmpl *template.Template
294+
295+
// searchTmpl is the template used by the http://go/.search page
296+
searchTmpl *template.Template
294297
)
295298

296299
type visitData struct {
@@ -305,6 +308,7 @@ type homeData struct {
305308
Clicks []visitData
306309
XSRF string
307310
ReadOnly bool
311+
User string
308312
}
309313

310314
// deleteData is the data used by deleteTmpl.
@@ -321,9 +325,9 @@ func init() {
321325
detailTmpl = newTemplate("base.html", "detail.html")
322326
successTmpl = newTemplate("base.html", "success.html")
323327
helpTmpl = newTemplate("base.html", "help.html")
324-
allTmpl = newTemplate("base.html", "all.html")
325328
deleteTmpl = newTemplate("base.html", "delete.html")
326329
opensearchTmpl = newTemplate("opensearch.xml")
330+
searchTmpl = newTemplate("base.html", "search.html")
327331

328332
b := make([]byte, 24)
329333
rand.Read(b)
@@ -477,6 +481,7 @@ func serveHandler() http.Handler {
477481
mux.HandleFunc("/.opensearch", serveOpenSearch)
478482
mux.HandleFunc("/.all", serveAll)
479483
mux.HandleFunc("/.delete/", serveDelete)
484+
mux.HandleFunc("/.search", serveSearch)
480485
mux.Handle("/.metrics", promhttp.Handler())
481486
mux.Handle("/.static/", http.StripPrefix("/.", http.FileServer(http.FS(embeddedFS))))
482487

@@ -539,6 +544,7 @@ func serveHome(w http.ResponseWriter, r *http.Request, short string) {
539544
Clicks: clicks,
540545
XSRF: xsrftoken.Generate(xsrfKey, cu.login, newShortName),
541546
ReadOnly: *readonly,
547+
User: cu.login,
542548
})
543549
}
544550

@@ -557,7 +563,7 @@ func serveAll(w http.ResponseWriter, _ *http.Request) {
557563
return links[i].Short < links[j].Short
558564
})
559565

560-
allTmpl.Execute(w, links)
566+
searchTmpl.Execute(w, links)
561567
}
562568

563569
func serveHelp(w http.ResponseWriter, _ *http.Request) {
@@ -706,6 +712,27 @@ func serveDetail(w http.ResponseWriter, r *http.Request) {
706712
detailTmpl.Execute(w, data)
707713
}
708714

715+
// serveSearch handles requests to /.search?q={query}, where {query} can currently only be
716+
// the owner formated like "owner:<email>".
717+
func serveSearch(w http.ResponseWriter, r *http.Request) {
718+
query := r.URL.Query().Get("q")
719+
owner, found := strings.CutPrefix(query, "owner:")
720+
if !found {
721+
http.Error(w, `search only supports "owner:<email>"`, http.StatusBadRequest)
722+
return
723+
}
724+
links, err := db.GetLinksByOwner(owner)
725+
if err != nil {
726+
http.Error(w, err.Error(), http.StatusInternalServerError)
727+
return
728+
}
729+
730+
sort.Slice(links, func(i, j int) bool {
731+
return links[i].Short < links[j].Short
732+
})
733+
searchTmpl.Execute(w, links)
734+
}
735+
709736
type expandEnv struct {
710737
Now time.Time
711738

golink_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,3 +754,76 @@ func TestHTTPSRedirectHandlerWithQuery(t *testing.T) {
754754
t.Errorf("got %q; want %q", w.Header().Get("Location"), "https://foobar.com/?query=bar")
755755
}
756756
}
757+
758+
func TestServeSearch(t *testing.T) {
759+
var err error
760+
db, err = NewSQLiteDB(":memory:")
761+
if err != nil {
762+
t.Fatal(err)
763+
}
764+
links := []*Link{
765+
{Short: "alpha", Long: "http://alpha/", Owner: "foo@example.com"},
766+
{Short: "beta", Long: "http://beta/", Owner: "foo@example.com"},
767+
{Short: "gamma", Long: "http://gamma/", Owner: "bar@example.com"},
768+
{Short: "delta", Long: "http://delta/", Owner: "FOO@example.com"},
769+
}
770+
for _, link := range links {
771+
if err := db.Save(link); err != nil {
772+
t.Error(err)
773+
}
774+
}
775+
776+
tests := []struct {
777+
name string
778+
owner string
779+
wantStatus int
780+
wantContains []string // substrings that should appear in response body
781+
wantNotContains []string // substrings that should NOT appear in response body
782+
}{
783+
{
784+
name: "search by owner with multiple links",
785+
owner: "foo@example.com",
786+
wantStatus: http.StatusOK,
787+
wantContains: []string{"alpha", "beta", "delta", "3 total"},
788+
wantNotContains: []string{"gamma"},
789+
},
790+
{
791+
name: "search by owner case insensitive",
792+
owner: "FOO@EXAMPLE.COM",
793+
wantStatus: http.StatusOK,
794+
wantContains: []string{"alpha", "beta", "delta"},
795+
},
796+
{
797+
name: "search by owner with single link",
798+
owner: "bar@example.com",
799+
wantStatus: http.StatusOK,
800+
wantContains: []string{"gamma", "1 total"},
801+
wantNotContains: []string{"alpha", "beta"},
802+
},
803+
}
804+
805+
for _, tt := range tests {
806+
t.Run(tt.name, func(t *testing.T) {
807+
testURL := "/.search?q=owner:" + url.QueryEscape(tt.owner)
808+
r := httptest.NewRequest("GET", testURL, nil)
809+
w := httptest.NewRecorder()
810+
serveHandler().ServeHTTP(w, r)
811+
812+
if w.Code != tt.wantStatus {
813+
t.Errorf("serveSearch(owner=%q) = %d; want %d", tt.owner, w.Code, tt.wantStatus)
814+
}
815+
816+
body := w.Body.String()
817+
for _, s := range tt.wantContains {
818+
if !strings.Contains(body, s) {
819+
t.Errorf("serveSearch(owner=%q) body missing %q", tt.owner, s)
820+
}
821+
}
822+
for _, s := range tt.wantNotContains {
823+
if strings.Contains(body, s) {
824+
t.Errorf("serveSearch(owner=%q) body unexpectedly contains %q", tt.owner, s)
825+
}
826+
}
827+
})
828+
}
829+
}

tmpl/home.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,6 @@ <h2 class="text-xl font-bold pt-6 pb-2">Popular Links</h2>
4343
{{end}}
4444
</tbody>
4545
</table>
46+
<p class="my-2 text-sm"><a class="text-blue-600 hover:underline" href="/.search?q=owner:{{.User}}">See my links.</a></p>
4647
<p class="my-2 text-sm"><a class="text-blue-600 hover:underline" href="/.all">See all links.</a></p>
4748
{{ end }}

tmpl/all.html renamed to tmpl/search.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{{ define "main" }}
2-
<h2 class="text-xl font-bold pt-6 pb-2">All Links ({{ len . }} total)</h2>
2+
<h2 class="text-xl font-bold pt-6 pb-2">Results ({{ len . }} total)</h2>
33
<table class="table-auto w-full max-w-screen-lg">
44
<thead class="border-b border-gray-200 uppercase text-xs text-gray-500 text-left">
55
<tr class="flex">

0 commit comments

Comments
 (0)