Files
pocket-id/backend/frontend/frontend_included.go
T

265 lines
6.6 KiB
Go

//go:build !exclude_frontend
package frontend
import (
"bytes"
"embed"
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"path"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
)
//go:embed all:dist/*
var frontendFS embed.FS
// SvelteKit generates both gzip and Brotli sidecars for these extensions when precompress is enabled
var precompressedExtensions = map[string]struct{}{
".css": {},
".html": {},
".js": {},
".json": {},
".md": {},
".mdx": {},
".mjs": {},
".svg": {},
".txt": {},
".wasm": {},
".xml": {},
}
// This function, created by the init() method, writes to "w" the index.html page, populating the nonce
var writeIndexFn func(w io.Writer, nonce string) error
func init() {
const scriptTag = "<script>"
// Read the index.html from the bundle
index, iErr := fs.ReadFile(frontendFS, "dist/index.html")
if iErr != nil {
panic(fmt.Errorf("failed to read index.html: %w", iErr))
}
writeIndexFn = func(w io.Writer, nonce string) (err error) {
// If there's no nonce, write the index as-is
if nonce == "" {
_, err = w.Write(index)
return err
}
// Add nonce to all <script> tags
// We replace "<script" with `<script nonce="..."` everywhere it appears
modified := bytes.ReplaceAll(
index,
[]byte(scriptTag),
[]byte(`<script nonce="`+nonce+`">`),
)
_, err = w.Write(modified)
return err
}
}
func RegisterFrontend(router *gin.Engine) error {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
return fmt.Errorf("failed to create sub FS: %w", err)
}
// Init the file server
fileServer := NewFileServerWithCaching(http.FS(distFS))
// Handler for Gin
handler := func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if strings.HasSuffix(path, "/") {
c.Redirect(http.StatusMovedPermanently, strings.TrimRight(c.Request.URL.String(), "/"))
return
}
if strings.HasPrefix(path, "api/") || strings.HasPrefix(path, ".well-known/") {
c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
return
}
if isSPARequest(path, distFS) {
nonce := middleware.GetCSPNonce(c)
// Do not cache the HTML shell, as it embeds a per-request nonce
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-store")
c.Status(http.StatusOK)
err = writeIndexFn(c.Writer, nonce)
if err != nil {
_ = c.Error(fmt.Errorf("failed to write index.html file: %w", err))
}
return
}
// Serve other static assets with caching
c.Request.URL.Path = "/" + path
fileServer.ServeHTTP(c.Writer, c.Request)
}
router.NoRoute(handler)
return nil
}
func isSPARequest(path string, distFS fs.FS) bool {
if path == "" {
return true
}
if _, err := fs.Stat(distFS, path); err != nil {
return true
}
return false
}
// FileServerWithCaching wraps http.FileServer to add caching headers
type FileServerWithCaching struct {
root http.FileSystem
lastModified time.Time
lastModifiedHeaderValue string
}
func NewFileServerWithCaching(root http.FileSystem) *FileServerWithCaching {
return &FileServerWithCaching{
root: root,
lastModified: time.Now(),
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
}
}
func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// First, set cache headers
// Check if the request is for an immutable asset
if isImmutableAsset(r) {
// Set the cache control header as immutable with a long expiration
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
// Check if the client has a cached version
ifModifiedSince := r.Header.Get("If-Modified-Since")
if ifModifiedSince != "" {
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
// Client's cached version is up to date
w.WriteHeader(http.StatusNotModified)
return
}
}
// Cache other assets for up to 24 hours, but set Last-Modified too
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
w.Header().Set("Cache-Control", "public, max-age=86400")
}
// SvelteKit creates both sidecars for every asset with a precompressed extension
_, ok := precompressedExtensions[path.Ext(r.URL.Path)]
if ok {
// Add a "Vary" with "Accept-Encoding" so CDNs are aware that content is pre-compressed
w.Header().Add("Vary", "Accept-Encoding")
// Select the encoding if any
ext, ce := selectEncoding(r)
if ext != "" {
// Set the content type explicitly before changing the path
ct := mime.TypeByExtension(path.Ext(r.URL.Path))
if ct != "" {
w.Header().Set("Content-Type", ct)
}
// Make the serve return the encoded content
w.Header().Set("Content-Encoding", ce)
r.URL.Path += "." + ext
}
}
http.FileServer(f.root).ServeHTTP(w, r)
}
func selectEncoding(r *http.Request) (ext string, contentEnc string) {
// Check which available encoding the client prefers
acceptEncoding := r.Header.Get("Accept-Encoding")
if acceptEncoding == "" {
return "", ""
}
// Header can have multiple encodings with optional quality values, e.g. "gzip;q=1.0, br;q=0.8, *;q=0.5"
brWeight, gzipWeight, wildcardWeight := -1.0, -1.0, -1.0
for part := range strings.SplitSeq(acceptEncoding, ",") {
codingAndParams := strings.Split(part, ";")
coding := strings.ToLower(strings.TrimSpace(codingAndParams[0]))
weight := 1.0
for _, param := range codingAndParams[1:] {
key, value, found := strings.Cut(param, "=")
if !found || !strings.EqualFold(strings.TrimSpace(key), "q") {
continue
}
// Parse the quality value
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || parsed < 0 || parsed > 1 {
weight = 0
break
}
weight = parsed
}
switch coding {
case "br":
brWeight = weight
case "gzip":
gzipWeight = weight
case "*":
wildcardWeight = weight
}
}
// Apply the wildcard only to encodings the client did not name explicitly
if brWeight < 0 {
brWeight = wildcardWeight
}
if gzipWeight < 0 {
gzipWeight = wildcardWeight
}
// Prefer brotli when both available encodings have the same quality
if brWeight > 0 && brWeight >= gzipWeight {
return "br", "br"
}
if gzipWeight > 0 {
return "gz", "gzip"
}
return "", ""
}
func isImmutableAsset(r *http.Request) bool {
switch {
// Fonts
case strings.HasPrefix(r.URL.Path, "/fonts/"):
return true
// Compiled SvelteKit assets
case strings.HasPrefix(r.URL.Path, "/_app/immutable/"):
return true
default:
return false
}
}