Skip to content

Commit c7ac33d

Browse files
golink: add support for deleting links
To delete a link, go to its page in `.detail` and click on the "Delete" button. Stats for the deleted link are removed as well. Co-authored-by: Will Norris <will@tailscale.com> Signed-off-by: Gabriel Wong <gabriel@bifrost.ai> Signed-off-by: Will Norris <will@tailscale.com>
1 parent 132a396 commit c7ac33d

File tree

8 files changed

+280
-6
lines changed

8 files changed

+280
-6
lines changed

db.go

+31
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,25 @@ func (s *SQLiteDB) Save(link *Link) error {
133133
return nil
134134
}
135135

136+
// Delete removes a Link using its short name.
137+
func (s *SQLiteDB) Delete(short string) error {
138+
s.mu.Lock()
139+
defer s.mu.Unlock()
140+
141+
result, err := s.db.Exec("DELETE FROM Links WHERE ID = ?", linkID(short))
142+
if err != nil {
143+
return err
144+
}
145+
rows, err := result.RowsAffected()
146+
if err != nil {
147+
return err
148+
}
149+
if rows != 1 {
150+
return fmt.Errorf("expected to affect 1 row, affected %d", rows)
151+
}
152+
return nil
153+
}
154+
136155
// LoadStats returns click stats for links.
137156
func (s *SQLiteDB) LoadStats() (ClickStats, error) {
138157
allLinks, err := s.LoadAll()
@@ -186,3 +205,15 @@ func (s *SQLiteDB) SaveStats(stats ClickStats) error {
186205
}
187206
return tx.Commit()
188207
}
208+
209+
// DeleteStats deletes click stats for a link.
210+
func (s *SQLiteDB) DeleteStats(short string) error {
211+
s.mu.Lock()
212+
defer s.mu.Unlock()
213+
214+
_, err := s.db.Exec("DELETE FROM Stats WHERE ID = ?", linkID(short))
215+
if err != nil {
216+
return err
217+
}
218+
return nil
219+
}

db_test.go

+34-4
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import (
1111
"github.com/google/go-cmp/cmp/cmpopts"
1212
)
1313

