From dc32ecfcd6032b84fab0a4a7952b3e886b10311b Mon Sep 17 00:00:00 2001 From: David Cruciani Date: Thu, 5 Feb 2026 15:46:49 +0100 Subject: [PATCH 1/3] chg: [mermaid] backend rendering --- app/__init__.py | 4 + app/assets/src/css/core.css | 11 +- app/case/CaseCore.py | 7 +- app/case/case.py | 54 ++++ app/static/css/core.css | 2 +- app/static/js/case/TaskComponent/tab-note.js | 62 +++- app/static/js/case/case_tasks.js | 30 +- .../js/case/computer_assistate_template.js | 20 +- app/static/js/case/hedgedoc_template.js | 17 +- app/static/js/case/note_from_template.js | 73 +++-- app/static/js/markdown_render.js | 20 ++ app/static/js/templating/task_template.js | 87 ++++-- .../js/tools/note_template_component.js | 36 ++- .../analyzer/misp_modules_index.html | 41 ++- app/templates/base.html | 31 -- app/templates/case/case_index.html | 26 +- app/templates/case/case_view.html | 89 ++++-- app/templates/case/create_case.html | 32 +- app/templates/case/create_task.html | 19 +- app/templates/case/edit_case.html | 18 +- app/templates/case/edit_task.html | 18 +- app/templates/home.html | 35 ++- .../my_assignment/my_assignment.html | 49 ++- app/templates/templating/add_task_case.html | 20 +- .../templating/case_template_view.html | 54 ++-- .../templating/case_templates_index.html | 24 +- .../templating/create_case_template.html | 19 +- .../templating/create_task_template.html | 20 +- .../templating/edit_case_template.html | 18 +- .../templating/edit_task_template.html | 18 +- app/templates/templating/task_template.html | 2 - app/templates/tools/create_note_template.html | 20 +- app/templates/tools/note_template_index.html | 2 - app/templates/tools/note_template_view.html | 27 +- app/utils/markdown_renderer.py | 287 ++++++++++++++++++ app/utils/markdown_routes.py | 25 ++ requirements.txt | 5 +- 37 files changed, 1028 insertions(+), 294 deletions(-) create mode 100644 app/static/js/markdown_render.js create mode 100644 app/utils/markdown_renderer.py create mode 100644 app/utils/markdown_routes.py diff --git a/app/__init__.py b/app/__init__.py index 873885a6..57345e89 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,6 +4,7 @@ from flask_migrate import Migrate from flask_session import Session from flask_login import LoginManager +import markdown from werkzeug.middleware.proxy_fix import ProxyFix from conf.config import config as Config @@ -17,6 +18,7 @@ migrate = Migrate() session = Session() login_manager = LoginManager() +md = markdown.Markdown(extensions=["fenced_code", "tables", "nl2br", "sane_lists"], output_format="html5") def create_app(): app = Flask(__name__) @@ -56,6 +58,7 @@ def create_app(): from .connectors.connectors import connector_blueprint from .analyzer.misp_modules import analyzer_blueprint from .custom_tags.custom_tags import custom_tags_blueprint + from .utils.markdown_routes import markdown_blueprint from .templating.templating import templating_blueprint app.register_blueprint(home_blueprint, url_prefix="/") app.register_blueprint(account_blueprint, url_prefix="/account") @@ -70,6 +73,7 @@ def create_app(): app.register_blueprint(analyzer_blueprint, url_prefix="/analyzer") csrf.exempt(analyzer_blueprint) app.register_blueprint(custom_tags_blueprint, url_prefix="/custom_tags") + app.register_blueprint(markdown_blueprint, url_prefix="/markdown") from .api import api_blueprint csrf.exempt(api_blueprint) diff --git a/app/assets/src/css/core.css b/app/assets/src/css/core.css index 42e2295d..9a18658c 100644 --- a/app/assets/src/css/core.css +++ b/app/assets/src/css/core.css @@ -31,16 +31,7 @@ pre.description { font-size: 0.85rem; } -pre.description h1, -pre.description h2, -pre.description h3, -pre.description h4, -pre.description h5, -pre.description h6 { - font-size: 0.95rem; - margin-top: 0.5rem; - margin-bottom: 0.25rem; -} + pre.description p { margin-bottom: 0.25rem; diff --git a/app/case/CaseCore.py b/app/case/CaseCore.py index 2fd34744..05f90505 100644 --- a/app/case/CaseCore.py +++ b/app/case/CaseCore.py @@ -24,6 +24,7 @@ from . import common_core as CommonModel from . TaskCore import TaskModel from ..utils.utils import get_modules_list +from ..utils.markdown_renderer import sanitize_markdown_input DATETIME_FORMAT = '%Y-%m-%dT%H:%M' from ..custom_tags import custom_tags_core as CustomModel @@ -931,7 +932,8 @@ def modify_note_core(self, cid, current_user, notes): """Modify notes of a case""" case = CommonModel.get_case(cid) if case: - case.notes = notes + safe_notes = sanitize_markdown_input(notes) + case.notes = safe_notes CommonModel.update_last_modif(cid) db.session.commit() CommonModel.save_history(case.uuid, current_user, f"Case's Notes modified") @@ -942,7 +944,8 @@ def append_note_core(self, cid, current_user, notes): """Modify notes of a case""" case = CommonModel.get_case(cid) if case: - case.notes += notes + safe_notes = sanitize_markdown_input(notes) + case.notes += safe_notes CommonModel.update_last_modif(cid) db.session.commit() CommonModel.save_history(case.uuid, current_user, f"Case's Notes modified") diff --git a/app/case/case.py b/app/case/case.py index 8c6b13f4..7cddadd5 100644 --- a/app/case/case.py +++ b/app/case/case.py @@ -12,6 +12,7 @@ from ..utils.utils import form_to_dict, get_object_templates from ..utils.formHelper import prepare_tags from ..utils.logger import flowintel_log +from ..utils.markdown_renderer import render_markdown_with_mermaid case_blueprint = Blueprint( 'case', @@ -741,6 +742,59 @@ def all_notes(cid): return {"message": "Case Not found", 'toast_class': "danger-subtle"}, 404 +@case_blueprint.route("//rendered_notes", methods=['GET']) +@login_required +def rendered_notes(cid): + """Render markdown notes to sanitized HTML on the server""" + case = CommonModel.get_case(cid) + if case: + if not check_user_private_case(case): + flowintel_log("audit", 403, "Render notes of a case: Private case: Permission denied", User=current_user.email, CaseId=cid) + return {"message": "Permission denied", 'toast_class': "danger-subtle"}, 403 + try: + html = render_markdown_with_mermaid(case.notes or "") + except RuntimeError as exc: + flowintel_log("error", 500, "Render notes failed: mmdc missing", User=current_user.email, CaseId=cid) + return {"message": str(exc), 'toast_class': "warning-subtle"}, 500 + flowintel_log("audit", 200, "Render notes of a case", User=current_user.email, CaseId=cid) + return {"html": html}, 200 + return {"message": "Case Not found", 'toast_class': "danger-subtle"}, 404 + + +@case_blueprint.route("//rendered_all_notes", methods=['GET']) +@login_required +def rendered_all_notes(cid): + """Render all task notes for a case to sanitized HTML""" + case = CommonModel.get_case(cid) + if case: + if not check_user_private_case(case): + flowintel_log("audit", 403, "Render all notes of a case: Private case: Permission denied", User=current_user.email, CaseId=cid) + return {"message": "Permission denied", 'toast_class': "danger-subtle"}, 403 + notes = CaseModel.get_all_notes(case) + try: + rendered = [render_markdown_with_mermaid(note) for note in notes] + except RuntimeError as exc: + flowintel_log("error", 500, "Render all notes failed: mmdc missing", User=current_user.email, CaseId=cid) + return {"message": str(exc), 'toast_class': "warning-subtle"}, 500 + flowintel_log("audit", 200, "Render all notes of a case", User=current_user.email, CaseId=cid) + return {"notes": rendered}, 200 + return {"message": "Case Not found", 'toast_class': "danger-subtle"}, 404 + + +@case_blueprint.route("/render_markdown_preview", methods=['POST']) +@login_required +def render_markdown_preview(): + """Render arbitrary markdown (for live preview) to sanitized HTML""" + payload = request.get_json(silent=True) or {} + text = payload.get("note", "") or "" + try: + html = render_markdown_with_mermaid(text) + except RuntimeError as exc: + flowintel_log("error", 500, "Render preview failed: mmdc missing", User=current_user.email) + return {"message": str(exc), 'toast_class': "warning-subtle"}, 500 + return {"html": html}, 200 + + @case_blueprint.route("//modif_note_case", methods=['POST']) @login_required @editor_required diff --git a/app/static/css/core.css b/app/static/css/core.css index 29e5b8ba..3e198d96 100644 --- a/app/static/css/core.css +++ b/app/static/css/core.css @@ -1 +1 @@ -:root{--sidebar: 6.5rem}body{background-color:#fbfbfb}main{padding-left:var(--sidebar);height:100%;min-height:90vh}.nav-pagination{margin-left:calc(40% - var(--sidebar))}[v-cloak]{display:none}pre.description{white-space:pre-wrap;word-wrap:break-word;overflow-x:auto;margin-bottom:0;font-size:.85rem}pre.description h1,pre.description h2,pre.description h3,pre.description h4,pre.description h5,pre.description h6{font-size:.95rem;margin-top:.5rem;margin-bottom:.25rem}pre.description p{margin-bottom:.25rem}#goTop{position:fixed;right:1em;bottom:1em}span#project-version{position:fixed;left:50%;bottom:1em}span#createTask{position:fixed;left:calc(var(--sidebar)+10px);bottom:1em;z-index:9999}.tag{filter:drop-shadow(-1px 3px 2px rgba(50,50,0,.5));margin-right:2px;margin-bottom:4px;border-radius:3px;display:inline-block;padding:2px 4px;font-size:12px;font-weight:700;line-height:14px;color:#fff}.ticket-id{filter:drop-shadow(-1px 2px 2px rgba(50,50,0,.5));border-radius:3px;margin-bottom:4px;display:inline-block;padding:2px 6px;background-color:#4e7095;font-size:15px;font-weight:700;color:#fbfbfb}.select2-container{padding:5px;min-width:250px}.select2-selection__choice{background-color:#ced4da;filter:drop-shadow(-1px 1px 1px rgba(50,50,0,.5))}.person{border-radius:50px}.orgs{border-radius:50px;--bs-btn-bg: #1f3763 !important;--bs-btn-border-color: #1f3763 !important}.analyse-editor-container{position:fixed;width:calc((100vw - var(--sidebar))/2);overflow-x:hidden;bottom:3em;right:calc(var(--bs-gutter-x) * .5);height:calc(100vh - 400px)}.note-editor-container{position:absolute;width:calc((100vw - 20px - var(--sidebar))/2);right:calc(var(--bs-gutter-x) * .5);padding-bottom:50px}fieldset.analyzer-select-case{border:1px groove #ddd!important;padding:0 1.4em 1.4em!important;margin:0 0 1.5em!important;-webkit-box-shadow:0px 0px 0px 0px #000;box-shadow:0 0 #000;background-color:#fff}legend.analyzer-select-case{float:none;color:#00000091;font-size:1.2em!important;text-align:left!important;width:auto;padding:1px 4px;border-bottom:none;background-color:#fff;border-radius:4px;border:1px groove #ddd!important;box-shadow:0 1px 2px 1px #0000001a}.note-editor{background-color:#fff;border:1px solid #aaaaaa80;border-radius:5px;width:50%;padding:5px;margin-right:10px}.markdown-render{background-color:#fff;border:1px solid #aaaaaa80;border-radius:5px;width:50%;padding:5px;white-space:pre-wrap;word-wrap:break-word;overflow-x:auto}.markdown-render-result{background-color:#fff;border:1px solid #aaaaaa80;border-radius:5px;padding:5px;margin-top:3px;white-space:pre-wrap;word-wrap:break-word;overflow-x:auto}.round-button{width:3%}.round-button-circle{padding-bottom:100%;border-radius:50%;background:#e13333;box-shadow:0 0 3px gray}.round-button-circle:hover{background:#c41313;cursor:pointer}.round-button a{float:left;width:100%;padding-top:50%;padding-bottom:50%;line-height:1em;margin-top:-.5em;text-align:center;color:#e2eaf3;text-decoration:none}.side-panel-config{background-color:#fff;box-shadow:#0000000d 0 2px 5px,#0000000d 0 2px 10px;border-radius:10px;height:75vh;position:fixed;max-width:calc((100%) / 2 - 1*var(--bs-gutter-x) * .5);right:calc(var(--bs-gutter-x) * .5);overflow-x:auto;padding-top:2px;padding-bottom:10px}@media(min-width:992px){.side-panel-config{max-width:calc((100% - 200px) / 2 - 1*var(--bs-gutter-x) * .5)}}.all-tasks-notes{background-color:#fff;border:1px solid grey;border-radius:10px;padding:5px;margin-bottom:10px}.cluster{padding:2px 3px;border-radius:3px;font-weight:700;background-color:#08c;filter:drop-shadow(-1px 2px 3px rgba(50,50,0,.5));color:#fff;font-size:15px}.subtask-tree{border-left:2px solid grey;border-bottom:2px solid grey;width:15px;height:15px;margin-left:.5em;margin-right:3px}.select2-type~.select2-container{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);background-color:var(--bs-form-control-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.select2-type~.select2-container>.selection>.select2-selection{border:0}.fading-line{display:block;border:none;height:1px;padding:0;background-image:linear-gradient(to right,#4d6571d9,#446d804d,#0000);background-repeat:no-repeat;background-size:100% 1px;margin:0 0 1rem}.fading-line-2{display:block;border:none;height:1px;padding:0;background-image:linear-gradient(to right,#00000017,#446d80bf,#00000017);background-repeat:no-repeat;background-size:100% 1px;margin:0 0 1rem}.card-stats{background-color:#fff;border:1px solid #d2d2d2;border-radius:5px;margin:2px}#calendar{background-color:#fff}#calendar a{color:#000;text-decoration:none}.fc-event{cursor:pointer}.case-core-style{background-color:#fff;border:#80808024 solid 1px;padding:25px;box-shadow:#0000000d 0 2px 5px,#0000000d 0 2px 10px;border-radius:15px}.case-header-card{background-color:#fff;border:1px solid #e9ecef;padding:1.5rem;box-shadow:0 2px 8px #0000000f;border-radius:12px}.case-header-top{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:1rem}.case-header-actions{display:flex;gap:.5rem;flex-wrap:wrap}.case-header-main{margin-bottom:1rem}.case-title-row{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;margin-bottom:.5rem}.case-title{font-size:1.75rem;font-weight:600;color:#212529;margin:0;line-height:1.3}.case-title .case-id{color:#6c757d;font-weight:500;margin-right:.25rem}.case-title .case-id:after{content:"-";margin-left:.25rem}.case-badges{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.case-visibility-badge{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;background-color:#f8f9fa;color:#6c757d;font-size:.8rem}.case-ticket-row{margin-top:.5rem}.ticket-badge{display:inline-flex;align-items:center;gap:.35rem;padding:.25rem .75rem;background-color:#f0f4f8;color:#495057;border-radius:20px;font-size:.85rem;font-weight:500}.ticket-badge i{color:#6c757d}.case-meta-grid{display:flex;flex-wrap:wrap;gap:1rem;padding:1rem 0;border-top:1px solid #f0f0f0;border-bottom:1px solid #f0f0f0;margin-bottom:1rem}.case-meta-item{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;background-color:#f8f9fa;border-radius:8px;font-size:.875rem;color:#495057}.case-meta-item i{color:#6c757d;font-size:.9rem}.case-meta-item .meta-label{color:#6c757d;font-weight:500}.case-meta-item .meta-value{color:#212529}.case-meta-deadline{background-color:#fff3cd}.case-meta-deadline i,.case-meta-deadline .meta-label{color:#856404}.case-description-section{margin-top:.5rem}.case-description-toggle{padding:.25rem 0;color:#6c757d;text-decoration:none;font-size:.875rem}.case-description-toggle:hover{color:#495057}.case-description-toggle i{transition:transform .2s ease;margin-right:.25rem}.case-description-toggle[aria-expanded=false] i{transform:rotate(-90deg)}.case-description{background-color:#f8f9fa;border-radius:8px;padding:1rem;margin-top:.5rem;font-family:inherit;white-space:pre-wrap;word-wrap:break-word;font-size:.9rem;line-height:1.6;color:#495057}.section-title{font-size:.9rem;font-weight:600;color:#495057;align-items:center}.section-title i{color:#6c757d}.case-tags-style{background-color:#fff;border:#80808024 solid 1px;padding:15px;box-shadow:#0000000d 0 2px 5px,#0000000d 0 2px 10px;border-radius:15px}.case-index-list{box-shadow:#0000000d 0 2px 5px,#0000000d 0 2px 10px;padding-top:15px;padding-bottom:15px;border-radius:15px;white-space:normal;word-break:break-word}#case-main-nav>.nav-tabs,#collapseTasks>.nav-tabs{border:none;margin:0}#case-main-nav>.nav-tabs>li,#collapseTasks>.nav-tabs>li{margin-right:2px}#case-main-nav>.nav-tabs>li>button,#collapseTasks>.nav-tabs>li>button{border:0;border-bottom:2px solid transparent;margin-right:0;color:#5e5e5e;padding:2px 15px}#case-main-nav>.nav-tabs>li>button.active,#collapseTasks>.nav-tabs>li>button.active{border-bottom:2px solid #007bff;color:#007bff}#case-main-nav>.nav-tabs>li>button:hover,#collapseTasks>.nav-tabs>li>button:hover{color:#4c8ac0}.dragging-ghost{background-color:#ffeeba!important;border:2px dashed #ff9800}.collapse .row .link-style-first:nth-child(2n+2){border-left:1px solid #80808042}.collapse .row .link-style-first:last-child{margin-bottom:5px}.blurred{filter:blur(6px);-webkit-filter:blur(6px);-webkit-user-select:none;user-select:none}.blur-btn{line-height:1;padding:.25rem .5rem}.blurred::selection{background:transparent} +:root{--sidebar: 6.5rem}body{background-color:#fbfbfb}main{padding-left:var(--sidebar);height:100%;min-height:90vh}.nav-pagination{margin-left:calc(40% - var(--sidebar))}[v-cloak]{display:none}pre.description{white-space:pre-wrap;word-wrap:break-word;overflow-x:auto;margin-bottom:0;font-size:.85rem}pre.description p{margin-bottom:.25rem}#goTop{position:fixed;right:1em;bottom:1em}span#project-version{position:fixed;left:50%;bottom:1em}span#createTask{position:fixed;left:calc(var(--sidebar)+10px);bottom:1em;z-index:9999}.tag{filter:drop-shadow(-1px 3px 2px rgba(50,50,0,.5));margin-right:2px;margin-bottom:4px;border-radius:3px;display:inline-block;padding:2px 4px;font-size:12px;font-weight:700;line-height:14px;color:#fff}.ticket-id{filter:drop-shadow(-1px 2px 2px rgba(50,50,0,.5));border-radius:3px;margin-bottom:4px;display:inline-block;padding:2px 6px;background-color:#4e7095;font-size:15px;font-weight:700;color:#fbfbfb}.select2-container{padding:5px;min-width:250px}.select2-selection__choice{background-color:#ced4da;filter:drop-shadow(-1px 1px 1px rgba(50,50,0,.5))}.person{border-radius:50px}.orgs{border-radius:50px;--bs-btn-bg: #1f3763 !important;--bs-btn-border-color: #1f3763 !important}.analyse-editor-container{position:fixed;width:calc((100vw - var(--sidebar))/2);overflow-x:hidden;bottom:3em;right:calc(var(--bs-gutter-x) * .5);height:calc(100vh - 400px)}.note-editor-container{position:absolute;width:calc((100vw - 20px - var(--sidebar))/2);right:calc(var(--bs-gutter-x) * .5);padding-bottom:50px}fieldset.analyzer-select-case{border:1px groove #ddd!important;padding:0 1.4em 1.4em!important;margin:0 0 1.5em!important;-webkit-box-shadow:0px 0px 0px 0px #000;box-shadow:0 0 #000;background-color:#fff}legend.analyzer-select-case{float:none;color:#00000091;font-size:1.2em!important;text-align:left!important;width:auto;padding:1px 4px;border-bottom:none;background-color:#fff;border-radius:4px;border:1px groove #ddd!important;box-shadow:0 1px 2px 1px #0000001a}.note-editor{background-color:#fff;border:1px solid #aaaaaa80;border-radius:5px;width:50%;padding:5px;margin-right:10px}.markdown-render{background-color:#fff;border:1px solid #aaaaaa80;border-radius:5px;width:50%;padding:5px;white-space:pre-wrap;word-wrap:break-word;overflow-x:auto}.markdown-render-result{background-color:#fff;border:1px solid #aaaaaa80;border-radius:5px;padding:5px;margin-top:3px;white-space:pre-wrap;word-wrap:break-word;overflow-x:auto}.round-button{width:3%}.round-button-circle{padding-bottom:100%;border-radius:50%;background:#e13333;box-shadow:0 0 3px gray}.round-button-circle:hover{background:#c41313;cursor:pointer}.round-button a{float:left;width:100%;padding-top:50%;padding-bottom:50%;line-height:1em;margin-top:-.5em;text-align:center;color:#e2eaf3;text-decoration:none}.side-panel-config{background-color:#fff;box-shadow:#0000000d 0 2px 5px,#0000000d 0 2px 10px;border-radius:10px;height:75vh;position:fixed;max-width:calc((100%) / 2 - 1*var(--bs-gutter-x) * .5);right:calc(var(--bs-gutter-x) * .5);overflow-x:auto;padding-top:2px;padding-bottom:10px}@media(min-width:992px){.side-panel-config{max-width:calc((100% - 200px) / 2 - 1*var(--bs-gutter-x) * .5)}}.all-tasks-notes{background-color:#fff;border:1px solid grey;border-radius:10px;padding:5px;margin-bottom:10px}.cluster{padding:2px 3px;border-radius:3px;font-weight:700;background-color:#08c;filter:drop-shadow(-1px 2px 3px rgba(50,50,0,.5));color:#fff;font-size:15px}.subtask-tree{border-left:2px solid grey;border-bottom:2px solid grey;width:15px;height:15px;margin-left:.5em;margin-right:3px}.select2-type~.select2-container{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);background-color:var(--bs-form-control-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.select2-type~.select2-container>.selection>.select2-selection{border:0}.fading-line{display:block;border:none;height:1px;padding:0;background-image:linear-gradient(to right,#4d6571d9,#446d804d,#0000);background-repeat:no-repeat;background-size:100% 1px;margin:0 0 1rem}.fading-line-2{display:block;border:none;height:1px;padding:0;background-image:linear-gradient(to right,#00000017,#446d80bf,#00000017);background-repeat:no-repeat;background-size:100% 1px;margin:0 0 1rem}.card-stats{background-color:#fff;border:1px solid #d2d2d2;border-radius:5px;margin:2px}#calendar{background-color:#fff}#calendar a{color:#000;text-decoration:none}.fc-event{cursor:pointer}.case-core-style{background-color:#fff;border:#80808024 solid 1px;padding:25px;box-shadow:#0000000d 0 2px 5px,#0000000d 0 2px 10px;border-radius:15px}.case-header-card{background-color:#fff;border:1px solid #e9ecef;padding:1.5rem;box-shadow:0 2px 8px #0000000f;border-radius:12px}.case-header-top{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:1rem}.case-header-actions{display:flex;gap:.5rem;flex-wrap:wrap}.case-header-main{margin-bottom:1rem}.case-title-row{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;margin-bottom:.5rem}.case-title{font-size:1.75rem;font-weight:600;color:#212529;margin:0;line-height:1.3}.case-title .case-id{color:#6c757d;font-weight:500;margin-right:.25rem}.case-title .case-id:after{content:"-";margin-left:.25rem}.case-badges{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.case-visibility-badge{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;background-color:#f8f9fa;color:#6c757d;font-size:.8rem}.case-ticket-row{margin-top:.5rem}.ticket-badge{display:inline-flex;align-items:center;gap:.35rem;padding:.25rem .75rem;background-color:#f0f4f8;color:#495057;border-radius:20px;font-size:.85rem;font-weight:500}.ticket-badge i{color:#6c757d}.case-meta-grid{display:flex;flex-wrap:wrap;gap:1rem;padding:1rem 0;border-top:1px solid #f0f0f0;border-bottom:1px solid #f0f0f0;margin-bottom:1rem}.case-meta-item{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;background-color:#f8f9fa;border-radius:8px;font-size:.875rem;color:#495057}.case-meta-item i{color:#6c757d;font-size:.9rem}.case-meta-item .meta-label{color:#6c757d;font-weight:500}.case-meta-item .meta-value{color:#212529}.case-meta-deadline{background-color:#fff3cd}.case-meta-deadline i,.case-meta-deadline .meta-label{color:#856404}.case-description-section{margin-top:.5rem}.case-description-toggle{padding:.25rem 0;color:#6c757d;text-decoration:none;font-size:.875rem}.case-description-toggle:hover{color:#495057}.case-description-toggle i{transition:transform .2s ease;margin-right:.25rem}.case-description-toggle[aria-expanded=false] i{transform:rotate(-90deg)}.case-description{background-color:#f8f9fa;border-radius:8px;padding:1rem;margin-top:.5rem;font-family:inherit;white-space:pre-wrap;word-wrap:break-word;font-size:.9rem;line-height:1.6;color:#495057}.section-title{font-size:.9rem;font-weight:600;color:#495057;align-items:center}.section-title i{color:#6c757d}.case-tags-style{background-color:#fff;border:#80808024 solid 1px;padding:15px;box-shadow:#0000000d 0 2px 5px,#0000000d 0 2px 10px;border-radius:15px}.case-index-list{box-shadow:#0000000d 0 2px 5px,#0000000d 0 2px 10px;padding-top:15px;padding-bottom:15px;border-radius:15px;white-space:normal;word-break:break-word}#case-main-nav>.nav-tabs,#collapseTasks>.nav-tabs{border:none;margin:0}#case-main-nav>.nav-tabs>li,#collapseTasks>.nav-tabs>li{margin-right:2px}#case-main-nav>.nav-tabs>li>button,#collapseTasks>.nav-tabs>li>button{border:0;border-bottom:2px solid transparent;margin-right:0;color:#5e5e5e;padding:2px 15px}#case-main-nav>.nav-tabs>li>button.active,#collapseTasks>.nav-tabs>li>button.active{border-bottom:2px solid #007bff;color:#007bff}#case-main-nav>.nav-tabs>li>button:hover,#collapseTasks>.nav-tabs>li>button:hover{color:#4c8ac0}.dragging-ghost{background-color:#ffeeba!important;border:2px dashed #ff9800}.collapse .row .link-style-first:nth-child(2n+2){border-left:1px solid #80808042}.collapse .row .link-style-first:last-child{margin-bottom:5px}.blurred{filter:blur(6px);-webkit-filter:blur(6px);-webkit-user-select:none;user-select:none}.blur-btn{line-height:1;padding:.25rem .5rem}.blurred::selection{background:transparent} diff --git a/app/static/js/case/TaskComponent/tab-note.js b/app/static/js/case/TaskComponent/tab-note.js index c93fb01e..b3410792 100644 --- a/app/static/js/case/TaskComponent/tab-note.js +++ b/app/static/js/case/TaskComponent/tab-note.js @@ -1,16 +1,19 @@ import { display_toast } from '/static/js/toaster.js' +import { renderMarkdownServer } from '/static/js/markdown_render.js' const { ref, nextTick, onMounted, watch } = Vue const { EditorView, basicSetup, languages } = window.CodeMirrorBundle; export default { delimiters: ['[[', ']]'], props: { cases_info: Object, - task: Object, - md: Object + task: Object }, setup(props) { const is_exporting = ref(false) // Boolean to display a spinner when exporting - const note_editor_render = ref([]) // Notes display in mermaid + const note_editor_render = ref([]) // Raw note content per editor + const rendered_notes = ref([]) // Saved notes rendered server-side + const rendered_preview = ref([]) // Live preview per editor + const previewTimers = {} let editor_list = [] // Variable for the editor const edit_mode = ref(-1) // Boolean use when the note is in edit mode @@ -22,6 +25,28 @@ export default { note_editor_render.value[0] = "" } + async function render_existing_notes() { + if (props.task.notes.length) { + for (let i in props.task.notes) { + const content = props.task.notes[i].note || "" + rendered_notes.value[i] = await renderMarkdownServer(content) + rendered_preview.value[i] = rendered_notes.value[i] + } + } else { + rendered_notes.value[0] = "" + rendered_preview.value[0] = await renderMarkdownServer("") + } + } + + function schedule_preview(idx, content) { + if (previewTimers[idx]) { + clearTimeout(previewTimers[idx]) + } + previewTimers[idx] = setTimeout(async () => { + rendered_preview.value[idx] = await renderMarkdownServer(content || "") + }, 200) + } + async function add_notes_task() { // Create a new empty note in the task @@ -32,6 +57,8 @@ export default { props.task.notes.push(loc["note"]) let key = props.task.notes.length - 1 note_editor_render.value[key] = "" + rendered_notes.value[key] = "" + rendered_preview.value[key] = await renderMarkdownServer("") await nextTick() const targetElement = document.getElementById('editor_' + key + "_" + props.task.id) @@ -47,7 +74,6 @@ export default { }) editor_list.push(editor) } - props.md.mermaid.run() } } @@ -57,6 +83,8 @@ export default { if (await res.status == 200) { props.task.notes.splice(key, 1); + rendered_notes.value.splice(key, 1) + rendered_preview.value.splice(key, 1) } display_toast(res) } @@ -81,7 +109,7 @@ export default { parent: targetElement }) editor_list[key] = editor - props.md.mermaid.run() + schedule_preview(key, task.notes[key].note) } async function export_notes(task, type, note_id) { @@ -128,6 +156,9 @@ export default { task.notes[key].note = notes_loc } + rendered_notes.value[key] = await renderMarkdownServer(notes_loc) + rendered_preview.value[key] = rendered_notes.value[key] + await nextTick() if (!notes_loc) { @@ -145,13 +176,13 @@ export default { editor_list[key] = editor } } - props.md.mermaid.run() } else { display_toast(res_msg) } } - onMounted(() => { + onMounted(async () => { + await render_existing_notes() if (props.task.notes.length) { for (let i in props.task.notes) { const targetElement = document.getElementById('editor_' + i + '_' + props.task.id) @@ -183,14 +214,17 @@ export default { }) - watch(note_editor_render, async (newAllContent, oldAllContent) => { - await nextTick() - props.md.mermaid.run() + watch(note_editor_render, (newAllContent) => { + for (let i = 0; i < newAllContent.length; i++) { + schedule_preview(i, newAllContent[i]) + } }, { deep: true }) return { // md, note_editor_render, + rendered_notes, + rendered_preview, is_exporting, edit_mode, @@ -232,7 +266,7 @@ export default {
-
+
@@ -268,7 +302,7 @@ export default { -

+

@@ -286,7 +320,7 @@ export default {
-
+
@@ -304,7 +338,7 @@ export default {
-
+
diff --git a/app/static/js/case/case_tasks.js b/app/static/js/case/case_tasks.js index c7ef88a3..2e1463d2 100644 --- a/app/static/js/case/case_tasks.js +++ b/app/static/js/case/case_tasks.js @@ -5,7 +5,8 @@ import tabFile from './TaskComponent/tab-file.js' import tabInfo from './TaskComponent/tab-info.js' import caseconnectors from './CaseConnectors.js' import { truncateText, getTextColor, mapIcon } from '/static/js/utils.js' -const { ref, nextTick } = Vue +import { renderMarkdownServer } from '/static/js/markdown_render.js' +const { ref, nextTick, watch } = Vue export default { delimiters: ['[[', ']]'], props: { @@ -15,7 +16,6 @@ export default { task: Object, key_loop: Number, open_closed: Object, - md: Object, all_connectors_list: Object, task_modules: Object, }, @@ -37,6 +37,8 @@ export default { const task_connectors_list = ref() const expandedTasks = ref({}); + const rendered_description_full = ref("") + const rendered_description_trunc = ref("") @@ -146,6 +148,12 @@ export default { await display_toast(res) } + async function render_descriptions() { + const raw = props.task.description || "" + rendered_description_full.value = await renderMarkdownServer(raw) + rendered_description_trunc.value = await renderMarkdownServer(raw ? truncateText(raw) : "") + } + async function take_task(task, current_user) { // Assign the task to the current user const res = await fetch('/case/' + task.case_id + '/take_task/' + task.id) @@ -195,9 +203,6 @@ export default { document.getElementById("tab-task-info-" + props.task.id).classList.remove("active") } await nextTick() - props.md.mermaid.run({ - querySelector: `#collapse${props.task.id} .mermaid` - }) } else if (tab_name == 'files') { selected_tab.value = 'files' if (!document.getElementById("tab-task-files-" + props.task.id).classList.contains("active")) { @@ -241,6 +246,10 @@ export default { // Vue function + watch(() => props.task.description, () => { + render_descriptions() + }, { immediate: true }) + Vue.onMounted(() => { fetch_task_connectors() select2_change(props.task.id) @@ -283,6 +292,8 @@ export default { selected_tab, task_connectors_list, expandedTasks, + rendered_description_full, + rendered_description_trunc, take_task, remove_assign_task, @@ -323,11 +334,11 @@ export default { -

+							

 						
 					
 					
 				
 				
diff --git a/app/static/js/markdown_render.js b/app/static/js/markdown_render.js
new file mode 100644
index 00000000..3364ba5e
--- /dev/null
+++ b/app/static/js/markdown_render.js
@@ -0,0 +1,20 @@
+import { display_toast } from '/static/js/toaster.js'
+
+// Render markdown on the server (Mermaid handled server-side)
+export async function renderMarkdownServer(markdownText = '', format = 'svg') {
+    const res = await fetch('/markdown/render', {
+        method: 'POST',
+        headers: {
+            'Content-Type': 'application/json',
+            'X-CSRFToken': document.getElementById('csrf_token')?.value || ''
+        },
+        body: JSON.stringify({ markdown: markdownText, format })
+    })
+    if (res.ok) {
+        const data = await res.json()
+        return data.html || ''
+    }
+    // Show toast if available
+    try { display_toast(res) } catch (_) { /* noop */ }
+    return ''
+}
diff --git a/app/static/js/templating/task_template.js b/app/static/js/templating/task_template.js
index 0f83d448..7db3ffef 100644
--- a/app/static/js/templating/task_template.js
+++ b/app/static/js/templating/task_template.js
@@ -1,8 +1,9 @@
 import { display_toast, create_message } from '../toaster.js'
 import TaskUrlTool from '../case/TaskComponent/TaskUrlTool.js'
 import { truncateText, getTextColor, mapIcon } from '/static/js/utils.js'
+import { renderMarkdownServer } from '/static/js/markdown_render.js'
 const { EditorView, basicSetup, languages } = window.CodeMirrorBundle;
-const { ref, nextTick } = Vue
+const { ref, nextTick, watch } = Vue
 export default {
 	delimiters: ['[[', ']]'],
 	components: {
@@ -24,6 +25,11 @@ export default {
 
 		const expandedTasks = ref({});
 
+		const rendered_description_full = ref('')
+		const rendered_description_trunc = ref('')
+		const rendered_notes = ref([])
+		const rendered_note_previews = ref([])
+
 		Vue.onMounted(async () => {
 			if (props.template.notes.length) {
 				for (let i in props.template.notes) {
@@ -33,6 +39,7 @@ export default {
 						extensions: [basicSetup, languages.markdown(), EditorView.updateListener.of((v) => {
 							if (v.docChanged) {
 								note_editor_render.value[i] = editor.state.doc.toString()
+								renderNotePreview(i)
 							}
 						})],
 						parent: targetElement
@@ -46,6 +53,7 @@ export default {
 					extensions: [basicSetup, languages.markdown(), EditorView.updateListener.of((v) => {
 						if (v.docChanged) {
 							note_editor_render.value[0] = editor.state.doc.toString()
+							renderNotePreview(0)
 						}
 					})],
 					parent: targetElement
@@ -53,34 +61,57 @@ export default {
 				editor_list[0] = editor
 			}
 
-			const allCollapses = document.getElementById('collapse' + props.template.id)
-			allCollapses.addEventListener('shown.bs.collapse', event => {
-				md.mermaid.init()
-			})
-			is_mounted.value = true
-		})
-		Vue.onUpdated(async () => {
-			// do not initialize mermaid before the page is mounted
-			if (is_mounted)
-				md.mermaid.init()
+			renderDescription()
+			renderNotesSaved()
 		})
 
-		const is_mounted = ref(false)
 		const edit_mode = ref(-1)
 
 		const note_editor_render = ref([])
 		let editor_list = []
-		const md = window.markdownit()
-		md.use(mermaidMarkdown.default)
 
 		if (props.template.notes.length) {
 			for (let i in props.template.notes) {
 				note_editor_render.value[i] = props.template.notes[i].note		// If this template has notes
+				rendered_note_previews.value[i] = ''
 			}
 		} else {
 			note_editor_render.value[0] = ""
+			rendered_note_previews.value[0] = ''
+		}
+
+		async function renderDescription() {
+			const description = props.template.description || ''
+			if (!description) {
+				rendered_description_full.value = ''
+				rendered_description_trunc.value = ''
+				return
+			}
+			const truncated = truncateText(description)
+			rendered_description_full.value = await renderMarkdownServer({ markdown: description })
+			rendered_description_trunc.value = await renderMarkdownServer({ markdown: truncated })
 		}
 
+		async function renderNotesSaved() {
+			if (!props.template.notes || !props.template.notes.length) {
+				rendered_notes.value = []
+				return
+			}
+			rendered_notes.value = await Promise.all(props.template.notes.map((note) => renderMarkdownServer({ markdown: note.note || '' })))
+		}
+
+		async function renderNotePreview(key) {
+			const content = note_editor_render.value[key] || ''
+			if (!content) {
+				rendered_note_previews.value[key] = ''
+				return
+			}
+			rendered_note_previews.value[key] = await renderMarkdownServer({ markdown: content })
+		}
+
+		watch(() => props.template.description, () => { renderDescription() })
+		watch(() => props.template.notes.map(note => note.note), () => { renderNotesSaved() })
+
 
 		async function add_notes_task() {
 			// Create a new empty note in the task
@@ -91,6 +122,8 @@ export default {
 				props.template.notes.push(loc["note"])
 				let key = props.template.notes.length - 1
 				note_editor_render.value[key] = ""
+				rendered_note_previews.value[key] = ''
+				rendered_notes.value[key] = ''
 				await nextTick()
 				const targetElement = document.getElementById('editor_' + key + "_" + props.template.id)
 
@@ -115,6 +148,8 @@ export default {
 
 			if (await res.status == 200) {
 				props.template.notes.splice(key, 1);
+				rendered_notes.value.splice(key, 1)
+				rendered_note_previews.value.splice(key, 1)
 			}
 			display_toast(res)
 		}
@@ -150,6 +185,8 @@ export default {
 			const res = await fetch('/templating/task/' + template.id + '/get_note?note_id=' + note_id)
 			let loc = await res.json()
 			template.notes[key].note = loc["notes"]
+			note_editor_render.value[key] = template.notes[key].note
+			renderNotePreview(key)
 
 			const targetElement = document.getElementById('editor1_' + key + "_" + props.template.id)
 			let editor = new EditorView({
@@ -157,6 +194,7 @@ export default {
 				extensions: [basicSetup, languages.markdown(), EditorView.updateListener.of((v) => {
 					if (v.docChanged) {
 						note_editor_render.value[key] = editor.state.doc.toString()
+						renderNotePreview(key)
 					}
 				})],
 				parent: targetElement
@@ -187,6 +225,8 @@ export default {
 				} else {
 					template.notes[key].note = notes_loc
 				}
+				rendered_notes.value[key] = await renderMarkdownServer({ markdown: notes_loc })
+				rendered_note_previews.value[key] = rendered_notes.value[key]
 				await nextTick()
 
 				if (!notes_loc) {
@@ -294,7 +334,10 @@ export default {
 
 		return {
 			note_editor_render,
-			md,
+			rendered_description_full,
+			rendered_description_trunc,
+			rendered_notes,
+			rendered_note_previews,
 			getTextColor,
 			mapIcon,
 			truncateText,
@@ -328,7 +371,7 @@ export default {
                 
[[ key_loop+1 ]]-[[ template.title ]]
-
@@ -588,7 +631,7 @@ export default {
-
+
@@ -604,7 +647,7 @@ export default {
-
+
diff --git a/app/static/js/tools/note_template_component.js b/app/static/js/tools/note_template_component.js index 41c0a23a..e94d0ee7 100644 --- a/app/static/js/tools/note_template_component.js +++ b/app/static/js/tools/note_template_component.js @@ -1,5 +1,6 @@ import { display_toast } from '../toaster.js' -const { ref, nextTick, onMounted, onUpdated } = Vue +import { renderMarkdownServer } from '/static/js/markdown_render.js' +const { ref, nextTick, onMounted, watch } = Vue const { EditorView, basicSetup, languages } = window.CodeMirrorBundle; export default { @@ -13,26 +14,26 @@ export default { const is_mounted = ref(false) const edit_mode = ref(false) const temp_content = ref(props.note_template.content || "") - - const md = window.markdownit() - md.use(mermaidMarkdown.default) + const rendered_content = ref("") + const rendered_preview = ref("") let content_editor = null + let previewTimer = null + onMounted(async () => { - const allCollapses = document.getElementById('collapse' + props.note_template.id) - if (allCollapses) { - allCollapses.addEventListener('shown.bs.collapse', async event => { - md.mermaid.init() - }) - } + rendered_content.value = await renderMarkdownServer(props.note_template.content || "") + rendered_preview.value = rendered_content.value is_mounted.value = true }) - onUpdated(async () => { - if (is_mounted.value) { - md.mermaid.init() + watch(temp_content, async (val) => { + if (previewTimer) { + clearTimeout(previewTimer) } + previewTimer = setTimeout(async () => { + rendered_preview.value = await renderMarkdownServer(val || "") + }, 200) }) async function delete_note_template(note_template, notes_array) { @@ -87,6 +88,8 @@ export default { let loc = await res.json() props.note_template.content = content_text props.note_template.version = loc.version + rendered_content.value = await renderMarkdownServer(content_text) + rendered_preview.value = rendered_content.value edit_mode.value = false } display_toast(res) @@ -99,9 +102,10 @@ export default { } return { - md, temp_content, edit_mode, + rendered_content, + rendered_preview, delete_note_template, init_editor, save_content, @@ -191,11 +195,11 @@ export default { diff --git a/app/templates/analyzer/misp_modules_index.html b/app/templates/analyzer/misp_modules_index.html index d88ff0a3..172a2851 100644 --- a/app/templates/analyzer/misp_modules_index.html +++ b/app/templates/analyzer/misp_modules_index.html @@ -5,9 +5,7 @@ {% extends 'base.html' %} {% block head %} - - {{ super() }} {%endblock%} @@ -89,19 +87,19 @@
MISP-Modules
Select note
-
+
Select note
-
@@ -315,7 +313,7 @@
C
-
+
@@ -580,7 +578,6 @@

Tasks Filter

:task="task" :key_loop="key" :open_closed="open_closed" - :md="md" :all_connectors_list="all_connectors_list" :task_modules="task_modules"> @@ -589,8 +586,8 @@

Tasks Filter

@@ -254,6 +252,7 @@
Tasks
import {display_toast, message_list} from '/static/js/toaster.js' import templateconnectors from '/static/js/templating/TemplateConnectors.js' import {getTextColor, mapIcon} from '/static/js/utils.js' + import { renderMarkdownServer } from '/static/js/markdown_render.js' const { EditorView, basicSetup, languages } = window.CodeMirrorBundle; createApp({ @@ -272,8 +271,21 @@
Tasks
const note_editor_render = ref("") const edit_mode = ref(false) - const md = window.markdownit() // Library to Parse and display markdown - md.use(mermaidMarkdown.default) // Use mermaid library + const rendered_description = ref("") + const rendered_note_saved = ref("") + const rendered_note_preview = ref("") + + async function renderNotePreview() { + if (!note_editor_render.value) { + rendered_note_preview.value = "" + return + } + rendered_note_preview.value = await renderMarkdownServer({ markdown: note_editor_render.value }) + } + + async function renderSavedNote() { + rendered_note_saved.value = await renderMarkdownServer({ markdown: case_template.value?.notes || "" }) + } const case_template_connectors_list = ref([]) const all_connectors_list = ref([]) @@ -284,6 +296,8 @@
Tasks
) let loc = await res.json() case_template.value = loc["template"] + rendered_description.value = await renderMarkdownServer(case_template.value.description || "") + rendered_note_saved.value = await renderMarkdownServer(case_template.value.notes || "") } async function fetchTasks() { @@ -345,15 +359,18 @@
Tasks
return false } - function prepare_editor(){ - md.mermaid.init() + async function prepare_editor(){ + await renderNotePreview() const targetElement = document.getElementById('editor') editor = new EditorView({ doc: case_template.value.notes, extensions: [basicSetup, languages.markdown(),EditorView.updateListener.of((v) => { - if (v.docChanged) { + rendered_description, + rendered_note_saved, + rendered_note_preview, note_editor_render.value = editor.state.doc.toString() - } + renderNotePreview() + })], parent: targetElement }) @@ -417,15 +434,15 @@
Tasks
edit_mode.value = true note_editor_render.value = case_template.value.notes + await renderNotePreview() await nextTick() - // prepare_editor() - md.mermaid.init() const targetElement = document.getElementById('editor') editor = new EditorView({ doc: case_template.value.notes, extensions: [basicSetup, languages.markdown(),EditorView.updateListener.of((v) => { if (v.docChanged) { note_editor_render.value = editor.state.doc.toString() + renderNotePreview() } })], parent: targetElement @@ -447,8 +464,9 @@
Tasks
if(await res_msg.status == 200){ edit_mode.value = false case_template.value.notes = notes_loc - // notes.value = notes_loc await nextTick() + await renderSavedNote() + await renderNotePreview() if(!notes_loc){ const targetElement = document.getElementById('editor') @@ -458,6 +476,7 @@
Tasks
extensions: [basicSetup, languages.markdown(),EditorView.updateListener.of((v) => { if (v.docChanged) { note_editor_render.value = editor.state.doc.toString() + renderNotePreview() } })], parent: targetElement @@ -465,7 +484,6 @@
Tasks
} } - md.mermaid.init() } await display_toast(res_msg) } @@ -546,7 +564,9 @@
Tasks
selected_tab, note_editor_render, edit_mode, - md, + rendered_description, + rendered_note_saved, + rendered_note_preview, active_tab, delete_case, diff --git a/app/templates/templating/case_templates_index.html b/app/templates/templating/case_templates_index.html index 8d169f42..d17b2c5a 100644 --- a/app/templates/templating/case_templates_index.html +++ b/app/templates/templating/case_templates_index.html @@ -5,7 +5,6 @@ {% extends 'base.html' %} {% block head %} - {{ super() }} {%endblock%} @@ -175,11 +174,11 @@

[[ template.id ]]- [[ template.title ]]

-

+