diff --git a/CHANGELOG.md b/CHANGELOG.md index 497f07337..f131c698c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.2.0] - 8 January 2026 + +### Added + +* Added support for inserting footnote objects in the collaborative editor (PR #783) + * Footnotes will appear in the editor as they do in Word (e.g., as superscript numbers) + * The text you set for your footnote will appear in Word as your footnote text + * We recommend adding Word's _Footnote Reference_ and _Footnote Text_ styles to templates, but this is not required + +### Changed + +* Updated Docker files to remove some of the exposed ports (Fixes #768) + * Removing the exposed ports addresses issues some individuals experienced with Docker v20 + * The removal also generally improves security by closing ports that do not need to be exposed by default + * This change may adversely affect anyone who uses the PostgreSQL port for remote administration + * Users may choose to re-expose ports, but you can execute `pgsql` commands inside the container + * This is a precursor to a larger change coming that introduces publisherd Docker images +* Adjusted the dark mode colors for inactive tabs to improve blending + +## Fixed + +* Fixed some fields not appearing with a WYSIWYG editor when adding them as a new formset + ## [6.1.1] - 15 December 2025 ### Added diff --git a/DOCS/features/reporting/report-templates/word-template-styles.mdx b/DOCS/features/reporting/report-templates/word-template-styles.mdx index 2b2fc70bc..41393798d 100644 --- a/DOCS/features/reporting/report-templates/word-template-styles.mdx +++ b/DOCS/features/reporting/report-templates/word-template-styles.mdx @@ -11,16 +11,18 @@ You can configure many variables for a style in a Word document. In some cases, These styles are called by name: -| **Style Name** | **Description** | -|--------------------|-------------------------------------------------------------------------------------------------------------------------------| -| `CodeBlock` | Style text evidence and anything in the WYSIWYG editor's code editor (must be a *Paragraph* style type). | -| `CodeInline` | Style runs of text formatted as code in the WYSIWYG editor (must be a *Character* style type). | -| `Number List` | Style numbered (ordered) lists. | -| `Bullet List` | Style bulleted (unordered) lists. | -| `Caption` | Built-in style used for captions below evidence and lines preceded by the *`{{.caption}}`* expression. | -| `List Paragraph` | Built-in base style used for bulleted and numbered lists; the fallback built-in style if your template lacks customized styles. | -| `BlockQuote` | Style used for block quotes. | -| `Table Grid` | Built-in style used for tables. | +| **Style Name** | **Description** | +|---------------------|---------------------------------------------------------------------------------------------------------------------------------| +| `CodeBlock` | Style text evidence and anything in the WYSIWYG editor's code editor (must be a *Paragraph* style type). | +| `CodeInline` | Style runs of text formatted as code in the WYSIWYG editor (must be a *Character* style type). | +| `Number List` | Style numbered (ordered) lists. | +| `Bullet List` | Style bulleted (unordered) lists. | +| `Caption` | Built-in style used for captions below evidence and lines preceded by the *`{{.caption}}`* expression. | +| `List Paragraph` | Built-in base style used for bulleted and numbered lists; the fallback built-in style if your template lacks customized styles. | +| `BlockQuote` | Style used for block quotes. | +| `Table Grid` | Built-in style used for tables. | +| `Footnote Reference`| Built-in style used for footnote numbers. | +| `Footnote Text` | Built-in style used for footnote text. | @@ -30,7 +32,7 @@ You can choose not to create these list styles, but lists will probably not look When you create a list in Word, the application applies *List Paragraph* and additional styling depending on your selection (numbered or bulleted). The style will appear as *List Paragraph,DAI2* or similar. -This style **does not exist** in your template until you use it once, so Ghostwriter can't default to using it. (See below.) +This style **does not exist** in your template until you use it once, so Ghostwriter can't default to using it (see below). Create a numbered list, open the styles tab, and save the style as a new style named *Numbered List*. Repeat this for bulleted lists. @@ -40,7 +42,7 @@ Feel free to modify the indentation for nested list items and any other style va **Note on Built-in Styles** -Word offers many, many built-in styles you might expect to be available to Ghostwriter; however, these styles only exist in the Word *application*. Word will only add a style to your template's styles.xml when you use it to keep file size down. +Word offers many, many built-in styles you might expect to be available to Ghostwriter; however, these styles only exist in the Word *application*. Word will only add a style to your template's internal _styles.xml_ when you use it to keep file size down. These styles have these attributes applied: ``. This means a style like *Caption* will not exist in your template until you've applied it or created it yourself. diff --git a/DOCS/sample_reports/template.docx b/DOCS/sample_reports/template.docx index 3c937f52a..7e2ca3d9e 100644 Binary files a/DOCS/sample_reports/template.docx and b/DOCS/sample_reports/template.docx differ diff --git a/VERSION b/VERSION index e671f310c..be61fbabb 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ -v6.1.1 -15 December 2025 +v6.2.0 +8 January 2026 diff --git a/compose/local/django/Dockerfile b/compose/local/django/Dockerfile index cb5b4e35c..fefc1495b 100644 --- a/compose/local/django/Dockerfile +++ b/compose/local/django/Dockerfile @@ -21,6 +21,8 @@ RUN apk --no-cache add build-base curl \ # Rust and Cargo required by the ``cryptography`` Python package && apk --no-cache add rust \ && apk --no-cache add cargo \ + # Git for installing packages from GitHub + && apk --no-cache add git \ && pip install --no-cache-dir -U setuptools pip COPY ./requirements /requirements diff --git a/compose/production/django/Dockerfile b/compose/production/django/Dockerfile index 4ca3ba8db..086d3beb6 100644 --- a/compose/production/django/Dockerfile +++ b/compose/production/django/Dockerfile @@ -19,7 +19,10 @@ RUN \ # XLSX dependencies && apk add libxml2-dev libxslt-dev \ # Rust and Cargo required by the ``cryptography`` Python package - && apk add rust cargo + && apk --no-cache add rust cargo \ + # Git for installing packages from GitHub + && apk --no-cache add git + RUN \ --mount=type=cache,target=/root/.cache/pip \ pip install wheel \ diff --git a/config/settings/base.py b/config/settings/base.py index 9d34a2273..2d195f1ba 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -11,9 +11,9 @@ # 3rd Party Libraries import environ -__version__ = "6.1.1" +__version__ = "6.2.0" VERSION = __version__ -RELEASE_DATE = "15 December 2025" +RELEASE_DATE = "8 January 2026" ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent APPS_DIR = ROOT_DIR / "ghostwriter" diff --git a/ghostwriter/modules/reportwriter/base/docx.py b/ghostwriter/modules/reportwriter/base/docx.py index 22410251d..2c78e6aea 100644 --- a/ghostwriter/modules/reportwriter/base/docx.py +++ b/ghostwriter/modules/reportwriter/base/docx.py @@ -4,12 +4,14 @@ import os import re -from docxtpl import DocxTemplate, RichText as DocxRichText -from docx.opc.exceptions import PackageNotFoundError +from docx import Document from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_ALIGN_PARAGRAPH -from docx.shared import Inches, Pt from docx.image.exceptions import UnrecognizedImageError +from docx.opc.exceptions import PackageNotFoundError +from docx.oxml.ns import qn +from docx.shared import Inches, Pt +from docxtpl import DocxTemplate, RichText as DocxRichText from ghostwriter.commandcenter.models import CompanyInformation, ReportConfiguration from ghostwriter.modules.reportwriter.base import ReportExportTemplateError @@ -32,6 +34,8 @@ "Caption", "List Paragraph", "Blockquote", + "footnote text", # Lowercase to match style name + "footnote reference" # Lowercase to match style name ] + [f"Heading {i}" for i in range(1, 7)] _img_desc_replace_re = re.compile(r"^\s*\[\s*([a-zA-Z0-9_]+)\s*\]\s*(.*)$") @@ -128,8 +132,65 @@ def run(self) -> io.BytesIO: out = io.BytesIO() self.word_doc.save(out) + + # Post-process to clean up separator footnotes (remove extra empty paragraphs) + out = self._cleanup_footnote_separators(out) + return out + def _cleanup_footnote_separators(self, docx_bytes: io.BytesIO) -> io.BytesIO: + """ + Remove extra empty paragraphs from separator footnotes. + + Some Word templates have extra empty paragraphs in the separator and + continuationSeparator footnotes, which causes unwanted spacing between + the footnote separator line and the actual footnotes. + + This post-processes the saved DOCX to avoid interfering with docxtpl's + template rendering. + """ + try: + docx_bytes.seek(0) + doc = Document(docx_bytes) + + # Access the footnotes part (requires accessing internal python-docx members) + # pylint: disable=protected-access + if not hasattr(doc._part, '_footnotes_part') or doc._part._footnotes_part is None: + docx_bytes.seek(0) + return docx_bytes + + footnotes_part = doc._part._footnotes_part + footnotes_element = footnotes_part._element + # pylint: enable=protected-access + + modified = False + for footnote in footnotes_element: + # Only clean separator footnotes (id=-1 or id=0) + footnote_id = footnote.get(qn("w:id")) + if footnote_id in ("-1", "0"): + # Find all paragraph elements + paragraphs = list(footnote.iterchildren(qn("w:p"))) + # Keep only the first paragraph (which contains the separator) + for para in paragraphs[1:]: + footnote.remove(para) + modified = True + + if modified: + # Save the modified document + out = io.BytesIO() + doc.save(out) + out.seek(0) + return out + + docx_bytes.seek(0) + return docx_bytes + + except Exception as e: # pylint: disable=broad-exception-caught + # Log but don't fail the report generation + logger.warning("Failed to cleanup footnote separators: %s", e) + docx_bytes.seek(0) + return docx_bytes + def create_styles(self): """ Creates default styles @@ -360,6 +421,12 @@ def lint(cls, report_template: ReportTemplate) -> Tuple[List[str], List[str]]: if style == "List Paragraph": if document_styles[style].type != WD_STYLE_TYPE.PARAGRAPH: warnings.append("List Paragraph style is not a paragraph style (see documentation)") + if style == "footnote text": + if document_styles[style].type != WD_STYLE_TYPE.PARAGRAPH: + warnings.append("Footnote Text style is not a character style (see documentation)") + if style == "footnote reference": + if document_styles[style].type != WD_STYLE_TYPE.CHARACTER: + warnings.append("Footnote Reference style is not a character style (see documentation)") if "Table Grid" not in document_styles: errors.append("Template is missing a required style (see documentation): Table Grid") if report_template.p_style and report_template.p_style not in document_styles: diff --git a/ghostwriter/modules/reportwriter/richtext/docx.py b/ghostwriter/modules/reportwriter/richtext/docx.py index 6b5e57db6..44a4f68cd 100644 --- a/ghostwriter/modules/reportwriter/richtext/docx.py +++ b/ghostwriter/modules/reportwriter/richtext/docx.py @@ -249,6 +249,101 @@ def tag_div(self, el, **kwargs): else: super().tag_div(el, **kwargs) + def tag_span(self, el, *, par, **kwargs): + """Override tag_span to handle footnotes.""" + if "footnote" in el.attrs.get("class", []): + self.make_footnote(el, par=par, **kwargs) + else: + super().tag_span(el, par=par, **kwargs) + + def make_footnote(self, el, *, par=None, **kwargs): + """ + Handle elements by creating a Word footnote. + + The footnote content is the text content of the element. + A footnote reference is inserted at the current position in the paragraph. + """ + if par is None: + logger.warning("Footnote found outside of a paragraph, skipping") + return + + # Get the footnote content from the element's text + footnote_content = el.get_text().strip() + if not footnote_content: + return + + # Emit any pending segment break before adding footnote + self.text_tracking.force_emit_pending_segment_break() + + # Calculate the next footnote ID by finding the max existing ID + # This is simpler and more reliable than the paragraph-based algorithm + # which doesn't work well for table cells or dynamically-built documents + max_existing_id = 0 + for footnote in self.doc.footnotes: + max_existing_id = max(max_existing_id, footnote.id) + next_footnote_id = max_existing_id + 1 + + # Add footnote reference to the run and create the footnote + paragraph_element = par._p.add_r() + paragraph_element.add_footnoteReference(next_footnote_id) + new_footnote = self.doc._add_footnote(next_footnote_id) + + # Add the footnote paragraph with the footnote reference mark at the start + # This is required for Word to properly display the footnote number + footnote_paragraph = new_footnote.add_paragraph() + + # Track if either style is missing + missing_footnote_text = False + missing_footnote_ref = False + + # Try to apply "Footnote Text" style to the paragraph + try: + footnote_paragraph.style = "Footnote Text" + except KeyError: + missing_footnote_text = True + + # Create a run for the footnote reference number + try: + footnote_ref_run = footnote_paragraph.add_run() + footnote_ref_run.style = "Footnote Reference" + footnote_ref_run.font.superscript = True + except KeyError: + missing_footnote_ref = True + footnote_ref_run = footnote_paragraph._p.add_r() + run_properties = OxmlElement("w:rPr") + style_element = OxmlElement("w:rStyle") + style_element.set(qn("w:val"), "FootnoteReference") + run_properties.append(style_element) + vert_align = OxmlElement("w:vertAlign") + vert_align.set(qn("w:val"), "superscript") + run_properties.append(vert_align) + footnote_ref_run.insert(0, run_properties) + + # Add the footnote reference mark + footnote_ref_element = OxmlElement("w:footnoteRef") + if hasattr(footnote_ref_run, "_r"): + footnote_ref_run._r.append(footnote_ref_element) + else: + footnote_ref_run.append(footnote_ref_element) + + # Add a space and the footnote text with "Footnote Text" character style + text_run = footnote_paragraph.add_run(" " + footnote_content) + try: + text_run.style = "Footnote Text" + except KeyError: + missing_footnote_text = True + text_run.font.size = Pt(10) + + # If either style is missing, apply default formatting to the paragraph + if missing_footnote_text or missing_footnote_ref: + pf = footnote_paragraph.paragraph_format + pf.line_spacing = 1.0 + pf.space_before = 0 + for run in footnote_paragraph.runs: + run.font.size = Pt(10) + + # ...existing code... + def create_table(self, rows, cols, **kwargs): table = self.doc.add_table(rows=rows, cols=cols, style="Table Grid") table.autofit = True @@ -325,6 +420,8 @@ def tag_span(self, el, *, par, **kwargs): ref_name = el.attrs["data-gw-ref"] self.text_tracking.force_emit_pending_segment_break() self.make_cross_ref(par, ref_name) + elif "footnote" in el.attrs.get("class", []): + self.make_footnote(el, par=par, **kwargs) else: super().tag_span(el, par=par, **kwargs) @@ -503,17 +600,17 @@ def make_evidence(self, par, evidence): try: self._make_image(par, file_path) except UnrecognizedImageError as e: - logger.exception( - "Evidence file known as %s (%s) was not recognized as a %s file.", - evidence["friendly_name"], - file_path, - extension, - ) - error_msg = ( - f'The evidence file, `{evidence["friendly_name"]},` was not recognized as a {extension} file. ' - "Try opening it, exporting as desired type, and re-uploading it." - ) - raise ReportExportTemplateError(error_msg) from e + logger.exception( + "Evidence file known as %s (%s) was not recognized as a %s file.", + evidence["friendly_name"], + file_path, + extension, + ) + error_msg = ( + f'The evidence file, `{evidence["friendly_name"]},` was not recognized as a {extension} file. ' + "Try opening it, exporting as desired type, and re-uploading it." + ) + raise ReportExportTemplateError(error_msg) from e if self.global_report_config.figure_caption_location == "bottom": par_caption = self.doc.add_paragraph() diff --git a/ghostwriter/modules/reportwriter/richtext/pptx.py b/ghostwriter/modules/reportwriter/richtext/pptx.py index e5877f769..27a5a5fe6 100644 --- a/ghostwriter/modules/reportwriter/richtext/pptx.py +++ b/ghostwriter/modules/reportwriter/richtext/pptx.py @@ -63,6 +63,12 @@ def style_run(self, run, style): if "font_color" in style: run.font.color.rgb = PptxRGBColor(*style["font_color"]) + def tag_footnote(self, el, **kwargs): # pylint: disable=unused-argument + """ + Handle elements - PowerPoint doesn't support footnotes, + so we silently ignore them. + """ + def tag_br(self, el, *, par=None, **kwargs): self.text_tracking.new_block() if par is not None: @@ -187,6 +193,8 @@ def tag_span(self, el, *, par, **kwargs): run = par.add_run() run.text = f"See {ref_name}" run.font.italic = True + elif "footnote" in el.attrs.get("class", []): + self.tag_footnote(el, par=par, **kwargs) else: super().tag_span(el, par=par, **kwargs) diff --git a/ghostwriter/reporting/tests/test_footnotes.py b/ghostwriter/reporting/tests/test_footnotes.py new file mode 100644 index 000000000..55ea280c4 --- /dev/null +++ b/ghostwriter/reporting/tests/test_footnotes.py @@ -0,0 +1,300 @@ +"""Tests for footnote functionality using local python-docx fork.""" + +import os +import shutil +import tempfile + +from django.test import TestCase + +from docx import Document + +from ghostwriter.modules.reportwriter.richtext.docx import HtmlToDocx + + +class FootnoteCreationTests(TestCase): + """Test footnote creation with python-docx.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + """Clean up temporary files.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def _get_baseline_footnote_count(self): + """Get the number of footnotes in a blank document (separator footnotes).""" + doc = Document() + output_path = os.path.join(self.temp_dir, "baseline.docx") + doc.save(output_path) + reopened = Document(output_path) + return len(reopened.footnotes) + + def test_create_document_with_footnote(self): + """Test creating a Word document with a footnote.""" + document = Document() + + # Add a paragraph + paragraph = document.add_paragraph("This is a paragraph with a footnote") + + # Add a footnote to the paragraph + new_footnote = paragraph.add_footnote() + new_footnote.add_paragraph("This is the footnote text.") + + # Verify footnote was created + self.assertIsNotNone(new_footnote) + self.assertEqual(len(new_footnote.paragraphs), 1) + + # Save to test output for inspection + output_path = os.path.join(self.temp_dir, "test_footnote.docx") + document.save(output_path) + + # Verify the file was created + self.assertTrue(os.path.exists(output_path)) + + # Reopen and verify footnote exists + reopened_doc = Document(output_path) + self.assertIsNotNone(reopened_doc.footnotes) + self.assertGreater(len(reopened_doc.footnotes), 0) + + def test_create_multiple_footnotes(self): + """Test creating multiple footnotes in a document.""" + baseline_count = self._get_baseline_footnote_count() + + document = Document() + + # Add first paragraph with footnote + para1 = document.add_paragraph("First paragraph") + footnote1 = para1.add_footnote() + footnote1.add_paragraph("First footnote text.") + + # Add second paragraph with footnote + para2 = document.add_paragraph("Second paragraph") + footnote2 = para2.add_footnote() + footnote2.add_paragraph("Second footnote text.") + + # Verify both footnotes were created + self.assertIsNotNone(footnote1) + self.assertIsNotNone(footnote2) + + # Footnote IDs should be different + self.assertNotEqual(footnote1.id, footnote2.id) + + # Save to test output for inspection + output_path = os.path.join(self.temp_dir, "test_multiple_footnotes.docx") + document.save(output_path) + self.assertTrue(os.path.exists(output_path)) + + # Reopen and verify both footnotes exist (plus baseline separator footnotes) + reopened_doc = Document(output_path) + self.assertEqual(len(reopened_doc.footnotes), baseline_count + 2) + + def test_footnote_with_multiple_paragraphs(self): + """Test creating a footnote with multiple paragraphs.""" + document = Document() + + paragraph = document.add_paragraph("Paragraph with multi-paragraph footnote") + + # Add footnote with multiple paragraphs + footnote = paragraph.add_footnote() + footnote.add_paragraph("First paragraph in footnote.") + footnote.add_paragraph("Second paragraph in footnote.") + + # Verify footnote has multiple paragraphs + self.assertEqual(len(footnote.paragraphs), 2) + + # Save to test output for inspection + output_path = os.path.join(self.temp_dir, "test_multi_para_footnote.docx") + document.save(output_path) + self.assertTrue(os.path.exists(output_path)) + + def test_access_footnotes_from_paragraph(self): + """Test accessing footnotes from a paragraph.""" + document = Document() + + # Create a paragraph with a footnote + paragraph = document.add_paragraph("Text with footnote reference") + new_footnote = paragraph.add_footnote() + new_footnote.add_paragraph("The footnote content.") + + # Save to test output for inspection + output_path = os.path.join(self.temp_dir, "test_access_footnotes.docx") + document.save(output_path) + + reopened_doc = Document(output_path) + reopened_para = reopened_doc.paragraphs[0] + + # Access footnotes from paragraph + footnotes = reopened_para.footnotes + self.assertIsNotNone(footnotes) + self.assertGreater(len(footnotes), 0) + + # Verify footnote properties + footnote = footnotes[0] + self.assertIsNotNone(footnote.id) + self.assertGreater(len(footnote.paragraphs), 0) + + def test_access_all_document_footnotes(self): + """Test accessing all footnotes from document.""" + baseline_count = self._get_baseline_footnote_count() + + document = Document() + + # Create multiple paragraphs with footnotes + num_footnotes = 3 + for i in range(num_footnotes): + para = document.add_paragraph(f"Paragraph {i + 1}") + footnote = para.add_footnote() + footnote.add_paragraph(f"Footnote {i + 1} content.") + + # Save to test output for inspection + output_path = os.path.join(self.temp_dir, "test_all_footnotes.docx") + document.save(output_path) + + reopened_doc = Document(output_path) + + # Access all footnotes (includes baseline separator footnotes) + all_footnotes = reopened_doc.footnotes + self.assertEqual(len(all_footnotes), baseline_count + num_footnotes) + + # Verify each footnote has an ID and paragraphs + for footnote in all_footnotes: + self.assertIsNotNone(footnote.id) + self.assertGreaterEqual(len(footnote.paragraphs), 0) + + def test_footnote_ids_are_sequential(self): + """Test that footnote IDs are assigned sequentially.""" + document = Document() + + footnote_ids = [] + for i in range(3): + para = document.add_paragraph(f"Paragraph {i + 1}") + footnote = para.add_footnote() + footnote.add_paragraph(f"Footnote {i + 1}") + footnote_ids.append(footnote.id) + + # Save to test output for inspection + output_path = os.path.join(self.temp_dir, "test_sequential_footnotes.docx") + document.save(output_path) + + # Verify IDs are unique and sequential + self.assertEqual(len(footnote_ids), len(set(footnote_ids))) + for i in range(1, len(footnote_ids)): + self.assertGreater(footnote_ids[i], footnote_ids[i - 1]) + + +class FootnoteRichTextConversionTests(TestCase): + """Test footnote conversion from HTML rich text to DOCX.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + """Clean up temporary files.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_html_to_docx_with_footnotes(self): + """ + Test converting HTML with footnotes to DOCX using HtmlToDocx. + """ + # Create a new document + doc = Document() + + # Sample HTML content with footnotes (simulating rich text from the editor) + html_content = """ +

