This commit is contained in:
Gordon Bleux 2025-12-29 16:56:23 +08:00 committed by GitHub
commit 8a19d64aea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 136 additions and 34 deletions

View file

@ -3,14 +3,12 @@ package frontend
import (
"context"
"embed"
"errors"
"io/fs"
"net/http"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/store"
)
@ -29,41 +27,65 @@ func NewFrontendService(profile *profile.Profile, store *store.Store) *FrontendS
}
}
func (*FrontendService) Serve(_ context.Context, e *echo.Echo) {
skipper := func(c echo.Context) bool {
// Skip API routes.
if util.HasPrefixes(c.Path(), "/api", "/memos.api.v1") {
return true
}
// For index.html and root path, set no-cache headers to prevent browser caching
// This prevents sensitive data from being accessible via browser back button after logout
if c.Path() == "/" || c.Path() == "/index.html" {
c.Response().Header().Set(echo.HeaderCacheControl, "no-cache, no-store, must-revalidate")
c.Response().Header().Set("Pragma", "no-cache")
c.Response().Header().Set("Expires", "0")
return false
func (*FrontendService) Serve(_ context.Context, e *echo.Echo) error {
fs, err := fs.Sub(embeddedFiles, "dist")
if err != nil {
return err
}
idx, err := parseFSTemplate(fs, "index.html")
if err != nil {
return err
}
htmlMeta := map[string]string{
"viewport": "width=device-width, initial-scale=1, user-scalable=no",
}
static := echo.StaticDirectoryHandler(fs, false)
index := templateHandler(idx, templateConfig{
MetaData: htmlMeta,
})
exploreFeedTitle := func(_ echo.Context) string {
return "Public Memos"
}
userFeedTitle := func(c echo.Context) string {
u := c.Param("username")
return u + " Memos"
}
assets := func(c echo.Context) error {
p := c.Request().URL.Path
if p == "/" || p == "/index.html" {
// do not serve index.html from the filesystem
// but serve it as rendered template instead
return index(c)
}
// Set Cache-Control header for static assets.
// Since Vite generates content-hashed filenames (e.g., index-BtVjejZf.js),
// we can cache aggressively but use immutable to prevent revalidation checks.
// For frequently redeployed instances, use shorter max-age (1 hour) to avoid
// serving stale assets after redeployment.
c.Response().Header().Set(echo.HeaderCacheControl, "public, max-age=3600, immutable") // 1 hour
return false
}
if err := static(c); err == nil || !errors.Is(err, echo.ErrNotFound) {
return err
}
// Route to serve the main app with HTML5 fallback for SPA behavior.
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Filesystem: getFileSystem("dist"),
HTML5: true, // Enable fallback to index.html
Skipper: skipper,
// fallback to the index document, assuming it is a SPA route
return index(c)
}
e.GET("/", index)
e.GET("/*", assets)
e.GET("/explore", templateHandler(idx, templateConfig{
MetaData: htmlMeta,
InjectFeedURL: true,
ResolveFeedTitle: exploreFeedTitle,
}))
e.GET("/u/:username", templateHandler(idx, templateConfig{
MetaData: htmlMeta,
InjectFeedURL: true,
ResolveFeedTitle: userFeedTitle,
}))
}
func getFileSystem(path string) http.FileSystem {
fs, err := fs.Sub(embeddedFiles, path)
if err != nil {
panic(err)
}
return http.FS(fs)
return nil
}

View file

@ -0,0 +1,71 @@
package frontend
import (
"io/fs"
"net/http"
"text/template"
echo "github.com/labstack/echo/v4"
)
var templateFuncs = template.FuncMap{
"default": templateFuncDefault,
}
type templateData struct {
FeedURL string
FeedTitle string
Title string
MetaData map[string]string
}
type templateConfig struct {
InjectFeedURL bool
ResolveFeedTitle func(c echo.Context) string
MetaData map[string]string
}
func templateHandler(tpl *template.Template, cfg templateConfig) echo.HandlerFunc {
return func(c echo.Context) error {
data := &templateData{
Title: "Memos",
MetaData: cfg.MetaData,
}
if cfg.InjectFeedURL {
if cfg.ResolveFeedTitle != nil {
data.FeedTitle = cfg.ResolveFeedTitle(c)
}
data.FeedURL = c.Request().URL.JoinPath("rss.xml").String()
}
header := c.Response().Header()
if header.Get(echo.HeaderContentType) == "" {
header.Set(echo.HeaderContentType, echo.MIMETextHTMLCharsetUTF8)
}
// Prevent sensitive data from being accessible via browser back button after logout
header.Set(echo.HeaderCacheControl, "no-cache, no-store, must-revalidate")
header.Set("Pragma", "no-cache")
header.Set("Expires", "0")
if err := tpl.Execute(c.Response().Writer, data); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "unable to render template").SetInternal(err)
}
return nil
}
}
func parseFSTemplate(root fs.FS, file string) (*template.Template, error) {
return template.New(file).Funcs(templateFuncs).ParseFS(root, file)
}
func templateFuncDefault(fallback, value string) string {
if value != "" {
return value
}
return fallback
}

View file

@ -63,7 +63,9 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
})
// Serve frontend static files.
frontend.NewFrontendService(profile, store).Serve(ctx, echoServer)
if err := frontend.NewFrontendService(profile, store).Serve(ctx, echoServer); err != nil {
return nil, errors.Wrap(err, "unable to set up frontend service")
}
rootGroup := echoServer.Group("")

View file

@ -8,9 +8,16 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/webp" href="/logo.webp" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<!-- memos.metadata.head -->
<title>Memos</title>
<!-- {{ printf "memos.metadata.head %s>" "--" }}
{{- with .FeedURL }}
<link rel="alternate" type="application/rss+xml" href="{{ . }}" title="{{ $.FeedTitle | default $.Title | html }}" />
{{ end }}
{{- range $name, $content := .MetaData }}
<meta name="{{ $name | html }}" content="{{ $content | html }}" />
{{ end }}
{{ printf "<%s" "!--" }} -->
<title>{{ .Title | html }}</title>
</head>
<body class="text-base w-full min-h-svh">
<div id="root" class="relative w-full min-h-full"></div>