diff --git a/browser-extension/background.js b/browser-extension/background.js index b78bede..79fc9fc 100644 --- a/browser-extension/background.js +++ b/browser-extension/background.js @@ -68,7 +68,26 @@ async function encryptAndStoreNotes(notes) { cryptoService = await CryptoService.new(settings.seed_phrase); } - const encrypted = await cryptoService.encrypt(notes); + // Filter out attachment data to save storage space in extension + const notesWithoutAttachments = notes.map(note => ({ + id: note.id, + title: note.title, + content: note.content, + created_at: note.created_at, + updated_at: note.updated_at, + deleted: note.deleted, + pending_sync: note.pending_sync, + // Keep minimal attachment metadata + attachments: note.attachments?.map(att => ({ + id: att.id, + name: att.name, + type: att.type, + size: att.size, + timestamp: att.timestamp + })) || [] + })); + + const encrypted = await cryptoService.encrypt(notesWithoutAttachments); await chrome.storage.local.set({ encrypted_notes: encrypted, lastUpdated: Date.now() diff --git a/browser-extension/manifest.chrome.json b/browser-extension/manifest.chrome.json index d190117..dce85f2 100644 --- a/browser-extension/manifest.chrome.json +++ b/browser-extension/manifest.chrome.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Trusty Notes", - "version": "0.1.6", + "version": "0.2.0", "description": "Quick access to your encrypted notes", "externally_connectable": { "matches": ["https://trustynotes.app/*"] diff --git a/browser-extension/manifest.firefox.json b/browser-extension/manifest.firefox.json index 4e91ad1..ca4055f 100644 --- a/browser-extension/manifest.firefox.json +++ b/browser-extension/manifest.firefox.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Trusty Notes", - "version": "0.1.6", + "version": "0.2.0", "description": "Quick access to your encrypted notes", "externally_connectable": { "matches": ["https://trustynotes.app/*"] diff --git a/browser-extension/popup/popup.css b/browser-extension/popup/popup.css index 7301368..6a0c4c5 100644 --- a/browser-extension/popup/popup.css +++ b/browser-extension/popup/popup.css @@ -1,246 +1,206 @@ body { - width: 350px; - margin: 0; - padding: 0; - font-family: Inter, system-ui, -apple-system, sans-serif; - background: var(--background); - color: var(--text); - } - - .container { - padding: 1rem; - } - - .header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--border); - } - - .header h1 { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - } - - .search { - margin-bottom: 1rem; - } - - #searchInput { - width: 100%; - padding: 0.75rem; - border-radius: 0.5rem; - border: 1px solid var(--border); - background: var(--surface); - transition: var(--transition); - } - - .notes-list { - max-height: 400px; - overflow-y: auto; - } - + width: 350px; + margin: 0; + padding: 0; + font-family: Inter, system-ui, -apple-system, sans-serif; + background: var(--background); + color: var(--text); +} + +.container { + padding: 1rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +.header h1 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.search { + margin-bottom: 1rem; +} + +#searchInput { + width: 100%; + padding: 0.75rem; + border-radius: 0.5rem; + border: 1px solid var(--border); + background: var(--surface); + transition: var(--transition); +} + +.notes-list { + max-height: 400px; + overflow-y: auto; +} + +.note-item { + padding: 0.75rem; + border-radius: 0.5rem; + margin-bottom: 0.5rem; + background: var(--surface); + border: 1px solid var(--border); + transition: var(--transition); +} + +.note-item:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.note-title { + font-weight: 500; + margin-bottom: 0.25rem; + color: var(--text); + display: flex; + align-items: center; + gap: 4px; +} + +.note-preview { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.footer { + margin-top: 16px; + text-align: center; +} + +button { + padding: 8px 16px; + border: none; + border-radius: 4px; + background-color: #007bff; + color: white; + cursor: pointer; +} + +button:hover { + background-color: #0056b3; +} + +@media (prefers-color-scheme: dark) { + body { + background-color: #2f2f2f; + color: #f6f6f6; + } + .note-item { - padding: 0.75rem; - border-radius: 0.5rem; - margin-bottom: 0.5rem; - background: var(--surface); - border: 1px solid var(--border); - transition: var(--transition); + border-bottom-color: #444; } - + .note-item:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-md); + background-color: #3f3f3f; } - - .note-title { - font-weight: 500; - margin-bottom: 0.25rem; - color: var(--text); - } - + .note-preview { - font-size: 0.875rem; - color: var(--text-secondary); + color: #999; } - - .footer { - margin-top: 16px; - text-align: center; + + #searchInput { + background-color: #3f3f3f; + border-color: #444; + color: #f6f6f6; } - + button { - padding: 8px 16px; - border: none; - border-radius: 4px; - background-color: #007bff; - color: white; - cursor: pointer; + background-color: #0d6efd; } - + button:hover { - background-color: #0056b3; - } - - @media (prefers-color-scheme: dark) { - body { - background-color: #2f2f2f; - color: #f6f6f6; - } - - .note-item { - border-bottom-color: #444; - } - - .note-item:hover { - background-color: #3f3f3f; - } - - .note-preview { - color: #999; - } - - #searchInput { - background-color: #3f3f3f; - border-color: #444; - color: #f6f6f6; - } - - button { - background-color: #0d6efd; - } - - button:hover { - background-color: #0b5ed7; - } - } - - .error-message { - padding: 16px; - text-align: center; - color: #666; - } - - .error-message button { - margin-top: 8px; - background-color: #dc3545; - } - - .error-message button:hover { - background-color: #c82333; - } - - #editor { - padding: 16px 0; - } - - #titleInput { - width: 100%; - padding: 8px; - margin-bottom: 8px; - border: 1px solid #ddd; - border-radius: 4px; - box-sizing: border-box; - } - - #contentInput { - width: 100%; - height: 200px; - padding: 8px; - margin-bottom: 8px; - border: 1px solid #ddd; - border-radius: 4px; - box-sizing: border-box; - resize: vertical; - } - - .editor-buttons { - display: flex; - gap: 8px; - justify-content: flex-end; - } - - @media (prefers-color-scheme: dark) { - #titleInput, - #contentInput { - background-color: #3f3f3f; - border-color: #444; - color: #f6f6f6; - } - } - - .editor-toolbar { - display: flex; - gap: 4px; - margin-bottom: 8px; - } - - .editor-toolbar button { - padding: 4px 8px; - font-size: 14px; - min-width: 30px; - } - + background-color: #0b5ed7; + } +} + +.error-message { + padding: 16px; + text-align: center; + color: #666; +} + +.error-message button { + margin-top: 8px; + background-color: #dc3545; +} + +.error-message button:hover { + background-color: #c82333; +} + +#editor { + padding: 16px 0; +} + +#titleInput { + width: 100%; + padding: 8px; + margin-bottom: 8px; + border: 1px solid #ddd; + border-radius: 4px; + box-sizing: border-box; +} + +#contentInput { + width: 100%; + height: 200px; + padding: 8px; + margin-bottom: 8px; + border: 1px solid #ddd; + border-radius: 4px; + box-sizing: border-box; + resize: vertical; +} + +.editor-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +@media (prefers-color-scheme: dark) { + #titleInput, #contentInput { - margin-bottom: 8px; - } - - #editor { - padding: 16px 0; - } - - #titleInput { - width: 100%; - padding: 8px; - margin-bottom: 8px; - border: 1px solid #ddd; - border-radius: 4px; - box-sizing: border-box; - } - - #contentInput { - width: 100%; - height: 200px; - padding: 8px; - margin-bottom: 8px; - border: 1px solid #ddd; - border-radius: 4px; - box-sizing: border-box; - resize: vertical; - } - - .editor-buttons { - display: flex; - gap: 8px; - justify-content: flex-end; - } - - @media (prefers-color-scheme: dark) { - #titleInput, - #contentInput { - background-color: #3f3f3f; - border-color: #444; - color: #f6f6f6; - } - } - + background-color: #3f3f3f; + border-color: #444; + color: #f6f6f6; + } +} + +.success-message { + padding: 16px; + text-align: center; + color: #198754; + background-color: #d1e7dd; + border-radius: 4px; + margin: 8px 0; +} + +@media (prefers-color-scheme: dark) { .success-message { - padding: 16px; - text-align: center; - color: #198754; - background-color: #d1e7dd; - border-radius: 4px; - margin: 8px 0; - } - - @media (prefers-color-scheme: dark) { - .success-message { - background-color: #051b11; - color: #75b798; - } - } \ No newline at end of file + background-color: #051b11; + color: #75b798; + } +} + +.attachment-indicator { + font-size: 0.8em; + color: #666; + margin-left: 8px; +} + +.note-title { + display: flex; + align-items: center; + gap: 4px; +} \ No newline at end of file diff --git a/browser-extension/popup/popup.html b/browser-extension/popup/popup.html index 8cb0607..b2d50a5 100644 --- a/browser-extension/popup/popup.html +++ b/browser-extension/popup/popup.html @@ -1,78 +1,29 @@ - - -
-
-

