diff --git a/assets/src/bundles/browse/swhid-utils.js b/assets/src/bundles/browse/swhid-utils.js index 36b5504d..d437b475 100644 --- a/assets/src/bundles/browse/swhid-utils.js +++ b/assets/src/bundles/browse/swhid-utils.js @@ -1,128 +1,128 @@ /** * Copyright (C) 2018-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import ClipboardJS from 'clipboard'; import 'thirdparty/jquery.tabSlideOut/jquery.tabSlideOut'; import 'thirdparty/jquery.tabSlideOut/jquery.tabSlideOut.css'; import {BREAKPOINT_SM} from 'utils/constants'; export function swhIdObjectTypeToggled(event) { event.preventDefault(); $(event.target).tab('show'); } export function swhIdContextOptionToggled(event) { event.stopPropagation(); const swhIdElt = $(event.target).closest('.swhid-ui').find('.swhid'); const swhIdWithContext = $(event.target).data('swhid-with-context'); const swhIdWithContextUrl = $(event.target).data('swhid-with-context-url'); let currentSwhId = swhIdElt.text(); if ($(event.target).prop('checked')) { swhIdElt.attr('href', swhIdWithContextUrl); currentSwhId = swhIdWithContext.replace(/;/g, ';\n'); } else { const pos = currentSwhId.indexOf(';'); if (pos !== -1) { currentSwhId = currentSwhId.slice(0, pos); } swhIdElt.attr('href', '/' + currentSwhId); } swhIdElt.text(currentSwhId); addLinesInfo(); } function addLinesInfo() { const swhIdElt = $('#swhid-tab-content').find('.swhid'); let currentSwhId = swhIdElt.text().replace(/;\n/g, ';'); const lines = []; let linesPart = ';lines='; const linesRegexp = new RegExp(/L(\d+)/g); let line = linesRegexp.exec(window.location.hash); while (line) { lines.push(parseInt(line[1])); line = linesRegexp.exec(window.location.hash); } if (lines.length > 0) { linesPart += lines[0]; } if (lines.length > 1) { linesPart += '-' + lines[1]; } if ($('#swhid-context-option-content').prop('checked')) { currentSwhId = currentSwhId.replace(/;lines=\d+-*\d*/g, ''); if (lines.length > 0) { currentSwhId += linesPart; } swhIdElt.text(currentSwhId.replace(/;/g, ';\n')); swhIdElt.attr('href', '/' + currentSwhId); } } $(document).ready(() => { new ClipboardJS('.btn-swhid-copy', { text: trigger => { const swhId = $(trigger).closest('.swhid-ui').find('.swhid').text(); return swhId.replace(/;\n/g, ';'); } }); new ClipboardJS('.btn-swhid-url-copy', { text: trigger => { const swhIdUrl = $(trigger).closest('.swhid-ui').find('.swhid').attr('href'); return window.location.origin + swhIdUrl; } }); if (window.innerWidth * 0.7 > 1000) { $('#swh-identifiers').css('width', '1000px'); } // prevent automatic closing of SWHIDs tab during guided tour // as it is displayed programmatically function clickScreenToCloseFilter() { return $('.introjs-overlay').length > 0; } const tabSlideOptions = { tabLocation: 'right', clickScreenToCloseFilters: [clickScreenToCloseFilter, '.ui-slideouttab-panel', '.modal'], offset: function() { const width = $(window).width(); if (width < BREAKPOINT_SM) { return '250px'; } else { return '200px'; } } }; // ensure tab scrolling on small screens if (window.innerHeight < 600 || window.innerWidth < 500) { tabSlideOptions['otherOffset'] = '20px'; } // initiate the sliding identifiers tab $('#swh-identifiers').tabSlideOut(tabSlideOptions); // set the tab visible once the close animation is terminated - $('#swh-identifiers').css('display', 'block'); + $('#swh-identifiers').addClass('d-none d-sm-block'); $('.swhid-context-option').trigger('click'); // highlighted code lines changed $(window).on('hashchange', () => { addLinesInfo(); }); // highlighted code lines removed $('body').click(() => { addLinesInfo(); }); }); diff --git a/assets/src/bundles/webapp/code-highlighting.js b/assets/src/bundles/webapp/code-highlighting.js index fb7f03ac..f16f1247 100644 --- a/assets/src/bundles/webapp/code-highlighting.js +++ b/assets/src/bundles/webapp/code-highlighting.js @@ -1,117 +1,127 @@ /** - * Copyright (C) 2018-2019 The Software Heritage developers + * Copyright (C) 2018-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {removeUrlFragment} from 'utils/functions'; // keep track of the first highlighted line let firstHighlightedLine = null; // highlighting color const lineHighlightColor = 'rgb(193, 255, 193)'; // function to highlight a line export function highlightLine(i, firstHighlighted = false) { const lineTd = $(`.hljs-ln-line[data-line-number="${i}"]`); lineTd.css('background-color', lineHighlightColor); if (firstHighlighted) { firstHighlightedLine = i; } return lineTd; } +// function to highlight a range of lines +export function highlightLines(first, last) { + if (!first) { + return; + } + if (!last) { + last = first; + } + for (let i = first; i <= last; ++i) { + highlightLine(i); + } +} + // function to reset highlighting export function resetHighlightedLines() { firstHighlightedLine = null; $('.hljs-ln-line[data-line-number]').css('background-color', 'inherit'); } -export function scrollToLine(lineDomElt) { +export function scrollToLine(lineDomElt, offset = 70) { if ($(lineDomElt).closest('.swh-content').length > 0) { $('html, body').animate({ - scrollTop: $(lineDomElt).offset().top - 70 + scrollTop: $(lineDomElt).offset().top - offset }, 500); } } -export async function highlightCode(showLineNumbers = true, selector = 'code') { +export async function highlightCode(showLineNumbers = true, selector = 'code', + enableLinesSelection = true) { await import(/* webpackChunkName: "highlightjs" */ 'utils/highlightjs'); // function to highlight lines based on a url fragment // in the form '#Lx' or '#Lx-Ly' function parseUrlFragmentForLinesToHighlight() { const lines = []; const linesRegexp = new RegExp(/L(\d+)/g); let line = linesRegexp.exec(window.location.hash); if (line === null) { return; } while (line) { lines.push(parseInt(line[1])); line = linesRegexp.exec(window.location.hash); } resetHighlightedLines(); if (lines.length === 1) { firstHighlightedLine = parseInt(lines[0]); scrollToLine(highlightLine(lines[0])); } else if (lines[0] < lines[lines.length - 1]) { firstHighlightedLine = parseInt(lines[0]); scrollToLine(highlightLine(lines[0])); - for (let i = lines[0] + 1; i <= lines[lines.length - 1]; ++i) { - highlightLine(i); - } + highlightLines(lines[0] + 1, lines[lines.length - 1]); } } $(document).ready(() => { // highlight code and add line numbers $(selector).each((i, elt) => { hljs.highlightElement(elt); if (showLineNumbers) { hljs.lineNumbersElement(elt, {singleLine: true}); } }); - if (!showLineNumbers) { + if (!showLineNumbers || !enableLinesSelection) { return; } // click handler to dynamically highlight line(s) // when the user clicks on a line number (lines range // can also be highlighted while holding the shift key) $('.swh-content').click(evt => { if (evt.target.classList.contains('hljs-ln-n')) { const line = parseInt($(evt.target).data('line-number')); if (evt.shiftKey && firstHighlightedLine && line > firstHighlightedLine) { const firstLine = firstHighlightedLine; resetHighlightedLines(); - for (let i = firstLine; i <= line; ++i) { - highlightLine(i); - } + highlightLines(firstLine, line); firstHighlightedLine = firstLine; window.location.hash = `#L${firstLine}-L${line}`; } else { resetHighlightedLines(); highlightLine(line); window.location.hash = `#L${line}`; scrollToLine(evt.target); } } else if ($(evt.target).closest('.hljs-ln').length) { resetHighlightedLines(); removeUrlFragment(); } }); // update lines highlighting when the url fragment changes $(window).on('hashchange', () => parseUrlFragmentForLinesToHighlight()); // schedule lines highlighting if any as hljs.lineNumbersElement() is async setTimeout(() => { parseUrlFragmentForLinesToHighlight(); }); }); } diff --git a/assets/src/bundles/webapp/iframes.js b/assets/src/bundles/webapp/iframes.js new file mode 100644 index 00000000..4a7b5301 --- /dev/null +++ b/assets/src/bundles/webapp/iframes.js @@ -0,0 +1,23 @@ +/** + * Copyright (C) 2021 The Software Heritage developers + * See the AUTHORS file at the top-level directory of this distribution + * License: GNU Affero General Public License version 3, or any later version + * See top-level LICENSE file for more information + */ + +export function showIframeInfoModal(objectType, objectSWHID) { + const html = ` +

+ You can embed that ${objectType} view in an external website + through the use of an iframe. Use the following HTML code + to do so. +

+
<iframe style="width: 100%; height: 500px; border: 1px solid rgba(0, 0, 0, 0.125);"
+        src="${window.location.origin}${Urls.swhid_iframe(objectSWHID.replaceAll('\n', ''))}">
+</iframe>
+ `; + swh.webapp.showModalHtml(`Software Heritage ${objectType} iframe`, html, '1000px'); + swh.webapp.highlightCode(false, '.swh-iframe-html'); +} diff --git a/assets/src/bundles/webapp/index.js b/assets/src/bundles/webapp/index.js index 1189dd91..d4f04bfd 100644 --- a/assets/src/bundles/webapp/index.js +++ b/assets/src/bundles/webapp/index.js @@ -1,28 +1,29 @@ /** * Copyright (C) 2018-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ // webapp entrypoint bundle centralizing global custom stylesheets // and utility js modules used in all swh-web applications // global swh-web custom stylesheets import './webapp.css'; import './breadcrumbs.css'; import './coverage.css'; export * from './webapp-utils'; // utility js modules export * from './code-highlighting'; export * from './readme-rendering'; export * from './pdf-rendering'; export * from './notebook-rendering'; export * from './xss-filtering'; export * from './history-counters'; export * from './badges'; export * from './sentry'; export * from './math-typesetting'; export * from './status-widget'; +export * from './iframes'; diff --git a/assets/src/bundles/webapp/webapp-utils.js b/assets/src/bundles/webapp/webapp-utils.js index 79a65270..5481088d 100644 --- a/assets/src/bundles/webapp/webapp-utils.js +++ b/assets/src/bundles/webapp/webapp-utils.js @@ -1,400 +1,402 @@ /** * Copyright (C) 2018-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import objectFitImages from 'object-fit-images'; import {selectText} from 'utils/functions'; import {BREAKPOINT_MD} from 'utils/constants'; let collapseSidebar = false; const previousSidebarState = localStorage.getItem('remember.lte.pushmenu'); if (previousSidebarState !== undefined) { collapseSidebar = previousSidebarState === 'sidebar-collapse'; } $(document).on('DOMContentLoaded', () => { // set state to collapsed on smaller devices if ($(window).width() < BREAKPOINT_MD) { collapseSidebar = true; } // restore previous sidebar state (collapsed/expanded) if (collapseSidebar) { // hack to avoid animated transition for collapsing sidebar // when loading a page const sidebarTransition = $('.main-sidebar, .main-sidebar:before').css('transition'); const sidebarEltsTransition = $('.sidebar .nav-link p, .main-sidebar .brand-text, .sidebar .user-panel .info').css('transition'); $('.main-sidebar, .main-sidebar:before').css('transition', 'none'); $('.sidebar .nav-link p, .main-sidebar .brand-text, .sidebar .user-panel .info').css('transition', 'none'); $('body').addClass('sidebar-collapse'); $('.swh-words-logo-swh').css('visibility', 'visible'); // restore transitions for user navigation setTimeout(() => { $('.main-sidebar, .main-sidebar:before').css('transition', sidebarTransition); $('.sidebar .nav-link p, .main-sidebar .brand-text, .sidebar .user-panel .info').css('transition', sidebarEltsTransition); }); } }); $(document).on('collapsed.lte.pushmenu', event => { if ($('body').width() >= BREAKPOINT_MD) { $('.swh-words-logo-swh').css('visibility', 'visible'); } }); $(document).on('shown.lte.pushmenu', event => { $('.swh-words-logo-swh').css('visibility', 'hidden'); }); function ensureNoFooterOverflow() { $('body').css('padding-bottom', $('footer').outerHeight() + 'px'); } $(document).ready(() => { // redirect to last browse page if any when clicking on the 'Browse' entry // in the sidebar $(`.swh-browse-link`).click(event => { const lastBrowsePage = sessionStorage.getItem('last-browse-page'); if (lastBrowsePage) { event.preventDefault(); window.location = lastBrowsePage; } }); const mainSideBar = $('.main-sidebar'); function updateSidebarState() { const body = $('body'); if (body.hasClass('sidebar-collapse') && !mainSideBar.hasClass('swh-sidebar-collapsed')) { mainSideBar.removeClass('swh-sidebar-expanded'); mainSideBar.addClass('swh-sidebar-collapsed'); $('.swh-words-logo-swh').css('visibility', 'visible'); } else if (!body.hasClass('sidebar-collapse') && !mainSideBar.hasClass('swh-sidebar-expanded')) { mainSideBar.removeClass('swh-sidebar-collapsed'); mainSideBar.addClass('swh-sidebar-expanded'); $('.swh-words-logo-swh').css('visibility', 'hidden'); } // ensure correct sidebar state when loading a page if (body.hasClass('hold-transition')) { setTimeout(() => { updateSidebarState(); }); } } // set sidebar state after collapse / expand animation mainSideBar.on('transitionend', evt => { updateSidebarState(); }); updateSidebarState(); // ensure footer do not overflow main content for mobile devices // or after resizing the browser window ensureNoFooterOverflow(); $(window).resize(function() { ensureNoFooterOverflow(); if ($('body').hasClass('sidebar-collapse') && $('body').width() >= BREAKPOINT_MD) { $('.swh-words-logo-swh').css('visibility', 'visible'); } }); // activate css polyfill 'object-fit: contain' in old browsers objectFitImages(); // reparent the modals to the top navigation div in order to be able // to display them $('.swh-browse-top-navigation').append($('.modal')); let selectedCode = null; function getCodeOrPreEltUnderPointer(e) { const elts = document.elementsFromPoint(e.clientX, e.clientY); for (const elt of elts) { if (elt.nodeName === 'CODE' || elt.nodeName === 'PRE') { return elt; } } return null; } // click handler to set focus on code block for copy $(document).click(e => { selectedCode = getCodeOrPreEltUnderPointer(e); }); function selectCode(event, selectedCode) { if (selectedCode) { const hljsLnCodeElts = $(selectedCode).find('.hljs-ln-code'); if (hljsLnCodeElts.length) { selectText(hljsLnCodeElts[0], hljsLnCodeElts[hljsLnCodeElts.length - 1]); } else { selectText(selectedCode.firstChild, selectedCode.lastChild); } event.preventDefault(); } } // select the whole text of focused code block when user // double clicks or hits Ctrl+A $(document).dblclick(e => { if ((e.ctrlKey || e.metaKey)) { selectCode(e, getCodeOrPreEltUnderPointer(e)); } }); $(document).keydown(e => { if ((e.ctrlKey || e.metaKey) && e.key === 'a') { selectCode(e, selectedCode); } }); // show/hide back-to-top button let scrollThreshold = 0; scrollThreshold += $('.swh-top-bar').height() || 0; scrollThreshold += $('.navbar').height() || 0; $(window).scroll(() => { if ($(window).scrollTop() > scrollThreshold) { $('#back-to-top').css('display', 'block'); } else { $('#back-to-top').css('display', 'none'); } }); // navbar search form submission callback $('#swh-origins-search-top').submit(event => { event.preventDefault(); if (event.target.checkValidity()) { $(event.target).removeClass('was-validated'); const searchQueryText = $('#swh-origins-search-top-input').val().trim(); const queryParameters = new URLSearchParams(); queryParameters.append('q', searchQueryText); queryParameters.append('with_visit', true); queryParameters.append('with_content', true); window.location = `${Urls.browse_search()}?${queryParameters.toString()}`; } else { $(event.target).addClass('was-validated'); } }); }); export function initPage(page) { $(document).ready(() => { // set relevant sidebar link to page active $(`.swh-${page}-item`).addClass('active'); $(`.swh-${page}-link`).addClass('active'); // triggered when unloading the current page $(window).on('unload', () => { // backup current browse page if (page === 'browse') { sessionStorage.setItem('last-browse-page', window.location); } }); }); } export function initHomePage() { $(document).ready(async() => { $('.swh-coverage-list').iFrameResize({heightCalculationMethod: 'taggedElement'}); const response = await fetch(Urls.stat_counters()); const data = await response.json(); if (data.stat_counters && !$.isEmptyObject(data.stat_counters)) { for (const objectType of ['content', 'revision', 'origin', 'directory', 'person', 'release']) { const count = data.stat_counters[objectType]; if (count !== undefined) { $(`#swh-${objectType}-count`).html(count.toLocaleString()); } else { $(`#swh-${objectType}-count`).closest('.swh-counter-container').hide(); } } } else { $('.swh-counter').html('0'); } if (data.stat_counters_history && !$.isEmptyObject(data.stat_counters_history)) { for (const objectType of ['content', 'revision', 'origin']) { const history = data.stat_counters_history[objectType]; if (history) { swh.webapp.drawHistoryCounterGraph(`#swh-${objectType}-count-history`, history); } else { $(`#swh-${objectType}-count-history`).hide(); } } } else { $('.swh-counter-history').hide(); } }); initPage('home'); } export function showModalMessage(title, message) { $('#swh-web-modal-message .modal-title').text(title); $('#swh-web-modal-message .modal-content p').text(message); $('#swh-web-modal-message').modal('show'); } export function showModalConfirm(title, message, callback) { $('#swh-web-modal-confirm .modal-title').text(title); $('#swh-web-modal-confirm .modal-content p').text(message); $('#swh-web-modal-confirm #swh-web-modal-confirm-ok-btn').bind('click', () => { callback(); $('#swh-web-modal-confirm').modal('hide'); $('#swh-web-modal-confirm #swh-web-modal-confirm-ok-btn').unbind('click'); }); $('#swh-web-modal-confirm').modal('show'); } -export function showModalHtml(title, html) { +export function showModalHtml(title, html, width = '500px') { $('#swh-web-modal-html .modal-title').text(title); $('#swh-web-modal-html .modal-body').html(html); + $('#swh-web-modal-html .modal-dialog').css('max-width', width); + $('#swh-web-modal-html .modal-dialog').css('width', width); $('#swh-web-modal-html').modal('show'); } export function addJumpToPagePopoverToDataTable(dataTableElt) { dataTableElt.on('draw.dt', function() { $('.paginate_button.disabled').css('cursor', 'pointer'); $('.paginate_button.disabled').on('click', event => { const pageInfo = dataTableElt.page.info(); let content = ' / ${pageInfo.pages}`; $(event.target).popover({ 'title': 'Jump to page', 'content': content, 'html': true, 'placement': 'top', 'sanitizeFn': swh.webapp.filterXSS }); $(event.target).popover('show'); $('.jump-to-page').on('change', function() { $('.paginate_button.disabled').popover('hide'); const pageNumber = parseInt($(this).val()) - 1; dataTableElt.page(pageNumber).draw('page'); }); }); }); dataTableElt.on('preXhr.dt', () => { $('.paginate_button.disabled').popover('hide'); }); } let swhObjectIcons; export function setSwhObjectIcons(icons) { swhObjectIcons = icons; } export function getSwhObjectIcon(swhObjectType) { return swhObjectIcons[swhObjectType]; } let browsedSwhObjectMetadata = {}; export function setBrowsedSwhObjectMetadata(metadata) { browsedSwhObjectMetadata = metadata; } export function getBrowsedSwhObjectMetadata() { return browsedSwhObjectMetadata; } // This will contain a mapping between an archived object type // and its related SWHID metadata for each object reachable from // the current browse view. // SWHID metadata contain the following keys: // * object_type: type of archived object // * object_id: sha1 object identifier // * swhid: SWHID without contextual info // * swhid_url: URL to resolve SWHID without contextual info // * context: object describing SWHID context // * swhid_with_context: SWHID with contextual info // * swhid_with_context_url: URL to resolve SWHID with contextual info let swhidsContext_ = {}; export function setSwhIdsContext(swhidsContext) { swhidsContext_ = {}; for (const swhidContext of swhidsContext) { swhidsContext_[swhidContext.object_type] = swhidContext; } } export function getSwhIdsContext() { return swhidsContext_; } function setFullWidth(fullWidth) { if (fullWidth) { $('#swh-web-content').removeClass('container'); $('#swh-web-content').addClass('container-fluid'); } else { $('#swh-web-content').removeClass('container-fluid'); $('#swh-web-content').addClass('container'); } localStorage.setItem('swh-web-full-width', JSON.stringify(fullWidth)); $('#swh-full-width-switch').prop('checked', fullWidth); } export function fullWidthToggled(event) { setFullWidth($(event.target).prop('checked')); } export function setContainerFullWidth() { const previousFullWidthState = JSON.parse(localStorage.getItem('swh-web-full-width')); if (previousFullWidthState !== null) { setFullWidth(previousFullWidthState); } } function coreSWHIDIsLowerCase(swhid) { const qualifiersPos = swhid.indexOf(';'); let coreSWHID = swhid; if (qualifiersPos !== -1) { coreSWHID = swhid.slice(0, qualifiersPos); } return coreSWHID.toLowerCase() === coreSWHID; } export async function validateSWHIDInput(swhidInputElt) { const swhidInput = swhidInputElt.value.trim(); let customValidity = ''; if (swhidInput.toLowerCase().startsWith('swh:')) { if (coreSWHIDIsLowerCase(swhidInput)) { const resolveSWHIDUrl = Urls.api_1_resolve_swhid(swhidInput); const response = await fetch(resolveSWHIDUrl); const responseData = await response.json(); if (responseData.hasOwnProperty('exception')) { customValidity = responseData.reason; } } else { const qualifiersPos = swhidInput.indexOf(';'); if (qualifiersPos === -1) { customValidity = 'Invalid SWHID: all characters must be in lowercase. '; customValidity += `Valid SWHID is ${swhidInput.toLowerCase()}`; } else { customValidity = 'Invalid SWHID: the core part must be in lowercase. '; const coreSWHID = swhidInput.slice(0, qualifiersPos); customValidity += `Valid SWHID is ${swhidInput.replace(coreSWHID, coreSWHID.toLowerCase())}`; } } } swhidInputElt.setCustomValidity(customValidity); $(swhidInputElt).siblings('.invalid-feedback').text(customValidity); } export function isUserLoggedIn() { return JSON.parse($('#swh_user_logged_in').text()); } diff --git a/assets/src/bundles/webapp/webapp.css b/assets/src/bundles/webapp/webapp.css index 7a200cd2..0b5f2a9e 100644 --- a/assets/src/bundles/webapp/webapp.css +++ b/assets/src/bundles/webapp/webapp.css @@ -1,749 +1,750 @@ /** * Copyright (C) 2018-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ html { height: 100%; overflow-x: hidden; scroll-behavior: auto !important; } body { min-height: 100%; margin: 0; position: relative; padding-bottom: 120px; } a:active, a.active { outline: none; } code { background-color: #f9f2f4; } pre code { background-color: transparent; } footer { background-color: #262626; color: #fff; font-size: 0.8rem; position: absolute; bottom: 0; width: 100%; padding-top: 10px; padding-bottom: 10px; } footer a, footer a:visited, footer a:hover { color: #fecd1b; } footer a:hover { text-decoration: underline; } .link-color { color: #fecd1b; } pre { background-color: #f5f5f5; border: 1px solid #ccc; border-radius: 4px; padding: 9.5px; font-size: 0.8rem; } .btn.active { background-color: #e7e7e7; } .card { margin-bottom: 5px !important; overflow-x: auto; } .navbar-brand { padding: 5px; margin-right: 0; } .table { margin-bottom: 0; } .swh-table thead { background-color: #f2f4f5; border-top: 1px solid rgba(0, 0, 0, 0.2); font-weight: normal; } .swh-table-striped th { border-top: none; } .swh-table-striped tbody tr:nth-child(even) { background-color: #f2f4f5; } .swh-table-striped tbody tr:nth-child(odd) { background-color: #fff; } .swh-web-app-link a { text-decoration: none; border: none; } .swh-web-app-link:hover { background-color: #efeff2; } .table > thead > tr > th { border-top: none; border-bottom: 1px solid #e20026; } .table > tbody > tr > td { border-style: none; } .sitename .first-word, .sitename .second-word { color: rgba(0, 0, 0, 0.75); font-weight: normal; font-size: 1.2rem; } .sitename .first-word { font-family: "Alegreya Sans", sans-serif; } .sitename .second-word { font-family: "Alegreya", serif; } .swh-counter { font-size: 150%; } @media (max-width: 600px) { .swh-counter-container { margin-top: 1rem; } } .swh-http-error { margin: 0 auto; text-align: center; } .swh-http-error-head { color: #2d353c; font-size: 30px; } .swh-http-error-code { bottom: 60%; color: #2d353c; font-size: 96px; line-height: 80px; margin-bottom: 10px !important; } .swh-http-error-desc { font-size: 12px; color: #647788; text-align: center; } .swh-http-error-desc pre { display: inline-block; text-align: left; max-width: 800px; white-space: pre-wrap; } .swh-list-unstyled { list-style: none; } .popover { max-width: 97%; z-index: 40000; } .modal { text-align: center; padding: 0 !important; z-index: 50000; } .modal::before { content: ""; display: inline-block; height: 100%; vertical-align: middle; margin-right: -4px; } .modal-dialog { display: inline-block; text-align: left; vertical-align: middle; } .dropdown-submenu { position: relative; } .dropdown-submenu .dropdown-menu { top: 0; left: -100%; margin-top: -5px; margin-left: -2px; } .dropdown-item:hover, .dropdown-item:focus { background-color: rgba(0, 0, 0, 0.1); } a.dropdown-left::before { content: "\f035e"; font-family: "Material Design Icons"; display: block; width: 20px; height: 20px; float: left; margin-left: 0; } #swh-navbar { border-top-style: none; border-left-style: none; border-right-style: none; border-bottom-style: solid; border-bottom-width: 5px; border-image: linear-gradient( to right, rgb(226, 0, 38) 0%, rgb(254, 205, 27) 100% ) 1 1 1 1; width: 100%; padding: 5px; margin-bottom: 10px; margin-top: 30px; justify-content: normal; flex-wrap: nowrap; height: 72px; overflow: hidden; } #back-to-top { display: none; position: fixed; bottom: 30px; right: 30px; z-index: 10; } #back-to-top a img { display: block; width: 32px; height: 32px; background-size: 32px 32px; text-indent: -999px; overflow: hidden; } .swh-top-bar { direction: ltr; height: 30px; position: fixed; top: 0; left: 0; width: 100%; z-index: 99999; background-color: #262626; color: #fff; text-align: center; font-size: 14px; } .swh-top-bar ul { margin-top: 4px; padding-left: 0; white-space: nowrap; } .swh-top-bar li { display: inline-block; margin-left: 10px; margin-right: 10px; } .swh-top-bar a, .swh-top-bar a:visited { color: white; } .swh-top-bar a.swh-current-site, .swh-top-bar a.swh-current-site:visited { color: #fecd1b; } .swh-position-left { position: absolute; left: 0; } .swh-position-right { position: absolute; right: 0; } .swh-background-gray { background: #efeff2; } .swh-donate-link { border: 1px solid #fecd1b; background-color: #e20026; color: white !important; padding: 3px; border-radius: 3px; } .swh-navbar-content h4 { padding-top: 7px; } .swh-navbar-content .bread-crumbs { display: block; margin-left: -40px; } .swh-navbar-content .bread-crumbs li.bc-no-root { padding-top: 7px; } .main-sidebar { margin-top: 30px; } .content-wrapper { background: none; } .brand-image { max-height: 40px; } .brand-link { padding-top: 18.5px; padding-bottom: 18px; padding-left: 4px; border-bottom: 5px solid #e20026 !important; } .navbar-header a, ul.dropdown-menu a, ul.navbar-nav a, ul.nav-sidebar a { border-bottom-style: none; color: #323232; } .swh-sidebar .nav-link.active { color: #323232 !important; background-color: #e7e7e7 !important; } .nav-tabs .nav-link.active { border-top: 3px solid #e20026; } .swh-image-error { width: 80px; height: auto; } @media (max-width: 600px) { .card { min-width: 80%; } .swh-image-error { width: 40px; height: auto; } .swh-donate-link { display: none; } } .form-check-label { padding-top: 4px; } .swhid { white-space: pre-wrap; } .swhid .swhid-option { display: inline-block; margin-right: 5px; line-height: 1rem; } .nav-pills .nav-link:not(.active):hover { color: rgba(0, 0, 0, 0.55); } .swh-heading-color { color: #e20026 !important; } .sidebar-mini.sidebar-collapse .main-sidebar:hover { width: 4.6rem; } .sidebar-mini.sidebar-collapse .main-sidebar:hover .user-panel > .info, .sidebar-mini.sidebar-collapse .main-sidebar:hover .nav-sidebar .nav-link p, .sidebar-mini.sidebar-collapse .main-sidebar:hover .brand-text { visibility: hidden !important; } .sidebar .nav-link p, .main-sidebar .brand-text, .sidebar .user-panel .info { transition: none; } .sidebar-mini.sidebar-mini.sidebar-collapse .sidebar { padding-right: 0; } .swh-words-logo { position: absolute; top: 0; left: 0; width: 73px; height: 73px; text-align: center; font-size: 10pt; color: rgba(0, 0, 0, 0.75); } .swh-words-logo:hover { text-decoration: none; } .swh-words-logo-swh { line-height: 1; padding-top: 13px; visibility: hidden; } hr.swh-faded-line { border: 0; height: 1px; background-image: linear-gradient(to left, #f0f0f0, #8c8b8b, #f0f0f0); } /* Ensure that section title with link is colored like standard section title */ .swh-readme h1 a, .swh-readme h2 a, .swh-readme h3 a, .swh-readme h4 a, .swh-readme h5 a, .swh-readme h6 a { color: #e20026; } /* Make list compact in reStructuredText rendering */ .swh-rst li p { margin-bottom: 0; } .swh-readme-txt pre { background: none; border: none; } .swh-coverage { padding-top: 0.3rem; border: none; overflow: visible; } .swh-coverage a { text-decoration: none; } .swh-coverage-col { padding-left: 10px; padding-right: 10px; } .swh-coverage-header { padding-top: 0; padding-bottom: 0; } .swh-coverage-logo { display: block; width: 100%; height: 50px; margin-left: auto; margin-right: auto; object-fit: contain; /* polyfill for old browsers, see https://github.com/bfred-it/object-fit-images */ font-family: "object-fit: contain;"; } .swh-coverage-list { width: 100%; height: 320px; border: none; } .swh-coverage-chevron { position: absolute; right: 0; } .swh-coverage .card-header .mdi { transition: 0.3s transform ease-in-out; } .swh-coverage .card-header .collapsed .mdi { transform: rotate(90deg); } .swh-coverage-info-body { max-height: 150px; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; /* Firefox only */ padding: 0; } /* Thin scrollbar for chromium based browsers */ .swh-coverage-info-body::-webkit-scrollbar { width: 4px; } .swh-coverage-info-body::-webkit-scrollbar-track { background: #eff0f1; } .swh-coverage-info-body::-webkit-scrollbar-thumb { background: #909396; } tr.swh-tr-hover-highlight:hover td { background: #ededed; } tr.swh-api-doc-route a { text-decoration: none; } .swh-apidoc .col { margin: 10px; } .swh-apidoc .swh-rst blockquote { border: 0; margin: 0; padding: 0; } a.toggle-col { text-decoration: none; } a.toggle-col.col-hidden { text-decoration: line-through; } .admonition.warning { background: #fcf8e3; border: 1px solid #faebcc; padding: 15px; border-radius: 4px; } .admonition.warning p { margin-bottom: 0; } .admonition.warning .first { font-size: 1.5rem; } .swh-popover { max-height: 50vh; overflow-y: auto; overflow-x: auto; padding: 0; } @media screen and (min-width: 768px) { .swh-popover { max-width: 50vw; } } .swh-popover pre { white-space: pre-wrap; margin-bottom: 0; } .d3-wrapper { position: relative; height: 0; width: 100%; padding: 0; /* padding-bottom will be overwritten by JavaScript later */ padding-bottom: 100%; } .d3-wrapper > svg { position: absolute; height: 100%; width: 100%; left: 0; top: 0; } div.d3-tooltip { position: absolute; text-align: center; width: auto; height: auto; padding: 2px; font: 12px sans-serif; background: white; border: 1px solid black; border-radius: 4px; pointer-events: none; } .page-link { cursor: pointer; } .wrapper { overflow: hidden; } .swh-badge { padding-bottom: 1rem; cursor: pointer; } .swh-badge-html, +.swh-iframe-html, .swh-badge-md, .swh-badge-rst { white-space: pre-wrap !important; } /* Material Design icons alignment tweaks */ .mdi { display: inline-block; } .mdi-camera { transform: translateY(1px); } .mdi-source-commit { transform: translateY(2px); } /* To set icons at a fixed width. Great to use when different icon widths throw off alignment. Courtesy of Font Awesome. */ .mdi-fw { text-align: center; width: 1.25em; } .main-header .nav-link { height: inherit; } .nav-sidebar .nav-header:not(:first-of-type) { padding-top: 1rem; } .nav-sidebar .nav-link { padding-top: 0; padding-bottom: 0; } .nav-sidebar > .nav-item .nav-icon { vertical-align: sub; } .swh-search-icon { line-height: 1rem; vertical-align: middle; } .swh-search-navbar { position: absolute; top: 0.7rem; right: 15rem; z-index: 50000; width: 500px; } .sidebar-collapse .swh-search-navbar { right: 4rem; } .swh-corner-ribbon { width: 200px; background: #fecd1b; color: #e20026; position: absolute; text-align: center; letter-spacing: 1px; box-shadow: 0 0 3px rgba(0, 0, 0, 0.3); top: 55px; right: -50px; left: auto; transform: rotate(45deg); z-index: 2000; } @media screen and (max-width: 600px) { .swh-corner-ribbon { top: 53px; right: -65px; } } .invalid-feedback { font-size: 100%; } diff --git a/docs/index.rst b/docs/index.rst index 147e8302..7d7cc33d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,29 +1,30 @@ .. _swh-web: Software Heritage - Web applications ==================================== Web application(s) to browse the archive, for both interactive (HTML UI) and mechanized (REST API) use .. toctree:: :maxdepth: 2 :caption: Contents: developers-info uri-scheme-api uri-scheme-browse uri-scheme-identifiers + uri-scheme-misc Reference Documentation ----------------------- .. toctree:: :maxdepth: 2 * :ref:`routingtable` diff --git a/docs/uri-scheme-misc.rst b/docs/uri-scheme-misc.rst new file mode 100644 index 00000000..3b0817b0 --- /dev/null +++ b/docs/uri-scheme-misc.rst @@ -0,0 +1,41 @@ +Miscellaneous URLs +^^^^^^^^^^^^^^^^^^ + +Iframe view for contents and directories +---------------------------------------- + +A subset of Software Heritage objects (contents and directories) can be embedded +in external websites through the use of iframes. A dedicated endpoint serving +a minimalist Web UI is available for that use case. + +.. http:get:: /embed/(swhid)/ + + Endpoint to embed Software Heritage objects in external websites using + an iframe. + + :param string swhid: a SoftWare Heritage persistent IDentifier + object, or SWHID (see :ref:`persistent-identifiers` to learn more about its syntax) + + :statuscode 200: no error + :statuscode 400: the provided identifier is malformed or + the object type is not supported by the view + :statuscode 404: requested object cannot be found in the archive + + **Example:** + + By adding HTML code similar to the one below in a web page, + + .. code-block:: html + + + + you will obtain the following rendering. + + .. raw:: html + + + diff --git a/swh/web/common/utils.py b/swh/web/common/utils.py index 93a0df4f..17cf475b 100644 --- a/swh/web/common/utils.py +++ b/swh/web/common/utils.py @@ -1,390 +1,391 @@ # Copyright (C) 2017-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from datetime import datetime, timezone import os import re from typing import Any, Dict, List, Optional from bs4 import BeautifulSoup from docutils.core import publish_parts import docutils.parsers.rst import docutils.utils from docutils.writers.html5_polyglot import HTMLTranslator, Writer from iso8601 import ParseError, parse_date from pkg_resources import get_distribution from prometheus_client.registry import CollectorRegistry import requests from requests.auth import HTTPBasicAuth from django.core.cache import cache from django.http import HttpRequest, QueryDict from django.urls import reverse as django_reverse from swh.web.common.exc import BadInputExc from swh.web.common.typing import QueryParameters from swh.web.config import ORIGIN_VISIT_TYPES, get_config SWH_WEB_METRICS_REGISTRY = CollectorRegistry(auto_describe=True) swh_object_icons = { "alias": "mdi mdi-star", "branch": "mdi mdi-source-branch", "branches": "mdi mdi-source-branch", "content": "mdi mdi-file-document", "cnt": "mdi mdi-file-document", "directory": "mdi mdi-folder", "dir": "mdi mdi-folder", "origin": "mdi mdi-source-repository", "ori": "mdi mdi-source-repository", "person": "mdi mdi-account", "revisions history": "mdi mdi-history", "release": "mdi mdi-tag", "rel": "mdi mdi-tag", "releases": "mdi mdi-tag", "revision": "mdi mdi-rotate-90 mdi-source-commit", "rev": "mdi mdi-rotate-90 mdi-source-commit", "snapshot": "mdi mdi-camera", "snp": "mdi mdi-camera", "visits": "mdi mdi-calendar-month", } def reverse( viewname: str, url_args: Optional[Dict[str, Any]] = None, query_params: Optional[QueryParameters] = None, current_app: Optional[str] = None, urlconf: Optional[str] = None, request: Optional[HttpRequest] = None, ) -> str: """An override of django reverse function supporting query parameters. Args: viewname: the name of the django view from which to compute a url url_args: dictionary of url arguments indexed by their names query_params: dictionary of query parameters to append to the reversed url current_app: the name of the django app tighten to the view urlconf: url configuration module request: build an absolute URI if provided Returns: str: the url of the requested view with processed arguments and query parameters """ if url_args: url_args = {k: v for k, v in url_args.items() if v is not None} url = django_reverse( viewname, urlconf=urlconf, kwargs=url_args, current_app=current_app ) if query_params: query_params = {k: v for k, v in query_params.items() if v is not None} if query_params and len(query_params) > 0: query_dict = QueryDict("", mutable=True) for k in sorted(query_params.keys()): query_dict[k] = query_params[k] url += "?" + query_dict.urlencode(safe="/;:") if request is not None: url = request.build_absolute_uri(url) return url def datetime_to_utc(date): """Returns datetime in UTC without timezone info Args: date (datetime.datetime): input datetime with timezone info Returns: datetime.datetime: datetime in UTC without timezone info """ if date.tzinfo and date.tzinfo != timezone.utc: return date.astimezone(tz=timezone.utc) else: return date def parse_iso8601_date_to_utc(iso_date: str) -> datetime: """Given an ISO 8601 datetime string, parse the result as UTC datetime. Returns: a timezone-aware datetime representing the parsed date Raises: swh.web.common.exc.BadInputExc: provided date does not respect ISO 8601 format Samples: - 2016-01-12 - 2016-01-12T09:19:12+0100 - 2007-01-14T20:34:22Z """ try: date = parse_date(iso_date) return datetime_to_utc(date) except ParseError as e: raise BadInputExc(e) def shorten_path(path): """Shorten the given path: for each hash present, only return the first 8 characters followed by an ellipsis""" sha256_re = r"([0-9a-f]{8})[0-9a-z]{56}" sha1_re = r"([0-9a-f]{8})[0-9a-f]{32}" ret = re.sub(sha256_re, r"\1...", path) return re.sub(sha1_re, r"\1...", ret) def format_utc_iso_date(iso_date, fmt="%d %B %Y, %H:%M UTC"): """Turns a string representation of an ISO 8601 datetime string to UTC and format it into a more human readable one. For instance, from the following input string: '2017-05-04T13:27:13+02:00' the following one is returned: '04 May 2017, 11:27 UTC'. Custom format string may also be provided as parameter Args: iso_date (str): a string representation of an ISO 8601 date fmt (str): optional date formatting string Returns: str: a formatted string representation of the input iso date """ if not iso_date: return iso_date date = parse_iso8601_date_to_utc(iso_date) return date.strftime(fmt) def gen_path_info(path): """Function to generate path data navigation for use with a breadcrumb in the swh web ui. For instance, from a path /folder1/folder2/folder3, it returns the following list:: [{'name': 'folder1', 'path': 'folder1'}, {'name': 'folder2', 'path': 'folder1/folder2'}, {'name': 'folder3', 'path': 'folder1/folder2/folder3'}] Args: path: a filesystem path Returns: list: a list of path data for navigation as illustrated above. """ path_info = [] if path: sub_paths = path.strip("/").split("/") path_from_root = "" for p in sub_paths: path_from_root += "/" + p path_info.append({"name": p, "path": path_from_root.strip("/")}) return path_info def parse_rst(text, report_level=2): """ Parse a reStructuredText string with docutils. Args: text (str): string with reStructuredText markups in it report_level (int): level of docutils report messages to print (1 info 2 warning 3 error 4 severe 5 none) Returns: docutils.nodes.document: a parsed docutils document """ parser = docutils.parsers.rst.Parser() components = (docutils.parsers.rst.Parser,) settings = docutils.frontend.OptionParser( components=components ).get_default_values() settings.report_level = report_level document = docutils.utils.new_document("rst-doc", settings=settings) parser.parse(text, document) return document def get_client_ip(request): """ Return the client IP address from an incoming HTTP request. Args: request (django.http.HttpRequest): the incoming HTTP request Returns: str: The client IP address """ x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") if x_forwarded_for: ip = x_forwarded_for.split(",")[0] else: ip = request.META.get("REMOTE_ADDR") return ip browsers_supported_image_mimes = set( [ "image/gif", "image/png", "image/jpeg", "image/bmp", "image/webp", "image/svg", "image/svg+xml", ] ) def context_processor(request): """ Django context processor used to inject variables in all swh-web templates. """ config = get_config() if ( hasattr(request, "user") and request.user.is_authenticated and not hasattr(request.user, "backend") ): # To avoid django.template.base.VariableDoesNotExist errors # when rendering templates when standard Django user is logged in. request.user.backend = "django.contrib.auth.backends.ModelBackend" site_base_url = request.build_absolute_uri("/") return { "swh_object_icons": swh_object_icons, "available_languages": None, "swh_client_config": config["client_config"], "oidc_enabled": bool(config["keycloak"]["server_url"]), "browsers_supported_image_mimes": browsers_supported_image_mimes, "keycloak": config["keycloak"], "site_base_url": site_base_url, "DJANGO_SETTINGS_MODULE": os.environ["DJANGO_SETTINGS_MODULE"], "status": config["status"], "swh_web_dev": "localhost" in site_base_url, "swh_web_staging": any( [ server_name in site_base_url for server_name in config["staging_server_names"] ] ), "swh_web_version": get_distribution("swh.web").version, "visit_types": ORIGIN_VISIT_TYPES, + "iframe_mode": False, } def resolve_branch_alias( snapshot: Dict[str, Any], branch: Optional[Dict[str, Any]] ) -> Optional[Dict[str, Any]]: """ Resolve branch alias in snapshot content. Args: snapshot: a full snapshot content branch: a branch alias contained in the snapshot Returns: The real snapshot branch that got aliased. """ while branch and branch["target_type"] == "alias": if branch["target"] in snapshot["branches"]: branch = snapshot["branches"][branch["target"]] else: from swh.web.common import archive snp = archive.lookup_snapshot( snapshot["id"], branches_from=branch["target"], branches_count=1 ) if snp and branch["target"] in snp["branches"]: branch = snp["branches"][branch["target"]] else: branch = None return branch class _NoHeaderHTMLTranslator(HTMLTranslator): """ Docutils translator subclass to customize the generation of HTML from reST-formatted docstrings """ def __init__(self, document): super().__init__(document) self.body_prefix = [] self.body_suffix = [] _HTML_WRITER = Writer() _HTML_WRITER.translator_class = _NoHeaderHTMLTranslator def rst_to_html(rst: str) -> str: """ Convert reStructuredText document into HTML. Args: rst: A string containing a reStructuredText document Returns: Body content of the produced HTML conversion. """ settings = { "initial_header_level": 2, "halt_level": 4, "traceback": True, } pp = publish_parts(rst, writer=_HTML_WRITER, settings_overrides=settings) return f'
{pp["html_body"]}
' def prettify_html(html: str) -> str: """ Prettify an HTML document. Args: html: Input HTML document Returns: The prettified HTML document """ return BeautifulSoup(html, "lxml").prettify() def get_deposits_list() -> List[Dict[str, Any]]: """Return the list of software deposits using swh-deposit API """ config = get_config()["deposit"] deposits_list_url = config["private_api_url"] + "deposits" deposits_list_auth = HTTPBasicAuth( config["private_api_user"], config["private_api_password"] ) nb_deposits = requests.get( "%s?page_size=1" % deposits_list_url, auth=deposits_list_auth, timeout=30 ).json()["count"] deposits_data = cache.get("swh-deposit-list") if not deposits_data or deposits_data["count"] != nb_deposits: deposits_data = requests.get( "%s?page_size=%s" % (deposits_list_url, nb_deposits), auth=deposits_list_auth, timeout=30, ).json() cache.set("swh-deposit-list", deposits_data) return deposits_data["results"] diff --git a/swh/web/misc/iframe.py b/swh/web/misc/iframe.py new file mode 100644 index 00000000..e52e033a --- /dev/null +++ b/swh/web/misc/iframe.py @@ -0,0 +1,310 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from typing import Any, Dict, List, Optional + +from django.conf.urls import url +from django.shortcuts import render +from django.views.decorators.clickjacking import xframe_options_exempt + +from swh.model.hashutil import hash_to_bytes +from swh.model.identifiers import ( + CONTENT, + DIRECTORY, + REVISION, + SNAPSHOT, + ObjectType, + QualifiedSWHID, +) +from swh.web.browse.snapshot_context import get_snapshot_context +from swh.web.browse.utils import ( + content_display_max_size, + get_directory_entries, + prepare_content_for_display, + request_content, +) +from swh.web.common import archive +from swh.web.common.exc import BadInputExc, NotFoundExc, http_status_code_message +from swh.web.common.identifiers import get_swhid, get_swhids_info +from swh.web.common.typing import SnapshotContext, SWHObjectInfo +from swh.web.common.utils import gen_path_info, reverse + + +def _get_content_rendering_data(cnt_swhid: QualifiedSWHID, path: str) -> Dict[str, Any]: + content_data = request_content(f"sha1_git:{cnt_swhid.object_id.hex()}") + content = None + language = None + mimetype = None + if content_data.get("raw_data") is not None: + content_display_data = prepare_content_for_display( + content_data["raw_data"], content_data["mimetype"], path + ) + content = content_display_data["content_data"] + language = content_display_data["language"] + mimetype = content_display_data["mimetype"] + + return { + "content": content, + "content_size": content_data.get("length"), + "max_content_size": content_display_max_size, + "filename": path.split("/")[-1], + "encoding": content_data.get("encoding"), + "mimetype": mimetype, + "language": language, + } + + +def _get_directory_rendering_data( + dir_swhid: QualifiedSWHID, focus_swhid: QualifiedSWHID, path: str, +) -> Dict[str, Any]: + dirs, files = get_directory_entries(dir_swhid.object_id.hex()) + for d in dirs: + if d["type"] == "rev": + d["url"] = None + else: + dir_swhid = QualifiedSWHID( + object_type=ObjectType.DIRECTORY, + object_id=hash_to_bytes(d["target"]), + origin=dir_swhid.origin, + visit=dir_swhid.visit, + anchor=dir_swhid.anchor, + path=(path or "/") + d["name"] + "/", + ) + d["url"] = reverse( + "swhid-iframe", + url_args={"swhid": str(dir_swhid)}, + query_params={"focus_swhid": str(focus_swhid)}, + ) + + for f in files: + object_id = hash_to_bytes(f["target"]) + cnt_swhid = QualifiedSWHID( + object_type=ObjectType.CONTENT, + object_id=object_id, + origin=dir_swhid.origin, + visit=dir_swhid.visit, + anchor=dir_swhid.anchor, + path=(path or "/") + f["name"], + lines=(focus_swhid.lines if object_id == focus_swhid.object_id else None), + ) + f["url"] = reverse( + "swhid-iframe", + url_args={"swhid": str(cnt_swhid)}, + query_params={"focus_swhid": str(focus_swhid)}, + ) + + return {"dirs": dirs, "files": files} + + +def _get_breacrumbs_data( + swhid: QualifiedSWHID, + focus_swhid: QualifiedSWHID, + path: str, + snapshot_context: Optional[SnapshotContext] = None, +) -> List[Dict[str, Any]]: + breadcrumbs = [] + filename = None + # strip any leading or trailing slash from path qualifier of SWHID + if path and path[0] == "/": + path = path[1:] + if path and path[-1] == "/": + path = path[:-1] + if swhid.object_type == ObjectType.CONTENT: + split_path = path.split("/") + filename = split_path[-1] + path = path[: -len(filename)] + + path_info = gen_path_info(path) if path != "/" else [] + + root_dir = None + if snapshot_context and snapshot_context["root_directory"]: + root_dir = snapshot_context["root_directory"] + elif focus_swhid.object_type == ObjectType.DIRECTORY: + root_dir = focus_swhid.object_id.hex() + + if root_dir: + root_dir_swhid = QualifiedSWHID( + object_type=ObjectType.DIRECTORY, + object_id=hash_to_bytes(root_dir), + origin=swhid.origin, + visit=swhid.visit, + anchor=swhid.anchor, + ) + breadcrumbs.append( + { + "name": root_dir[:7], + "object_id": root_dir_swhid.object_id.hex(), + "path": "/", + "url": reverse( + "swhid-iframe", + url_args={"swhid": str(root_dir_swhid)}, + query_params={"focus_swhid": focus_swhid}, + ), + } + ) + + for pi in path_info: + dir_info = archive.lookup_directory_with_path(root_dir, pi["path"]) + dir_swhid = QualifiedSWHID( + object_type=ObjectType.DIRECTORY, + object_id=hash_to_bytes(dir_info["target"]), + origin=swhid.origin, + visit=swhid.visit, + anchor=swhid.anchor, + path="/" + pi["path"] + "/", + ) + breadcrumbs.append( + { + "name": pi["name"], + "object_id": dir_swhid.object_id.hex(), + "path": dir_swhid.path.decode("utf-8") if dir_swhid.path else "", + "url": reverse( + "swhid-iframe", + url_args={"swhid": str(dir_swhid)}, + query_params={"focus_swhid": focus_swhid}, + ), + } + ) + if filename: + breadcrumbs.append( + { + "name": filename, + "object_id": swhid.object_id.hex(), + "path": path, + "url": "", + } + ) + + return breadcrumbs + + +@xframe_options_exempt +def swhid_iframe(request, swhid: str): + """Django view that can be embedded in an iframe to display objects archived + by Software Heritage (currently contents and directories) in a minimalist + Web UI. + """ + focus_swhid = request.GET.get("focus_swhid", swhid) + parsed_swhid = None + view_data = {} + breadcrumbs = [] + swh_objects = [] + snapshot_context = None + swhids_info_extra_context = {} + try: + parsed_swhid = get_swhid(swhid) + parsed_focus_swhid = get_swhid(focus_swhid) + path = parsed_swhid.path.decode("utf-8") if parsed_swhid.path else "" + + snapshot_context = None + revision_id = None + if ( + parsed_swhid.anchor + and parsed_swhid.anchor.object_type == ObjectType.REVISION + ): + revision_id = parsed_swhid.anchor.object_id.hex() + if parsed_swhid.origin or parsed_swhid.visit: + snapshot_context = get_snapshot_context( + origin_url=parsed_swhid.origin, + snapshot_id=parsed_swhid.visit.object_id.hex() + if parsed_swhid.visit + else None, + revision_id=revision_id, + ) + + error_info: Dict[str, Any] = {"status_code": 200, "description": ""} + + if parsed_swhid and parsed_swhid.object_type == ObjectType.CONTENT: + view_data = _get_content_rendering_data(parsed_swhid, path) + swh_objects.append( + SWHObjectInfo( + object_type=CONTENT, object_id=parsed_swhid.object_id.hex() + ) + ) + + elif parsed_swhid and parsed_swhid.object_type == ObjectType.DIRECTORY: + view_data = _get_directory_rendering_data( + parsed_swhid, parsed_focus_swhid, path + ) + swh_objects.append( + SWHObjectInfo( + object_type=DIRECTORY, object_id=parsed_swhid.object_id.hex() + ) + ) + + elif parsed_swhid: + error_info = { + "status_code": 400, + "description": ( + f"Objects of type {parsed_swhid.object_type} are not supported" + ), + } + + swhids_info_extra_context["path"] = path + if parsed_swhid and view_data: + breadcrumbs = _get_breacrumbs_data( + parsed_swhid, parsed_focus_swhid, path, snapshot_context + ) + + if parsed_swhid.object_type == ObjectType.CONTENT and len(breadcrumbs) > 1: + swh_objects.append( + SWHObjectInfo( + object_type=DIRECTORY, object_id=breadcrumbs[-2]["object_id"] + ) + ) + swhids_info_extra_context["path"] = breadcrumbs[-2]["path"] + swhids_info_extra_context["filename"] = breadcrumbs[-1]["name"] + + if snapshot_context: + swh_objects.append( + SWHObjectInfo( + object_type=REVISION, + object_id=snapshot_context["revision_id"] or "", + ) + ) + swh_objects.append( + SWHObjectInfo( + object_type=SNAPSHOT, + object_id=snapshot_context["snapshot_id"] or "", + ) + ) + + except BadInputExc as e: + error_info = {"status_code": 400, "description": f"BadInputExc: {str(e)}"} + except NotFoundExc as e: + error_info = {"status_code": 404, "description": f"NotFoundExc: {str(e)}"} + except Exception as e: + error_info = {"status_code": 500, "description": str(e)} + + return render( + request, + "misc/iframe.html", + { + **view_data, + "iframe_mode": True, + "object_type": parsed_swhid.object_type.value if parsed_swhid else None, + "lines": parsed_swhid.lines if parsed_swhid else None, + "breadcrumbs": breadcrumbs, + "swhid": swhid, + "focus_swhid": focus_swhid, + "error_code": error_info["status_code"], + "error_message": http_status_code_message.get(error_info["status_code"]), + "error_description": error_info["description"], + "snapshot_context": None, + "swhids_info": get_swhids_info( + swh_objects, snapshot_context, swhids_info_extra_context + ), + }, + status=error_info["status_code"], + ) + + +urlpatterns = [ + url( + r"^embed/(?Pswh:[0-9]+:[a-z]+:[0-9a-f]+.*)$", + swhid_iframe, + name="swhid-iframe", + ), +] diff --git a/swh/web/misc/urls.py b/swh/web/misc/urls.py index 58336cd2..eb405b1a 100644 --- a/swh/web/misc/urls.py +++ b/swh/web/misc/urls.py @@ -1,102 +1,103 @@ -# Copyright (C) 2019 The Software Heritage developers +# Copyright (C) 2019-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import json import requests import sentry_sdk from django.conf.urls import include, url from django.contrib.staticfiles import finders from django.http import JsonResponse from django.shortcuts import render from swh.web.common import archive from swh.web.config import get_config from swh.web.misc.metrics import prometheus_metrics def _jslicenses(request): jslicenses_file = finders.find("jssources/jslicenses.json") jslicenses_data = json.load(open(jslicenses_file)) jslicenses_data = sorted( jslicenses_data.items(), key=lambda item: item[0].split("/")[-1] ) return render(request, "misc/jslicenses.html", {"jslicenses_data": jslicenses_data}) def _stat_counters(request): stat_counters = archive.stat_counters() url = get_config()["history_counters_url"] stat_counters_history = {} try: response = requests.get(url, timeout=5) stat_counters_history = json.loads(response.text) except Exception as exc: sentry_sdk.capture_exception(exc) counters = { "stat_counters": stat_counters, "stat_counters_history": stat_counters_history, } return JsonResponse(counters) urlpatterns = [ url(r"^", include("swh.web.misc.coverage")), url(r"^jslicenses/$", _jslicenses, name="jslicenses"), url(r"^", include("swh.web.misc.origin_save")), url(r"^stat_counters/", _stat_counters, name="stat-counters"), url(r"^", include("swh.web.misc.badges")), url(r"^metrics/prometheus/$", prometheus_metrics, name="metrics-prometheus"), + url(r"^", include("swh.web.misc.iframe")), ] # when running end to end tests trough cypress, declare some extra # endpoints to provide input data for some of those tests if get_config()["e2e_tests_mode"]: from swh.web.tests.views import ( get_content_code_data_all_exts, get_content_code_data_all_filenames, get_content_code_data_by_ext, get_content_code_data_by_filename, get_content_other_data_by_ext, ) urlpatterns.append( url( r"^tests/data/content/code/extension/(?P.+)/$", get_content_code_data_by_ext, name="tests-content-code-extension", ) ) urlpatterns.append( url( r"^tests/data/content/other/extension/(?P.+)/$", get_content_other_data_by_ext, name="tests-content-other-extension", ) ) urlpatterns.append( url( r"^tests/data/content/code/extensions/$", get_content_code_data_all_exts, name="tests-content-code-extensions", ) ) urlpatterns.append( url( r"^tests/data/content/code/filename/(?P.+)/$", get_content_code_data_by_filename, name="tests-content-code-filename", ) ) urlpatterns.append( url( r"^tests/data/content/code/filenames/$", get_content_code_data_all_filenames, name="tests-content-code-filenames", ) ) diff --git a/swh/web/templates/includes/breadcrumbs.html b/swh/web/templates/includes/breadcrumbs.html index 8d27e30f..869e7cd2 100644 --- a/swh/web/templates/includes/breadcrumbs.html +++ b/swh/web/templates/includes/breadcrumbs.html @@ -1,24 +1,23 @@ {% comment %} -Copyright (C) 2017-2018 The Software Heritage developers +Copyright (C) 2017-2021 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% if breadcrumbs %} - {% if breadcrumbs|length > 1 or breadcrumbs.0.url %} - - {% endif %} + {% if iframe_mode or breadcrumbs|length > 1 or breadcrumbs.0.url %} + + {% endif %} {% endif %} - diff --git a/swh/web/templates/includes/content-display.html b/swh/web/templates/includes/content-display.html index 292b31dd..f6aa480a 100644 --- a/swh/web/templates/includes/content-display.html +++ b/swh/web/templates/includes/content-display.html @@ -1,81 +1,85 @@ {% comment %} -Copyright (C) 2017-2020 The Software Heritage developers +Copyright (C) 2017-2021 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load swh_templatetags %} {% include "includes/revision-info.html" %} {% if snapshot_context and snapshot_context.is_empty %} {% include "includes/empty-snapshot.html" %} {% else %} -
- {% if filename %} -
- {{ filename }} -
- {% endif %} + {% if not iframe_mode %} +
+ {% if filename %} +
+ {{ filename }} +
+ {% endif %} + {% endif %}
{% if content_size > max_content_size %} Content is too large to be displayed (size is greater than {{ max_content_size|filesizeformat }}). {% elif "inode/x-empty" == mimetype %} File is empty {% elif filename and filename|default:""|slice:"-5:" == "ipynb" %}
{% elif "text/" in mimetype or "application/" in mimetype and encoding != "binary" %}
{{ content }}
{% elif mimetype in browsers_supported_image_mimes and content %} {% elif "application/pdf" == mimetype %}
Page: /
{% elif content %} Content with mime type {{ mimetype }} and encoding {{ encoding }} cannot be displayed. {% else %} {% include "includes/http-error.html" %} {% endif %}
-
+ {% if not iframe_mode %} +
+ {% endif %} {% if content %} {% endif %} {% endif %} diff --git a/swh/web/templates/includes/directory-display.html b/swh/web/templates/includes/directory-display.html index 61026cbb..50135399 100644 --- a/swh/web/templates/includes/directory-display.html +++ b/swh/web/templates/includes/directory-display.html @@ -1,65 +1,69 @@ {% comment %} -Copyright (C) 2017-2020 The Software Heritage developers +Copyright (C) 2017-2021 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} -{% include "includes/revision-info.html" %} +{% if not iframe_mode %} + {% include "includes/revision-info.html" %} +{% endif %} {% if snapshot_context and snapshot_context.is_empty %} {% include "includes/empty-snapshot.html" %} {% elif dirs|length > 0 or files|length > 0 %}
{% for d in dirs %} {% endfor %} {% for f in files %} {% endfor %}
File Mode Size
{{ d.name }}
{{ f.name }} {{ f.perms }} {{ f.length }}
-
+ {% if not iframe_mode %} +
+ {% endif %} {% elif "revision_found" in swh_object_metadata and swh_object_metadata.revision_found is False %} Revision {{ swh_object_metadata.revision }} could not be found in the archive.
Its associated directory can not be displayed. {% elif error_code != 200 %} {% include "includes/http-error.html" %} {% elif dirs|length == 0 and files|length == 0 %} Directory is empty {% endif %} diff --git a/swh/web/templates/includes/http-error.html b/swh/web/templates/includes/http-error.html index 33033688..28d8f145 100644 --- a/swh/web/templates/includes/http-error.html +++ b/swh/web/templates/includes/http-error.html @@ -1,33 +1,35 @@ {% comment %} -Copyright (C) 2018 The Software Heritage developers +Copyright (C) 2018-2021 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load static %}
Error
{{ error_code }}

{{ error_message }}

{{ error_description }}
- + {% if not iframe_mode %} + + {% endif %}
\ No newline at end of file diff --git a/swh/web/templates/includes/show-swhids.html b/swh/web/templates/includes/show-swhids.html index 122ae6a7..be805756 100644 --- a/swh/web/templates/includes/show-swhids.html +++ b/swh/web/templates/includes/show-swhids.html @@ -1,108 +1,118 @@ {% comment %} Copyright (C) 2017-2021 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load swh_templatetags %} {% if swhids_info %}