Kaynağa Gözat

feat(googlereader): add feed icon field, endpoint

Adds an endpoint to the Google Reader integration to serve
feed icon URLs.
Josiah Campbell 1 yıl önce
ebeveyn
işleme
df8bc742fb

+ 47 - 0
internal/database/migrations.go

@@ -5,6 +5,8 @@ package database // import "miniflux.app/v2/internal/database"
 
 import (
 	"database/sql"
+
+	"miniflux.app/v2/internal/crypto"
 )
 
 var schemaVersion = len(migrations)
@@ -1015,4 +1017,49 @@ var migrations = []func(tx *sql.Tx, driver string) error{
 		_, err = tx.Exec(sql)
 		return err
 	},
+	func(tx *sql.Tx, _ string) (err error) {
+		sql := `
+			ALTER TABLE icons ADD COLUMN external_id text default '';
+			CREATE UNIQUE INDEX icons_external_id_idx ON icons USING btree(external_id) WHERE external_id <> '';
+		`
+		_, err = tx.Exec(sql)
+
+		return err
+	},
+	func(tx *sql.Tx, _ string) (err error) {
+		_, err = tx.Exec(`
+				DECLARE id_cursor CURSOR FOR
+				SELECT
+					id
+				FROM icons
+				WHERE external_id = ''
+				FOR UPDATE`)
+		if err != nil {
+			return err
+		}
+		defer tx.Exec("CLOSE id_cursor")
+
+		for {
+			var id int64
+
+			if err := tx.QueryRow(`FETCH NEXT FROM id_cursor`).Scan(&id); err != nil {
+				if err == sql.ErrNoRows {
+					break
+				}
+				return err
+			}
+
+			_, err = tx.Exec(
+				`
+				UPDATE icons SET external_id = $1 WHERE id = $2
+				`,
+				crypto.GenerateRandomStringHex(20), id)
+
+			if err != nil {
+				return err
+			}
+		}
+
+		return nil
+	},
 }

+ 44 - 1
internal/googlereader/handler.go

@@ -210,6 +210,7 @@ func (r RequestModifiers) String() string {
 func Serve(router *mux.Router, store *storage.Storage) {
 	handler := &handler{store, router}
 	router.HandleFunc("/accounts/ClientLogin", handler.clientLoginHandler).Methods(http.MethodPost).Name("ClientLogin")
+	router.HandleFunc("/reader/api/0/icons/{externalIconID}", handler.iconHandler).Methods(http.MethodGet).Name("Icons")
 
 	middleware := newMiddleware(store)
 	sr := router.PathPrefix("/reader/api/0").Subrouter()
@@ -727,6 +728,39 @@ func (h *handler) quickAddHandler(w http.ResponseWriter, r *http.Request) {
 	})
 }
 
