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),
VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat
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))
if err != nil {

View File

@ -14,11 +14,11 @@ import (
// It will include values for Subject (username), Issuer (site title), ExpiresAt, IssuedAt, NotBefore.
//
// 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{
Subject: username,
Issuer: config.Title,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expires)),
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: 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/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/utility"
"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.
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
@ -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,
// and it must be viewable to all users for a long time.
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.
@ -60,13 +61,21 @@ func FilenameHash(filename string) string {
}
// 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),
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)

View File

@ -7,6 +7,22 @@ import (
"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.
func FormatDurationCoarse(duration time.Duration) string {
// Negative durations (e.g. future dates) should work too.

View File

@ -7,6 +7,53 @@ import (
"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) {
var tests = []struct {
In time.Duration