Compare commits

..

4 Commits

Author SHA1 Message Date
b20b60ad10 fix: add broadcast_move stub to TestStore
Some checks failed
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (push) Has been cancelled
2026-05-28 01:50:37 +02:00
54bd2b60d0 feat: add POST /federation/me/move endpoint 2026-05-28 01:47:29 +02:00
94193f2d2e feat: bump k-ap to v0.1.9 and implement migrate_follower_actor 2026-05-28 01:43:06 +02:00
f54fb543b2 feat: update k-ap dependency to v0.1.8 and enhance middleware for ActivityPub requests 2026-05-28 01:08:45 +02:00
14 changed files with 161 additions and 17 deletions

4
Cargo.lock generated
View File

@@ -2015,8 +2015,8 @@ dependencies = [
[[package]]
name = "k-ap"
version = "0.1.7"
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.7#699258f830922830df956db8e5dea739ee1642aa"
version = "0.1.9"
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.9#432f39cbb4f8d74255a1f614a9bb7c8bbfe11cde"
dependencies = [
"activitypub_federation",
"anyhow",

View File

@@ -47,11 +47,18 @@ services:
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
# Original API subdomain — keep for backwards compat and direct API access
- "traefik.http.routers.thoughts-api.rule=Host(`api.thoughts.gabrielkaszewski.dev`)"
- "traefik.http.routers.thoughts-api.entrypoints=web,websecure"
- "traefik.http.routers.thoughts-api.tls.certresolver=letsencrypt"
- "traefik.http.routers.thoughts-api.service=thoughts-api"
- "traefik.http.services.thoughts-api.loadbalancer.server.port=8000"
# Federation routes on the main domain — higher priority than the frontend catch-all
- "traefik.http.routers.thoughts-federation.rule=Host(`thoughts.gabrielkaszewski.dev`) && (PathPrefix(`/.well-known`) || PathPrefix(`/nodeinfo`) || Path(`/inbox`) || (Method(`POST`) && PathPrefix(`/users/`)))"
- "traefik.http.routers.thoughts-federation.entrypoints=web,websecure"
- "traefik.http.routers.thoughts-federation.tls.certresolver=letsencrypt"
- "traefik.http.routers.thoughts-federation.service=thoughts-api"
- "traefik.http.routers.thoughts-federation.priority=1000"
worker:
container_name: thoughts-worker
@@ -77,6 +84,7 @@ services:
environment:
NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000
NEXT_PUBLIC_API_URL: https://api.thoughts.gabrielkaszewski.dev
NEXT_PUBLIC_FEDIVERSE_DOMAIN: thoughts.gabrielkaszewski.dev
PORT: 3000
HOSTNAME: 0.0.0.0
depends_on:

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.7" }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.9" }
domain = { workspace = true }
url = { workspace = true }
serde = { workspace = true }

View File

@@ -736,6 +736,17 @@ impl FederationFollowPort for ApFederationAdapter {
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn broadcast_move(
&self,
user_id: &UserId,
new_actor_url: url::Url,
) -> Result<(), DomainError> {
self.inner
.broadcast_move(user_id.as_uuid(), new_actor_url)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
}
// ── FederationFollowRequestPort ───────────────────────────────────────────────

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.7" }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.9" }
sqlx = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@@ -490,6 +490,41 @@ impl FederationRepository for PostgresFederationRepository {
).bind(local_user_id).bind(actor_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(n > 0)
}
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
let mut tx = self.pool.begin().await.map_err(|e| anyhow!(e))?;
// Copy rows to the new actor URL, carrying over existing data.
// ON CONFLICT DO NOTHING skips users already following the new actor.
// RETURNING gives us user IDs that actually need a re-follow.
let affected: Vec<uuid::Uuid> = sqlx::query_scalar(
"INSERT INTO federation_following(local_user_id, remote_actor_url, follow_activity_id, outbox_url)
SELECT local_user_id, $2, follow_activity_id, outbox_url
FROM federation_following
WHERE remote_actor_url = $1
ON CONFLICT (local_user_id, remote_actor_url) DO NOTHING
RETURNING local_user_id",
)
.bind(old_actor_url)
.bind(new_actor_url)
.fetch_all(&mut *tx)
.await
.map_err(|e| anyhow!(e))?;
// Delete the old rows.
sqlx::query("DELETE FROM federation_following WHERE remote_actor_url = $1")
.bind(old_actor_url)
.execute(&mut *tx)
.await
.map_err(|e| anyhow!(e))?;
tx.commit().await.map_err(|e| anyhow!(e))?;
Ok(affected)
}
}
// ── PostgresApUserRepository ──────────────────────────────────────────────────

View File

@@ -14,7 +14,7 @@ postgres = { workspace = true }
postgres-search = { workspace = true }
postgres-federation = { workspace = true }
activitypub = { workspace = true }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.7" }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.9" }
nats = { workspace = true }
event-transport = { workspace = true }
auth = { workspace = true }

View File