This is an executive summary with important findings.Source: Internal Security Assessment, 2024

+ +

During the assessment, several critical vulnerabilities were identified.See Appendix A for full vulnerability details. The team discovered issues in the authentication systemAuthentication bypass via SQL injection in login form. and the session management module.

+ +

Key Findings

+ +

The following critical issues require immediate attention:

+ +
    +
  • SQL Injection vulnerability in user loginCVE-2024-1234 - CVSS Score 9.8
  • +
  • Cross-Site Scripting (XSS) in comment fields
  • +
  • Insecure Direct Object ReferencesAllows unauthorized access to other users' data.
  • +
+ +

We recommend prioritizing remediation based on risk severity.Risk ratings based on CVSS v3.1 scoring methodology.

+ +

Recommendations

+ +

Implement input validation and parameterized queriesOWASP recommends using prepared statements for all database queries. to prevent injection attacks. Additionally, deploy a Web Application Firewall (WAF)Consider solutions like ModSecurity or cloud-based WAF services. for defense in depth.

+ """ + + # Run the HTML to DOCX converter + HtmlToDocx.run(html_content, doc, None) + + # Save to temp file and reopen to verify footnotes + output_path = os.path.join(self.temp_dir, "test_footnotes.docx") + doc.save(output_path) + + # Reopen and verify footnotes exist + reopened_doc = Document(output_path) + self.assertIsNotNone(reopened_doc.footnotes) + + # Count actual footnotes (excluding separator footnotes which have id <= 0) + actual_footnotes = [fn for fn in reopened_doc.footnotes if fn.id > 0] + self.assertEqual(len(actual_footnotes), 8, "Expected 8 footnotes in the document") + + # Verify footnote content (strip leading space from footnoteRef marker) + footnote_texts = [ + fn.paragraphs[0].text.strip() if fn.paragraphs else "" + for fn in actual_footnotes + ] + self.assertIn("Source: Internal Security Assessment, 2024", footnote_texts) + self.assertIn("CVE-2024-1234 - CVSS Score 9.8", footnote_texts) + self.assertIn( + "OWASP recommends using prepared statements for all database queries.", + footnote_texts, + ) + + def test_html_to_docx_footnotes_in_table(self): + """Test footnotes within table cells.""" + doc = Document() + + # HTML with a table containing footnotes + html_content = """ +

