icon.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package storage // import "miniflux.app/v2/internal/storage"
  4. import (
  5. "database/sql"
  6. "errors"
  7. "fmt"
  8. "strings"
  9. "miniflux.app/v2/internal/crypto"
  10. "miniflux.app/v2/internal/model"
  11. )
  12. // HasFeedIcon reports whether the specified feed already has an associated icon record.
  13. func (s *Storage) HasFeedIcon(feedID int64) bool {
  14. var result bool
  15. query := `SELECT true FROM feed_icons WHERE feed_id=$1 LIMIT 1`
  16. s.db.QueryRow(query, feedID).Scan(&result)
  17. return result
  18. }
  19. // IconByUserAndIconID fetches a single icon by its internal identifier, scoped
  20. // to the given user. It returns nil when the icon does not exist or is not
  21. // associated with any feed owned by the user.
  22. func (s *Storage) IconByUserAndIconID(userID, iconID int64) (*model.Icon, error) {
  23. var icon model.Icon
  24. query := `
  25. SELECT
  26. i.id,
  27. i.hash,
  28. i.mime_type,
  29. i.content,
  30. i.external_id
  31. FROM icons AS i
  32. WHERE i.id = $2
  33. AND EXISTS (
  34. SELECT 1
  35. FROM feeds AS f
  36. INNER JOIN feed_icons AS fi ON fi.feed_id = f.id
  37. WHERE f.user_id = $1
  38. AND fi.icon_id = $2
  39. )`
  40. err := s.db.QueryRow(query, userID, iconID).Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content, &icon.ExternalID)
  41. switch {
  42. case errors.Is(err, sql.ErrNoRows):
  43. return nil, nil
  44. case err != nil:
  45. return nil, fmt.Errorf("store: cannot load icon id=%d for user_id=%d: %w", iconID, userID, err)
  46. default:
  47. return &icon, nil
  48. }
  49. }
  50. // IconByExternalID fetches an icon using its external identifier, returning nil when no match exists.
  51. func (s *Storage) IconByExternalID(externalIconID string) (*model.Icon, error) {
  52. var icon model.Icon
  53. query := `
  54. SELECT
  55. id,
  56. hash,
  57. mime_type,
  58. content,
  59. external_id
  60. FROM icons
  61. WHERE external_id=$1
  62. `
  63. err := s.db.QueryRow(query, externalIconID).Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content, &icon.ExternalID)
  64. switch {
  65. case errors.Is(err, sql.ErrNoRows):
  66. return nil, nil
  67. case err != nil:
  68. return nil, fmt.Errorf("store: cannot load icon external_id=%s: %w", externalIconID, err)
  69. default:
  70. return &icon, nil
  71. }
  72. }
  73. // IconByFeedID returns the icon linked to the given feed for the specified user, or nil if none is set.
  74. func (s *Storage) IconByFeedID(userID, feedID int64) (*model.Icon, error) {
  75. query := `
  76. SELECT
  77. icons.id,
  78. icons.hash,
  79. icons.mime_type,
  80. icons.content,
  81. icons.external_id
  82. FROM icons
  83. INNER JOIN feed_icons ON feed_icons.icon_id=icons.id
  84. INNER JOIN feeds ON feeds.id=feed_icons.feed_id
  85. WHERE
  86. feeds.user_id=$1 AND feeds.id=$2
  87. LIMIT 1
  88. `
  89. var icon model.Icon
  90. err := s.db.QueryRow(query, userID, feedID).Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content, &icon.ExternalID)
  91. switch {
  92. case errors.Is(err, sql.ErrNoRows):
  93. return nil, nil
  94. case err != nil:
  95. return nil, fmt.Errorf("store: cannot load icon for feed_id=%d user_id=%d: %w", feedID, userID, err)
  96. default:
  97. return &icon, nil
  98. }
  99. }
  100. // StoreFeedIcon creates or reuses an icon by hash and associates it with the given feed atomically.
  101. func (s *Storage) StoreFeedIcon(feedID int64, icon *model.Icon) error {
  102. tx, err := s.db.Begin()
  103. if err != nil {
  104. return fmt.Errorf(`store: unable to start transaction: %v`, err)
  105. }
  106. err = tx.QueryRow(`SELECT id FROM icons WHERE hash=$1`, icon.Hash).Scan(&icon.ID)
  107. if errors.Is(err, sql.ErrNoRows) {
  108. query := `
  109. INSERT INTO icons
  110. (hash, mime_type, content, external_id)
  111. VALUES
  112. ($1, $2, $3, $4)
  113. RETURNING
  114. id
  115. `
  116. err := tx.QueryRow(
  117. query,
  118. icon.Hash,
  119. normalizeMimeType(icon.MimeType),
  120. icon.Content,
  121. crypto.GenerateRandomStringHex(20),
  122. ).Scan(&icon.ID)
  123. if err != nil {
  124. tx.Rollback()
  125. return fmt.Errorf(`store: unable to create icon: %v`, err)
  126. }
  127. } else if err != nil {
  128. tx.Rollback()
  129. return fmt.Errorf(`store: unable to fetch icon by hash %q: %v`, icon.Hash, err)
  130. }
  131. if _, err := tx.Exec(`DELETE FROM feed_icons WHERE feed_id=$1`, feedID); err != nil {
  132. tx.Rollback()
  133. return fmt.Errorf(`store: unable to delete feed icon: %v`, err)
  134. }
  135. if _, err := tx.Exec(`INSERT INTO feed_icons (feed_id, icon_id) VALUES ($1, $2)`, feedID, icon.ID); err != nil {
  136. tx.Rollback()
  137. return fmt.Errorf(`store: unable to associate feed and icon: %v`, err)
  138. }
  139. if err := tx.Commit(); err != nil {
  140. return fmt.Errorf(`store: unable to commit transaction: %v`, err)
  141. }
  142. return nil
  143. }
  144. // CleanupOrphanIcons removes icons that are no longer associated with any
  145. // feed. Such rows accumulate when feeds are deleted (the cascade only removes
  146. // the feed_icons mapping, not the dedup-by-hash icons row) or when a feed's
  147. // icon is replaced by StoreFeedIcon.
  148. func (s *Storage) CleanupOrphanIcons() (int64, error) {
  149. result, err := s.db.Exec(`
  150. DELETE FROM icons
  151. WHERE NOT EXISTS (
  152. SELECT 1 FROM feed_icons WHERE feed_icons.icon_id = icons.id
  153. )
  154. `)
  155. if err != nil {
  156. return 0, fmt.Errorf(`store: unable to clean orphan icons: %v`, err)
  157. }
  158. n, _ := result.RowsAffected()
  159. return n, nil
  160. }
  161. // Icons lists all icons currently associated with any feed owned by the given user.
  162. func (s *Storage) Icons(userID int64) (model.Icons, error) {
  163. query := `
  164. SELECT
  165. icons.id,
  166. icons.hash,
  167. icons.mime_type,
  168. icons.content,
  169. icons.external_id
  170. FROM icons
  171. INNER JOIN feed_icons ON feed_icons.icon_id=icons.id
  172. INNER JOIN feeds ON feeds.id=feed_icons.feed_id
  173. WHERE
  174. feeds.user_id=$1
  175. `
  176. rows, err := s.db.Query(query, userID)
  177. if err != nil {
  178. return nil, fmt.Errorf(`store: unable to fetch icons: %v`, err)
  179. }
  180. defer rows.Close()
  181. var icons model.Icons
  182. for rows.Next() {
  183. var icon model.Icon
  184. err := rows.Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content, &icon.ExternalID)
  185. if err != nil {
  186. return nil, fmt.Errorf(`store: unable to fetch icons row: %v`, err)
  187. }
  188. icons = append(icons, &icon)
  189. }
  190. return icons, nil
  191. }
  192. func normalizeMimeType(mimeType string) string {
  193. mimeType = strings.ToLower(mimeType)
  194. switch mimeType {
  195. case "image/png", "image/jpeg", "image/jpg", "image/webp", "image/svg+xml", "image/x-icon", "image/gif":
  196. return mimeType
  197. default:
  198. return "image/x-icon"
  199. }
  200. }