Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ frontend/dist/

# OS
.DS_Store
**/.DS_Store
Thumbs.db

# Agent/Session files
Expand Down Expand Up @@ -65,4 +66,11 @@ backend/coverage/

# OpenCode/Sisyphus
.sisyphus/
.opencode/

# Playwright
playwright-report/
test-results/
blob-report/


21 changes: 21 additions & 0 deletions backend/api/migrations/0003_cheatsheet_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 6.0.2 on 2026-04-21 19:29

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0002_cheatsheet_selected_formulas'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AddField(
model_name='cheatsheet',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cheat_sheets', to=settings.AUTH_USER_MODEL),
),
]
3 changes: 2 additions & 1 deletion backend/api/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from django.db import models

from django.contrib.auth.models import User
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The model imports User directly for the CheatSheet.user FK. For better compatibility with a swappable user model, prefer referencing settings.AUTH_USER_MODEL (and avoid importing django.contrib.auth.models.User in models). This keeps the model consistent with the migration which targets settings.AUTH_USER_MODEL.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in commit 1462605: CheatSheet.user now references settings.AUTH_USER_MODEL instead of importing User directly, keeping the model consistent with the migration.


class Template(models.Model):
name = models.CharField(max_length=200)
Expand All @@ -21,6 +21,7 @@ class CheatSheet(models.Model):
template = models.ForeignKey(
Template, on_delete=models.SET_NULL, null=True, blank=True
)
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, related_name="cheat_sheets")
Comment thread
Davictory2003 marked this conversation as resolved.
Outdated
columns = models.IntegerField(default=2)
margins = models.CharField(max_length=20, default="0.5in")
font_size = models.CharField(max_length=10, default="10pt")
Expand Down
3 changes: 2 additions & 1 deletion backend/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,11 @@ class Meta:
"selected_formulas",
"problems",
"full_latex",
"user",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at", "full_latex"]
read_only_fields = ["id", "user", "created_at", "updated_at", "full_latex"]

def get_full_latex(self, obj):
"""Return the fully-assembled LaTeX document string."""
Expand Down
13 changes: 11 additions & 2 deletions backend/api/views.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from rest_framework.decorators import api_view, action
from rest_framework.decorators import api_view, action, permission_classes
from rest_framework.response import Response
from rest_framework import status, viewsets
from django.http import FileResponse
from django.contrib.auth.models import User
from rest_framework.generics import CreateAPIView
from rest_framework.permissions import AllowAny
from rest_framework.permissions import AllowAny, IsAuthenticated
from django.shortcuts import get_object_or_404
import subprocess
import tempfile
Expand Down Expand Up @@ -129,6 +129,7 @@ def generate_sheet(request):


@api_view(["POST"])
@permission_classes([IsAuthenticated])
def compile_latex(request):
Comment on lines 131 to 133
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While adding IsAuthenticated here is good, compile_latex still resolves cheat_sheet_id later via get_object_or_404(CheatSheet, pk=cheat_sheet_id) without checking that the sheet belongs to request.user. That creates an IDOR risk (authenticated users can compile other users' sheets if they guess IDs). When a cheat_sheet_id is provided, ensure the lookup is restricted to user=request.user (or enforce object-level permissions) and return 404/403 for non-owned sheets.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in commit 1462605. compile_latex now uses get_object_or_404(CheatSheet, pk=cheat_sheet_id, user=request.user), returning 404 for any sheet not owned by the requesting user.

"""
POST /api/compile/
Expand Down Expand Up @@ -209,6 +210,13 @@ class CheatSheetViewSet(viewsets.ModelViewSet):
"""
queryset = CheatSheet.objects.all()
serializer_class = CheatSheetSerializer
permission_classes = [IsAuthenticated]

def get_queryset(self):
return self.queryset.filter(user=self.request.user).order_by('-updated_at')
Comment on lines +213 to +216
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds per-user isolation for cheat sheets (IsAuthenticated + get_queryset() filtering by request.user), but there don't appear to be tests asserting that a user cannot list/retrieve/update/delete another user's sheets. Adding a multi-user test case would help prevent regressions in access control behavior.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in commit 1462605. Added TestCheatSheetAccessControl with five test cases asserting that list, retrieve, update, delete, and compile all return 404 when a second user attempts to access the first user's sheet.


def perform_create(self, serializer):
serializer.save(user=self.request.user)

@action(detail=False, methods=['post'], url_path='from-template')
def from_template(self, request):
Expand All @@ -226,6 +234,7 @@ def from_template(self, request):

cheatsheet = CheatSheet.objects.create(
title=title,
user=request.user,
template=template,
latex_content=template.latex_content,
margins=template.default_margins,
Expand Down
33 changes: 31 additions & 2 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Routes, Route, Link, Navigate } from 'react-router-dom';
import AuthContext from './context/AuthContext';
import Login from './components/Login';
import SignUp from './components/SignUp';
import Dashboard from './components/Dashboard';
import './App.css'
import CreateCheatSheet from './components/CreateCheatSheet';

Expand Down Expand Up @@ -48,7 +49,7 @@ function App() {
localStorage.setItem('theme', theme);
}, [theme]);

