Skip to content

Commit bc552bf

Browse files
author
Marc Schlaeppi
committed
Handle large notes and add direct export
1 parent 20aa224 commit bc552bf

8 files changed

Lines changed: 313 additions & 42 deletions

File tree

source/app/blueprints/pages/case/templates/case_notes_v2.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ <h4 class="page-title mb-0" id="currentNoteTitle"></h4>
8787
<i class="fa-brands fa-markdown mr-2"></i>
8888
</span>
8989
</button>
90-
<button type="button" class="btn bg-transparent btn-xs" onclick="download_note();return false;" title="Download as MD">
90+
<button type="button" class="btn bg-transparent btn-xs" onclick="download_note();return false;" title="Export note as Markdown" aria-label="Export note as Markdown">
9191
<span class="btn-label">
9292
<i class="fa-solid fa-download mr-2"></i>
9393
</span>

source/app/blueprints/rest/case/case_notes_routes.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@
1616
# along with this program; if not, write to the Free Software Foundation,
1717
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1818

19+
import io
20+
1921
from marshmallow import ValidationError
2022
from datetime import datetime
2123
from flask import Blueprint
2224
from flask import request
25+
from flask import send_file
26+
from werkzeug.utils import secure_filename
2327

2428
from app.db import db
2529
from app import app
@@ -62,6 +66,14 @@
6266
case_notes_rest_blueprint = Blueprint('case_notes_rest', __name__)
6367

6468

69+
def _note_export_filename(title, note_id):
70+
safe_title = secure_filename(title or '')
71+
if safe_title.lower().endswith('.md'):
72+
safe_title = safe_title[:-3]
73+
safe_title = safe_title[:120].rstrip('._-') or f'note-{note_id}'
74+
return f'{safe_title}.md'
75+
76+
6577
@case_notes_rest_blueprint.route('/case/notes/<int:cur_id>', methods=['GET'])
6678
@endpoint_deprecated('GET', '/api/v2/cases/{case_identifier}/notes/{identifier}')
6779
@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access)
@@ -112,6 +124,28 @@ def case_note_detail(cur_id, caseid):
112124
return response_error(msg="Data error", data=e.messages)
113125

114126

127+
@case_notes_rest_blueprint.route('/case/notes/<int:cur_id>/export', methods=['GET'])
128+
@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access)
129+
@ac_api_requires()
130+
def case_note_export(cur_id, caseid):
131+
note = get_note(cur_id)
132+
if not note or note.note_case_id != caseid:
133+
return response_error(msg="Invalid note ID")
134+
135+
content = (note.note_content or '').encode('utf-8')
136+
response = send_file(
137+
io.BytesIO(content),
138+
mimetype='text/markdown; charset=utf-8',
139+
as_attachment=True,
140+
download_name=_note_export_filename(note.note_title, note.note_id),
141+
max_age=0,
142+
)
143+
response.cache_control.no_store = True
144+
response.cache_control.private = True
145+
response.headers['X-Content-Type-Options'] = 'nosniff'
146+
return response
147+
148+
115149
@case_notes_rest_blueprint.route('/case/notes/delete/<int:cur_id>', methods=['POST'])
116150
@endpoint_deprecated('DELETE', '/api/v2/cases/{case_identifier}/notes/{identifier}')
117151
@ac_requires_case_identifier(CaseAccessLevel.full_access)

tests/tests_rest_notes.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,50 @@ def test_get_note_should_return_404_when_case_does_not_exist(self):
131131
response = self._subject.get(f'/api/v2/cases/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/notes/{identifier}')
132132
self.assertEqual(404, response.status_code)
133133

