Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.env*.local
.env
client/JS/config.js
JS/config.js
node_modules
dist
public
4 changes: 3 additions & 1 deletion client/JS/aiAssistant.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<br>');
// 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('<br>');
document.execCommand('insertHTML', false, safeHtml);
}
} catch (e) {
Expand Down
3 changes: 2 additions & 1 deletion client/JS/authPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<provider>/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");
Expand Down
6 changes: 3 additions & 3 deletions client/JS/exportImport.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "");
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -133,7 +133,7 @@ function printNotes(notes) {
</div>
<div class="meta-col">
<span class="meta-label">Tags</span>
<span class="meta-value">${escapeHtml((note.tags || []).join(", ") || "No specific tags")}</span>
<span class="meta-value">${escapeHtml((Array.isArray(note.tags) ? note.tags : []).join(", ") || "No specific tags")}</span>
</div>
${note.folderId ? `
<div class="meta-col">
Expand Down
4 changes: 2 additions & 2 deletions client/JS/filterSearchSort.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
70 changes: 20 additions & 50 deletions client/JS/geminiAPI.js
Original file line number Diff line number Diff line change
@@ -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<string>} 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.';
}
}
6 changes: 3 additions & 3 deletions client/JS/noteOperations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 8 additions & 3 deletions client/JS/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -376,7 +381,7 @@ export function renderNotesDashboard(notes, folders, activeFolderId, activeLibra
? `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10V6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4M3 14v6a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-6M8 12h8"/></svg>`
: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>`;

const tagsHtml = (note.tags && note.tags.length > 0)
const tagsHtml = (Array.isArray(note.tags) && note.tags.length > 0)
? `<div class="note-card-tags">${note.tags.map(tag => `<span class="chip small tag-chip" style="--tag-color:${getTagColor(tag)}">${escapeHtml(tag)}</span>`).join('')}</div>`
: '';

Expand Down
20 changes: 19 additions & 1 deletion client/JS/storage.js
Original file line number Diff line number Diff line change
@@ -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"}`;
}
Expand Down Expand Up @@ -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
Expand Down
34 changes: 26 additions & 8 deletions client/JS/supabaseClient_v2.js
Original file line number Diff line number Diff line change
@@ -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 };
8 changes: 6 additions & 2 deletions client/JS/tagManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 = `
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions client/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,8 @@ <h3 class="confirm-title" id="prompt-title">Enter Details</h3>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<script type="text/javascript" src="JS/vendor/lz-string.min.js"></script>
<script type="text/javascript" src="JS/vendor/qrcode.min.js"></script>
<!-- DOMPurify: XSS sanitization for all innerHTML assignments (H6, L2) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js" integrity="sha512-jB0TkTBeQC9ZSkBqDhdmfTv1qdfbWpGE72yJ/01Srq6hEzZIz2xkz1e57p9ai7IeHMwEG7HpzG6NdptChif5Pg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script type="module" src="JS/notesApp.js?v=3.4"></script>
<script>
if ('serviceWorker' in navigator) {
Expand Down
39 changes: 20 additions & 19 deletions generate-config.js
Original file line number Diff line number Diff line change
@@ -1,40 +1,41 @@
/**
* generate-config.js
* Generates client/JS/config.js from environment variables.
*
* SECURITY: GROQ_API_KEY is intentionally excluded. It is consumed server-side
* only via the /api/ai/generate proxy route and must never reach the browser.
*
* Usage: node generate-config.js
*/

// L1: Use dotenv properly instead of naive split('=') which breaks on values containing '='
require('dotenv').config();

const fs = require('fs');
const path = require('path');

// Simple .env parser for local development
const envPath = path.join(__dirname, '.env');
if (fs.existsSync(envPath)) {
const envConfig = fs.readFileSync(envPath, 'utf8');
envConfig.split('\n').forEach(line => {
const [key, value] = line.split('=');
if (key && value) {
process.env[key.trim()] = value.trim();
}
});
}

const configContent = `const config = {
APPWRITE_ENDPOINT: '${process.env.APPWRITE_ENDPOINT || "https://cloud.appwrite.io/v1"}',
APPWRITE_PROJECT_ID: '${process.env.APPWRITE_PROJECT_ID || ""}',
APPWRITE_DATABASE_ID: '${process.env.APPWRITE_DATABASE_ID || ""}',
SUPABASE_URL: '${process.env.SUPABASE_URL || ""}',
SUPABASE_ANON_KEY: '${process.env.SUPABASE_ANON_KEY || ""}',
GROQ_API_KEY: '${process.env.GROQ_API_KEY || ""}'
SUPABASE_ANON_KEY: '${process.env.SUPABASE_ANON_KEY || ""}'
};

export default config;
`;

const jsDir = path.join(__dirname, 'client', 'JS');
if (!fs.existsSync(jsDir)) {
fs.mkdirSync(jsDir, { recursive: true });
fs.mkdirSync(jsDir, { recursive: true });
}
const configPath = path.join(jsDir, 'config.js');

try {
fs.writeFileSync(configPath, configContent);
console.log('Successfully generated client/JS/config.js');
fs.writeFileSync(configPath, configContent);
console.log('Successfully generated client/JS/config.js');
console.log('[SECURITY] GROQ_API_KEY was intentionally excluded from the browser bundle.');
} catch (error) {
console.error('Error generating configuration:', error);
process.exit(1);
console.error('Error generating configuration:', error);
process.exit(1);
}
Loading