Skip to content

Commit a0dd39f

Browse files
committed
Stop browsers from caching index.html files
1 parent 1872666 commit a0dd39f

5 files changed

Lines changed: 192 additions & 1 deletion

File tree

backend/cmd/server/main.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535

3636
"github.com/asgardeo/thunder/internal/system/cache"
3737
"github.com/asgardeo/thunder/internal/system/config"
38+
"github.com/asgardeo/thunder/internal/system/constants"
3839
"github.com/asgardeo/thunder/internal/system/crypto/pki"
3940
"github.com/asgardeo/thunder/internal/system/database/provider"
4041
"github.com/asgardeo/thunder/internal/system/jwt"
@@ -303,9 +304,25 @@ func createStaticFileHandler(routePrefix, directory string, logger *log.Logger)
303304
fileServer := http.FileServer(http.Dir(directory))
304305

305306
return http.StripPrefix(routePrefix, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
307+
// Handle root path "/" by explicitly serving index.html
308+
if r.URL.Path == "/" || r.URL.Path == "" {
309+
indexPath := path.Join(directory, "index.html")
310+
if fileExists(indexPath) {
311+
// Set no-cache headers for index.html
312+
w.Header().Set(constants.CacheControlHeaderName, constants.CacheControlNoCache+", "+constants.CacheControlNoStore+", "+constants.CacheControlMustRevalidate)
313+
w.Header().Set(constants.PragmaHeaderName, constants.PragmaNoCache)
314+
w.Header().Set(constants.ExpiresHeaderName, constants.ExpiresZero)
315+
http.ServeFile(w, r, indexPath)
316+
return
317+
}
318+
}
319+
306320
// Get the file path
307321
filePath := path.Join(directory, r.URL.Path)
308322

323+
// Check if the requested file is index.html
324+
isIndexHTML := r.URL.Path == "/index.html" || path.Base(filePath) == "index.html"
325+
309326
// Check if file exists
310327
if _, err := os.Stat(filePath); os.IsNotExist(err) {
311328
// For SPA routing, serve index.html for non-existent files
@@ -314,6 +331,23 @@ func createStaticFileHandler(routePrefix, directory string, logger *log.Logger)
314331
logger.Debug("Serving index.html for SPA routing",
315332
log.String("requested_path", r.URL.Path),
316333
log.String("route_prefix", routePrefix))
334+
// Set no-cache headers for index.html
335+
w.Header().Set(constants.CacheControlHeaderName, constants.CacheControlNoCache+", "+constants.CacheControlNoStore+", "+constants.CacheControlMustRevalidate)
336+
w.Header().Set(constants.PragmaHeaderName, constants.PragmaNoCache)
337+
w.Header().Set(constants.ExpiresHeaderName, constants.ExpiresZero)
338+
http.ServeFile(w, r, indexPath)
339+
return
340+
}
341+
}
342+
343+
// Serve index.html directly with no-cache headers when requested
344+
if isIndexHTML {
345+
indexPath := path.Join(directory, "index.html")
346+
if fileExists(indexPath) {
347+
// Set no-cache headers for index.html
348+
w.Header().Set(constants.CacheControlHeaderName, constants.CacheControlNoCache+", "+constants.CacheControlNoStore+", "+constants.CacheControlMustRevalidate)
349+
w.Header().Set(constants.PragmaHeaderName, constants.PragmaNoCache)
350+
w.Header().Set(constants.ExpiresHeaderName, constants.ExpiresZero)
317351
http.ServeFile(w, r, indexPath)
318352
return
319353
}

backend/cmd/server/main_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,140 @@ func TestCreateStaticFileHandler(t *testing.T) {
385385
})
386386
}
387387

