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 = [
"argon2",
"async-trait",
"bcrypt",
"chrono",
"domain",
"jsonwebtoken",
@@ -537,6 +538,19 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "bitflags"
version = "2.11.1"
@@ -564,6 +578,16 @@ dependencies = [
"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]]
name = "bootstrap"
version = "0.1.0"
@@ -670,6 +694,16 @@ dependencies = [
"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]]
name = "cmake"
version = "0.1.58"
@@ -1850,6 +1884,15 @@ dependencies = [
"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]]
name = "ipnet"
version = "2.12.0"

View File

@@ -1,9 +1,7 @@
use std::sync::Arc;
use activitypub_federation::{
activity_sending::SendActivityTask,
fetch::{object_id::ObjectId, webfinger::webfinger_resolve_actor},
protocol::context::WithContext,
activity_sending::SendActivityTask, fetch::object_id::ObjectId, protocol::context::WithContext,
traits::Actor,
};
use axum::{Router, routing::get, routing::post};
@@ -342,6 +340,48 @@ impl ActivityPubService {
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<()> {
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;
}
let remote_actor: DbActor = webfinger_resolve_actor(handle, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let remote_actor: DbActor = Self::webfinger_https(handle, &data).await?;
let local_actor = get_local_actor(local_user_id, &data)
.await
@@ -1424,9 +1462,9 @@ impl domain::ports::FederationActionPort for ActivityPubService {
handle: &str,
) -> Result<(), domain::errors::DomainError> {
let data = self.federation_config.to_request_data();
let remote_actor: DbActor = webfinger_resolve_actor(handle, &data).await.map_err(|e| {
domain::errors::DomainError::ExternalService(anyhow::anyhow!("{e}").to_string())
})?;
let remote_actor: DbActor = Self::webfinger_https(handle, &data)
.await
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
let actor_url = remote_actor.ap_id.to_string();
self.unfollow(local_user_id.as_uuid(), &actor_url)
.await

View File

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

View File

@@ -76,6 +76,10 @@ impl PasswordHasher for Argon2PasswordHasher {
}
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};
let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Argon2::default()

View File

@@ -23,6 +23,21 @@ pub async fn get_user_by_username(
.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(
users: &dyn UserRepository,
user_id: &UserId,

View File

@@ -44,9 +44,12 @@ impl Username {
if s.is_empty() || s.len() > 32 {
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(
"username: alphanumeric or underscore only".into(),
"username: alphanumeric, underscore, or dot only".into(),
));
}
Ok(Self(s))

View File

@@ -9,7 +9,9 @@ use api_types::{
responses::{ErrorResponse, ProfileField, RemoteActorResponse, UserResponse},
};
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 axum::{
extract::{Path, Query, State},
@@ -32,7 +34,7 @@ pub async fn get_user(
OptionalAuthUser(viewer): OptionalAuthUser,
headers: HeaderMap,
) -> 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
.get(header::ACCEPT)
@@ -78,11 +80,7 @@ pub async fn patch_profile(
body.custom_css,
)
.await?;
let user = s
.users
.find_by_id(&uid)
.await?
.ok_or(domain::errors::DomainError::NotFound)?;
let user = fetch_user(&*s.users, &uid).await?;
Ok(Json(to_user_response(&user)))
}
@@ -98,11 +96,7 @@ pub async fn get_me(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
) -> Result<Json<UserResponse>, ApiError> {
let user = s
.users
.find_by_id(&uid)
.await?
.ok_or(domain::errors::DomainError::NotFound)?;
let user = fetch_user(&*s.users, &uid).await?;
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 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 }>();
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
@@ -182,6 +188,11 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
>
@{user.username}
</p>
{fediverseHandle && (
<p className="text-xs text-muted-foreground/70 mt-0.5 font-mono select-all">
{fediverseHandle}
</p>
)}
</div>
<p

View File

@@ -8,7 +8,7 @@ import {
} from "@/components/ui/card";
import { UserAvatar } from "./user-avatar";
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 { useState } from "react";
import { useRouter } from "next/navigation";
@@ -121,9 +121,13 @@ export function ThoughtCard({
<span className="font-bold">
{author.displayName || author.username}
</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}
</span>
</time>
</div>
</Link>
<DropdownMenu>