Summary of Findings:

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
FindingSeverityStatus
SQL InjectionFound in login and search forms.CriticalOpen
XSS VulnerabilityReflected XSS in query parameters.HighIn Progress
Missing CSRF TokenState-changing operations lack protection.MediumFixed
+ +

See the detailed findings section for remediation steps.Remediation guidance follows OWASP best practices.

+ """ + + HtmlToDocx.run(html_content, doc, None) + + # Save to temp file and reopen to verify footnotes + output_path = os.path.join(self.temp_dir, "test_table_footnotes.docx") + doc.save(output_path) + + reopened_doc = Document(output_path) + actual_footnotes = [fn for fn in reopened_doc.footnotes if fn.id > 0] + self.assertEqual(len(actual_footnotes), 4, "Expected 4 footnotes in the document") diff --git a/ghostwriter/reporting/tests/test_rich_text_docx.py b/ghostwriter/reporting/tests/test_rich_text_docx.py index c938035f4..6d281bb37 100644 --- a/ghostwriter/reporting/tests/test_rich_text_docx.py +++ b/ghostwriter/reporting/tests/test_rich_text_docx.py @@ -673,3 +673,135 @@ class RichTextToDocxTests(TestCase): "

Hello World!

This is a test!

", """Hello World!This is a test!""", ) + + +class FootnoteToDocxTests(TestCase): + """Tests for footnote HTML to DOCX conversion.""" + + maxDiff = None + + def test_footnote_creates_footnote_in_document(self): + """Test that elements create Word footnotes.""" + html = '

Text with a footnoteThis is the footnote content. and more text.

' + doc = docx.Document() + HtmlToDocx.run(html, doc, None) + + out = BytesIO() + doc.save(out) + + # Check that footnotes.xml exists and contains the footnote + with ZipFile(out) as zip: + self.assertIn("word/footnotes.xml", zip.namelist()) + with zip.open("word/footnotes.xml") as file: + contents = file.read().decode("utf-8") + self.assertIn("This is the footnote content.", contents) + + def test_multiple_footnotes(self): + """Test that multiple footnotes are created correctly.""" + html = """ +