@@ -287,6 +287,11 @@ pub trait FederationFollowPort: Send + Sync {
) -> Result<(), DomainError>;
async fn get_remote_following(&self, user_id: &UserId)
-> Result<Vec<RemoteActor>, DomainError>;
async fn broadcast_move(
&self,
user_id: &UserId,
new_actor_url: url::Url,
) -> Result<(), DomainError>;
}
#[async_trait]

View File

@@ -706,6 +706,14 @@ impl FederationFollowPort for TestStore {
) -> Result<Vec<RemoteActor>, DomainError> {
Ok(vec![])
}
async fn broadcast_move(
&self,
_user_id: &UserId,
_new_actor_url: url::Url,
) -> Result<(), DomainError> {
Ok(())
}
}
#[async_trait]

View File

@@ -22,6 +22,11 @@ pub struct HandleBody {
pub handle: String,
}
#[derive(Deserialize)]
pub struct MoveBody {
pub new_actor_url: String,
}
deps_struct!(FederationManagementDeps {
federation: FederationActionPort,
follows: FollowRepository,
@@ -107,3 +112,17 @@ pub async fn delete_following(
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn post_move_account(
Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser,
Json(body): Json<MoveBody>,
) -> Result<StatusCode, ApiError> {
let new_url = url::Url::parse(&body.new_actor_url)
.map_err(|_| ApiError::BadRequest("invalid new_actor_url".into()))?;
d.federation
.broadcast_move(&uid, new_url)
.await
.map_err(ApiError::from)?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -104,6 +104,10 @@ pub fn router() -> Router<AppState> {
get(federation_management::get_remote_following)
.delete(federation_management::delete_following),
)
.route(
"/federation/me/move",
post(federation_management::post_move_account),
)
.route("/tags/popular", get(feed::get_popular_tags))
.route("/tags/{name}", get(feed::tag_thoughts_handler))
// notifications

View File

@@ -13,7 +13,7 @@ application = { workspace = true }
nats = { workspace = true }
event-transport = { workspace = true }
event-payload = { workspace = true }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.7" }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.9" }
activitypub = { workspace = true }
postgres = { workspace = true }
postgres-federation = { workspace = true }

View File

@@ -126,11 +126,13 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const followingCount = localFollowingCount + remoteFollowingCount;
const isFollowing = user.isFollowedByViewer;
const apiDomain = process.env.NEXT_PUBLIC_API_URL
? new URL(process.env.NEXT_PUBLIC_API_URL).hostname
: null;
const fediverseDomain =
process.env.NEXT_PUBLIC_FEDIVERSE_DOMAIN ??
(process.env.NEXT_PUBLIC_API_URL
? new URL(process.env.NEXT_PUBLIC_API_URL).hostname
: null);
const fediverseHandle =
user.local && apiDomain ? `@${user.username}@${apiDomain}` : null;
user.local && fediverseDomain ? `@${user.username}@${fediverseDomain}` : null;
return (
<div id={`profile-page-${user.username}`}>

View File

@@ -1,16 +1,68 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const parts = request.nextUrl.pathname.split("/");
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// /users/@user@instance or /users/%40user%40instance
if (parts.length === 3 && parts[1] === "users") {
const decoded = decodeURIComponent(parts[2]);
if (decoded.startsWith("@") && decoded.indexOf("@", 1) !== -1) {
function isApRequest(accept: string): boolean {
return (
accept.includes("application/activity+json") ||
accept.includes("application/ld+json")
);
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const parts = pathname.split("/");
if (parts.length >= 3 && parts[1] === "users") {
const segment = decodeURIComponent(parts[2]);
const accept = request.headers.get("accept") ?? "";
if (UUID_RE.test(segment)) {
const apiBase =
process.env.NEXT_PUBLIC_SERVER_SIDE_API_URL ?? "http://api:8000";
if (isApRequest(accept)) {
// AP GET request → proxy to backend (actor JSON, outbox, followers, following)
// Inbox POSTs are routed directly via Traefik to preserve the host header for signature verification
const forwardHeaders: Record<string, string> = {};
for (const [key, value] of request.headers.entries()) {
if (key.toLowerCase() !== "host") {
forwardHeaders[key] = value;
}
}
const res = await fetch(`${apiBase}${pathname}`, {
headers: forwardHeaders,
});
// Buffer the body — streaming ReadableStream via NextResponse is unreliable in Edge runtime
const body = await res.text();
return new NextResponse(body, {
status: res.status,
headers: {
"content-type":
res.headers.get("content-type") ?? "application/activity+json",
},
});
}
// Browser request → redirect to the human-readable username URL
const res = await fetch(`${apiBase}/users/${segment}`);
if (res.ok) {
const user = await res.json();
const url = request.nextUrl.clone();
url.pathname = `/users/${user.username}`;
return NextResponse.redirect(url, 301);
}
}
// Remote handle redirect: /users/@user@instance
if (segment.startsWith("@") && segment.indexOf("@", 1) !== -1) {
const url = request.nextUrl.clone();
url.pathname = "/remote-actor";
url.searchParams.set("handle", decoded);
url.searchParams.set("handle", segment);
return NextResponse.rewrite(url);
}
}