diff --git a/frontend/disputes.js b/frontend/disputes.js
index 2c73375d..786221f1 100644
--- a/frontend/disputes.js
+++ b/frontend/disputes.js
@@ -28,7 +28,8 @@
const formData = new FormData();
formData.append('trade_id', tradeId);
formData.append('reason', reason);
-
+
+ // Legacy: attach files directly for any server-side handler that reads them
evidence.forEach((file, index) => {
formData.append(`evidence_${index}`, file);
});
@@ -39,8 +40,15 @@
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
-
+
const data = await response.json();
+
+ // Upload evidence to the dedicated storage backend (Issue #125)
+ const uploader = data.raised_by || '';
+ if (uploader) {
+ await uploadStagedEvidence(data.id || tradeId, uploader);
+ }
+
dispatchDisputeEvent('raised', { disputeId: data.id, tradeId });
return { success: true, disputeId: data.id };
} catch (error) {
@@ -48,7 +56,6 @@
return { success: false, error: error.message };
}
}
-
async function getDisputeDetails(disputeId) {
try {
const response = await fetch(`${DISPUTE_API}/${disputeId}`);
@@ -100,15 +107,17 @@
// ============================================
function validateEvidence(file) {
+ // Delegate to EvidenceService when available, fall back to local check
+ if (window.EvidenceService) {
+ return window.EvidenceService.validateEvidenceFile(file);
+ }
if (file.size > MAX_EVIDENCE_SIZE) {
return { valid: false, error: 'File exceeds 5MB limit' };
}
-
const allowed = ['image/jpeg', 'image/png', 'application/pdf', 'text/plain'];
if (!allowed.includes(file.type)) {
return { valid: false, error: 'File type not allowed' };
}
-
return { valid: true };
}
@@ -134,6 +143,48 @@
disputeState.evidence = disputeState.evidence.filter(e => e.id !== evidenceId);
}
+ /**
+ * Upload all staged evidence files to the backend storage service.
+ * Called after a dispute is successfully raised.
+ * @param {number|string} disputeId - trade_id / dispute identifier
+ * @param {string} uploader - Stellar address
+ */
+ async function uploadStagedEvidence(disputeId, uploader) {
+ if (!window.EvidenceService || disputeState.evidence.length === 0) return;
+
+ const results = await Promise.allSettled(
+ disputeState.evidence.map(({ file }) =>
+ window.EvidenceService.uploadEvidence(disputeId, file, uploader)
+ )
+ );
+
+ const failed = results.filter(r => r.status === 'rejected' || !r.value?.success);
+ if (failed.length > 0) {
+ console.warn(`${failed.length} evidence file(s) failed to upload.`);
+ }
+ }
+
+ /**
+ * Load and render evidence for the currently displayed dispute.
+ * @param {number|string} disputeId
+ * @param {string} requester
+ */
+ async function loadAndRenderEvidence(disputeId, requester) {
+ if (!window.EvidenceService) return;
+
+ const container = document.getElementById('dispute-evidence-list');
+ if (!container) return;
+
+ container.innerHTML = '
+
${dispute.resolution ? `
@@ -307,6 +365,36 @@
` : ''}
`;
+
+ // Wire up the inline evidence upload input
+ const evidenceInput = container.querySelector('#evidence-detail-input');
+ if (evidenceInput) {
+ evidenceInput.addEventListener('change', async (e) => {
+ const files = Array.from(e.target.files);
+ const uploader = dispute.raised_by || '';
+ for (const file of files) {
+ const validation = validateEvidence(file);
+ if (!validation.valid) {
+ showToast(validation.error, 'error');
+ continue;
+ }
+ if (window.EvidenceService && uploader) {
+ const result = await window.EvidenceService.uploadEvidence(
+ dispute.trade_id, file, uploader
+ );
+ if (!result.success) {
+ showToast(`Upload failed: ${result.error}`, 'error');
+ }
+ }
+ }
+ e.target.value = '';
+ // Refresh the evidence list
+ await loadAndRenderEvidence(dispute.trade_id, dispute.raised_by || '');
+ });
+ }
+
+ // Load evidence from the storage backend
+ loadAndRenderEvidence(dispute.trade_id, dispute.raised_by || '');
}
function displayDisputesList(disputes) {
@@ -423,6 +511,8 @@
resolveDispute,
addEvidence,
removeEvidence,
+ uploadStagedEvidence,
+ loadAndRenderEvidence,
displayDisputeDetails,
displayDisputesList,
initDisputeUI,
diff --git a/frontend/evidenceService.js b/frontend/evidenceService.js
new file mode 100644
index 00000000..3931a6fa
--- /dev/null
+++ b/frontend/evidenceService.js
@@ -0,0 +1,220 @@
+/**
+ * Evidence Storage Service (Issue #124)
+ * Integrates with POST /disputes/:id/evidence and signed download URL endpoints.
+ */
+
+(function () {
+ 'use strict';
+
+ const INDEXER_BASE = window.INDEXER_BASE_URL || '';
+
+ // Allowed MIME types and max size must match the backend StorageService
+ const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
+ const MAX_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB
+
+ /**
+ * Client-side validation before upload.
+ * @param {File} file
+ * @returns {{ valid: boolean, error?: string }}
+ */
+ function validateEvidenceFile(file) {
+ if (file.size > MAX_SIZE_BYTES) {
+ return { valid: false, error: `File "${file.name}" exceeds the 20 MB limit.` };
+ }
+ if (!ALLOWED_MIMES.includes(file.type)) {
+ return {
+ valid: false,
+ error: `File type "${file.type}" is not allowed. Use JPEG, PNG, WebP, or PDF.`,
+ };
+ }
+ return { valid: true };
+ }
+
+ /**
+ * Upload a single evidence file for a dispute.
+ * @param {number|string} disputeId - trade_id acting as dispute identifier
+ * @param {File} file
+ * @param {string} uploader - Stellar address of the uploader
+ * @param {string} [description]
+ * @returns {Promise<{ success: boolean, evidence?: object, error?: string }>}
+ */
+ async function uploadEvidence(disputeId, file, uploader, description = '') {
+ const validation = validateEvidenceFile(file);
+ if (!validation.valid) {
+ return { success: false, error: validation.error };
+ }
+
+ const formData = new FormData();
+ formData.append('file', file, file.name);
+ formData.append('uploader', uploader);
+ if (description.trim()) {
+ formData.append('description', description.trim());
+ }
+
+ try {
+ const res = await fetch(`${INDEXER_BASE}/disputes/${disputeId}/evidence`, {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ return { success: false, error: body.error || `Upload failed (HTTP ${res.status})` };
+ }
+
+ const data = await res.json();
+ return { success: true, evidence: data };
+ } catch (err) {
+ return { success: false, error: err.message };
+ }
+ }
+
+ /**
+ * Fetch all evidence records for a dispute.
+ * @param {number|string} disputeId
+ * @param {string} requester - Stellar address of the viewer
+ * @returns {Promise<{ success: boolean, evidence?: object[], error?: string }>}
+ */
+ async function listEvidence(disputeId, requester) {
+ try {
+ const res = await fetch(
+ `${INDEXER_BASE}/disputes/${disputeId}/evidence?requester=${encodeURIComponent(requester)}`
+ );
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ return { success: false, error: body.error || `Fetch failed (HTTP ${res.status})` };
+ }
+ const data = await res.json();
+ return { success: true, evidence: data.evidence || [] };
+ } catch (err) {
+ return { success: false, error: err.message };
+ }
+ }
+
+ /**
+ * Obtain a short-lived signed download URL for a specific evidence item.
+ * @param {number|string} disputeId
+ * @param {string} evidenceId - UUID of the evidence record
+ * @param {string} requester
+ * @returns {Promise<{ success: boolean, downloadUrl?: string, expiresAt?: string, error?: string }>}
+ */
+ async function getDownloadUrl(disputeId, evidenceId, requester) {
+ try {
+ const res = await fetch(
+ `${INDEXER_BASE}/disputes/${disputeId}/evidence/${evidenceId}/download-url?requester=${encodeURIComponent(requester)}`
+ );
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ return { success: false, error: body.error || `Token request failed (HTTP ${res.status})` };
+ }
+ const data = await res.json();
+ return {
+ success: true,
+ downloadUrl: `${INDEXER_BASE}${data.download_url}`,
+ expiresAt: data.expires_at,
+ };
+ } catch (err) {
+ return { success: false, error: err.message };
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // UI helpers
+ // -------------------------------------------------------------------------
+
+ /**
+ * Render the evidence list into a container element.
+ * Each item gets a "Download" button that fetches a signed URL on demand.
+ *
+ * @param {HTMLElement} container
+ * @param {object[]} evidenceItems - from listEvidence()
+ * @param {number|string} disputeId
+ * @param {string} requester
+ */
+ function renderEvidenceList(container, evidenceItems, disputeId, requester) {
+ if (!container) return;
+
+ if (!evidenceItems || evidenceItems.length === 0) {
+ container.innerHTML = '
No evidence uploaded yet.
';
+ return;
+ }
+
+ container.innerHTML = evidenceItems
+ .map(
+ (item) => `
+
+
+ ${escapeHtml(item.file?.original_name || 'File')}
+ by ${formatAddress(item.uploader)}
+ ${item.description ? `${escapeHtml(item.description)}` : ''}
+ ${formatTimestamp(item.created_at)}
+
+
+
`
+ )
+ .join('');
+
+ container.querySelectorAll('.evidence-download-btn').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ btn.disabled = true;
+ btn.textContent = 'Getting linkā¦';
+ const result = await getDownloadUrl(disputeId, btn.dataset.evidenceId, requester);
+ if (result.success) {
+ // Open in new tab ā browser handles the download
+ window.open(result.downloadUrl, '_blank', 'noopener');
+ } else {
+ alert(`Could not get download link: ${result.error}`);
+ }
+ btn.disabled = false;
+ btn.textContent = 'Download';
+ });
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // Private helpers (duplicated from disputes.js to keep module self-contained)
+ // -------------------------------------------------------------------------
+
+ function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = String(text);
+ return div.innerHTML;
+ }
+
+ function formatAddress(addr) {
+ if (!addr || addr.length < 12) return addr || '';
+ return `${addr.slice(0, 6)}ā¦${addr.slice(-6)}`;
+ }
+
+ function formatTimestamp(ts) {
+ return new Date(ts).toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // Public API
+ // -------------------------------------------------------------------------
+
+ window.EvidenceService = {
+ validateEvidenceFile,
+ uploadEvidence,
+ listEvidence,
+ getDownloadUrl,
+ renderEvidenceList,
+ ALLOWED_MIMES,
+ MAX_SIZE_BYTES,
+ };
+
+ console.log('Evidence Service module loaded');
+})();
diff --git a/indexer/migrations/20260330000000_dispute_evidence.sql b/indexer/migrations/20260330000000_dispute_evidence.sql
new file mode 100644
index 00000000..9e20b451
--- /dev/null
+++ b/indexer/migrations/20260330000000_dispute_evidence.sql
@@ -0,0 +1,25 @@
+-- Dispute evidence: links uploaded files to disputes with participant/arbitrator access control
+CREATE TABLE IF NOT EXISTS dispute_evidence (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ dispute_id BIGINT NOT NULL, -- trade_id acting as dispute identifier
+ file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
+ uploader VARCHAR(100) NOT NULL, -- Stellar address of uploader
+ description TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_dispute_evidence_dispute ON dispute_evidence (dispute_id);
+CREATE INDEX IF NOT EXISTS idx_dispute_evidence_file ON dispute_evidence (file_id);
+
+-- Signed download tokens (short-lived, single-use)
+CREATE TABLE IF NOT EXISTS evidence_download_tokens (
+ token UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
+ requester VARCHAR(100) NOT NULL,
+ expires_at TIMESTAMPTZ NOT NULL,
+ used BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_evidence_tokens_file ON evidence_download_tokens (file_id);
+CREATE INDEX IF NOT EXISTS idx_evidence_tokens_expires ON evidence_download_tokens (expires_at);
diff --git a/indexer/src/database.rs b/indexer/src/database.rs
index d0aea895..18114a94 100644
--- a/indexer/src/database.rs
+++ b/indexer/src/database.rs
@@ -1942,6 +1942,142 @@ impl Database {
Ok(())
}
+ // =========================================================================
+ // Dispute Evidence (Issue #125)
+ // =========================================================================
+
+ /// Verify that `address` is a buyer, seller, or arbitrator for the given dispute (trade_id).
+ /// Returns Forbidden if not a participant.
+ pub async fn check_dispute_participant(&self, dispute_id: i64, address: &str) -> Result<(), crate::error::AppError> {
+ use sqlx::Row;
+ // Participants are derived from trade events stored in the events table.
+ let row = sqlx::query(
+ r#"
+ SELECT COUNT(*) AS cnt
+ FROM events
+ WHERE event_type IN ('trade_created', 'dispute_raised', 'arbitrator_registered')
+ AND (
+ data->>'trade_id' = $1::text
+ AND (
+ data->>'seller' = $2
+ OR data->>'buyer' = $2
+ OR data->>'raised_by' = $2
+ OR data->>'arbitrator' = $2
+ )
+ )
+ "#,
+ )
+ .bind(dispute_id)
+ .bind(address)
+ .fetch_one(&self.pool)
+ .await?;
+
+ let cnt: i64 = row.get("cnt");
+ if cnt == 0 {
+ return Err(crate::error::AppError::Forbidden(
+ "Only dispute participants or arbitrators may access evidence".into(),
+ ));
+ }
+ Ok(())
+ }
+
+ /// Insert a dispute evidence record linking a file to a dispute.
+ pub async fn insert_dispute_evidence(
+ &self,
+ dispute_id: i64,
+ file_id: uuid::Uuid,
+ uploader: &str,
+ description: Option<&str>,
+ ) -> Result
{
+ let record = sqlx::query_as::<_, crate::models::DisputeEvidenceRecord>(
+ r#"
+ INSERT INTO dispute_evidence (dispute_id, file_id, uploader, description)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *
+ "#,
+ )
+ .bind(dispute_id)
+ .bind(file_id)
+ .bind(uploader)
+ .bind(description)
+ .fetch_one(&self.pool)
+ .await?;
+ Ok(record)
+ }
+
+ /// List all evidence records for a dispute.
+ pub async fn list_dispute_evidence(
+ &self,
+ dispute_id: i64,
+ ) -> Result, crate::error::AppError> {
+ let records = sqlx::query_as::<_, crate::models::DisputeEvidenceRecord>(
+ "SELECT * FROM dispute_evidence WHERE dispute_id = $1 ORDER BY created_at ASC",
+ )
+ .bind(dispute_id)
+ .fetch_all(&self.pool)
+ .await?;
+ Ok(records)
+ }
+
+ /// Fetch a single evidence record by its id.
+ pub async fn get_dispute_evidence(
+ &self,
+ evidence_id: uuid::Uuid,
+ ) -> Result