Skip to content

Commit 342787e

Browse files
committed
Create updates.xml RSS feed
This creates an RSS feed published at https://static.crates.io/rss/updates.xml. The feed is synced with the database in a background job after every successful publish of a new version. It includes the latest 100 published versions with the crate name, version number, crate description, URL and publish date.
1 parent 1173146 commit 342787e

File tree

18 files changed

+363
-1
lines changed

18 files changed

+363
-1
lines changed

Cargo.lock

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,10 @@ p256 = "=0.13.2"
9898
parking_lot = "=0.12.3"
9999
paste = "=1.0.15"
100100
prometheus = { version = "=0.13.4", default-features = false }
101+
quick-xml = "=0.31.0"
101102
rand = "=0.8.5"
102103
reqwest = { version = "=0.12.5", features = ["blocking", "gzip", "json"] }
104+
rss = { version = "=2.0.8", default-features = false, features = ["atom"] }
103105
scheduled-thread-pool = "=0.2.7"
104106
secrecy = "=0.8.0"
105107
semver = { version = "=1.0.23", features = ["serde"] }

src/admin/enqueue_job.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ pub enum Command {
3939
force: bool,
4040
},
4141
SendTokenExpiryNotifications,
42+
SyncUpdatesFeed,
4243
}
4344

4445
pub fn run(command: Command) -> Result<()> {
@@ -125,6 +126,9 @@ pub fn run(command: Command) -> Result<()> {
125126
Command::SendTokenExpiryNotifications => {
126127
jobs::SendTokenExpiryNotifications.enqueue(conn)?;
127128
}
129+
Command::SyncUpdatesFeed => {
130+
jobs::rss::SyncUpdatesFeed.enqueue(conn)?;
131+
}
128132
};
129133

130134
Ok(())

src/controllers/krate/publish.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,10 @@ pub async fn publish(app: AppState, req: BytesRequest) -> AppResult<Json<GoodCra
431431
CheckTyposquat::new(&krate.name).enqueue(conn)?;
432432
}
433433

434+
if let Err(error) = jobs::rss::SyncUpdatesFeed.enqueue(conn) {
435+
error!("Failed to enqueue `rss::SyncUpdatesFeed` job: {error}");
436+
}
437+
434438
// The `other` field on `PublishWarnings` was introduced to handle a temporary warning
435439
// that is no longer needed. As such, crates.io currently does not return any `other`
436440
// warnings at this time, but if we need to, the field is available.

src/storage.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ use object_store::local::LocalFileSystem;
77
use object_store::memory::InMemory;
88
use object_store::path::Path;
99
use object_store::prefix::PrefixStore;
10-
use object_store::{Attribute, Attributes, ClientOptions, ObjectStore, Result};
10+
use object_store::{Attribute, Attributes, ClientOptions, ObjectStore, PutPayload, Result};
1111
use secrecy::{ExposeSecret, SecretString};
1212
use std::fs;
13+
use std::io::Cursor;
1314
use std::path::PathBuf;
1415
use std::sync::Arc;
1516
use tokio::fs::File;
@@ -203,6 +204,11 @@ impl Storage {
203204
apply_cdn_prefix(&self.cdn_prefix, &readme_path(name, version)).replace('+', "%2B")
204205
}
205206

