From b6696f1bfda074d020c473b5347117adfe319ec1 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 3 Sep 2026 14:45:17 -0700 Subject: [PATCH] branding: brand the front page's link card and let nodes upload its image The default OpenGraph card carried Stream.place copy and the bundled banner regardless of branding. Its title, site name and description now come from siteTitle and siteDescription, and /linkbanner.png serves a new linkBanner branding asset (PNG/JPEG/WebP up to 2MB, 1200x630 suggested) with its real content type, falling back to the bundled banner. The Branding screen gains an upload row for it. Claude-Session: https://claude.ai/code/session_014aPQ5yqG9QFxKnbwQfCYKa --- js/app/components/settings/branding-admin.tsx | 54 ++++++++++++++ js/i18n/locales/en-US/settings.ftl | 4 ++ js/i18n/public/locales/en-US/settings.json | 4 ++ js/web/public/locales/en-US/settings.json | 4 ++ pkg/api/api.go | 6 ++ pkg/linking/linking.go | 72 ++++++++++--------- pkg/spxrpc/place_stream_branding.go | 30 ++++++++ 7 files changed, 139 insertions(+), 35 deletions(-) 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) +} -- 2.51.2