Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show all file content in iframes #172

Merged
merged 5 commits into from
Mar 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions airlock/file_browser_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,6 @@ class PathNotFound(Exception):
# but this allow it to be overridden.
display_text: str = None

DISPLAY_TYPES = {
"html": "iframe",
"jpeg": "image",
"jpg": "image",
"png": "image",
"svg": "image",
"csv": "table",
"tsv": "table",
"txt": "preformatted",
"log": "preformatted",
}

def __post_init__(self):
# ensure is UrlPath
self.relpath = UrlPath(self.relpath)
Expand Down Expand Up @@ -121,9 +109,6 @@ def suffix(self):
def file_type(self):
return self.suffix().lstrip(".")

def display_type(self):
return self.DISPLAY_TYPES.get(self.file_type(), "preformatted")

def breadcrumbs(self):
item = self
crumbs = [item]
Expand Down
95 changes: 41 additions & 54 deletions airlock/templates/file_browser/contents.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,66 +21,53 @@

{% else %}
{% fragment as add_button %}
{% if context == "workspace" %}
{% if form %}
{% #modal id="addRequestFile" button_text="Add File to Request" variant="success" %}
{% #card container=True title="Add a file" %}
<form action="{{ request_file_url }}" method="POST" aria-label="add-file-form">
{% csrf_token %}
{% form_select class="w-full max-w-lg mx-auto" label="Select a file group" field=form.filegroup choices=form.filegroup.field.choices %}
{% form_input class="w-full max-w-lg mx-auto" label="Or create a new file group" field=form.new_filegroup %}
<input type=hidden name="path" value="{{ path_item.relpath }}"/>
<div class="mt-2">
{% #button type="submit" variant="success" id="add-file-button" %}Add File to Request{% /button %}
{% #button variant="danger" type="cancel" %}Cancel{% /button %}
</div>
</form>
{% /card %}
{% /modal %}
{% elif file_in_request %}
{% #button type="button" disabled=True tooltip="This file has already been added to the current request" id="add-file-modal-button-disabled" %}
Add File to Request
{% /button %}
{% else %}
{% #button type="button" disabled=True tooltip="You do not have permission to add this file to a request" id="add-file-modal-button-disabled" %}
Add File to Request
{% /button %}
<div class="flex items-center gap-2">
{% if context == "workspace" %}
{% if form %}
{% #modal id="addRequestFile" button_text="Add File to Request" variant="success" %}
{% #card container=True title="Add a file" %}
<form action="{{ request_file_url }}" method="POST" aria-label="add-file-form">
{% csrf_token %}
{% form_select class="w-full max-w-lg mx-auto" label="Select a file group" field=form.filegroup choices=form.filegroup.field.choices %}
{% form_input class="w-full max-w-lg mx-auto" label="Or create a new file group" field=form.new_filegroup %}
<input type=hidden name="path" value="{{ path_item.relpath }}"/>
<div class="mt-2">
{% #button type="submit" variant="success" id="add-file-button" %}Add File to Request{% /button %}
{% #button variant="danger" type="cancel" %}Cancel{% /button %}
</div>
</form>
{% /card %}
{% /modal %}
{% elif file_in_request %}
{% #button type="button" disabled=True tooltip="This file has already been added to the current request" id="add-file-modal-button-disabled" %}
Add File to Request
{% /button %}
{% else %}
{% #button type="button" disabled=True tooltip="You do not have permission to add this file to a request" id="add-file-modal-button-disabled" %}
Add File to Request
{% /button %}
{% endif %}
{% elif is_author %}
<form action="" method="POST">
{% csrf_token %}
{% #button type="submit" tooltip="Remove this file from this request" variant="warning" %}Remove File from Request{% /button %}
</form>
{% elif is_output_checker %}
{% #button variant="primary" type="link" href=path_item.download_url id="download-button" %}Download file{% /button %}
{% endif %}
{% elif is_author %}
<form action="" method="POST">
{% csrf_token %}
{% #button type="submit" tooltip="Remove this file from this request" variant="warning" %}Remove File from Request{% /button %}
</form>
{% elif is_output_checker %}
{% #button variant="primary" type="link" href=path_item.download_url id="download-button" %}Download file{% /button %}
{% endif %}
{% #button variant="primary" type="link" href=path_item.contents_url external=True id="view-button" %}View ↗{% /button %}
</div>
{% endfragment %}

{% #card title=path_item.name container=True custom_button=add_button %}

<div class="content">
{% if path_item.display_type == "iframe" %}
<iframe
src="{{ path_item.contents_url }}"
title="{{ path_item.relpath }}"
frameborder=0
height=1000
style="width: 100%;"
>
</iframe>
{% elif path_item.display_type == "image" %}
<div class="content-scroller">
<img src="{{ path_item.contents_url }}">
</div>
{% elif path_item.display_type == "table" %}
<div class="content-scroller">
{% include "file_browser/csv.html" with contents=path_item.contents %}
</div>
{% else %}
<div class="content-scroller">
<pre>{{ path_item.contents }}</pre>
</div>
{% endif %}
<iframe src="{{ path_item.contents_url }}"
title="{{ path_item.relpath }}"
frameborder=0
height=1000
style="width: 100%;"
/>
</div>
{% /card %}

Expand Down
23 changes: 17 additions & 6 deletions airlock/templates/file_browser/csv.html
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
{% load airlocktags %}
{% as_csv_data contents as csv_data %}
<div id="csvtable" class="inline-block min-w-full align-middle max-w-full">
<style>
.datatable thead {
position: sticky;
top: 0;
text-align: left;
background-color: rgba(248,250,252);
}

.datatable tbody tr:nth-child(even) {
background-color: rgba(248,250,252);
}
</style>

<div id="csvtable">
<p class="spinner">Loading table data...</p>
<table class="datatable" style="display: none">
<thead class="bg-slate-200" id="csvTable">
<thead>
<tr>
{% for header in csv_data.headers %}
{% for header in headers %}
<th>{{ header }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in csv_data.rows %}
{% for row in rows %}
<tr>
{% for cell in row %}
<td>{{ cell }}</td>
Expand Down
11 changes: 0 additions & 11 deletions airlock/templates/file_browser/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,6 @@
overflow: scroll;
}

.datatable thead {
position: sticky;
top: 0;
text-align: left;
}

.datatable tbody tr:nth-child(even) {
background-color: rgba(248,250,252);
}


</style>
{% endblock extra_styles %}

Expand Down
3 changes: 3 additions & 0 deletions airlock/templates/file_browser/text.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<pre class="{{ class }}">
{{ text }}
</pre>
Empty file removed airlock/templatetags/__init__.py
Empty file.
15 changes: 0 additions & 15 deletions airlock/templatetags/airlocktags.py

This file was deleted.

127 changes: 112 additions & 15 deletions airlock/views/helpers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
from email.utils import formatdate
import csv
import functools
from email.utils import formatdate, parsedate
from pathlib import Path

from django.core.exceptions import PermissionDenied
from django.http import FileResponse, Http404
from django.http import FileResponse, Http404, HttpResponseNotModified
from django.template import loader
from django.template.response import SimpleTemplateResponse

from airlock.business_logic import bll
from airlock.business_logic import UrlPath, bll


class ServeFileException(Exception):
pass


def login_exempt(view):
Expand Down Expand Up @@ -35,16 +44,104 @@ def get_release_request_or_raise(user, request_id):
return release_request


def serve_file(abspath, download=False, filename=None):
stat = abspath.stat()
# use same ETag format as whitenoise
headers = {
"Last-Modified": formatdate(stat.st_mtime, usegmt=True),
"ETag": f'"{int(stat.st_mtime):x}-{stat.st_size:x}"',
def download_file(abspath, filename=None):
"""Simple Helper to download file."""
return FileResponse(abspath.open("rb"), as_attachment=True, filename=filename)


def render_with_template(template):
"""Micro framework for rendering content.

The main purpose is to be able to check to see if the template has changed
ahead of calling the render function and doing the work.

It loads the template path used, and stores it on the wrapper function
object for later inspection.
"""
django_template = loader.get_template(template)
template_path = Path(django_template.template.origin.name)

def decorator(func):
@functools.wraps(func)
def wrapper(abspath, suffix):
context = func(abspath, suffix)
return SimpleTemplateResponse(template, context)

wrapper.template_path = template_path

return wrapper

return decorator

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat


@render_with_template("file_browser/csv.html")
def csv_renderer(abspath, suffix):
reader = csv.reader(abspath.open())
headers = next(reader)
return {"headers": headers, "rows": reader}


@render_with_template("file_browser/text.html")
def text_renderer(abspath, suffix):
return {
"text": abspath.read_text(),
"class": suffix.lstrip("."),
}
return FileResponse(
abspath.open("rb"),
headers=headers,
as_attachment=download,
filename=filename,
)


FILE_RENDERERS = {
".csv": csv_renderer,
".log": text_renderer,
".txt": text_renderer,
".json": text_renderer,
}


def build_etag(content_stat, template=None):
# Like whitenoise, use filesystem metadata rather than hash as its faster
etag = f"{int(content_stat.st_mtime):x}-{content_stat.st_size:x}"
if template:
# add the renderer's template etag so cache is invalidated if we change it
template_stat = Path(template).stat()
etag = f"{etag}-{int(template_stat.st_mtime):x}-{template_stat.st_size:x}"

# quote as per spec
return f'"{etag}"'


def serve_file(request, abspath, filename=None):
"""Serve file contents in a form the browser can render.

For html and images, just serve directly.

For csv and text, render that to html then serve.
"""
if filename:
suffix = UrlPath(filename).suffix
else:
suffix = abspath.suffix

if not suffix:
raise ServeFileException(
f"Cannot serve file {abspath}, filename {filename}, as there is no suffix on either"
)

renderer = FILE_RENDERERS.get(suffix)
stat = abspath.stat()
last_modified = formatdate(stat.st_mtime, usegmt=True)
etag = build_etag(stat, getattr(renderer, "template_path", None))
last_requested = request.headers.get("If-Modified-Since")

if request.headers.get("If-None-Match") == etag:
response = HttpResponseNotModified()
elif last_requested and parsedate(last_requested) >= parsedate(last_modified):
response = HttpResponseNotModified()
elif renderer:
response = renderer(abspath, suffix)
else:
response = FileResponse(abspath.open("rb"), filename=filename)

response.headers["Last-Modified"] = last_modified
response.headers["ETag"] = etag

return response
16 changes: 9 additions & 7 deletions airlock/views/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from airlock.business_logic import Status, bll
from airlock.file_browser_api import get_request_tree

from .helpers import get_release_request_or_raise, serve_file
from .helpers import download_file, get_release_request_or_raise, serve_file


def request_index(request):
Expand Down Expand Up @@ -104,13 +104,15 @@ def request_contents(request, request_id: str, path: str):
# Downloads are only allowed for output checkers
# Downloads are not allowed for request authors (including those that are also
# output checkers)
if download and (
not request.user.output_checker
or (release_request.author == request.user.username)
):
raise PermissionDenied()
if download:
if not request.user.output_checker or (
release_request.author == request.user.username
):
raise PermissionDenied()

return serve_file(abspath, download, filename=path)
return download_file(abspath, filename=path)

return serve_file(request, abspath, filename=path)


@require_http_methods(["POST"])
Expand Down
2 changes: 1 addition & 1 deletion airlock/views/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def workspace_contents(request, workspace_name: str, path: str):
if not abspath.is_file():
return HttpResponseBadRequest()

return serve_file(abspath)
return serve_file(request, abspath)


@require_http_methods(["POST"])
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ branch = true
# Required to get full coverage when using Playwright
concurrency = ["greenlet", "thread"]
plugins = ["django_coverage_plugin"]
omit = ["*/assets/*",]
omit = ["*/assets/*"]


[tool.coverage.report]
fail_under = 100
Expand Down
Loading
Loading