From 7807d67bd5f1848516f147f95ceb04e8fc74ecfa Mon Sep 17 00:00:00 2001
From: 0xGingi <0xgingi@0xgingi.com>
Date: Sun, 29 Dec 2024 15:57:51 +0000
Subject: [PATCH] v0.2.0 - Experimental Note Attachments
---
browser-extension/background.js | 21 +-
browser-extension/manifest.chrome.json | 2 +-
browser-extension/manifest.firefox.json | 2 +-
browser-extension/popup/popup.css | 426 +++++++++++-------------
browser-extension/popup/popup.html | 75 +----
browser-extension/popup/popup.js | 227 ++++++-------
build-extension.sh | 4 +-
package.json | 2 +-
server/src/index.js | 46 ++-
src/App.tsx | 20 +-
src/components/NoteEditor.tsx | 215 ++++++++++++
src/hooks/useAutoSync.ts | 2 +-
src/service-worker.js | 2 +-
src/services/apiService.ts | 2 +-
src/services/cryptoService.ts | 70 +++-
src/services/webStorage.ts | 130 +++++++-
src/types/sync.ts | 13 +
17 files changed, 787 insertions(+), 472 deletions(-)
create mode 100644 src/components/NoteEditor.tsx
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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 {