Improve browser caching with signed JWT photo URLs

* JWT tokens will now expire on the 10th of the next month, to produce
  consistent values for a period of time and aid with browser caching.
This commit is contained in:
Noah Petherbridge 2024-10-05 20:24:45 -07:00
parent 77a9d9a7fd
commit 2262edfe09
5 changed files with 83 additions and 11 deletions

View File

@ -131,7 +131,7 @@ func Landing() http.HandlerFunc {
Gender: Gender(currentUser), Gender: Gender(currentUser),
VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat
Rules: rules, Rules: rules,
RegisteredClaims: encryption.StandardClaims(currentUser.ID, currentUser.Username, 5*time.Minute), RegisteredClaims: encryption.StandardClaims(currentUser.ID, currentUser.Username, time.Now().Add(5*time.Minute)),
} }
token, err := encryption.SignClaims(claims, []byte(config.Current.BareRTC.JWTSecret)) token, err := encryption.SignClaims(claims, []byte(config.Current.BareRTC.JWTSecret))
if err != nil { if err != nil {

View File

@ -14,11 +14,11 @@ import (
// It will include values for Subject (username), Issuer (site title), ExpiresAt, IssuedAt, NotBefore. // It will include values for Subject (username), Issuer (site title), ExpiresAt, IssuedAt, NotBefore.
// //
// If the userID is >0, the ID field is included. // If the userID is >0, the ID field is included.
func StandardClaims(userID uint64, username string, expires time.Duration) jwt.RegisteredClaims { func StandardClaims(userID uint64, username string, expiresAt time.Time) jwt.RegisteredClaims {
claim := jwt.RegisteredClaims{ claim := jwt.RegisteredClaims{
Subject: username, Subject: username,
Issuer: config.Title, Issuer: config.Title,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expires)), ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()),
} }

View File

@ -7,6 +7,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/encryption" "code.nonshy.com/nonshy/website/pkg/encryption"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/utility"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
) )
@ -29,7 +30,7 @@ func VisibleAvatarURL(user, currentUser *models.User) string {
// SignedPhotoURL returns a URL path to a photo's filename, signed for the current user only. // SignedPhotoURL returns a URL path to a photo's filename, signed for the current user only.
func SignedPhotoURL(user *models.User, filename string) string { func SignedPhotoURL(user *models.User, filename string) string {
return createSignedPhotoURL(user.ID, user.Username, filename, config.SignedPhotoJWTExpires, false) return createSignedPhotoURL(user.ID, user.Username, filename, false)
} }
// SignedPublicAvatarURL returns a signed URL for a user's public square avatar image, which has // SignedPublicAvatarURL returns a signed URL for a user's public square avatar image, which has
@ -38,7 +39,7 @@ func SignedPhotoURL(user *models.User, filename string) string {
// The primary use case is for the chat room: users are sent into chat with their avatar URL, // The primary use case is for the chat room: users are sent into chat with their avatar URL,
// and it must be viewable to all users for a long time. // and it must be viewable to all users for a long time.
func SignedPublicAvatarURL(filename string) string { func SignedPublicAvatarURL(filename string) string {
return createSignedPhotoURL(0, "@", filename, config.SignedPublicAvatarJWTExpires, true) return createSignedPhotoURL(0, "@", filename, true)
} }
// SignedPhotoClaims are a JWT claims object used to sign and authenticate image (direct .jpg) links. // SignedPhotoClaims are a JWT claims object used to sign and authenticate image (direct .jpg) links.
@ -60,13 +61,21 @@ func FilenameHash(filename string) string {
} }
// Common function to create a signed photo URL with an expiration. // Common function to create a signed photo URL with an expiration.
func createSignedPhotoURL(userID uint64, username string, filename string, expires time.Duration, anyone bool) string { func createSignedPhotoURL(userID uint64, username string, filename string, anyone bool) string {
claims := SignedPhotoClaims{ // Claims expire on the 10th of next month.
var (
expiresAt = utility.NextMonth(time.Now(), 10)
claims = SignedPhotoClaims{
FilenameHash: FilenameHash(filename), FilenameHash: FilenameHash(filename),
Anyone: anyone, Anyone: anyone,
RegisteredClaims: encryption.StandardClaims(userID, username, expires), RegisteredClaims: encryption.StandardClaims(userID, username, expiresAt),
} }
)
// Lock the date stamps for a consistent JWT value for caching.
claims.IssuedAt = nil
claims.NotBefore = nil
log.Debug("createSignedPhotoURL(%s): %+v", filename, claims) log.Debug("createSignedPhotoURL(%s): %+v", filename, claims)

View File

@ -7,6 +7,22 @@ import (
"time" "time"
) )
// NextMonth takes an input time (usually time.Now) and will return the next month on the given day.
//
// Example, NextMonth from any time in April should return e.g. May 10th.
func NextMonth(now time.Time, day int) time.Time {
var (
year, month, _ = now.Date()
nextMonth = month + 1
)
if nextMonth > 12 {
nextMonth = 1
year++
}
return time.Date(year, nextMonth, day, 0, 0, 0, 0, now.Location())
}
// FormatDurationCoarse returns a pretty printed duration with coarse granularity. // FormatDurationCoarse returns a pretty printed duration with coarse granularity.
func FormatDurationCoarse(duration time.Duration) string { func FormatDurationCoarse(duration time.Duration) string {
// Negative durations (e.g. future dates) should work too. // Negative durations (e.g. future dates) should work too.

View File

@ -7,6 +7,53 @@ import (
"code.nonshy.com/nonshy/website/pkg/utility" "code.nonshy.com/nonshy/website/pkg/utility"
) )
func TestNextMonth(t *testing.T) {
var tests = []struct {
Now string
Day int
Expect string
}{
{
Now: "1995-08-01",
Day: 15,
Expect: "1995-09-15",
},
{
Now: "2006-12-15",
Day: 11,
Expect: "2007-01-11",
},
{
Now: "2006-12-01",
Day: 15,
Expect: "2007-01-15",
},
{
Now: "2007-01-15",
Day: 29, // no leap day
Expect: "2007-03-01",
},
{
Now: "2004-01-08",
Day: 29, // leap day
Expect: "2004-02-29",
},
}
for i, test := range tests {
now, err := time.Parse(time.DateOnly, test.Now)
if err != nil {
t.Errorf("Test #%d: parse error: %s", i, err)
continue
}
actual := utility.NextMonth(now, test.Day).Format("2006-01-02")
if actual != test.Expect {
t.Errorf("Test #%d: expected %s but got %s", i, test.Expect, actual)
}
}
}
func TestFormatDurationCoarse(t *testing.T) { func TestFormatDurationCoarse(t *testing.T) {
var tests = []struct { var tests = []struct {
In time.Duration In time.Duration