1515use App \Models \Extensions \UTCBasedTimes ;
1616use App \Models \Photo ;
1717use App \Policies \AlbumPolicy ;
18+ use App \Policies \AlbumQueryPolicy ;
1819use App \Policies \PhotoQueryPolicy ;
1920use App \Repositories \ConfigManager ;
2021use App \Services \UrlGenerator ;
2122use Carbon \Exceptions \InvalidFormatException ;
2223use Carbon \Exceptions \UnitException ;
2324use GrahamCampbell \Markdown \Facades \Markdown ;
2425use Illuminate \Contracts \Container \BindingResolutionException ;
26+ use Illuminate \Database \Query \Builder as BaseBuilder ;
2527use Illuminate \Support \Carbon ;
2628use Illuminate \Support \Collection ;
2729use Illuminate \Support \Facades \Auth ;
2830use Illuminate \Support \Facades \DB ;
31+ use function Safe \parse_url ;
2932use Spatie \Feed \FeedItem ;
3033
3134/**
32- * @template T of object{id:string,title:string,description:?string,type:string,created_at:string,updated_at:string,album_id:string,album_title:string, short_path:string,filesize:int,storage_disk:string,size_variant_type:string,username:string}
35+ * @template T of object{id:string,title:string,description:?string,type:string,created_at:string,updated_at:string,short_path:string,filesize:int,storage_disk:string,size_variant_type:string,username:string}
3336 */
3437class Generate
3538{
3639 use UTCBasedTimes;
3740
3841 public function __construct (
3942 protected PhotoQueryPolicy $ photo_query_policy ,
43+ protected AlbumQueryPolicy $ album_query_policy ,
4044 protected readonly ConfigManager $ config_manager ,
4145 protected readonly UrlGenerator $ url_generator ,
4246 ) {
4347 }
4448
4549 /**
46- * @param T $data
50+ * @param T $data the row supplying the item's photo/user fields
51+ * @param string $album_id the album whose view of the photo the item links to
52+ * @param string[] $categories album titles to list on the item as `<category>`
4753 *
4854 * @return FeedItem
4955 *
5056 * @throws BindingResolutionException
5157 */
52- private function toFeedItem (object $ data ): FeedItem
58+ private function toFeedItem (object $ data, string $ album_id , array $ categories ): FeedItem
5359 {
54- $ page_link = route ('gallery ' , ['albumId ' => $ data ->album_id , 'photoId ' => $ data ->id ]);
60+ $ page_link = route ('gallery ' , ['albumId ' => $ album_id , 'photoId ' => $ data ->id ]);
61+ // A tag: URI (RFC 4151) is an opaque, album-independent identity for the
62+ // photo. Unlike $page_link (which points at the newest album and moves if
63+ // that album changes), it depends only on the host and the photo's
64+ // immutable id/created_at, so a photo keeps the same <guid> for life.
65+ $ host = parse_url ((string ) config ('app.url ' ), PHP_URL_HOST ) ?? 'lychee ' ;
66+ $ guid = sprintf ('tag:%s,%s:photo/%s ' , $ host , Carbon::parse ($ data ->created_at )->format ('Y-m-d ' ), $ data ->id );
5567 $ feed_item = [
56- 'id ' => $ page_link ,
68+ 'id ' => $ guid ,
5769 'title ' => $ data ->title ,
5870 'summary ' => Markdown::convert ($ data ->description ?? '' )->getContent (),
5971 'updated ' => $ this ->asDateTime ($ data ->updated_at ),
@@ -64,7 +76,7 @@ private function toFeedItem(object $data): FeedItem
6476 'authorName ' => ($ data ->display_name !== null && $ data ->display_name !== '' )
6577 ? $ data ->display_name
6678 : $ data ->username ,
67- 'category ' => [ $ data -> album_title ] ,
79+ 'category ' => $ categories ,
6880 ];
6981
7082 return FeedItem::create ($ feed_item );
@@ -97,34 +109,112 @@ public function do(): Collection
97109 origin: null ,
98110 include_nsfw: !$ this ->config_manager ->getValueAsBool ('hide_nsfw_in_rss ' )
99111 )
100- ->joinSub (DB ::table (PA ::PHOTO_ALBUM ), 'outer_ ' . PA ::PHOTO_ALBUM , 'photos.id ' , '= ' , 'outer_ ' . PA ::PHOTO_ID )
101- ->join ('base_albums ' , 'base_albums.id ' , '= ' , 'outer_ ' . PA ::ALBUM_ID )
102112 ->join ('size_variants ' , 'size_variants.photo_id ' , '= ' , 'photos.id ' )
103113 ->join ('users ' , 'users.id ' , '= ' , 'photos.owner_id ' )
104114 ->where ('size_variants.type ' , '= ' , SizeVariantType::ORIGINAL ->value )
115+ // Require at least one album (needed for the item's link) without
116+ // re-introducing the per-album fan-out a join would add. The
117+ // base_albums join mirrors the categories query below, so every
118+ // selected photo is guaranteed a row there.
119+ ->whereExists (fn (BaseBuilder $ q ) => $ q
120+ ->from (PA ::PHOTO_ALBUM )
121+ ->join ('base_albums ' , 'base_albums.id ' , '= ' , PA ::ALBUM_ID )
122+ ->whereColumn (PA ::PHOTO_ID , 'photos.id ' ))
105123 ->select ([
106124 'photos.id ' ,
107125 'photos.title ' ,
108126 'photos.description ' ,
109127 'photos.type ' ,
110128 'photos.created_at ' ,
111129 'photos.updated_at ' ,
112- 'outer_ ' . PA ::ALBUM_ID ,
113- 'base_albums.title as album_title ' ,
114130 'size_variants.short_path ' ,
115131 'size_variants.filesize ' ,
116132 'size_variants.storage_disk ' ,
117133 'users.username ' ,
118134 'users.display_name ' ,
119135 ]
120136 )
137+ // distinct() collapses the photo_album fan-out that
138+ // applySearchabilityFilter's internal left-join introduces, so each
139+ // photo yields a single row (and the LIMIT counts photos).
121140 ->distinct ()
122141 ->where ('photos.created_at ' , '>= ' , $ now_minus )
123142 ->limit ($ rss_max )
124143 ->orderBy ('photos.created_at ' , 'desc ' )
125144 ->toBase () // We use toBase() to avoid the use of the Eloquent casts etc.
126145 ->get ();
127146
128- return $ photos ->map (fn (object $ p ) => $ this ->toFeedItem ($ p ));
147+ // All album memberships of the selected photos, newest-first so first()
148+ // is the album each item links to: the most recently created album that
149+ // holds the photo (album_id breaks created_at ties for a stable choice).
150+ //
151+ // The same accessibility/NSFW constraints the photo query applies must
152+ // also be applied here: a photo can be shared into a locked or sensitive
153+ // album, and that album must not surface as the item's link or as a
154+ // <category> to a viewer who cannot reach it.
155+ $ albums_query = DB ::table (PA ::PHOTO_ALBUM )
156+ ->join ('base_albums ' , 'base_albums.id ' , '= ' , PA ::ALBUM_ID )
157+ ->whereIn (PA ::PHOTO_ID , $ photos ->pluck ('id ' )->all ());
158+
159+ // Accessibility: keep only albums the current user (or a guest, when
160+ // $user is null) may access, honouring password locks. Admins may reach
161+ // every album, so — like the album policies themselves — they skip this.
162+ if ($ user ?->may_administrate !== true ) {
163+ $ this ->album_query_policy ->joinSubComputedAccessPermissions (
164+ query: $ albums_query ,
165+ second: PA ::ALBUM_ID ,
166+ type: 'left ' ,
167+ user: $ user ,
168+ );
169+ $ albums_query ->where (fn (BaseBuilder $ q ) => $ this ->album_query_policy
170+ ->appendAccessibilityConditions ($ q , $ user , $ unlocked_album_ids ));
171+ }
172+
173+ // NSFW: when the feed is configured to hide sensitive content, drop
174+ // albums that are marked sensitive or sit under a sensitive ancestor.
175+ // This mirrors the include_nsfw filter applied to the photo query above
176+ // (which is applied to admins too), so it is applied unconditionally.
177+ if ($ this ->config_manager ->getValueAsBool ('hide_nsfw_in_rss ' )) {
178+ $ albums_query
179+ ->join ('albums ' , 'albums.id ' , '= ' , PA ::ALBUM_ID )
180+ ->whereNotExists (fn (BaseBuilder $ q ) => $ this ->album_query_policy
181+ ->appendRecursiveSensitiveAlbumsCondition ($ q , null , null ));
182+ }
183+
184+ $ albums_by_photo = $ albums_query
185+ ->orderBy ('base_albums.created_at ' , 'desc ' )
186+ ->orderBy (PA ::ALBUM_ID )
187+ ->get ([
188+ PA ::PHOTO_ID . ' as photo_id ' ,
189+ PA ::ALBUM_ID . ' as album_id ' ,
190+ 'base_albums.title as album_title ' ,
191+ ])
192+ ->groupBy ('photo_id ' );
193+
194+ return $ photos
195+ ->map (function (object $ photo ) use ($ albums_by_photo ): ?FeedItem {
196+ /** @var Collection<int,object{album_id:string,album_title:string}>|null $albums */
197+ $ albums = $ albums_by_photo ->get ($ photo ->id );
198+ // The two queries are not a single snapshot: a concurrent request
199+ // can detach the photo from its last album between them, leaving
200+ // no album to link to. Drop the photo rather than fatal on null.
201+ if ($ albums === null ) {
202+ return null ;
203+ }
204+
205+ // The computed-access-permissions join can return an album more
206+ // than once (e.g. shared to several of the user's groups), so
207+ // collapse to one row per album before listing categories. first()
208+ // still yields the newest album for the link (order is preserved).
209+ $ albums = $ albums ->unique ('album_id ' );
210+
211+ return $ this ->toFeedItem (
212+ $ photo ,
213+ $ albums ->first ()->album_id ,
214+ $ albums ->pluck ('album_title ' )->all (),
215+ );
216+ })
217+ ->filter ()
218+ ->values ();
129219 }
130- }
220+ }
0 commit comments