mirror of https://github.com/usememos/memos.git
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
This commit is contained in:
parent
332d32bd35
commit
2741a1cac7
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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("")
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue