diff --git a/frontend/index.html b/frontend/index.html index 13ecac0..46d87c7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,10 +3,17 @@ + + MarkdownHub
+ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..8684cd0 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "MarkdownHub", + "short_name": "MdHub", + "start_url": "/", + "display": "standalone", + "background_color": "#1e1e2e", + "theme_color": "#89b4fa", + "description": "Self-hosted collaborative markdown workspace", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..c43773d --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,49 @@ +const CACHE_NAME = 'markdownhub-v1' +const STATIC_ASSETS = [ + '/', + '/index.html', +] + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) + ) + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + ) + self.clients.claim() +}) + +self.addEventListener('fetch', (event) => { + const { request } = event + const url = new URL(request.url) + + // Don't cache API calls or WebSocket + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws/')) { + return + } + + event.respondWith( + caches.match(request).then((cached) => { + // Network first for HTML, cache first for assets + if (request.destination === 'document') { + return fetch(request).then((response) => { + const clone = response.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)) + return response + }).catch(() => cached) + } + return cached || fetch(request).then((response) => { + const clone = response.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)) + return response + }) + }) + ) +}) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4452bcf..e9c3706 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -247,6 +247,7 @@ import FileTree from './components/FileTree.vue' import MilkdownEditor from './components/MilkdownEditor.vue' import { api, setToken } from './lib/api.js' import { renderMarkdown } from './lib/markdown.js' +import { cacheFile, getCachedFile, addPendingChange, getPendingChanges, clearAllPending } from './lib/offline.js' const authenticated = ref(false) const email = ref('') @@ -318,12 +319,28 @@ async function login() { loadFiles() loadShared() loadRemotes() + syncPending() if (isAdmin.value) loadUsers() + // Sync pending changes when coming back online + window.addEventListener('online', syncPending) } catch (e) { loginError.value = 'Invalid credentials' } } +async function syncPending() { + const pending = await getPendingChanges() + for (const item of pending) { + try { + await api('/api/files/write', { path: item.path, content: item.content }) + } catch { break } + } + if (pending.length) { + await clearAllPending() + loadFiles() + } +} + // ─── Files ─────────────────────────────────────────────────────────────────── async function loadFiles() { @@ -357,10 +374,24 @@ function filterTree(items, query) { async function openFile(path) { if (isDirty.value && !confirm('Discard unsaved changes?')) return - const res = await api('/api/files/read', { path }) - currentFile.value = path - content.value = res.content - fileMeta.value = res.created ? `Created: ${formatDate(res.created)}` : '' + try { + const res = await api('/api/files/read', { path }) + currentFile.value = path + content.value = res.content + fileMeta.value = res.created ? `Created: ${formatDate(res.created)}` : '' + cacheFile(path, res.content) + } catch (e) { + // Offline fallback + const cached = await getCachedFile(path) + if (cached !== null) { + currentFile.value = path + content.value = cached + fileMeta.value = '(offline cache)' + } else { + alert('File unavailable offline') + return + } + } isDirty.value = false view.value = 'files' showHistory.value = false @@ -372,21 +403,32 @@ async function openFile(path) { async function saveFile() { if (!currentFile.value) { - // No file open — prompt to create one const name = prompt('Save as (e.g. notes.md):') if (!name) return let path = name if (!path.endsWith('.md') && !path.endsWith('.txt')) { path += '.md' } - await api('/api/files/create', { path, content: content.value }) - currentFile.value = path - isDirty.value = false - await loadFiles() + try { + await api('/api/files/create', { path, content: content.value }) + currentFile.value = path + isDirty.value = false + await loadFiles() + } catch (e) { + await addPendingChange(path, content.value) + currentFile.value = path + isDirty.value = false + } + cacheFile(currentFile.value, content.value) setTimeout(checkGitStatus, 1000) return } - await api('/api/files/write', { path: currentFile.value, content: content.value }) + try { + await api('/api/files/write', { path: currentFile.value, content: content.value }) + } catch (e) { + await addPendingChange(currentFile.value, content.value) + } + cacheFile(currentFile.value, content.value) isDirty.value = false setTimeout(checkGitStatus, 1000) } diff --git a/frontend/src/lib/encryption.js b/frontend/src/lib/encryption.js new file mode 100644 index 0000000..c869dbc --- /dev/null +++ b/frontend/src/lib/encryption.js @@ -0,0 +1,67 @@ +/** + * Client-side AES-256-GCM encryption for Private Vault. + * Key derived from user password via PBKDF2. + * All encryption/decryption happens in the browser — server never sees plaintext. + */ + +const ALGO = 'AES-GCM' +const KEY_LENGTH = 256 +const ITERATIONS = 100000 + +export async function deriveKey(password, salt) { + const enc = new TextEncoder() + const keyMaterial = await crypto.subtle.importKey( + 'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'] + ) + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' }, + keyMaterial, + { name: ALGO, length: KEY_LENGTH }, + false, + ['encrypt', 'decrypt'] + ) +} + +export function generateSalt() { + return crypto.getRandomValues(new Uint8Array(16)) +} + +export async function encrypt(plaintext, key) { + const enc = new TextEncoder() + const iv = crypto.getRandomValues(new Uint8Array(12)) + const ciphertext = await crypto.subtle.encrypt( + { name: ALGO, iv }, + key, + enc.encode(plaintext) + ) + // Prepend IV to ciphertext + const result = new Uint8Array(iv.length + ciphertext.byteLength) + result.set(iv) + result.set(new Uint8Array(ciphertext), iv.length) + return bufToBase64(result) +} + +export async function decrypt(encoded, key) { + const data = base64ToBuf(encoded) + const iv = data.slice(0, 12) + const ciphertext = data.slice(12) + const plaintext = await crypto.subtle.decrypt( + { name: ALGO, iv }, + key, + ciphertext + ) + return new TextDecoder().decode(plaintext) +} + +function bufToBase64(buf) { + return btoa(String.fromCharCode(...new Uint8Array(buf))) +} + +function base64ToBuf(b64) { + const binary = atob(b64) + const buf = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + buf[i] = binary.charCodeAt(i) + } + return buf +} diff --git a/frontend/src/lib/offline.js b/frontend/src/lib/offline.js new file mode 100644 index 0000000..19a46ed --- /dev/null +++ b/frontend/src/lib/offline.js @@ -0,0 +1,67 @@ +const DB_NAME = 'markdownhub' +const DB_VERSION = 1 +const STORE_FILES = 'files' +const STORE_PENDING = 'pending' + +function openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION) + req.onupgradeneeded = (e) => { + const db = e.target.result + if (!db.objectStoreNames.contains(STORE_FILES)) { + db.createObjectStore(STORE_FILES, { keyPath: 'path' }) + } + if (!db.objectStoreNames.contains(STORE_PENDING)) { + db.createObjectStore(STORE_PENDING, { keyPath: 'path' }) + } + } + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) +} + +export async function cacheFile(path, content) { + const db = await openDB() + const tx = db.transaction(STORE_FILES, 'readwrite') + tx.objectStore(STORE_FILES).put({ path, content, cachedAt: Date.now() }) + return new Promise((resolve) => { tx.oncomplete = resolve }) +} + +export async function getCachedFile(path) { + const db = await openDB() + const tx = db.transaction(STORE_FILES, 'readonly') + const req = tx.objectStore(STORE_FILES).get(path) + return new Promise((resolve) => { + req.onsuccess = () => resolve(req.result?.content || null) + }) +} + +export async function addPendingChange(path, content) { + const db = await openDB() + const tx = db.transaction(STORE_PENDING, 'readwrite') + tx.objectStore(STORE_PENDING).put({ path, content, timestamp: Date.now() }) + return new Promise((resolve) => { tx.oncomplete = resolve }) +} + +export async function getPendingChanges() { + const db = await openDB() + const tx = db.transaction(STORE_PENDING, 'readonly') + const req = tx.objectStore(STORE_PENDING).getAll() + return new Promise((resolve) => { + req.onsuccess = () => resolve(req.result || []) + }) +} + +export async function clearPending(path) { + const db = await openDB() + const tx = db.transaction(STORE_PENDING, 'readwrite') + tx.objectStore(STORE_PENDING).delete(path) + return new Promise((resolve) => { tx.oncomplete = resolve }) +} + +export async function clearAllPending() { + const db = await openDB() + const tx = db.transaction(STORE_PENDING, 'readwrite') + tx.objectStore(STORE_PENDING).clear() + return new Promise((resolve) => { tx.oncomplete = resolve }) +}