CrowdProof-Observe-Firefox/sidebar/sidebar.js

828 lines
29 KiB
JavaScript

// CrowdProof Observe - Sidebar Script
let serverUrl = null;
let pendingImageFile = null; // Stores image file from context menu
// Apply browser theme
async function applyTheme() {
const theme = await browser.theme.getCurrent();
const colors = theme.colors || {};
document.documentElement.style.setProperty('--theme-bg', colors.popup || colors.frame || '');
document.documentElement.style.setProperty('--theme-text', colors.popup_text || colors.tab_text || '');
document.documentElement.style.setProperty('--theme-border', colors.popup_border || colors.toolbar_top_separator || '');
document.documentElement.style.setProperty('--theme-button-bg', colors.button_background_active || colors.toolbar || '');
document.documentElement.style.setProperty('--theme-highlight', colors.tab_line || colors.ntp_text || colors.popup_highlight || '');
document.documentElement.style.setProperty('--theme-highlight-text', colors.popup_highlight_text || colors.toolbar_text || colors.popup_text || '');
}
browser.theme.onUpdated.addListener(applyTheme);
applyTheme();
// Screen management
function showScreen(screenId) {
document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden'));
document.getElementById(screenId).classList.remove('hidden');
}
function showStatus(message, isError = false) {
const status = document.getElementById('status');
status.textContent = message;
status.className = `status ${isError ? 'error' : 'success'}`;
setTimeout(() => {
status.className = 'status hidden';
}, 4000);
}
// API helper
async function apiRequest(endpoint, options = {}) {
if (!serverUrl) {
throw new Error('Server URL not configured');
}
const url = `${serverUrl}${endpoint}`;
const fetchOptions = {
credentials: 'include',
...options
};
return fetch(url, fetchOptions);
}
// Form submission handler
async function handleFormSubmit(e, type) {
e.preventDefault();
console.log('=== FORM SUBMIT START ===');
console.log('Type:', type);
console.log('Server URL:', serverUrl);
const form = e.target;
const formData = new FormData(form);
// For photo submissions, use pendingImageFile if available
if (type === 'photo' && pendingImageFile) {
formData.set('file', pendingImageFile);
pendingImageFile = null;
hideImagePreview();
}
console.log('Form data entries:');
for (let [key, value] of formData.entries()) {
console.log(' ', key, '=', value);
}
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.textContent = 'Creating...';
submitBtn.disabled = true;
const url = `${serverUrl}/create_observation/${type}`;
console.log('Fetching URL:', url);
try {
console.log('About to fetch...');
const response = await fetch(url, {
method: 'POST',
body: formData,
credentials: 'include',
headers: {
'Accept': 'application/json'
}
});
console.log('Fetch completed');
console.log('Response status:', response.status);
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const result = await response.json();
if (result.success) {
showStatus('Observation created successfully!');
form.reset();
if (type === 'photo') hideImagePreview();
} else if (result.error === 'login_required') {
showStatus('Session expired. Please login again.', true);
showScreen('login');
} else {
showStatus(result.error || 'Failed to create observation', true);
}
} else {
// Fallback to old redirect-based detection
if (response.url.includes('/login')) {
showStatus('Session expired. Please login again.', true);
showScreen('login');
} else if (response.url.includes('/observations')) {
showStatus('Observation created successfully!');
form.reset();
if (type === 'photo') hideImagePreview();
} else {
showStatus('Failed to create observation', true);
}
}
} catch (error) {
console.error('Fetch error:', error);
showStatus('Error: ' + error.message, true);
} finally {
submitBtn.textContent = originalText;
submitBtn.disabled = false;
console.log('=== FORM SUBMIT END ===');
}
}
// Check for pending URL from context menu
async function checkPendingUrl() {
const result = await browser.storage.local.get('pendingUrl');
if (result.pendingUrl) {
// Clear it
await browser.storage.local.remove('pendingUrl');
// Switch to URL tab
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelector('.tab[data-type="url"]').classList.add('active');
document.querySelectorAll('.obs-form').forEach(f => f.classList.add('hidden'));
document.getElementById('form-url').classList.remove('hidden');
// Populate URL field
document.getElementById('url-reference_url').value = result.pendingUrl;
}
}
// Check for pending image from context menu
async function checkPendingImage() {
const result = await browser.storage.local.get('pendingImage');
if (result.pendingImage) {
const { dataUrl, sourceUrl } = result.pendingImage;
// Clear it
await browser.storage.local.remove('pendingImage');
// Switch to Photo tab
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelector('.tab[data-type="photo"]').classList.add('active');
document.querySelectorAll('.obs-form').forEach(f => f.classList.add('hidden'));
document.getElementById('form-photo').classList.remove('hidden');
// Populate the reference URL field
if (sourceUrl) {
document.getElementById('photo-photo_url').value = sourceUrl;
}
// Convert dataUrl to File and store it
if (dataUrl) {
const response = await fetch(dataUrl);
const blob = await response.blob();
pendingImageFile = new File([blob], `image-${Date.now()}.png`, { type: 'image/png' });
// Show preview
document.getElementById('image-preview-img').src = dataUrl;
document.getElementById('image-preview').classList.remove('hidden');
showStatus('Image captured and ready to submit!');
} else {
showStatus('Could not capture image data', true);
}
}
}
// Show image preview
function showImagePreview(src) {
document.getElementById('image-preview-img').src = src;
document.getElementById('image-preview').classList.remove('hidden');
}
// Hide image preview
function hideImagePreview() {
document.getElementById('image-preview').classList.add('hidden');
document.getElementById('image-preview-img').src = '';
}
// Update logos and instance name from server
async function updateLogos() {
if (!serverUrl) return;
const loginLogo = document.getElementById('login-logo');
const mainLogo = document.getElementById('main-logo');
const loginTitle = document.getElementById('login-title');
const mainTitle = document.getElementById('main-title');
const loginInstanceName = document.getElementById('login-instance-name');
const mainInstanceName = document.getElementById('main-instance-name');
// Fetch instance name from login page
try {
const pageResponse = await fetch(`${serverUrl}/login`, {
credentials: 'include'
});
if (pageResponse.ok) {
const html = await pageResponse.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const brand = doc.querySelector('.navbar-brand');
if (brand) {
const instanceName = brand.textContent.trim();
loginInstanceName.textContent = instanceName;
mainInstanceName.textContent = instanceName;
} else {
throw new Error('Brand not found');
}
} else {
throw new Error('Page fetch failed');
}
} catch (e) {
loginInstanceName.textContent = 'CrowdProof (Error)';
mainInstanceName.textContent = 'CrowdProof (Error)';
}
// Fetch logo
try {
const response = await fetch(`${serverUrl}/static/logo.svg`, {
credentials: 'include'
});
if (response.ok) {
const svgText = await response.text();
const dataUrl = 'data:image/svg+xml;base64,' + btoa(svgText);
loginLogo.src = dataUrl;
mainLogo.src = dataUrl;
loginLogo.classList.remove('hidden');
mainLogo.classList.remove('hidden');
loginTitle.classList.add('hidden');
mainTitle.classList.add('hidden');
} else {
throw new Error('Logo not found');
}
} catch (e) {
// Fallback to text
loginLogo.classList.add('hidden');
mainLogo.classList.add('hidden');
loginTitle.classList.remove('hidden');
mainTitle.classList.remove('hidden');
}
}
// Load observations list for access screen
async function loadObservations() {
const listEl = document.getElementById('observations-list');
listEl.innerHTML = '<p class="loading-text">Loading...</p>';
try {
const response = await fetch(`${serverUrl}/observations`, {
credentials: 'include',
headers: {
'Accept': 'application/json'
}
});
if (response.url.includes('/login')) {
showScreen('login');
return;
}
const data = await response.json();
if (data.success && data.observations) {
renderObservationsList(data.observations);
} else {
listEl.innerHTML = '<p class="no-observations">Failed to load observations</p>';
}
} catch (error) {
console.error('Load observations error:', error);
listEl.innerHTML = '<p class="no-observations">Error loading observations</p>';
}
}
// Render observations list with access toggles
function renderObservationsList(observations) {
const listEl = document.getElementById('observations-list');
if (!observations || observations.length === 0) {
listEl.innerHTML = '<p class="no-observations">No observations found</p>';
return;
}
listEl.innerHTML = observations.map(obs => {
const isOwner = obs.is_owner;
const disabledClass = isOwner ? '' : 'disabled';
const disabledAttr = isOwner ? '' : 'disabled';
const details = obs.details || {};
// Determine which button is active based on public_access value
const noneActive = obs.public_access === 0 ? 'active-none' : '';
const editorActive = obs.public_access === 1 ? 'active-editor' : '';
const viewerActive = obs.public_access === 2 ? 'active-viewer' : '';
// Build thumbnail for photos
let thumbHtml = '';
if (obs.observation_type === 'photo' && details.image_url) {
thumbHtml = `<img class="observation-thumb" src="${serverUrl}${details.image_url}" alt="">`;
}
// Build type-specific details
let detailsHtml = '';
if (obs.observation_type === 'photo') {
if (details.description) detailsHtml += `<div class="observation-detail">${escapeHtml(details.description)}</div>`;
if (details.date) detailsHtml += `<div class="observation-detail"><strong>Date:</strong> ${escapeHtml(details.date)}</div>`;
} else if (obs.observation_type === 'email') {
if (details.subject) detailsHtml += `<div class="observation-detail"><strong>Subject:</strong> ${escapeHtml(details.subject)}</div>`;
if (details.sender) detailsHtml += `<div class="observation-detail"><strong>From:</strong> ${escapeHtml(details.sender)}</div>`;
if (details.date) detailsHtml += `<div class="observation-detail"><strong>Date:</strong> ${escapeHtml(details.date)}</div>`;
} else if (obs.observation_type === 'url') {
if (details.url) detailsHtml += `<div class="observation-detail">${escapeHtml(details.url)}</div>`;
if (details.date) detailsHtml += `<div class="observation-detail"><strong>Date:</strong> ${escapeHtml(details.date)}</div>`;
} else if (obs.observation_type === 'log') {
if (details.source) detailsHtml += `<div class="observation-detail"><strong>Source:</strong> ${escapeHtml(details.source)}</div>`;
if (details.date) detailsHtml += `<div class="observation-detail"><strong>Date:</strong> ${escapeHtml(details.date)}</div>`;
} else if (obs.observation_type === 'statement') {
if (details.issuer) detailsHtml += `<div class="observation-detail"><strong>Issuer:</strong> ${escapeHtml(details.issuer)}</div>`;
if (details.recipient) detailsHtml += `<div class="observation-detail"><strong>To:</strong> ${escapeHtml(details.recipient)}</div>`;
if (details.date) detailsHtml += `<div class="observation-detail"><strong>Date:</strong> ${escapeHtml(details.date)}</div>`;
}
return `
<div class="observation-item ${disabledClass}" data-uuid="${obs.uuid}">
<div class="observation-header">
${thumbHtml}
<div class="observation-info">
<a href="${serverUrl}/observation/${obs.uuid}" target="_blank" class="observation-name" title="${escapeHtml(obs.display_name)}">${escapeHtml(obs.display_name)}</a>
<div class="observation-type">${obs.observation_type}</div>
${detailsHtml}
</div>
</div>
<div class="access-toggle">
<button type="button" class="${noneActive}" data-level="0" ${disabledAttr}>None</button>
<button type="button" class="${editorActive}" data-level="1" ${disabledAttr}>Editor</button>
<button type="button" class="${viewerActive}" data-level="2" ${disabledAttr}>Viewer</button>
</div>
</div>
`;
}).join('');
// Add click handlers for toggle buttons
listEl.querySelectorAll('.access-toggle button:not([disabled])').forEach(btn => {
btn.addEventListener('click', async (e) => {
const item = e.target.closest('.observation-item');
const uuid = item.dataset.uuid;
const level = parseInt(e.target.dataset.level);
await setPublicAccess(uuid, level, item);
});
});
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Set public access for an observation
async function setPublicAccess(uuid, level, itemEl) {
const buttons = itemEl.querySelectorAll('.access-toggle button');
buttons.forEach(btn => btn.disabled = true);
try {
const response = await fetch(`${serverUrl}/set_public_access/observation/${uuid}/${level}`, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
// Update button states
buttons.forEach(btn => {
btn.classList.remove('active-none', 'active-editor', 'active-viewer');
const btnLevel = parseInt(btn.dataset.level);
if (btnLevel === level) {
if (level === 0) btn.classList.add('active-none');
else if (level === 1) btn.classList.add('active-editor');
else if (level === 2) btn.classList.add('active-viewer');
}
});
} else {
showStatus('Failed to update access', true);
}
} catch (error) {
console.error('Set public access error:', error);
showStatus('Error updating access', true);
} finally {
buttons.forEach(btn => btn.disabled = false);
}
}
// Listen for storage changes to catch pending URL/image even when sidebar is already open
browser.storage.onChanged.addListener((changes, area) => {
if (area === 'local') {
if (changes.pendingUrl && changes.pendingUrl.newValue) {
checkPendingUrl();
}
if (changes.pendingImage && changes.pendingImage.newValue) {
checkPendingImage();
}
}
});
// Initialize
async function init() {
console.log('Init starting...');
const result = await browser.storage.local.get('serverUrl');
serverUrl = result.serverUrl || 'https://crowdproof.silogroup.org';
console.log('Server URL:', serverUrl);
// Set create account link
document.getElementById('createAccountLink').href = `${serverUrl}/register`;
updateLogos();
try {
const response = await apiRequest('/profile');
if (response.url.includes('/login')) {
showScreen('login');
} else {
showScreen('main');
// Check for pending URL/image after showing main screen
await checkPendingUrl();
await checkPendingImage();
}
} catch (error) {
console.error('Auth check failed:', error);
showScreen('login');
}
}
// Setup all event handlers when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
console.log('=== DOM READY ===');
// Open settings
document.getElementById('openSettings').addEventListener('click', () => {
showScreen('settings');
});
// Login form
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
console.log('Login form submitted');
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorEl = document.getElementById('loginError');
try {
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const response = await apiRequest('/login', {
method: 'POST',
body: formData
});
if (!response.url.includes('/login')) {
showScreen('main');
} else {
errorEl.textContent = 'Invalid username or password';
errorEl.classList.remove('hidden');
}
} catch (error) {
errorEl.textContent = error.message;
errorEl.classList.remove('hidden');
}
});
// Login settings button
document.getElementById('loginSettingsBtn').addEventListener('click', async () => {
const result = await browser.storage.local.get('serverUrl');
document.getElementById('settings-serverUrl').value = result.serverUrl || 'https://crowdproof.silogroup.org';
showScreen('settings');
});
// Logout
document.getElementById('logoutBtn').addEventListener('click', async () => {
try {
await apiRequest('/logout');
showScreen('login');
} catch (error) {
showStatus('Logout failed: ' + error.message, true);
}
});
// Settings button on main screen
document.getElementById('mainSettingsBtn').addEventListener('click', async () => {
const result = await browser.storage.local.get('serverUrl');
document.getElementById('settings-serverUrl').value = result.serverUrl || '';
showScreen('settings');
});
// Settings back button
document.getElementById('settingsBackBtn').addEventListener('click', async () => {
// Go back to appropriate screen
if (serverUrl) {
try {
const response = await apiRequest('/profile');
if (response.url.includes('/login')) {
showScreen('login');
} else {
showScreen('main');
}
} catch {
showScreen('login');
}
} else {
showScreen('setup');
}
});
// Access button
document.getElementById('accessBtn').addEventListener('click', () => {
showScreen('access');
loadObservations();
});
// Access back button
document.getElementById('accessBackBtn').addEventListener('click', () => {
showScreen('main');
});
// Settings form
document.getElementById('settingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const newUrl = document.getElementById('settings-serverUrl').value.replace(/\/+$/, '');
await browser.storage.local.set({ serverUrl: newUrl });
serverUrl = newUrl;
document.getElementById('createAccountLink').href = `${serverUrl}/register`;
updateLogos();
const statusEl = document.getElementById('settingsStatus');
statusEl.textContent = 'Settings saved';
statusEl.className = 'status success';
statusEl.classList.remove('hidden');
setTimeout(() => {
statusEl.classList.add('hidden');
}, 2000);
});
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
const type = tab.dataset.type;
console.log('Tab clicked:', type);
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('.obs-form').forEach(f => f.classList.add('hidden'));
document.getElementById(`form-${type}`).classList.remove('hidden');
});
});
// Capture URL button
document.getElementById('captureUrl').addEventListener('click', async () => {
console.log('Capture URL clicked');
try {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
if (tabs[0]) {
document.getElementById('url-reference_url').value = tabs[0].url;
if (!document.getElementById('url-observation_name').value) {
document.getElementById('url-observation_name').value = tabs[0].title;
}
showStatus('URL captured!');
}
} catch (error) {
console.error('Capture URL error:', error);
showStatus('Could not capture URL', true);
}
});
// Log file import
document.getElementById('log-file-import').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
document.getElementById('log-log_content').value = text;
showStatus('File imported');
} catch (error) {
showStatus('Could not read file', true);
}
});
// Photo file input - show preview
document.getElementById('photo-file').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (event) => {
showImagePreview(event.target.result);
};
reader.readAsDataURL(file);
// Clear any pending context menu image since user selected a file
pendingImageFile = null;
} else {
hideImagePreview();
}
});
// Form submissions
const forms = ['url', 'statement', 'log', 'photo', 'email'];
forms.forEach(type => {
const form = document.getElementById(`form-${type}`);
console.log(`Attaching handler for form-${type}:`, form);
form.addEventListener('submit', (e) => {
console.log(`Form ${type} submit event fired`);
handleFormSubmit(e, type);
});
});
// Paste target for photo form - click to enable paste
const pasteTarget = document.getElementById('paste-target');
console.log('Paste target element:', pasteTarget);
// Click to focus (enables paste)
pasteTarget.addEventListener('click', () => {
console.log('Paste target clicked, focusing...');
pasteTarget.focus();
});
// Update text on focus/blur
pasteTarget.addEventListener('focus', () => {
console.log('Paste target focused');
pasteTarget.querySelector('span').textContent = 'Ready - paste now (Ctrl+V)';
});
pasteTarget.addEventListener('blur', () => {
console.log('Paste target blurred');
pasteTarget.querySelector('span').textContent = 'Click to enable paste';
});
// Handle paste when paste target is focused (document-level since divs don't receive paste)
document.addEventListener('paste', async (e) => {
// Only handle if paste target is focused
if (document.activeElement !== pasteTarget) return;
console.log('Paste event received, paste target is focused');
const photoForm = document.getElementById('form-photo');
if (photoForm.classList.contains('hidden')) return;
console.log('Clipboard data:', e.clipboardData);
e.preventDefault();
const items = e.clipboardData?.items;
if (!items) return;
console.log('Items:', items.length);
for (const item of items) {
console.log('Item type:', item.type);
if (item.type.startsWith('image/')) {
const blob = item.getAsFile();
console.log('Blob:', blob);
if (!blob) continue;
// Create a File from the blob
const file = new File([blob], `screenshot-${Date.now()}.png`, { type: blob.type });
console.log('File created:', file.name, file.size);
// Build FormData from the photo form
const form = document.getElementById('form-photo');
const formData = new FormData();
formData.append('file', file);
formData.append('observation_name', form.querySelector('[name="observation_name"]').value);
formData.append('photo_description', form.querySelector('[name="photo_description"]').value);
formData.append('photo_url', form.querySelector('[name="photo_url"]').value);
formData.append('photo_date', form.querySelector('[name="photo_date"]').value);
// Submit
pasteTarget.classList.add('ready');
pasteTarget.querySelector('span').textContent = 'Submitting...';
try {
const response = await fetch(`${serverUrl}/create_observation/photo`, {
method: 'POST',
body: formData,
credentials: 'include',
headers: {
'Accept': 'application/json'
}
});
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const result = await response.json();
if (result.success) {
showStatus('Photo observation created!');
form.reset();
hideImagePreview();
pendingImageFile = null;
} else if (result.error === 'login_required') {
showStatus('Session expired. Please login again.', true);
showScreen('login');
} else {
showStatus(result.error || 'Failed to create observation', true);
}
} else {
// Fallback
if (response.url.includes('/login')) {
showStatus('Session expired. Please login again.', true);
showScreen('login');
} else if (response.url.includes('/observations')) {
showStatus('Photo observation created!');
form.reset();
hideImagePreview();
pendingImageFile = null;
} else {
showStatus('Failed to create observation', true);
}
}
} catch (error) {
showStatus('Error: ' + error.message, true);
} finally {
pasteTarget.classList.remove('ready');
pasteTarget.querySelector('span').textContent = 'Click to enable paste';
}
return;
}
}
showStatus('No image found in clipboard', true);
});
// Email drop target
const emailDropTarget = document.getElementById('email-drop-target');
emailDropTarget.addEventListener('dragover', (e) => {
e.preventDefault();
emailDropTarget.classList.add('dragover');
});
emailDropTarget.addEventListener('dragleave', () => {
emailDropTarget.classList.remove('dragover');
});
emailDropTarget.addEventListener('drop', async (e) => {
e.preventDefault();
emailDropTarget.classList.remove('dragover');
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
const file = files[0];
const validExtensions = ['.msg', '.eml'];
const hasValidExt = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
if (!hasValidExt) {
showStatus('Please drop a .msg or .eml file', true);
return;
}
// Build FormData from the email form
const form = document.getElementById('form-email');
const formData = new FormData();
formData.append('msg_file', file);
formData.append('observation_name', form.querySelector('[name="observation_name"]').value);
formData.append('email_description', form.querySelector('[name="email_description"]').value);
// Submit
emailDropTarget.classList.add('ready');
emailDropTarget.querySelector('span').textContent = 'Submitting...';
try {
const response = await fetch(`${serverUrl}/create_observation/email`, {
method: 'POST',
body: formData,
credentials: 'include',
headers: {
'Accept': 'application/json'
}
});
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const result = await response.json();
if (result.success) {
showStatus('Email observation created!');
form.reset();
} else if (result.error === 'login_required') {
showStatus('Session expired. Please login again.', true);
showScreen('login');
} else {
showStatus(result.error || 'Failed to create observation', true);
}
} else {
// Fallback
if (response.url.includes('/login')) {
showStatus('Session expired. Please login again.', true);
showScreen('login');
} else if (response.url.includes('/observations')) {
showStatus('Email observation created!');
form.reset();
} else {
showStatus('Failed to create observation', true);
}
}
} catch (error) {
showStatus('Error: ' + error.message, true);
} finally {
emailDropTarget.classList.remove('ready');
emailDropTarget.querySelector('span').textContent = 'Drop .msg or .eml file to submit';
}
});
console.log('=== ALL HANDLERS ATTACHED ===');
// Initialize
init();
});