1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-06-27 16:36:00 +00:00

feat(googlereader): add feed icon field, endpoint

Adds an endpoint to the Google Reader integration to serve
feed icon URLs.
This commit is contained in:
Josiah Campbell 2025-03-03 22:38:56 -06:00
parent e342a4f143
commit a3148dbed9
5 changed files with 149 additions and 17 deletions

View file

@ -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
},
}

View file

@ -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)

View file

@ -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"`
}

View file

@ -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 {

View file

@ -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)
}