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 = [
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user