+func (h *handler) iconHandler(w http.ResponseWriter, r *http.Request) {
+	clientIP := request.ClientIP(r)
+	externalIconID := request.RouteStringParam(r, "externalIconID")
+
+	slog.Debug("[GoogleReader] Handle /icons/{externalIconID}",
+		slog.String("handler", "iconHandler"),
+		slog.String("client_ip", clientIP),
+		slog.String("user_agent", r.UserAgent()),
+		slog.String("external_icon_id", externalIconID),
+	)
+
+	icon, err := h.store.IconByExternalID(externalIconID)
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+
+	if icon == nil {
+		json.NotFound(w, r)
+		return
+	}
+
+	response.New(w, r).WithCaching(icon.Hash, 72*time.Hour, func(b *response.Builder) {
+		b.WithHeader("Content-Security-Policy", `default-src 'self'`)
+		b.WithHeader("Content-Type", icon.MimeType)
+		b.WithBody(icon.Content)
+		if icon.MimeType != "image/svg+xml" {
+			b.WithoutCompression()
+		}
+		b.Write()
+	})
+}
+
 func getFeed(stream Stream, store *storage.Storage, userID int64) (*model.Feed, error) {
 	feedID, err := strconv.ParseInt(stream.ID, 10, 64)
 	if err != nil {
@@ -827,6 +861,14 @@ func move(stream Stream, destination Stream, store *storage.Storage, userID int6
 	return store.UpdateFeed(feed)
 }
 
+func (h *handler) feedIconURL(f *model.Feed) string {
+	if f.Icon != nil && f.Icon.ExternalIconID != "" {
+		return config.Opts.RootURL() + route.Path(h.router, "Icons", "externalIconID", f.Icon.ExternalIconID)
+	} else {
+		return ""
+	}
+}
+
 func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
 	clientIP := request.ClientIP(r)
@@ -1208,6 +1250,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
 		json.ServerError(w, r, err)
 		return
 	}
+
 	result.Subscriptions = make([]subscription, 0)
 	for _, feed := range feeds {
 		result.Subscriptions = append(result.Subscriptions, subscription{
@@ -1216,7 +1259,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
 			URL:        feed.FeedURL,
 			Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
 			HTMLURL:    feed.SiteURL,
-			IconURL:    "", // TODO: Icons are base64 encoded in the DB.
+			IconURL:    h.feedIconURL(feed),
 		})
 	}
 	json.OK(w, r, result)

+ 8 - 6
internal/model/icon.go

@@ -10,10 +10,11 @@ import (
 
 // Icon represents a website icon (favicon)
 type Icon struct {
-	ID       int64  `json:"id"`
-	Hash     string `json:"hash"`
-	MimeType string `json:"mime_type"`
-	Content  []byte `json:"-"`
+	ID         int64  `json:"id"`
+	Hash       string `json:"hash"`
+	MimeType   string `json:"mime_type"`
+	Content    []byte `json:"-"`
+	ExternalID string `json:"external_id"`
 }
 
 // DataURL returns the data URL of the icon.
@@ -26,6 +27,7 @@ type Icons []*Icon
 
 // FeedIcon is a junction table between feeds and icons.
 type FeedIcon struct {
-	FeedID int64 `json:"feed_id"`
-	IconID int64 `json:"icon_id"`
+	FeedID         int64  `json:"feed_id"`
+	IconID         int64  `json:"icon_id"`
+	ExternalIconID string `json:"external_icon_id"`
 }

+ 7 - 2
internal/storage/feed_query_builder.go

@@ -163,6 +163,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
 			c.title as category_title,
 			c.hide_globally as category_hidden,
 			fi.icon_id,
+			i.external_id,
 			u.timezone,
 			f.apprise_service_urls,
 			f.webhook_url,
@@ -178,6 +179,8 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
 			categories c ON c.id=f.category_id
 		LEFT JOIN
 			feed_icons fi ON fi.feed_id=f.id
+		LEFT JOIN
+			icons i ON i.id=fi.icon_id
 		LEFT JOIN
 			users u ON u.id=f.user_id
 		WHERE %s
@@ -201,6 +204,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
 	for rows.Next() {
 		var feed model.Feed
 		var iconID sql.NullInt64
+		var externalIconID string
 		var tz string
 		feed.Category = &model.Category{}
 
@@ -237,6 +241,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
 			&feed.Category.Title,
 			&feed.Category.HideGlobally,
 			&iconID,
+			&externalIconID,
 			&tz,
 			&feed.AppriseServiceURLs,
 			&feed.WebhookURL,
@@ -253,9 +258,9 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
 		}
 
 		if iconID.Valid {
-			feed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: iconID.Int64}
+			feed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: iconID.Int64, ExternalIconID: externalIconID}
 		} else {
-			feed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: 0}
+			feed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: 0, ExternalIconID: ""}
 		}
 
 		if readCounters != nil {

+ 43 - 8
internal/storage/icon.go

@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"strings"
 
+	"miniflux.app/v2/internal/crypto"
 	"miniflux.app/v2/internal/model"
 )
 
