diff --git a/js/app/components/settings/branding-admin.tsx b/js/app/components/settings/branding-admin.tsx index 4d7cc0c5..9cd91ac1 100644 --- a/js/app/components/settings/branding-admin.tsx +++ b/js/app/components/settings/branding-admin.tsx @@ -62,6 +62,7 @@ export function BrandingAdmin() { const currentLogo = useBrandingAsset("mainLogo"); const currentFavicon = useBrandingAsset("favicon"); const currentSidebarBg = useSidebarBackgroundImage(); + const currentLinkBanner = useBrandingAsset("linkBanner"); const currentLegalLinks = useBrandingAsset("legalLinks"); // parse legal links @@ -1368,6 +1369,59 @@ export function BrandingAdmin() { + + + + {t("branding-link-banner")} + + + {currentLinkBanner?.data && ( + <> + + + {currentLinkBanner?.width || "unknown"} x{" "} + {currentLinkBanner?.height || "unknown"} + + + )} + + + + + + + {Platform.OS !== "web" && ( diff --git a/js/i18n/locales/en-US/settings.ftl b/js/i18n/locales/en-US/settings.ftl index 68cc9469..4e085cdd 100644 --- a/js/i18n/locales/en-US/settings.ftl +++ b/js/i18n/locales/en-US/settings.ftl @@ -265,6 +265,10 @@ branding-delete-logo = Delete Logo branding-upload-favicon = Upload Favicon branding-delete-favicon = Delete Favicon branding-upload-background = Upload Background +branding-link-banner = Link Preview Image +branding-link-banner-description = The image shown when the site's link is shared (OpenGraph / Twitter card). PNG, JPEG or WebP, 1200 x 630 recommended, max 2MB. The card's title and description come from the Site Title and Site Description above. +branding-upload-link-banner = Upload Image +branding-delete-link-banner = Remove Image branding-delete-background = Delete Background branding-web-only = Image uploads are only available on web. diff --git a/js/i18n/public/locales/en-US/settings.json b/js/i18n/public/locales/en-US/settings.json index 26cf0c0e..ca35d60d 100644 --- a/js/i18n/public/locales/en-US/settings.json +++ b/js/i18n/public/locales/en-US/settings.json @@ -203,6 +203,10 @@ "branding-upload-favicon": "Upload Favicon", "branding-delete-favicon": "Delete Favicon", "branding-upload-background": "Upload Background", + "branding-link-banner": "Link Preview Image", + "branding-link-banner-description": "The image shown when the site's link is shared (OpenGraph / Twitter card). PNG, JPEG or WebP, 1200 x 630 recommended, max 2MB. The card's title and description come from the Site Title and Site Description above.", + "branding-upload-link-banner": "Upload Image", + "branding-delete-link-banner": "Remove Image", "branding-delete-background": "Delete Background", "branding-web-only": "Image uploads are only available on web.", "refresh-branding": "Refresh branding assets", diff --git a/js/web/public/locales/en-US/settings.json b/js/web/public/locales/en-US/settings.json index 26cf0c0e..ca35d60d 100644 --- a/js/web/public/locales/en-US/settings.json +++ b/js/web/public/locales/en-US/settings.json @@ -203,6 +203,10 @@ "branding-upload-favicon": "Upload Favicon", "branding-delete-favicon": "Delete Favicon", "branding-upload-background": "Upload Background", + "branding-link-banner": "Link Preview Image", + "branding-link-banner-description": "The image shown when the site's link is shared (OpenGraph / Twitter card). PNG, JPEG or WebP, 1200 x 630 recommended, max 2MB. The card's title and description come from the Site Title and Site Description above.", + "branding-upload-link-banner": "Upload Image", + "branding-delete-link-banner": "Remove Image", "branding-delete-background": "Delete Background", "branding-web-only": "Image uploads are only available on web.", "refresh-branding": "Refresh branding assets", diff --git a/pkg/api/api.go b/pkg/api/api.go index e705bb3b..d2ad3899 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -247,6 +247,12 @@ func (a *StreamplaceAPI) Handler(ctx context.Context) (http.Handler, error) { router.Handler("PATCH", "/xrpc/*resource", xrpcHandler) router.Handler("DELETE", "/xrpc/*resource", xrpcHandler) // i wonder if there's a better way to do this? + router.GET("/linkbanner.png", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + if err := a.XRPCServer.HandleLinkBanner(echo.New().NewContext(r, w)); err != nil { + log.Error(ctx, "error handling linkbanner.png", "error", err) + w.WriteHeader(500) + } + }) router.GET("/favicon.ico", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { err := a.XRPCServer.HandleFaviconICO(echo.New().NewContext(r, w)) if err != nil { diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go index 3c84addf..a22ec244 100644 --- a/pkg/linking/linking.go +++ b/pkg/linking/linking.go @@ -72,6 +72,7 @@ var BrandingAssetList = [...]string{ "infoColor", "infoColorLight", "liveColor", + "linkBanner", } // inlineBrandingImageLimit caps the image assets embedded as data URLs in @@ -366,45 +367,35 @@ func (l *Linker) GenerateDefaultCard(ctx context.Context, u *url.URL, sentryDSN return nil, errors.New("url is nil") } + // The front-page card is the node's own: its siteTitle and + // siteDescription branding, and /linkbanner.png, which serves the + // uploaded linkBanner asset when there is one and the bundled brand + // banner otherwise. thumbURL, _ := url.Parse(u.String()) thumbURL.Path = "/linkbanner.png" - // Define all meta tags - metaTags := []MetaTag{ - // Basic meta - {Type: "name", Key: "description", Content: "Stream.place is open-source livestreaming on the AT Protocol."}, - - // Facebook Meta Tags - {Type: "property", Key: "og:url", Content: u.String()}, - {Type: "property", Key: "og:type", Content: "website"}, - {Type: "property", Key: "og:title", Content: "Stream.place"}, - {Type: "property", Key: "og:description", Content: "Open-source livestreaming on the AT Protocol."}, - {Type: "property", Key: "og:image", Content: thumbURL.String()}, - - // Twitter Meta Tags - {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, - {Type: "property", Key: "twitter:domain", Content: u.Host}, - {Type: "property", Key: "twitter:url", Content: u.String()}, - {Type: "name", Key: "twitter:title", Content: "Stream.place"}, - {Type: "name", Key: "twitter:description", Content: "Open-source livestreaming on the AT Protocol."}, - {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, - } - brandingTitle := "streamplace node" + brandingDescription := "Open-source livestreaming on the AT Protocol." + var brandMetas []MetaTag if l.sdb != nil && l.cli != nil { branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) if err == nil { for i := range branding { val := branding[i] - if val.Key == "siteTitle" && val.Data != nil { - brandingTitle = *val.Data + if val.Data != nil && strings.TrimSpace(*val.Data) != "" { + switch val.Key { + case "siteTitle": + brandingTitle = *val.Data + case "siteDescription": + brandingDescription = *val.Data + } } marshalledJson, err := json.Marshal(val) if err != nil { log.Error(ctx, "error marshalling branding asset", "key", val.Key, "error", err) continue } - metaTags = append(metaTags, MetaTag{ + brandMetas = append(brandMetas, MetaTag{ Type: "name", Key: "internal-brand:" + val.Key, Content: string(marshalledJson), @@ -416,17 +407,28 @@ func (l *Linker) GenerateDefaultCard(ctx context.Context, u *url.URL, sentryDSN } } - // do twitter/og title after - metaTags = append(metaTags, MetaTag{ - Type: "property", - Key: "og:title", - Content: brandingTitle, - }) - metaTags = append(metaTags, MetaTag{ - Type: "name", - Key: "twitter:title", - Content: brandingTitle, - }) + // Define all meta tags + metaTags := []MetaTag{ + // Basic meta + {Type: "name", Key: "description", Content: brandingDescription}, + + // Facebook Meta Tags + {Type: "property", Key: "og:url", Content: u.String()}, + {Type: "property", Key: "og:type", Content: "website"}, + {Type: "property", Key: "og:site_name", Content: brandingTitle}, + {Type: "property", Key: "og:title", Content: brandingTitle}, + {Type: "property", Key: "og:description", Content: brandingDescription}, + {Type: "property", Key: "og:image", Content: thumbURL.String()}, + + // Twitter Meta Tags + {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, + {Type: "property", Key: "twitter:domain", Content: u.Host}, + {Type: "property", Key: "twitter:url", Content: u.String()}, + {Type: "name", Key: "twitter:title", Content: brandingTitle}, + {Type: "name", Key: "twitter:description", Content: brandingDescription}, + {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, + } + metaTags = append(metaTags, brandMetas...) // at-tags: the site itself is identified by this node's did:web; there's // no single author or canonical record for the front page diff --git a/pkg/spxrpc/place_stream_branding.go b/pkg/spxrpc/place_stream_branding.go index 6613e1fc..f16e2c0b 100644 --- a/pkg/spxrpc/place_stream_branding.go +++ b/pkg/spxrpc/place_stream_branding.go @@ -229,6 +229,8 @@ func (s *Server) handlePlaceStreamBrandingUpdateBlob(ctx context.Context, input maxSize := 500 * 1024 // 500KB default for logos if input.Key == "favicon" { maxSize = 100 * 1024 // 100KB for favicons + } else if input.Key == "linkBanner" { + maxSize = 2 * 1024 * 1024 // 2MB for the OpenGraph banner (1200x630) } else if brandingTextKeys[input.Key] { maxSize = 1024 // 1KB for text values } @@ -331,3 +333,31 @@ func (s *Server) HandleFaviconICO(c echo.Context) error { return c.Blob(http.StatusOK, mimeType, data) } + +// HandleLinkBanner serves /linkbanner.png, the image behind the front +// page's OpenGraph card: the node's uploaded linkBanner branding asset with +// its real content type (link crawlers refuse application/octet-stream), +// else the bundled brand banner. Branding is public even on a private node. +func (s *Server) HandleLinkBanner(c echo.Context) error { + ctx := c.Request().Context() + data, mimeType, _, _, err := s.GetBrandingBlob(ctx, s.cli.BroadcasterDID(), "linkBanner") + if err == nil && len(data) > 0 && strings.HasPrefix(mimeType, "image/") { + c.Response().Header().Set("Cache-Control", "public, max-age=300") + return c.Blob(http.StatusOK, mimeType, data) + } + distFiles, fsErr := app.Files() + if fsErr != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to load link banner") + } + f, fsErr := distFiles.Open("linkbanner.png") + if fsErr != nil { + return echo.NewHTTPError(http.StatusNotFound, "link banner not found") + } + defer f.Close() + bs, fsErr := io.ReadAll(f) + if fsErr != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to read link banner") + } + c.Response().Header().Set("Cache-Control", "public, max-age=300") + return c.Blob(http.StatusOK, "image/png", bs) +}