From 9c1bd837b6778cfac97dd960608e03dab30dbbca Mon Sep 17 00:00:00 2001 From: FaithOnuh Date: Mon, 30 Mar 2026 12:53:42 +0000 Subject: [PATCH 1/2] feat(api): dispute evidence storage backend (Issue #125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add migration: dispute_evidence table + evidence_download_tokens table - POST /disputes/:id/evidence — multipart upload with participant/arbitrator access control - GET /disputes/:id/evidence — list evidence (participants only) - GET /disputes/:id/evidence/:eid/download-url — issue 15-min signed token - GET /evidence/download/:token — redeem token and stream file - File validation (size/MIME) delegated to existing StorageService - DB helpers: check_dispute_participant, insert/list/get_dispute_evidence, create/consume_evidence_download_token Closes #125 --- .../20260330000000_dispute_evidence.sql | 25 +++ indexer/src/database.rs | 136 +++++++++++++ indexer/src/dispute_evidence_handlers.rs | 190 ++++++++++++++++++ indexer/src/main.rs | 18 +- indexer/src/models.rs | 31 +++ 5 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 indexer/migrations/20260330000000_dispute_evidence.sql create mode 100644 indexer/src/dispute_evidence_handlers.rs 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, crate::error::AppError> { + let record = sqlx::query_as::<_, crate::models::DisputeEvidenceRecord>( + "SELECT * FROM dispute_evidence WHERE id = $1", + ) + .bind(evidence_id) + .fetch_optional(&self.pool) + .await?; + Ok(record) + } + + /// Create a short-lived (15-minute) signed download token for an evidence file. + pub async fn create_evidence_download_token( + &self, + file_id: uuid::Uuid, + requester: &str, + ) -> Result { + use chrono::Duration; + let expires_at = chrono::Utc::now() + Duration::minutes(15); + let record = sqlx::query_as::<_, crate::models::EvidenceDownloadToken>( + r#" + INSERT INTO evidence_download_tokens (file_id, requester, expires_at) + VALUES ($1, $2, $3) + RETURNING token, file_id, requester, expires_at, used, created_at + "#, + ) + .bind(file_id) + .bind(requester) + .bind(expires_at) + .fetch_one(&self.pool) + .await?; + Ok(record) + } + + /// Consume (mark used) a download token if it is valid and unexpired. + /// Returns None if the token is invalid, expired, or already used. + pub async fn consume_evidence_download_token( + &self, + token: uuid::Uuid, + ) -> Result, crate::error::AppError> { + let record = sqlx::query_as::<_, crate::models::EvidenceDownloadToken>( + r#" + UPDATE evidence_download_tokens + SET used = TRUE + WHERE token = $1 + AND used = FALSE + AND expires_at > NOW() + RETURNING token, file_id, requester, expires_at, used, created_at + "#, + ) + .bind(token) + .fetch_optional(&self.pool) + .await?; + Ok(record) + } + fn row_to_compliance_check( &self, row: &sqlx::postgres::PgRow, diff --git a/indexer/src/dispute_evidence_handlers.rs b/indexer/src/dispute_evidence_handlers.rs new file mode 100644 index 00000000..4ccc3fe6 --- /dev/null +++ b/indexer/src/dispute_evidence_handlers.rs @@ -0,0 +1,190 @@ +use axum::{ + extract::{Multipart, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Json}, +}; +use bytes::Bytes; +use chrono::Utc; +use serde::Deserialize; +use serde_json::json; +use std::sync::Arc; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::{DisputeEvidenceRecord, DisputeEvidenceResponse, EvidenceDownloadToken}; +use crate::storage::{FileCategory, StorageService}; +use crate::database::Database; + +#[derive(Clone)] +pub struct EvidenceState { + pub storage: Arc, + pub db: Arc, +} + +#[derive(Deserialize)] +pub struct EvidenceAccessQuery { + pub requester: String, +} + +/// POST /disputes/:id/evidence +/// Multipart: `file` (binary), `uploader` (Stellar address), `description` (optional text) +pub async fn upload_dispute_evidence( + Path(dispute_id): Path, + State(state): State, + mut multipart: Multipart, +) -> Result { + let mut file_data: Option = None; + let mut original_name = String::from("evidence"); + let mut mime_type = String::from("application/octet-stream"); + let mut uploader = String::new(); + let mut description: Option = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::Storage(format!("Multipart error: {e}")))? + { + match field.name() { + Some("file") => { + if let Some(fname) = field.file_name() { + original_name = fname.to_string(); + } + if let Some(ct) = field.content_type() { + mime_type = ct.to_string(); + } + file_data = Some( + field + .bytes() + .await + .map_err(|e| AppError::Storage(format!("Failed to read file: {e}")))?, + ); + } + Some("uploader") => { + uploader = field + .text() + .await + .map_err(|e| AppError::Storage(format!("Failed to read uploader: {e}")))?; + } + Some("description") => { + let text = field.text().await.unwrap_or_default(); + if !text.is_empty() { + description = Some(text); + } + } + _ => {} + } + } + + if uploader.is_empty() { + return Err(AppError::BadRequest("uploader address is required".into())); + } + let data = file_data.ok_or_else(|| AppError::BadRequest("No file field in request".into()))?; + + // Verify uploader is a participant or arbitrator for this dispute + state + .db + .check_dispute_participant(dispute_id, &uploader) + .await?; + + // Store the file under the "evidence" category + let file_record = state + .storage + .upload(&uploader, FileCategory::Evidence, &original_name, &mime_type, data, Some(dispute_id)) + .await?; + + // Record the dispute evidence link + let evidence = state + .db + .insert_dispute_evidence(dispute_id, file_record.id, &uploader, description.as_deref()) + .await?; + + Ok(( + StatusCode::CREATED, + Json(json!({ + "evidence": evidence, + "file": file_record, + })), + )) +} + +/// GET /disputes/:id/evidence +/// Returns all evidence for a dispute (participants/arbitrators only). +pub async fn list_dispute_evidence( + Path(dispute_id): Path, + Query(params): Query, + State(state): State, +) -> Result { + state + .db + .check_dispute_participant(dispute_id, ¶ms.requester) + .await?; + + let items = state.db.list_dispute_evidence(dispute_id).await?; + Ok(Json(json!({ "evidence": items }))) +} + +/// GET /disputes/:id/evidence/:evidence_id/download-url?requester= +/// Issues a short-lived signed token for downloading a specific evidence file. +pub async fn get_evidence_download_url( + Path((dispute_id, evidence_id)): Path<(i64, Uuid)>, + Query(params): Query, + State(state): State, +) -> Result { + state + .db + .check_dispute_participant(dispute_id, ¶ms.requester) + .await?; + + // Verify the evidence belongs to this dispute + let evidence = state + .db + .get_dispute_evidence(evidence_id) + .await? + .ok_or(AppError::NotFound("Evidence not found".into()))?; + + if evidence.dispute_id != dispute_id { + return Err(AppError::Forbidden("Evidence does not belong to this dispute".into())); + } + + let token = state + .db + .create_evidence_download_token(evidence.file_id, ¶ms.requester) + .await?; + + Ok(Json(json!({ + "token": token.token, + "expires_at": token.expires_at, + "download_url": format!("/evidence/download/{}", token.token), + }))) +} + +/// GET /evidence/download/:token +/// Redeems a signed download token and streams the file. +pub async fn redeem_evidence_download( + Path(token): Path, + State(state): State, +) -> Result { + let token_record = state + .db + .consume_evidence_download_token(token) + .await? + .ok_or(AppError::NotFound("Invalid or expired download token".into()))?; + + let (record, data) = state + .storage + .download(token_record.file_id, &token_record.requester) + .await?; + + let response = axum::response::Response::builder() + .status(StatusCode::OK) + .header(axum::http::header::CONTENT_TYPE, &record.mime_type) + .header( + axum::http::header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", record.original_name), + ) + .header(axum::http::header::CONTENT_LENGTH, data.len()) + .body(axum::body::Body::from(data)) + .map_err(|e| AppError::Storage(format!("Response build error: {e}")))?; + + Ok(response) +} diff --git a/indexer/src/main.rs b/indexer/src/main.rs index 1f29497f..6b5086a2 100644 --- a/indexer/src/main.rs +++ b/indexer/src/main.rs @@ -17,6 +17,7 @@ mod analytics_service; mod backup_service; mod cache_service; mod compliance_service; +mod dispute_evidence_handlers; mod monitoring_service; mod webhook_service; mod job_queue; @@ -56,6 +57,10 @@ use config::Config; use database::Database; use event_monitor::EventMonitor; use file_handlers::{delete_file, download_file, list_files, upload_file}; +use dispute_evidence_handlers::{ + upload_dispute_evidence, list_dispute_evidence, + get_evidence_download_url, redeem_evidence_download, EvidenceState, +}; use fraud_service::FraudDetectionService; use gateway::{GatewayConfig, GatewayState}; use handlers::{AppState, *}; @@ -302,7 +307,17 @@ async fn main() -> Result<(), Box> { .route("/files", get(list_files)) .route("/files/:category", post(upload_file)) .route("/files/:id", get(download_file).delete(delete_file)) - .with_state(storage_service); + .with_state(storage_service.clone()); + + let evidence_state = EvidenceState { + storage: storage_service.clone(), + db: database.clone(), + }; + let evidence_router = Router::new() + .route("/disputes/:id/evidence", post(upload_dispute_evidence).get(list_dispute_evidence)) + .route("/disputes/:id/evidence/:evidence_id/download-url", get(get_evidence_download_url)) + .route("/evidence/download/:token", get(redeem_evidence_download)) + .with_state(evidence_state); // Versioned API router (v1) - includes gateway-enhanced endpoints let v1_api = Router::new() @@ -424,6 +439,7 @@ async fn main() -> Result<(), Box> { .route("/audit/purge", delete(purge_audit_logs)) .merge(admin_router) .merge(file_router) + .merge(evidence_router) .merge(Router::new().nest("/api/v1", v1_api)) .with_state(AppState { database, diff --git a/indexer/src/models.rs b/indexer/src/models.rs index cb3842ea..89a6b726 100644 --- a/indexer/src/models.rs +++ b/indexer/src/models.rs @@ -644,3 +644,34 @@ pub struct PushRegistrationRequest { pub platform: String, pub address: String, } + +// ============================================================================= +// Dispute Evidence Models (Issue #125) +// ============================================================================= + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DisputeEvidenceRecord { + pub id: Uuid, + pub dispute_id: i64, + pub file_id: Uuid, + pub uploader: String, + pub description: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DisputeEvidenceResponse { + pub evidence: DisputeEvidenceRecord, + pub file: FileRecord, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct EvidenceDownloadToken { + pub token: Uuid, + pub file_id: Uuid, + pub requester: String, + pub expires_at: DateTime, + pub used: bool, + pub created_at: DateTime, +} From 93cda8d4ddc1bd572e9f6567143dead4e950f327 Mon Sep 17 00:00:00 2001 From: FaithOnuh Date: Mon, 30 Mar 2026 12:58:55 +0000 Subject: [PATCH 2/2] feat(frontend): dispute evidence upload UI (Issue #124) - Add evidenceService.js: uploadEvidence, listEvidence, getDownloadUrl, renderEvidenceList with signed URL support - Extend disputes.js: integrate EvidenceService for upload/display, uploadStagedEvidence after dispute raised, loadAndRenderEvidence in detail view, inline Add Evidence input in displayDisputeDetails Closes #124 --- frontend/disputes.js | 120 +++++++++++++++++--- frontend/evidenceService.js | 220 ++++++++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 frontend/evidenceService.js 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 = '

Loading evidence…

'; + + const result = await window.EvidenceService.listEvidence(disputeId, requester); + if (result.success) { + window.EvidenceService.renderEvidenceList(container, result.evidence, disputeId, requester); + } else { + container.innerHTML = `

Could not load evidence: ${result.error}

`; + } + } + // ============================================ // UI Initialization // ============================================ @@ -280,18 +331,25 @@ ` : ''} - ${dispute.evidence && dispute.evidence.length > 0 ? ` -
+
+

Evidence

-
- ${dispute.evidence.map(e => ` - - 📎 ${escapeHtml(e.name)} - - `).join('')} -
+ +
- ` : ''} +
+

Loading evidence…

+
+
${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'); +})();