diff --git a/src/atproto/lexicon/mod.rs b/src/atproto/lexicon/mod.rs index fb64cc9..d9d3583 100644 --- a/src/atproto/lexicon/mod.rs +++ b/src/atproto/lexicon/mod.rs @@ -74,6 +74,21 @@ pub trait Record: Sized { .await .map_err(WriteError::Failed) } + + async fn delete( + &self, + state: &AppState, + session: &SessionCookie, + rkey: &Rkey, + ) -> Result<(), WriteError> { + self.validate().map_err(WriteError::Invalid)?; + pds::delete(state, session, Self::NSID, rkey.as_str()) + .await + .map_err(WriteError::Failed)?; + ModListing::delete_local(&state.db, &session.did, rkey) + .await + .map_err(WriteError::Failed) + } } /// Either the record was invalid (the caller's fault) or writing it failed @@ -139,6 +154,16 @@ impl Found { self.record.update(&self.state, session, &self.rkey).await?; Ok(()) } + + pub async fn delete(&self) -> Result<(), AppError> { + let session = self + .session + .as_ref() + .filter(|s| s.did == self.did) + .ok_or(AppError::Forbidden)?; + self.record.delete(&self.state,session,&self.rkey).await?; + Ok(()) + } } /// Lexicon `maxLength` counts UTF-8 bytes. diff --git a/src/atproto/pds.rs b/src/atproto/pds.rs index 58d62a2..664f43a 100644 --- a/src/atproto/pds.rs +++ b/src/atproto/pds.rs @@ -2,7 +2,7 @@ use anyhow::{bail, Result}; use atproto_client::client::{Auth, DPoPAuth}; -use atproto_client::com::atproto::repo::{put_record, PutRecordRequest, PutRecordResponse}; +use atproto_client::com::atproto::repo::{put_record, PutRecordRequest, PutRecordResponse, delete_record, DeleteRecordRequest, DeleteRecordResponse}; use atproto_identity::key::identify_key; use serde_json::Value; @@ -37,9 +37,39 @@ pub async fn put( }, ) .await?; - match response { PutRecordResponse::StrongRef { .. } => Ok(()), PutRecordResponse::Error(err) => bail!("PDS rejected the record: {err:?}"), } } + +/// `com.atproto.repo.deleteRecord`, authenticated as `session`'s owner. +pub async fn delete( + state: &AppState, + session: &SessionCookie, + collection: &str, + rkey: &str, +) -> Result<()> { + let dpop_auth = DPoPAuth { + dpop_private_key_data: identify_key(&session.dpop_private_key)?, + oauth_access_token: session.access_token.clone(), + }; + + let response = delete_record( + &state.http_client, + &Auth::DPoP(dpop_auth), + &session.pds_endpoint, + DeleteRecordRequest { + repo: session.did.to_string(), + collection: collection.to_string(), + record_key: rkey.to_string(), + swap_commit: None, + swap_record: None, + } + ) + .await?; + match response { + DeleteRecordResponse::Commit { .. } => Ok(()), + DeleteRecordResponse::Error(err) => bail!("PDS rejected the delete: {err:?}"), + } +} \ No newline at end of file diff --git a/src/mods.rs b/src/mods.rs index 76fe151..1aec4f8 100644 --- a/src/mods.rs +++ b/src/mods.rs @@ -5,7 +5,7 @@ use axum::extract::{Form, FromRequestParts, Path, State}; use axum::http::request::Parts; -use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::response::{Html, IntoResponse, Redirect, Response,AppendHeaders}; use axum::routing::get; use axum::Router; use chrono::Utc; @@ -25,7 +25,7 @@ pub fn router() -> Router { .route("/create", get(handle_get_create).post(handle_post_create)) .route( "/{handle}/{slug}", - get(handle_get_mod).post(handle_post_mod), + get(handle_get_mod).post(handle_post_mod).delete(handle_delete_mod), ) } @@ -176,7 +176,7 @@ async fn handle_get_mod(existing: Found, page: Page) -> Html } @if is_owner { (edit_form(&existing.handle, listing)) - (delete_form(&existing.handle,listing)) + (delete_form(&existing.handle, listing)) } }) .title(&listing.title) @@ -224,14 +224,12 @@ fn edit_form(handle: &Handle, listing: &ModListing) -> Markup { fn delete_form(handle: &Handle, listing: &ModListing) -> Markup { html! { - div.centered { form.bg-pink method="post" action={ "/" (handle) "/" (listing.slug) } { + div.centered { div.label { label {"Are you sure you want to delete this mod? (It's permanent!)"} } - div.form-actions { - button.accent-red type="submit" { "Delete Mod" } - } - } } + button.accent-red hx-delete={ "/" (handle) "/" (listing.slug)} { "Delete Mod" } + } } } @@ -264,3 +262,10 @@ async fn handle_post_mod( existing.save().await?; Ok(Redirect::to(&format!("/{}/{}", existing.handle, existing.record.slug)).into_response()) } + +async fn handle_delete_mod( + existing: Found + ) -> Result { + existing.delete().await?; + Ok(AppendHeaders([("HX-Replace-Url","/"),("HX-Push-Url","/"),("HX-Location","/")]).into_response()) +} \ No newline at end of file diff --git a/tests/test_mod_listing.py b/tests/test_mod_listing.py index b2007dd..3d92556 100644 --- a/tests/test_mod_listing.py +++ b/tests/test_mod_listing.py @@ -34,3 +34,43 @@ def test_create_mod_listing_writes_to_pds( assert record["value"]["title"] == "Test Mod" assert record["value"]["description"] == "A mod created by an E2E test." assert record["value"]["$type"] == "dev.starhaven.mod.listing" + +def test_delete_mod_listing_writes_to_pds( + page: Page, appview_url: str, pds_url: str, alice_did: str +): + login(page, appview_url) + + slug = f"test-mod-{int(time.time() * 1000)}" + + page.goto(f"{appview_url}/create") + page.get_by_label("Title").fill("Test Mod") + page.get_by_label("Slug").fill(slug) + page.get_by_label("Description").fill("A mod created by an E2E test.") + page.get_by_role("button", name="Create mod").click() + + expect(page).to_have_url(f"{appview_url}/alice.test/{slug}") + expect(page.get_by_role("heading", name="Test Mod")).to_be_visible() + + # Confirm item exists before we delete + response = requests.get( + f"{pds_url}/xrpc/com.atproto.repo.listRecords", + params={"repo": alice_did, "collection": "dev.starhaven.mod.listing"}, + timeout=5, + ) + + page.get_by_role("button",name="Delete Mod").click() + expect(page).to_have_url(f"{appview_url}/") + + response = requests.get( + f"{pds_url}/xrpc/com.atproto.repo.listRecords", + params={"repo": alice_did, "collection": "dev.starhaven.mod.listing"}, + timeout=5, + ) + response.raise_for_status() + records = response.json()["records"] + record = "" + try: + record = next(r for r in records if r["value"]["slug"] == slug) + except StopIteration: + # Iterated through the whole list and found nothing, so it was deleted successfully + assert record == "" \ No newline at end of file