Skip to content

Commit 4c6804e

Browse files
committed
Stop browsers from caching index.html files
1 parent bef655c commit 4c6804e

5 files changed

Lines changed: 272 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.CacheControlNoCacheComposite)
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.CacheControlNoCacheComposite)
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.CacheControlNoCacheComposite)
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: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import (
4242
"github.com/stretchr/testify/suite"
4343

4444
"github.com/asgardeo/thunder/internal/system/config"
45+
"github.com/asgardeo/thunder/internal/system/constants"
4546
"github.com/asgardeo/thunder/internal/system/log"
4647
"github.com/asgardeo/thunder/tests/mocks/jwtmock"
4748
)
@@ -385,6 +386,216 @@ func TestCreateStaticFileHandler(t *testing.T) {
385386
})
386387
}
387388

389+
func TestCreateStaticFileHandler_CacheHeaders(t *testing.T) {
390+
logger := log.GetLogger()
391+
tmpDir := t.TempDir()
392+
393+
indexContent := []byte("<!DOCTYPE html><html><body>index</body></html>")
394+
jsContent := []byte("console.log('hello');")
395+
cssContent := []byte("body { margin: 0; }")
396+
imageContent := []byte{0xFF, 0xD8, 0xFF} // Mock image bytes
397+
398+
requireWriteFile(t, filepath.Join(tmpDir, "index.html"), indexContent)
399+
requireWriteFile(t, filepath.Join(tmpDir, "app.js"), jsContent)
400+
requireWriteFile(t, filepath.Join(tmpDir, "styles.css"), cssContent)
401+
requireWriteFile(t, filepath.Join(tmpDir, "logo.png"), imageContent)
402+
403+
handler := createStaticFileHandler("/app/", tmpDir, logger)
404+
405+
t.Run("sets cache headers when serving index.html at root", func(t *testing.T) {
406+
req := httptest.NewRequest(http.MethodGet, "/app/", nil)
407+
rr := httptest.NewRecorder()
408+
409+
handler.ServeHTTP(rr, req)
410+
411+
assert.Equal(t, http.StatusOK, rr.Code)
412+
assert.Equal(t, constants.CacheControlNoCacheComposite, rr.Header().Get(constants.CacheControlHeaderName),
413+
"Cache-Control header should prevent caching for index.html at root")
414+
assert.Equal(t, constants.PragmaNoCache, rr.Header().Get(constants.PragmaHeaderName),
415+
"Pragma header should be set for index.html at root")
416+
assert.Equal(t, constants.ExpiresZero, rr.Header().Get(constants.ExpiresHeaderName),
417+
"Expires header should be set for index.html at root")
418+
assert.Contains(t, rr.Body.String(), "index")
419+
})
420+
421+
t.Run("sets cache headers when serving index.html directly", func(t *testing.T) {
422+
req := httptest.NewRequest(http.MethodGet, "/app/index.html", nil)
423+
rr := httptest.NewRecorder()
424+
425+
handler.ServeHTTP(rr, req)
426+
427+
assert.Equal(t, http.StatusOK, rr.Code)
428+
assert.Equal(t, constants.CacheControlNoCacheComposite, rr.Header().Get(constants.CacheControlHeaderName),
429+
"Cache-Control header should prevent caching for direct index.html request")
430+
assert.Equal(t, constants.PragmaNoCache, rr.Header().Get(constants.PragmaHeaderName),
431+
"Pragma header should be set for direct index.html request")
432+
assert.Equal(t, constants.ExpiresZero, rr.Header().Get(constants.ExpiresHeaderName),
433+
"Expires header should be set for direct index.html request")
434+
assert.Contains(t, rr.Body.String(), "index")
435+
})
436+
437+
t.Run("sets cache headers when serving index.html as SPA fallback", func(t *testing.T) {
438+
testCases := []struct {
439+
path string
440+
description string
441+
}{
442+
{"/app/dashboard", "single level path"},
443+
{"/app/users/profile", "multi level path"},
444+
{"/app/settings/advanced/security", "deeply nested path"},
445+
{"/app/nonexistent.html", "non-existent HTML file"},
446+
}
447+
448+
for _, tc := range testCases {
449+
t.Run(tc.description, func(t *testing.T) {
450+
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
451+
rr := httptest.NewRecorder()
452+
453+
handler.ServeHTTP(rr, req)
454+
455+
assert.Equal(t, http.StatusOK, rr.Code)
456+
assert.Equal(t, constants.CacheControlNoCacheComposite,
457+
rr.Header().Get(constants.CacheControlHeaderName),
458+
"Cache-Control header should prevent caching for SPA fallback at %s", tc.path)
459+
assert.Equal(t, constants.PragmaNoCache, rr.Header().Get(constants.PragmaHeaderName),
460+
"Pragma header should be set for SPA fallback at %s", tc.path)
461+
assert.Equal(t, constants.ExpiresZero, rr.Header().Get(constants.ExpiresHeaderName),
462+
"Expires header should be set for SPA fallback at %s", tc.path)
463+
assert.Contains(t, rr.Body.String(), "index",
464+
"Should serve index.html content for SPA fallback at %s", tc.path)
465+
})
466+
}
467+
})
468+
469+
t.Run("does not set cache headers for static assets", func(t *testing.T) {
470+
testCases := []struct {
471+
path string
472+
description string
473+
content []byte
474+
}{
475+
{"/app/app.js", "JavaScript file", jsContent},
476+
{"/app/styles.css", "CSS file", cssContent},
477+
{"/app/logo.png", "image file", imageContent},
478+
}
479+
480+
for _, tc := range testCases {
481+
t.Run(tc.description, func(t *testing.T) {
482+
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
483+
rr := httptest.NewRecorder()
484+
485+
handler.ServeHTTP(rr, req)
486+
487+
assert.Equal(t, http.StatusOK, rr.Code)
488+
assert.Empty(t, rr.Header().Get(constants.CacheControlHeaderName),
489+
"Cache-Control header should not be set for %s", tc.description)
490+
assert.Empty(t, rr.Header().Get(constants.PragmaHeaderName),
491+
"Pragma header should not be set for %s", tc.description)
492+
assert.Empty(t, rr.Header().Get(constants.ExpiresHeaderName),
493+
"Expires header should not be set for %s", tc.description)
494+
assert.Equal(t, string(tc.content), rr.Body.String(),
495+
"Should serve correct content for %s", tc.description)
496+
})
497+
}
498+
})
499+
500+
t.Run("does not match files ending with index.html incorrectly", func(t *testing.T) {
501+
customIndexFile := []byte("custom index content")
502+
requireWriteFile(t, filepath.Join(tmpDir, "my-custom-index.html"), customIndexFile)
503+
504+
req := httptest.NewRequest(http.MethodGet, "/app/my-custom-index.html", nil)
505+
rr := httptest.NewRecorder()
506+
507+
handler.ServeHTTP(rr, req)
508+
509+
assert.Equal(t, http.StatusOK, rr.Code)
510+
assert.Empty(t, rr.Header().Get(constants.CacheControlHeaderName),
511+
"Cache-Control should not be set for files that contain 'index.html' but are not exactly 'index.html'")
512+
assert.Empty(t, rr.Header().Get(constants.PragmaHeaderName),
513+
"Pragma should not be set for files that contain 'index.html' but are not exactly 'index.html'")
514+
assert.Empty(t, rr.Header().Get(constants.ExpiresHeaderName),
515+
"Expires should not be set for files that contain 'index.html' but are not exactly 'index.html'")
516+
assert.Equal(t, string(customIndexFile), rr.Body.String())
517+
})
518+
}
519+
520+
func TestDirectoryExists(t *testing.T) {
521+
tmpDir := t.TempDir()
522+
523+
t.Run("returns true for existing directory", func(t *testing.T) {
524+
assert.True(t, directoryExists(tmpDir))
525+
})
526+
527+
t.Run("returns false for non-existent directory", func(t *testing.T) {
528+
assert.False(t, directoryExists(filepath.Join(tmpDir, "nonexistent")))
529+
})
530+
531+
t.Run("returns false for file, not directory", func(t *testing.T) {
532+
filePath := filepath.Join(tmpDir, "file.txt")
533+
requireWriteFile(t, filePath, []byte("content"))
534+
assert.False(t, directoryExists(filePath))
535+
})
536+
}
537+
538+
func TestFileExists(t *testing.T) {
539+
tmpDir := t.TempDir()
540+
filePath := filepath.Join(tmpDir, "file.txt")
541+
requireWriteFile(t, filePath, []byte("content"))
542+
543+
t.Run("returns true for existing file", func(t *testing.T) {
544+
assert.True(t, fileExists(filePath))
545+
})
546+
547+
t.Run("returns false for non-existent file", func(t *testing.T) {
548+
assert.False(t, fileExists(filepath.Join(tmpDir, "nonexistent.txt")))
549+
})
550+
551+
t.Run("returns false for directory, not file", func(t *testing.T) {
552+
assert.False(t, fileExists(tmpDir))
553+
})
554+
}
555+
556+
func TestRegisterStaticFileHandlers(t *testing.T) {
557+
logger := log.GetLogger()
558+
tmpDir := t.TempDir()
559+
560+
// Create gate and develop directories
561+
gateDir := filepath.Join(tmpDir, "apps", "gate")
562+
developDir := filepath.Join(tmpDir, "apps", "develop")
563+
err := os.MkdirAll(gateDir, 0o750)
564+
assert.NoError(t, err)
565+
err = os.MkdirAll(developDir, 0o750)
566+
assert.NoError(t, err)
567+
568+
// Create index.html files
569+
requireWriteFile(t, filepath.Join(gateDir, "index.html"), []byte("gate app"))
570+
requireWriteFile(t, filepath.Join(developDir, "index.html"), []byte("develop app"))
571+
572+
t.Run("registers handlers for existing directories", func(t *testing.T) {
573+
mux := http.NewServeMux()
574+
registerStaticFileHandlers(logger, mux, tmpDir)
575+
576+
// Test gate handler
577+
req := httptest.NewRequest(http.MethodGet, "/gate/", nil)
578+
rr := httptest.NewRecorder()
579+
mux.ServeHTTP(rr, req)
580+
assert.Equal(t, http.StatusOK, rr.Code)
581+
assert.Contains(t, rr.Body.String(), "gate app")
582+
583+
// Test develop handler
584+
req = httptest.NewRequest(http.MethodGet, "/develop/", nil)
585+
rr = httptest.NewRecorder()
586+
mux.ServeHTTP(rr, req)
587+
assert.Equal(t, http.StatusOK, rr.Code)
588+
assert.Contains(t, rr.Body.String(), "develop app")
589+
})
590+
591+
t.Run("handles missing directories gracefully", func(t *testing.T) {
592+
emptyTmpDir := t.TempDir()
593+
mux := http.NewServeMux()
594+
// Should not panic
595+
registerStaticFileHandlers(logger, mux, emptyTmpDir)
596+
})
597+
}
598+
388599
func requireWriteFile(t *testing.T, path string, content []byte) {
389600
t.Helper()
390601
cleanPath := filepath.Clean(path)

backend/internal/system/constants/server_constants.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,30 @@ 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+
// CacheControlNoCacheComposite is the combined cache-control directive to prevent caching and require revalidation.
72+
const CacheControlNoCacheComposite = "no-cache, no-store, must-revalidate"
73+
74+
// ExpiresZero is the expires value to indicate immediate expiration.
75+
const ExpiresZero = "0"
76+
6277
// DefaultPageSize is the default limit for pagination when not specified.
6378
const DefaultPageSize = 30
6479

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)