diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go index 7cf71e16..bd0de3ed 100644 --- a/pkg/linking/linking.go +++ b/pkg/linking/linking.go @@ -8,6 +8,7 @@ import ( "fmt" "log" "net/url" + "strings" "golang.org/x/net/html" "stream.place/streamplace/pkg/config" @@ -56,6 +57,31 @@ var BrandingAssetList = [...]string{ "legalLinks", } +// atTags returns meta tags implementing the at-tags proposal +// (https://tangled.org/chrisshank.com/at-tags), which maps web pages back to +// atproto identities and records via tags in the page +// : +// +// - at:canonical — the AT URI of the record this page canonically maps to +// (pass "" to omit, e.g. for pages that don't represent a record) +// - at:author — the atproto identity of the page's author, as an +// at:// URI (pass "" to omit) +// - at:me — the identity of the overall website, i.e. this node's +// did:web, when a broadcaster host is configured +func (l *Linker) atTags(canonicalURI string, authorDID string) []MetaTag { + tags := make([]MetaTag, 0, 3) + if strings.HasPrefix(canonicalURI, "at://") { + tags = append(tags, MetaTag{Type: "name", Key: "at:canonical", Content: canonicalURI}) + } + if authorDID != "" { + tags = append(tags, MetaTag{Type: "name", Key: "at:author", Content: "at://" + authorDID}) + } + if l.cli != nil && l.cli.BroadcasterHost != "" { + tags = append(tags, MetaTag{Type: "name", Key: "at:me", Content: "at://did:web:" + l.cli.BroadcasterHost}) + } + return tags +} + // fetch branding assets for a given broadcaster DID func (l *Linker) getBrandingAssets(broadcasterDid string) ([]placestream.BrandingGetBranding_BrandingAsset, error) { ret := make([]placestream.BrandingGetBranding_BrandingAsset, 0) @@ -169,6 +195,10 @@ func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *plac Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), }) + // at-tags: this page canonically maps to the livestream record, authored + // by the streamer + metaTags = append(metaTags, l.atTags(lsv.Uri, lsv.Author.Did)...) + return l.GenerateHTML(ctx, &PageConfig{ Title: fmt.Sprintf("%s%s", titleStr, brandingTitle), Metas: metaTags, @@ -269,6 +299,10 @@ func (l *Linker) GenerateVideoCard(ctx context.Context, u *url.URL, vv *placestr MetaTag{Type: "name", Key: "twitter:title", Content: title}, ) + // at-tags: this page canonically maps to the place.stream.video record, + // authored by the streamer + metaTags = append(metaTags, l.atTags(vv.Uri, authorDid)...) + return l.GenerateHTML(ctx, &PageConfig{ Title: title, Metas: metaTags, @@ -343,6 +377,10 @@ func (l *Linker) GenerateDefaultCard(ctx context.Context, u *url.URL, sentryDSN Content: brandingTitle, }) + // 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 + metaTags = append(metaTags, l.atTags("", "")...) + return l.GenerateHTML(ctx, &PageConfig{ Title: brandingTitle, Metas: metaTags, diff --git a/pkg/linking/linking_test.go b/pkg/linking/linking_test.go index 0e8970aa..99edb658 100644 --- a/pkg/linking/linking_test.go +++ b/pkg/linking/linking_test.go @@ -13,6 +13,7 @@ import ( "stream.place/streamplace/js/app" "stream.place/streamplace/pkg/appbsky" "stream.place/streamplace/pkg/comatproto" + "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/placestream" ) @@ -69,6 +70,14 @@ func TestGenerateLinkCard(t *testing.T) { require.True(t, strings.Contains(linkStr, "iame.li")) require.True(t, strings.Contains(linkStr, ls.Title), "should contain the livestream title") require.True(t, strings.Count(linkStr, "") == 1, "should have exactly one title tag") + + // at-tags (https://tangled.org/chrisshank.com/at-tags) + require.Contains(t, linkStr, `<meta name="at:canonical" content="`+lsv.Uri+`"/>`, + "should map the page to the canonical livestream record") + require.Contains(t, linkStr, `<meta name="at:author" content="at://did:plc:2zmxikig2sj7gqaezl5gntae"/>`, + "should identify the streamer as the page author") + require.NotContains(t, linkStr, `at:me`, + "at:me should be omitted when the linker has no CLI/broadcaster host") } func TestGenerateVideoCard(t *testing.T) { @@ -113,4 +122,27 @@ func TestGenerateVideoCard(t *testing.T) { "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:2zmxikig2sj7gqaezl5gntae/"+thumbCID+"@jpeg"), "og:image should be the video thumbnail served via the bsky CDN") require.True(t, strings.Count(linkStr, "<title>") == 1, "should have exactly one title tag") + + // at-tags (https://tangled.org/chrisshank.com/at-tags) + require.Contains(t, linkStr, `<meta name="at:canonical" content="`+vv.Uri+`"/>`, + "should map the page to the canonical place.stream.video record") + require.Contains(t, linkStr, `<meta name="at:author" content="at://did:plc:2zmxikig2sj7gqaezl5gntae"/>`, + "should identify the streamer as the page author") +} + +func TestGenerateDefaultCardAtMe(t *testing.T) { + index := IndexHTML(t) + linker, err := NewLinker(context.Background(), index, nil, &config.CLI{BroadcasterHost: "stream.place"}) + require.NoError(t, err) + require.NotNil(t, linker) + + u, err := url.Parse("https://stream.place/") + require.NoError(t, err) + linkCard, err := linker.GenerateDefaultCard(context.Background(), u, "") + require.NoError(t, err) + linkStr := string(linkCard) + require.Contains(t, linkStr, `<meta name="at:me" content="at://did:web:stream.place"/>`, + "should identify the node via its did:web") + require.NotContains(t, linkStr, "at:canonical", "front page has no canonical record") + require.NotContains(t, linkStr, "at:author", "front page has no single author") }