feat: add SHA-256 integrity hashes to messages with copy/link buttons

Add a hash column to messages table computed from SHA-256(created_at + content)
to ensure message integrity. Existing messages get backfilled during migration.
All messages now show copy and permalink buttons on hover, with hash-based
URL fragments that auto-scroll and highlight the target message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-03-16 17:11:03 -06:00
parent 6cb423b342
commit 2e1a0ac858
16 changed files with 298 additions and 51 deletions

View File

@ -36,7 +36,27 @@
"Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force; Write-Output ''Killed''\")", "Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force; Write-Output ''Killed''\")",
"Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; Set-Location ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; cargo build 2>&1\")", "Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; Set-Location ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; cargo build 2>&1\")",
"Bash(git init)", "Bash(git init)",
"Bash(git add -A)" "Bash(git add -A)",
"Bash(git commit:*)",
"Bash(git branch stream)",
"Bash(git checkout stream)",
"Bash(powershell -Command \"cd ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force; cargo build 2>&1\")",
"Bash(powershell -Command \"cd ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; cargo build 2>&1\")",
"Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Select-Object Id, ProcessName\")",
"Bash(powershell -Command \"cd ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; cargo build 2>&1; Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Seconds 1; Start-Process -FilePath ''.\\\\target\\\\debug\\\\groupchat-server.exe'' -WindowStyle Hidden; Start-Sleep -Seconds 2; Get-Process groupchat-server -ErrorAction SilentlyContinue | Select-Object Id\")",
"Bash(powershell -Command \"try { $r = Invoke-WebRequest -Uri ''http://localhost:3001/api/models'' -TimeoutSec 5 -ErrorAction Stop; $r.StatusCode } catch { $_Exception.Message }\")",
"Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force\")",
"Bash(powershell -Command \"Start-Process -FilePath ''.\\\\target\\\\debug\\\\groupchat-server.exe'' -WindowStyle Hidden\")",
"Bash(npm run build)",
"Bash(git add client/src/components/app.riot client/src/components/chat-room.riot client/src/components/message-bubble.riot client/src/services/stream-buffer.js server/src/handlers/ws.rs server/src/main.rs server/src/models/mod.rs server/src/services/openrouter.rs)",
"Bash(taskkill /F /IM server.exe)",
"Bash(timeout /t 2 /nobreak)",
"Bash(curl -s http://localhost:3001/api/auth/me -H \"Authorization: Bearer test\")",
"Bash(git add server/migrations/004_ai_name.sql server/src/main.rs server/src/models/mod.rs server/src/handlers/rooms.rs server/src/handlers/ws.rs server/src/handlers/invites.rs client/src/components/app.riot client/src/components/chat-room.riot client/src/components/create-room-modal.riot client/src/components/message-bubble.riot)",
"WebFetch(domain:api.dicebear.com)",
"mcp__Desktop_Commander__list_sessions",
"mcp__Desktop_Commander__write_file",
"Bash(git checkout -b CAN)"
] ]
} }
} }

View File

