many fixes

cmd/tailpaste:
- better handle the error cases from web

cmd/web:
- Implement list of pastes
- Add contextual navbar for paste list and meta help
- Add error page rendering
- Add paginated paste listing
- Add created_at column
- Make paster information less janky on per-paste views

Signed-off-by: Xe Iaso <xe@tailscale.com>
This commit is contained in:
Xe Iaso 2022-12-15 18:47:09 +00:00
parent a4b18e796a
commit e132530597
9 changed files with 165 additions and 11 deletions

View file

@ -61,7 +61,7 @@ func main() {
q.Set("filename", filepath.Base(*fname)) q.Set("filename", filepath.Base(*fname))
q.Set("content", string(data)) q.Set("content", string(data))
req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(q.Encode)) req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(q.Encode()))
if err != nil { if err != nil {
log.Fatalf("can't make HTTP request: %v", err) log.Fatalf("can't make HTTP request: %v", err)
} }

View file

@ -12,8 +12,10 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"text/template" "text/template"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/tailscale/sqlite" "github.com/tailscale/sqlite"
@ -68,8 +70,7 @@ func (s *Server) TailnetIndex(w http.ResponseWriter, r *http.Request) {
ui, err := upsertUserInfo(r.Context(), s.db, s.lc, r.RemoteAddr) ui, err := upsertUserInfo(r.Context(), s.db, s.lc, r.RemoteAddr)
if err != nil { if err != nil {
log.Printf("%s: error fetching user info: %v", r.RemoteAddr, err) s.ShowError(w, r, err, http.StatusInternalServerError)
http.Error(w, "can't fetch user info", http.StatusBadRequest)
return return
} }
@ -147,6 +148,7 @@ func (s *Server) TailnetSubmitPaste(w http.ResponseWriter, r *http.Request) {
q := ` q := `
INSERT INTO pastes INSERT INTO pastes
( id ( id
, created_at
, user_id , user_id
, filename , filename
, data , data
@ -156,19 +158,20 @@ VALUES
, ?2 , ?2
, ?3 , ?3
, ?4 , ?4
, ?5
)` )`
_, err = s.db.ExecContext( _, err = s.db.ExecContext(
r.Context(), r.Context(),
q, q,
id, id,
time.Now(),
userInfo.UserProfile.ID, userInfo.UserProfile.ID,
fname, fname,
data, data,
) )
if err != nil { if err != nil {
log.Printf("%s: %v", r.RemoteAddr, err) s.ShowError(w, r, err, http.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -184,6 +187,126 @@ VALUES
} }
func (s *Server) TailnetPasteIndex(w http.ResponseWriter, r *http.Request) {
userInfo, err := upsertUserInfo(r.Context(), s.db, s.lc, r.RemoteAddr)
if err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
_ = userInfo
type JoinedPasteInfo struct {
ID string `json:"id"`
Filename string `json:"fname"`
CreatedAt string `json:"created_at"`
PasterDisplayName string `json:"created_by"`
}
q := `
SELECT p.id
, p.filename
, p.created_at
, u.display_name
FROM pastes p
INNER JOIN users u
ON p.user_id = u.id
LIMIT 25
OFFSET ?1
`
uq := r.URL.Query()
page := uq.Get("page")
if page == "" {
page = "0"
}
pageNum, err := strconv.Atoi(page)
if err != nil {
log.Printf("%s: invalid ?page: %s: %v", r.RemoteAddr, page, err)
pageNum = 0
}
rows, err := s.db.Query(q, clampToZero(pageNum))
if err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
jpis := make([]JoinedPasteInfo, 0, 25)
defer rows.Close()
for rows.Next() {
jpi := JoinedPasteInfo{}
err := rows.Scan(&jpi.ID, &jpi.Filename, &jpi.CreatedAt, &jpi.PasterDisplayName)
if err != nil {
s.ShowError(w, r, err, http.StatusInternalServerError)
return
}
jpis = append(jpis, jpi)
}
if len(jpis) == 0 {
}
var prev, next *int
if pageNum != 0 {
i := pageNum - 1
prev = &i
}
if len(jpis) == 25 {
i := pageNum + 1
next = &i
}
err = s.tmpls.ExecuteTemplate(w, "listpaste.tmpl", struct {
UserInfo *tailcfg.UserProfile
Title string
Pastes []JoinedPasteInfo
Prev *int
Next *int
Page int
}{
UserInfo: userInfo.UserProfile,
Title: "Pastes",
Pastes: jpis,
Prev: prev,
Next: next,
Page: pageNum,
})
if err != nil {
log.Printf("%s: %v", r.RemoteAddr, err)
}
}
func (s *Server) ShowError(w http.ResponseWriter, r *http.Request, err error, code int) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(code)
log.Printf("%s: %v", r.RemoteAddr, err)
if err := s.tmpls.ExecuteTemplate(w, "error.tmpl", struct {
Title, Error string
UserInfo any
}{
Title: "Oh noes!",
Error: err.Error(),
}); err != nil {
log.Printf("%s: %v", r.RemoteAddr, err)
}
}
func clampToZero(i int) int {
if i <= 0 {
return 0
}
return i
}
func (s *Server) ShowPost(w http.ResponseWriter, r *http.Request) { func (s *Server) ShowPost(w http.ResponseWriter, r *http.Request) {
ui, _ := upsertUserInfo(r.Context(), s.db, s.lc, r.RemoteAddr) ui, _ := upsertUserInfo(r.Context(), s.db, s.lc, r.RemoteAddr)
var up *tailcfg.UserProfile var up *tailcfg.UserProfile
@ -208,6 +331,7 @@ func (s *Server) ShowPost(w http.ResponseWriter, r *http.Request) {
q := ` q := `
SELECT p.filename SELECT p.filename
, p.created_at
, p.data , p.data
, u.id , u.id
, u.login_name , u.login_name
@ -220,11 +344,11 @@ WHERE p.id = ?1`
row := s.db.QueryRowContext(r.Context(), q, id) row := s.db.QueryRowContext(r.Context(), q, id)
var fname, data, userID, userLoginName, userDisplayName, userProfilePicURL string var fname, data, userID, userLoginName, userDisplayName, userProfilePicURL string
var createdAt string
err := row.Scan(&fname, &data, &userID, &userLoginName, &userDisplayName, &userProfilePicURL) err := row.Scan(&fname, &createdAt, &data, &userID, &userLoginName, &userDisplayName, &userProfilePicURL)
if err != nil { if err != nil {
log.Printf("%s: looking up %s: %v", r.RemoteAddr, id, err) s.ShowError(w, r, fmt.Errorf("can't find paste %s: %w", id, err), http.StatusInternalServerError)
http.Error(w, fmt.Sprintf("can't find paste %s: %v", id, err), http.StatusInternalServerError)
return return
} }
@ -252,6 +376,7 @@ WHERE p.id = ?1`
err = s.tmpls.ExecuteTemplate(w, "showpaste.tmpl", struct { err = s.tmpls.ExecuteTemplate(w, "showpaste.tmpl", struct {
UserInfo *tailcfg.UserProfile UserInfo *tailcfg.UserProfile
Title string Title string
CreatedAt string
PasterDisplayName string PasterDisplayName string
PasterProfilePicURL string PasterProfilePicURL string
ID string ID string
@ -259,6 +384,7 @@ WHERE p.id = ?1`
}{ }{
UserInfo: up, UserInfo: up,
Title: fname, Title: fname,
CreatedAt: createdAt,
PasterDisplayName: userDisplayName, PasterDisplayName: userDisplayName,
PasterProfilePicURL: userProfilePicURL, PasterProfilePicURL: userProfilePicURL,
ID: id, ID: id,
@ -309,6 +435,7 @@ func main() {
tailnetMux := http.NewServeMux() tailnetMux := http.NewServeMux()
tailnetMux.Handle("/static/", http.FileServer(http.FS(staticFiles))) tailnetMux.Handle("/static/", http.FileServer(http.FS(staticFiles)))
tailnetMux.HandleFunc("/paste/", srv.ShowPost) tailnetMux.HandleFunc("/paste/", srv.ShowPost)
tailnetMux.HandleFunc("/paste/list", srv.TailnetPasteIndex)
tailnetMux.HandleFunc("/api/post", srv.TailnetSubmitPaste) tailnetMux.HandleFunc("/api/post", srv.TailnetSubmitPaste)
tailnetMux.HandleFunc("/", srv.TailnetIndex) tailnetMux.HandleFunc("/", srv.TailnetIndex)

View file

@ -4,6 +4,7 @@ PRAGMA journal_mode=WAL;
-- Paste data -- Paste data
CREATE TABLE IF NOT EXISTS pastes CREATE TABLE IF NOT EXISTS pastes
( id TEXT PRIMARY KEY NOT NULL ( id TEXT PRIMARY KEY NOT NULL
, created_at TEXT NOT NULL -- RFC 3339 timestamp
, user_id TEXT NOT NULL , user_id TEXT NOT NULL
, filename TEXT NOT NULL , filename TEXT NOT NULL
, data TEXT NOT NULL , data TEXT NOT NULL

View file

@ -9,7 +9,7 @@
<body id="top"> <body id="top">
<main> <main>
<nav> <nav>
<a href="/">Paste</a> <a href="/">Paste</a>{{if .UserInfo}} - <a href="/paste/list">List</a> - <a href="/help">Help</a>{{end}}
{{if .UserInfo}}<div class="right">{{.UserInfo.DisplayName}} <img style="width:32px;height:32px" src="{{.UserInfo.ProfilePicURL}}" /></div>{{end}} {{if .UserInfo}}<div class="right">{{.UserInfo.DisplayName}} <img style="width:32px;height:32px" src="{{.UserInfo.ProfilePicURL}}" /></div>{{end}}
</nav> </nav>

View file

@ -6,4 +6,8 @@
<input type="text" id="filename" name="filename" value="filename.txt" /> <input type="text" id="filename" name="filename" value="filename.txt" />
<input type="submit" value="Submit" /> <input type="submit" value="Submit" />
</form> </form>
<br />
<br />
<a href="/paste/list">See all pastes</a>
{{template "footer" .}} {{template "footer" .}}

9
cmd/web/tmpl/error.tmpl Normal file
View file

@ -0,0 +1,9 @@
{{template "header" .}}
Oh noes! There was an error when trying to do this thing:
<code><pre>
{{.Error}}
</code></pre>
This is almost certainly not what was anticipated, and if you think you are seeing this in error, please contact your administrator or Tailscale support for help with the program itself.
{{template "footer" .}}

View file

@ -0,0 +1,11 @@
{{template "header" .}}
<ul>
{{range .Pastes}}
<li><a href="/paste/{{.ID}}">{{.Filename}}</a> - {{.CreatedAt}} - {{.PasterDisplayName}}</li>
{{end}}
</ul>
<p>{{if .Prev}}<a href="/paste/list?page={{.Prev}}">Prev</a> - {{end}} Page {{.Page}} {{if .Next}} - <a href="/paste/list?page={{.Next}}">Next</a>{{end}}</p>
{{template "footer" .}}

View file

@ -1,3 +1,3 @@
{{template "header" .}} {{template "header" .}}
<p>The URL you requested could not be found. Please check your URL and hang up to try your call again. <p>The URL you requested could not be found. Please check your URL and hang up to try your call again.</p>
{{template "footer" .}} {{template "footer" .}}

View file

@ -1,6 +1,8 @@
{{template "header" .}} {{template "header" .}}
<div class="right">{{.PasterDisplayName}} <img style="width:32px;height:32px" src="{{.PasterProfilePicURL}}" /></div> <div class="right"><img style="width:32px;height:32px" src="{{.PasterProfilePicURL}}" /></div>
<p>Created at {{.CreatedAt}} by {{.PasterDisplayName}}</p>
<pre><code> <pre><code>
{{.Data}} {{.Data}}