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
52 changes: 32 additions & 20 deletions client/JS/codeWorkspace.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import config from './config.js';
// config.js removed - AI key is now server-side in .env
import { generateTextWithGemini } from './geminiAPI.js';
import { wireThemeToggle, setThemeStorageKey } from './themeManager.js';
import { CODE_THEME_KEY } from './constants.js';
Expand Down Expand Up @@ -120,26 +120,38 @@ class CodeWorkspace {
}
}

checkAPIKey() {
const apiKey = config.GROQ_API_KEY;
if (!apiKey || apiKey === "YOUR_GROQ_API_KEY" || apiKey.includes("YOUR")) {
document.getElementById('api-warning').classList.remove('hidden');
['ai-explain-btn', 'ai-docs-btn', 'ai-improve-btn', 'ai-analyze-btn', 'ai-debug-btn', 'ai-visualize-flowchart-btn', 'ai-chat-btn'].forEach(id => {
const btn = document.getElementById(id);
if (btn) {
btn.disabled = true;
btn.classList.add('is-locked');
btn.title = "Add Groq API key to enable AI features";

// Add a small lock badge
if (!btn.querySelector('.lock-badge')) {
const badge = document.createElement('div');
badge.className = 'lock-badge';
badge.innerHTML = '🔒';
btn.appendChild(badge);
}
}
async checkAPIKey() {
try {
const res = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: 'ping' })
});
// 200 = works, 401 = auth needed (key exists), 429 = rate limited (key exists)
if (res.ok || res.status === 401 || res.status === 429) {
return; // AI is available
}
const data = await res.json().catch(() => ({}));
const msg = (data.error || '').toLowerCase();
if (msg.includes('api key') || msg.includes('groq') || res.status === 503) {
document.getElementById('api-warning').classList.remove('hidden');
['ai-explain-btn', 'ai-docs-btn', 'ai-improve-btn', 'ai-analyze-btn', 'ai-debug-btn', 'ai-visualize-flowchart-btn', 'ai-chat-btn'].forEach(id => {
const btn = document.getElementById(id);
if (btn) {
btn.disabled = true;
btn.classList.add('is-locked');
btn.title = "AI proxy key not configured on server";
if (!btn.querySelector('.lock-badge')) {
const badge = document.createElement('div');
badge.className = 'lock-badge';
badge.innerHTML = '🔒';
btn.appendChild(badge);
}
}
});
}
} catch (e) {
console.warn('[codeWorkspace] Could not reach AI proxy:', e.message);
}
}

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
83 changes: 26 additions & 57 deletions client/JS/geminiAPI.js
Original file line number Diff line number Diff line change
@@ -1,68 +1,37 @@
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.
`);
const response = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt })
});

if (response.status === 401) {
throw new Error('You need to be logged in to use AI features.');
}

try {
const response = await fetch(API_URL, {
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
})
});

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 data = await response.json();
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
const message = errData.error || `AI service error (${response.status})`;
console.error('[geminiAPI] Proxy error:', message);
throw new Error(message);
}

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.";
}
const data = await response.json().catch(() => {
throw new Error('Invalid response from AI service.');
});

} 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}
`;
}
const text = data.text;
if (!text) throw new Error('AI returned an empty response. Please try again.');
return text;
}
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
41 changes: 26 additions & 15 deletions client/JS/studentHub.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import config from './config.js';
// config.js removed - AI key is now server-side in .env
import { generateTextWithGemini } from './geminiAPI.js';
import { THEME_KEY } from './constants.js';
import { setThemeStorageKey, wireThemeToggle, getStoredTheme } from './themeManager.js';
Expand Down Expand Up @@ -218,11 +218,28 @@ function initHub() {
}
}

// Check API Key status
function checkAPIKey() {
const apiKey = config.GROQ_API_KEY;
if (!apiKey || apiKey === 'YOUR_GROQ_API_KEY' || apiKey.includes('YOUR')) {
document.getElementById('api-warning').classList.remove('hidden');
// Check if the server-side AI proxy is configured
async function checkAPIKey() {
try {
const res = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: 'ping' })
});
// 200 = works, 401 = auth needed (key exists), 429 = rate limited (key exists)
// 500/503 with specific message = key missing on server
if (res.ok || res.status === 401 || res.status === 429) {
// AI proxy is reachable and key is configured — hide banner
return;
}
const data = await res.json().catch(() => ({}));
const msg = (data.error || '').toLowerCase();
if (msg.includes('api key') || msg.includes('groq') || res.status === 503) {
document.getElementById('api-warning').classList.remove('hidden');
}
} catch (e) {
// Network error — server not running, don't show banner (dev mode may differ)
console.warn('[studentHub] Could not reach AI proxy:', e.message);
}
}

Expand Down Expand Up @@ -675,9 +692,7 @@ Syllabus / Notes / Topic Input:
${sourceText}`;

const aiResponse = await generateTextWithGemini(prompt);
if (aiResponse.includes("Error:") || aiResponse.includes("Deployment Error")) {
throw new Error(aiResponse);
}


const cards = parseJsonArray(aiResponse);
if (!Array.isArray(cards)) {
Expand Down Expand Up @@ -996,9 +1011,7 @@ Syllabus Details:
${sourceSyllabus}`;

const aiResponse = await generateTextWithGemini(prompt);
if (aiResponse.includes("Error:") || aiResponse.includes("Deployment Error")) {
throw new Error(aiResponse);
}


const schedule = parseJsonArray(aiResponse);
if (!Array.isArray(schedule)) {
Expand Down Expand Up @@ -2111,9 +2124,7 @@ Input Process Description:
${promptText}`;

const aiResponse = await generateTextWithGemini(prompt);
if (aiResponse.includes("Error:") || aiResponse.includes("Deployment Error")) {
throw new Error(aiResponse);
}


const data = parseJsonArray(aiResponse);
if (!Array.isArray(data)) {
Expand Down
Loading