Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion crates/sprout-auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,6 @@ impl AuthService {
/// helpers) in release-mode integration test harnesses. It must **not** be
/// enabled in production relay deployments. Check `sprout-relay/Cargo.toml` to
/// ensure `sprout-auth` is not listed with `features = ["dev"]` in production.
#[cfg(any(test, feature = "dev", debug_assertions))]
pub fn derive_pubkey_from_username(username: &str) -> Result<nostr::PublicKey, AuthError> {
use sha2::{Digest, Sha256};
let seed = format!("sprout-test-key:{username}");
Expand Down
29 changes: 20 additions & 9 deletions crates/sprout-db/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -689,12 +689,15 @@ pub struct UserRecord {
///
/// Uses DISTINCT + LEFT JOIN so a user who is a member of an open channel does not
/// see it twice. Results are ordered stream → forum → dm, then alphabetically by name.
///
/// If `visibility_filter` is `Some("open")` or `Some("private")`, only channels with
/// that visibility value are returned. `None` returns all accessible channels.
pub async fn get_accessible_channels(
pool: &MySqlPool,
pubkey: &[u8],
visibility_filter: Option<&str>,
) -> Result<Vec<ChannelRecord>> {
let rows = sqlx::query(
r#"
let base = r#"
SELECT DISTINCT c.id, c.name, c.channel_type, c.visibility, c.description, c.canvas,
c.created_by, c.created_at, c.updated_at, c.archived_at, c.deleted_at,
c.nip29_group_id, c.topic_required, c.max_members,
Expand All @@ -705,14 +708,22 @@ pub async fn get_accessible_channels(
ON c.id = cm.channel_id AND cm.pubkey = ? AND cm.removed_at IS NULL
WHERE c.deleted_at IS NULL
AND (c.visibility = 'open' OR cm.channel_id IS NOT NULL)
ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name
LIMIT 1000
"#,
)
.bind(pubkey)
.fetch_all(pool)
.await?;
"#;

let sql = if visibility_filter.is_some() {
format!("{base} AND c.visibility = ?\n ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name\n LIMIT 1000")
} else {
format!("{base} ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name\n LIMIT 1000")
};

let query = sqlx::query(&sql).bind(pubkey);
let query = if let Some(vis) = visibility_filter {
query.bind(vis)
} else {
query
};

let rows = query.fetch_all(pool).await?;
rows.into_iter().map(row_to_channel_record).collect()
}

Expand Down
45 changes: 45 additions & 0 deletions crates/sprout-db/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,51 @@ pub async fn insert_event_with_thread_metadata(
// Only bump reply counts if the metadata row was actually inserted.
if tm_result.rows_affected() > 0 {
if let Some(pid) = meta.parent_event_id {
// Ensure the parent has a thread_metadata row so the UPDATE
// below has something to hit. Root (depth=0) messages don't
// get a row on first insert, so we create a stub here.
let parent_ts = meta
.parent_event_created_at
.unwrap_or(meta.event_created_at);
sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
"#,
)
.bind(parent_ts)
.bind(pid)
.bind(ch_bytes.as_slice())
.execute(&mut *tx)
.await?;

// Ensure the root also has a row (may differ from parent for nested replies).
if let Some(root_id) = meta.root_event_id {
if root_id != pid {
let root_ts =
meta.root_event_created_at.unwrap_or(meta.event_created_at);
sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
"#,
)
.bind(root_ts)
.bind(root_id)
.bind(ch_bytes.as_slice())
.execute(&mut *tx)
.await?;
}
}

sqlx::query(
r#"
UPDATE thread_metadata
Expand Down
11 changes: 8 additions & 3 deletions crates/sprout-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,15 @@ impl Db {

/// Returns full channel records for all channels accessible to `pubkey`:
/// open channels plus channels where the user is an active member.
///
/// If `visibility_filter` is `Some("open")` or `Some("private")`, only channels
/// with that visibility are returned.
pub async fn get_accessible_channels(
&self,
pubkey: &[u8],
visibility_filter: Option<&str>,
) -> Result<Vec<channel::ChannelRecord>> {
channel::get_accessible_channels(&self.pool, pubkey).await
channel::get_accessible_channels(&self.pool, pubkey, visibility_filter).await
}

/// Returns all bot-role members with aggregated channel names.
Expand Down Expand Up @@ -555,14 +559,15 @@ impl Db {
user::get_user(&self.pool, pubkey).await
}

/// Update a user's display_name and/or avatar_url.
/// Update a user's display_name, avatar_url, and/or about.
pub async fn update_user_profile(
&self,
pubkey: &[u8],
display_name: Option<&str>,
avatar_url: Option<&str>,
about: Option<&str>,
) -> Result<()> {
user::update_user_profile(&self.pool, pubkey, display_name, avatar_url).await
user::update_user_profile(&self.pool, pubkey, display_name, avatar_url, about).await
}

// ── API Tokens ───────────────────────────────────────────────────────────
Expand Down
52 changes: 52 additions & 0 deletions crates/sprout-db/src/thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub struct ThreadReply {
pub channel_id: Uuid,
/// Compressed public key of the reply author.
pub pubkey: Vec<u8>,
/// Nostr event tags (JSON array), used to extract effective author.
pub tags: serde_json::Value,
/// Text content of the reply.
pub content: String,
/// Nostr event kind number.
Expand Down Expand Up @@ -58,6 +60,8 @@ pub struct TopLevelMessage {
pub event_id: Vec<u8>,
/// Compressed public key of the message author.
pub pubkey: Vec<u8>,
/// Nostr event tags (JSON array), used to extract effective author.
pub tags: serde_json::Value,
/// Text content of the message.
pub content: String,
/// Nostr event kind number.
Expand Down Expand Up @@ -147,6 +151,48 @@ pub async fn insert_thread_metadata(
// INSERT IGNORE on a duplicate key returns rows_affected = 0.
if result.rows_affected() > 0 {
if let Some(pid) = parent_event_id {
// Ensure the parent has a thread_metadata row so the UPDATE below
// has something to hit. Root (depth=0) messages don't get a row on
// first insert, so we create a stub here with INSERT IGNORE.
let parent_ts = parent_event_created_at.unwrap_or(event_created_at);
sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
"#,
)
.bind(parent_ts)
.bind(pid)
.bind(channel_id_bytes.as_slice())
.execute(&mut *tx)
.await?;

// Ensure the root also has a row (may differ from parent for nested replies).
if let Some(root_id) = root_event_id {
if root_id != pid {
let root_ts = root_event_created_at.unwrap_or(event_created_at);
sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
"#,
)
.bind(root_ts)
.bind(root_id)
.bind(channel_id_bytes.as_slice())
.execute(&mut *tx)
.await?;
}
}

// Increment parent's direct reply count and last_reply_at.
sqlx::query(
r#"
Expand Down Expand Up @@ -303,6 +349,7 @@ pub async fn get_thread_replies(
tm.root_event_id,
tm.channel_id,
e.pubkey,
e.tags,
e.content,
e.kind,
tm.depth,
Expand Down Expand Up @@ -345,6 +392,7 @@ pub async fn get_thread_replies(
let root_event_id_col: Option<Vec<u8>> = row.try_get("root_event_id")?;
let channel_id_bytes: Vec<u8> = row.try_get("channel_id")?;
let pubkey: Vec<u8> = row.try_get("pubkey")?;
let tags: serde_json::Value = row.try_get("tags")?;
let content: String = row.try_get("content")?;
let kind: i32 = row.try_get("kind")?;
let depth: i32 = row.try_get("depth")?;
Expand All @@ -359,6 +407,7 @@ pub async fn get_thread_replies(
root_event_id: root_event_id_col,
channel_id,
pubkey,
tags,
content,
kind,
depth,
Expand Down Expand Up @@ -455,6 +504,7 @@ pub async fn get_channel_messages_top_level(
SELECT
e.id AS event_id,
e.pubkey,
e.tags,
e.content,
e.kind,
e.created_at,
Expand Down Expand Up @@ -492,6 +542,7 @@ pub async fn get_channel_messages_top_level(
for row in rows {
let event_id: Vec<u8> = row.try_get("event_id")?;
let pubkey: Vec<u8> = row.try_get("pubkey")?;
let tags: serde_json::Value = row.try_get("tags")?;
let content: String = row.try_get("content")?;
let kind: i32 = row.try_get("kind")?;
let created_at: DateTime<Utc> = row.try_get("created_at")?;
Expand All @@ -501,6 +552,7 @@ pub async fn get_channel_messages_top_level(
messages.push(TopLevelMessage {
event_id,
pubkey,
tags,
content,
kind,
created_at,
Expand Down
76 changes: 46 additions & 30 deletions crates/sprout-db/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ pub struct UserProfile {
pub display_name: Option<String>,
/// URL of the user's avatar image.
pub avatar_url: Option<String>,
/// Short bio or description provided by the user.
pub about: Option<String>,
/// NIP-05 identifier (user@domain).
pub nip05_handle: Option<String>,
}
Expand All @@ -33,9 +35,18 @@ pub async fn ensure_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<()> {

/// Get a single user record by pubkey.
pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<Option<UserProfile>> {
let row = sqlx::query_as::<_, (Vec<u8>, Option<String>, Option<String>, Option<String>)>(
let row = sqlx::query_as::<
_,
(
Vec<u8>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
),
>(
r#"
SELECT pubkey, display_name, avatar_url, nip05_handle
SELECT pubkey, display_name, avatar_url, about, nip05_handle
FROM users
WHERE pubkey = ?
"#,
Expand All @@ -45,50 +56,55 @@ pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<Option<UserProf
.await?;

Ok(row.map(
|(pubkey, display_name, avatar_url, nip05_handle)| UserProfile {
|(pubkey, display_name, avatar_url, about, nip05_handle)| UserProfile {
pubkey,
display_name,
avatar_url,
about,
nip05_handle,
},
))
}

/// Update a user's profile fields (display_name, avatar_url).
/// Update a user's profile fields (display_name, avatar_url, about).
/// Only updates fields that are Some — None fields are left unchanged.
/// At least one field must be Some, otherwise returns Ok(()) without touching the DB.
pub async fn update_user_profile(
pool: &MySqlPool,
pubkey: &[u8],
display_name: Option<&str>,
avatar_url: Option<&str>,
about: Option<&str>,
) -> Result<()> {
match (display_name, avatar_url) {
(Some(name), Some(url)) => {
sqlx::query(r#"UPDATE users SET display_name = ?, avatar_url = ? WHERE pubkey = ?"#)
.bind(name)
.bind(url)
.bind(pubkey)
.execute(pool)
.await?;
}
(Some(name), None) => {
sqlx::query(r#"UPDATE users SET display_name = ? WHERE pubkey = ?"#)
.bind(name)
.bind(pubkey)
.execute(pool)
.await?;
}
(None, Some(url)) => {
sqlx::query(r#"UPDATE users SET avatar_url = ? WHERE pubkey = ?"#)
.bind(url)
.bind(pubkey)
.execute(pool)
.await?;
}
(None, None) => {
// Nothing to update — caller should have validated at least one field.
}
// Build SET clause dynamically to avoid 2^3 match arms.
let mut set_parts: Vec<&str> = Vec::new();
if display_name.is_some() {
set_parts.push("display_name = ?");
}
if avatar_url.is_some() {
set_parts.push("avatar_url = ?");
}
if about.is_some() {
set_parts.push("about = ?");
}

if set_parts.is_empty() {
// Nothing to update — caller should have validated at least one field.
return Ok(());
}

let sql = format!("UPDATE users SET {} WHERE pubkey = ?", set_parts.join(", "));
let mut query = sqlx::query(&sql);
if let Some(name) = display_name {
query = query.bind(name);
}
if let Some(url) = avatar_url {
query = query.bind(url);
}
if let Some(bio) = about {
query = query.bind(bio);
}
query = query.bind(pubkey);
query.execute(pool).await?;
Ok(())
}
Loading