const { user, logoutUser } = useContext(AuthContext);
const { user, authTokens, logoutUser } = useContext(AuthContext);

const toggleTheme = () => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
Expand All @@ -57,6 +58,8 @@ function App() {
const handleReset = () => {
setCheatSheet(DEFAULT_SHEET);
localStorage.setItem('currentCheatSheet', JSON.stringify(DEFAULT_SHEET));
localStorage.removeItem('cheatSheetData');
localStorage.removeItem('cheatSheetLatex');
};

useEffect(() => {
Expand Down Expand Up @@ -90,7 +93,10 @@ function App() {
const sheetId = nextSheet.id;
const response = await fetch(sheetId ? `/api/cheatsheets/${sheetId}/` : '/api/cheatsheets/', {
method: sheetId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authTokens?.access}`
},
Comment on lines 94 to +99
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleSave always includes an Authorization header even when authTokens is falsy, which can result in sending Bearer undefined and returning 401s that are hard to diagnose. Consider conditionally adding the header only when a token exists (and/or blocking save attempts when not authenticated).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in commit 1462605. The Authorization header in handleSave is now conditionally spread: ...(authTokens?.access ? { 'Authorization': \****** } : {})`.

body: JSON.stringify({
title: nextSheet.title,
latex_content: nextSheet.content,
Expand Down Expand Up @@ -128,6 +134,22 @@ function App() {
}
};

const handleEditSheet = (sheet) => {
const editSheet = {
id: sheet.id,
title: sheet.title,
content: sheet.latex_content,
columns: sheet.columns,
margins: sheet.margins,
fontSize: sheet.font_size,
selectedFormulas: sheet.selected_formulas || [],
};
setCheatSheet(editSheet);
localStorage.setItem('currentCheatSheet', JSON.stringify(editSheet));
localStorage.removeItem('cheatSheetData');
localStorage.removeItem('cheatSheetLatex');
};

return (
<div className="App">
<header className="app-header">
Expand All @@ -142,6 +164,8 @@ function App() {
{user ? (
<>
<span style={{ fontWeight: 'bold' }}>Hi, {user.username || 'User'}</span>
<Link to="/" className="btn primary" onClick={handleReset}>Create New Sheet</Link>
<Link to="/dashboard" className="btn">My Sheets</Link>
<button onClick={logoutUser} className="btn">Log Out</button>
</>
) : (
Expand Down Expand Up @@ -169,6 +193,11 @@ function App() {
/>
</PrivateRoute>
} />
<Route path="/dashboard" element={
<PrivateRoute>
<Dashboard onEditSheet={handleEditSheet} onCreateNewSheet={handleReset} />
</PrivateRoute>
} />
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<SignUp />} />
</Routes>
Expand Down
130 changes: 130 additions & 0 deletions frontend/src/components/Dashboard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import React, { useState, useEffect, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import AuthContext from '../context/AuthContext';
import '../styles/Dashboard.css';

const Dashboard = ({ onEditSheet, onCreateNewSheet }) => {
const [sheets, setSheets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const { authTokens } = useContext(AuthContext);
const navigate = useNavigate();

useEffect(() => {
const fetchSheets = async () => {
try {
const response = await fetch('/api/cheatsheets/', {
headers: {
'Authorization': `Bearer ${authTokens?.access}`,
},
});
Comment on lines +20 to +24
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These requests always send an Authorization: Bearer ${authTokens?.access} header. If authTokens is temporarily null/undefined (e.g., during initial render or logout transitions), the header becomes Bearer undefined and can cause confusing 401s. Build headers conditionally (only include Authorization when you have a token) or early-return/navigate when the token is missing.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in commit 1462605. fetchSheets, handleDelete, and handleDownload all early-return when authTokens?.access is absent, so no `****** header is ever sent.


if (!response.ok) {
throw new Error('Failed to load cheat sheets');
}

const data = await response.json();
setSheets(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};

fetchSheets();
}, [authTokens]);

const handleEdit = (sheet) => {
onEditSheet(sheet);
navigate('/');
};

const handleDelete = async (id) => {
if (!window.confirm('Are you sure you want to delete this cheat sheet?')) return;

try {
const response = await fetch(`/api/cheatsheets/${id}/`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authTokens?.access}`,
},
});

if (!response.ok) {
throw new Error('Failed to delete cheat sheet');
}

setSheets(sheets.filter((sheet) => sheet.id !== id));
Comment thread
Davictory2003 marked this conversation as resolved.
Outdated
} catch (err) {
alert(err.message);
}
};

const handleDownload = async (sheet) => {
try {
const response = await fetch('/api/compile/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authTokens?.access}`,
},
body: JSON.stringify({ cheat_sheet_id: sheet.id }),
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to generate PDF');
}

const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sheet.title || 'cheat_sheet'}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
alert(err.message);
}
};

if (loading) return <div className="dashboard-loading">Loading your sheets...</div>;
if (error) return <div className="dashboard-error">Error: {error}</div>;

return (
<div className="dashboard-container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2>My Cheat Sheets</h2>
<button className="btn primary" onClick={() => { onCreateNewSheet(); navigate('/'); }}>Create New Sheet</button>
</div>
{sheets.length === 0 ? (
<div className="empty-state">
<p>You haven't saved any cheat sheets yet.</p>
<button className="btn primary" onClick={() => { onCreateNewSheet(); navigate('/'); }}>Create Your First Sheet</button>
</div>
) : (
<div className="sheets-grid">
{sheets.map((sheet) => (
<div key={sheet.id} className="sheet-card">
<h3>{sheet.title || 'Untitled Sheet'}</h3>
<p className="sheet-meta">
Created: {new Date(sheet.created_at).toLocaleDateString()}<br/>
Last modified: {new Date(sheet.updated_at).toLocaleDateString()}
</p>
<div className="sheet-actions">
<button className="btn small" onClick={() => handleEdit(sheet)}>Edit</button>
<button className="btn small primary" onClick={() => handleDownload(sheet)}>Download PDF</button>
<button className="btn small danger" onClick={() => handleDelete(sheet.id)}>Delete</button>
</div>
</div>
))}
</div>
)}
</div>
);
};

export default Dashboard;
2 changes: 1 addition & 1 deletion frontend/src/context/AuthContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const AuthProvider = ({ children }) => {
if (response.ok) {
setAuthTokens(data);
setUser(jwtDecode(data.access));
navigate('/');
navigate('/dashboard');
} else {
alert(data.detail || 'Invalid credentials');
}
Expand Down
16 changes: 12 additions & 4 deletions frontend/src/hooks/latex.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useRef, useEffect, useCallback, useContext } from 'react';
import AuthContext from '../context/AuthContext';

const STORAGE_KEY = 'cheatSheetLatex';
const SAVE_DEBOUNCE_MS = 500;
Expand Down Expand Up @@ -33,6 +34,7 @@ function formatCompileError(errorData = {}) {
}

export function useLatex(initialData) {
const { authTokens } = useContext(AuthContext);
const [title, setTitle] = useState(initialData?.title ?? '');
const [content, setContent] = useState(initialData?.content ?? '');
const [contentModified, setContentModified] = useState(false);
Expand Down Expand Up @@ -141,7 +143,10 @@ export function useLatex(initialData) {
const compileLatexContent = useCallback(async (latexContent) => {
const response = await fetch('/api/compile/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(authTokens ? { 'Authorization': `Bearer ${authTokens.access}` } : {})
},
body: JSON.stringify({ content: latexContent }),
});

Expand All @@ -156,7 +161,7 @@ export function useLatex(initialData) {
}
pdfBlobUrlRef.current = URL.createObjectURL(blob);
setPdfBlob(pdfBlobUrlRef.current);
}, []);
}, [authTokens]);

const handleCompileOnly = useCallback(async () => {
if (isCompilingRef.current) return;
Expand Down Expand Up @@ -261,7 +266,10 @@ export function useLatex(initialData) {
try {
const response = await fetch('/api/compile/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(authTokens ? { 'Authorization': `Bearer ${authTokens.access}` } : {})
},
body: JSON.stringify({ content }),
});
if (!response.ok) throw new Error('Failed to compile LaTeX');
Expand Down
Loading
Loading