Skip to main content

mail4agent_server/
account.rs

1//! Account data, tags, filters, and public-room listing.
2//! Profile nick lookup and any restricted user directory are product concerns, not in this crate.
3
4use rusqlite::Connection;
5
6use crate::error::MatrixError;
7use crate::store::Membership;
8
9// ============================================================================
10// GET /_matrix/client/v3/account/whoami
11// ============================================================================
12
13/// `GET /account/whoami`'s wire shape: the caller's own mxid and the device
14/// this credential resolved to (minting it on first use — see
15/// [`auth::device_id_for`]'s own doc). This is how a client with no
16/// `/login` flow (this server has none — plan §1) learns its own device id.
17/// `is_guest` is always `false`: this server has no guest accounts.
18pub fn whoami_response(mxid: &str, device_id: &str) -> serde_json::Value {
19    serde_json::json!({ "user_id": mxid, "device_id": device_id, "is_guest": false })
20}
21
22
23// ============================================================================
24// Shared DB-only gate helpers — own copies per this codebase's convention
25// (mirror `routes::matrix::rooms`/`ephemeral`'s own `require_member` byte-
26// for-byte).
27// ============================================================================
28
29pub fn require_member(membership: Option<Membership>) -> Result<(), MatrixError> {
30    match membership {
31        Some(Membership::Join) => Ok(()),
32        _ => Err(MatrixError::forbidden("not a member of this room")),
33    }
34}
35
36
37/// The tags gate (P8 brief item 3): joined OR invited, unlike plain
38/// per-room account data (item 2), which requires a full join.
39pub fn require_member_or_invited(membership: Option<Membership>) -> Result<(), MatrixError> {
40    match membership {
41        Some(Membership::Join) | Some(Membership::Invite) => Ok(()),
42        _ => Err(MatrixError::forbidden("not a member or invitee of this room")),
43    }
44}
45
46
47/// Every `/user/{userId}/...` route in this module: the path's `userId`
48/// (a full mxid) must be the caller's own — this server has no notion of one
49/// account managing another's account data/tags/filters.
50pub fn check_caller_owns_user_id(path_user_id: &str, caller_mxid: &str) -> Result<(), MatrixError> {
51    if path_user_id == caller_mxid {
52        Ok(())
53    } else {
54        Err(MatrixError::forbidden("userId must be the caller's own mxid"))
55    }
56}
57
58
59// ============================================================================
60// Account data (global + per-room)
61// ============================================================================
62
63/// `m.fully_read` is refused on both the global and per-room account-data
64/// endpoints (P8 brief items 1/2): it belongs to room account data via
65/// `POST .../read_markers` ([`crate::ephemeral::apply_read_markers`]), which
66/// is the only writer this server ever lets touch it — a raw `PUT` here
67/// would let a client desync it from the receipt bookkeeping that route also
68/// updates in the same call.
69pub fn check_account_data_type_allowed(event_type: &str) -> Result<(), MatrixError> {
70    if event_type == "m.fully_read" {
71        Err(MatrixError::managed_account_data_type(
72            "m.fully_read is managed through POST .../read_markers, not raw account data",
73        ))
74    } else {
75        Ok(())
76    }
77}
78
79
80/// `PUT` account-data content must be a JSON object (Matrix's own shape for
81/// every account-data type) within [`crate::store::MATRIX_EVENT_CONTENT_MAX_BYTES`]
82/// once serialized — same ceiling `routes::matrix::rooms::put_state` applies
83/// to state-event content. Returns the serialized string ready to store.
84pub fn validate_account_data_content(value: &serde_json::Value) -> Result<String, MatrixError> {
85    if !value.is_object() {
86        return Err(MatrixError::bad_json("account data content must be a JSON object"));
87    }
88    let serialized = value.to_string();
89    if serialized.len() > crate::store::MATRIX_EVENT_CONTENT_MAX_BYTES {
90        return Err(MatrixError::invalid_param("account data content too large"));
91    }
92    Ok(serialized)
93}
94
95
96// ============================================================================
97// Tags — a thin view over room account data type `m.tag`
98// ============================================================================
99
100pub const MAX_TAG_NAME_BYTES: usize = 255;
101
102pub const MAX_TAGS_PER_ROOM: usize = 100;
103
104
105#[derive(serde::Deserialize, Default)]
106pub struct TagBody {
107    #[serde(default)]
108    pub order: Option<f64>,
109}
110
111
112pub fn validate_tag_name(tag: &str) -> Result<(), MatrixError> {
113    if tag.is_empty() || tag.len() > MAX_TAG_NAME_BYTES {
114        Err(MatrixError::invalid_param("tag name must be 1-255 bytes"))
115    } else {
116        Ok(())
117    }
118}
119
120
121/// `order` (P8 brief item 3): when present, must be in `[0, 1]`.
122pub fn validate_tag_order(order: Option<f64>) -> Result<(), MatrixError> {
123    match order {
124        Some(v) if !(0.0..=1.0).contains(&v) => Err(MatrixError::invalid_param("tag order must be between 0 and 1")),
125        _ => Ok(()),
126    }
127}
128
129
130/// `{"tags": {...}}`'s `tags` object, or empty when the room has no `m.tag`
131/// account-data row yet.
132pub fn read_tags(conn: &Connection, user_id: i64, room_id: &str) -> Result<serde_json::Map<String, serde_json::Value>, MatrixError> {
133    match crate::store::get_account_data(conn, user_id, room_id, "m.tag")? {
134        Some(row) => {
135            let content: serde_json::Value = serde_json::from_str(&row.content)?;
136            Ok(content.get("tags").and_then(|v| v.as_object()).cloned().unwrap_or_default())
137        }
138        None => Ok(serde_json::Map::new()),
139    }
140}
141
142
143pub fn write_tags(conn: &mut Connection, user_id: i64, room_id: &str, tags: serde_json::Map<String, serde_json::Value>) -> Result<(), MatrixError> {
144    let content = serde_json::json!({ "tags": tags }).to_string();
145    crate::store::upsert_account_data(conn, user_id, room_id, "m.tag", &content)?;
146    Ok(())
147}
148
149
150/// `PUT .../tags/{tag}`'s whole DB-side decision: validate the tag name and
151/// `order`, refuse growing past [`MAX_TAGS_PER_ROOM`] on a genuinely NEW tag
152/// (re-setting an existing tag's `order` never counts against the cap), then
153/// upsert. Membership is checked by the route handler, not here (this
154/// function takes no caller/room-membership context at all — the "testable
155/// cores" convention this module states).
156pub fn apply_tag_put(conn: &mut Connection, user_id: i64, room_id: &str, tag: &str, order: Option<f64>) -> Result<(), MatrixError> {
157    validate_tag_name(tag)?;
158    validate_tag_order(order)?;
159
160    let mut tags = read_tags(conn, user_id, room_id)?;
161    if !tags.contains_key(tag) && tags.len() >= MAX_TAGS_PER_ROOM {
162        return Err(MatrixError::invalid_param("too many tags on this room"));
163    }
164    let mut entry = serde_json::Map::new();
165    if let Some(order) = order {
166        entry.insert("order".to_string(), serde_json::json!(order));
167    }
168    tags.insert(tag.to_string(), serde_json::Value::Object(entry));
169    write_tags(conn, user_id, room_id, tags)
170}
171
172
173/// `DELETE .../tags/{tag}` — idempotent: removing an absent tag is a
174/// silent no-op, matching Matrix's own DELETE semantics.
175pub fn apply_tag_delete(conn: &mut Connection, user_id: i64, room_id: &str, tag: &str) -> Result<(), MatrixError> {
176    let mut tags = read_tags(conn, user_id, room_id)?;
177    tags.remove(tag);
178    write_tags(conn, user_id, room_id, tags)
179}
180
181
182// ============================================================================
183// Filters
184// ============================================================================
185
186/// Ceiling on a stored Filter JSON object (P8 brief item 4).
187pub const FILTER_MAX_BYTES: usize = 64 * 1024;
188
189
190/// A Filter must be a JSON object within [`FILTER_MAX_BYTES`] once
191/// serialized — stored verbatim, opaque, interpreted only by a later `/sync`
192/// piece. Returns the serialized string ready to store.
193pub fn validate_filter_definition(value: &serde_json::Value) -> Result<String, MatrixError> {
194    if !value.is_object() {
195        return Err(MatrixError::bad_json("filter must be a JSON object"));
196    }
197    let serialized = value.to_string();
198    if serialized.len() > FILTER_MAX_BYTES {
199        return Err(MatrixError::invalid_param("filter definition too large"));
200    }
201    Ok(serialized)
202}
203
204
205// ============================================================================
206// POST /_matrix/client/v3/user_directory/search
207// ============================================================================
208
209/// A trimmed, lowercased `search_term` longer than this is truncated rather
210/// than refused — matches `routes::dm::lookup_recipients`'s own
211/// `MAX_LOOKUP_QUERY_LEN`.
212pub const MAX_SEARCH_TERM_LEN: usize = 64;
213
214
215/// `limit`'s default when the client omits it — matches
216/// `routes::dm::lookup_recipients`'s own `MAX_LOOKUP_RESULTS`.
217pub const DEFAULT_DIRECTORY_RESULTS: usize = 10;
218
219
220/// Ceiling a client-supplied `limit` is clamped to — the DM lookup has no
221/// client-facing `limit` at all (always 10); the Matrix endpoint's own spec
222/// shape does take one, so this is the abuse-prevention ceiling on it.
223pub const MAX_DIRECTORY_RESULTS: usize = 50;
224
225
226#[derive(serde::Deserialize)]
227pub struct UserDirectorySearchRequest {
228    pub search_term: String,
229    #[serde(default)]
230    pub limit: Option<i64>,
231}
232
233
234#[derive(serde::Serialize)]
235pub struct UserDirectoryResult {
236    pub user_id: String,
237    pub display_name: String,
238}
239
240
241#[derive(serde::Serialize)]
242pub struct UserDirectorySearchResponse {
243    pub results: Vec<UserDirectoryResult>,
244    pub limited: bool,
245}
246
247
248/// One ranked, labeled match — [`rank_directory_candidates`]'s own output
249/// row, carrying `public_id` along so the route handler can
250/// [`crate::store::ensure_matrix_user`] it into an mxid without a second
251/// identity-database round trip.
252pub struct DirectoryMatch {
253    pub user_id: i64,
254    pub public_id: String,
255    pub label: String,
256}
257
258
259// ============================================================================
260// GET/POST /_matrix/client/v3/publicRooms
261// ============================================================================
262
263pub const DEFAULT_PUBLIC_ROOMS_LIMIT: i64 = 20;
264
265pub const MAX_PUBLIC_ROOMS_LIMIT: i64 = 100;
266
267
268pub fn clamp_public_rooms_limit(limit: Option<i64>) -> usize {
269    limit.unwrap_or(DEFAULT_PUBLIC_ROOMS_LIMIT).clamp(1, MAX_PUBLIC_ROOMS_LIMIT) as usize
270}
271
272
273#[derive(serde::Deserialize, Default)]
274pub struct PublicRoomsQuery {
275    pub limit: Option<i64>,
276    pub since: Option<String>,
277}
278
279
280#[derive(serde::Deserialize, Default)]
281pub struct PublicRoomsFilter {
282    #[serde(default)]
283    pub generic_search_term: Option<String>,
284}
285
286
287#[derive(serde::Deserialize, Default)]
288pub struct PublicRoomsRequestBody {
289    #[serde(default)]
290    pub limit: Option<i64>,
291    #[serde(default)]
292    pub since: Option<String>,
293    #[serde(default)]
294    pub filter: PublicRoomsFilter,
295}
296
297
298/// One `chunk` entry's wire shape (P8 brief item 7) — `name`/`topic` are
299/// omitted entirely (not `null`) when the room never set them, matching
300/// Matrix's own convention of omitting rather than nulling absent profile
301/// fields.
302pub fn public_room_json(room: &crate::store::PublicRoomSummary) -> serde_json::Value {
303    let mut value = serde_json::json!({
304        "room_id": room.room_id,
305        "num_joined_members": room.num_joined_members,
306        "world_readable": room.world_readable,
307        "guest_can_join": false,
308        "join_rule": "public",
309    });
310    if let Some(name) = &room.name {
311        value["name"] = serde_json::Value::String(name.clone());
312    }
313    if let Some(topic) = &room.topic {
314        value["topic"] = serde_json::Value::String(topic.clone());
315    }
316    value
317}
318
319
320/// One page of public rooms from the messenger database alone.
321pub fn list_public_rooms(
322    conn: &Connection,
323    since: Option<&str>,
324    limit: Option<i64>,
325    search_term: Option<&str>,
326) -> Result<serde_json::Value, MatrixError> {
327    let limit = clamp_public_rooms_limit(limit);
328    let (page, has_more, total) = crate::store::public_rooms_page(conn, since, limit, search_term)?;
329    let chunk: Vec<serde_json::Value> = page.iter().map(public_room_json).collect();
330    let mut response = serde_json::json!({ "chunk": chunk, "total_room_count_estimate": total });
331    if has_more {
332        if let Some(last) = page.last() {
333            response["next_batch"] = serde_json::Value::String(last.room_id.clone());
334        }
335    }
336    Ok(response)
337}
338
339/// Match an mxid localpart stored in this database. No nick index and no
340/// restricted directory: a product owns both of those.
341pub fn search_users_by_localpart(conn: &Connection, query: &str, limit: usize) -> rusqlite::Result<Vec<String>> {
342    let limit = limit.clamp(1, 50) as i64;
343    let pattern = format!("%{query}%");
344    let mut stmt = conn.prepare(
345        "SELECT mxid FROM matrix_users WHERE mxid LIKE ?1 ORDER BY mxid LIMIT ?2",
346    )?;
347    let rows = stmt.query_map(rusqlite::params![pattern, limit], |row| row.get(0))?;
348    rows.collect()
349}