Ctrl K
ComunidadGuías…

server/frontend/frontend.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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"