package frontend import ( "context" "embed" "fmt" "io/fs" "net/http" "strings" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/yourselfhosted/slash/internal/util" storepb "github.com/yourselfhosted/slash/proto/gen/store" "github.com/yourselfhosted/slash/server/metric" "github.com/yourselfhosted/slash/server/profile" "github.com/yourselfhosted/slash/store" ) //go:embed dist var embeddedFiles embed.FS const ( headerMetadataPlaceholder = "" ) 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(ctx context.Context, e *echo.Echo) { // Use echo static middleware to serve the built dist folder. // Reference: https://github.com/labstack/echo/blob/master/middleware/static.go e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ HTML5: true, Filesystem: getFileSystem("dist"), Skipper: func(c echo.Context) bool { return util.HasPrefixes(c.Path(), "/api", "/slash.api.v1", "/robots.txt", "/sitemap.xml", "/s/:shortcutName", "/c/:collectionName") }, })) g := e.Group("assets") // Use echo gzip middleware to compress the response. // Reference: https://echo.labstack.com/docs/middleware/gzip g.Use(middleware.GzipWithConfig(middleware.GzipConfig{ Skipper: func(c echo.Context) bool { return util.HasPrefixes(c.Path(), "/api", "/slash.api.v1", "/robots.txt", "/sitemap.xml", "/s/:shortcutName", "/c/:collectionName") }, Level: 5, })) g.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { c.Response().Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable") return next(c) } }) g.Use(middleware.StaticWithConfig(middleware.StaticConfig{ HTML5: true, Filesystem: getFileSystem("dist/assets"), Skipper: func(c echo.Context) bool { return util.HasPrefixes(c.Path(), "/api", "/slash.api.v1", "/robots.txt", "/sitemap.xml", "/s/:shortcutName", "/c/:collectionName") }, })) s.registerRoutes(e) s.registerFileRoutes(ctx, e) } func (s *FrontendService) registerRoutes(e *echo.Echo) { rawIndexHTML := getRawIndexHTML() e.GET("/s/:shortcutName", func(c echo.Context) error { ctx := c.Request().Context() shortcutName := c.Param("shortcutName") shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{ Name: &shortcutName, }) if err != nil { return c.HTML(http.StatusOK, rawIndexHTML) } if shortcut == nil { return c.HTML(http.StatusOK, rawIndexHTML) } metric.Enqueue("shortcut view") // Inject shortcut metadata into `index.html`. indexHTML := strings.ReplaceAll(rawIndexHTML, headerMetadataPlaceholder, generateShortcutMetadata(shortcut).String()) return c.HTML(http.StatusOK, indexHTML) }) e.GET("/c/:collectionName", func(c echo.Context) error { ctx := c.Request().Context() collectionName := c.Param("collectionName") collection, err := s.Store.GetCollection(ctx, &store.FindCollection{ Name: &collectionName, }) if err != nil { return c.HTML(http.StatusOK, rawIndexHTML) } if collection == nil { return c.HTML(http.StatusOK, rawIndexHTML) } metric.Enqueue("collection view") // Inject collection metadata into `index.html`. indexHTML := strings.ReplaceAll(rawIndexHTML, headerMetadataPlaceholder, generateCollectionMetadata(collection).String()) return c.HTML(http.StatusOK, indexHTML) }) } func (s *FrontendService) registerFileRoutes(ctx context.Context, e *echo.Echo) { instanceURL := s.Profile.InstanceURL if instanceURL == "" { return } e.GET("/robots.txt", func(c echo.Context) error { robotsTxt := fmt.Sprintf(`User-agent: * Allow: / Host: %s Sitemap: %s/sitemap.xml`, instanceURL, instanceURL) return c.String(http.StatusOK, robotsTxt) }) e.GET("/sitemap.xml", func(c echo.Context) error { urlsets := []string{} // Append shortcut list. shortcuts, err := s.Store.ListShortcuts(ctx, &store.FindShortcut{ VisibilityList: []store.Visibility{store.VisibilityPublic}, }) if err != nil { return err } for _, shortcut := range shortcuts { urlsets = append(urlsets, fmt.Sprintf(`%s/s/%s`, instanceURL, shortcut.Name)) } // Append collection list. collections, err := s.Store.ListCollections(ctx, &store.FindCollection{ VisibilityList: []store.Visibility{store.VisibilityPublic}, }) if err != nil { return err } for _, collection := range collections { urlsets = append(urlsets, fmt.Sprintf(`%s/c/%s`, instanceURL, collection.Name)) } sitemap := fmt.Sprintf(`%s`, strings.Join(urlsets, "\n")) return c.XMLBlob(http.StatusOK, []byte(sitemap)) }) } func getFileSystem(path string) http.FileSystem { fs, err := fs.Sub(embeddedFiles, path) if err != nil { panic(err) } return http.FS(fs) } func generateShortcutMetadata(shortcut *storepb.Shortcut) *Metadata { metadata := getDefaultMetadata() title, description := shortcut.Title, shortcut.Description if shortcut.OgMetadata != nil { if shortcut.OgMetadata.Title != "" { title = shortcut.OgMetadata.Title } if shortcut.OgMetadata.Description != "" { description = shortcut.OgMetadata.Description } metadata.ImageURL = shortcut.OgMetadata.Image } metadata.Title = title metadata.Description = description return metadata } func generateCollectionMetadata(collection *storepb.Collection) *Metadata { metadata := getDefaultMetadata() metadata.Title = collection.Title metadata.Description = collection.Description return metadata } func getRawIndexHTML() string { bytes, _ := embeddedFiles.ReadFile("dist/index.html") return string(bytes) } type Metadata struct { Title string Description string ImageURL string } func getDefaultMetadata() *Metadata { return &Metadata{ Title: "Slash", } } func (m *Metadata) String() string { metadataList := []string{ fmt.Sprintf(`%s`, m.Title), fmt.Sprintf(``, m.Description), fmt.Sprintf(``, m.Title), fmt.Sprintf(``, m.Description), fmt.Sprintf(``, m.ImageURL), ``, // Twitter related fields. fmt.Sprintf(``, m.Title), fmt.Sprintf(``, m.Description), fmt.Sprintf(``, m.ImageURL), } return strings.Join(metadataList, "\n") }