From 55c17b2999ee1dc99f0f8aa74754ed321ef7e443 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 16 Mar 2026 17:44:44 -0600 Subject: [PATCH] fix: support hash-only permalink format with server-side resolution Add /api/messages/hash/:hash endpoint that resolves a message hash to its room ID (with membership check). The client now handles both #roomId/hash and #hash formats - the latter calls the API to find which room the message belongs to, then loads it and scrolls. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/components/app.riot | 37 +++++++++++++++++++++++++--------- client/src/services/api.js | 1 + server/src/handlers/rooms.rs | 24 ++++++++++++++++++++++ server/src/main.rs | 1 + 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/client/src/components/app.riot b/client/src/components/app.riot index 8e011e9..fd314b8 100644 --- a/client/src/components/app.riot +++ b/client/src/components/app.riot @@ -480,7 +480,7 @@ /** Stash a message permalink hash so it survives the login flow */ stashPendingLink() { const fragment = window.location.hash?.slice(1) - if (fragment && fragment.includes('/')) { + if (fragment) { sessionStorage.setItem('pendingMessageLink', fragment) } }, @@ -533,21 +533,41 @@ }) }, - /** Parse #roomId/messageHash from URL or sessionStorage, load the room, and scroll to the message */ + /** Parse #roomId/messageHash or #messageHash from URL or sessionStorage, load the room, and scroll */ async navigateToMessageLink() { // Try URL hash first, then sessionStorage (for post-login flow) let fragment = window.location.hash?.slice(1) - if (!fragment || !fragment.includes('/')) { + if (!fragment) { fragment = sessionStorage.getItem('pendingMessageLink') sessionStorage.removeItem('pendingMessageLink') } if (!fragment) return - const slashIdx = fragment.indexOf('/') - if (slashIdx === -1) return + let roomId, msgHash + + if (fragment.includes('/')) { + // New format: #roomId/messageHash + const slashIdx = fragment.indexOf('/') + roomId = fragment.slice(0, slashIdx) + msgHash = fragment.slice(slashIdx + 1) + } else { + // Old format: #messageHash — resolve room via API + msgHash = fragment + try { + const result = await api.resolveMessageHash(msgHash) + roomId = result.room_id + } catch (e) { + const status = e?.status + if (status === 404) { + this.update({ linkError: 'Message not found or you don\'t have access' }) + } else { + this.update({ linkError: 'Could not find this message' }) + } + window.history.replaceState(null, '', window.location.pathname) + return + } + } - const roomId = fragment.slice(0, slashIdx) - const msgHash = fragment.slice(slashIdx + 1) if (!roomId || !msgHash) return // Store the target hash so selectRoom skips scrollToBottom @@ -559,7 +579,7 @@ await this.selectRoom(roomId) } catch (e) { this._pendingScrollHash = null - const status = e?.status || e?.response?.status + const status = e?.status if (status === 403) { this.update({ linkError: 'You don\'t have access to this room' }) } else if (status === 404) { @@ -567,7 +587,6 @@ } else { this.update({ linkError: 'Could not load this room' }) } - // Clean the hash from URL window.history.replaceState(null, '', window.location.pathname) return } diff --git a/client/src/services/api.js b/client/src/services/api.js index ccb0745..2148d7a 100644 --- a/client/src/services/api.js +++ b/client/src/services/api.js @@ -86,6 +86,7 @@ export const api = { joinRoom: (roomId) => request('POST', `/rooms/${roomId}/join`), deleteRoom: (roomId) => request('DELETE', `/rooms/${roomId}`), clearRoom: (roomId) => request('POST', `/rooms/${roomId}/clear`), + resolveMessageHash: (hash) => request('GET', `/messages/hash/${hash}`), // Models listModels: () => request('GET', '/models'), diff --git a/server/src/handlers/rooms.rs b/server/src/handlers/rooms.rs index 01abfc6..c742751 100644 --- a/server/src/handlers/rooms.rs +++ b/server/src/handlers/rooms.rs @@ -236,6 +236,30 @@ pub async fn get_messages( Ok(Json(payloads)) } +pub async fn resolve_message_hash( + State(state): State>, + auth: AuthUser, + Path(hash): Path, +) -> Result, (StatusCode, String)> { + // Find the message by hash + let row = sqlx::query_as::<_, (String,)>( + "SELECT m.room_id FROM messages m \ + JOIN room_members rm ON rm.room_id = m.room_id AND rm.user_id = ? \ + JOIN rooms r ON r.id = m.room_id AND r.deleted_at IS NULL \ + WHERE m.hash = ? LIMIT 1", + ) + .bind(&auth.user_id) + .bind(&hash) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + match row { + Some((room_id,)) => Ok(Json(serde_json::json!({ "room_id": room_id, "hash": hash }))), + None => Err((StatusCode::NOT_FOUND, "Message not found or no access".into())), + } +} + pub async fn join_room( State(state): State>, auth: AuthUser, diff --git a/server/src/main.rs b/server/src/main.rs index 4a65a83..8f8d3f7 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -287,6 +287,7 @@ async fn main() { .route("/api/rooms/:room_id/messages", get(handlers::rooms::get_messages)) .route("/api/rooms/:room_id/join", post(handlers::rooms::join_room)) .route("/api/rooms/:room_id/clear", post(handlers::rooms::clear_room)) + .route("/api/messages/hash/:hash", get(handlers::rooms::resolve_message_hash)) // Upload (chat images) .route("/api/upload", post(handlers::upload::upload_chat_image)) // Models