205 lines
5.6 KiB
Go
205 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
//go:embed web/templates/*.html
|
|
var tmplFS embed.FS
|
|
|
|
//go:embed web/static
|
|
var staticFS embed.FS
|
|
|
|
type webUI struct {
|
|
tmpl *template.Template
|
|
}
|
|
|
|
func NewWebUI() (*webUI, error) {
|
|
funcs := template.FuncMap{
|
|
"humanSize": humanSize,
|
|
}
|
|
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "web/templates/*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &webUI{tmpl: t}, nil
|
|
}
|
|
|
|
func humanSize(n int) string {
|
|
if n < 1024 {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
if n < 1024*1024 {
|
|
return fmt.Sprintf("%.1f KB", float64(n)/1024)
|
|
}
|
|
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
|
}
|
|
|
|
func staticHandler() http.Handler {
|
|
sub, _ := fs.Sub(staticFS, "web/static")
|
|
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
|
|
}
|
|
|
|
func renderPage(w http.ResponseWriter, name string, data any) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := webUIInstance.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
http.Error(w, "template error: "+err.Error(), 500)
|
|
}
|
|
}
|
|
|
|
var webUIInstance *webUI
|
|
|
|
func (a *apiServer) handleNewPage(w http.ResponseWriter, r *http.Request) {
|
|
renderPage(w, "new.html", map[string]any{"Page": "new"})
|
|
}
|
|
|
|
func (a *apiServer) handleHistoryPage(w http.ResponseWriter, r *http.Request) {
|
|
renderPage(w, "history.html", map[string]any{"Page": "history"})
|
|
}
|
|
|
|
func agoString(ts int64) string {
|
|
s := time.Now().Unix() - ts
|
|
switch {
|
|
case s < 60:
|
|
return fmt.Sprintf("%ds ago", s)
|
|
case s < 3600:
|
|
return fmt.Sprintf("%dm ago", s/60)
|
|
case s < 86400:
|
|
return fmt.Sprintf("%dh ago", s/3600)
|
|
default:
|
|
return fmt.Sprintf("%dd ago", s/86400)
|
|
}
|
|
}
|
|
|
|
func expiryString(expiresAt int64) string {
|
|
s := expiresAt - time.Now().Unix()
|
|
switch {
|
|
case s < 3600:
|
|
return fmt.Sprintf("%dm", s/60)
|
|
case s < 86400:
|
|
return fmt.Sprintf("%dh", s/3600)
|
|
default:
|
|
return fmt.Sprintf("%dd", s/86400)
|
|
}
|
|
}
|
|
|
|
func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreated bool, deletionToken string) {
|
|
lines := strings.Count(row.Content, "\n") + 1
|
|
gutter := ""
|
|
for i := 1; i <= lines; i++ {
|
|
gutter += fmt.Sprintf("%d\n", i)
|
|
}
|
|
expIn := ""
|
|
if row.ExpiresAt.Valid {
|
|
expIn = expiryString(row.ExpiresAt.Int64)
|
|
}
|
|
lang := row.Language.String
|
|
if lang == "" {
|
|
lang = "text"
|
|
}
|
|
summary := fmt.Sprintf("%s · %s · %d views · %s", lang, humanSize(len(row.Content)), row.ViewCount, agoString(row.CreatedAt))
|
|
data := map[string]any{
|
|
"Page": "paste",
|
|
"ID": row.ID,
|
|
"Title": row.Title.String,
|
|
"Language": row.Language.String,
|
|
"StatsSummary": summary,
|
|
"SizeHuman": humanSize(len(row.Content)),
|
|
"HasPassword": row.PasswordHash.Valid,
|
|
"BurnAfterRead": row.BurnAfterRead,
|
|
"CustomSlug": row.CustomSlug.String,
|
|
"ContentHTML": template.HTML(highlightCode(row.Content, row.Language.String)), // safe: highlightCode escapes all non-span text
|
|
"ContentAttr": row.Content,
|
|
"Gutter": strings.TrimSuffix(gutter, "\n"),
|
|
"LineCount": lines,
|
|
"SizeBytes": len(row.Content),
|
|
"CreatedAgo": agoString(row.CreatedAt),
|
|
"ViewCount": row.ViewCount,
|
|
"Visibility": row.Visibility,
|
|
"ExpiresAt": row.ExpiresAt.Valid,
|
|
"ExpiresIn": expIn,
|
|
"DeletionToken": deletionToken,
|
|
"JustCreated": justCreated,
|
|
"Host": "this host",
|
|
}
|
|
renderPage(w, "paste.html", data)
|
|
}
|
|
|
|
// handlePastePage renders the paste view; supports both ID and custom slug.
|
|
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
row, err := a.store.GetPaste(id)
|
|
if err != nil {
|
|
http.Error(w, "db error", 500)
|
|
return
|
|
}
|
|
if row == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
|
http.Error(w, "paste expired", 404)
|
|
return
|
|
}
|
|
if row.PasswordHash.Valid {
|
|
// if a password was submitted via unlock form, verify and set cookie for this paste
|
|
if r.Method == http.MethodPost {
|
|
if !rateLimitUnlock(row.ID, r) {
|
|
writeRateLimited(w, 60)
|
|
return
|
|
}
|
|
r.ParseForm()
|
|
pw := r.FormValue("password")
|
|
if pw != "" && checkPassword(row.PasswordHash.String, pw) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "pw_" + row.ID, Value: "1", Path: "/",
|
|
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
// re-render without lock, or redirect if ?next= was given (#26)
|
|
if next := r.FormValue("next"); next != "" {
|
|
// only allow same-origin relative paths
|
|
if len(next) > 0 && next[0] == '/' && !strings.HasPrefix(next, "//") {
|
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
|
return
|
|
}
|
|
}
|
|
a.renderPaste(w, row, false, "")
|
|
return
|
|
}
|
|
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt)})
|
|
return
|
|
}
|
|
// check cookie
|
|
c, err := r.Cookie("pw_" + row.ID)
|
|
if err != nil || c.Value != "1" {
|
|
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt)})
|
|
return
|
|
}
|
|
}
|
|
|
|
a.store.IncrementViews(row.ID)
|
|
justCreated := r.URL.Query().Get("created") == "1"
|
|
token := r.URL.Query().Get("token")
|
|
if justCreated && token != "" {
|
|
// one-time display of the deletion token via the created banner
|
|
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
|
}
|
|
// only pass the token to the template right after creation
|
|
if justCreated {
|
|
a.renderPaste(w, row, true, token)
|
|
return
|
|
}
|
|
a.renderPaste(w, row, false, "")
|
|
}
|
|
|
|
var _ = strconv.Itoa
|