134+
def test_export_note_should_return_original_markdown_as_attachment(self):
135+
case_identifier = self._subject.create_dummy_case()
136+
directory = self._subject.create(
137+
f'/api/v2/cases/{case_identifier}/notes-directories',
138+
{'name': 'directory_name'}
139+
).json()
140+
markdown = '# Findings\n\nExact note content.\n'
141+
note = self._subject.create(
142+
f'/api/v2/cases/{case_identifier}/notes',
143+
{
144+
'directory_id': directory['id'],
145+
'note_title': 'SecureDocs.ps1',
146+
'note_content': markdown,
147+
}
148+
).json()
149+
150+
response = self._subject.get(
151+
f'/case/notes/{note["note_id"]}/export',
152+
query_parameters={'cid': case_identifier}
153+
)
154+
155+
self.assertEqual(200, response.status_code)
156+
self.assertEqual(markdown.encode('utf-8'), response.content)
157+
self.assertIn('attachment', response.headers['Content-Disposition'])
158+
self.assertIn('SecureDocs.ps1.md', response.headers['Content-Disposition'])
159+
self.assertTrue(response.headers['Content-Type'].startswith('text/markdown'))
160+
self.assertEqual('nosniff', response.headers['X-Content-Type-Options'])
161+
162+
def test_export_note_should_deny_user_without_case_access(self):
163+
case_identifier = self._subject.create_dummy_case()
164+
directory = self._subject.create(
165+
f'/api/v2/cases/{case_identifier}/notes-directories',
166+
{'name': 'directory_name'}
167+
).json()
168+
note = self._subject.create(
169+
f'/api/v2/cases/{case_identifier}/notes',
170+
{'directory_id': directory['id']}
171+
).json()
172+
user = self._subject.create_dummy_user()
173+
174+
response = user.get(f'/case/notes/{note["note_id"]}/export?cid={case_identifier}')
175+
176+
self.assertEqual(403, response.status_code)
177+
134178
def test_update_note_should_return_200(self):
135179
case_identifier = self._subject.create_dummy_case()
136180
response = self._subject.create(f'/api/v2/cases/{case_identifier}/notes-directories',

ui/src/lib/large_document.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const LARGE_DOCUMENT_MAX_CHARS = 1_000_000;
2+
export const LARGE_DOCUMENT_MAX_LINE_CHARS = 100_000;
3+
4+
export function getLargeDocumentReason(markdown) {
5+
const content = typeof markdown === 'string' ? markdown : '';
6+
if (content.length > LARGE_DOCUMENT_MAX_CHARS) {
7+
return 'document-size';
8+
}
9+
10+
let lineLength = 0;
11+
for (let index = 0; index < content.length; index += 1) {
12+
if (content.charCodeAt(index) === 10) {
13+
lineLength = 0;
14+
continue;
15+
}
16+
lineLength += 1;
17+
if (lineLength > LARGE_DOCUMENT_MAX_LINE_CHARS) {
18+
return 'line-length';
19+
}
20+
}
21+
return null;
22+
}

ui/src/lib/milkdown_overrides.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,30 @@
474474
box-shadow: none;
475475
}
476476

477+
.iris-view-toggle button:disabled {
478+
cursor: not-allowed;
479+
opacity: 0.45;
480+
}
481+
482+
.iris-view-toggle button:disabled:hover {
483+
background: transparent;
484+
color: #5f6368;
485+
}
486+
487+
.iris-source-only-status {
488+
display: inline-flex;
489+
align-items: center;
490+
margin-left: 10px;
491+
color: #5f6368;
492+
font-size: 11px;
493+
font-weight: 600;
494+
line-height: 1;
495+
}
496+
497+
.iris-split-shell[data-theme="dark"] .iris-source-only-status {
498+
color: #8b92a9;
499+
}
500+
477501
/* ---- 10. View-mode pane visibility overrides ------------------------------ */
478502
.iris-split.view-source > .iris-split-pane.iris-split-preview,
479503
.iris-split.view-preview > .iris-split-pane.iris-split-source {

ui/src/pages/case.notes.js

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -916,18 +916,20 @@ async function load_directories() {
916916
});
917917
}
918918

919-
function download_note() {
920-
// Use the content of whichever editor is currently active (ACE or Milkdown)
921-
let content = get_active_note_markdown();
922-
let filename = $('#currentNoteTitle').text() + '.md';
923-
let blob = new Blob([content], {type: 'text/plain'});
924-
let url = window.URL.createObjectURL(blob);
925-
926-
// Create a link to the file and click it to download it
919+
function download_note(noteId) {
920+
const activeNoteId = $('#currentNoteIDLabel').data('note_id');
921+
const targetNoteId = noteId || activeNoteId;
922+
if (!targetNoteId) {
923+
return false;
924+
}
925+
927926
let link = document.createElement('a');
928-
link.href = url;
929-
link.download = filename;
927+
link.href = `/case/notes/${encodeURIComponent(targetNoteId)}/export${case_param()}`;
928+
link.hidden = true;
929+
document.body.appendChild(link);
930930
link.click();
931+
link.remove();
932+
return false;
931933
}
932934

933935
function add_note(directory_id) {
@@ -1345,6 +1347,14 @@ function createDirectoryListItem(directory, directoryMap) {
13451347
copy_object_link_md('notes',note.id);
13461348
}));
13471349

1350+
menu.append($('<a></a>').addClass('dropdown-item').attr('href', '#')
1351+
.append($('<i></i>').addClass('fa-solid fa-download mr-2'))
1352+
.append(document.createTextNode('Export note'))
1353+
.on('click', function (e) {
1354+
e.preventDefault();
1355+
download_note(note.id);
1356+
}));
1357+
13481358
menu.append($('<a></a>').addClass('dropdown-item').attr('href', '#').text('Move').on('click', function (e) {
13491359
e.preventDefault();
13501360
move_item(note.id, 'note');

0 commit comments

Comments
 (0)