@@ -22,8 +23,16 @@ func (s *Storage) HasFeedIcon(feedID int64) bool {
 // IconByID returns an icon by the ID.
 func (s *Storage) IconByID(iconID int64) (*model.Icon, error) {
 	var icon model.Icon
-	query := `SELECT id, hash, mime_type, content FROM icons WHERE id=$1`
-	err := s.db.QueryRow(query, iconID).Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content)
+	query := `
+		SELECT
+			id,
+			hash,
+			mime_type,
+			content,
+			external_id
+		FROM icons
+		WHERE id=$1`
+	err := s.db.QueryRow(query, iconID).Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content, &icon.ExternalID)
 	if err == sql.ErrNoRows {
 		return nil, nil
 	} else if err != nil {
@@ -33,6 +42,29 @@ func (s *Storage) IconByID(iconID int64) (*model.Icon, error) {
 	return &icon, nil
 }
 
+// IconByExternalID returns an icon by the External Icon ID.
+func (s *Storage) IconByExternalID(externalIconID string) (*model.Icon, error) {
+	var icon model.Icon
+	query := `
+		SELECT
+			id,
+			hash,
+			mime_type,
+			content,
+			external_id
+		FROM icons
+		WHERE external_id=$1
+	`
+	err := s.db.QueryRow(query, externalIconID).Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content, &icon.ExternalID)
+	if err == sql.ErrNoRows {
+		return nil, nil
+	} else if err != nil {
+		return nil, fmt.Errorf("store: unable to fetch icon #%s: %w", externalIconID, err)
+	}
+
+	return &icon, nil
+}
+
 // IconByFeedID returns a feed icon.
 func (s *Storage) IconByFeedID(userID, feedID int64) (*model.Icon, error) {
 	query := `
@@ -40,7 +72,8 @@ func (s *Storage) IconByFeedID(userID, feedID int64) (*model.Icon, error) {
 			icons.id,
 			icons.hash,
 			icons.mime_type,
-			icons.content
+			icons.content,
+			icons.external_id
 		FROM icons
 		LEFT JOIN feed_icons ON feed_icons.icon_id=icons.id
 		LEFT JOIN feeds ON feeds.id=feed_icons.feed_id
@@ -49,7 +82,7 @@ func (s *Storage) IconByFeedID(userID, feedID int64) (*model.Icon, error) {
 		LIMIT 1
 	`
 	var icon model.Icon
-	err := s.db.QueryRow(query, userID, feedID).Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content)
+	err := s.db.QueryRow(query, userID, feedID).Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content, &icon.ExternalID)
 	if err != nil {
 		return nil, fmt.Errorf(`store: unable to fetch icon: %v`, err)
 	}
@@ -67,9 +100,9 @@ func (s *Storage) StoreFeedIcon(feedID int64, icon *model.Icon) error {
 	if err := tx.QueryRow(`SELECT id FROM icons WHERE hash=$1`, icon.Hash).Scan(&icon.ID); err == sql.ErrNoRows {
 		query := `
 			INSERT INTO icons
-				(hash, mime_type, content)
+				(hash, mime_type, content, external_id)
 			VALUES
-				($1, $2, $3)
+				($1, $2, $3, $4)
 			RETURNING
 				id
 		`
@@ -78,6 +111,7 @@ func (s *Storage) StoreFeedIcon(feedID int64, icon *model.Icon) error {
 			icon.Hash,
 			normalizeMimeType(icon.MimeType),
 			icon.Content,
+			crypto.GenerateRandomStringHex(20),
 		).Scan(&icon.ID)
 
 		if err != nil {
@@ -113,7 +147,8 @@ func (s *Storage) Icons(userID int64) (model.Icons, error) {
 			icons.id,
 			icons.hash,
 			icons.mime_type,
-			icons.content
+			icons.content,
+			icons.external_id
 		FROM icons
 		LEFT JOIN feed_icons ON feed_icons.icon_id=icons.id
 		LEFT JOIN feeds ON feeds.id=feed_icons.feed_id
@@ -129,7 +164,7 @@ func (s *Storage) Icons(userID int64) (model.Icons, error) {
 	var icons model.Icons
 	for rows.Next() {
 		var icon model.Icon
-		err := rows.Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content)
+		err := rows.Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content, &icon.ExternalID)
 		if err != nil {
 			return nil, fmt.Errorf(`store: unable to fetch icons row: %v`, err)
 		}