First footnoteFootnote one. and + second footnoteFootnote two. in same paragraph.

+ """ + doc = docx.Document() + HtmlToDocx.run(html, doc, None) + + out = BytesIO() + doc.save(out) + + with ZipFile(out) as zip: + with zip.open("word/footnotes.xml") as file: + contents = file.read().decode("utf-8") + self.assertIn("Footnote one.", contents) + self.assertIn("Footnote two.", contents) + + def test_footnote_with_formatted_content(self): + """Test that footnotes preserve basic text content.""" + html = '

TextFootnote with content.

' + doc = docx.Document() + HtmlToDocx.run(html, doc, None) + + out = BytesIO() + doc.save(out) + + with ZipFile(out) as zip: + with zip.open("word/footnotes.xml") as file: + contents = file.read().decode("utf-8") + self.assertIn("Footnote with content.", contents) + + def test_footnote_reference_in_document(self): + """Test that footnote reference is inserted in the document.""" + html = '

Text with footnoteThe footnote. here.

' + doc = docx.Document() + HtmlToDocx.run(html, doc, None) + + out = BytesIO() + doc.save(out) + + with ZipFile(out) as zip: + with zip.open("word/document.xml") as file: + contents = file.read().decode("utf-8") + # Should contain a footnote reference element + self.assertIn("footnoteReference", contents) + + def test_footnote_numbering_with_out_of_order_insertion(self): + """ + Test that footnotes renumber correctly when inserted out of order. + + Simulates editing scenario where a document is built incrementally + and a new footnote is inserted before existing ones. + """ + # Create document with two footnotes first + doc = docx.Document() + + # Add paragraphs 2 and 3 with footnotes (simulating original document) + html_initial = """ +