388+
func TestCreateStaticFileHandler_CacheHeaders(t *testing.T) {
389+
logger := log.GetLogger()
390+
tmpDir := t.TempDir()
391+
392+
indexContent := []byte("<!DOCTYPE html><html><body>index</body></html>")
393+
jsContent := []byte("console.log('hello');")
394+
cssContent := []byte("body { margin: 0; }")
395+
imageContent := []byte{0xFF, 0xD8, 0xFF} // Mock image bytes
396+
397+
requireWriteFile(t, filepath.Join(tmpDir, "index.html"), indexContent)
398+
requireWriteFile(t, filepath.Join(tmpDir, "app.js"), jsContent)
399+
requireWriteFile(t, filepath.Join(tmpDir, "styles.css"), cssContent)
400+
requireWriteFile(t, filepath.Join(tmpDir, "logo.png"), imageContent)
401+
402+
handler := createStaticFileHandler("/app/", tmpDir, logger)
403+
404+
expectedCacheControl := "no-cache, no-store, must-revalidate"
405+
expectedPragma := "no-cache"
406+
expectedExpires := "0"
407+
408+
t.Run("sets cache headers when serving index.html at root", func(t *testing.T) {
409+
req := httptest.NewRequest(http.MethodGet, "/app/", nil)
410+
rr := httptest.NewRecorder()
411+
412+
handler.ServeHTTP(rr, req)
413+
414+
assert.Equal(t, http.StatusOK, rr.Code)
415+
assert.Equal(t, expectedCacheControl, rr.Header().Get("Cache-Control"),
416+
"Cache-Control header should prevent caching for index.html at root")
417+
assert.Equal(t, expectedPragma, rr.Header().Get("Pragma"),
418+
"Pragma header should be set for index.html at root")
419+
assert.Equal(t, expectedExpires, rr.Header().Get("Expires"),
420+
"Expires header should be set for index.html at root")
421+
assert.Contains(t, rr.Body.String(), "index")
422+
})
423+
424+
t.Run("sets cache headers when serving index.html directly", func(t *testing.T) {
425+
req := httptest.NewRequest(http.MethodGet, "/app/index.html", nil)
426+
rr := httptest.NewRecorder()
427+
428+
handler.ServeHTTP(rr, req)
429+
430+
assert.Equal(t, http.StatusOK, rr.Code)
431+
assert.Equal(t, expectedCacheControl, rr.Header().Get("Cache-Control"),
432+
"Cache-Control header should prevent caching for direct index.html request")
433+
assert.Equal(t, expectedPragma, rr.Header().Get("Pragma"),
434+
"Pragma header should be set for direct index.html request")
435+
assert.Equal(t, expectedExpires, rr.Header().Get("Expires"),
436+
"Expires header should be set for direct index.html request")
437+
assert.Contains(t, rr.Body.String(), "index")
438+
})
439+
440+
t.Run("sets cache headers when serving index.html as SPA fallback", func(t *testing.T) {
441+
testCases := []struct {
442+
path string
443+
description string
444+
}{
445+
{"/app/dashboard", "single level path"},
446+
{"/app/users/profile", "multi level path"},
447+
{"/app/settings/advanced/security", "deeply nested path"},
448+
{"/app/nonexistent.html", "non-existent HTML file"},
449+
}
450+
451+
for _, tc := range testCases {
452+
t.Run(tc.description, func(t *testing.T) {
453+
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
454+
rr := httptest.NewRecorder()
455+
456+
handler.ServeHTTP(rr, req)
457+
458+
assert.Equal(t, http.StatusOK, rr.Code)
459+
assert.Equal(t, expectedCacheControl, rr.Header().Get("Cache-Control"),
460+
"Cache-Control header should prevent caching for SPA fallback at %s", tc.path)
461+
assert.Equal(t, expectedPragma, rr.Header().Get("Pragma"),
462+
"Pragma header should be set for SPA fallback at %s", tc.path)
463+
assert.Equal(t, expectedExpires, rr.Header().Get("Expires"),
464+
"Expires header should be set for SPA fallback at %s", tc.path)
465+
assert.Contains(t, rr.Body.String(), "index",
466+
"Should serve index.html content for SPA fallback at %s", tc.path)
467+
})
468+
}
469+
})
470+
471+
t.Run("does not set cache headers for static assets", func(t *testing.T) {
472+
testCases := []struct {
473+
path string
474+
description string
475+
content []byte
476+
}{
477+
{"/app/app.js", "JavaScript file", jsContent},
478+
{"/app/styles.css", "CSS file", cssContent},
479+
{"/app/logo.png", "image file", imageContent},
480+
}
481+
482+
for _, tc := range testCases {
483+
t.Run(tc.description, func(t *testing.T) {
484+
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
485+
rr := httptest.NewRecorder()
486+
487+
handler.ServeHTTP(rr, req)
488+
489+
assert.Equal(t, http.StatusOK, rr.Code)
490+
assert.Empty(t, rr.Header().Get("Cache-Control"),
491+
"Cache-Control header should not be set for %s", tc.description)
492+
assert.Empty(t, rr.Header().Get("Pragma"),
493+
"Pragma header should not be set for %s", tc.description)
494+
assert.Empty(t, rr.Header().Get("Expires"),
495+
"Expires header should not be set for %s", tc.description)
496+
assert.Equal(t, string(tc.content), rr.Body.String(),
497+
"Should serve correct content for %s", tc.description)
498+
})
499+
}
500+
})
501+
502+
t.Run("does not match files ending with index.html incorrectly", func(t *testing.T) {
503+
customIndexFile := []byte("custom index content")
504+
requireWriteFile(t, filepath.Join(tmpDir, "my-custom-index.html"), customIndexFile)
505+
506+
req := httptest.NewRequest(http.MethodGet, "/app/my-custom-index.html", nil)
507+
rr := httptest.NewRecorder()
508+
509+
handler.ServeHTTP(rr, req)
510+
511+
assert.Equal(t, http.StatusOK, rr.Code)
512+
assert.Empty(t, rr.Header().Get("Cache-Control"),
513+
"Cache-Control should not be set for files that contain 'index.html' but are not exactly 'index.html'")
514+
assert.Empty(t, rr.Header().Get("Pragma"),
515+
"Pragma should not be set for files that contain 'index.html' but are not exactly 'index.html'")
516+
assert.Empty(t, rr.Header().Get("Expires"),
517+
"Expires should not be set for files that contain 'index.html' but are not exactly 'index.html'")
518+
assert.Equal(t, string(customIndexFile), rr.Body.String())
519+
})
520+
}
521+
388522
func requireWriteFile(t *testing.T, path string, content []byte) {
389523
t.Helper()
390524
cleanPath := filepath.Clean(path)

backend/internal/system/constants/server_constants.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,27 @@ const ContentTypeFormURLEncoded = "application/x-www-form-urlencoded"
5050
// CacheControlHeaderName is the name of the cache-control header used in HTTP responses.
5151
const CacheControlHeaderName = "Cache-Control"
5252

53-
// CacheControlNoStore is the cache-control value to prevent caching.
53+
// CacheControlNoCache is the cache-control directive to force revalidation.
54+
const CacheControlNoCache = "no-cache"
55+
56+
// CacheControlNoStore is the cache-control directive to prevent caching.
5457
const CacheControlNoStore = "no-store"
5558

59+
// CacheControlMustRevalidate is the cache-control directive to require revalidation of stale cache entries.
60+
const CacheControlMustRevalidate = "must-revalidate"
61+
5662
// PragmaHeaderName is the name of the pragma header used in HTTP responses.
5763
const PragmaHeaderName = "Pragma"
5864

5965
// PragmaNoCache is the pragma value to prevent caching.
6066
const PragmaNoCache = "no-cache"
6167

68+
// ExpiresHeaderName is the name of the expires header used in HTTP responses.
69+
const ExpiresHeaderName = "Expires"
70+
71+
// ExpiresZero is the expires value to indicate immediate expiration.
72+
const ExpiresZero = "0"
73+
6274
// DefaultPageSize is the default limit for pagination when not specified.
6375
const DefaultPageSize = 30
6476

frontend/apps/thunder-develop/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.ico" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77

8+
<!-- Prevent browser caching -->
9+
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
10+
<meta http-equiv="Pragma" content="no-cache" />
11+
<meta http-equiv="Expires" content="0" />
12+
813
<!-- Start of loading application configurations -->
914
<script type="text/javascript" src="/config.js"></script>
1015
<!-- End of loading application configurations -->

frontend/apps/thunder-gate/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.ico" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
8+
<!-- Prevent browser caching -->
9+
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
10+
<meta http-equiv="Pragma" content="no-cache" />
11+
<meta http-equiv="Expires" content="0" />
12+
713
<title>Gate</title>
814

915
<!-- Start of loading application configurations -->

0 commit comments

Comments
 (0)