Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 52 additions & 30 deletions server/router/frontend/frontend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
}
71 changes: 71 additions & 0 deletions server/router/frontend/template.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 3 additions & 1 deletion server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("")

Expand Down
13 changes: 10 additions & 3 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down