Compare commits
4 Commits
a460428be1
...
b20b60ad10
| Author | SHA1 | Date | |
|---|---|---|---|
| b20b60ad10 | |||
| 54bd2b60d0 | |||
| 94193f2d2e | |||
| f54fb543b2 |
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -2015,8 +2015,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "k-ap"
|
name = "k-ap"
|
||||||
version = "0.1.7"
|
version = "0.1.9"
|
||||||
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.7#699258f830922830df956db8e5dea739ee1642aa"
|
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.9#432f39cbb4f8d74255a1f614a9bb7c8bbfe11cde"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub_federation",
|
"activitypub_federation",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
@@ -47,11 +47,18 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.docker.network=traefik"
|
- "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.rule=Host(`api.thoughts.gabrielkaszewski.dev`)"
|
||||||
- "traefik.http.routers.thoughts-api.entrypoints=web,websecure"
|
- "traefik.http.routers.thoughts-api.entrypoints=web,websecure"
|
||||||
- "traefik.http.routers.thoughts-api.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.thoughts-api.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.routers.thoughts-api.service=thoughts-api"
|
- "traefik.http.routers.thoughts-api.service=thoughts-api"
|
||||||
- "traefik.http.services.thoughts-api.loadbalancer.server.port=8000"
|
- "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:
|
worker:
|
||||||
container_name: thoughts-worker
|
container_name: thoughts-worker
|
||||||
@@ -77,6 +84,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000
|
NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000
|
||||||
NEXT_PUBLIC_API_URL: https://api.thoughts.gabrielkaszewski.dev
|
NEXT_PUBLIC_API_URL: https://api.thoughts.gabrielkaszewski.dev
|
||||||
|
NEXT_PUBLIC_FEDIVERSE_DOMAIN: thoughts.gabrielkaszewski.dev
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[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 }
|
domain = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -736,6 +736,17 @@ impl FederationFollowPort for ApFederationAdapter {
|
|||||||
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
|
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
.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 ───────────────────────────────────────────────
|
// ── FederationFollowRequestPort ───────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[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 }
|
sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -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))?;
|
).bind(local_user_id).bind(actor_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
||||||
Ok(n > 0)
|
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 ──────────────────────────────────────────────────
|
// ── PostgresApUserRepository ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ postgres = { workspace = true }
|
|||||||
postgres-search = { workspace = true }
|
postgres-search = { workspace = true }
|
||||||
postgres-federation = { workspace = true }
|
postgres-federation = { workspace = true }
|
||||||
activitypub = { 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 }
|
nats = { workspace = true }
|
||||||
event-transport = { workspace = true }
|
event-transport = { workspace = true }
|
||||||
auth = { workspace = true }
|
auth = { workspace = true }
|
||||||
|
|||||||
@@ -287,6 +287,11 @@ pub trait FederationFollowPort: Send + Sync {
|
|||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
async fn get_remote_following(&self, user_id: &UserId)
|
async fn get_remote_following(&self, user_id: &UserId)
|
||||||
-> Result<Vec<RemoteActor>, DomainError>;
|
-> Result<Vec<RemoteActor>, DomainError>;
|
||||||
|
async fn broadcast_move(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
new_actor_url: url::Url,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -706,6 +706,14 @@ impl FederationFollowPort for TestStore {
|
|||||||
) -> Result<Vec<RemoteActor>, DomainError> {
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn broadcast_move(
|
||||||
|
&self,
|
||||||
|
_user_id: &UserId,
|
||||||
|
_new_actor_url: url::Url,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ pub struct HandleBody {
|
|||||||
pub handle: String,
|
pub handle: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MoveBody {
|
||||||
|
pub new_actor_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
deps_struct!(FederationManagementDeps {
|
deps_struct!(FederationManagementDeps {
|
||||||
federation: FederationActionPort,
|
federation: FederationActionPort,
|
||||||
follows: FollowRepository,
|
follows: FollowRepository,
|
||||||
@@ -107,3 +112,17 @@ pub async fn delete_following(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ pub fn router() -> Router<AppState> {
|
|||||||
get(federation_management::get_remote_following)
|
get(federation_management::get_remote_following)
|
||||||
.delete(federation_management::delete_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/popular", get(feed::get_popular_tags))
|
||||||
.route("/tags/{name}", get(feed::tag_thoughts_handler))
|
.route("/tags/{name}", get(feed::tag_thoughts_handler))
|
||||||
// notifications
|
// notifications
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ application = { workspace = true }
|
|||||||
nats = { workspace = true }
|
nats = { workspace = true }
|
||||||
event-transport = { workspace = true }
|
event-transport = { workspace = true }
|
||||||
event-payload = { 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 }
|
activitypub = { workspace = true }
|
||||||
postgres = { workspace = true }
|
postgres = { workspace = true }
|
||||||
postgres-federation = { workspace = true }
|
postgres-federation = { workspace = true }
|
||||||
|
|||||||
@@ -126,11 +126,13 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
const followingCount = localFollowingCount + remoteFollowingCount;
|
const followingCount = localFollowingCount + remoteFollowingCount;
|
||||||
const isFollowing = user.isFollowedByViewer;
|
const isFollowing = user.isFollowedByViewer;
|
||||||
|
|
||||||
const apiDomain = process.env.NEXT_PUBLIC_API_URL
|
const fediverseDomain =
|
||||||
? new URL(process.env.NEXT_PUBLIC_API_URL).hostname
|
process.env.NEXT_PUBLIC_FEDIVERSE_DOMAIN ??
|
||||||
: null;
|
(process.env.NEXT_PUBLIC_API_URL
|
||||||
|
? new URL(process.env.NEXT_PUBLIC_API_URL).hostname
|
||||||
|
: null);
|
||||||
const fediverseHandle =
|
const fediverseHandle =
|
||||||
user.local && apiDomain ? `@${user.username}@${apiDomain}` : null;
|
user.local && fediverseDomain ? `@${user.username}@${fediverseDomain}` : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id={`profile-page-${user.username}`}>
|
<div id={`profile-page-${user.username}`}>
|
||||||
|
|||||||
@@ -1,16 +1,68 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
const UUID_RE =
|
||||||
const parts = request.nextUrl.pathname.split("/");
|
/^[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
|
function isApRequest(accept: string): boolean {
|
||||||
if (parts.length === 3 && parts[1] === "users") {
|
return (
|
||||||
const decoded = decodeURIComponent(parts[2]);
|
accept.includes("application/activity+json") ||
|
||||||
if (decoded.startsWith("@") && decoded.indexOf("@", 1) !== -1) {
|
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();
|
const url = request.nextUrl.clone();
|
||||||
url.pathname = "/remote-actor";
|
url.pathname = "/remote-actor";
|
||||||
url.searchParams.set("handle", decoded);
|
url.searchParams.set("handle", segment);
|
||||||
return NextResponse.rewrite(url);
|
return NextResponse.rewrite(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user