diff --git a/README.md b/README.md index 69dd244..6a64f3b 100644 --- a/README.md +++ b/README.md @@ -253,3 +253,7 @@ enabled over its HTTP interface you _must_ specify the `-L` flag to follow these redirects or else your request will terminate early with an empty response. We recommend the use of the `-L` flag in all deployments regardless of current HTTPS status to avoid accidental outages should it be enabled in the future. + +## My Links + +Navigate to `http://go/.mine` to view all the links you have created. This page filters the links to display only those owned by your account, providing a personalized view of your shortlinks. \ No newline at end of file diff --git a/db.go b/db.go index 5e69fff..7a461d6 100644 --- a/db.go +++ b/db.go @@ -90,6 +90,31 @@ func (s *SQLiteDB) LoadAll() ([]*Link, error) { return links, rows.Err() } +// LoadByOwner retrieves all links owned by the specified user. +func (s *SQLiteDB) LoadByOwner(owner string) ([]*Link, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var links []*Link + rows, err := s.db.Query("SELECT Short, Long, Created, LastEdit, Owner FROM Links WHERE Owner = ?", owner) + if err != nil { + return nil, err + } + for rows.Next() { + link := new(Link) + var created, lastEdit int64 + err := rows.Scan(&link.Short, &link.Long, &created, &lastEdit, &link.Owner) + if err != nil { + return nil, err + } + link.Created = time.Unix(created, 0).UTC() + link.LastEdit = time.Unix(lastEdit, 0).UTC() + links = append(links, link) + } + return links, rows.Err() +} + + // Load returns a Link by its short name. // // It returns fs.ErrNotExist if the link does not exist. @@ -217,3 +242,5 @@ func (s *SQLiteDB) DeleteStats(short string) error { } return nil } + + diff --git a/golink.go b/golink.go index 6d26df9..411746c 100644 --- a/golink.go +++ b/golink.go @@ -260,6 +260,9 @@ var ( // opensearchTmpl is the template used by the http://go/.opensearch page opensearchTmpl *template.Template + + // mineTmpl is the template used by the http://go/.mine page + mineTmpl *template.Template ) type visitData struct { @@ -292,6 +295,7 @@ func init() { allTmpl = newTemplate("base.html", "all.html") deleteTmpl = newTemplate("base.html", "delete.html") opensearchTmpl = newTemplate("opensearch.xml") + mineTmpl = newTemplate("base.html", "mine.html") b := make([]byte, 24) rand.Read(b) @@ -496,6 +500,38 @@ func serveAll(w http.ResponseWriter, _ *http.Request) { allTmpl.Execute(w, links) } +func serveMine(w http.ResponseWriter, r *http.Request) { + cu, err := currentUser(r) + if err != nil { + http.Error(w, "Failed to retrieve current user", http.StatusInternalServerError) + return + } + + // Flush stats before loading links + if err := flushStats(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Load links owned by the current user + links, err := db.LoadByOwner(cu.login) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Return JSON if the client doesn't accept HTML + if !acceptHTML(r) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(links); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + mineTmpl.Execute(w, links) +} + func serveHelp(w http.ResponseWriter, _ *http.Request) { helpTmpl.Execute(w, nil) } @@ -516,6 +552,16 @@ func serveGo(w http.ResponseWriter, r *http.Request) { return } + // Route the user-specific link list /.mine endpoint + if r.URL.Path == "/.mine" { + if r.Method != "GET" { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + serveMine(w, r) + return + } + short, remainder, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") // redirect {name}+ links to /.detail/{name} @@ -1020,3 +1066,4 @@ func isRequestAuthorized(r *http.Request, u user, short string) bool { return xsrftoken.Valid(r.PostFormValue("xsrf"), xsrfKey, u.login, short) } + diff --git a/golink_test.go b/golink_test.go index 564a56b..3f26480 100644 --- a/golink_test.go +++ b/golink_test.go @@ -4,6 +4,7 @@ package golink import ( + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -612,3 +613,87 @@ func TestNoHSTSShortDomain(t *testing.T) { }) } } + +func TestServeMine(t *testing.T) { + var err error + db, err = NewSQLiteDB(":memory:") + if err != nil { + t.Fatal(err) + } + + // Seed the database with links - update Owner to match login format + db.Save(&Link{Short: "link1", Long: "http://example.com/1", Owner: "user1@example.com"}) + db.Save(&Link{Short: "link2", Long: "http://example.com/2", Owner: "user2@example.com"}) + db.Save(&Link{Short: "link3", Long: "http://example.com/3", Owner: "user1@example.com"}) + + tests := []struct { + name string + currentUser func(*http.Request) (user, error) + wantLinks []*Link + wantStatus int + }{ + { + name: "User with links", + currentUser: func(*http.Request) (user, error) { + return user{login: "user1@example.com"}, nil + }, + wantLinks: []*Link{ + {Short: "link1", Long: "http://example.com/1", Owner: "user1@example.com"}, + {Short: "link3", Long: "http://example.com/3", Owner: "user1@example.com"}, + }, + wantStatus: http.StatusOK, + }, + { + name: "User with no links", + currentUser: func(*http.Request) (user, error) { + return user{login: "user3@example.com"}, nil + }, + wantLinks: []*Link{}, + wantStatus: http.StatusOK, + }, + { + name: "Failed to retrieve user", + currentUser: func(*http.Request) (user, error) { + return user{}, errors.New("authentication failed") + }, + wantLinks: nil, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.currentUser != nil { + oldCurrentUser := currentUser + currentUser = tt.currentUser + t.Cleanup(func() { + currentUser = oldCurrentUser + }) + } + + r := httptest.NewRequest("GET", "/.mine", nil) + w := httptest.NewRecorder() + serveMine(w, r) + + if w.Code != tt.wantStatus { + t.Errorf("serveMine() = %d; want %d", w.Code, tt.wantStatus) + } + + if tt.wantStatus == http.StatusOK { + var gotLinks []*Link + err := json.NewDecoder(w.Body).Decode(&gotLinks) + if err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + if len(gotLinks) != len(tt.wantLinks) { + t.Errorf("Number of links = %d; want %d", len(gotLinks), len(tt.wantLinks)) + } + for i, link := range gotLinks { + if link.Short != tt.wantLinks[i].Short || link.Owner != tt.wantLinks[i].Owner { + t.Errorf("Link %d = %+v; want %+v", i, link, tt.wantLinks[i]) + } + } + } + }) + } +} diff --git a/schema.sql b/schema.sql index ac6dd04..344c476 100644 --- a/schema.sql +++ b/schema.sql @@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS Links ( Owner TEXT NOT NULL DEFAULT "" ); +CREATE INDEX IF NOT EXISTS idx_owner ON Links (Owner); + CREATE TABLE IF NOT EXISTS Stats ( ID TEXT NOT NULL DEFAULT "", Created INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), -- unix seconds diff --git a/tmpl/home.html b/tmpl/home.html index 0e146be..6138838 100644 --- a/tmpl/home.html +++ b/tmpl/home.html @@ -40,4 +40,5 @@

Popular Links

See all links.

+

See all your links.

{{ end }} diff --git a/tmpl/mine.html b/tmpl/mine.html new file mode 100644 index 0000000..d309b66 --- /dev/null +++ b/tmpl/mine.html @@ -0,0 +1,31 @@ +{{ define "main" }} +

My Links ({{ len . }} total)

+ + + + + + + + + + {{ range . }} + + + + + + {{ end }} + +
Link
+ +

{{ .Long }}

+

Owner {{ .Owner }}

+

Last Edited {{ .LastEdit.Format "Jan 2, 2006" }}

+
+{{ end }} \ No newline at end of file