From 2741a1cac718d87e832a5cd478bae435f1efa2c9 Mon Sep 17 00:00:00 2001 From: Gordon Bleux <33967640+UiP9AV6Y@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:00:12 +0100 Subject: [PATCH] feat(web): render index.html as template to inject HTML tags render the SPA differently based on the route. if a page is available as RSS feed, inject a link element into the head element of the HTML variant. this ensures feeds are discoverable. this change replaces the middleware with custom router handlers to serve either assets from the embedded filesystem or the SPA document. closes #4276 --- server/router/frontend/frontend.go | 82 +++++++++++++++++++----------- server/router/frontend/template.go | 71 ++++++++++++++++++++++++++ server/server.go | 4 +- web/index.html | 13 +++-- 4 files changed, 136 insertions(+), 34 deletions(-) create mode 100644 server/router/frontend/template.go diff --git a/server/router/frontend/frontend.go b/server/router/frontend/frontend.go index 1e54ffb6f..918f652a4 100644 --- a/server/router/frontend/frontend.go +++ b/server/router/frontend/frontend.go @@ -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 } diff --git a/server/router/frontend/template.go b/server/router/frontend/template.go new file mode 100644 index 000000000..9709f674d --- /dev/null +++ b/server/router/frontend/template.go @@ -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 +} diff --git a/server/server.go b/server/server.go index a1fbc1ffe..96ed046a5 100644 --- a/server/server.go +++ b/server/server.go @@ -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("") diff --git a/web/index.html b/web/index.html index 2f65b7630..5aea6d82d 100644 --- a/web/index.html +++ b/web/index.html @@ -8,9 +8,16 @@ - - - Memos + + {{ .Title | html }}