Skip to content

Commit

Permalink
feat(pwa): implement PWA
Browse files Browse the repository at this point in the history
  • Loading branch information
s0up4200 committed Nov 7, 2024
1 parent 9b67bc7 commit 08af31f
Show file tree
Hide file tree
Showing 24 changed files with 3,277 additions and 543 deletions.
22 changes: 16 additions & 6 deletions backend/api/middleware/secure.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type SecureConfig struct {
CSPObjectSrc []string
CSPMediaSrc []string
CSPFrameSrc []string
CSPWorkerSrc []string
CSPManifestSrc []string
HSTSEnabled bool
HSTSMaxAge int
HSTSIncludeSubdomains bool
Expand All @@ -38,14 +40,16 @@ func DefaultSecureConfig() *SecureConfig {
return &SecureConfig{
CSPEnabled: true,
CSPDefaultSrc: []string{"'self'"},
CSPScriptSrc: []string{"'self'", "'unsafe-inline'", "'unsafe-eval'"},
CSPStyleSrc: []string{"'self'", "'unsafe-inline'"},
CSPImgSrc: []string{"'self'", "data:", "https:"},
CSPConnectSrc: []string{"'self'"},
CSPFontSrc: []string{"'self'"},
CSPScriptSrc: []string{"'self'", "'unsafe-inline'", "'unsafe-eval'", "blob:", "data:", "http:", "https:"},
CSPStyleSrc: []string{"'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "http:", "https:"},
CSPImgSrc: []string{"'self'", "data:", "http:", "https:", "blob:"},
CSPConnectSrc: []string{"'self'", "ws:", "wss:", "http:", "https:", "data:"},
CSPFontSrc: []string{"'self'", "https://fonts.googleapis.com", "https://fonts.gstatic.com", "http:", "https:"},
CSPObjectSrc: []string{"'none'"},
CSPMediaSrc: []string{"'self'"},
CSPMediaSrc: []string{"'self'", "http:", "https:"},
CSPFrameSrc: []string{"'none'"},
CSPWorkerSrc: []string{"'self'", "blob:", "http:", "https:"},
CSPManifestSrc: []string{"'self'", "http:", "https:"},
HSTSEnabled: true,
HSTSMaxAge: 31536000, // 1 year
HSTSIncludeSubdomains: true,
Expand Down Expand Up @@ -93,6 +97,12 @@ func (c *SecureConfig) buildCSPHeader() string {
if len(c.CSPFrameSrc) > 0 {
csp += "frame-src " + joinSources(c.CSPFrameSrc) + "; "
}
if len(c.CSPWorkerSrc) > 0 {
csp += "worker-src " + joinSources(c.CSPWorkerSrc) + "; "
}
if len(c.CSPManifestSrc) > 0 {
csp += "manifest-src " + joinSources(c.CSPManifestSrc) + "; "
}

return csp
}
Expand Down
1 change: 1 addition & 0 deletions backend/api/routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func SetupRoutes(r *gin.Engine, db *database.DB, health *services.HealthService)
r.Use(middleware.Logger())
r.Use(gin.Recovery())
r.Use(middleware.SetupCORS())
r.Use(middleware.Secure(nil)) // Add secure middleware with default config

// Initialize Redis cache
redisCache, err := cache.InitCache()
Expand Down
122 changes: 90 additions & 32 deletions backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,68 +117,126 @@ func main() {
log.Fatal().Err(err).Msg("Failed to get embedded files")
}

// Serve static files with proper MIME types and headers
r.GET("/assets/*filepath", func(c *gin.Context) {
filepath := strings.TrimPrefix(c.Param("filepath"), "/")

// Open the file from embedded filesystem
file, err := dist.Open(path.Join("assets", filepath))
// Helper function to serve static files with proper headers
serveStaticFile := func(c *gin.Context, filepath string, contentType string) {
file, err := dist.Open(filepath)
if err != nil {
c.Status(http.StatusNotFound)
return
}
defer file.Close()

// Read file info to get content type
stat, err := file.Stat()
if err != nil {
c.Status(http.StatusInternalServerError)
return
}

c.Header("Content-Type", contentType)
if strings.Contains(filepath, "sw.js") || strings.Contains(filepath, "manifest.json") {
c.Header("Cache-Control", "no-cache")
if strings.Contains(filepath, "sw.js") {
c.Header("Service-Worker-Allowed", "/")
}
} else {
c.Header("Cache-Control", "public, max-age=31536000")
}
c.Header("X-Content-Type-Options", "nosniff")

c.DataFromReader(http.StatusOK, stat.Size(), contentType, file, nil)
}

// Serve static files from root path
r.GET("/logo.svg", func(c *gin.Context) {
serveStaticFile(c, "logo.svg", "image/svg+xml")
})

r.GET("/masked-icon.svg", func(c *gin.Context) {
serveStaticFile(c, "masked-icon.svg", "image/svg+xml")
})

r.GET("/favicon.ico", func(c *gin.Context) {
serveStaticFile(c, "favicon.ico", "image/x-icon")
})

r.GET("/apple-touch-icon.png", func(c *gin.Context) {
serveStaticFile(c, "apple-touch-icon.png", "image/png")
})

r.GET("/apple-touch-icon-iphone-60x60.png", func(c *gin.Context) {
serveStaticFile(c, "apple-touch-icon-iphone-60x60.png", "image/png")
})

r.GET("/apple-touch-icon-ipad-76x76.png", func(c *gin.Context) {
serveStaticFile(c, "apple-touch-icon-ipad-76x76.png", "image/png")
})

r.GET("/apple-touch-icon-iphone-retina-120x120.png", func(c *gin.Context) {
serveStaticFile(c, "apple-touch-icon-iphone-retina-120x120.png", "image/png")
})

r.GET("/apple-touch-icon-ipad-retina-152x152.png", func(c *gin.Context) {
serveStaticFile(c, "apple-touch-icon-ipad-retina-152x152.png", "image/png")
})

r.GET("/pwa-192x192.png", func(c *gin.Context) {
serveStaticFile(c, "pwa-192x192.png", "image/png")
})

r.GET("/pwa-512x512.png", func(c *gin.Context) {
serveStaticFile(c, "pwa-512x512.png", "image/png")
})

// Serve manifest.json
r.GET("/manifest.json", func(c *gin.Context) {
serveStaticFile(c, "manifest.json", "application/manifest+json; charset=utf-8")
})

// Serve service worker
r.GET("/sw.js", func(c *gin.Context) {
serveStaticFile(c, "sw.js", "text/javascript; charset=utf-8")
})

// Serve workbox files
r.GET("/workbox-:hash.js", func(c *gin.Context) {
serveStaticFile(c, c.Request.URL.Path[1:], "text/javascript; charset=utf-8")
})

// Serve assets directory
r.GET("/assets/*filepath", func(c *gin.Context) {
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
fullPath := path.Join("assets", filepath)

// Set content type based on file extension
ext := strings.ToLower(path.Ext(filepath))
var contentType string
switch ext {
case ".css":
contentType = "text/css; charset=utf-8"
case ".js":
contentType = "application/javascript; charset=utf-8"
case ".js", ".mjs", ".tsx", ".ts":
contentType = "text/javascript; charset=utf-8"
case ".svg":
contentType = "image/svg+xml"
case ".png":
contentType = "image/png"
case ".jpg", ".jpeg":
contentType = "image/jpeg"
case ".json":
contentType = "application/json; charset=utf-8"
case ".woff":
contentType = "font/woff"
case ".woff2":
contentType = "font/woff2"
default:
contentType = "application/octet-stream"
contentType = "text/javascript; charset=utf-8"
}

// Set headers
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "public, max-age=31536000")
c.Header("X-Content-Type-Options", "nosniff")

// Stream the file
c.DataFromReader(http.StatusOK, stat.Size(), contentType, file, nil)
})

// Serve specific static files
r.GET("/favicon.ico", func(c *gin.Context) {
file, err := dist.Open("favicon.ico")
if err != nil {
c.Status(http.StatusNotFound)
return
}
defer file.Close()

c.Header("Content-Type", "image/x-icon")
c.Header("Cache-Control", "public, max-age=31536000")
io.Copy(c.Writer, file)
serveStaticFile(c, fullPath, contentType)
})

// Serve index.html for root path with proper headers
// Serve index.html for root path and direct requests
r.GET("/", serveIndex(dist))
r.GET("/index.html", serveIndex(dist))

// Handle all other routes
r.NoRoute(func(c *gin.Context) {
Expand Down
13 changes: 7 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,22 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Dashbrr</title>

<!-- iOS PWA specific tags -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon" sizes="60x60" href="/apple-touch-icon-iphone-60x60.png" />
<link rel="apple-touch-icon" sizes="76x76" href="/apple-touch-icon-ipad-76x76.png" />
<link rel="apple-touch-icon" sizes="120x120" href="/apple-touch-icon-iphone-retina-120x120.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/apple-touch-icon-ipad-retina-152x152.png" />

<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Dashbrr" />
<meta name="theme-color" content="#1a1d24" />

<!-- Preload critical assets -->
<link rel="preload" href="/src/assets/logo.svg" as="image" type="image/svg+xml" />
<link rel="modulepreload" href="/src/main.tsx" />
<link rel="modulepreload" href="/src/App.tsx" />

<!-- Optimize font loading -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />

Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"react-masonry-css": "^1.0.16",
"react-router-dom": "^6.27.0",
"tailwind-lerp-colors": "^1.2.6",
"vite-plugin-pwa": "^0.20.5",
"vite-plugin-svgr": "^4.3.0"
},
"devDependencies": {
Expand All @@ -59,6 +60,11 @@
"typescript": "~5.6.3",
"typescript-eslint": "^8.13.0",
"vite": "^5.4.10",
"vite-svg-loader": "^5.1.0"
"vite-svg-loader": "^5.1.0",
"workbox-expiration": "^7.3.0",
"workbox-precaching": "^7.3.0",
"workbox-routing": "^7.3.0",
"workbox-strategies": "^7.3.0",
"workbox-window": "^7.3.0"
}
}
Loading

0 comments on commit 08af31f

Please sign in to comment.