14-
// Test saving and loading links for SQLiteDB
15-
func Test_SQLiteDB_SaveLoadLinks(t *testing.T) {
14+
// Test saving, loading, and deleting links for SQLiteDB.
15+
func Test_SQLiteDB_SaveLoadDeleteLinks(t *testing.T) {
1616
db, err := NewSQLiteDB(path.Join(t.TempDir(), "links.db"))
1717
if err != nil {
1818
t.Error(err)
@@ -48,10 +48,25 @@ func Test_SQLiteDB_SaveLoadLinks(t *testing.T) {
4848
if !cmp.Equal(got, links, sortLinks) {
4949
t.Errorf("db.LoadAll got %v, want %v", got, links)
5050
}
51+
52+
for _, link := range links {
53+
if err := db.Delete(link.Short); err != nil {
54+
t.Error(err)
55+
}
56+
}
57+
58+
got, err = db.LoadAll()
59+
if err != nil {
60+
t.Error(err)
61+
}
62+
want := []*Link(nil)
63+
if !cmp.Equal(got, want) {
64+
t.Errorf("db.LoadAll got %v, want %v", got, want)
65+
}
5166
}
5267

53-
// Test saving and loading stats for SQLiteDB
54-
func Test_SQLiteDB_SaveLoadStats(t *testing.T) {
68+
// Test saving, loading, and deleting stats for SQLiteDB.
69+
func Test_SQLiteDB_SaveLoadDeleteStats(t *testing.T) {
5570
db, err := NewSQLiteDB(path.Join(t.TempDir(), "links.db"))
5671
if err != nil {
5772
t.Error(err)
@@ -94,4 +109,19 @@ func Test_SQLiteDB_SaveLoadStats(t *testing.T) {
94109
if !cmp.Equal(got, want) {
95110
t.Errorf("db.LoadStats got %v, want %v", got, want)
96111
}
112+
113+
for k := range want {
114+
if err := db.DeleteStats(k); err != nil {
115+
t.Error(err)
116+
}
117+
}
118+
119+
got, err = db.LoadStats()
120+
if err != nil {
121+
t.Error(err)
122+
}
123+
want = ClickStats{}
124+
if !cmp.Equal(got, want) {
125+
t.Errorf("db.LoadStats got %v, want %v", got, want)
126+
}
97127
}

golink.go

+64
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
"bufio"
99
"bytes"
1010
"context"
11+
"crypto/rand"
1112
"embed"
13+
"encoding/base64"
1214
"encoding/json"
1315
"errors"
1416
"flag"
@@ -29,6 +31,7 @@ import (
2931
texttemplate "text/template"
3032
"time"
3133

34+
"golang.org/x/net/xsrftoken"
3235
"tailscale.com/client/tailscale"
3336
"tailscale.com/hostinfo"
3437
"tailscale.com/ipn"
@@ -137,6 +140,7 @@ func Run() error {
137140
http.HandleFunc("/.help", serveHelp)
138141
http.HandleFunc("/.opensearch", serveOpenSearch)
139142
http.HandleFunc("/.all", serveAll)
143+
http.HandleFunc("/.delete/", serveDelete)
140144
http.Handle("/.static/", http.StripPrefix("/.", http.FileServer(http.FS(embeddedFS))))
141145

142146
if *dev != "" {
@@ -200,6 +204,9 @@ var (
200204
// allTmpl is the template used by the http://go/.all page
201205
allTmpl *template.Template
202206

207+
// deleteTmpl is the template used after a link has been deleted.
208+
deleteTmpl *template.Template
209+
203210
// opensearchTmpl is the template used by the http://go/.opensearch page
204211
opensearchTmpl *template.Template
205212
)
@@ -215,13 +222,20 @@ type homeData struct {
215222
Clicks []visitData
216223
}
217224

225+
var xsrfKey string
226+
218227
func init() {
219228
homeTmpl = template.Must(template.ParseFS(embeddedFS, "tmpl/base.html", "tmpl/home.html"))
220229
detailTmpl = template.Must(template.ParseFS(embeddedFS, "tmpl/base.html", "tmpl/detail.html"))
221230
successTmpl = template.Must(template.ParseFS(embeddedFS, "tmpl/base.html", "tmpl/success.html"))
222231
helpTmpl = template.Must(template.ParseFS(embeddedFS, "tmpl/base.html", "tmpl/help.html"))
223232
allTmpl = template.Must(template.ParseFS(embeddedFS, "tmpl/base.html", "tmpl/all.html"))
233+
deleteTmpl = template.Must(template.ParseFS(embeddedFS, "tmpl/base.html", "tmpl/delete.html"))
224234
opensearchTmpl = template.Must(template.ParseFS(embeddedFS, "tmpl/opensearch.xml"))
235+
236+
b := make([]byte, 24)
237+
rand.Read(b)
238+
xsrfKey = base64.StdEncoding.EncodeToString(b)
225239
}
226240

227241
// initStats initializes the in-memory stats counter with counts from db.
@@ -266,6 +280,16 @@ func flushStatsLoop() {
266280
}
267281
}
268282

283+
// deleteLinkStats removes the link stats from memory.
284+
func deleteLinkStats(link *Link) {
285+
stats.mu.Lock()
286+
delete(stats.clicks, link.Short)
287+
delete(stats.dirty, link.Short)
288+
stats.mu.Unlock()
289+
290+
db.DeleteStats(link.Short)
291+
}
292+
269293
func serveHome(w http.ResponseWriter, short string) {
270294
var clicks []visitData
271295

@@ -388,6 +412,7 @@ type detailData struct {
388412
// Editable indicates whether the current user can edit the link.
389413
Editable bool
390414
Link *Link
415+
XSRF string
391416
}
392417

393418
func serveDetail(w http.ResponseWriter, r *http.Request) {
@@ -426,6 +451,7 @@ func serveDetail(w http.ResponseWriter, r *http.Request) {
426451
if link.Owner == login || !ownerExists {
427452
data.Editable = true
428453
data.Link.Owner = login
454+
data.XSRF = xsrftoken.Generate(xsrfKey, login, short)
429455
}
430456

431457
detailTmpl.Execute(w, data)
@@ -521,6 +547,44 @@ func userExists(ctx context.Context, login string) (bool, error) {
521547

522548
var reShortName = regexp.MustCompile(`^\w[\w\-\.]*$`)
523549

550+
func serveDelete(w http.ResponseWriter, r *http.Request) {
551+
short := strings.TrimPrefix(r.RequestURI, "/.delete/")
552+
if short == "" {
553+
http.Error(w, "short required", http.StatusBadRequest)
554+
return
555+
}
556+
557+
login, err := currentUser(r)
558+
if err != nil {
559+
http.Error(w, err.Error(), http.StatusInternalServerError)
560+
return
561+
}
562+
563+
link, err := db.Load(short)
564+
if errors.Is(err, fs.ErrNotExist) {
565+
http.NotFound(w, r)
566+
return
567+
}
568+
569+
if link.Owner != login {
570+
http.Error(w, "cannot delete link owned by another user", http.StatusForbidden)
571+
return
572+
}
573+
574+
if !xsrftoken.Valid(r.PostFormValue("xsrf"), xsrfKey, login, short) {
575+
http.Error(w, "invalid XSRF token", http.StatusBadRequest)
576+
return
577+
}
578+
579+
if err := db.Delete(short); err != nil {
580+
http.Error(w, err.Error(), http.StatusInternalServerError)
581+
return
582+
}
583+
deleteLinkStats(link)
584+
585+
deleteTmpl.Execute(w, link)
586+
}
587+
524588
// serveSave handles requests to save or update a Link. Both short name and
525589
// long URL are validated for proper format. Existing links may only be updated
526590
// by their owner.

golink_test.go

+75
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"strings"
1212
"testing"
1313
"time"
14+
15+
"golang.org/x/net/xsrftoken"
1416
)
1517

1618
func init() {
@@ -163,6 +165,79 @@ func TestServeSave(t *testing.T) {
163165
}
164166
}
165167

168+
func TestServeDelete(t *testing.T) {
169+
var err error
170+
db, err = NewSQLiteDB(":memory:")
171+
if err != nil {
172+
t.Fatal(err)
173+
}
174+
db.Save(&Link{Short: "a", Owner: "a@example.com"})
175+
db.Save(&Link{Short: "foo", Owner: "foo@example.com"})
176+
177+
xsrf := func(short string) string {
178+
return xsrftoken.Generate(xsrfKey, "foo@example.com", short)
179+
}
180+
181+
tests := []struct {
182+
name string
183+
short string
184+
xsrf string
185+
currentUser func(*http.Request) (string, error)
186+
wantStatus int
187+
}{
188+
{
189+
name: "missing short",
190+
short: "",
191+
wantStatus: http.StatusBadRequest,
192+
},
193+
{
194+
name: "non-existant link",
195+
short: "does-not-exist",
196+
wantStatus: http.StatusNotFound,
197+
},
198+
{
199+
name: "unowned link",
200+
short: "a",
201+
wantStatus: http.StatusForbidden,
202+
},
203+
{
204+
name: "invalid xsrf",
205+
short: "foo",
206+
xsrf: xsrf("invalid"),
207+
wantStatus: http.StatusBadRequest,
208+
},
209+
{
210+
name: "valid xsrf",
211+
short: "foo",
212+
xsrf: xsrf("foo"),
213+
wantStatus: http.StatusOK,
214+
},
215+
}
216+
217+
for _, tt := range tests {
218+
t.Run(tt.name, func(t *testing.T) {
219+
if tt.currentUser != nil {
220+
oldCurrentUser := currentUser
221+
currentUser = tt.currentUser
222+
t.Cleanup(func() {
223+
currentUser = oldCurrentUser
224+
})
225+
}
226+
227+
r := httptest.NewRequest("POST", "/.delete/"+tt.short, strings.NewReader(url.Values{
228+
"xsrf": {tt.xsrf},
229+
}.Encode()))
230+
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
231+
w := httptest.NewRecorder()
232+
serveDelete(w, r)
233+
234+
if w.Code != tt.wantStatus {
235+
t.Errorf("serveDelete(%q) = %d; want %d", tt.short, w.Code, tt.wantStatus)
236+
}
237+
})
238+
}
239+
}
240+
166241
func TestExpandLink(t *testing.T) {
167242
tests := []struct {
168243
name string // test name

0 commit comments

Comments
 (0)