diff --git a/.gitignore b/.gitignore index 4f5aa95..68b6d66 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .env*.local .env client/JS/config.js +JS/config.js node_modules dist public diff --git a/client/JS/aiAssistant.js b/client/JS/aiAssistant.js index ada2432..ce5a9ef 100644 --- a/client/JS/aiAssistant.js +++ b/client/JS/aiAssistant.js @@ -42,7 +42,9 @@ export function wireAIAssistant(state, callbacks) { document.execCommand('insertText', false, text); } else { // Escape HTML entities in each paragraph to prevent XSS - const safeHtml = paragraphs.map(p => escapeHtml(p)).join('
'); + // L2: Sanitize AI-generated HTML to prevent adversarial model output from injecting scripts + const purify = (typeof DOMPurify !== 'undefined') ? DOMPurify : { sanitize: (s) => s }; + const safeHtml = paragraphs.map(p => purify.sanitize(escapeHtml(p))).join('
'); document.execCommand('insertHTML', false, safeHtml); } } catch (e) { diff --git a/client/JS/authPage.js b/client/JS/authPage.js index 51ec0a5..1b052af 100644 --- a/client/JS/authPage.js +++ b/client/JS/authPage.js @@ -19,7 +19,8 @@ function initAuthPage() { setMessage(`Connecting to ${provider}...`, "info"); try { await signInWithProvider(provider); - // Redirect handled by Supabase (setRedirectTo) + // OAuth flow: browser is redirected to the provider, then back to /api/auth//callback + // Session is managed server-side via Passport.js + express-session (not Supabase) } catch (error) { console.error("Social Login Error", error); setMessage(`Error logging in with ${provider}: ${error.message}`, "error"); diff --git a/client/JS/exportImport.js b/client/JS/exportImport.js index 3f79678..9776c46 100644 --- a/client/JS/exportImport.js +++ b/client/JS/exportImport.js @@ -11,7 +11,7 @@ export function formatNotesAsText(notes) { return notes .map((note, index) => { const title = note.title || "Untitled note"; - const tags = (note.tags || []).join(", ") || "none"; + const tags = (Array.isArray(note.tags) ? note.tags : []).join(", ") || "none"; const created = note.createdAt || ""; const updated = note.updatedAt || ""; const content = stripHtml(note.content || ""); @@ -78,7 +78,7 @@ export function formatNotesAsMarkdown(notes) { return notes.map((note) => { const title = note.title || "Untitled"; const created = note.createdAt ? `*Created: ${note.createdAt}*` : ""; - const tags = (note.tags || []).length ? `**Tags:** ${note.tags.join(", ")}` : ""; + const tags = (Array.isArray(note.tags) ? note.tags : []).length ? `**Tags:** ${note.tags.join(", ")}` : ""; const content = htmlToMarkdown(note.content); return `# ${title}\n${created}\n${tags}\n\n${content}\n\n---\n`; @@ -133,7 +133,7 @@ function printNotes(notes) {
Tags - ${escapeHtml((note.tags || []).join(", ") || "No specific tags")} + ${escapeHtml((Array.isArray(note.tags) ? note.tags : []).join(", ") || "No specific tags")}
${note.folderId ? `
diff --git a/client/JS/filterSearchSort.js b/client/JS/filterSearchSort.js index ba63a69..544e268 100644 --- a/client/JS/filterSearchSort.js +++ b/client/JS/filterSearchSort.js @@ -36,12 +36,12 @@ export function applyFilterSearchAndSort(baseNotes) { let result = [...baseNotes]; if (filter && filter !== "all") { - result = result.filter((note) => note.tags && note.tags.includes(filter)); + result = result.filter((note) => Array.isArray(note.tags) && note.tags.includes(filter)); } if (query) { result = result.filter((note) => { - const haystack = [note.title || "", note.content || "", (note.tags || []).join(" ")] + const haystack = [note.title || "", note.content || "", (Array.isArray(note.tags) ? note.tags : []).join(" ")] .join(" ") .toLowerCase(); return haystack.includes(query); diff --git a/client/JS/geminiAPI.js b/client/JS/geminiAPI.js index 29f909e..755ac9a 100644 --- a/client/JS/geminiAPI.js +++ b/client/JS/geminiAPI.js @@ -1,68 +1,38 @@ -import config from './config.js'; +/** + * geminiAPI.js + * Calls the server-side AI proxy (/api/ai/generate) which forwards the request + * to Groq securely. The GROQ_API_KEY never touches the browser. + */ /** - * Calls the Groq API (OpenAI compatible) to generate content. - * Keeps the original function name to avoid breaking imports in other files. + * Generates text via the server-side AI proxy. * @param {string} prompt The user's prompt. * @returns {Promise} The generated text. */ export async function generateTextWithGemini(prompt) { - const { GROQ_API_KEY: API_KEY } = config; - const API_URL = "https://api.groq.com/openai/v1/chat/completions"; - - if (!API_KEY || API_KEY === '' || API_KEY === 'YOUR_GROQ_API_KEY') { - const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; - const message = isLocal - ? "Please add GROQ_API_KEY to your .env file and run 'npm run build'." - : "Deployment Error: GROQ_API_KEY is missing. Please add it to your deployment platform's Environment Variables."; - - return Promise.resolve(` -[AI Assistant]: ${message} -You can get a key from console.groq.com. -`); - } - try { - const response = await fetch(API_URL, { + const response = await fetch('/api/ai/generate', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${API_KEY}` - }, - body: JSON.stringify({ - model: "llama-3.3-70b-versatile", - messages: [ - { - role: "user", - content: prompt - } - ], - temperature: 0.7, - max_tokens: 2048 - }) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }) }); + if (response.status === 401) { + return '[AI Assistant]: You need to be logged in to use the AI assistant.'; + } + if (!response.ok) { - const errorBody = await response.json(); - console.error('Groq API request failed:', errorBody); - throw new Error(`API request failed with status ${response.status}: ${errorBody.error?.message || 'Unknown error'}`); + const errData = await response.json().catch(() => ({})); + const message = errData.error || `Server error (${response.status})`; + console.error('[geminiAPI] Proxy error:', message); + return `[AI Assistant]: ${message}`; } const data = await response.json(); - - if (data.choices && data.choices.length > 0 && data.choices[0].message) { - return data.choices[0].message.content; - } else { - console.warn("Groq API response was successful, but no content was found.", data); - return "I'm sorry, I couldn't generate a response. Please try again."; - } + return data.text ?? '[AI Assistant]: No response received.'; } catch (error) { - console.error('Error calling Groq API:', error); - return ` -[AI Assistant]: There was an error contacting the Groq service. -Please check the console for more details. -Error: ${error.message} - `; + console.error('[geminiAPI] Network error:', error.message); + return '[AI Assistant]: Could not connect to the AI service. Please check your connection.'; } } diff --git a/client/JS/noteOperations.js b/client/JS/noteOperations.js index 031dab0..d4e5df4 100644 --- a/client/JS/noteOperations.js +++ b/client/JS/noteOperations.js @@ -18,7 +18,7 @@ export function addTagToActiveNote(notes, activeNoteId, tag, activeUser) { if (!trimmed) return; const note = notes.find((n) => n.id === activeNoteId); if (!note) return; - note.tags = note.tags || []; + note.tags = Array.isArray(note.tags) ? note.tags : []; if (!note.tags.includes(trimmed)) { note.tags.push(trimmed); note.updatedAt = new Date().toISOString(); @@ -31,7 +31,7 @@ export function addTagToActiveNote(notes, activeNoteId, tag, activeUser) { // Removes a specific tag from the currently active note export function removeTagFromActiveNote(notes, activeNoteId, tag, activeUser, callbacks) { const note = notes.find((n) => n.id === activeNoteId); - if (!note || !note.tags) return; + if (!note || !Array.isArray(note.tags)) return; note.tags = note.tags.filter((t) => t !== tag); note.updatedAt = new Date().toISOString(); persistNotes(activeUser, notes); @@ -275,7 +275,7 @@ export function handleDuplicateNote(notes, activeNoteId, activeUser, callbacks) const copy = createNote({ title: note.title ? `${note.title} (Copy)` : "Untitled note (Copy)", content: note.content, - tags: [...(note.tags || [])], + tags: [...(Array.isArray(note.tags) ? note.tags : [])], }); notes.unshift(copy); persistNotes(activeUser, notes); diff --git a/client/JS/renderer.js b/client/JS/renderer.js index 3e3fc4c..5bca723 100644 --- a/client/JS/renderer.js +++ b/client/JS/renderer.js @@ -47,7 +47,12 @@ export function renderActiveNote(note, removeTagFromActiveNote) { if (titleInput) titleInput.value = note.title || ""; if (contentInput) { - contentInput.innerHTML = note.content || ""; + // H6: Sanitize HTML content before inserting into the DOM to prevent stored XSS. + // DOMPurify is loaded globally in app.html before this module runs. + const sanitize = (html) => (typeof DOMPurify !== 'undefined') + ? DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }) + : html; + contentInput.innerHTML = sanitize(note.content || ""); // Apply editor pattern contentInput.setAttribute("data-pattern", note.editorPattern || "plain"); } @@ -83,7 +88,7 @@ export function renderActiveNote(note, removeTagFromActiveNote) { if (tagsContainer) { tagsContainer.innerHTML = ""; - (note.tags || []).forEach((tag) => { + (Array.isArray(note.tags) ? note.tags : []).forEach((tag) => { const chip = document.createElement("button"); chip.className = "chip small tag-chip"; chip.textContent = tag; @@ -376,7 +381,7 @@ export function renderNotesDashboard(notes, folders, activeFolderId, activeLibra ? `` : ``; - const tagsHtml = (note.tags && note.tags.length > 0) + const tagsHtml = (Array.isArray(note.tags) && note.tags.length > 0) ? `
${note.tags.map(tag => `${escapeHtml(tag)}`).join('')}
` : ''; diff --git a/client/JS/storage.js b/client/JS/storage.js index 8ccf872..6e6681f 100644 --- a/client/JS/storage.js +++ b/client/JS/storage.js @@ -1,6 +1,12 @@ import { NOTES_STORAGE_PREFIX, ACTIVE_USER_KEY } from "./constants.js"; import { showToast } from "./utilities.js"; +// M2 SECURITY NOTE: The username stored in localStorage is used ONLY for keying +// local note storage and display purposes. It is NOT a security boundary. +// All actual access control is enforced server-side via Passport.js session auth. +// A user changing their localStorage username can only affect their own local cache, +// never other users' data on the server. + export function storageKeyForUser(user) { return `${NOTES_STORAGE_PREFIX}.${user || "guest"}`; } @@ -58,7 +64,19 @@ export async function getNotes(username) { } }); - const finalNotes = Array.from(notesMap.values()); + const finalNotes = Array.from(notesMap.values()).map(note => { + // Normalize tags to be an array of strings (preventing crashes when note.tags is string or null) + if (!Array.isArray(note.tags)) { + if (typeof note.tags === 'string') { + note.tags = note.tags.trim() ? note.tags.split(',').map(t => t.trim()) : []; + } else { + note.tags = []; + } + } else { + note.tags = note.tags.filter(t => typeof t === 'string'); + } + return note; + }); finalNotes.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); // Update LocalStorage to keep them in sync diff --git a/client/JS/supabaseClient_v2.js b/client/JS/supabaseClient_v2.js index e978cab..4ac7a72 100644 --- a/client/JS/supabaseClient_v2.js +++ b/client/JS/supabaseClient_v2.js @@ -1,15 +1,33 @@ import config from './config.js'; -// Initialize Supabase Client using Global Object (Robust & Browser-Compatible) -// Fallback to window.supabase which is loaded via script tag in index.html - DO NOT REVERT TO ESM IMPORT -const { createClient } = window.supabase; - +// L3: Added error guard — without this, missing config causes uncaught exception +// that crashes any feature importing this module. const { SUPABASE_URL, SUPABASE_ANON_KEY } = config; -// Create a single instance of the client -const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); +let supabase; -// Optional: Log to verify init -console.log("Supabase Client Initialized via Global (v2)"); +try { + if (!SUPABASE_URL || !SUPABASE_ANON_KEY) { + throw new Error('Supabase credentials missing in config.js'); + } + // Initialize Supabase Client using Global Object (loaded via script tag) + const { createClient } = window.supabase; + supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); + console.log('Supabase Client Initialized via Global (v2)'); +} catch (error) { + console.warn('Supabase v2 Init Failed (App running in Local/Offline mode):', error.message); + // Provide a no-op mock to prevent crashes in features that import this module + supabase = { + auth: { + getSession: async () => ({ data: { session: null }, error: null }), + getUser: async () => ({ data: { user: null }, error: null }), + }, + from: () => ({ + select: () => ({ eq: () => ({ data: [], error: { message: 'Offline Mode' } }) }), + upsert: async () => ({ error: { message: 'Offline Mode' } }), + delete: () => ({ eq: async () => ({ error: { message: 'Offline Mode' } }) }), + }) + }; +} export { supabase }; diff --git a/client/JS/tagManager.js b/client/JS/tagManager.js index 7e89db1..bdc04f6 100644 --- a/client/JS/tagManager.js +++ b/client/JS/tagManager.js @@ -104,7 +104,7 @@ function renderTagMenuOptions(filterText = "") { const predefined = ["work", "personal", "ideas", "todo", "remote"]; const customTags = getCustomTags(stateRef.activeUser).map(t => t.name); - const noteTags = stateRef.notes.flatMap(n => n.tags || []); + const noteTags = stateRef.notes.flatMap(n => Array.isArray(n.tags) ? n.tags : []); const allTags = new Set([...predefined, ...customTags, ...noteTags]); const sortedTags = Array.from(allTags).sort(); @@ -125,7 +125,7 @@ function renderTagMenuOptions(filterText = "") { item.className = "tag-menu-item"; const activeNote = stateRef.notes.find(n => n.id === stateRef.activeNoteId); - const hasTag = activeNote?.tags?.includes(tag); + const hasTag = Array.isArray(activeNote?.tags) && activeNote.tags.includes(tag); const color = getTagColor(tag); item.innerHTML = ` @@ -157,6 +157,10 @@ function removeTagFromNote(tag) { const note = stateRef.notes.find(n => n.id === stateRef.activeNoteId); if (!note) return; + if (!Array.isArray(note.tags)) { + note.tags = []; + } + if (note.tags.includes(tag)) { note.tags = note.tags.filter(t => t !== tag); note.updatedAt = new Date().toISOString(); diff --git a/client/app.html b/client/app.html index 32079f8..64a54d1 100644 --- a/client/app.html +++ b/client/app.html @@ -1044,6 +1044,8 @@

Enter Details

+ +