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) <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-03-16 17:44:44 -06:00
parent e630cca6c6
commit 55c17b2999
4 changed files with 54 additions and 9 deletions

View File

@ -480,7 +480,7 @@
/** Stash a message permalink hash so it survives the login flow */ /** Stash a message permalink hash so it survives the login flow */
stashPendingLink() { stashPendingLink() {
const fragment = window.location.hash?.slice(1) const fragment = window.location.hash?.slice(1)
if (fragment && fragment.includes('/')) { if (fragment) {
sessionStorage.setItem('pendingMessageLink', 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() { async navigateToMessageLink() {
// Try URL hash first, then sessionStorage (for post-login flow) // Try URL hash first, then sessionStorage (for post-login flow)
let fragment = window.location.hash?.slice(1) let fragment = window.location.hash?.slice(1)
if (!fragment || !fragment.includes('/')) { if (!fragment) {
fragment = sessionStorage.getItem('pendingMessageLink') fragment = sessionStorage.getItem('pendingMessageLink')
sessionStorage.removeItem('pendingMessageLink') sessionStorage.removeItem('pendingMessageLink')
} }
if (!fragment) return if (!fragment) return
const slashIdx = fragment.indexOf('/') let roomId, msgHash
if (slashIdx === -1) return
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 if (!roomId || !msgHash) return
// Store the target hash so selectRoom skips scrollToBottom // Store the target hash so selectRoom skips scrollToBottom
@ -559,7 +579,7 @@
await this.selectRoom(roomId) await this.selectRoom(roomId)
} catch (e) { } catch (e) {
this._pendingScrollHash = null this._pendingScrollHash = null
const status = e?.status || e?.response?.status const status = e?.status
if (status === 403) { if (status === 403) {
this.update({ linkError: 'You don\'t have access to this room' }) this.update({ linkError: 'You don\'t have access to this room' })
} else if (status === 404) { } else if (status === 404) {
@ -567,7 +587,6 @@
} else { } else {
this.update({ linkError: 'Could not load this room' }) this.update({ linkError: 'Could not load this room' })
} }
// Clean the hash from URL
window.history.replaceState(null, '', window.location.pathname) window.history.replaceState(null, '', window.location.pathname)
return return
} }

View File

@ -86,6 +86,7 @@ export const api = {
joinRoom: (roomId) => request('POST', `/rooms/${roomId}/join`), joinRoom: (roomId) => request('POST', `/rooms/${roomId}/join`),
deleteRoom: (roomId) => request('DELETE', `/rooms/${roomId}`), deleteRoom: (roomId) => request('DELETE', `/rooms/${roomId}`),
clearRoom: (roomId) => request('POST', `/rooms/${roomId}/clear`), clearRoom: (roomId) => request('POST', `/rooms/${roomId}/clear`),
resolveMessageHash: (hash) => request('GET', `/messages/hash/${hash}`),
// Models // Models
listModels: () => request('GET', '/models'), listModels: () => request('GET', '/models'),

View File

@ -236,6 +236,30 @@ pub async fn get_messages(
Ok(Json(payloads)) Ok(Json(payloads))
} }
pub async fn resolve_message_hash(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Path(hash): Path<String>,
) -> Result<Json<serde_json::Value>, (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( pub async fn join_room(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
auth: AuthUser, auth: AuthUser,

View File

@ -287,6 +287,7 @@ async fn main() {
.route("/api/rooms/:room_id/messages", get(handlers::rooms::get_messages)) .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/join", post(handlers::rooms::join_room))
.route("/api/rooms/:room_id/clear", post(handlers::rooms::clear_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) // Upload (chat images)
.route("/api/upload", post(handlers::upload::upload_chat_image)) .route("/api/upload", post(handlers::upload::upload_chat_image))
// Models // Models