diff --git a/crates/crates_io_database/src/schema.rs b/crates/crates_io_database/src/schema.rs index 1fe23e88ace..b0696b4c753 100644 --- a/crates/crates_io_database/src/schema.rs +++ b/crates/crates_io_database/src/schema.rs @@ -411,6 +411,26 @@ diesel::table! { } } +diesel::table! { + /// Crates that have been deleted by users + deleted_crates (id) { + /// Unique identifier of the `deleted_crates` row + id -> Int4, + /// Name of the deleted crate (use `canon_crate_name()` for normalization, if needed) + name -> Varchar, + /// Date and time when the crate was created + created_at -> Timestamptz, + /// Date and time when the crate was deleted + deleted_at -> Timestamptz, + /// ID of the user who deleted the crate, or NULL if the user was deleted + deleted_by -> Nullable, + /// Optional message left by the user who deleted the crate + message -> Nullable, + /// Date and time when users will be able to create a new crate with the same name + available_at -> Timestamptz, + } +} + diesel::table! { /// Representation of the `dependencies` table. /// @@ -1026,6 +1046,7 @@ diesel::joinable!(crates_keywords -> crates (crate_id)); diesel::joinable!(crates_keywords -> keywords (keyword_id)); diesel::joinable!(default_versions -> crates (crate_id)); diesel::joinable!(default_versions -> versions (version_id)); +diesel::joinable!(deleted_crates -> users (deleted_by)); diesel::joinable!(dependencies -> crates (crate_id)); diesel::joinable!(dependencies -> versions (version_id)); diesel::joinable!(emails -> users (user_id)); @@ -1054,6 +1075,7 @@ diesel::allow_tables_to_appear_in_same_query!( crates_categories, crates_keywords, default_versions, + deleted_crates, dependencies, emails, follows, diff --git a/crates/crates_io_database_dump/src/dump-db.toml b/crates/crates_io_database_dump/src/dump-db.toml index b9fab4d7b1e..8e7c595fc81 100644 --- a/crates/crates_io_database_dump/src/dump-db.toml +++ b/crates/crates_io_database_dump/src/dump-db.toml @@ -106,6 +106,17 @@ dependencies = ["crates", "versions"] crate_id = "public" version_id = "public" +[deleted_crates] +dependencies = ["users"] +[deleted_crates.columns] +id = "private" +name = "private" +created_at = "private" +deleted_at = "private" +deleted_by = "private" +message = "private" +available_at = "private" + [dependencies] dependencies = ["crates", "versions"] [dependencies.columns] diff --git a/migrations/2024-11-12-125605_create-deleted-crates-table/down.sql b/migrations/2024-11-12-125605_create-deleted-crates-table/down.sql new file mode 100644 index 00000000000..67888dffeec --- /dev/null +++ b/migrations/2024-11-12-125605_create-deleted-crates-table/down.sql @@ -0,0 +1 @@ +drop table deleted_crates; diff --git a/migrations/2024-11-12-125605_create-deleted-crates-table/up.sql b/migrations/2024-11-12-125605_create-deleted-crates-table/up.sql new file mode 100644 index 00000000000..6b9bca145da --- /dev/null +++ b/migrations/2024-11-12-125605_create-deleted-crates-table/up.sql @@ -0,0 +1,25 @@ +create table deleted_crates +( + id serial primary key, + name varchar not null, + created_at timestamptz not null, + deleted_at timestamptz not null, + deleted_by integer + constraint deleted_crates_users_id_fk + references users + on delete set null, + message varchar, + available_at timestamptz not null +); + +comment on table deleted_crates is 'Crates that have been deleted by users'; +comment on column deleted_crates.id is 'Unique identifier of the `deleted_crates` row'; +comment on column deleted_crates.name is 'Name of the deleted crate (use `canon_crate_name()` for normalization, if needed)'; +comment on column deleted_crates.created_at is 'Date and time when the crate was created'; +comment on column deleted_crates.deleted_at is 'Date and time when the crate was deleted'; +comment on column deleted_crates.deleted_by is 'ID of the user who deleted the crate, or NULL if the user was deleted'; +comment on column deleted_crates.message is 'Optional message left by the user who deleted the crate'; +comment on column deleted_crates.available_at is 'Date and time when users will be able to create a new crate with the same name'; + +create index deleted_crates_canon_crate_name_index + on deleted_crates (canon_crate_name(name)); diff --git a/src/bin/crates-admin/delete_crate.rs b/src/bin/crates-admin/delete_crate.rs index 9f7876de8c4..190344b01e8 100644 --- a/src/bin/crates-admin/delete_crate.rs +++ b/src/bin/crates-admin/delete_crate.rs @@ -1,7 +1,9 @@ use crate::dialoguer; use anyhow::Context; +use chrono::{NaiveDateTime, Utc}; use colored::Colorize; -use crates_io::schema::crate_downloads; +use crates_io::models::{NewDeletedCrate, User}; +use crates_io::schema::{crate_downloads, deleted_crates}; use crates_io::worker::jobs; use crates_io::{db, schema::crates}; use crates_io_worker::BackgroundJob; @@ -9,7 +11,8 @@ use diesel::dsl::sql; use diesel::expression::SqlLiteral; use diesel::prelude::*; use diesel::sql_types::{Array, BigInt, Text}; -use diesel_async::RunQueryDsl; +use diesel_async::scoped_futures::ScopedFutureExt; +use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl}; use std::fmt::Display; #[derive(clap::Parser, Debug)] @@ -26,6 +29,14 @@ pub struct Opts { /// Don't ask for confirmation: yes, we are sure. Best for scripting. #[arg(short, long)] yes: bool, + + /// Your GitHub username. + #[arg(long)] + deleted_by: String, + + /// An optional message explaining why the crate was deleted. + #[arg(long)] + message: Option, } pub async fn run(opts: Opts) -> anyhow::Result<()> { @@ -44,6 +55,10 @@ pub async fn run(opts: Opts) -> anyhow::Result<()> { .await .context("Failed to look up crate name from the database")?; + let deleted_by = User::async_find_by_login(&mut conn, &opts.deleted_by) + .await + .context("Failed to look up `--deleted-by` user from the database")?; + println!("Deleting the following crates:"); println!(); for name in &crate_names { @@ -58,17 +73,29 @@ pub async fn run(opts: Opts) -> anyhow::Result<()> { return Ok(()); } + let now = Utc::now(); + for name in &crate_names { if let Some(crate_info) = existing_crates.iter().find(|info| info.name == *name) { let id = crate_info.id; + let created_at = crate_info.created_at.and_utc(); + let deleted_crate = NewDeletedCrate::builder(name) + .created_at(&created_at) + .deleted_at(&now) + .deleted_by(deleted_by.id) + .maybe_message(opts.message.as_deref()) + .available_at(&now) + .build(); + info!("{name}: Deleting crate from the database…"); - if let Err(error) = diesel::delete(crates::table.find(id)) - .execute(&mut conn) - .await - { + let result = conn + .transaction(|conn| delete_from_database(conn, id, deleted_crate).scope_boxed()) + .await; + + if let Err(error) = result { warn!(%id, "{name}: Failed to delete crate from the database: {error}"); - } + }; } else { info!("{name}: Skipped missing crate"); }; @@ -94,12 +121,31 @@ pub async fn run(opts: Opts) -> anyhow::Result<()> { Ok(()) } +async fn delete_from_database( + conn: &mut AsyncPgConnection, + crate_id: i32, + deleted_crate: NewDeletedCrate<'_>, +) -> anyhow::Result<()> { + diesel::delete(crates::table.find(crate_id)) + .execute(conn) + .await?; + + diesel::insert_into(deleted_crates::table) + .values(deleted_crate) + .execute(conn) + .await?; + + Ok(()) +} + #[derive(Debug, Clone, Queryable, Selectable)] struct CrateInfo { #[diesel(select_expression = crates::columns::name)] name: String, #[diesel(select_expression = crates::columns::id)] id: i32, + #[diesel(select_expression = crates::columns::created_at)] + created_at: NaiveDateTime, #[diesel(select_expression = crate_downloads::columns::downloads)] downloads: i64, #[diesel(select_expression = owners_subquery())] diff --git a/src/controllers/krate/publish.rs b/src/controllers/krate/publish.rs index 9b1cf49e7d6..8dde9be8473 100644 --- a/src/controllers/krate/publish.rs +++ b/src/controllers/krate/publish.rs @@ -9,6 +9,7 @@ use crate::worker::jobs::{ use axum::body::Bytes; use axum::Json; use cargo_manifest::{Dependency, DepsSet, TargetDepsSet}; +use chrono::{DateTime, SecondsFormat, Utc}; use crates_io_tarball::{process_tarball, TarballError}; use crates_io_worker::BackgroundJob; use diesel::connection::DefaultLoadingMode; @@ -86,6 +87,22 @@ pub async fn publish(app: AppState, req: BytesRequest) -> AppResult)> = deleted_crates::table + .filter(canon_crate_name(deleted_crates::name).eq(canon_crate_name(&metadata.name))) + .filter(deleted_crates::available_at.gt(Utc::now())) + .select((deleted_crates::name, deleted_crates::available_at)) + .first(&mut conn) + .await + .optional()?; + + if let Some(deleted_crate) = deleted_crate { + return Err(bad_request(format!( + "A crate with the name `{}` was recently deleted. Reuse of this name will be available after {}.", + deleted_crate.0, + deleted_crate.1.to_rfc3339_opts(SecondsFormat::Secs, true) + ))); + } + // this query should only be used for the endpoint scope calculation // since a race condition there would only cause `publish-new` instead of // `publish-update` to be used. diff --git a/src/models.rs b/src/models.rs index f6c97b195f0..7121c7aa222 100644 --- a/src/models.rs +++ b/src/models.rs @@ -2,6 +2,7 @@ pub use self::action::{insert_version_owner_action, VersionAction, VersionOwnerA pub use self::category::{Category, CrateCategory, NewCategory}; pub use self::crate_owner_invitation::{CrateOwnerInvitation, NewCrateOwnerInvitationOutcome}; pub use self::default_versions::{update_default_version, verify_default_version}; +pub use self::deleted_crate::NewDeletedCrate; pub use self::dependency::{Dependency, DependencyKind, ReverseDependency}; pub use self::download::VersionDownload; pub use self::email::{Email, NewEmail}; @@ -21,6 +22,7 @@ mod action; pub mod category; mod crate_owner_invitation; pub mod default_versions; +mod deleted_crate; pub mod dependency; mod download; mod email; diff --git a/src/models/deleted_crate.rs b/src/models/deleted_crate.rs new file mode 100644 index 00000000000..a21d667589f --- /dev/null +++ b/src/models/deleted_crate.rs @@ -0,0 +1,16 @@ +use bon::Builder; +use chrono::{DateTime, Utc}; +use crates_io_database::schema::deleted_crates; + +/// Struct used to `INSERT` a new `deleted_crates` record into the database. +#[derive(Insertable, Debug, Builder)] +#[diesel(table_name = deleted_crates, check_for_backend(diesel::pg::Pg))] +pub struct NewDeletedCrate<'a> { + #[builder(start_fn)] + name: &'a str, + created_at: &'a DateTime, + deleted_at: &'a DateTime, + deleted_by: Option, + message: Option<&'a str>, + available_at: &'a DateTime, +} diff --git a/src/models/user.rs b/src/models/user.rs index 88a7307debe..80ef72f96a4 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -51,6 +51,20 @@ impl User { .first(conn) } + pub async fn async_find_by_login( + conn: &mut AsyncPgConnection, + login: &str, + ) -> QueryResult { + use diesel_async::RunQueryDsl; + + users::table + .filter(lower(users::gh_login).eq(login.to_lowercase())) + .filter(users::gh_id.ne(-1)) + .order(users::gh_id.desc()) + .first(conn) + .await + } + pub fn owning(krate: &Crate, conn: &mut impl Conn) -> QueryResult> { use diesel::RunQueryDsl; diff --git a/src/tests/krate/publish/deleted_crates.rs b/src/tests/krate/publish/deleted_crates.rs new file mode 100644 index 00000000000..695fece47fb --- /dev/null +++ b/src/tests/krate/publish/deleted_crates.rs @@ -0,0 +1,39 @@ +use crate::models::NewDeletedCrate; +use crate::tests::builders::PublishBuilder; +use crate::tests::util::{RequestHelper, TestApp}; +use chrono::{Duration, Utc}; +use crates_io_database::schema::deleted_crates; +use diesel_async::RunQueryDsl; +use googletest::prelude::*; +use http::StatusCode; +use insta::assert_snapshot; + +#[tokio::test(flavor = "multi_thread")] +async fn test_recently_deleted_crate_with_same_name() -> anyhow::Result<()> { + let (app, _, _, token) = TestApp::full().with_token(); + let mut conn = app.async_db_conn().await; + + let now = Utc::now(); + let created_at = now - Duration::hours(24); + let deleted_at = now - Duration::hours(1); + let available_at = "2099-12-25T12:34:56Z".parse()?; + + let deleted_crate = NewDeletedCrate::builder("actix_web") + .created_at(&created_at) + .deleted_at(&deleted_at) + .available_at(&available_at) + .build(); + + diesel::insert_into(deleted_crates::table) + .values(deleted_crate) + .execute(&mut conn) + .await?; + + let crate_to_publish = PublishBuilder::new("actix-web", "1.0.0"); + let response = token.publish_crate(crate_to_publish).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"A crate with the name `actix_web` was recently deleted. Reuse of this name will be available after 2099-12-25T12:34:56Z."}]}"#); + assert_that!(app.stored_files().await, empty()); + + Ok(()) +} diff --git a/src/tests/krate/publish/mod.rs b/src/tests/krate/publish/mod.rs index 0c86e35c871..f98106367db 100644 --- a/src/tests/krate/publish/mod.rs +++ b/src/tests/krate/publish/mod.rs @@ -3,6 +3,7 @@ mod auth; mod basics; mod build_metadata; mod categories; +mod deleted_crates; mod dependencies; mod emails; mod features;