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:
parent
6cb423b342
commit
2e1a0ac858
@ -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 -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; Set-Location ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; cargo build 2>&1\")",
|
||||
"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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,10 +146,24 @@
|
||||
.no-room-content p {
|
||||
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>
|
||||
|
||||
<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 { StreamBuffer } from '../services/stream-buffer.js'
|
||||
|
||||
@ -173,24 +187,58 @@
|
||||
},
|
||||
|
||||
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()
|
||||
if (user && isAuthenticated()) {
|
||||
this.update({ user })
|
||||
this.initChat()
|
||||
// Verify the token is still valid with the server before trusting it
|
||||
this.verifyAndInit(user)
|
||||
} else {
|
||||
// Not logged in — store invite token so we can accept after login
|
||||
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() {
|
||||
ws.disconnect()
|
||||
window.removeEventListener('hashchange', this._onHashChange)
|
||||
},
|
||||
|
||||
async initChat() {
|
||||
const token = localStorage.getItem('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) => {
|
||||
if (msg.message.room_id === this.state.activeRoomId) {
|
||||
// If we were streaming this message, cancel the buffer and remove placeholder
|
||||
@ -360,7 +408,14 @@
|
||||
typingUsers: [],
|
||||
})
|
||||
ws.joinRoom(roomId)
|
||||
this.scrollToBottom()
|
||||
|
||||
// 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()
|
||||
}
|
||||
} catch (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>
|
||||
</app>
|
||||
|
||||
@ -72,7 +72,7 @@
|
||||
<!-- Messages -->
|
||||
<div class="messages-list" ref="messagesList">
|
||||
<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={msg}
|
||||
is-own={msg.sender_id === props.user?.id}
|
||||
|
||||
@ -129,7 +129,7 @@
|
||||
|
||||
try {
|
||||
const data = await api.login({
|
||||
email: this.state.email,
|
||||
email: this.state.email.trim().toLowerCase(),
|
||||
password: this.state.password,
|
||||
})
|
||||
this.props.cbLogin(data)
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
<img if={!props.message?.is_ai}
|
||||
src={avatarFromHash(props.message?.avatar_hash)}
|
||||
src={getMessageAvatar(props.message)}
|
||||
alt={props.message?.sender_name}
|
||||
width="32"
|
||||
height="32"
|
||||
@ -41,25 +41,33 @@
|
||||
</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.message?.is_ai && props.message?.ai_meta} class="ai-stats-bar">
|
||||
<button class="ai-stat-btn" onclick={copyFullMessage} title="Copy response">
|
||||
<div if={!props.isStreaming} class="message-actions-bar">
|
||||
<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">
|
||||
<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>
|
||||
</button>
|
||||
<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>
|
||||
{formatModel(props.message.ai_meta.model)}
|
||||
</span>
|
||||
<span class="ai-stat-item" title="Generation speed">
|
||||
⚡ {calcSpeed(props.message.ai_meta)} tok/sec
|
||||
</span>
|
||||
<span class="ai-stat-item" title="Completion tokens">
|
||||
🎯 {props.message.ai_meta.completion_tokens} tokens
|
||||
</span>
|
||||
<span class="ai-stat-item" title="Response time">
|
||||
⏱ {(props.message.ai_meta.response_ms / 1000).toFixed(1)}s
|
||||
</span>
|
||||
<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">
|
||||
<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)}
|
||||
</span>
|
||||
<span class="ai-stat-item" title="Generation speed">
|
||||
⚡ {calcSpeed(props.message.ai_meta)} tok/sec
|
||||
</span>
|
||||
<span class="ai-stat-item" title="Completion tokens">
|
||||
🎯 {props.message.ai_meta.completion_tokens} tokens
|
||||
</span>
|
||||
<span class="ai-stat-item" title="Response time">
|
||||
⏱ {(props.message.ai_meta.response_ms / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -262,7 +270,7 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ai-stats-bar {
|
||||
.message-actions-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
@ -271,9 +279,21 @@
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -284,12 +304,12 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ai-stat-btn:hover {
|
||||
.msg-action-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-stat-btn.copied {
|
||||
.msg-action-btn.copied {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
@ -345,10 +365,14 @@
|
||||
|
||||
<script>
|
||||
import { renderMarkdown } from '../services/markdown.js'
|
||||
import { avatarFromHash as _avatarFromHash } from '../services/avatar.js'
|
||||
import { avatarFromHash } from '../services/avatar.js'
|
||||
|
||||
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() {
|
||||
this.renderContent()
|
||||
@ -444,7 +468,24 @@
|
||||
btn.title = 'Copied!'
|
||||
setTimeout(() => {
|
||||
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)
|
||||
})
|
||||
},
|
||||
|
||||
@ -143,9 +143,9 @@
|
||||
|
||||
try {
|
||||
const data = await api.register({
|
||||
email: this.state.email,
|
||||
email: this.state.email.trim().toLowerCase(),
|
||||
password: this.state.password,
|
||||
display_name: this.state.display_name,
|
||||
display_name: this.state.display_name.trim(),
|
||||
})
|
||||
this.props.cbRegister(data)
|
||||
} catch (err) {
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
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() {
|
||||
return localStorage.getItem('token')
|
||||
}
|
||||
@ -25,6 +32,11 @@ async function request(method, path, body) {
|
||||
const res = await fetch(`${API_BASE}${path}`, opts)
|
||||
|
||||
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()
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ class WebSocketManager {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) return
|
||||
|
||||
this.token = token
|
||||
this._authFailed = false
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
@ -25,6 +26,7 @@ class WebSocketManager {
|
||||
this.ws.onopen = () => {
|
||||
console.log('[WS] Connected')
|
||||
this.reconnectDelay = 1000
|
||||
this._authFailed = false
|
||||
|
||||
// Re-subscribe to all rooms we were watching
|
||||
for (const roomId of this.subscribedRooms) {
|
||||
@ -46,8 +48,23 @@ class WebSocketManager {
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log('[WS] Disconnected', event.code)
|
||||
console.log('[WS] Disconnected, code:', event.code)
|
||||
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) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
|
||||
1
server/Cargo.lock
generated
1
server/Cargo.lock
generated
@ -863,6 +863,7 @@ dependencies = [
|
||||
"scraper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
|
||||
@ -25,4 +25,5 @@ rand = "0.8"
|
||||
async-trait = "0.1"
|
||||
scraper = "0.22"
|
||||
md-5 = "0.10"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
||||
5
server/migrations/007_message_hash.sql
Normal file
5
server/migrations/007_message_hash.sql
Normal 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.
|
||||
@ -16,9 +16,20 @@ pub async fn register(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<RegisterRequest>,
|
||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||
// Check if email already exists
|
||||
let existing = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE email = ?")
|
||||
.bind(&body.email)
|
||||
// Normalize email: trim whitespace and lowercase for consistent matching
|
||||
let email = body.email.trim().to_lowercase();
|
||||
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)
|
||||
.await
|
||||
.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 (?, ?, ?, ?)")
|
||||
.bind(&user_id)
|
||||
.bind(&body.email)
|
||||
.bind(&body.display_name)
|
||||
.bind(&email)
|
||||
.bind(&display_name)
|
||||
.bind(&password_hash)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.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()))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
token,
|
||||
user: UserPublic {
|
||||
id: user_id,
|
||||
email: body.email,
|
||||
display_name: body.display_name,
|
||||
email,
|
||||
display_name,
|
||||
avatar_url: None,
|
||||
},
|
||||
}))
|
||||
@ -61,10 +72,13 @@ pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> 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>)>(
|
||||
"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)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
|
||||
@ -180,10 +180,10 @@ pub async fn get_messages(
|
||||
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) = ¶ms.before {
|
||||
sqlx::query_as::<_, (String, String, String, String, String, String, bool, 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 \
|
||||
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, u.avatar_url, m.hash \
|
||||
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 ?",
|
||||
)
|
||||
@ -193,8 +193,8 @@ pub async fn get_messages(
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<_, (String, String, String, String, String, String, bool, 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 \
|
||||
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, u.avatar_url, m.hash \
|
||||
FROM messages m LEFT JOIN users u ON m.sender_id = u.id \
|
||||
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
|
||||
.into_iter()
|
||||
.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
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<crate::models::AiMeta>(s).ok());
|
||||
@ -226,7 +226,9 @@ pub async fn get_messages(
|
||||
created_at,
|
||||
ai_meta,
|
||||
avatar_hash,
|
||||
avatar_url,
|
||||
image_url,
|
||||
hash,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@ -164,9 +164,12 @@ async fn handle_send_message(
|
||||
let mentions_json = serde_json::to_string(mentions).unwrap_or_else(|_| "[]".to_string());
|
||||
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)
|
||||
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(room_id)
|
||||
@ -176,9 +179,19 @@ async fn handle_send_message(
|
||||
.bind(&mentions_json)
|
||||
.bind(&now)
|
||||
.bind(image_url)
|
||||
.bind(&hash)
|
||||
.execute(&state.db)
|
||||
.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
|
||||
let payload = MessagePayload {
|
||||
id: msg_id,
|
||||
@ -191,7 +204,9 @@ async fn handle_send_message(
|
||||
created_at: now,
|
||||
ai_meta: None,
|
||||
avatar_hash: crate::models::gravatar_hash(email),
|
||||
avatar_url,
|
||||
image_url: image_url.map(String::from),
|
||||
hash: Some(hash),
|
||||
};
|
||||
|
||||
let _ = state.tx.send(BroadcastEvent {
|
||||
@ -402,8 +417,11 @@ async fn handle_send_message(
|
||||
// Serialize ai_meta for database storage
|
||||
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(
|
||||
"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(room_id)
|
||||
@ -412,6 +430,7 @@ async fn handle_send_message(
|
||||
.bind(&ai_response)
|
||||
.bind(&ai_now)
|
||||
.bind(&ai_meta_json)
|
||||
.bind(&ai_hash)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
@ -427,7 +446,9 @@ async fn handle_send_message(
|
||||
created_at: ai_now,
|
||||
ai_meta,
|
||||
avatar_hash: String::new(),
|
||||
avatar_url: None,
|
||||
image_url: None,
|
||||
hash: Some(ai_hash),
|
||||
};
|
||||
|
||||
let _ = state.tx.send(BroadcastEvent {
|
||||
|
||||
@ -221,6 +221,38 @@ async fn main() {
|
||||
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");
|
||||
|
||||
let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096);
|
||||
|
||||
@ -35,6 +35,7 @@ pub struct Message {
|
||||
pub is_ai: bool,
|
||||
pub created_at: String,
|
||||
pub ai_meta: Option<String>,
|
||||
pub hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
@ -212,7 +213,11 @@ pub struct MessagePayload {
|
||||
#[serde(default)]
|
||||
pub avatar_hash: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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.
|
||||
@ -223,6 +228,15 @@ pub fn gravatar_hash(email: &str) -> String {
|
||||
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)]
|
||||
pub struct AiMeta {
|
||||
pub model: String,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user