server/frontend/frontend.go
Rama main · réplica de lectura del repositorio original.
package frontend
import (
"context"
"embed"
"encoding/xml"
"io"
"io/fs"
"net/http"
"path"
"strings"
"github.com/pkg/errors"
"github.com/usememos/memos/internal/httphandler"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/store"
)
//go:embed dist/*
var embeddedFiles embed.FS
const (
frontendHTMLCacheControl = "no-cache, no-store, must-revalidate"
frontendStaticAssetCacheControl = "public, max-age=3600"
frontendHashedAssetCacheControl = "public, max-age=2592000, immutable"
)
type FrontendService struct {
Profile *profile.Profile
Store *store.Store
}
func NewFrontendService(profile *profile.Profile, store *store.Store) *FrontendService {
return &FrontendService{
Profile: profile,
Store: store,
}
}
func (s *FrontendService) Serve(_ context.Context, mux *http.ServeMux, fallback http.Handler) {
mux.Handle("/robots.txt", httphandler.GET(httphandler.Func(s.getRobotsTXT), nil))
mux.Handle("/sitemap.xml", httphandler.GET(httphandler.Func(s.getSitemapXML), nil))
mux.Handle("/", s.Handler(fallback))
}
// Handler serves static files and SPA routes, passing API namespaces to fallback.
func (*FrontendService) Handler(fallback http.Handler) http.Handler {
frontendFS := getFileSystem("dist")
return httphandler.Func(func(w http.ResponseWriter, r *http.Request) error {
requestPath := r.URL.Path
if shouldSkipFrontendStatic(requestPath) {
fallback.ServeHTTP(w, r)
return nil
}
setFrontendCacheHeaders(w, requestPath)
filename := strings.TrimPrefix(path.Clean("/"+requestPath), "/")
if filename == "" {
filename = "index.html"
}
info, err := fs.Stat(frontendFS, filename)
if err == nil && info.IsDir() {
filename = path.Join(filename, "index.html")
_, err = fs.Stat(frontendFS, filename)
}
if err != nil {
if !shouldServeFrontendHTML(requestPath) {
return &httphandler.Error{Code: http.StatusNotFound, Message: http.StatusText(http.StatusNotFound)}
}
filename = "index.html"
}
// ServeContent avoids FileServer's /index.html and directory redirects.
file, err := frontendFS.Open(filename)
if err != nil {
return err
}
defer file.Close()
info, err = file.Stat()
if err != nil {
return err
}
content, ok := file.(io.ReadSeeker)
if !ok {
return errors.New("frontend file does not support seeking")
}
http.ServeContent(w, r, info.Name(), info.ModTime(), content)
return nil
})
}
func shouldSkipFrontendStatic(requestPath string) bool {
if requestPath == "/robots.txt" || requestPath == "/sitemap.xml" {
return true
}
return hasPathPrefix(requestPath, "/api") ||
hasPathPrefix(requestPath, "/file") ||
// RFC 8615 reserves this namespace for protocol metadata. The SPA
// fallback already refuses it (see shouldServeFrontendHTML), but the
// 404 that results would otherwise inherit the static asset cache
// policy and be stored by clients and CDNs for an hour.
hasPathPrefix(requestPath, "/.well-known") ||
requestPath == "/memos.api" ||
strings.HasPrefix(requestPath, "/memos.api.")
}
// SetCacheHeaders applies the frontend cache policy before route dispatch.
// Dynamic namespaces and discovery routes manage their own cache headers.
func SetCacheHeaders(w http.ResponseWriter, r *http.Request) {
if !shouldSkipFrontendStatic(r.URL.Path) {
setFrontendCacheHeaders(w, r.URL.Path)
}
}
func setFrontendCacheHeaders(w http.ResponseWriter, requestPath string) {
if shouldServeFrontendHTML(requestPath) {
w.Header().Set("Cache-Control", frontendHTMLCacheControl)
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
return
}
cacheControl := frontendStaticAssetCacheControl
if strings.HasPrefix(requestPath, "/assets/") {
cacheControl = frontendHashedAssetCacheControl
}
w.Header().Set("Cache-Control", cacheControl)
}
// shouldServeFrontendHTML reports whether an unknown path is an SPA route
// that loads index.html. Paths with a file extension are assets, and paths
// with a hidden segment are never app routes: no username, memo UID, or fixed
// route begins with a dot. This keeps the SPA out of "/.well-known/", which
// RFC 8615 reserves for registered metadata that clients probe and expect a
// 404 for when the server does not offer it, and out of probes like "/.git/".
func shouldServeFrontendHTML(requestPath string) bool {
if requestPath == "/" || requestPath == "/index.html" {
return true
}
return path.Ext(requestPath) == "" && !strings.Contains(requestPath, "/.")
}
func hasPathPrefix(requestPath, prefix string) bool {
return requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/")
}
func getFileSystem(path string) fs.FS {
sub, err := fs.Sub(embeddedFiles, path)
if err != nil {
panic(err)
}
return sub
}
func (s *FrontendService) getRobotsTXT(w http.ResponseWriter, r *http.Request) error {
if err := s.requirePublicAccess(r.Context()); err != nil {
return err
}
instanceURL, err := normalizeInstanceURL(s.Profile.InstanceURL)
if err != nil {
return err
}
robotsTXT := strings.Join([]string{
"User-agent: *",
"Allow: /",
"Host: " + instanceURL,
"Sitemap: " + instanceURL + "/sitemap.xml",
}, "\n")
return httphandler.Text(w, http.StatusOK, robotsTXT)
}
func (s *FrontendService) getSitemapXML(w http.ResponseWriter, r *http.Request) error {
if err := s.requirePublicAccess(r.Context()); err != nil {
return err
}
instanceURL, err := normalizeInstanceURL(s.Profile.InstanceURL)
if err != nil {
return err
}
normal := store.Normal
memos, err := s.Store.ListMemos(r.Context(), &store.FindMemo{
RowStatus: &normal,
VisibilityList: []store.Visibility{store.Public},
Access: &store.MemoAccessScope{AllowPublic: true},
ExcludeComments: true,
})
if err != nil {
return errors.Wrap(err, "failed to list public memos for sitemap")
}
// The set of public memo URLs changes with audience updates. Require
// revalidation so a revoked URL is not retained by an intermediary cache.
w.Header().Set("Cache-Control", "public, no-cache")
urls := make([]sitemapURL, 0, len(memos))
for _, memo := range memos {
urls = append(urls, sitemapURL{
Loc: instanceURL + "/memos/" + memo.UID,
})
}
w.Header().Set("Content-Type", "application/xml; charset=UTF-8")
w.WriteHeader(http.StatusOK)
if _, err := io.WriteString(w, xml.Header); err != nil {
return err
}
return xml.NewEncoder(w).Encode(sitemapURLSet{
XMLNS: sitemapXMLNamespace,
URLs: urls,
})
}
func (s *FrontendService) requirePublicAccess(ctx context.Context) error {
allowsAnonymous, err := s.Store.AllowsAnonymousAccess(ctx)
if err != nil {
return &httphandler.Error{Code: http.StatusInternalServerError, Message: "failed to get instance access policy", Cause: err}
}
if !allowsAnonymous {
return &httphandler.Error{Code: http.StatusNotFound, Message: "public instance discovery is unavailable"}
}
return nil
}
func normalizeInstanceURL(instanceURL string) (string, error) {
instanceURL = strings.TrimRight(instanceURL, "/")
if instanceURL == "" {
return "", &httphandler.Error{Code: http.StatusNotFound, Message: "instance URL is not configured"}
}
return instanceURL, nil
}
type sitemapURLSet struct {
XMLName xml.Name `xml:"urlset"`
XMLNS string `xml:"xmlns,attr"`
URLs []sitemapURL `xml:"url"`
}
type sitemapURL struct {
Loc string `xml:"loc"`
}
//nolint:revive
const sitemapXMLNamespace = "http://www.sitemaps.org/schemas/sitemap/0.9"