🐸 Frogue(Fed)

~ololduck/Frogue

feat(webui): clickable note authors with remote avatars

c768b122026-10-25T12:06:31+01:00Ololduck

24 files changed, +443, βˆ’47
README.md+1βˆ’1
@@ -34,7 +34,7 @@ These are features that are not considered planned, either at the moment or ever
So, it's still heavily a work in progress but when Federation support is enabled you:
- Have a fediverse handle (e.g., `@alice@forge.example.org`)
- Publish notes that appear in their followers' feeds
βˆ’- Receive follows, likes, and announces from other ActivityPub-compatible platforms (same as above)
+- Receive follows, likes, and announces from other ActivityPub-compatible platforms
- Create and federate tickets across ForgeFed-compatible forges
- Submit patches (merge requests) via federation from remote repositories
- Federate ticket and patch discussions as ActivityPub conversations
TODO.md+3βˆ’8
@@ -76,16 +76,9 @@ This is an ActivityPub-compatible stream of messages and updates related to the
- [x] Follow remote actors with Accept/Reject handling
- [x] Receive and store remote notes from followed actors
- [x] Logged-in home feed with remote notes
+- [ ] Proxy remote avatar images server-side to protect viewer privacy
- [x] Federate reactions on remote notes
βˆ’- [x] Phase 0: Unified outbound HTTP signing and remote actor cache improvements
βˆ’- [x] Phase 1: Webfinger-based remote actor discovery and SSRF protection
βˆ’ - [ ] Fix SSRF DNS-rebinding TOCTOU: DNS is checked before request but could change between check and actual request
βˆ’- [x] Phase 2: Follow remote actors with Accept/Reject handling
βˆ’- [x] Phase 3: Receive and store remote notes from followed actors
βˆ’- [x] Phase 4: Logged-in home feed with remote notes
βˆ’- [x] Phase 5: Federate reactions on remote notes
βˆ’
### Repository `/~{user_or_team}/{repo_slug}`
- [x] Rename the route names:
@@ -153,6 +146,8 @@ They have a summary (≃title) with markdown, a description with markdown, a lis
- [x] edit
- [x] delete
- [x] attachments (with size and count limits, configurable via envvars)
+ - [ ] dumbass forgot to put form entries to upload attachements…
+ - [ ] dumbass forgotΒ² to put envvars for config…
- [x] comments
- [ ] FTS search
- [x] federation for comments (migrate comments to proper Notes)
frogue/src/activitypub/feed.rs+11βˆ’5
@@ -22,6 +22,7 @@ pub enum FeedEntry {
content_html: String,
created_at: DateTime<FixedOffset>,
author_username: String,
+ author_icon_url: Option<String>,
reacted: bool,
boosted: bool,
likes: u64,
@@ -36,6 +37,8 @@ pub enum FeedEntry {
published: DateTime<FixedOffset>,
author_username: String,
author_host: String,
+ author_icon_url: Option<String>,
+ author_actor_url: String,
reacted: bool,
boosted: bool,
likes: u64,
@@ -167,6 +170,8 @@ pub async fn get_home_feed(
published: note.published,
author_username,
author_host,
+ author_icon_url: remote_actor.icon_url.clone(),
+ author_actor_url: remote_actor.actor_url.clone(),
reacted: false, // Will be updated below
boosted: false, // Will be updated below
likes: 0, // Will be updated below
@@ -194,11 +199,12 @@ pub async fn get_home_feed(
content_html: note.content_html,
created_at: note.created_at,
author_username: user.name.clone(),
βˆ’ reacted: false, // Will be updated below
βˆ’ boosted: false, // Will be updated below
βˆ’ likes: 0, // Will be updated below
βˆ’ announces: 0, // Will be updated below
βˆ’ emojis: Vec::new(), // Will be updated below
+ author_icon_url: None, // Local users don't have avatars yet
+ reacted: false, // Will be updated below
+ boosted: false, // Will be updated below
+ likes: 0, // Will be updated below
+ announces: 0, // Will be updated below
+ emojis: Vec::new(), // Will be updated below
});
}
frogue/src/activitypub/remote/mod.rs+56βˆ’0
@@ -33,6 +33,58 @@ struct RemoteActor {
name: Option<String>,
#[serde(default, rename = "preferredUsername")]
preferred_username: Option<String>,
+ #[serde(default, deserialize_with = "deserialize_icon")]
+ icon: Option<String>,
+}
+
+/// Deserialize the icon field, which can be either a URL string or an Image object
+fn deserialize_icon<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ use serde::de::{self, Visitor};
+ use std::fmt;
+
+ struct IconVisitor;
+
+ impl<'de> Visitor<'de> for IconVisitor {
+ type Value = Option<String>;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ formatter.write_str("a string URL or an Image object with a url field")
+ }
+
+ fn visit_none<E>(self) -> Result<Self::Value, E>
+ where
+ E: de::Error,
+ {
+ Ok(None)
+ }
+
+ fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ #[derive(Deserialize)]
+ struct Image {
+ url: String,
+ }
+
+ // Try to deserialize as either a string or an object
+ let value = serde_json::Value::deserialize(deserializer)?;
+
+ match value {
+ serde_json::Value::String(url) => Ok(Some(url)),
+ serde_json::Value::Object(_) => {
+ let image: Image = serde_json::from_value(value).map_err(de::Error::custom)?;
+ Ok(Some(image.url))
+ }
+ _ => Ok(None),
+ }
+ }
+ }
+
+ deserializer.deserialize_option(IconVisitor)
}
#[derive(Debug, Deserialize)]
@@ -335,6 +387,7 @@ async fn fetch_actor_signed(
active.public_key_id = Set(remote_actor.public_key.id);
active.name = Set(remote_actor.name);
active.preferred_username = Set(remote_actor.preferred_username);
+ active.icon_url = Set(remote_actor.icon);
active.fetched_at = Set(chrono::Utc::now().into());
let updated = active.update(db).await?;
info!(actor_url, "updated cached remote actor");
@@ -349,6 +402,7 @@ async fn fetch_actor_signed(
public_key_id: Set(remote_actor.public_key.id),
name: Set(remote_actor.name),
preferred_username: Set(remote_actor.preferred_username),
+ icon_url: Set(remote_actor.icon),
..Default::default()
};
@@ -479,6 +533,7 @@ pub async fn resolve_actor(
active.public_key_id = Set(remote_actor.public_key.id);
active.name = Set(remote_actor.name);
active.preferred_username = Set(remote_actor.preferred_username);
+ active.icon_url = Set(remote_actor.icon);
active.fetched_at = Set(chrono::Utc::now().into());
let updated = active.update(db).await?;
info!(actor_url, "updated cached remote actor");
@@ -493,6 +548,7 @@ pub async fn resolve_actor(
public_key_id: Set(remote_actor.public_key.id),
name: Set(remote_actor.name),
preferred_username: Set(remote_actor.preferred_username),
+ icon_url: Set(remote_actor.icon),
..Default::default()
};
frogue/src/entities/ap_remote_actors.rs+1βˆ’0
@@ -19,6 +19,7 @@ pub struct Model {
pub public_key_id: String,
pub name: Option<String>,
pub preferred_username: Option<String>,
+ pub icon_url: Option<String>,
pub fetched_at: DateTimeWithTimeZone,
}
frogue/src/views/index.rs+8βˆ’0
@@ -126,6 +126,7 @@ fn render_feed_entry(
content_html,
created_at,
author_username,
+ author_icon_url,
reacted,
boosted,
likes,
@@ -133,11 +134,14 @@ fn render_feed_entry(
emojis,
} => {
let permalink = format!("{public_url}/~{author_username}/notes/{uuid}");
+ let actor_url = format!("/~{author_username}");
let author = NoteAuthor {
display_name: author_username.clone(),
handle: NoteAuthorHandle::Local {
username: author_username.clone(),
+ actor_url,
},
+ icon_url: author_icon_url.clone(),
};
let target = ActionTarget::Local {
user: author_username.clone(),
@@ -164,6 +168,8 @@ fn render_feed_entry(
published,
author_username,
author_host,
+ author_icon_url,
+ author_actor_url,
reacted,
boosted,
likes,
@@ -176,7 +182,9 @@ fn render_feed_entry(
handle: NoteAuthorHandle::Remote {
username: author_username.clone(),
host: author_host.clone(),
+ actor_url: author_actor_url.clone(),
},
+ icon_url: author_icon_url.clone(),
};
let target = ActionTarget::Remote {
id: *remote_note_id,
frogue/src/views/notes.rs+70βˆ’11
@@ -63,34 +63,81 @@ pub struct NoteActions {
pub struct NoteAuthor {
pub display_name: String,
pub handle: NoteAuthorHandle,
+ pub icon_url: Option<String>,
}
impl Render for NoteAuthor {
fn render(&self) -> Markup {
html! {
span class="note-author" {
βˆ’ span class="note-author-display-name" { (self.display_name) }
βˆ’ " "
+ (Avatar {
+ icon_url: self.icon_url.clone(),
+ alt: self.display_name.clone(),
+ })
(self.handle)
}
}
}
}
+/// Avatar image with fallback to frogue logo
+pub struct Avatar {
+ pub icon_url: Option<String>,
+ pub alt: String,
+}
+
+impl Render for Avatar {
+ fn render(&self) -> Markup {
+ let src = self
+ .icon_url
+ .as_deref()
+ .unwrap_or("/static/frogue-logo.svg");
+ html! {
+ img.avatar src=(src) alt=(self.alt) loading="lazy" width="24" height="24" {}
+ }
+ }
+}
+
/// A note author handle (local or remote).
pub enum NoteAuthorHandle {
βˆ’ Local { username: String },
βˆ’ Remote { username: String, host: String },
+ Local {
+ username: String,
+ actor_url: String,
+ },
+ Remote {
+ username: String,
+ host: String,
+ actor_url: String,
+ },
}
impl Render for NoteAuthorHandle {
fn render(&self) -> Markup {
βˆ’ html! {
βˆ’ span class="note-author-handle" {
βˆ’ (match self {
βˆ’ Self::Local { username } => format!("@{username}"),
βˆ’ Self::Remote { username, host } => format!("@{username}@{host}"),
βˆ’ })
+ match self {
+ Self::Local {
+ username,
+ actor_url,
+ } => {
+ html! {
+ a class="note-author-handle frogue-username" href=(actor_url) {
+ "~" (username)
+ }
+ }
+ }
+ Self::Remote {
+ username,
+ host,
+ actor_url,
+ } => {
+ html! {
+ span class="note-author-handle" {
+ a href=(actor_url) {
+ "@" (username) "@" (host)
+ }
+ " "
+ span class="remote-badge" { "remote" }
+ }
+ }
}
}
}
@@ -193,7 +240,9 @@ mod tests {
display_name: "Alice".to_string(),
handle: NoteAuthorHandle::Local {
username: "alice".to_string(),
+ actor_url: "/~alice".to_string(),
},
+ icon_url: None,
},
content_html: "<p>Hello world!</p>".to_string(),
created_at: components::DateTime::from(&Utc::now()),
@@ -208,7 +257,7 @@ mod tests {
assert!(html.contains("<p>Hello world!</p>"));
assert!(html.contains("permalink"));
assert!(html.contains("http://localhost/notes/1"));
βˆ’ assert!(html.contains("@alice"));
+ assert!(html.contains("~alice"));
}
#[test]
@@ -219,7 +268,9 @@ mod tests {
handle: NoteAuthorHandle::Remote {
username: "bob".to_string(),
host: "example.com".to_string(),
+ actor_url: "http://example.com/users/bob".to_string(),
},
+ icon_url: None,
},
content_html: "<p>Test</p>".to_string(),
created_at: components::DateTime::from(&Utc::now()),
@@ -246,7 +297,9 @@ mod tests {
display_name: "Alice".to_string(),
handle: NoteAuthorHandle::Local {
username: "alice".to_string(),
+ actor_url: "/~alice".to_string(),
},
+ icon_url: None,
},
content_html: "<p>My note</p>".to_string(),
created_at: components::DateTime::from(&Utc::now()),
@@ -280,7 +333,9 @@ mod tests {
display_name: "Bob".to_string(),
handle: NoteAuthorHandle::Local {
username: "bob".to_string(),
+ actor_url: "/~bob".to_string(),
},
+ icon_url: None,
},
content_html: "<p>Bob's note</p>".to_string(),
created_at: components::DateTime::from(&Utc::now()),
@@ -314,7 +369,9 @@ mod tests {
handle: NoteAuthorHandle::Remote {
username: "remote".to_string(),
host: "example.org".to_string(),
+ actor_url: "http://example.org/remote".to_string(),
},
+ icon_url: None,
},
content_html: "<p>Remote note</p>".to_string(),
created_at: components::DateTime::from(&Utc::now()),
@@ -344,7 +401,9 @@ mod tests {
display_name: "Test".to_string(),
handle: NoteAuthorHandle::Local {
username: "test".to_string(),
+ actor_url: "/~test".to_string(),
},
+ icon_url: None,
},
content_html: "<strong>Bold</strong> & <em>italic</em>".to_string(),
created_at: components::DateTime::from(&Utc::now()),
frogue/src/views/profile.rs+5βˆ’0
@@ -14,6 +14,8 @@ pub struct ActorProfile {
pub handle: String,
pub host: Option<String>,
pub profile_url: Option<String>,
+ pub icon_url: Option<String>,
+ pub actor_url: String,
}
impl Render for ActorProfile {
fn render(&self) -> Markup {
@@ -69,12 +71,15 @@ impl Render for NoteView {
NoteAuthorHandle::Remote {
username: self.actor_profile.handle.clone(),
host: host.clone(),
+ actor_url: self.actor_profile.actor_url.clone(),
}
} else {
NoteAuthorHandle::Local {
username: self.actor_profile.handle.clone(),
+ actor_url: self.actor_profile.actor_url.clone(),
}
},
+ icon_url: self.actor_profile.icon_url.clone(),
};
let is_author = self.viewer_username.as_ref() == Some(&self.actor_profile.handle);
frogue/src/webui/federation.rs+2βˆ’0
@@ -479,7 +479,9 @@ pub async fn get_note_html(
display_name: user.name.clone(),
handle: crate::views::NoteAuthorHandle::Local {
username: user.name.clone(),
+ actor_url: format!("/~{}", user.name),
},
+ icon_url: None, // Local users don't have avatars yet
};
let actions = viewer.as_ref().map(|_| crate::views::NoteActions {
frogue/src/webui/user.rs+2βˆ’0
@@ -161,6 +161,8 @@ pub(crate) async fn get_profile(
handle: user.name.clone(),
host: None,
profile_url: None,
+ icon_url: None, // Local users don't have avatars yet
+ actor_url: format!("/~{}", user.name),
},
note,
reactions,
frogue/tests/home_feed.rs+196βˆ’0
@@ -824,3 +824,199 @@ async fn feed_pagination_works() -> miette::Result<()> {
Ok(())
}
+
+/// Test: Local note author in feed links to profile
+#[tokio::test]
+async fn feed_local_author_links_to_profile() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut alice_client = Client::new(port);
+ register(&mut alice_client, "alice");
+
+ // Post a note
+ let response = alice_client.post_form("/~alice/notes", &[("content", "Alice's note")]);
+ assert_eq!(response.status, 303, "posting a note should redirect");
+
+ // Visit the home page
+ let response = alice_client.get("/");
+ assert_eq!(response.status, 200, "home page should load");
+ assert!(
+ response.body.contains("Alice's note"),
+ "feed should contain the note. Body:\n{}",
+ response.body
+ );
+
+ // The author should be a link to /~alice with ~alice format
+ assert!(
+ response.body.contains("href=\"/~alice\"") && response.body.contains("~alice"),
+ "author should link to profile with ~alice format. Body:\n{}",
+ response.body
+ );
+
+ // Should NOT have @alice format (that's for remotes)
+ assert!(
+ !response.body.contains("@alice"),
+ "local author should not use @alice format. Body:\n{}",
+ response.body
+ );
+
+ Ok(())
+}
+
+/// Test: Remote note author in feed links to actor URL with badge
+#[tokio::test]
+async fn feed_remote_author_links_to_actor_url() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut alice_client = Client::new(port);
+ register(&mut alice_client, "alice");
+
+ // Start a remote actor
+ let recorder_port = free_port();
+ let recorder = RemoteActor::serve(recorder_port);
+
+ // Follow the remote actor
+ follow_and_accept(&mut alice_client, port, &recorder, recorder_port);
+
+ // Send a Create(Note) activity from the remote actor
+ let note_json = serde_json::json!({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": format!("{}/activities/note_link_test", recorder.actor_url),
+ "type": "Create",
+ "actor": recorder.actor_url,
+ "object": {
+ "id": format!("{}/notes/note_link_test", recorder.actor_url),
+ "type": "Note",
+ "attributedTo": recorder.actor_url,
+ "content": "<p>Remote author test</p>",
+ "published": "2026-11-01T12:00:00Z"
+ }
+ });
+ let body = serde_json::to_string(&note_json).unwrap();
+ let (signature, date, digest) = support::ap::signed_post(
+ &recorder.private_key_pem,
+ &recorder.key_id,
+ &format!("127.0.0.1:{port}"),
+ "/~alice/inbox",
+ &body,
+ );
+ alice_client.post_json_with_headers(
+ "/~alice/inbox",
+ &body,
+ &[
+ ("Signature", &signature),
+ ("Date", &date),
+ ("Digest", &digest),
+ ("Content-Type", "application/activity+json"),
+ ],
+ );
+
+ // Give it a moment to process
+ std::thread::sleep(Duration::from_millis(100));
+
+ // Visit the home page
+ let response = alice_client.get("/");
+ assert_eq!(response.status, 200, "home page should load");
+ assert!(
+ response.body.contains("Remote author test"),
+ "feed should contain the note. Body:\n{}",
+ response.body
+ );
+
+ // The remote author should link to their actor URL
+ assert!(
+ response
+ .body
+ .contains(&format!("href=\"{}\"", recorder.actor_url)),
+ "remote author should link to their actor URL. Body:\n{}",
+ response.body
+ );
+
+ // Should have the remote badge
+ assert!(
+ response.body.contains("remote"),
+ "remote author should have 'remote' badge. Body:\n{}",
+ response.body
+ );
+
+ Ok(())
+}
+
+/// Test: Remote actor with icon URL renders avatar image
+#[tokio::test]
+async fn feed_remote_actor_with_icon_renders_avatar() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut alice_client = Client::new(port);
+ register(&mut alice_client, "alice");
+
+ // Start a remote actor (we'll need to serve an icon URL)
+ let recorder_port = free_port();
+ let recorder = RemoteActor::serve(recorder_port);
+
+ // Follow the remote actor
+ follow_and_accept(&mut alice_client, port, &recorder, recorder_port);
+
+ // Send a Create(Note) activity from the remote actor
+ // Note: The remote actor doesn't have an icon in the default RemoteActor,
+ // so this test will fail until we implement icon support.
+ // For now, we're testing that the fallback logo is used.
+ let note_json = serde_json::json!({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": format!("{}/activities/note_avatar_test", recorder.actor_url),
+ "type": "Create",
+ "actor": recorder.actor_url,
+ "object": {
+ "id": format!("{}/notes/note_avatar_test", recorder.actor_url),
+ "type": "Note",
+ "attributedTo": recorder.actor_url,
+ "content": "<p>Avatar test note</p>",
+ "published": "2026-11-01T12:00:00Z"
+ }
+ });
+ let body = serde_json::to_string(&note_json).unwrap();
+ let (signature, date, digest) = support::ap::signed_post(
+ &recorder.private_key_pem,
+ &recorder.key_id,
+ &format!("127.0.0.1:{port}"),
+ "/~alice/inbox",
+ &body,
+ );
+ alice_client.post_json_with_headers(
+ "/~alice/inbox",
+ &body,
+ &[
+ ("Signature", &signature),
+ ("Date", &date),
+ ("Digest", &digest),
+ ("Content-Type", "application/activity+json"),
+ ],
+ );
+
+ // Give it a moment to process
+ std::thread::sleep(Duration::from_millis(100));
+
+ // Visit the home page
+ let response = alice_client.get("/");
+ assert_eq!(response.status, 200, "home page should load");
+ assert!(
+ response.body.contains("Avatar test note"),
+ "feed should contain the note. Body:\n{}",
+ response.body
+ );
+
+ // Should show the frogue logo as fallback (since remote actor has no icon)
+ assert!(
+ response.body.contains("frogue-logo.svg"),
+ "should show frogue logo as fallback. Body:\n{}",
+ response.body
+ );
+
+ Ok(())
+}
migration/src/lib.rs+24βˆ’22
@@ -13,18 +13,19 @@ mod m20261022_000010_add_state_to_tickets;
mod m20261023_000011_create_ap_actors;
mod m20261023_000012_create_ap_remote_actors;
mod m20261025_000013_create_ap_notes;
+mod m20261025_000014_create_ap_deliveries;
+mod m20261025_000015_create_ap_reactions;
+mod m20261025_000016_create_invites_table;
+mod m20261025_000017_create_ap_following;
+mod m20261025_000018_create_ap_remote_notes;
+mod m20261025_000019_add_remote_note_to_reactions;
+mod m20261025_000020_federate_tickets;
+mod m20261025_000021_federate_patch_proposals;
+mod m20261025_000022_federate_comments;
+mod m20261025_000023_create_ap_remote_tickets;
+mod m20261025_000024_create_ap_remote_actor_tombstones;
mod m20261025_000025_add_snapshot_oids_to_patch_proposals;
βˆ’mod m20261027_000014_create_ap_deliveries;
βˆ’mod m20261029_000015_create_ap_reactions;
βˆ’mod m20261030_000016_create_invites_table;
βˆ’mod m20261101_000017_create_ap_following;
βˆ’mod m20261101_000018_create_ap_remote_notes;
βˆ’mod m20261102_000019_add_remote_note_to_reactions;
βˆ’mod m20261103_000020_federate_tickets;
βˆ’mod m20261103_000021_federate_patch_proposals;
βˆ’mod m20261103_000022_federate_comments;
βˆ’mod m20261103_000023_create_ap_remote_tickets;
βˆ’mod m20261103_000024_create_ap_remote_actor_tombstones;
+mod m20261025_000026_add_icon_url_to_ap_remote_actors;
pub struct Migrator;
@@ -45,18 +46,19 @@ impl MigratorTrait for Migrator {
Box::new(m20261023_000011_create_ap_actors::Migration),
Box::new(m20261023_000012_create_ap_remote_actors::Migration),
Box::new(m20261025_000013_create_ap_notes::Migration),
βˆ’ Box::new(m20261027_000014_create_ap_deliveries::Migration),
βˆ’ Box::new(m20261029_000015_create_ap_reactions::Migration),
βˆ’ Box::new(m20261030_000016_create_invites_table::Migration),
βˆ’ Box::new(m20261101_000017_create_ap_following::Migration),
βˆ’ Box::new(m20261101_000018_create_ap_remote_notes::Migration),
βˆ’ Box::new(m20261102_000019_add_remote_note_to_reactions::Migration),
βˆ’ Box::new(m20261103_000020_federate_tickets::Migration),
βˆ’ Box::new(m20261103_000021_federate_patch_proposals::Migration),
βˆ’ Box::new(m20261103_000022_federate_comments::Migration),
βˆ’ Box::new(m20261103_000023_create_ap_remote_tickets::Migration),
βˆ’ Box::new(m20261103_000024_create_ap_remote_actor_tombstones::Migration),
+ Box::new(m20261025_000014_create_ap_deliveries::Migration),
+ Box::new(m20261025_000015_create_ap_reactions::Migration),
+ Box::new(m20261025_000016_create_invites_table::Migration),
+ Box::new(m20261025_000017_create_ap_following::Migration),
+ Box::new(m20261025_000018_create_ap_remote_notes::Migration),
+ Box::new(m20261025_000019_add_remote_note_to_reactions::Migration),
+ Box::new(m20261025_000020_federate_tickets::Migration),
+ Box::new(m20261025_000021_federate_patch_proposals::Migration),
+ Box::new(m20261025_000022_federate_comments::Migration),
+ Box::new(m20261025_000023_create_ap_remote_tickets::Migration),
+ Box::new(m20261025_000024_create_ap_remote_actor_tombstones::Migration),
Box::new(m20261025_000025_add_snapshot_oids_to_patch_proposals::Migration),
+ Box::new(m20261025_000026_add_icon_url_to_ap_remote_actors::Migration),
]
}
}
migration/src/m20261027_000014_create_ap_deliveries.rs β†’ migration/src/m20261025_000014_create_ap_deliveries.rs+0βˆ’0
migration/src/m20261029_000015_create_ap_reactions.rs β†’ migration/src/m20261025_000015_create_ap_reactions.rs+0βˆ’0
migration/src/m20261030_000016_create_invites_table.rs β†’ migration/src/m20261025_000016_create_invites_table.rs+0βˆ’0
migration/src/m20261101_000017_create_ap_following.rs β†’ migration/src/m20261025_000017_create_ap_following.rs+0βˆ’0
migration/src/m20261101_000018_create_ap_remote_notes.rs β†’ migration/src/m20261025_000018_create_ap_remote_notes.rs+0βˆ’0
migration/src/m20261102_000019_add_remote_note_to_reactions.rs β†’ migration/src/m20261025_000019_add_remote_note_to_reactions.rs+0βˆ’0
migration/src/m20261103_000020_federate_tickets.rs β†’ migration/src/m20261025_000020_federate_tickets.rs+0βˆ’0
migration/src/m20261103_000021_federate_patch_proposals.rs β†’ migration/src/m20261025_000021_federate_patch_proposals.rs+0βˆ’0
migration/src/m20261103_000022_federate_comments.rs β†’ migration/src/m20261025_000022_federate_comments.rs+0βˆ’0
migration/src/m20261103_000023_create_ap_remote_tickets.rs β†’ migration/src/m20261025_000023_create_ap_remote_tickets.rs+0βˆ’0
migration/src/m20261103_000024_create_ap_remote_actor_tombstones.rs β†’ migration/src/m20261025_000024_create_ap_remote_actor_tombstones.rs+0βˆ’0
migration/src/m20261025_000026_add_icon_url_to_ap_remote_actors.rs+64βˆ’0
@@ -0,0 +1,64 @@
+//! Migration to add icon_url column to ap_remote_actors table
+//!
+//! This allows storing the avatar/icon URL for remote actors.
+
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .get_connection()
+ .execute_unprepared("ALTER TABLE ap_remote_actors ADD COLUMN icon_url TEXT")
+ .await?;
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .get_connection()
+ .execute_unprepared(
+ r#"
+ CREATE TABLE ap_remote_actors_backup (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ actor_url TEXT NOT NULL UNIQUE,
+ inbox_url TEXT NOT NULL,
+ shared_inbox_url TEXT,
+ public_key_pem TEXT NOT NULL,
+ public_key_id TEXT NOT NULL,
+ name TEXT,
+ preferred_username TEXT,
+ fetched_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ "#,
+ )
+ .await?;
+
+ manager
+ .get_connection()
+ .execute_unprepared(
+ r#"
+ INSERT INTO ap_remote_actors_backup
+ SELECT id, actor_url, inbox_url, shared_inbox_url, public_key_pem,
+ public_key_id, name, preferred_username, fetched_at
+ FROM ap_remote_actors
+ "#,
+ )
+ .await?;
+
+ manager
+ .get_connection()
+ .execute_unprepared("DROP TABLE ap_remote_actors")
+ .await?;
+
+ manager
+ .get_connection()
+ .execute_unprepared("ALTER TABLE ap_remote_actors_backup RENAME TO ap_remote_actors")
+ .await?;
+
+ Ok(())
+ }
+}