diff --git a/globus_portal_framework/checks.py b/globus_portal_framework/checks.py index 5b849275..0911ad13 100644 --- a/globus_portal_framework/checks.py +++ b/globus_portal_framework/checks.py @@ -82,3 +82,22 @@ def check_allowed_groups(app_configs, **kwargs): errors.append(e) return errors + + +@register +def check_gpreview(app_configs, **kwargs): + """Simplistic check: GLOBUS_PREVIEW_COLLECTIONS must be a dict of {uuid: https_url}""" + s = getattr(settings, 'GLOBUS_PREVIEW_COLLECTIONS', {}) + + catchall = [ + Error('GLOBUS_PREVIEW_COLLECTIONS must be a dict of {uuid: https_url}', + obj=s, id='globus_portal_framework.settings.E005') + ] + + if not isinstance(s, dict): + return catchall + + for k, v in s.items(): + if len(k.replace('-', '')) != 32 or not v.startswith('https'): + return catchall + return [] diff --git a/globus_portal_framework/exc.py b/globus_portal_framework/exc.py index 820564af..4e3407b2 100644 --- a/globus_portal_framework/exc.py +++ b/globus_portal_framework/exc.py @@ -120,6 +120,13 @@ def __init__(self, **kwargs): self.message = 'Preview is unable to display binary data.' +class PreviewCollectionError(GlobusPortalException): + DEFAULT_MESSAGE = 'Could not determine how to access the specified collection for Preview Mode' + def __init__(self, message=DEFAULT_MESSAGE, **kwargs): + super().__init__(message=message, **kwargs) + self.code = 'BadPreviewConfig' + + class ExpiredGlobusToken(GlobusPortalException): def __init__(self, token_name='', **kwargs): super().__init__(**kwargs) diff --git a/globus_portal_framework/gclients.py b/globus_portal_framework/gclients.py index 0b969b3e..d3a31394 100644 --- a/globus_portal_framework/gclients.py +++ b/globus_portal_framework/gclients.py @@ -64,7 +64,7 @@ def is_globus_user(user): return False -def load_globus_access_token(user: "django.contrib.auth.models.User", token_name: str): +def load_globus_access_token(user: "django.contrib.auth.models.User", token_name: str) -> t.Union[str, None]: """ Load a globus user access token using a provided lookup by resource server. Scopes MUST have already been configured in settings.py, and additionally diff --git a/globus_portal_framework/gpreview.py b/globus_portal_framework/gpreview.py new file mode 100644 index 00000000..9eb18e3e --- /dev/null +++ b/globus_portal_framework/gpreview.py @@ -0,0 +1,67 @@ +""" +Functions related to the "file preview" functionality +""" +import logging +import typing as t +from urllib.parse import urljoin + +from django.conf import settings +from django.urls import reverse + +from globus_portal_framework.exc import PreviewCollectionError + +log = logging.getLogger(__name__) + + +def get_https_url(collection_uuid: str) -> t.Union[str, None]: + """ + Get the HTTPS server URL associated with a collection UUID + + Enforces an assumption that the site is only allowed to talk to (authenticated) collections that + are whitelisted in app settings + """ + whitelist = getattr(settings, 'GLOBUS_PREVIEW_COLLECTIONS', {}) + try: + return whitelist[collection_uuid] + except KeyError: + raise PreviewCollectionError(message=f'No HTTPS URL available for collection UUID {collection_uuid}') + + +def get_render_options(url: str=None, + collection_id: str=None, path: str=None, + is_authenticated: bool=False, + render_mode: str='auto'): + """ + Generate "render options" used by the client-side preview feature. + + Translates collection id + path into URLs based on info in app config + """ + token_endpoint = None + + if not url and not collection_id and not path: + # There's nothing to render, and it makes sense to hide the render widget on page + raise PreviewCollectionError(message='Please specify how to locate the file') + + if (collection_id and path) and not url: + # Translate collection IDs into "where is the file and how to access it" + try: + base = get_https_url(collection_id) + except PreviewCollectionError as e: + log.error(f'Preview mode requested a file from unrecognized storage collection: "{collection_id}". Check GLOBUS_PREVIEW_COLLECTIONS setting.') + raise e + + url = urljoin(base, path) + + # Now that we know it's a globus collections, answer the question: + # "How does the renderer get globus access credentials?" (if appropriate) + if is_authenticated: + token_endpoint = reverse('get-token', kwargs={'collection_id': collection_id}) + + return { + 'url': url, + 'render_mode': render_mode, + + # null if file is public + 'collection_id': collection_id, + 'token_endpoint': token_endpoint, + } diff --git a/globus_portal_framework/static/js/render-asset/main.js b/globus_portal_framework/static/js/render-asset/main.js new file mode 100644 index 00000000..e69de29b diff --git a/globus_portal_framework/static/js/render-asset/renderers.js b/globus_portal_framework/static/js/render-asset/renderers.js new file mode 100644 index 00000000..a50f2757 --- /dev/null +++ b/globus_portal_framework/static/js/render-asset/renderers.js @@ -0,0 +1,441 @@ +"use strict"; +//////////////////////// +// Render data in various ways +const MAX_SIZE = 2*2**20; // ~2MB + + + +//////////////// +// Authorization helpers +function getTokenFromUrl(token_endpoint) { + /** + * Return a callable that fetches a token from provided URL. If the token was already retrieved (like on a page + * with multiple files), the callable will return the cached Promise. + * @returns {function} + */ + let cache = null; + return () => { + const nt = Promise.resolve({access_token: null}); + + if (!token_endpoint) { + // If this is a public asset, return "I have no token to give you" and let render treat it as public + return nt; + } + + if (!cache) { + cache = fetch(token_endpoint) + .then((resp) => { + if (resp.ok) { + return resp.json(); + } + throw new Error('Token request refused for current user. Make sure you are logged in, and that the application has configured access for this collection'); + }).then(({access_token}) => { + return {access_token}; + }).catch(() => nt); + } + return cache; + }; +} + + +//////////////////////// +// Data retrieval helpers +function checkFileSize({ContentLength} = fileData, maxSize = MAX_SIZE) { + // Throw an error if the file exceeds a size that can be safely handled. This is because authenticated embeds rely + // on loading entire file in memory first, which would be bad for video etc + const size = (ContentLength ?? 0); + if (size > maxSize) { + throw new Error(`File is too large to preview`); + } + return true; +} + + +function fetchAuthenticatedContent(urlOptions, nBytes = null, rangeStart = 0) { + // Fixme: refactor to general purpose fetcher since auth is optional as written + const {url, access_token} = urlOptions; + + const headers = new Headers(); + if (access_token) { + headers.set('Authorization', `bearer ${access_token}`); + } + if (nBytes) { + headers.set('Range', `bytes=${rangeStart}-${rangeStart + nBytes}`); + } + return fetch(url, { + method: 'GET', + headers: headers, + }).then((response) => { + if (response.ok) { + return response; + } else { + console.error(response); + throw new Error('Error retrieving file contents'); + } + }).then((resp) => resp.blob()); +} + + +function fetchBinaryBlob(urlOptions) { + // Since binary blobs (like images) must be fetched all at once, there is a hard size limit + checkFileSize(urlOptions); + return fetchAuthenticatedContent(urlOptions); +} + + +function fetchText(urlOptions, maxSize = MAX_SIZE) { + // Text files support partial rendering + const nBytes = (urlOptions.ContentLength > maxSize) ? maxSize : null; + return fetchAuthenticatedContent(urlOptions, nBytes) + .then((blob) => blob.text()); +} + + +/////////////// +// Utilities/ abstract methods +class Registry { + constructor() { + // Matching items can be identified via exact match, or approximate (according to a bool test function) + this._name_registry = new Map(); + this._match_registry = new Map(); + } + + add(name, item, test = null) { + if (this._name_registry.has(name)) { + console.warn(`User-defined function is overriding existing registry item '${name}'`); + } + this._name_registry.set(name, item); + if (test) { + this._match_registry.set(name, test); + } + } + + get(name) { + // Find items by either literal match, or an approximate function + const by_name = this._name_registry.get(name); + if (by_name) { + return by_name; + } + for (const [key, test_func] of this._match_registry) { + if (test_func(name)) { + return this._name_registry.get(key); + } + } + return null; + } +} + +const RENDERERS = new Registry(); + + +function addCss(url) { + const el = document.createElement('link'); + el.rel = 'stylesheet'; + el.href = url; + el.type = 'text/css'; + document.head.appendChild(el); +} + + +function addScript(url) { + // Synchronously load JS file, only if the renderer needs it + const el = document.createElement('script'); + el.src = url; + el.type = 'text/javascript'; + el.defer = false; + document.head.appendChild(el); +} + + +function embedBlob(target, blob, {ContentType}) { + // Many media renderers are a thin wrapper for sticking something in an object tag + const el = document.createElement('object'); + el.type = ContentType; + el.data = URL.createObjectURL(blob); + target.appendChild(el); + return el; +} + + +function sizeToFit(target, content) { + /** + * Size to fit the parent or the image, whichever is smaller. Good for fixed-resolution things like JPGs. + */ + + const {height: mh, width: mw} = window.visualViewport; + const {width: rw, height: rh} = content.getBoundingClientRect(); + + if (!(rw <= mw && rh <= mh)) { + // Only rescale if image doesn't fit within bounding box + const ar = rw / rh; + + let nw = rw; + let nh = rh; + if (rw > mw) { + nw = mw; + nh = nw / ar; + } + if (nh > mh) { + nh = mh; + nw = nh * ar; + } + content.setAttribute('width', nw); + content.setAttribute('height', nh); + } + // Always center resulting image inside the render area / iframe + target.style.setProperty("display", "flex"); + target.style.setProperty("justify-content", "center"); + target.style.setProperty("align-items", "center"); + target.style.setProperty("height", "100vh"); +} + + +function sizeToPage(target, content) { + // Good for "content renderers" that implement their own zoom, like PDF, PDB, or svg image. Fill all available space. + content.style.width = '100%'; + content.style.height = '100vh'; +} + + +////////////////// +// Renderers for specific data types +// All renderers have a public call signature of f(targetNode, urlOptions, renderOptions) -> null, and any errors +// thrown by render function.will be shown to user as a message. + +function renderText(target, urlOptions, {message}={}) { + /** + * Display a message + */ + const p = document.createElement('p'); + p.innerText = message; + target.appendChild(p); +} + + +function renderLink(target, {url}, {message} = {}) { + /** + * Provide a direct download link for viewing a file later. Useful if we cannot render all or part of a file. + */ + if (message) { + renderText(target, {}, {message}) + } + const p = document.createElement('p'); + const a = document.createElement('a'); + + // Adding a query param causes Globus HTTPS servers to send this as a download + // https://docs.globus.org/globus-connect-server/v5/https-access-collections/#request_a_browser_download + const href = new URL(url); + href.searchParams.append('download', '1'); + + a.href = href.toString(); + a.setAttribute('download', ''); + a.innerText = 'Download the file directly'; + p.appendChild(a); + target.appendChild(p); +} + + +function renderUrlToObject(target, urlOptions, {sizer = sizeToFit} = {}) { + // Generic binary object renderer (image, PDF, short movie clip) + return fetchBinaryBlob(urlOptions) + .then((blob) => { + const el = embedBlob(target, blob, urlOptions); + // Render a fallback message if object fails to display due to bad video codecs, PDF inside iframe sandbox, etc + const fb = document.createElement('div'); + renderLink(fb, urlOptions, {message: 'The item failed to render'}); + el.appendChild(fb); + return el; + }) + .then((el) => { + // Once binary item loads, resize according to best behavior (customized to needs of eg image vs pdf) + el.onload = () => sizer(target, el); + }); +} + + +function renderUrlToText(target, urlOptions, renderOptions={}) { + // Displays text in a simple pre tag + return fetchText(urlOptions) + .then((text) => { + const pre = document.createElement('pre'); + pre.innerText = text; + target.appendChild(pre); + }); +} + + +function renderUrlToCode(target, urlOptions, renderOptions={}) { + /** + * Pretty print text snippets as code + */ + try { + // TODO investigate smaller bundles (core, common) + addCss("https://unpkg.com/@highlightjs/cdn-assets@11.9.0/styles/default.min.css"); + addScript("https://unpkg.com/@highlightjs/cdn-assets@11.9.0/highlight.min.js"); + } catch (e) { + return renderUrlToText(target, urlOptions); + } + + return fetchText(urlOptions) + .then((text) => { + const pre = document.createElement('pre') + const c = document.createElement('code'); + pre.appendChild(c); + + const res = hljs.highlightAuto(text); + if (res.language) { + console.debug('HL.JS auto detected language:', res.language); + c.innerHTML = res.value; + } else { + // If hljs did not process the string, treat it as unsafe markup + console.debug('HL.JS failed to detect language; rendering as text'); + c.innerText = res.value; + } + target.appendChild(pre); + }); +} + + +function renderUrlToTable(target, urlOptions, renderOptions={}) { + try { + // _addCss("https://unpkg.com/tabulator-tables@6.3.1/dist/css/tabulator.min.css"); + addCss("https://unpkg.com/tabulator-tables@6.3.1/dist/css/tabulator_bootstrap4.min.css"); + addScript("https://unpkg.com/tabulator-tables@6.3.1/dist/js/tabulator.min.js"); + } catch (e) { + return renderUrlToText(target, urlOptions); + } + + return fetchText(urlOptions).then((text) => { + const table = new Tabulator(target, { + data: text.trim(), + // Note: `text/tab-separated-values` may need custom code in tabulator to process + importFormat: "csv", + autoColumns: true, + }); + }); +} + + +/////////////////// +// Perform the act of rendering on a page in a re-usable way +function _getURLOptions(url, access_token) { + /** + * Check the file to see if it is a valid target for rendering. For Globus https endpoints, this provides some + * valuable information that can make something easier to render + * @returns {{url, access_token, status, statusText, contentType, contentLength, required_scopes}} + */ + const headers = new Headers({'X-Requested-With': 'XMLHttpRequest'}); + if (access_token) { + headers.set('Authorization', `bearer ${access_token}`); + } + return fetch(url, { + method: 'HEAD', + headers: headers, + }).then((response) => { + const res = { + // Always provide these fields. Anything more is server-dependent. + url, + access_token, + 'status': response.status, + 'statusText': response.statusText + }; + + if (response.ok) { + res['ContentType'] = response.headers.get('content-type'); + res['ContentLength'] = response.headers.get('content-length'); + } + // TODO FUTURE: This function does not try to handle 401 cases, such as determining `required_scopes` from globus GET response + return res; + }); +} + + +function doRender(target, pageOptions, getToken) { + /** + * Make an (authenticated) request to retrieve the file + * + * @param {Node | string} target The DOM node to use as rendered content root + * @param {object} pageOptions An object containing options used to render this asset. Must answer the questions + * "where is the asset" and "tell me how to access it". + * @param pageOptions.url URL of the asset to render. The backend will translate collection + * ID + path -> https URL before it reaches the rendering code. + * @param [pageOptions.token_endpoint] A django-provided URL for where to get read-only file view credentials. + * Generally prefer application-locked read-only credentials, as some user tokens might have write access. + * @param {string} [pogeOptions.render_mode] Optionally specify how to render the file. Usually 'auto' to guess by mimetype, or a + * user-specified renderer (like PDB, plot.ly, or leaflet.js) + * @param {function} [getToken] A callable that returns `Promise.resolve({access_token}). Defaults to making an API call to the token endpoint. + */ + const {url, token_endpoint, render_mode = 'auto'} = pageOptions; + + target.innerText = ""; + + // On a page with multiple files, this can be passed explicitly to use a shared cache and only fetch the token once + getToken = getToken || getTokenFromUrl(token_endpoint); + + // Perform all actions required to render an asset on the page, incl (authorized) data retrieval + return getToken().then(({access_token}) => { + return _getURLOptions(url, access_token) + .then((urlOptions) => { + const {ContentType, status, statusText} = urlOptions; + if (status === 404) { + return renderText(target, urlOptions, {message: statusText}); + } else if (status !== 200) { + // Asset cannot be retrieved, but a globus https link may allow auth+access + // TODO: Add a branch for 401. In the future, we might be able to use required_scopes for incremental reauth. + throw new Error(statusText); + } + + let method; + if (render_mode && render_mode !== 'auto') { + method = RENDERERS.get(render_mode); + } else { + method = RENDERERS.get(ContentType); + } + if (!method) { + return renderLink(target, urlOptions, {message: `Unable to render files of type '${ContentType}'`}); + } + return method(target, urlOptions); + }).catch((e) => { + console.error(e); + renderLink(target, {url}, {message: e.message}); + }); + }); +} + + +///////////// +// Populate the registry with default render helpers +RENDERERS.add(null, renderLink); // Very rare file extensions may return no content-type at all. +// Known issue: PDFs don't render inside a sandboxed iframe. If your dataset is PDF heavy, customize template to remove the sandbox +RENDERERS.add('application/pdf', (t, u, r) => renderUrlToObject(t, u, {sizer: sizeToPage})); +RENDERERS.add('text/csv', renderUrlToTable); // We may need extra code to handle TSVs w/current library +RENDERERS.add('text/javascript', renderUrlToCode); +RENDERERS.add('application/javascript', renderUrlToCode); +RENDERERS.add('application/json', renderUrlToCode); +// Incredibly leaky catchall; see https://mimetype.io/all-types +RENDERERS.add('code', renderUrlToCode, (mt) => mt.startsWith('text/x-')); + +// Renderers are checked in order, so these are registered last as fallbacks +RENDERERS.add('text', renderUrlToText, (mt) => mt.includes('text')); +RENDERERS.add('image', renderUrlToObject, (mt) => mt.includes('image')); + +//////// +// All renderers have the standard call signature f(t, {u}, {r}) -> Promise +// Useful generic renderers +export {renderLink, renderText}; +// Specific renderers with limited customization via renderOptions arg +export {renderUrlToObject, renderUrlToText, renderUrlToCode, renderUrlToTable}; + +// Helpers used to build your own renderer +export { + fetchBinaryBlob, fetchText, fetchAuthenticatedContent, + addCss, addScript, + sizeToPage, sizeToFit, + embedBlob, +}; + + +export { doRender }; +// Exports a plugin registry which other code may choose to extend with custom functions / types +// And lo, a thousand purists cried out and were suddenly silenced +export default RENDERERS; diff --git a/globus_portal_framework/templates/globus-portal-framework/v3/components/search-facets.html b/globus_portal_framework/templates/globus-portal-framework/v3/components/search-facets.html index de5ccdb0..8f06e7ad 100644 --- a/globus_portal_framework/templates/globus-portal-framework/v3/components/search-facets.html +++ b/globus_portal_framework/templates/globus-portal-framework/v3/components/search-facets.html @@ -7,20 +7,22 @@ {% if facet.buckets %} {% for field in facet.buckets %}
- - {% if field.filter_type == 'year' %} - {{field.datetime|date:'Y'}} - {% elif field.filter_type == 'month' %} - {{field.datetime|date:'F Y'}} - {% elif field.filter_type == 'day' %} - {{field.datetime|date:'M d Y'}} - {% elif field.filter_type in 'hour minute second' %} - {{field.datetime|date:'M d Y H:i:s T'}} - {% else %} - {{field.value}} - {% endif %} + ({{field.count}})
{% empty %} diff --git a/globus_portal_framework/templates/globus-portal-framework/v3/detail-overview.html b/globus_portal_framework/templates/globus-portal-framework/v3/detail-overview.html index a381c9f0..dc03a029 100644 --- a/globus_portal_framework/templates/globus-portal-framework/v3/detail-overview.html +++ b/globus_portal_framework/templates/globus-portal-framework/v3/detail-overview.html @@ -1,4 +1,5 @@ {% extends 'globus-portal-framework/v3/detail-base.html' %} +{% load render_options index_template srcdoc %} {% block breadcrumb_items %} @@ -44,7 +45,18 @@

Default Detail Page

{% endif %} {% endblock %} - + {% render_options is_authenticated=request.user.is_authenticated url=preview_url collection_id=preview_collection path=preview_path as ro %} + {% index_template 'globus-portal-framework/v3/detail-render-asset.html' as it_render %} + {% block detail_preview %} + {% if preview_collection and preview_path or preview_url %} + {# If you know that your page will only be used with public assets, remove `allow-same-origin` from the sandbox for added security #} + + {% endif %} + {% endblock %} diff --git a/globus_portal_framework/templates/globus-portal-framework/v3/detail-render-asset.html b/globus_portal_framework/templates/globus-portal-framework/v3/detail-render-asset.html new file mode 100644 index 00000000..8a445e87 --- /dev/null +++ b/globus_portal_framework/templates/globus-portal-framework/v3/detail-render-asset.html @@ -0,0 +1,27 @@ +{% load static %} +{# Can be used as a standalone page or an embeddable iframe viewer. For security reasons the latter is preferred. #} + + + + Render asset + + {%block headextras%} + {# You can add any custom code here, like custom render functions or stylesheets. #} + {% endblock %} + + +
Waiting...
+ + {# This must be passed to the template. A template tag helper is available to generate this dict. #} + {{ render_options|json_script:"render-options" }} + + + + diff --git a/globus_portal_framework/templatetags/render_options.py b/globus_portal_framework/templatetags/render_options.py new file mode 100644 index 00000000..2b923897 --- /dev/null +++ b/globus_portal_framework/templatetags/render_options.py @@ -0,0 +1,9 @@ +from django import template + +from globus_portal_framework import gpreview + +register = template.Library() + +@register.simple_tag +def render_options(**kwargs): + return gpreview.get_render_options(**kwargs) diff --git a/globus_portal_framework/templatetags/srcdoc.py b/globus_portal_framework/templatetags/srcdoc.py new file mode 100644 index 00000000..6094a931 --- /dev/null +++ b/globus_portal_framework/templatetags/srcdoc.py @@ -0,0 +1,30 @@ +""" +A template helper that allows another template to be rendered as an iframe srcdoc attribute, + such as for the file preview feature. srcdocs must escape quotes to avoid terminating input early. + + Example usage: {% srcdoc %}{%include tpl %}{% endsrcdoc %} + +Based on + https://github.com/sheepman4267/django-srcdoc/blob/main/src/django_srcdoc/templatetags/srcdoc.py +""" + +from django import template + +register = template.Library() + +class SrcdocNode(template.Node): + def __init__(self, nodelist): + self.nodelist = nodelist + + def render(self, context): + content = self.nodelist.render(context) + result = content.replace('"', '"') + return result + +@register.tag(name="srcdoc") +def do_srcdoc(parser, token): + nodelist = parser.parse(("endsrcdoc",)) + parser.delete_first_token() + return SrcdocNode(nodelist) + + diff --git a/globus_portal_framework/urls.py b/globus_portal_framework/urls.py index 3885a386..d8b10b4c 100644 --- a/globus_portal_framework/urls.py +++ b/globus_portal_framework/urls.py @@ -19,7 +19,7 @@ from django.conf import settings from globus_portal_framework.views import ( search, index_selection, detail, detail_transfer, detail_preview, logout, - allowed_groups, search_about, + allowed_groups, search_about, get_token ) from globus_portal_framework.apps import get_setting from globus_portal_framework.api import restricted_endpoint_proxy_stream @@ -101,6 +101,7 @@ def to_url(self, value): path('/detail-transfer/', detail_transfer, name='detail-transfer'), path('/detail//', detail, name='detail'), + path('get-token//', get_token, name='get-token'), path('allowed-groups/', allowed_groups, name='allowed-groups'), # Globus search portal. Provides default url '/'. path('', index_selection, name='index-selection'), diff --git a/globus_portal_framework/views/__init__.py b/globus_portal_framework/views/__init__.py index 8c3e430b..ba4f4b3e 100644 --- a/globus_portal_framework/views/__init__.py +++ b/globus_portal_framework/views/__init__.py @@ -1,5 +1,5 @@ from globus_portal_framework.views.base import ( - search, detail, index_selection, allowed_groups, logout, search_about, + search, detail, get_token, index_selection, allowed_groups, logout, search_about, search_debug, search_debug_detail, detail_transfer, detail_preview, @@ -9,7 +9,7 @@ __all__ = [ - 'search', 'detail', 'index_selection', 'allowed_groups', 'logout', + 'search', 'detail', 'get_token', 'index_selection', 'allowed_groups', 'logout', 'search_about', 'search_debug', 'search_debug_detail', 'detail_transfer', 'detail_preview', diff --git a/globus_portal_framework/views/base.py b/globus_portal_framework/views/base.py index 78b9ba56..737f0ca0 100644 --- a/globus_portal_framework/views/base.py +++ b/globus_portal_framework/views/base.py @@ -1,22 +1,23 @@ import logging import copy -from urllib.parse import urlparse +from urllib.parse import urlparse, unquote from collections import OrderedDict from json import dumps import globus_sdk + import django from django.conf import settings from django.contrib import messages from django.shortcuts import render, redirect from django.urls import reverse -from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, Http404, HttpResponseForbidden, JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.defaults import server_error, page_not_found from django.contrib.auth import logout as django_logout from globus_portal_framework.apps import get_setting from globus_portal_framework import ( - gsearch, gclients, gtransfer, + gsearch, gclients, gpreview, gtransfer, PreviewException, PreviewURLNotFound, ExpiredGlobusToken, GroupsException, ) @@ -300,6 +301,35 @@ def detail(request: HttpRequest, index: str, subject: str) -> django.http.HttpR request.user)) +def get_token(request: HttpRequest, collection_id: str) -> HttpResponse: + """ + Send information to the frontend that can be used to read files from a collection. + + CAUTION: The globus https scope allows read AND write access to files from a collection. + Globus collections determines write via user permissions on the collection, not via token scope. + Take precautions against token leakage, such as exempting this route from authenticated CORS (if applicable). + """ + if not request.user.is_authenticated: + return HttpResponse('Tokens are not available for unauthenticated users', status=401) + + # EXPLICIT WHITELIST defends vs requesting arbitrary resource server tokens, like "flows" or "auth.globus.org". + allowed = getattr(settings, 'GLOBUS_PREVIEW_COLLECTIONS', {}) + if collection_id not in allowed: + # Log attempt to access arbitrary tokens + log.warning(f'Frontend access token request blocked for resource "{collection_id}" by user "{request.user.username}"') + return HttpResponseForbidden(f'This server does not support tokens for collection "{collection_id}"') + + try: + access_token = gclients.load_globus_access_token(request.user, collection_id) + except ValueError: + return HttpResponseForbidden('No tokens available for this specified collection. If this is a public collection, you may not need a token.') + + # TODO: Sanity-check token scopes before sending to browser; may require refactor of `load_globus_access_token` + return JsonResponse({ + 'access_token': access_token, + }) + + @csrf_exempt def detail_transfer(request, index, subject): """