Compare commits

...

8 Commits

9 changed files with 139 additions and 26 deletions

43
Cargo.lock generated
View File

@@ -423,6 +423,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"async-trait", "async-trait",
"bcrypt",
"chrono", "chrono",
"domain", "domain",
"jsonwebtoken", "jsonwebtoken",
@@ -537,6 +538,19 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bcrypt"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7"
dependencies = [
"base64",
"blowfish",
"getrandom 0.2.17",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.11.1"
@@ -564,6 +578,16 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"cipher",
]
[[package]] [[package]]
name = "bootstrap" name = "bootstrap"
version = "0.1.0" version = "0.1.0"
@@ -670,6 +694,16 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.58" version = "0.1.58"
@@ -1850,6 +1884,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"

View File

@@ -1,9 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use activitypub_federation::{ use activitypub_federation::{
activity_sending::SendActivityTask, activity_sending::SendActivityTask, fetch::object_id::ObjectId, protocol::context::WithContext,
fetch::{object_id::ObjectId, webfinger::webfinger_resolve_actor},
protocol::context::WithContext,
traits::Actor, traits::Actor,
}; };
use axum::{Router, routing::get, routing::post}; use axum::{Router, routing::get, routing::post};
@@ -342,6 +340,48 @@ impl ActivityPubService {
Ok(()) Ok(())
} }
/// Resolve a `@user@domain` handle to a `DbActor` over HTTPS directly.
/// The library's `webfinger_resolve_actor` tries HTTP first in debug mode, which breaks
/// on servers that don't redirect HTTP → HTTPS.
async fn webfinger_https(
handle: &str,
data: &activitypub_federation::config::Data<FederationData>,
) -> anyhow::Result<DbActor> {
let normalized = handle.trim_start_matches('@');
let at = normalized
.rfind('@')
.ok_or_else(|| anyhow::anyhow!("handle must be user@domain"))?;
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
let wf_url = format!(
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
domain_str, user, domain_str
);
let wf: serde_json::Value = reqwest::Client::new()
.get(&wf_url)
.header("Accept", "application/jrd+json, application/json")
.send()
.await?
.json()
.await?;
let self_href = wf["links"]
.as_array()
.and_then(|links| {
links.iter().find(|l| {
l["rel"].as_str() == Some("self")
&& l["type"].as_str() == Some("application/activity+json")
})
})
.and_then(|l| l["href"].as_str())
.ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))?
.to_owned();
let self_url = url::Url::parse(&self_href)?;
let actor: DbActor = ObjectId::from(self_url)
.dereference(data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(actor)
}
pub async fn follow(&self, local_user_id: uuid::Uuid, handle: &str) -> anyhow::Result<()> { pub async fn follow(&self, local_user_id: uuid::Uuid, handle: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
@@ -351,9 +391,7 @@ impl ActivityPubService {
return self.follow_local(local_user_id, parts[0], &data).await; return self.follow_local(local_user_id, parts[0], &data).await;
} }
let remote_actor: DbActor = webfinger_resolve_actor(handle, &data) let remote_actor: DbActor = Self::webfinger_https(handle, &data).await?;
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let local_actor = get_local_actor(local_user_id, &data) let local_actor = get_local_actor(local_user_id, &data)
.await .await
@@ -1424,9 +1462,9 @@ impl domain::ports::FederationActionPort for ActivityPubService {
handle: &str, handle: &str,
) -> Result<(), domain::errors::DomainError> { ) -> Result<(), domain::errors::DomainError> {
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
let remote_actor: DbActor = webfinger_resolve_actor(handle, &data).await.map_err(|e| { let remote_actor: DbActor = Self::webfinger_https(handle, &data)
domain::errors::DomainError::ExternalService(anyhow::anyhow!("{e}").to_string()) .await
})?; .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
let actor_url = remote_actor.ap_id.to_string(); let actor_url = remote_actor.ap_id.to_string();
self.unfollow(local_user_id.as_uuid(), &actor_url) self.unfollow(local_user_id.as_uuid(), &actor_url)
.await .await

View File

@@ -13,4 +13,5 @@ tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
jsonwebtoken = "9" jsonwebtoken = "9"
argon2 = "0.5" argon2 = "0.5"
bcrypt = "0.15"
rand = "0.8" rand = "0.8"

View File

@@ -76,6 +76,10 @@ impl PasswordHasher for Argon2PasswordHasher {
} }
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> { async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
if hash.0.starts_with("$2") {
return bcrypt::verify(plain, &hash.0)
.map_err(|e| DomainError::Internal(e.to_string()));
}
use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier}; use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier};
let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?; let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Argon2::default() Ok(Argon2::default()

View File

@@ -23,6 +23,21 @@ pub async fn get_user_by_username(
.ok_or(DomainError::NotFound) .ok_or(DomainError::NotFound)
} }
/// Resolve a path segment that is either a UUID (AP actor URL) or a username.
pub async fn get_user_by_id_or_username(
users: &dyn UserRepository,
id_or_username: &str,
) -> Result<User, DomainError> {
if let Ok(uuid) = uuid::Uuid::parse_str(id_or_username) {
users
.find_by_id(&UserId::from_uuid(uuid))
.await?
.ok_or(DomainError::NotFound)
} else {
get_user_by_username(users, id_or_username).await
}
}
pub async fn update_profile( pub async fn update_profile(
users: &dyn UserRepository, users: &dyn UserRepository,
user_id: &UserId, user_id: &UserId,

View File

@@ -44,9 +44,12 @@ impl Username {
if s.is_empty() || s.len() > 32 { if s.is_empty() || s.len() > 32 {
return Err(DomainError::InvalidInput("username: 1-32 chars".into())); return Err(DomainError::InvalidInput("username: 1-32 chars".into()));
} }
if !s.chars().all(|c| c.is_alphanumeric() || c == '_') { if !s
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '.')
{
return Err(DomainError::InvalidInput( return Err(DomainError::InvalidInput(
"username: alphanumeric or underscore only".into(), "username: alphanumeric, underscore, or dot only".into(),
)); ));
} }
Ok(Self(s)) Ok(Self(s))

View File

@@ -9,7 +9,9 @@ use api_types::{
responses::{ErrorResponse, ProfileField, RemoteActorResponse, UserResponse}, responses::{ErrorResponse, ProfileField, RemoteActorResponse, UserResponse},
}; };
use application::use_cases::feed::list_users; use application::use_cases::feed::list_users;
use application::use_cases::profile::{get_user_by_username, update_profile}; use application::use_cases::profile::{
get_user as fetch_user, get_user_by_id_or_username, update_profile,
};
use application::use_cases::search::search_users; use application::use_cases::search::search_users;
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
@@ -32,7 +34,7 @@ pub async fn get_user(
OptionalAuthUser(viewer): OptionalAuthUser, OptionalAuthUser(viewer): OptionalAuthUser,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let user = get_user_by_username(&*s.users, &username).await?; let user = get_user_by_id_or_username(&*s.users, &username).await?;
let accept = headers let accept = headers
.get(header::ACCEPT) .get(header::ACCEPT)
@@ -78,11 +80,7 @@ pub async fn patch_profile(
body.custom_css, body.custom_css,
) )
.await?; .await?;
let user = s let user = fetch_user(&*s.users, &uid).await?;
.users
.find_by_id(&uid)
.await?
.ok_or(domain::errors::DomainError::NotFound)?;
Ok(Json(to_user_response(&user))) Ok(Json(to_user_response(&user)))
} }
@@ -98,11 +96,7 @@ pub async fn get_me(
State(s): State<AppState>, State(s): State<AppState>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
) -> Result<Json<UserResponse>, ApiError> { ) -> Result<Json<UserResponse>, ApiError> {
let user = s let user = fetch_user(&*s.users, &uid).await?;
.users
.find_by_id(&uid)
.await?
.ok_or(domain::errors::DomainError::NotFound)?;
Ok(Json(to_user_response(&user))) Ok(Json(to_user_response(&user)))
} }

