From 7a499a930287d22e2f0d1a30bad3c59b08b131aa Mon Sep 17 00:00:00 2001 From: Ben Pate Date: Fri, 8 Nov 2024 15:03:59 -0700 Subject: [PATCH] Enable OEmbed in custom Templates --- handler/oembed.go | 185 +++++++++++++++++++++++++++++++++++++++-- handler/oembed_test.go | 36 ++++++++ handler/utilities.go | 10 +++ model/domain.go | 1 + model/template.go | 15 ++++ server.go | 2 +- 6 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 handler/oembed_test.go diff --git a/handler/oembed.go b/handler/oembed.go index c939fc790..43ab0ff99 100644 --- a/handler/oembed.go +++ b/handler/oembed.go @@ -1,22 +1,191 @@ package handler import ( - "github.com/EmissarySocial/emissary/server" + "net/url" + "regexp" + "strings" + + "github.com/EmissarySocial/emissary/build" + "github.com/EmissarySocial/emissary/domain" + "github.com/EmissarySocial/emissary/model" "github.com/benpate/derp" - "github.com/labstack/echo/v4" + "github.com/benpate/rosetta/convert" + "github.com/benpate/rosetta/mapof" + "github.com/benpate/steranko" ) // GetOEmbed will provide an OEmbed service to be used exclusively by websites on this domain. -func GetOEmbed(factoryManager *server.Factory) echo.HandlerFunc { +func GetOEmbed(ctx *steranko.Context, factory *domain.Factory) error { + + const location = "handler.GetOEmbed" + + // Verify that the URL is valid + token := ctx.QueryParam("url") + format := ctx.QueryParam("format") + + parsedToken, err := url.Parse(token) + + if err != nil { + return derp.Wrap(err, location, "Invalid URL") + } + + // Verify that the URL is on this domain + if parsedToken.Host != factory.Hostname() { + return derp.NewNotFoundError(location, "Invalid URL", "URL does not match domain") + } + + // Load the OEmbed result + result, err := getOEmbed_record(ctx, factory, parsedToken.Path) + + if err != nil { + return derp.Wrap(err, location, "Error loading OEmbed record") + } + + // Return the result in the requested format + if format == "xml" { + return ctx.XML(200, result) + } + + return ctx.JSON(200, result) +} + +func getOEmbed_record(ctx *steranko.Context, factory *domain.Factory, path string) (mapof.Any, error) { + + // Parse the path as either a Stream or a User + path = strings.TrimPrefix(path, "/") + + // If the path begins with "@", then it is a User + if strings.HasPrefix(path, "@") { + path = strings.TrimPrefix(path, "@") + return getOEmbed_User(factory, path) + } + + // Otherwise, the path is for a Stream + return getOEmbed_Stream(ctx, factory, path) +} + +func getOEmbed_Stream(ctx *steranko.Context, factory *domain.Factory, token string) (mapof.Any, error) { + + const location = "handler.getOEmbed_Stream" + + // Load the Stream + streamService := factory.Stream() + stream := model.NewStream() + + if err := streamService.LoadByToken(token, &stream); err != nil { + return mapof.Any{}, derp.Wrap(err, location, "Error loading stream from database") + } + + // Export the stream as an OEmbed object + // Export the user as an OEmbed object + // Get the domain + domain := factory.Domain().Get() + + // Export the user as an OEmbed object + result := mapof.Any{ + "version": "1.0", + "type": "link", + "title": stream.Label, + "cache_age": 86400, // cache for 24 hours + "provider_name": domain.Label, + "provider_url": domain.Host(), + } + + if iconURL := stream.IconURL; iconURL != "" { + result["thumbnail_url"] = iconURL + ".webp?height=300&width=300" + result["thumbnail_height"] = 300 + result["thumbnail_width"] = 300 + } + + // Special case for Templates that define HTML content of OEmbed + templateService := factory.Template() + if template, err := templateService.Load(stream.TemplateID); err == nil { + + if htmlTemplate := template.GetOEmbed(); htmlTemplate != nil { + + if builder, err := build.NewStream(factory, ctx.Request(), ctx.Response(), template, &stream, "view"); err == nil { + + html := executeTemplate(htmlTemplate, builder) + + if html != "" { + + // Enable this line for nice-ish debugging + // return nil, ctx.HTML(200, html) + + result["html"] = html + result["type"] = "rich" - return func(ctx echo.Context) error { + height, width := getOEmbed_heightAndWidth(html) - factory, err := factoryManager.ByContext(ctx) + if height > 0 { + result["height"] = height + } - if err != nil { - return derp.Wrap(err, "handlers.GetOEmbed", "Can't get domain") + if width > 0 { + result["width"] = width + } + } + } } + } + + return result, nil +} + +func getOEmbed_User(factory *domain.Factory, token string) (mapof.Any, error) { + + const location = "handler.getOEmbed_User" + + // Load the User + userService := factory.User() + user := model.NewUser() + + if err := userService.LoadByToken(token, &user); err != nil { + return mapof.Any{}, derp.Wrap(err, location, "Error loading user from database") + } + + // Get the domain + domain := factory.Domain().Get() + + // Export the user as an OEmbed object + result := mapof.Any{ + "version": "1.0", + "type": "link", + "title": "@" + domain.Hostname + "@" + user.Username, + "cache_age": 86400, // cache for 24 hours + "provider_name": domain.Label, + "provider_url": domain.Host(), + } - return ctx.JSON(200, factory.Hostname()) + if iconURL := user.ActivityPubIconURL(); iconURL != "" { + result["thumbnail_url"] = iconURL + ".webp?height=300&width=300" + result["thumbnail_height"] = 300 + result["thumbnail_width"] = 300 } + + return result, nil +} + +func getOEmbed_heightAndWidth(html string) (int, int) { + + var height int + var width int + + // Find height + findHeight := regexp.MustCompile(`height:\s*(\d+)px;`) + heightStrings := findHeight.FindStringSubmatch(html) + + if len(heightStrings) == 2 { + height = convert.Int(heightStrings[1]) + } + + // Find width + findWidth := regexp.MustCompile(`width:\s*(\d+)px;`) + widthStrings := findWidth.FindStringSubmatch(html) + + if len(widthStrings) == 2 { + width = convert.Int(widthStrings[1]) + } + + return height, width } diff --git a/handler/oembed_test.go b/handler/oembed_test.go new file mode 100644 index 000000000..dd7e7f795 --- /dev/null +++ b/handler/oembed_test.go @@ -0,0 +1,36 @@ +package handler + +import ( + "regexp" + "testing" + + "github.com/benpate/rosetta/convert" + "github.com/stretchr/testify/require" +) + +func TestOEmbed(t *testing.T) { + + var height int + var width int + + html := `
Here's some stuff
` + findWidth := regexp.MustCompile(`max-width:\s*(\d+)px;`) + findHeight := regexp.MustCompile(`max-height:\s*(\d+)px;`) + + heightStrings := findHeight.FindStringSubmatch(html) + widthStrings := findWidth.FindStringSubmatch(html) + + if len(heightStrings) == 2 { + height = convert.Int(heightStrings[1]) + } + + t.Log(height) + require.Equal(t, 100, height) + + if len(widthStrings) == 2 { + width = convert.Int(widthStrings[1]) + } + + t.Log(width) + require.Equal(t, 200, width) +} diff --git a/handler/utilities.go b/handler/utilities.go index 664abce66..aedbd99e3 100644 --- a/handler/utilities.go +++ b/handler/utilities.go @@ -1,6 +1,8 @@ package handler import ( + "bytes" + "html/template" "net/http" "net/url" @@ -128,3 +130,11 @@ func inlineError(ctx echo.Context, errorMessage string) error { return ctx.String(http.StatusOK, errorMessage) } + +func executeTemplate(template *template.Template, data any) string { + var buffer bytes.Buffer + if err := template.Execute(&buffer, data); err != nil { + return "" + } + return buffer.String() +} diff --git a/model/domain.go b/model/domain.go index 1f85e0fd8..922d6849a 100644 --- a/model/domain.go +++ b/model/domain.go @@ -66,6 +66,7 @@ func (domain *Domain) HasRegistrationForm() bool { return domain.RegistrationID != "" } +// Host returns a usable URL for this domain, including the HTTP(S) protocol and hostname func (domain *Domain) Host() string { return domainlib.Protocol(domain.Hostname) + domain.Hostname } diff --git a/model/template.go b/model/template.go index eb5bf2dae..347291966 100644 --- a/model/template.go +++ b/model/template.go @@ -190,3 +190,18 @@ func (template *Template) Inherit(parent *Template) { } } } + +/****************************************** + * OEmbed Methods + ******************************************/ + +// HasOEmbed returns TRUE if this Template has an OEmbed template +func (template *Template) HasOEmbed() bool { + return template.HTMLTemplate.Lookup("oembed") != nil +} + +// GetOEmbed returns the OEmbed template for this Template +// If no OEmbed template is found, then nil is returned +func (template *Template) GetOEmbed() *template.Template { + return template.HTMLTemplate.Lookup("oembed") +} diff --git a/server.go b/server.go index bc638fb46..329d29ba9 100644 --- a/server.go +++ b/server.go @@ -201,7 +201,7 @@ func makeStandardRoutes(factory *server.Factory, e *echo.Echo) { e.GET("/.well-known/host-meta", handler.GetHostMeta(factory)) e.GET("/.well-known/host-meta.json", handler.GetHostMetaJSON(factory)) e.GET("/.well-known/nodeinfo", handler.GetNodeInfo(factory)) - e.GET("/.well-known/oembed", handler.GetOEmbed(factory)) + e.GET("/.well-known/oembed", handler.WithFactory(factory, handler.GetOEmbed)) e.GET("/.well-known/webfinger", handler.GetWebfinger(factory)) e.GET("/nodeinfo/2.0", handler.GetNodeInfo20(factory)) e.GET("/nodeinfo/2.1", handler.GetNodeInfo21(factory))