TrustyNotes

- -
-
+
+

TrustyNotes

+ +
- +
- + +
diff --git a/browser-extension/popup/popup.js b/browser-extension/popup/popup.js index 57e0aaf..7d855da 100644 --- a/browser-extension/popup/popup.js +++ b/browser-extension/popup/popup.js @@ -140,30 +140,37 @@ async function initializeCrypto() { } } -function renderNotes(notesToRender) { +function renderNotes(notes) { + console.log('Rendering notes:', notes); + const notesList = document.getElementById('notesList'); - notesList.innerHTML = ''; - - notesToRender.forEach(note => { - const noteElement = document.createElement('div'); - noteElement.className = 'note-item'; - noteElement.innerHTML = ` -
${note.title || 'Untitled'}
-
${note.content.substring(0, 100)}...
- `; + if (!notes || notes.length === 0) { + notesList.innerHTML = '
No notes found
'; + return; + } - noteElement.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - showEditor(note); + notesList.innerHTML = notes + .sort((a, b) => b.updated_at - a.updated_at) + .map(note => ` +
+
+ ${note.title || 'Untitled'} +
+
${note.content ? note.content.substring(0, 100) : ''}
+
+ `) + .join(''); + + document.querySelectorAll('.note-item').forEach(noteElement => { + noteElement.addEventListener('click', () => { + const noteId = parseInt(noteElement.dataset.id); + const note = notes.find(n => n.id === noteId); + console.log('Note clicked:', note); + if (note) { + showEditor(note); + } }); - - notesList.appendChild(noteElement); }); - - if (notesToRender.length === 0) { - notesList.innerHTML = '
No notes found
'; - } } async function handleNotesUpdate(notes) { @@ -183,6 +190,8 @@ async function handleNotesUpdate(notes) { } function showEditor(note = null) { + console.log('Opening editor with note:', note); + document.getElementById('mainView').style.display = 'none'; document.getElementById('editor').style.display = 'block'; @@ -190,56 +199,66 @@ function showEditor(note = null) { const contentInput = document.getElementById('contentInput'); if (note) { - currentEditingNote = note; - titleInput.value = note.title || ''; - } else { - currentEditingNote = null; - titleInput.value = ''; - } + currentEditingNote = { + id: note.id, + title: note.title || '', + content: note.content || '', + created_at: note.created_at, + updated_at: note.updated_at, + attachments: note.attachments || [] + }; - const initializeEditor = () => { - if (typeof window.tiptap === 'undefined') { - console.log('Editor not loaded yet, waiting...'); - window.addEventListener('tiptap-ready', initializeEditor, { once: true }); - return; - } + console.log('Current editing note:', currentEditingNote); - try { - if (editor) { - editor.destroy(); - editor = null; - } - - contentInput.innerHTML = ''; - - editor = new window.tiptap.Editor({ - element: contentInput, - extensions: [window.tiptap.StarterKit], - content: note ? note.content : '', - editable: true, - autofocus: 'end', - editorProps: { - attributes: { - class: 'ProseMirror', - } - } - }); - - setTimeout(() => { - if (editor) { - editor.setEditable(true); - editor.commands.focus('end'); - } - }, 50); + titleInput.value = currentEditingNote.title; + contentInput.innerHTML = currentEditingNote.content || ''; + contentInput.contentEditable = 'true'; + + Object.assign(contentInput.style, { + backgroundColor: '#ffffff', + color: '#000000', + minHeight: '200px', + padding: '8px', + border: '1px solid #ddd', + borderRadius: '4px', + overflow: 'auto', + display: 'block', + width: '100%', + boxSizing: 'border-box' + }); - } catch (error) { - console.error('Failed to initialize editor:', error); - contentInput.contentEditable = true; - contentInput.innerHTML = note ? note.content : ''; + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + Object.assign(contentInput.style, { + backgroundColor: '#3f3f3f', + color: '#f6f6f6', + borderColor: '#444' + }); } - }; - initializeEditor(); + console.log('Content input styles:', contentInput.style.cssText); + } else { + currentEditingNote = null; + titleInput.value = ''; + contentInput.innerHTML = ''; + contentInput.contentEditable = 'true'; + + Object.assign(contentInput.style, { + backgroundColor: window.matchMedia('(prefers-color-scheme: dark)').matches ? '#3f3f3f' : '#ffffff', + color: window.matchMedia('(prefers-color-scheme: dark)').matches ? '#f6f6f6' : '#000000', + minHeight: '200px', + padding: '8px', + border: '1px solid #ddd', + borderRadius: '4px', + overflow: 'auto', + display: 'block', + width: '100%', + boxSizing: 'border-box' + }); + } + + contentInput.style.display = 'none'; + contentInput.offsetHeight; + contentInput.style.display = 'block'; } function hideEditor() { @@ -347,21 +366,15 @@ async function saveNote() { const titleInput = document.getElementById('titleInput'); const contentInput = document.getElementById('contentInput'); - let content = ''; - if (editor && editor.isEditable) { - content = editor.getHTML(); - } else { - content = contentInput.innerHTML; - } - const now = Date.now(); const noteData = { id: currentEditingNote?.id || now, title: titleInput.value, - content: content || '', + content: contentInput.innerHTML || '', created_at: currentEditingNote?.created_at || now, updated_at: now, - pending_sync: true + pending_sync: true, + attachments: currentEditingNote?.attachments || [] }; try { @@ -400,14 +413,21 @@ async function saveNote() { } document.addEventListener('DOMContentLoaded', async () => { + document.documentElement.style.setProperty('--surface', '#ffffff'); + document.documentElement.style.setProperty('--text', '#000000'); + + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + document.documentElement.style.setProperty('--surface', '#3f3f3f'); + document.documentElement.style.setProperty('--text', '#f6f6f6'); + } + const searchInput = document.getElementById('searchInput'); const notesList = document.getElementById('notesList'); - const newNoteButton = document.getElementById('newNote'); - const openWebappButton = document.getElementById('openWebapp'); - const saveNoteButton = document.getElementById('saveNote'); - const cancelEditButton = document.getElementById('cancelEdit'); + const newNoteBtn = document.getElementById('newNoteBtn'); + const saveNoteBtn = document.getElementById('saveNoteBtn'); + const cancelEditBtn = document.getElementById('cancelEditBtn'); - if (!searchInput || !notesList || !newNoteButton || !openWebappButton || !saveNoteButton || !cancelEditButton) { + if (!searchInput || !notesList || !newNoteBtn || !saveNoteBtn || !cancelEditBtn) { console.error('Required elements not found'); return; } @@ -419,11 +439,6 @@ document.addEventListener('DOMContentLoaded', async () => { if (result.encrypted_notes) { notes = await cryptoService.decrypt(result.encrypted_notes); renderNotes(notes); - - if (webappTabCheckInterval) { - clearInterval(webappTabCheckInterval); - } - webappTabCheckInterval = setInterval(checkForWebappTab, 2000); } } } catch (error) { @@ -439,54 +454,20 @@ document.addEventListener('DOMContentLoaded', async () => { renderNotes(filteredNotes); }); - newNoteButton.addEventListener('click', () => { + newNoteBtn.addEventListener('click', () => { showEditor(); }); - openWebappButton.addEventListener('click', () => { - chrome.tabs.create({ - url: 'https://trustynotes.app' - }); - window.close(); - }); - - const boldButton = document.getElementById('boldButton'); - const italicButton = document.getElementById('italicButton'); - const bulletListButton = document.getElementById('bulletListButton'); - - boldButton.addEventListener('click', (e) => { + saveNoteBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - if (!editor) return; - editor.chain().focus().toggleBold().run(); - }); - - italicButton.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (!editor) return; - editor.chain().focus().toggleItalic().run(); - }); - - bulletListButton.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (!editor) return; - editor.chain().focus().toggleBulletList().run(); - }); - - cancelEditButton.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - requestAnimationFrame(() => { - hideEditor(); - }); + saveNote(); }); - saveNoteButton.addEventListener('click', (e) => { + cancelEditBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - saveNote(); + hideEditor(); }); }); diff --git a/build-extension.sh b/build-extension.sh index 2caae21..b8a7710 100644 --- a/build-extension.sh +++ b/build-extension.sh @@ -1,11 +1,11 @@ bun run build:chrome cd browser-extension web-ext build --overwrite-dest -mv web-ext-artifacts/trusty_notes-0.1.6.zip web-ext-artifacts/trusty_notes-chrome-0.1.6.zip +mv web-ext-artifacts/trusty_notes-0.2.0.zip web-ext-artifacts/trusty_notes-chrome-0.2.0.zip rm manifest.json cd .. bun run build:firefox cd browser-extension web-ext build --overwrite-dest -mv web-ext-artifacts/trusty_notes-0.1.6.zip web-ext-artifacts/trusty_notes-firefox-0.1.6.zip +mv web-ext-artifacts/trusty_notes-0.2.0.zip web-ext-artifacts/trusty_notes-firefox-0.2.0.zip rm manifest.json diff --git a/package.json b/package.json index 1b87d00..f2ddbcf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "trusty-notes-web", "private": true, - "version": "0.1.6", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/src/index.js b/server/src/index.js index 2e0f65f..c9b679d 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -11,9 +11,8 @@ const app = express(); let client; let db; -// Body parsing middleware -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(express.json({ limit: '50mb' })); +app.use(express.urlencoded({ limit: '50mb', extended: true })); app.use(helmet()); app.use(cors({ @@ -43,6 +42,9 @@ app.use((req, res, next) => { next(); }); +const MAX_FILE_SIZE = 10 * 1024 * 1024; +const MAX_TOTAL_ATTACHMENTS_SIZE = 50 * 1024 * 1024; + async function setupDatabase() { try { client = new MongoClient(process.env.MONGODB_URI, { @@ -103,8 +105,36 @@ app.post('/api/sync', async (req, res) => { }); } + for (const note of notes) { + if (note.attachments) { + let totalSize = 0; + for (const attachment of note.attachments) { + const fileSize = Buffer.from(attachment.data, 'base64').length; + if (fileSize > MAX_FILE_SIZE) { + return res.status(400).json({ + error: 'File size exceeds limit', + details: { + fileName: attachment.name, + size: fileSize, + limit: MAX_FILE_SIZE + } + }); + } + totalSize += fileSize; + } + if (totalSize > MAX_TOTAL_ATTACHMENTS_SIZE) { + return res.status(400).json({ + error: 'Total attachments size exceeds limit', + details: { + totalSize, + limit: MAX_TOTAL_ATTACHMENTS_SIZE + } + }); + } + } + } + try { - // Update user's last sync time await db.collection('users').updateOne( { public_key }, { @@ -116,7 +146,6 @@ app.post('/api/sync', async (req, res) => { const results = await processNotes(public_key, notes); - // Log the response we're about to send console.log('Sending sync response:', { notesCount: results.notes.length, updatedIds: results.updated, @@ -182,7 +211,8 @@ async function processNotes(public_key, incoming_notes) { timestamp: note.timestamp, signature: note.signature, public_key, - deleted: note.deleted + deleted: note.deleted, + attachments: note.attachments } }, { upsert: true } @@ -214,7 +244,8 @@ async function processNotes(public_key, incoming_notes) { data: note.data, nonce: note.nonce, timestamp: note.timestamp, - signature: note.signature + signature: note.signature, + attachments: note.attachments })); return results; @@ -246,7 +277,6 @@ app.get('/api/health', async (req, res) => { } }); -// Global error handler app.use((err, req, res, next) => { console.error('Unhandled error:', { error: err.message, diff --git a/src/App.tsx b/src/App.tsx index a6f926d..0a8a26d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,20 +37,13 @@ import { useDebouncedCallback, useMediaQuery } from '@mantine/hooks'; import { MarkdownEditor } from './components/MarkdownEditor'; import { SyncSettings } from './components/SyncSettings'; import { useAutoSync } from './hooks/useAutoSync'; -import { SyncSettings as SyncSettingsType } from './types/sync'; +import { SyncSettings as SyncSettingsType, Note } from './types/sync'; import { WebStorageService } from './services/webStorage'; import './styles/richtext.css'; import { MobileNav } from './components/MobileNav'; import { notifications } from '@mantine/notifications'; import { InstallPrompt } from './components/InstallPrompt'; - -interface Note { - id?: number; - title: string; - content: string; - created_at: number; - updated_at: number; -} +import { NoteEditor } from './components/NoteEditor'; function isBrowserExtensionEnvironment(): boolean { return typeof chrome !== 'undefined' && @@ -692,6 +685,15 @@ async function deleteNote(noteId: number) { defaultView="edit" editorType="richtext" /> + + {selectedNote && ( + + + + )} diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx new file mode 100644 index 0000000..7f99c7b --- /dev/null +++ b/src/components/NoteEditor.tsx @@ -0,0 +1,215 @@ +import { useState, useEffect } from 'react'; +import { + Stack, + Text, + Button, + Group, + Paper, + ActionIcon, + Alert, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { IconTrash, IconPlus, IconAlertCircle } from '@tabler/icons-react'; +import { WebStorageService } from '../services/webStorage'; +import { Note } from '../types/sync'; + +interface AttachmentsListProps { + note: Note; + onRemove: (attachmentId: string) => void; + onDownload: (attachmentId: string) => void; + isSyncing: boolean; +} + +function AttachmentsList({ note, onRemove, onDownload, isSyncing }: AttachmentsListProps) { + if (!note.attachments?.length) return null; + + return ( + + {note.attachments.map(attachment => ( + + {attachment.name} + + + onRemove(attachment.id)} + disabled={isSyncing} + > + + + + + ))} + + ); +} + +interface NoteEditorProps { + note: Note; + loadNotes: () => Promise; +} + +export function NoteEditor({ note, loadNotes }: NoteEditorProps) { + const [cryptoInitialized, setCryptoInitialized] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [currentNote, setCurrentNote] = useState(note); + + useEffect(() => { + setCurrentNote(note); + }, [note]); + + // Check if crypto is initialized and sync if needed + useEffect(() => { + const checkCryptoAndSync = async () => { + const settings = await WebStorageService.getSyncSettings(); + const hasCrypto = !!settings.seed_phrase; + setCryptoInitialized(hasCrypto); + + if (hasCrypto && settings.seed_phrase) { + try { + await WebStorageService.initializeCrypto(settings.seed_phrase); + if (settings.server_url) { + await WebStorageService.syncWithServer(settings.server_url); + } + } catch (error) { + console.error('Failed to initialize crypto:', error); + notifications.show({ + title: 'Error', + message: 'Failed to initialize encryption', + color: 'red', + }); + } + } + }; + checkCryptoAndSync(); + }, []); + + const handleFileUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file || !currentNote?.id) return; + + try { + setIsSyncing(true); + const settings = await WebStorageService.getSyncSettings(); + if (settings.server_url) { + await WebStorageService.syncWithServer(settings.server_url); + } + + const updatedNote = await WebStorageService.addAttachment(currentNote.id, file); + setCurrentNote(updatedNote); + + if (settings.server_url) { + await WebStorageService.syncWithServer(settings.server_url); + } + await loadNotes(); + + notifications.show({ + title: 'Success', + message: 'File attached successfully', + color: 'green', + }); + } catch (error) { + console.error('Failed to attach file:', error); + notifications.show({ + title: 'Error', + message: error instanceof Error ? error.message : 'Failed to attach file', + color: 'red', + }); + } finally { + setIsSyncing(false); + } + }; + + const handleDownload = async (attachmentId: string) => { + try { + setIsSyncing(true); + // Ensure we're synced before download + const settings = await WebStorageService.getSyncSettings(); + if (settings.server_url) { + await WebStorageService.syncWithServer(settings.server_url); + } + await WebStorageService.downloadAttachment(note.id!, attachmentId); + } catch (error) { + console.error('Failed to download file:', error); + notifications.show({ + title: 'Error', + message: error instanceof Error ? error.message : 'Failed to download file', + color: 'red', + }); + } finally { + setIsSyncing(false); + } + }; + + return ( + + + {!cryptoInitialized ? ( + } + title="Sync Setup Required" + color="yellow" + > + To attach files, you need to set up sync first. Click the cloud icon in the sidebar to get started. + + ) : ( + <> + + Attachments + + + { + setIsSyncing(true); + try { + const settings = await WebStorageService.getSyncSettings(); + if (settings.server_url) { + await WebStorageService.syncWithServer(settings.server_url); + } + await WebStorageService.removeAttachment(currentNote.id!, attachmentId); + const updatedNotes = await WebStorageService.getNotes(); + const updatedNote = updatedNotes.find(n => n.id === currentNote.id); + if (updatedNote) { + setCurrentNote(updatedNote); + } + if (settings.server_url) { + await WebStorageService.syncWithServer(settings.server_url); + } + await loadNotes(); + } finally { + setIsSyncing(false); + } + }} + onDownload={handleDownload} + isSyncing={isSyncing} + /> + + )} + + + ); +} \ No newline at end of file diff --git a/src/hooks/useAutoSync.ts b/src/hooks/useAutoSync.ts index bb7ff58..c5c1139 100644 --- a/src/hooks/useAutoSync.ts +++ b/src/hooks/useAutoSync.ts @@ -32,7 +32,7 @@ export function useAutoSync(auto_sync: boolean, sync_interval: number) { body: JSON.stringify({ public_key: await cryptoService.getPublicKeyBase64(), notes: encryptedNotes, - client_version: '0.1.6' + client_version: '0.2.0' }), }); diff --git a/src/service-worker.js b/src/service-worker.js index 30bac2c..a3d632f 100644 --- a/src/service-worker.js +++ b/src/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'trustynotes-cache-v0.1.6'; +const CACHE_NAME = 'trustynotes-cache-v0.2.0'; const ASSETS_TO_CACHE = [ '/', '/index.html', diff --git a/src/services/apiService.ts b/src/services/apiService.ts index 83bfdae..9f0f43c 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -62,7 +62,7 @@ body: JSON.stringify({ public_key: publicKey, notes: encryptedNotes, - client_version: '0.1.6' + client_version: '0.2.0' }), }); diff --git a/src/services/cryptoService.ts b/src/services/cryptoService.ts index 0d68735..4edf1fd 100644 --- a/src/services/cryptoService.ts +++ b/src/services/cryptoService.ts @@ -1,5 +1,6 @@ import { Buffer } from 'buffer/'; import { mnemonicToSeedSync, wordlists } from 'bip39'; +import { Note, Attachment } from '../types/sync'; const WORDLIST = wordlists.english; interface EncryptedNote { @@ -9,17 +10,9 @@ interface EncryptedNote { timestamp: number; signature: string; deleted?: boolean; + attachments?: Attachment[]; } - interface Note { - id?: number; - title: string; - content: string; - created_at: number; - updated_at: number; - deleted?: boolean; - } - export class CryptoService { private encryptionKey: Uint8Array; private signingKey: CryptoKey | null = null; @@ -98,7 +91,8 @@ export class CryptoService { content: note.content, created_at: note.created_at, updated_at: note.updated_at, - deleted: note.deleted + deleted: note.deleted, + attachments: note.attachments }); const key = await crypto.subtle.importKey( @@ -170,4 +164,60 @@ export class CryptoService { const keyData = await crypto.subtle.exportKey('raw', this.verifyingKey!); return Buffer.from(keyData).toString('base64'); } + + async encryptFile(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const nonceBytes = crypto.getRandomValues(new Uint8Array(12)); + + const key = await crypto.subtle.importKey( + 'raw', + this.encryptionKey, + { name: 'AES-GCM' }, + false, + ['encrypt'] + ); + + const encryptedData = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: nonceBytes + }, + key, + arrayBuffer + ); + + return { + id: crypto.randomUUID(), + name: file.name, + type: file.type, + size: file.size, + data: Buffer.from(encryptedData).toString('base64'), + nonce: Buffer.from(nonceBytes).toString('base64'), + timestamp: Date.now() + }; + } + + async decryptFile(attachment: Attachment): Promise { + const encryptedData = Buffer.from(attachment.data, 'base64'); + const nonce = Buffer.from(attachment.nonce, 'base64'); + + const key = await crypto.subtle.importKey( + 'raw', + this.encryptionKey, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + + const decryptedData = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: nonce + }, + key, + encryptedData + ); + + return new Blob([decryptedData], { type: attachment.type }); + } } \ No newline at end of file diff --git a/src/services/webStorage.ts b/src/services/webStorage.ts index 4b0eb03..91a8a36 100644 --- a/src/services/webStorage.ts +++ b/src/services/webStorage.ts @@ -1,22 +1,6 @@ import { CryptoService } from './cryptoService'; import { ApiService } from './apiService'; - -interface Note { - id?: number; - title: string; - content: string; - created_at: number; - updated_at: number; - deleted?: boolean; -} - -interface SyncSettings { - auto_sync: boolean; - sync_interval: number; - server_url: string; - custom_servers: string[]; - seed_phrase: string | null; -} +import { Note, SyncSettings } from '../types/sync'; export class WebStorageService { private static readonly NOTES_KEY = 'notes'; @@ -35,13 +19,17 @@ export class WebStorageService { } static async saveNote(note: Partial): Promise { - const notes = await this.getNotes(); + const notesJson = localStorage.getItem(this.NOTES_KEY); + const notes: Note[] = notesJson ? JSON.parse(notesJson) : []; const now = Date.now(); + const existingNote = notes.find(n => n.id === note.id); + const updatedNote = { ...note, updated_at: now, created_at: note.created_at || now, + attachments: existingNote?.attachments || note.attachments || [], } as Note; if (!updatedNote.id) { @@ -281,4 +269,110 @@ export class WebStorageService { console.log('App is offline. Changes will be synced when online.'); }); } + + static async addAttachment(noteId: number, file: File): Promise { + if (!this.crypto) { + throw new Error('Crypto not initialized'); + } + + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + if (file.size > MAX_FILE_SIZE) { + throw new Error(`File size exceeds limit of ${MAX_FILE_SIZE / (1024 * 1024)}MB`); + } + + const notesJson = localStorage.getItem(this.NOTES_KEY); + const notes: Note[] = notesJson ? JSON.parse(notesJson) : []; + const note = notes.find(n => n.id === noteId); + + if (!note) { + throw new Error('Note not found'); + } + + const MAX_TOTAL_ATTACHMENTS_SIZE = 50 * 1024 * 1024; // 50MB + let totalSize = file.size; + if (note.attachments) { + for (const attachment of note.attachments) { + totalSize += Buffer.from(attachment.data, 'base64').length; + } + } + if (totalSize > MAX_TOTAL_ATTACHMENTS_SIZE) { + throw new Error(`Total attachments size would exceed limit of ${MAX_TOTAL_ATTACHMENTS_SIZE / (1024 * 1024)}MB`); + } + + const attachment = await this.crypto.encryptFile(file); + + note.attachments = note.attachments || []; + note.attachments.push(attachment); + note.updated_at = Date.now(); + note.pending_sync = true; + + const index = notes.findIndex(n => n.id === noteId); + if (index !== -1) { + notes[index] = note; + } + + localStorage.setItem(this.NOTES_KEY, JSON.stringify(notes)); + return note; + } + + static async removeAttachment(noteId: number, attachmentId: string): Promise { + if (!this.crypto) { + throw new Error('Crypto not initialized'); + } + + const notesJson = localStorage.getItem(this.NOTES_KEY); + const notes: Note[] = notesJson ? JSON.parse(notesJson) : []; + const note = notes.find(n => n.id === noteId); + + if (!note || !note.attachments) { + throw new Error('Note not found'); + } + + note.attachments = note.attachments.filter(a => a.id !== attachmentId); + note.updated_at = Date.now(); + note.pending_sync = true; + + const index = notes.findIndex(n => n.id === noteId); + if (index !== -1) { + notes[index] = note; + } + + localStorage.setItem(this.NOTES_KEY, JSON.stringify(notes)); + } + + static async downloadAttachment(noteId: number, attachmentId: string): Promise { + if (!this.crypto) { + throw new Error('Crypto not initialized'); + } + + const notesJson = localStorage.getItem(this.NOTES_KEY); + const notes: Note[] = notesJson ? JSON.parse(notesJson) : []; + const note = notes.find(n => n.id === noteId); + + if (!note || !note.attachments) { + throw new Error('Note not found'); + } + + const attachment = note.attachments.find(a => a.id === attachmentId); + if (!attachment) { + throw new Error('Attachment not found'); + } + + try { + const decryptedBlob = await this.crypto.decryptFile(attachment); + + // Create download link + const url = URL.createObjectURL(decryptedBlob); + const a = document.createElement('a'); + a.href = url; + a.download = attachment.name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('Failed to decrypt attachment:', error); + throw new Error('Failed to decrypt attachment'); + } + } } \ No newline at end of file diff --git a/src/types/sync.ts b/src/types/sync.ts index 6fbcb09..fa51cd6 100644 --- a/src/types/sync.ts +++ b/src/types/sync.ts @@ -1,3 +1,13 @@ +export interface Attachment { + id: string; + name: string; + type: string; + size: number; + data: string; + nonce: string; + timestamp: number; +} + export interface Note { id?: number; title: string; @@ -5,6 +15,8 @@ export interface Note { created_at: number; updated_at: number; deleted?: boolean; + attachments?: Attachment[]; + pending_sync?: boolean; } export interface EncryptedNote { @@ -14,6 +26,7 @@ export interface EncryptedNote { timestamp: number; signature: string; deleted?: boolean; + attachments?: Attachment[]; } export interface SyncSettings {