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)