Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding .mine support to view only links created by the viewer #146

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
27 changes: 27 additions & 0 deletions db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -217,3 +242,5 @@ func (s *SQLiteDB) DeleteStats(short string) error {
}
return nil
}


47 changes: 47 additions & 0 deletions golink.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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}
Expand Down Expand Up @@ -1020,3 +1066,4 @@ func isRequestAuthorized(r *http.Request, u user, short string) bool {

return xsrftoken.Valid(r.PostFormValue("xsrf"), xsrfKey, u.login, short)
}

85 changes: 85 additions & 0 deletions golink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package golink

import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -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: "[email protected]"})
db.Save(&Link{Short: "link2", Long: "http://example.com/2", Owner: "[email protected]"})
db.Save(&Link{Short: "link3", Long: "http://example.com/3", Owner: "[email protected]"})

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: "[email protected]"}, nil
},
wantLinks: []*Link{
{Short: "link1", Long: "http://example.com/1", Owner: "[email protected]"},
{Short: "link3", Long: "http://example.com/3", Owner: "[email protected]"},
},
wantStatus: http.StatusOK,
},
{
name: "User with no links",
currentUser: func(*http.Request) (user, error) {
return user{login: "[email protected]"}, 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])
}
}
}
})
}
}
2 changes: 2 additions & 0 deletions schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tmpl/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
31 changes: 31 additions & 0 deletions tmpl/mine.html
Original file line number Diff line number Diff line change
@@ -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 }}