@ -146,10 +146,24 @@
.no-room-content p { .no-room-content p {
font-size: var(--text-sm); font-size: var(--text-sm);
} }
:global(.hash-highlight) {
animation: hash-flash 3s ease;
}
@keyframes hash-flash {
0%, 15% {
background: rgba(108, 92, 231, 0.2);
border-radius: 8px;
}
100% {
background: transparent;
}
}
</style> </style>
<script> <script>
import { api, saveAuth, getUser, clearAuth, isAuthenticated } from '../services/api.js' import { api, saveAuth, getUser, clearAuth, isAuthenticated, setOnUnauthorized } from '../services/api.js'
import { ws } from '../services/websocket.js' import { ws } from '../services/websocket.js'
import { StreamBuffer } from '../services/stream-buffer.js' import { StreamBuffer } from '../services/stream-buffer.js'
@ -173,24 +187,58 @@
}, },
onMounted() { onMounted() {
// Register global 401 handler so any expired-token API call triggers logout
setOnUnauthorized(() => this.handleLogout())
// Listen for hash changes to scroll to linked messages
this._onHashChange = () => {
const hash = window.location.hash?.slice(1)
if (hash) this.scrollToHash(hash)
}
window.addEventListener('hashchange', this._onHashChange)
const user = getUser() const user = getUser()
if (user && isAuthenticated()) { if (user && isAuthenticated()) {
this.update({ user }) // Verify the token is still valid with the server before trusting it
this.initChat() this.verifyAndInit(user)
} else { } else {
// Not logged in — store invite token so we can accept after login // Not logged in — store invite token so we can accept after login
this.checkPendingInvite() this.checkPendingInvite()
} }
}, },
async verifyAndInit(cachedUser) {
try {
// Ask the server if our stored token is still good
const freshUser = await api.me()
// Use the fresh data from the server (display_name etc. may have changed)
this.update({ user: freshUser })
this.initChat()
} catch (err) {
// Token is expired or invalid — force back to login screen
console.warn('Stored token is no longer valid, logging out:', err.message)
clearAuth()
ws.disconnect()
this.update({ user: null })
this.checkPendingInvite()
}
},
onUnmounted() { onUnmounted() {
ws.disconnect() ws.disconnect()
window.removeEventListener('hashchange', this._onHashChange)
}, },
async initChat() { async initChat() {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
ws.connect(token) ws.connect(token)
// If WebSocket detects auth failure (expired/invalid token), auto-logout
ws.on('auth_failed', () => {
console.warn('WebSocket auth failed — logging out')
this.handleLogout()
})
ws.on('new_message', (msg) => { ws.on('new_message', (msg) => {
if (msg.message.room_id === this.state.activeRoomId) { if (msg.message.room_id === this.state.activeRoomId) {
// If we were streaming this message, cancel the buffer and remove placeholder // If we were streaming this message, cancel the buffer and remove placeholder
@ -360,7 +408,14 @@
typingUsers: [], typingUsers: [],
}) })
ws.joinRoom(roomId) ws.joinRoom(roomId)
// Check for hash in URL to scroll to a specific message
const targetHash = window.location.hash?.slice(1)
if (targetHash) {
this.scrollToHash(targetHash)
} else {
this.scrollToBottom() this.scrollToBottom()
}
} catch (e) { } catch (e) {
console.error('Failed to load room:', e) console.error('Failed to load room:', e)
} }
@ -448,6 +503,18 @@
} }
}) })
}, },
scrollToHash(hash) {
requestAnimationFrame(() => {
const el = document.getElementById('msg-' + hash)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Briefly highlight the message
el.classList.add('hash-highlight')
setTimeout(() => el.classList.remove('hash-highlight'), 3000)
}
})
},
} }
</script> </script>
</app> </app>

View File

@ -72,7 +72,7 @@
<!-- Messages --> <!-- Messages -->
<div class="messages-list" ref="messagesList"> <div class="messages-list" ref="messagesList">
<div class="messages-spacer"></div> <div class="messages-spacer"></div>
<div each={msg in props.messages} key={msg.id}> <div each={msg in props.messages} key={msg.id} data-hash={msg.hash || ''} id={msg.hash ? 'msg-' + msg.hash : ''}>
<message-bubble <message-bubble
message={msg} message={msg}
is-own={msg.sender_id === props.user?.id} is-own={msg.sender_id === props.user?.id}

View File

