Skip to content

Commit 07230f1

Browse files
authored
RSS Feed includes one <item> per photo (#4500)
1 parent 36e2e7e commit 07230f1

3 files changed

Lines changed: 273 additions & 22 deletions

File tree

app/Actions/RSS/Generate.php

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,45 +15,57 @@
1515
use App\Models\Extensions\UTCBasedTimes;
1616
use App\Models\Photo;
1717
use App\Policies\AlbumPolicy;
18+
use App\Policies\AlbumQueryPolicy;
1819
use App\Policies\PhotoQueryPolicy;
1920
use App\Repositories\ConfigManager;
2021
use App\Services\UrlGenerator;
2122
use Carbon\Exceptions\InvalidFormatException;
2223
use Carbon\Exceptions\UnitException;
2324
use GrahamCampbell\Markdown\Facades\Markdown;
2425
use Illuminate\Contracts\Container\BindingResolutionException;
26+
use Illuminate\Database\Query\Builder as BaseBuilder;
2527
use Illuminate\Support\Carbon;
2628
use Illuminate\Support\Collection;
2729
use Illuminate\Support\Facades\Auth;
2830
use Illuminate\Support\Facades\DB;
31+
use function Safe\parse_url;
2932
use 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
*/
3437
class 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+
}

resources/views/vendor/feed/rss.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
<link>{{ url($item->link) }}</link>
2424
<description><![CDATA[@if($item->__isset('enclosure'))<img src="{{ url($item->enclosure) }}" /><br/><br/>@endif{!! $item->summary !!}]]></description>
2525
<author><![CDATA[{{ $item->authorName }}@if(!empty($item->authorEmail)) <{{ $item->authorEmail }}>@endif]]></author>
26-
<guid>{{ url($item->id) }}</guid>
26+
<guid isPermaLink="false">{{ $item->id }}</guid>
2727
<pubDate>{{ $item->timestamp() }}</pubDate>
2828
@foreach($item->category as $category)
2929
<category>{{ $category }}</category>

0 commit comments

Comments
 (0)