website/pkg/templates/template_funcs.go
Noah Petherbridge b7bee75e1f Function to re-sign photo URLs on profile pages
* With the new JWT signatures on photo URLs, it was no longer possible for
  creative users to embed their gallery photos on their profile page.
* Add a function to ReSignPhotoLinks that finds/replaces (on the server side)
  all references to paths under "/static/photos/" and gives them a fresh
  ?jwt= query string signature.
* Note: only applies to the profile page essays, ReSignPhotoLinks is a
  template func that must be opted-in on a per page basis.

Other miscellaneous fixes

* Add "Edit" buttons in the corners of profile cards, when the current user
  looks at their profile page. They link to URIs like
  "/settings#profile/about_me" which will now:
  1. Select the "Profile settings" tab like #profile
  2. Scroll and focus the profile essay field that the user clicked to edit.
2024-10-13 19:50:11 -07:00

346 lines
9.0 KiB
Go

package templates
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"sort"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/markdown"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/utility"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
// Generics
type Number interface {
int | int64 | uint64 | float32 | float64
}
// TemplateFuncs available to all pages.
func TemplateFuncs(r *http.Request) template.FuncMap {
return template.FuncMap{
"InputCSRF": InputCSRF(r),
"SincePrettyCoarse": SincePrettyCoarse(),
"FormatNumberShort": FormatNumberShort(),
"FormatNumberCommas": FormatNumberCommas(),
"ComputeAge": utility.Age,
"Split": strings.Split,
"ToMarkdown": ToMarkdown,
"ToJSON": ToJSON,
"ToHTML": ToHTML,
"PhotoURL": PhotoURL(r),
"VisibleAvatarURL": photo.VisibleAvatarURL,
"Now": time.Now,
"RunTime": RunTime,
"PrettyTitle": func() template.HTML {
return template.HTML(fmt.Sprintf(
`<strong style="color: #0077FF">non</strong>` +
`<strong style="color: #FF77FF">shy</strong>`,
))
},
"PrettyTitleShort": func() template.HTML {
return template.HTML(`<strong style="color: #0077FF">n</strong>` +
`<strong style="color: #FF77FF">s</strong>`,
)
},
"Pluralize": Pluralize[int],
"Pluralize64": Pluralize[int64],
"PluralizeU64": Pluralize[uint64],
"Substring": Substring,
"TrimEllipses": TrimEllipses,
"IterRange": IterRange,
"SubtractInt": SubtractInt,
"SubtractInt64": SubtractInt64,
"UrlEncode": UrlEncode,
"QueryPlus": QueryPlus(r),
"SimplePager": SimplePager(r),
"HasSuffix": strings.HasSuffix,
// Test if a photo should be blurred ({{BlurExplicit .Photo}})
"BlurExplicit": BlurExplicit(r),
// Get a description for an admin scope (e.g. for transparency page).
"AdminScopeDescription": config.AdminScopeDescription,
// "ReSignPhotoLinks": photo.ReSignPhotoLinks,
"ReSignPhotoLinks": func(s template.HTML) template.HTML {
if currentUser, err := session.CurrentUser(r); err == nil {
return template.HTML(photo.ReSignPhotoLinks(currentUser, string(s)))
}
return s
},
}
}
// InputCSRF returns the HTML snippet for a CSRF token hidden input field.
func InputCSRF(r *http.Request) func() template.HTML {
return func() template.HTML {
ctx := r.Context()
if token, ok := ctx.Value(session.CSRFKey).(string); ok {
return template.HTML(fmt.Sprintf(
`<input type="hidden" name="%s" value="%s">`,
config.CSRFInputName,
token,
))
} else {
return template.HTML(`[CSRF middleware error]`)
}
}
}
// RunTime returns the elapsed time between the HTTP request start and now, as a formatted string.
func RunTime(r *http.Request) string {
if rt, ok := r.Context().Value(session.RequestTimeKey).(time.Time); ok {
duration := time.Since(rt)
return duration.Round(time.Millisecond).String()
}
return "ERROR"
}
// PhotoURL returns a URL path to photos.
func PhotoURL(r *http.Request) func(filename string) string {
return func(filename string) string {
// Get the current user to sign a JWT token.
var token string
if currentUser, err := session.CurrentUser(r); err == nil {
return photo.SignedPhotoURL(currentUser, filename)
}
return photo.URLPath(filename) + token
}
}
// BlurExplicit returns true if the current user has the blur_explicit setting on and the given Photo is Explicit.
func BlurExplicit(r *http.Request) func(*models.Photo) bool {
return func(photo *models.Photo) bool {
if !photo.Explicit {
return false
}
currentUser, err := session.CurrentUser(r)
if err != nil {
return false
}
return currentUser.GetProfileField("blur_explicit") == "true"
}
}
// SincePrettyCoarse formats a time.Duration in plain English. Intended for "joined 2 months ago" type
// strings - returns the coarsest level of granularity.
func SincePrettyCoarse() func(time.Time) template.HTML {
return func(since time.Time) template.HTML {
return template.HTML(utility.FormatDurationCoarse(time.Since(since)))
}
}
// FormatNumberShort will format any integer number into a short representation.
func FormatNumberShort() func(v interface{}) template.HTML {
return func(v interface{}) template.HTML {
var number int64
switch t := v.(type) {
case int:
number = int64(t)
case int64:
number = int64(t)
case uint:
number = int64(t)
case uint64:
number = int64(t)
case float32:
number = int64(t)
case float64:
number = int64(t)
default:
return template.HTML("#INVALID#")
}
return template.HTML(utility.FormatNumberShort(number))
}
}
// FormatNumberCommas will pretty print a long number by adding commas.
func FormatNumberCommas() func(v interface{}) template.HTML {
return func(v interface{}) template.HTML {
var number int64
switch t := v.(type) {
case int:
number = int64(t)
case int64:
number = int64(t)
case uint:
number = int64(t)
case uint64:
number = int64(t)
case float32:
number = int64(t)
case float64:
number = int64(t)
default:
return template.HTML("#INVALID#")
}
p := message.NewPrinter(language.English)
return template.HTML(p.Sprintf("%d", number))
}
}
// ToMarkdown renders input text as Markdown.
func ToMarkdown(input string) template.HTML {
return template.HTML(markdown.Render(input))
}
// ToHTML renders input text as trusted HTML code.
func ToHTML(input string) template.HTML {
return template.HTML(input)
}
// ToJSON will stringify any json-serializable object.
func ToJSON(v any) template.JS {
bin, err := json.Marshal(v)
if err != nil {
return template.JS(err.Error())
}
return template.JS(string(bin))
}
// Pluralize text based on a quantity number. Provide up to 2 labels for the
// singular and plural cases, or the defaults are "", "s"
func Pluralize[V Number](count V, labels ...string) string {
if len(labels) < 2 {
labels = []string{"", "s"}
}
if count == 1 {
return labels[0]
}
return labels[1]
}
// Substring safely returns the first N characters of a string.
func Substring(value string, n int) string {
if n > len(value) {
return value
}
return value[:n]
}
// TrimEllipses is like Substring but will add an ellipses if truncated.
func TrimEllipses(value string, n int) string {
if n > len(value) {
return value
}
return value[:n] + "…"
}
// IterRange returns a list of integers useful for pagination.
func IterRange(start, n int) []int {
var result = []int{}
for i := start; i <= n; i++ {
result = append(result, i)
}
return result
}
// SubtractInt subtracts two numbers.
func SubtractInt(a, b int) int {
return a - b
}
// SubtractInt64 subtracts two numbers.
func SubtractInt64(a, b int64) int64 {
return a - b
}
// UrlEncode escapes a series of values (joined with no delimiter)
func UrlEncode(values ...interface{}) string {
var result string
for _, value := range values {
result += url.QueryEscape(fmt.Sprintf("%v", value))
}
return result
}
// QueryPlus takes the current request's query parameters and upserts them with new values.
//
// Use it like: {{QueryPlus "page" .NextPage}}
//
// Returns the query string sans the ? prefix, like "key1=value1&key2=value2"
func QueryPlus(r *http.Request) func(...interface{}) template.URL {
return func(upsert ...interface{}) template.URL {
// Get current parameters.
// Note: COPY them from r.Form so we don't accidentally modify r.Form.
var params = map[string][]string{}
for k, v := range r.Form {
params[k] = v
}
// Mix in the incoming fields.
for i := 0; i < len(upsert); i += 2 {
var (
key = fmt.Sprintf("%v", upsert[i])
value interface{}
)
if len(upsert) > i {
value = upsert[i+1]
}
params[key] = []string{fmt.Sprintf("%v", value)}
}
// Assemble and return the query string.
var parts = []string{}
for k, vs := range params {
for _, v := range vs {
parts = append(parts,
fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(v)),
)
}
}
// Sort them deterministically.
sort.Strings(parts)
return template.URL(strings.Join(parts, "&"))
}
}
// SimplePager creates a paginator row (partial template).
//
// Use it like: {{SimplePager .Pager}}
//
// It runs the template partial 'simple_pager.html' to customize it for the site theme.
func SimplePager(r *http.Request) func(*models.Pagination) template.HTML {
return func(pager *models.Pagination) template.HTML {
tmpl, err := template.New("index").Funcs(template.FuncMap{
"QueryPlus": QueryPlus(r),
}).ParseFiles(config.TemplatePath + "/partials/simple_pager.html")
if err != nil {
return template.HTML(err.Error())
}
var (
vars = struct {
Pager *models.Pagination
Request *http.Request
}{pager, r}
buf = bytes.NewBuffer([]byte{})
)
err = tmpl.ExecuteTemplate(buf, "SimplePager", vars)
if err != nil {
return template.HTML(err.Error())
}
return template.HTML(buf.String())
}
}