@ -129,7 +129,7 @@
try { try {
const data = await api.login({ const data = await api.login({
email: this.state.email, email: this.state.email.trim().toLowerCase(),
password: this.state.password, password: this.state.password,
}) })
this.props.cbLogin(data) this.props.cbLogin(data)

View File

@ -10,7 +10,7 @@
<circle cx="15" cy="14" r="1.5" fill="currentColor"/> <circle cx="15" cy="14" r="1.5" fill="currentColor"/>
</svg> </svg>
<img if={!props.message?.is_ai} <img if={!props.message?.is_ai}
src={avatarFromHash(props.message?.avatar_hash)} src={getMessageAvatar(props.message)}
alt={props.message?.sender_name} alt={props.message?.sender_name}
width="32" width="32"
height="32" height="32"
@ -41,12 +41,19 @@
</div> </div>
<div if={props.isStreaming} class="message-content streaming-content">{props.message?.content}<span class="streaming-cursor">▌</span></div> <div if={props.isStreaming} class="message-content streaming-content">{props.message?.content}<span class="streaming-cursor">▌</span></div>
<div if={!props.isStreaming} class="message-content markdown-content"></div> <div if={!props.isStreaming} class="message-content markdown-content"></div>
<div if={props.message?.is_ai && props.message?.ai_meta} class="ai-stats-bar"> <div if={!props.isStreaming} class="message-actions-bar">
<button class="ai-stat-btn" onclick={copyFullMessage} title="Copy response"> <button class="msg-action-btn" onclick={copyFullMessage} title="Copy message">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/> <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg> </svg>
</button> </button>
<button if={props.message?.hash} class="msg-action-btn" onclick={copyMessageLink} title="Copy link to message">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</button>
<template if={props.message?.is_ai && props.message?.ai_meta}>
<span class="ai-stat-item model"> <span class="ai-stat-item model">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/></svg>
{formatModel(props.message.ai_meta.model)} {formatModel(props.message.ai_meta.model)}
@ -60,6 +67,7 @@
<span class="ai-stat-item" title="Response time"> <span class="ai-stat-item" title="Response time">
⏱ {(props.message.ai_meta.response_ms / 1000).toFixed(1)}s ⏱ {(props.message.ai_meta.response_ms / 1000).toFixed(1)}s
</span> </span>
</template>
</div> </div>
</div> </div>
</div> </div>
@ -262,7 +270,7 @@
overflow-y: auto; overflow-y: auto;
} }
.ai-stats-bar { .message-actions-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
@ -271,9 +279,21 @@
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
flex-wrap: wrap; flex-wrap: wrap;
opacity: 0;
transition: opacity var(--transition-fast);
} }
.ai-stat-btn { .message:hover .message-actions-bar,
.message-actions-bar:focus-within {
opacity: 1;
}
/* Always show for AI messages with stats */
.ai-message .message-actions-bar {
opacity: 1;
}
.msg-action-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -284,12 +304,12 @@
line-height: 1; line-height: 1;
} }
.ai-stat-btn:hover { .msg-action-btn:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
} }
.ai-stat-btn.copied { .msg-action-btn.copied {
color: var(--success); color: var(--success);
} }
@ -345,10 +365,14 @@
<script> <script>
import { renderMarkdown } from '../services/markdown.js' import { renderMarkdown } from '../services/markdown.js'
import { avatarFromHash as _avatarFromHash } from '../services/avatar.js' import { avatarFromHash } from '../services/avatar.js'
export default { export default {
avatarFromHash: _avatarFromHash, /** Prefer custom avatar_url, fall back to Gravatar hash */
getMessageAvatar(msg) {
if (msg?.avatar_url) return msg.avatar_url
return avatarFromHash(msg?.avatar_hash, 32)
},
onMounted() { onMounted() {
this.renderContent() this.renderContent()
@ -444,7 +468,24 @@
btn.title = 'Copied!' btn.title = 'Copied!'
setTimeout(() => { setTimeout(() => {
btn.classList.remove('copied') btn.classList.remove('copied')
btn.title = 'Copy response' btn.title = 'Copy message'
}, 2000)
})
},
copyMessageLink(e) {
e.preventDefault()
e.stopPropagation()
const hash = this.props.message?.hash
if (!hash) return
const url = `${window.location.origin}${window.location.pathname}#${hash}`
const btn = e.currentTarget
navigator.clipboard.writeText(url).then(() => {
btn.classList.add('copied')
btn.title = 'Link copied!'
setTimeout(() => {
btn.classList.remove('copied')
btn.title = 'Copy link to message'
}, 2000) }, 2000)
}) })
}, },

View File

