diff --git a/airlock/renderers.py b/airlock/renderers.py index 8faa73cc..60407a90 100644 --- a/airlock/renderers.py +++ b/airlock/renderers.py @@ -2,6 +2,7 @@ import csv import mimetypes +import re from dataclasses import dataclass from email.utils import formatdate from functools import cached_property @@ -9,9 +10,11 @@ from pathlib import Path from typing import IO, Any, ClassVar, Self, cast +from ansi2html import Ansi2HTMLConverter from django.http import FileResponse, HttpResponseBase from django.template import Template, loader from django.template.response import SimpleTemplateResponse +from django.utils.safestring import mark_safe from airlock.types import UrlPath from airlock.utils import is_valid_file_type @@ -158,9 +161,35 @@ def context(self): } +class LogRenderer(TextRenderer): + def context(self): + # Convert the text of the log file to HTML, converting ANSI colour codes to css classes + # so we get the colour formatting from the original log. + # We don't need the full HTML file that's produced, so just extract the

+        # tag which contains the log content and the inline styles.
+        conv = Ansi2HTMLConverter()
+        text = conv.convert(self.stream.read())
+        match = re.match(
+            r".*(?P).*(?P).*",
+            text,
+            flags=re.S,
+        )
+        if match:  # pragma: no branch
+            # After conversion, we should always find a match. As a precaution, check
+            # and render the plain text if we don't.
+            style_tag = match.group("style")
+            pre_tag = match.group("pre_tag")
+            text = mark_safe(f"{style_tag}{pre_tag}")
+
+        return {
+            "text": text,
+            "class": Path(self.filename).suffix.lstrip("."),
+        }
+
+
 FILE_RENDERERS = {
     ".csv": CSVRenderer,
-    ".log": TextRenderer,
+    ".log": LogRenderer,
     ".txt": TextRenderer,
     ".json": TextRenderer,
     ".md": TextRenderer,
diff --git a/requirements.prod.in b/requirements.prod.in
index 80a47b1f..c092513a 100644
--- a/requirements.prod.in
+++ b/requirements.prod.in
@@ -1,3 +1,4 @@
+ansi2html
 Django
 django-vite
 slippers
diff --git a/requirements.prod.txt b/requirements.prod.txt
index 6935e226..9fede944 100644
--- a/requirements.prod.txt
+++ b/requirements.prod.txt
@@ -4,6 +4,10 @@
 #
 #    pip-compile --allow-unsafe --generate-hashes --strip-extras requirements.prod.in
 #
+ansi2html==1.9.2 \
+    --hash=sha256:3453bf87535d37b827b05245faaa756dbab4ec3d69925e352b6319c3c955c0a5 \
+    --hash=sha256:dccb75aa95fb018e5d299be2b45f802952377abfdce0504c17a6ee6ef0a420c5
+    # via -r requirements.prod.in
 asgiref==3.8.1 \
     --hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \
     --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590
diff --git a/tests/unit/test_renderers.py b/tests/unit/test_renderers.py
index 0d813e06..e4c7dcb4 100644
--- a/tests/unit/test_renderers.py
+++ b/tests/unit/test_renderers.py
@@ -12,10 +12,12 @@
     (".png", "image/png", False, None),
     (".csv", "text/html", False, "airlock/templates/file_browser/csv.html"),
     (".txt", "text/html", False, "airlock/templates/file_browser/text.html"),
+    (".log", "text/html", False, "airlock/templates/file_browser/text.html"),
     (".html", "text/html", True, "airlock/templates/file_browser/plaintext.html"),
     (".png", "text/html", True, "airlock/templates/file_browser/plaintext.html"),
     (".csv", "text/html", True, "airlock/templates/file_browser/plaintext.html"),
     (".txt", "text/html", True, "airlock/templates/file_browser/plaintext.html"),
+    (".log", "text/html", True, "airlock/templates/file_browser/plaintext.html"),
 ]
 
 
@@ -145,3 +147,27 @@ def test_plaintext_renderer_handles_invalid_utf8(tmp_path):
     response.render()
     assert response.status_code == 200
     assert "invalid � continuation byte" in response.rendered_content
+
+
+def test_log_renderer_handles_ansi_colors(tmp_path):
+    log_file = tmp_path / "test.log"
+    # in ansi codes:
+    # \x1B[32m = foregrouund green
+    # \x1b[1m bold
+    # \x1b[0m resets formatting
+    log_file.write_bytes(
+        b"No ansi here \x1b[32m\x1b[1mThis is green and bold.\x1b[0m This is not."
+    )
+    relpath = log_file.relative_to(tmp_path)
+    Renderer = renderers.get_renderer(relpath)
+    renderer = Renderer.from_file(log_file, relpath)
+    response = renderer.get_response()
+    response.render()
+    assert response.status_code == 200
+
+    assert "ansi1 { font-weight: bold; }" in response.rendered_content
+    assert "ansi32 { color: #00aa00; }" in response.rendered_content
+    assert (
+        'This is green and bold.'
+        in response.rendered_content
+    )