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 }}