Second paragraphFootnote A (added first).

+

Third paragraphFootnote B (added second).

+ """ + HtmlToDocx.run(html_initial, doc, None) + + # Now simulate editing: insert a paragraph with footnote at the beginning + # This mimics the real-world scenario where user edits the TipTap editor + # Note: In reality, this would involve re-rendering the entire document + # but python-docx processes HTML sequentially, so we need to simulate + # the incremental addition that causes the issue + + # For this test, let's verify that a full re-render produces sequential IDs + doc2 = docx.Document() + html_edited = """ +

First paragraphFootnote C (added later).

+

Second paragraphFootnote A (added first).

+

Third paragraphFootnote B (added second).

+ """ + HtmlToDocx.run(html_edited, doc2, None) + + out = BytesIO() + doc2.save(out) + + with ZipFile(out) as zip: + with zip.open("word/document.xml") as file: + doc_xml = file.read().decode("utf-8") + + # Extract footnote reference IDs in document order + import re + + refs = re.findall(r']*w:id="(\d+)"', doc_xml) + + # Should be sequential: 1, 2, 3 (not 3, 1, 2 or 1, 1, 2) + self.assertEqual( + refs, + ["1", "2", "3"], + f"Footnote IDs should be sequential in document order, got {refs}", + ) + + with zip.open("word/footnotes.xml") as file: + footnotes_xml = file.read().decode("utf-8") + + # Verify footnote IDs match document references + import re + + footnote_ids = re.findall(r']*w:id="(\d+)"', footnotes_xml) + # Filter out separators (-1, 0) + footnote_ids = [fid for fid in footnote_ids if int(fid) > 0] + self.assertEqual(sorted(footnote_ids), ["1", "2", "3"]) + diff --git a/ghostwriter/reporting/tests/test_rich_text_pptx.py b/ghostwriter/reporting/tests/test_rich_text_pptx.py index a0c76d623..5dd4a36f2 100644 --- a/ghostwriter/reporting/tests/test_rich_text_pptx.py +++ b/ghostwriter/reporting/tests/test_rich_text_pptx.py @@ -557,3 +557,27 @@ class RichTextToPptxTests(TestCase): """, add_suffix=False, ) + + # Footnotes are not supported in PowerPoint - they should be silently ignored + test_footnote_ignored = mk_test_pptx( + "test_footnote_ignored", + "

