Compare commits
8 Commits
555bcea307
...
f7ac6f6476
| Author | SHA1 | Date | |
|---|---|---|---|
| f7ac6f6476 | |||
| 9976c1481a | |||
| e6b351b472 | |||
| 706d7389ed | |||
| 6e9b1596d8 | |||
| bbf6c97379 | |||
| b5427cab7d | |||
| f7350847c5 |
43
Cargo.lock
generated
43
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,4 +13,5 @@ tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
jsonwebtoken = "9"
|
||||
argon2 = "0.5"
|
||||
bcrypt = "0.15"
|
||||
rand = "0.8"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user