207+
/// Returns the URL of an uploaded RSS feed.
208+
pub fn feed_url(&self, feed_id: &FeedId) -> String {
209+
apply_cdn_prefix(&self.cdn_prefix, &feed_id.into()).replace('+', "%2B")
210+
}
211+
206212
#[instrument(skip(self))]
207213
pub async fn delete_all_crate_files(&self, name: &str) -> Result<()> {
208214
let prefix = format!("{PREFIX_CRATES}/{name}").into();
@@ -251,6 +257,25 @@ impl Storage {
251257
Ok(())
252258
}
253259

260+
#[instrument(skip(self, channel))]
261+
pub async fn upload_feed(
262+
&self,
263+
feed_id: &FeedId,
264+
channel: &rss::Channel,
265+
) -> anyhow::Result<()> {
266+
let path = feed_id.into();
267+
268+
let mut buffer = Vec::new();
269+
let mut cursor = Cursor::new(&mut buffer);
270+
channel.pretty_write_to(&mut cursor, b' ', 4)?;
271+
let payload = PutPayload::from_bytes(buffer.into());
272+
273+
let attributes = self.attrs([(Attribute::ContentType, "text/xml; charset=UTF-8")]);
274+
let opts = attributes.into();
275+
self.store.put_opts(&path, payload, opts).await?;
276+
Ok(())
277+
}
278+
254279
#[instrument(skip(self, content))]
255280
pub async fn sync_index(&self, name: &str, content: Option<String>) -> Result<()> {
256281
let path = crates_io_index::Repository::relative_index_file_for_url(name).into();
@@ -349,6 +374,19 @@ fn apply_cdn_prefix(cdn_prefix: &Option<String>, path: &Path) -> String {
349374
}
350375
}
351376

377+
#[derive(Debug)]
378+
pub enum FeedId {
379+
Updates,
380+
}
381+
382+
impl From<&FeedId> for Path {
383+
fn from(feed_id: &FeedId) -> Path {
384+
match feed_id {
385+
FeedId::Updates => "rss/updates.xml".into(),
386+
}
387+
}
388+
}
389+
352390
#[cfg(test)]
353391
mod tests {
354392
use super::*;

src/tests/krate/publish/basics.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ async fn new_krate() {
2424
assert_snapshot!(app.stored_files().await.join("\n"), @r###"
2525
crates/foo_new/foo_new-1.0.0.crate
2626
index/fo/o_/foo_new
27+
rss/updates.xml
2728
"###);
2829

2930
app.db(|conn| {
@@ -50,6 +51,7 @@ async fn new_krate_with_token() {
5051
assert_snapshot!(app.stored_files().await.join("\n"), @r###"
5152
crates/foo_new/foo_new-1.0.0.crate
5253
index/fo/o_/foo_new
54+
rss/updates.xml
5355
"###);
5456
}
5557

@@ -68,6 +70,7 @@ async fn new_krate_weird_version() {
6870
assert_snapshot!(app.stored_files().await.join("\n"), @r###"
6971
crates/foo_weird/foo_weird-0.0.0-pre.crate
7072
index/fo/o_/foo_weird
73+
rss/updates.xml
7174
"###);
7275
}
7376

@@ -94,6 +97,7 @@ async fn new_krate_twice() {
9497
crates/foo_twice/foo_twice-0.99.0.crate
9598
crates/foo_twice/foo_twice-2.0.0.crate
9699
index/fo/o_/foo_twice
100+
rss/updates.xml
97101
"###);
98102
}
99103

src/tests/krate/publish/git.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ async fn new_krate_git_upload_with_conflicts() {
1414
assert_snapshot!(app.stored_files().await.join("\n"), @r###"
1515
crates/foo_conflicts/foo_conflicts-1.0.0.crate
1616
index/fo/o_/foo_conflicts
17+
rss/updates.xml
1718
"###);
1819
}

src/tests/krate/publish/max_size.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ async fn tarball_between_default_axum_limit_and_max_upload_size() {
5252
assert_snapshot!(app.stored_files().await.join("\n"), @r###"
5353
crates/foo/foo-1.1.0.crate
5454
index/3/f/foo
55+
rss/updates.xml
5556
"###);
5657
}
5758

@@ -146,5 +147,6 @@ async fn new_krate_too_big_but_whitelisted() {
146147
assert_snapshot!(app.stored_files().await.join("\n"), @r###"
147148
crates/foo_whitelist/foo_whitelist-1.1.0.crate
148149
index/fo/o_/foo_whitelist
150+
rss/updates.xml
149151
"###);
150152
}

src/tests/krate/publish/rate_limit.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ async fn publish_new_crate_ratelimit_expires() {
6969
assert_snapshot!(app.stored_files().await.join("\n"), @r###"
7070
crates/rate_limited/rate_limited-1.0.0.crate
7171
index/ra/te/rate_limited
72+
rss/updates.xml
7273
"###);
7374

7475
let json = anon.show_crate("rate_limited").await;
@@ -105,6 +106,7 @@ async fn publish_new_crate_override_loosens_ratelimit() {
105106
assert_snapshot!(app.stored_files().await.join("\n"), @r###"
106107
crates/rate_limited1/rate_limited1-1.0.0.crate
107108
index/ra/te/rate_limited1
109+
rss/updates.xml
108110
"###);
109111

110112
let json = anon.show_crate("rate_limited1").await;
@@ -118,6 +120,7 @@ async fn publish_new_crate_override_loosens_ratelimit() {
118120
crates/rate_limited2/rate_limited2-1.0.0.crate
119121
index/ra/te/rate_limited1
120122
index/ra/te/rate_limited2
123+
rss/updates.xml
121124
"###);
122125

123126
let json = anon.show_crate("rate_limited2").await;
@@ -134,6 +137,7 @@ async fn publish_new_crate_override_loosens_ratelimit() {
134137
crates/rate_limited2/rate_limited2-1.0.0.crate
135138
index/ra/te/rate_limited1
136139
index/ra/te/rate_limited2
140+
rss/updates.xml
137141
"###);
138142

139143
let response = anon.get::<()>("/api/v1/crates/rate_limited3").await;
@@ -171,6 +175,7 @@ async fn publish_new_crate_expired_override_ignored() {
171175
assert_snapshot!(app.stored_files().await.join("\n"), @r###"
172176
crates/rate_limited1/rate_limited1-1.0.0.crate
173177
index/ra/te/rate_limited1
178+
rss/updates.xml
174179
"###);
175180

176181
let json = anon.show_crate("rate_limited1").await;
@@ -185,6 +190,7 @@ async fn publish_new_crate_expired_override_ignored() {
185190
assert_snapshot!(app.stored_files().await.join("\n"), @r###"
186191
crates/rate_limited1/rate_limited1-1.0.0.crate
187192
index/ra/te/rate_limited1
193+
rss/updates.xml
188194
"###);
189195

190196
let response = anon.get::<()>("/api/v1/crates/rate_limited2").await;
@@ -220,6 +226,7 @@ async fn publish_existing_crate_rate_limited() {
220226
assert_snapshot!(app.stored_files().await.join("\n"), @r###"
221227
crates/rate_limited1/rate_limited1-1.0.0.crate
222228
index/ra/te/rate_limited1
229+
rss/updates.xml
223230
"###);
224231

225232
// Uploading the first update to the crate works
@@ -232,6 +239,7 @@ async fn publish_existing_crate_rate_limited() {
232239
crates/rate_limited1/rate_limited1-1.0.0.crate
233240
crates/rate_limited1/rate_limited1-1.0.1.crate
234241
index/ra/te/rate_limited1
242+
rss/updates.xml
235243
"###);
236244

237245
// Uploading the second update to the crate is rate limited
@@ -248,6 +256,7 @@ async fn publish_existing_crate_rate_limited() {
248256
crates/rate_limited1/rate_limited1-1.0.0.crate
249257
crates/rate_limited1/rate_limited1-1.0.1.crate
250258
index/ra/te/rate_limited1
259+
rss/updates.xml
251260
"###);
252261

253262
// Wait for the limit to be up
@@ -263,6 +272,7 @@ async fn publish_existing_crate_rate_limited() {
263272
crates/rate_limited1/rate_limited1-1.0.1.crate
264273
crates/rate_limited1/rate_limited1-1.0.2.crate
265274
index/ra/te/rate_limited1
275+
rss/updates.xml
266276
"###);
267277
}
268278

src/tests/krate/publish/readme.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ async fn new_krate_with_readme() {
1919
crates/foo_readme/foo_readme-1.0.0.crate
2020
index/fo/o_/foo_readme
2121
readmes/foo_readme/foo_readme-1.0.0.html
22+
rss/updates.xml
2223
"###);
2324
}
2425

@@ -37,6 +38,7 @@ async fn new_krate_with_empty_readme() {
3738
assert_snapshot!(app.stored_files().await.join("\n"), @r###"
3839
crates/foo_readme/foo_readme-1.0.0.crate
3940
index/fo/o_/foo_readme
41+
rss/updates.xml
4042
"###);
4143
}
4244

@@ -56,6 +58,7 @@ async fn new_krate_with_readme_and_plus_version() {
5658
crates/foo_readme/foo_readme-1.0.0+foo.crate
5759
index/fo/o_/foo_readme
5860
readmes/foo_readme/foo_readme-1.0.0+foo.html
61+
rss/updates.xml
5962
"###);
6063
}
6164

src/tests/worker/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
mod git;
2+
mod rss;
23
mod sync_admins;

src/tests/worker/rss/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
mod sync_updates_feed;

0 commit comments

Comments
 (0)