View File

@@ -105,6 +105,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const isOwnProfile = me?.username === user.username; const isOwnProfile = me?.username === user.username;
const isFollowing = user.isFollowedByViewer; const isFollowing = user.isFollowedByViewer;
const apiDomain = 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;
const authorDetails = new Map<string, { avatarUrl?: string | null }>(); const authorDetails = new Map<string, { avatarUrl?: string | null }>();
authorDetails.set(user.username, { avatarUrl: user.avatarUrl }); authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
@@ -182,6 +188,11 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
> >
@{user.username} @{user.username}
</p> </p>
{fediverseHandle && (
<p className="text-xs text-muted-foreground/70 mt-0.5 font-mono select-all">
{fediverseHandle}
</p>
)}
</div> </div>
<p <p

View File

@@ -8,7 +8,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { UserAvatar } from "./user-avatar"; import { UserAvatar } from "./user-avatar";
import { deleteThought, Me, Thought } from "@/lib/api"; import { deleteThought, Me, Thought } from "@/lib/api";
import { formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -121,9 +121,13 @@ export function ThoughtCard({
<span className="font-bold"> <span className="font-bold">
{author.displayName || author.username} {author.displayName || author.username}
</span> </span>
<span className="text-sm text-muted-foreground text-shadow-sm"> <time
dateTime={new Date(thought.createdAt).toISOString()}
title={format(new Date(thought.createdAt), "PPP p")}
className="text-sm text-muted-foreground text-shadow-sm"
>
{timeAgo} {timeAgo}
</span> </time>
</div> </div>
</Link> </Link>
<DropdownMenu> <DropdownMenu>