@ -143,9 +143,9 @@
try { try {
const data = await api.register({ const data = await api.register({
email: this.state.email, email: this.state.email.trim().toLowerCase(),
password: this.state.password, password: this.state.password,
display_name: this.state.display_name, display_name: this.state.display_name.trim(),
}) })
this.props.cbRegister(data) this.props.cbRegister(data)
} catch (err) { } catch (err) {

View File

@ -1,5 +1,12 @@
const API_BASE = '/api' const API_BASE = '/api'
// Global callback for 401 responses (set by app component to trigger auto-logout)
let onUnauthorized = null
export function setOnUnauthorized(callback) {
onUnauthorized = callback
}
function getToken() { function getToken() {
return localStorage.getItem('token') return localStorage.getItem('token')
} }
@ -25,6 +32,11 @@ async function request(method, path, body) {
const res = await fetch(`${API_BASE}${path}`, opts) const res = await fetch(`${API_BASE}${path}`, opts)
if (!res.ok) { if (!res.ok) {
// Auto-logout on 401 for any authenticated request (not login/register)
if (res.status === 401 && path !== '/auth/login' && path !== '/auth/register') {
if (onUnauthorized) onUnauthorized()
throw new Error('Session expired — please log in again')
}
const text = await res.text() const text = await res.text()
throw new Error(text || `HTTP ${res.status}`) throw new Error(text || `HTTP ${res.status}`)
} }

View File

@ -17,6 +17,7 @@ class WebSocketManager {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return if (this.ws && this.ws.readyState === WebSocket.OPEN) return
this.token = token this.token = token
this._authFailed = false
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host const host = window.location.host
@ -25,6 +26,7 @@ class WebSocketManager {
this.ws.onopen = () => { this.ws.onopen = () => {
console.log('[WS] Connected') console.log('[WS] Connected')
this.reconnectDelay = 1000 this.reconnectDelay = 1000
this._authFailed = false
// Re-subscribe to all rooms we were watching // Re-subscribe to all rooms we were watching
for (const roomId of this.subscribedRooms) { for (const roomId of this.subscribedRooms) {
@ -46,8 +48,23 @@ class WebSocketManager {
} }
this.ws.onclose = (event) => { this.ws.onclose = (event) => {
console.log('[WS] Disconnected', event.code) console.log('[WS] Disconnected, code:', event.code)
this.emit('disconnected') this.emit('disconnected')
// Code 1008 = Policy Violation (server rejected auth)
// Code 4401 = custom auth failure
// Also detect immediate close without open (HTTP 401 on upgrade)
if (event.code === 1008 || event.code === 4401 || !event.wasClean) {
// If we never successfully connected, the token is likely invalid
if (this._authFailed || (!event.wasClean && this.reconnectDelay > 4000)) {
console.warn('[WS] Auth appears invalid, stopping reconnect')
this.token = null
this.emit('auth_failed')
return
}
this._authFailed = true
}
if (this.token) { if (this.token) {
this.scheduleReconnect() this.scheduleReconnect()
} }

1
server/Cargo.lock generated
View File

@ -863,6 +863,7 @@ dependencies = [
"scraper", "scraper",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"sqlx", "sqlx",
"tokio", "tokio",
"tower 0.4.13", "tower 0.4.13",

View File

@ -25,4 +25,5 @@ rand = "0.8"
async-trait = "0.1" async-trait = "0.1"
scraper = "0.22" scraper = "0.22"
md-5 = "0.10" md-5 = "0.10"
sha2 = "0.10"
base64 = "0.22" base64 = "0.22"

View File

@ -0,0 +1,5 @@
-- Add SHA-256 integrity hash column to messages
ALTER TABLE messages ADD COLUMN hash TEXT;
-- Backfill hashes for existing messages is done in Rust (see main.rs)
-- because SQLite doesn't have a built-in SHA-256 function.

View File

@ -16,9 +16,20 @@ pub async fn register(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(body): Json<RegisterRequest>, Json(body): Json<RegisterRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> { ) -> Result<Json<AuthResponse>, (StatusCode, String)> {
// Check if email already exists // Normalize email: trim whitespace and lowercase for consistent matching
let existing = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE email = ?") let email = body.email.trim().to_lowercase();
.bind(&body.email) let display_name = body.display_name.trim().to_string();
if email.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Email is required".into()));
}
if display_name.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Display name is required".into()));
}
// Check if email already exists (case-insensitive for safety with legacy data)
let existing = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE LOWER(email) = ?")
.bind(&email)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@ -36,22 +47,22 @@ pub async fn register(
sqlx::query("INSERT INTO users (id, email, display_name, password_hash) VALUES (?, ?, ?, ?)") sqlx::query("INSERT INTO users (id, email, display_name, password_hash) VALUES (?, ?, ?, ?)")
.bind(&user_id) .bind(&user_id)
.bind(&body.email) .bind(&email)
.bind(&body.display_name) .bind(&display_name)
.bind(&password_hash) .bind(&password_hash)
.execute(&state.db) .execute(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let token = create_token(&user_id, &body.email, &body.display_name, &state.jwt_secret) let token = create_token(&user_id, &email, &display_name, &state.jwt_secret)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(AuthResponse { Ok(Json(AuthResponse {
token, token,
user: UserPublic { user: UserPublic {
id: user_id, id: user_id,
email: body.email, email,
display_name: body.display_name, display_name,
avatar_url: None, avatar_url: None,
}, },
})) }))
@ -61,10 +72,13 @@ pub async fn login(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(body): Json<LoginRequest>, Json(body): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> { ) -> Result<Json<AuthResponse>, (StatusCode, String)> {
// Normalize email: trim whitespace and lowercase for case-insensitive matching
let email = body.email.trim().to_lowercase();
let user = sqlx::query_as::<_, (String, String, String, String, Option<String>)>( let user = sqlx::query_as::<_, (String, String, String, String, Option<String>)>(
"SELECT id, email, display_name, password_hash, avatar_url FROM users WHERE email = ?", "SELECT id, email, display_name, password_hash, avatar_url FROM users WHERE LOWER(email) = ?",
) )
.bind(&body.email) .bind(&email)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?

View File

@ -180,10 +180,10 @@ pub async fn get_messages(
return Err((StatusCode::FORBIDDEN, "Not a member of this room".into())); return Err((StatusCode::FORBIDDEN, "Not a member of this room".into()));
} }
// Query messages with user email via LEFT JOIN for Gravatar hash // Query messages with user email + avatar_url via LEFT JOIN
let rows = if let Some(before) = &params.before { let rows = if let Some(before) = &params.before {
sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>, Option<String>)>( sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>(
"SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, m.image_url, u.email \ "SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, m.image_url, u.email, u.avatar_url, m.hash \
FROM messages m LEFT JOIN users u ON m.sender_id = u.id \ FROM messages m LEFT JOIN users u ON m.sender_id = u.id \
WHERE m.room_id = ? AND m.created_at < ? ORDER BY m.created_at DESC LIMIT ?", WHERE m.room_id = ? AND m.created_at < ? ORDER BY m.created_at DESC LIMIT ?",
) )
@ -193,8 +193,8 @@ pub async fn get_messages(
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
} else { } else {
sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>, Option<String>)>( sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>(
"SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, m.image_url, u.email \ "SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, m.image_url, u.email, u.avatar_url, m.hash \
FROM messages m LEFT JOIN users u ON m.sender_id = u.id \ FROM messages m LEFT JOIN users u ON m.sender_id = u.id \
WHERE m.room_id = ? ORDER BY m.created_at DESC LIMIT ?", WHERE m.room_id = ? ORDER BY m.created_at DESC LIMIT ?",
) )
@ -208,7 +208,7 @@ pub async fn get_messages(
let payloads: Vec<MessagePayload> = rows let payloads: Vec<MessagePayload> = rows
.into_iter() .into_iter()
.rev() .rev()
.map(|(id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, ai_meta_str, image_url, email)| { .map(|(id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, ai_meta_str, image_url, email, avatar_url, hash)| {
let ai_meta = ai_meta_str let ai_meta = ai_meta_str
.as_deref() .as_deref()
.and_then(|s| serde_json::from_str::<crate::models::AiMeta>(s).ok()); .and_then(|s| serde_json::from_str::<crate::models::AiMeta>(s).ok());
@ -226,7 +226,9 @@ pub async fn get_messages(
created_at, created_at,
ai_meta, ai_meta,
avatar_hash, avatar_hash,
avatar_url,
image_url, image_url,
hash,
} }
}) })
.collect(); .collect();

View File

@ -164,9 +164,12 @@ async fn handle_send_message(
let mentions_json = serde_json::to_string(mentions).unwrap_or_else(|_| "[]".to_string()); let mentions_json = serde_json::to_string(mentions).unwrap_or_else(|_| "[]".to_string());
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
// Compute integrity hash from timestamp + content
let hash = crate::models::message_hash(&now, content);
// Store in database (with image_url) // Store in database (with image_url)
let _ = sqlx::query( let _ = sqlx::query(
"INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, image_url) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)", "INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, image_url, hash) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
) )
.bind(&msg_id) .bind(&msg_id)
.bind(room_id) .bind(room_id)
@ -176,9 +179,19 @@ async fn handle_send_message(
.bind(&mentions_json) .bind(&mentions_json)
.bind(&now) .bind(&now)
.bind(image_url) .bind(image_url)
.bind(&hash)
.execute(&state.db) .execute(&state.db)
.await; .await;
// Look up the sender's custom avatar (if any) for the message payload
let avatar_url: Option<String> = sqlx::query_scalar("SELECT avatar_url FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.flatten();
// Broadcast human message // Broadcast human message
let payload = MessagePayload { let payload = MessagePayload {
id: msg_id, id: msg_id,
@ -191,7 +204,9 @@ async fn handle_send_message(
created_at: now, created_at: now,
ai_meta: None, ai_meta: None,
avatar_hash: crate::models::gravatar_hash(email), avatar_hash: crate::models::gravatar_hash(email),
avatar_url,
image_url: image_url.map(String::from), image_url: image_url.map(String::from),
hash: Some(hash),
}; };
let _ = state.tx.send(BroadcastEvent { let _ = state.tx.send(BroadcastEvent {
@ -402,8 +417,11 @@ async fn handle_send_message(
// Serialize ai_meta for database storage // Serialize ai_meta for database storage
let ai_meta_json = ai_meta.as_ref().and_then(|m| serde_json::to_string(m).ok()); let ai_meta_json = ai_meta.as_ref().and_then(|m| serde_json::to_string(m).ok());
// Compute integrity hash from timestamp + content
let ai_hash = crate::models::message_hash(&ai_now, &ai_response);
let _ = sqlx::query( let _ = sqlx::query(
"INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, ai_meta) VALUES (?, ?, ?, ?, ?, '[]', 1, ?, ?)", "INSERT INTO messages (id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, ai_meta, hash) VALUES (?, ?, ?, ?, ?, '[]', 1, ?, ?, ?)",
) )
.bind(&ai_msg_id) .bind(&ai_msg_id)
.bind(room_id) .bind(room_id)
@ -412,6 +430,7 @@ async fn handle_send_message(
.bind(&ai_response) .bind(&ai_response)
.bind(&ai_now) .bind(&ai_now)
.bind(&ai_meta_json) .bind(&ai_meta_json)
.bind(&ai_hash)
.execute(&state.db) .execute(&state.db)
.await; .await;
@ -427,7 +446,9 @@ async fn handle_send_message(
created_at: ai_now, created_at: ai_now,
ai_meta, ai_meta,
avatar_hash: String::new(), avatar_hash: String::new(),
avatar_url: None,
image_url: None, image_url: None,
hash: Some(ai_hash),
}; };
let _ = state.tx.send(BroadcastEvent { let _ = state.tx.send(BroadcastEvent {

View File

@ -221,6 +221,38 @@ async fn main() {
Err(e) => panic!("Failed to run migration 006: {}", e), Err(e) => panic!("Failed to run migration 006: {}", e),
} }
// Run migration 007 - SHA-256 integrity hash on messages
let migration_007 = include_str!("../migrations/007_message_hash.sql");
match sqlx::raw_sql(migration_007).execute(&db).await {
Ok(_) => {
tracing::info!("Migration 007 applied, backfilling hashes for existing messages...");
// Backfill hashes for all existing messages that don't have one
let rows = sqlx::query_as::<_, (String, String, String)>(
"SELECT id, created_at, content FROM messages WHERE hash IS NULL",
)
.fetch_all(&db)
.await
.unwrap_or_default();
let count = rows.len();
for (id, created_at, content) in rows {
let hash = models::message_hash(&created_at, &content);
let _ = sqlx::query("UPDATE messages SET hash = ? WHERE id = ?")
.bind(&hash)
.bind(&id)
.execute(&db)
.await;
}
if count > 0 {
tracing::info!("Backfilled hashes for {} existing messages", count);
}
}
Err(e) if e.to_string().contains("duplicate column") => {
tracing::debug!("Migration 007 already applied, skipping");
}
Err(e) => panic!("Failed to run migration 007: {}", e),
}
tracing::info!("Database initialized"); tracing::info!("Database initialized");
let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096); let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096);

View File

@ -35,6 +35,7 @@ pub struct Message {
pub is_ai: bool, pub is_ai: bool,
pub created_at: String, pub created_at: String,
pub ai_meta: Option<String>, pub ai_meta: Option<String>,
pub hash: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
@ -212,7 +213,11 @@ pub struct MessagePayload {
#[serde(default)] #[serde(default)]
pub avatar_hash: String, pub avatar_hash: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>, pub image_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
} }
/// Compute Gravatar-compatible MD5 hash from an email address. /// Compute Gravatar-compatible MD5 hash from an email address.
@ -223,6 +228,15 @@ pub fn gravatar_hash(email: &str) -> String {
format!("{:x}", result) format!("{:x}", result)
} }
/// Compute SHA-256 integrity hash from created_at timestamp + message content.
pub fn message_hash(created_at: &str, content: &str) -> String {
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(created_at.as_bytes());
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiMeta { pub struct AiMeta {
pub model: String, pub model: String,