From 8cbfc9b1887ebba7bcf1a36d295178b92edf940c Mon Sep 17 00:00:00 2001 From: kaimanhub Date: Fri, 21 Mar 2025 23:58:13 +0200 Subject: [PATCH] Add preview card support --- internal/gtsmodel/card.go | 68 +++++++++++++ internal/gtsmodel/status.go | 2 + internal/processing/status/create.go | 14 ++- internal/processing/status/preview_card.go | 111 +++++++++++++++++++++ internal/typeutils/internaltofrontend.go | 29 +++++- 5 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 internal/gtsmodel/card.go create mode 100644 internal/processing/status/preview_card.go diff --git a/internal/gtsmodel/card.go b/internal/gtsmodel/card.go new file mode 100644 index 000000000..9185b4de1 --- /dev/null +++ b/internal/gtsmodel/card.go @@ -0,0 +1,68 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package gtsmodel contains types used *internally* by GoToSocial and added/removed/selected from the database. +// These types should never be serialized and/or sent out via public APIs, as they contain sensitive information. +// The annotation used on these structs is for handling them via the bun-db ORM. +// See here for more info on bun model annotations: https://bun.uptrace.dev/guide/models.html +package gtsmodel + +type Card struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // Unique identity string. + // Location of linked resource. + // example: https://buzzfeed.com/some/fuckin/buzzfeed/article + URL string `bun:",nullzero"` + // Title of linked resource. + // example: Buzzfeed - Is Water Wet? + Title string `bun:",nullzero"` + // Description of preview. + // example: Is water wet? We're not sure. In this article, we ask an expert... + Description string `bun:",nullzero"` + // The type of the preview card. + // enum: + // - link + // - photo + // - video + // - rich + // example: link + Type string `bun:",nullzero"` + // The author of the original resource. + // example: weewee@buzzfeed.com + AuthorName string `bun:"author_name,nullzero"` + // A link to the author of the original resource. + // example: https://buzzfeed.com/authors/weewee + AuthorURL string `bun:"author_url,nullzero"` + // The provider of the original resource. + // example: Buzzfeed + ProviderName string `bun:"provider_name,nullzero"` + // A link to the provider of the original resource. + // example: https://buzzfeed.com + ProviderURL string `bun:"provider_url,nullzero"` + // HTML to be used for generating the preview card. + HTML string `bun:",nullzero"` + // Width of preview, in pixels. + Width int `bun:",nullzero"` + // Height of preview, in pixels. + Height int `bun:",nullzero"` + // Preview thumbnail. + // example: https://example.org/fileserver/preview/thumb.jpg + Image string `bun:",nullzero"` + // Used for photo embeds, instead of custom html. + EmbedURL string `bun:",nullzero"` + // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet. + Blurhash string `bun:",nullzero"` +} diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go index cdeccbebb..61675faf4 100644 --- a/internal/gtsmodel/status.go +++ b/internal/gtsmodel/status.go @@ -76,6 +76,8 @@ type Status struct { PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed. PreApproved bool `bun:"-"` // If true, then status is a reply to or boost wrapper of a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB. ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves the Announce or Create Activity that this status was/will be attached to. + CardID string `bun:"type:CHAR(26),nullzero,notnull"` // + Card *Card `bun:"-"` // Preview card for links included within status content. } // GetID implements timeline.Timelineable{}. diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go index 10a5560b6..e04e03cb1 100644 --- a/internal/processing/status/create.go +++ b/internal/processing/status/create.go @@ -189,6 +189,17 @@ func (p *Processor) Create( PendingApproval: util.Ptr(false), } + // Get preview card + card, errWithCode := FetchPreview(content.Content) + if errWithCode != nil { + return nil, errWithCode + } + + if card != nil { + status.CardID = id.NewULIDFromTime(now) + status.Card = card + } + // Only store ContentWarningText if the parsed // result is different from the given SpoilerText, // otherwise skip to avoid duplicating db columns. @@ -315,7 +326,6 @@ func (p *Processor) Create( // backfilledStatusID tries to find an unused ULID for a backfilled status. func (p *Processor) backfilledStatusID(ctx context.Context, createdAt time.Time) (string, error) { - // Any fetching of statuses here is // only to check availability of ID, // no need for any attached models. @@ -522,7 +532,6 @@ func processInteractionPolicy( settings *gtsmodel.AccountSettings, status *gtsmodel.Status, ) gtserror.WithCode { - // If policy is set on the // form then prefer this. // @@ -535,7 +544,6 @@ func processInteractionPolicy( form.InteractionPolicy, form.Visibility, ) - if err != nil { errWithCode := gtserror.NewErrorBadRequest(err, err.Error()) return errWithCode diff --git a/internal/processing/status/preview_card.go b/internal/processing/status/preview_card.go new file mode 100644 index 000000000..777fe1c1a --- /dev/null +++ b/internal/processing/status/preview_card.go @@ -0,0 +1,111 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package status + +import ( + "fmt" + "net/http" + "net/url" + "regexp" + + "github.com/PuerkitoBio/goquery" + + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +var urlRegex = regexp.MustCompile(`https?://[a-zA-Z0-9./?=_-]+`) + +func extractLastURL(text string) string { + matches := urlRegex.FindAllString(text, -1) + if len(matches) == 0 { + return "" + } + return matches[len(matches)-1] +} + +// FetchPreview retrieves OpenGraph metadata from a URL. +func FetchPreview(text string) (*gtsmodel.Card, gtserror.WithCode) { + link := extractLastURL(text) + if link == "" { + return nil, nil + } + + parsed, err := url.ParseRequestURI(link) + if err != nil { + return nil, gtserror.NewErrorInternalError(err, "invalid URL") + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("unsupported scheme: %s", parsed.Scheme)) + } + + resp, err := http.Get(link) + if err != nil { + return nil, gtserror.NewErrorInternalError(err, "request failed") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("unexpected status: %s", resp.Status)) + } + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("failed to parse HTML: %w", err)) + } + + card := >smodel.Card{ + URL: link, + } + + doc.Find("meta").Each(func(i int, s *goquery.Selection) { + property, _ := s.Attr("property") + content, _ := s.Attr("content") + + switch property { + case "og:title": + card.Title = content + case "og:description": + card.Description = content + case "og:type": + card.Type = content + case "og:image": + card.Image = content + case "og:url": + if content != "" { + card.URL = content + } + case "og:site_name": + card.ProviderName = content + } + }) + + if card.Title == "" { + card.Title = doc.Find("title").Text() + } + + if card.Description == "" { + desc, exists := doc.Find("meta[name='description']").Attr("content") + if exists { + card.Description = desc + } + } + + return card, nil +} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index d23c67a72..2403a9f7f 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -240,7 +240,6 @@ func (c *Converter) AccountToWebAccount( // accountToAPIAccountPublic provides all the logic for AccountToAPIAccount, MINUS fetching moved account, to prevent possible recursion. func (c *Converter) accountToAPIAccountPublic(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) { - // Populate account struct fields. err := c.state.DB.PopulateAccount(ctx, a) @@ -1375,6 +1374,8 @@ func (c *Converter) baseStatusToFrontend( log.Errorf(ctx, "error converting status emojis: %v", err) } + apiCard := c.convertCardToAPICard(s.Card, s.CardID) + // Take status's interaction policy, or // fall back to default for its visibility. var p *gtsmodel.InteractionPolicy @@ -1411,7 +1412,7 @@ func (c *Converter) baseStatusToFrontend( Mentions: apiMentions, Tags: apiTags, Emojis: apiEmojis, - Card: nil, // TODO: implement cards + Card: apiCard, Text: s.Text, ContentType: ContentTypeToAPIContentType(s.ContentType), InteractionPolicy: *apiInteractionPolicy, @@ -2598,6 +2599,29 @@ func (c *Converter) convertAttachmentsToAPIAttachments(ctx context.Context, atta return apiAttachments, errs.Combine() } +func (c *Converter) convertCardToAPICard(card *gtsmodel.Card, _ string) *apimodel.Card { + if card == nil { + return nil + } + + return &apimodel.Card{ + URL: card.URL, + Title: card.Title, + Description: card.Description, + Type: card.Type, + AuthorName: card.AuthorName, + AuthorURL: card.AuthorURL, + ProviderName: card.ProviderName, + ProviderURL: card.ProviderURL, + HTML: card.HTML, + Width: card.Width, + Height: card.Height, + Image: card.Image, + EmbedURL: card.EmbedURL, + Blurhash: card.Blurhash, + } +} + // FilterToAPIFiltersV1 converts one GTS model filter into an API v1 filter list func (c *Converter) FilterToAPIFiltersV1(ctx context.Context, filter *gtsmodel.Filter) ([]*apimodel.FilterV1, error) { apiFilters := make([]*apimodel.FilterV1, 0, len(filter.Keywords)) @@ -2922,7 +2946,6 @@ func (c *Converter) InteractionPolicyToAPIInteractionPolicy( } func policyValsToAPIPolicyVals(vals gtsmodel.PolicyValues) []apimodel.PolicyValue { - var ( valsLen = len(vals)