icon.go 5.7 KB

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