diff --git a/app/__init__.py b/app/__init__.py index 379845a6..b6b3ba6f 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 @@ -19,6 +20,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__) @@ -98,6 +100,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") @@ -112,6 +115,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 a4b9da64..4505ce86 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' UPLOAD_FOLDER = os.path.join(os.getcwd(), "uploads") @@ -944,7 +945,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") @@ -955,7 +957,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 b7074d2e..a7021a0a 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', @@ -787,6 +788,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 23b65822..c0592d29 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, @@ -233,7 +267,7 @@ export default {
-
+
@@ -271,7 +305,7 @@ export default { -

+

@@ -292,7 +326,7 @@ export default { @@ -310,7 +344,7 @@ export default {
-
+
diff --git a/app/static/js/case/case_tasks.js b/app/static/js/case/case_tasks.js index bf5ff813..f227c20d 100644 --- a/app/static/js/case/case_tasks.js +++ b/app/static/js/case/case_tasks.js @@ -6,7 +6,8 @@ import tabExternalRef from './TaskComponent/tab-external-ref.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: { @@ -16,7 +17,6 @@ export default { task: Object, key_loop: Number, open_closed: Object, - md: Object, all_connectors_list: Object, task_modules: Object, }, @@ -39,6 +39,8 @@ export default { const task_connectors_list = ref() const expandedTasks = ref({}); + const rendered_description_full = ref("") + const rendered_description_trunc = ref("") @@ -148,6 +150,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) @@ -199,9 +207,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")) { @@ -258,6 +263,10 @@ export default { // Vue function + watch(() => props.task.description, () => { + render_descriptions() + }, { immediate: true }) + Vue.onMounted(() => { fetch_task_connectors() select2_change(props.task.id) @@ -300,6 +309,8 @@ export default { selected_tab, task_connectors_list, expandedTasks, + rendered_description_full, + rendered_description_trunc, take_task, remove_assign_task, @@ -340,11 +351,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..635902b3
--- /dev/null
+++ b/app/static/js/markdown_render.js
@@ -0,0 +1,87 @@
+import { display_toast } from '/static/js/toaster.js'
+
+let activeRenders = 0
+let spinnerEl = null
+
+function ensureSpinner() {
+    if (spinnerEl && document.body.contains(spinnerEl)) return spinnerEl
+    spinnerEl = document.createElement('div')
+    spinnerEl.id = 'markdown-render-spinner'
+    spinnerEl.setAttribute('aria-live', 'polite')
+    spinnerEl.style.position = 'fixed'
+    spinnerEl.style.top = '1rem'
+    spinnerEl.style.right = '1rem'
+    spinnerEl.style.zIndex = '2000'
+    spinnerEl.style.padding = '0.5rem 0.75rem'
+    spinnerEl.style.borderRadius = '0.5rem'
+    spinnerEl.style.background = 'rgba(0,0,0,0.7)'
+    spinnerEl.style.color = 'white'
+    spinnerEl.style.fontSize = '0.9rem'
+    spinnerEl.style.display = 'none'
+    spinnerEl.style.alignItems = 'center'
+    spinnerEl.style.gap = '0.5rem'
+
+    // Minimal inline spinner dot
+    const dot = document.createElement('span')
+    dot.style.width = '0.85rem'
+    dot.style.height = '0.85rem'
+    dot.style.border = '2px solid rgba(255,255,255,0.4)'
+    dot.style.borderTopColor = 'white'
+    dot.style.borderRadius = '50%'
+    dot.style.display = 'inline-block'
+    dot.style.animation = 'markdown-spin 0.8s linear infinite'
+
+    const text = document.createElement('span')
+    text.textContent = 'Rendering markdown…'
+
+    spinnerEl.appendChild(dot)
+    spinnerEl.appendChild(text)
+
+    // Add a tiny keyframe once
+    if (!document.getElementById('markdown-render-style')) {
+        const style = document.createElement('style')
+        style.id = 'markdown-render-style'
+        style.textContent = '@keyframes markdown-spin { from { transform: rotate(0deg);} to { transform: rotate(360deg);} }'
+        document.head.appendChild(style)
+    }
+
+    document.body.appendChild(spinnerEl)
+    return spinnerEl
+}
+
+function setLoading(isLoading) {
+    const el = ensureSpinner()
+    if (isLoading) {
+        activeRenders += 1
+        el.style.display = 'flex'
+    } else {
+        activeRenders = Math.max(0, activeRenders - 1)
+        if (activeRenders === 0) {
+            el.style.display = 'none'
+        }
+    }
+}
+
+// Render markdown on the server (Mermaid handled server-side)
+export async function renderMarkdownServer(markdownText = '', format = 'svg') {
+    setLoading(true)
+    try {
+        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 ''
+    } finally {
+        setLoading(false)
+    }
+}
diff --git a/app/static/js/templating/task_template.js b/app/static/js/templating/task_template.js
index a9927020..cdcb8fc7 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: {
@@ -32,8 +33,11 @@ export default {
 		const edit_mode = ref(-1)
 		const note_editor_render = ref([])
 		const editor_list = ref({})
-		const md = window.markdownit()
-		md.use(mermaidMarkdown.default)
+
+		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) {
@@ -45,6 +49,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
@@ -60,6 +65,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
@@ -68,29 +74,66 @@ export default {
 				}
 			}
 
-			const allCollapses = document.getElementById('collapse' + props.template.id)
-			if (allCollapses) {
-				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.value)
-				md.mermaid.init()
+			// const allCollapses = document.getElementById('collapse' + props.template.id)
+			// if (allCollapses) {
+			// 	allCollapses.addEventListener('shown.bs.collapse', event => {
+			// 		md.mermaid.init()
+			// 	})
+			// }
+			// is_mounted.value = true
+
+			renderDescription()
+			renderNotesSaved()
 		})
+		// Vue.onUpdated(async () => {
+		// 	// do not initialize mermaid before the page is mounted
+		// 	if (is_mounted.value)
+		// 		md.mermaid.init()
+		// })
 
 		// Initialize reactive refs and note data
 		if (props.template.notes && 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
@@ -101,6 +144,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)
 
@@ -125,6 +170,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)
 		}
@@ -160,6 +207,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({
@@ -167,6 +216,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
@@ -197,6 +247,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) {
@@ -305,7 +357,10 @@ export default {
 		return {
 			can_manage_templates,
 			note_editor_render,
-			md,
+			rendered_description_full,
+			rendered_description_trunc,
+			rendered_notes,
+			rendered_note_previews,
 			getTextColor,
 			mapIcon,
 			truncateText,
@@ -339,7 +394,7 @@ export default {
                 
[[ key_loop+1 ]]-[[ template.title ]]
-
@@ -603,7 +658,7 @@ export default {
-
+
@@ -619,7 +674,7 @@ export default {
-
+
diff --git a/app/static/js/tools/note_template_component.js b/app/static/js/tools/note_template_component.js index 2ff7b343..ff66c9b6 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 { @@ -17,26 +18,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) { @@ -91,6 +92,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) @@ -103,9 +106,10 @@ export default { } return { - md, temp_content, edit_mode, + rendered_content, + rendered_preview, delete_note_template, init_editor, save_content, @@ -195,11 +199,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
-
@@ -327,7 +335,7 @@
C
-
+
@@ -603,7 +611,6 @@

Tasks Filter

:task="task" :key_loop="key" :open_closed="open_closed" - :md="md" :all_connectors_list="all_connectors_list" :task_modules="task_modules"> @@ -612,8 +619,12 @@

Tasks Filter