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 151b3d2..83fb750 100644 --- a/golink.go +++ b/golink.go @@ -262,6 +262,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 { @@ -294,6 +297,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) @@ -498,6 +502,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) } @@ -518,6 +554,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} @@ -1031,3 +1077,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 12c0263..be86b75 100644 --- a/golink_test.go +++ b/golink_test.go @@ -4,6 +4,7 @@ package golink import ( + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -637,3 +638,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 @@ <h2 class="text-xl font-bold pt-6 pb-2">Popular Links</h2> </tbody> </table> <p class="my-2 text-sm"><a class="text-blue-600 hover:underline" href="/.all">See all links.</a></p> + <p class="my-2 text-sm"><a class="text-blue-600 hover:underline" href="/.mine">See all your links.</a></p> {{ 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" }} + <h2 class="text-xl font-bold pt-6 pb-2">My Links ({{ len . }} total)</h2> + <table class="table-auto w-full max-w-screen-lg"> + <thead class="border-b border-gray-200 uppercase text-xs text-gray-500 text-left"> + <tr class="flex"> + <th class="flex-1 p-2">Link</th> + <th class="hidden md:block w-60 truncate p-2">Owner</th> + <th class="hidden md:block w-32 p-2">Last Edited</th> + </tr> + </thead> + <tbody> + {{ range . }} + <tr class="flex hover:bg-gray-100 group border-b border-gray-200"> + <td class="flex-1 p-2"> + <div class="flex"> + <a class="flex-1 hover:text-blue-500 hover:underline" href="/{{ .Short }}">{{go}}/{{ .Short }}</a> + <a class="flex items-center px-2 invisible group-hover:visible" title="Link Details" href="/.detail/{{ .Short }}"> + <svg class="hover:fill-blue-500" xmlns="http://www.w3.org/2000/svg" height="1.3em" viewBox="0 0 24 24" width="1.3em" fill="#000000" stroke-width="2"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> + </a> + </div> + <p class="text-sm leading-normal text-gray-500 group-hover:text-gray-700 max-w-[75vw] md:max-w-[40vw] truncate">{{ .Long }}</p> + <p class="md:hidden text-sm leading-normal text-gray-700"><span class="text-gray-500 inline-block w-20">Owner</span> {{ .Owner }}</p> + <p class="md:hidden text-sm leading-normal text-gray-700"><span class="text-gray-500 inline-block w-20">Last Edited</span> {{ .LastEdit.Format "Jan 2, 2006" }}</p> + </td> + <td class="hidden md:block w-60 truncate p-2">{{ .Owner }}</td> + <td class="hidden md:block w-32 p-2">{{ .LastEdit.Format "Jan 2, 2006" }}</td> + </tr> + {{ end }} + </tbody> + </table> +{{ end }} \ No newline at end of file