Text with footnoteThis footnote should be ignored. continues here.

", + """ + + Text with footnote + continues here. + + """, + ) + + test_multiple_footnotes_ignored = mk_test_pptx( + "test_multiple_footnotes_ignored", + "

FirstNote 1 and secondNote 2 footnotes.

", + """ + + First + and second + footnotes. + + """, + ) diff --git a/ghostwriter/rolodex/forms_client.py b/ghostwriter/rolodex/forms_client.py index b818ff37f..638cc2430 100644 --- a/ghostwriter/rolodex/forms_client.py +++ b/ghostwriter/rolodex/forms_client.py @@ -138,7 +138,7 @@ def __init__(self, *args, **kwargs): Button( "formset-del-button", "Delete Contact", - css_class="btn-outline-danger formset-del-button col-4", + css_class="btn-outline-danger formset-del-button col-8", ), css_class="form-group col-6 offset-3", ), @@ -215,7 +215,7 @@ def __init__(self, *args, **kwargs): Button( "formset-del-button", "Delete Invite", - css_class="btn-outline-danger formset-del-button col-4", + css_class="btn-outline-danger formset-del-button col-8", ), css_class="form-group col-6 offset-3", ), diff --git a/ghostwriter/rolodex/forms_project.py b/ghostwriter/rolodex/forms_project.py index 67ee64a4f..3a10b379b 100644 --- a/ghostwriter/rolodex/forms_project.py +++ b/ghostwriter/rolodex/forms_project.py @@ -551,7 +551,7 @@ def __init__(self, *args, **kwargs): Button( "formset-del-button", "Delete Assignment", - css_class="btn-outline-danger formset-del-button col-4", + css_class="btn-outline-danger formset-del-button col-8", ), css_class="form-group col-6 offset-md-3", ), @@ -673,7 +673,7 @@ def __init__(self, *args, **kwargs): Button( "formset-del-button", "Delete Objective", - css_class="btn-outline-danger formset-del-button col-4", + css_class="btn-outline-danger formset-del-button col-8", ), css_class="form-group col-6 offset-3", ), @@ -767,7 +767,7 @@ def __init__(self, *args, **kwargs): Button( "formset-del-button", "Delete List", - css_class="btn-outline-danger formset-del-button col-4", + css_class="btn-outline-danger formset-del-button col-8", ), css_class="form-group col-6 offset-3", ), @@ -850,7 +850,7 @@ def __init__(self, *args, **kwargs): Button( "formset-del-button", "Delete Target", - css_class="btn-outline-danger formset-del-button col-4", + css_class="btn-outline-danger formset-del-button col-8", ), css_class="form-group col-6 offset-3", ), @@ -946,7 +946,7 @@ def __init__(self, *args, **kwargs): Button( "formset-del-button", "Delete White Card", - css_class="btn-outline-danger formset-del-button col-5", + css_class="btn-outline-danger formset-del-button col-8", ), css_class="form-group col-6 offset-3", ), @@ -1039,7 +1039,7 @@ def __init__(self, *args, **kwargs): Button( "formset-del-button", "Delete Contact", - css_class="btn-outline-danger formset-del-button col-4", + css_class="btn-outline-danger formset-del-button col-8", ), css_class="form-group col-6 offset-3", ), @@ -1104,7 +1104,7 @@ def __init__(self, *args, **kwargs): Button( "formset-del-button", "Delete Invite", - css_class="btn-outline-danger formset-del-button col-4", + css_class="btn-outline-danger formset-del-button col-8", ), css_class="form-group col-6 offset-3", ), diff --git a/ghostwriter/rolodex/templates/rolodex/client_form.html b/ghostwriter/rolodex/templates/rolodex/client_form.html index 513398deb..3326f244c 100644 --- a/ghostwriter/rolodex/templates/rolodex/client_form.html +++ b/ghostwriter/rolodex/templates/rolodex/client_form.html @@ -53,7 +53,7 @@ let new_form = $('#formset-{{ contacts.prefix }}').find('div.formset-container').last() new_form.find('span.counter').html(parseInt(form_idx) + 1); new_form.fadeOut(100).fadeIn(100).fadeOut(100).fadeIn(100); - let target = 'id_{{ contacts.prefix }}-' + parseInt(form_idx) + '-note' + let target = 'id_{{ contacts.prefix }}-' + parseInt(form_idx) + '-description' tinymce.execCommand('mceAddEditor', false, target); }); diff --git a/ghostwriter/rolodex/templates/rolodex/project_form.html b/ghostwriter/rolodex/templates/rolodex/project_form.html index 8de1f85ec..f6534cf8e 100644 --- a/ghostwriter/rolodex/templates/rolodex/project_form.html +++ b/ghostwriter/rolodex/templates/rolodex/project_form.html @@ -95,7 +95,7 @@ let new_form = $('#formset-{{ assignments.prefix }}').find('div.formset-container').last() new_form.find('span.counter').html(parseInt(form_idx) + 1); new_form.fadeOut(100).fadeIn(100).fadeOut(100).fadeIn(100); - let target = 'id_{{ assignments.prefix }}-' + parseInt(form_idx) + '-note' + let target = 'id_{{ assignments.prefix }}-' + parseInt(form_idx) + '-description' tinymce.execCommand('mceAddEditor', false, target); }); @@ -128,7 +128,7 @@ let new_form = $('#formset-{{ targets.prefix }}').find('div.formset-container').last() new_form.find('span.counter').html(parseInt(form_idx) + 1); new_form.fadeOut(100).fadeIn(100).fadeOut(100).fadeIn(100); - let target = 'id_{{ targets.prefix }}-' + parseInt(form_idx) + '-note' + let target = 'id_{{ targets.prefix }}-' + parseInt(form_idx) + '-description' tinymce.execCommand('mceAddEditor', false, target); }); diff --git a/ghostwriter/shepherd/forms_server.py b/ghostwriter/shepherd/forms_server.py index a43557e3a..7e6c8f371 100644 --- a/ghostwriter/shepherd/forms_server.py +++ b/ghostwriter/shepherd/forms_server.py @@ -164,7 +164,7 @@ def __init__(self, *args, **kwargs): Button( "formset-del-button", "Delete Address", - css_class="btn-outline-danger formset-del-button col-4", + css_class="btn-outline-danger formset-del-button col-8", ), css_class="form-group col-6 offset-3", ), diff --git a/ghostwriter/static/css/base_styles.css b/ghostwriter/static/css/base_styles.css index b6ab954fc..22b86dcbe 100644 --- a/ghostwriter/static/css/base_styles.css +++ b/ghostwriter/static/css/base_styles.css @@ -107,7 +107,7 @@ --secondary-color-fade: #9AA5E8; --secondary-color-med: #A893E0; --secondary-color-light: #3A3D5E; - --neutral-color: #9a9a9a; + --neutral-color: #272727; --blocked-color: #FF7E79; --warning-color: #F4B083; --ghost-white: #2A2B3D; diff --git a/ghostwriter/static/css/styles.css b/ghostwriter/static/css/styles.css index 183350fed..d80f9bd42 100644 --- a/ghostwriter/static/css/styles.css +++ b/ghostwriter/static/css/styles.css @@ -3902,10 +3902,12 @@ input[type="color"]:focus, [data-theme="dark"] .nav-tabs .nav-link { color: var(--main-txt-color); + border: 0.02em solid var(--search-box-shadow); } [data-theme="dark"] .nav-tabs .nav-link:hover { border-color: var(--search-box-shadow) var(--search-box-shadow) var(--main-bg-color); + border: 0.02em solid var(--sidebar-text-color); } [data-theme="dark"] .nav-tabs .nav-link.active { @@ -3968,7 +3970,7 @@ input[type="color"]:focus, /* Bootstrap tabs - inactive tabs styling */ [data-theme="dark"] .nav-tabs .nav-link:not(.active) { background-color: var(--neutral-color); - color: var(--main-bg-color); + color: var(--main-txt-color); } /* jQuery autocomplete dropdown */ @@ -4448,6 +4450,22 @@ input:checked + .theme-slider .star { margin: 0; } +/* --------------------------------------------------- + Footnote styling for rich text previews +-----------------------------------------------------*/ +span.footnote { + vertical-align: super; + font-size: 0.75em; + color: #0066cc; + cursor: help; + font-weight: 500; +} + +span.footnote:hover { + color: #0052a3; + text-decoration: underline; +} + /* --------------------------------------------------- Evidence lightbox modal styles ----------------------------------------------------- */ diff --git a/ghostwriter/static/images/amazed.png b/ghostwriter/static/images/amazed.png new file mode 100644 index 000000000..f8f93a69d Binary files /dev/null and b/ghostwriter/static/images/amazed.png differ diff --git a/ghostwriter/static/images/scared.png b/ghostwriter/static/images/scared.png new file mode 100644 index 000000000..7bb29471a Binary files /dev/null and b/ghostwriter/static/images/scared.png differ diff --git a/ghostwriter/templates/400.html b/ghostwriter/templates/400.html index f6dc07f78..a3a3202ab 100644 --- a/ghostwriter/templates/400.html +++ b/ghostwriter/templates/400.html @@ -1,7 +1,17 @@ {% extends "base_generic.html" %} +{% load static %} + {% block title %}Bad Request (400){% endblock %} {% block content %}

Bad Request (400)

+ + 400 Ghost Image + +

Something went wrong with your request. Feel free to try it again.

+ +

You weren't trying to break me, were you?

+ + Return to Home {% endblock content %} diff --git a/ghostwriter/templates/403.html b/ghostwriter/templates/403.html index ed84405d7..1e9e6e066 100644 --- a/ghostwriter/templates/403.html +++ b/ghostwriter/templates/403.html @@ -1,7 +1,24 @@ {% extends "base_generic.html" %} +{% load static %} + {% block title %}Forbidden (403){% endblock %} +{% block breadcrumbs %} + +{% endblock %} + {% block content %}

Forbidden (403)

+ + 403 Ghost Image + +

Uh oh, you aren't supposed to be here.

+ +

You weren't trying to access something restricted, were you?

{% endblock content %} diff --git a/ghostwriter/templates/404.html b/ghostwriter/templates/404.html index a50e3ecc6..4c845df54 100644 --- a/ghostwriter/templates/404.html +++ b/ghostwriter/templates/404.html @@ -1,9 +1,22 @@ {% extends "base_generic.html" %} -{% block title %}Page Not Found{% endblock %} +{% load static %} + +{% block title %}Page Not Found (404){% endblock %} + +{% block breadcrumbs %} + +{% endblock %} {% block content %} -

Page Not Found

+

Page Not Found (404)

+ + 404 Ghost Image -

This is not the page you were looking for...

+

I couldn't find the page you were looking for. Benny is very sorry.

{% endblock content %} diff --git a/ghostwriter/templates/500.html b/ghostwriter/templates/500.html index 939d176d9..ed2dba641 100644 --- a/ghostwriter/templates/500.html +++ b/ghostwriter/templates/500.html @@ -1,13 +1,25 @@ {% extends "base_generic.html" %} -{% block title %}Server Error{% endblock %} +{% load static %} + +{% block title %}Server Error (500){% endblock %} {% block content %} -

Ooops!!! 500

+

Server Error (500)

+ + 500 Ghost Image + +

This is so embarassing! Something went wrong on our end.

-

Looks like something went wrong!

+ -

We track these errors automatically, but if the problem persists feel free to contact us. In the meantime, try refreshing.

+ Return to Home {% endblock content %} diff --git a/javascript/src/frontend/collab_forms/rich_text_editor/footnote.tsx b/javascript/src/frontend/collab_forms/rich_text_editor/footnote.tsx new file mode 100644 index 000000000..57e736c06 --- /dev/null +++ b/javascript/src/frontend/collab_forms/rich_text_editor/footnote.tsx @@ -0,0 +1,96 @@ +import { useId, useState } from "react"; +import ReactModal from "react-modal"; +import { Editor, useEditorState } from "@tiptap/react"; +import { MenuItem } from "@szhsin/react-menu"; + +export default function FootnoteButton({ editor }: { editor: Editor }) { + const [modalOpen, setModalOpen] = useState(false); + const [footnoteContent, setFootnoteContent] = useState(""); + const fieldId = useId(); + + const enabled = useEditorState({ + editor, + selector: ({ editor }) => editor.can().insertFootnote({ content: "" }), + }); + + return ( + <> + { + setFootnoteContent(""); + setModalOpen(true); + }} + > + Insert Footnote + + setModalOpen(false)} + contentLabel="Insert Footnote" + className="modal-dialog modal-dialog-centered" + > +
+
+
Insert Footnote
+
+
{ + ev.preventDefault(); + const content = footnoteContent.trim(); + if (content) { + editor + .chain() + .focus() + .insertFootnote({ content }) + .run(); + } + setModalOpen(false); + }} + > +
+ +