diff --git a/assets/src/bundles/admin/origin-save.js b/assets/src/bundles/admin/origin-save.js index aabc4aa2..f6c6d27f 100644 --- a/assets/src/bundles/admin/origin-save.js +++ b/assets/src/bundles/admin/origin-save.js @@ -1,353 +1,354 @@ /** * Copyright (C) 2018-2020 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 {handleFetchError, csrfPost, htmlAlert} from 'utils/functions'; import {swhSpinnerSrc} from 'utils/constants'; let authorizedOriginTable; let unauthorizedOriginTable; let pendingSaveRequestsTable; let acceptedSaveRequestsTable; let rejectedSaveRequestsTable; function enableRowSelection(tableSel) { $(`${tableSel} tbody`).on('click', 'tr', function() { if ($(this).hasClass('selected')) { $(this).removeClass('selected'); $(tableSel).closest('.tab-pane').find('.swh-action-need-selection').prop('disabled', true); } else { $(`${tableSel} tr.selected`).removeClass('selected'); $(this).addClass('selected'); $(tableSel).closest('.tab-pane').find('.swh-action-need-selection').prop('disabled', false); } }); } export function initOriginSaveAdmin() { $(document).ready(() => { $.fn.dataTable.ext.errMode = 'throw'; authorizedOriginTable = $('#swh-authorized-origin-urls').DataTable({ serverSide: true, ajax: Urls.admin_origin_save_authorized_urls_list(), columns: [{data: 'url', name: 'url'}], scrollY: '50vh', scrollCollapse: true, info: false }); enableRowSelection('#swh-authorized-origin-urls'); swh.webapp.addJumpToPagePopoverToDataTable(authorizedOriginTable); unauthorizedOriginTable = $('#swh-unauthorized-origin-urls').DataTable({ serverSide: true, ajax: Urls.admin_origin_save_unauthorized_urls_list(), columns: [{data: 'url', name: 'url'}], scrollY: '50vh', scrollCollapse: true, info: false }); enableRowSelection('#swh-unauthorized-origin-urls'); swh.webapp.addJumpToPagePopoverToDataTable(unauthorizedOriginTable); const columnsData = [ { data: 'id', name: 'id', visible: false, searchable: false }, { data: 'save_request_date', name: 'request_date', render: (data, type, row) => { if (type === 'display') { const date = new Date(data); return date.toLocaleString(); } return data; } }, { data: 'visit_type', name: 'visit_type' }, { data: 'origin_url', name: 'origin_url', render: (data, type, row) => { if (type === 'display') { let html = ''; const sanitizedURL = $.fn.dataTable.render.text().display(data); if (row.save_task_status === 'succeeded') { let browseOriginUrl = `${Urls.browse_origin()}?origin_url=${encodeURIComponent(sanitizedURL)}`; if (row.visit_date) { browseOriginUrl += `&timestamp=${encodeURIComponent(row.visit_date)}`; } html += `${sanitizedURL}`; } else { html += sanitizedURL; } - html += ` `; + html += ` ` + + ''; return html; } return data; } } ]; pendingSaveRequestsTable = $('#swh-origin-save-pending-requests').DataTable({ serverSide: true, processing: true, language: { processing: `` }, ajax: Urls.origin_save_requests_list('pending'), searchDelay: 1000, columns: columnsData, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']], responsive: { details: { type: 'none' } } }); enableRowSelection('#swh-origin-save-pending-requests'); swh.webapp.addJumpToPagePopoverToDataTable(pendingSaveRequestsTable); rejectedSaveRequestsTable = $('#swh-origin-save-rejected-requests').DataTable({ serverSide: true, processing: true, language: { processing: `` }, ajax: Urls.origin_save_requests_list('rejected'), searchDelay: 1000, columns: columnsData, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']], responsive: { details: { type: 'none' } } }); enableRowSelection('#swh-origin-save-rejected-requests'); swh.webapp.addJumpToPagePopoverToDataTable(rejectedSaveRequestsTable); columnsData.push({ data: 'save_task_status', name: 'save_task_status' }); columnsData.push({ name: 'info', render: (data, type, row) => { if (row.save_task_status === 'succeeded' || row.save_task_status === 'failed') { return '`; } else { return ''; } } }); acceptedSaveRequestsTable = $('#swh-origin-save-accepted-requests').DataTable({ serverSide: true, processing: true, language: { processing: `` }, ajax: Urls.origin_save_requests_list('accepted'), searchDelay: 1000, columns: columnsData, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']], responsive: { details: { type: 'none' } } }); enableRowSelection('#swh-origin-save-accepted-requests'); swh.webapp.addJumpToPagePopoverToDataTable(acceptedSaveRequestsTable); $('#swh-origin-save-requests-nav-item').on('shown.bs.tab', () => { pendingSaveRequestsTable.draw(); }); $('#swh-origin-save-url-filters-nav-item').on('shown.bs.tab', () => { authorizedOriginTable.draw(); }); $('#swh-authorized-origins-tab').on('shown.bs.tab', () => { authorizedOriginTable.draw(); }); $('#swh-unauthorized-origins-tab').on('shown.bs.tab', () => { unauthorizedOriginTable.draw(); }); $('#swh-save-requests-pending-tab').on('shown.bs.tab', () => { pendingSaveRequestsTable.draw(); }); $('#swh-save-requests-accepted-tab').on('shown.bs.tab', () => { acceptedSaveRequestsTable.draw(); }); $('#swh-save-requests-rejected-tab').on('shown.bs.tab', () => { rejectedSaveRequestsTable.draw(); }); $('#swh-save-requests-pending-tab').click(() => { pendingSaveRequestsTable.ajax.reload(null, false); }); $('#swh-save-requests-accepted-tab').click(() => { acceptedSaveRequestsTable.ajax.reload(null, false); }); $('#swh-save-requests-rejected-tab').click(() => { rejectedSaveRequestsTable.ajax.reload(null, false); }); $('body').on('click', e => { if ($(e.target).parents('.popover').length > 0) { e.stopPropagation(); } else if ($(e.target).parents('.swh-save-request-info').length === 0) { $('.swh-save-request-info').popover('dispose'); } }); }); } export async function addAuthorizedOriginUrl() { const originUrl = $('#swh-authorized-url-prefix').val(); const addOriginUrl = Urls.admin_origin_save_add_authorized_url(originUrl); try { const response = await csrfPost(addOriginUrl); handleFetchError(response); authorizedOriginTable.row.add({'url': originUrl}).draw(); $('.swh-add-authorized-origin-status').html( htmlAlert('success', 'The origin url prefix has been successfully added in the authorized list.', true) ); } catch (_) { $('.swh-add-authorized-origin-status').html( htmlAlert('warning', 'The provided origin url prefix is already registered in the authorized list.', true) ); } } export async function removeAuthorizedOriginUrl() { const originUrl = $('#swh-authorized-origin-urls tr.selected').text(); if (originUrl) { const removeOriginUrl = Urls.admin_origin_save_remove_authorized_url(originUrl); try { const response = await csrfPost(removeOriginUrl); handleFetchError(response); authorizedOriginTable.row('.selected').remove().draw(); } catch (_) {} } } export async function addUnauthorizedOriginUrl() { const originUrl = $('#swh-unauthorized-url-prefix').val(); const addOriginUrl = Urls.admin_origin_save_add_unauthorized_url(originUrl); try { const response = await csrfPost(addOriginUrl); handleFetchError(response); unauthorizedOriginTable.row.add({'url': originUrl}).draw(); $('.swh-add-unauthorized-origin-status').html( htmlAlert('success', 'The origin url prefix has been successfully added in the unauthorized list.', true) ); } catch (_) { $('.swh-add-unauthorized-origin-status').html( htmlAlert('warning', 'The provided origin url prefix is already registered in the unauthorized list.', true) ); } } export async function removeUnauthorizedOriginUrl() { const originUrl = $('#swh-unauthorized-origin-urls tr.selected').text(); if (originUrl) { const removeOriginUrl = Urls.admin_origin_save_remove_unauthorized_url(originUrl); try { const response = await csrfPost(removeOriginUrl); handleFetchError(response); unauthorizedOriginTable.row('.selected').remove().draw(); } catch (_) {}; } } export function acceptOriginSaveRequest() { const selectedRow = pendingSaveRequestsTable.row('.selected'); if (selectedRow.length) { const acceptOriginSaveRequestCallback = async() => { const rowData = selectedRow.data(); const acceptSaveRequestUrl = Urls.admin_origin_save_request_accept(rowData['visit_type'], rowData['origin_url']); await csrfPost(acceptSaveRequestUrl); pendingSaveRequestsTable.ajax.reload(null, false); }; swh.webapp.showModalConfirm( 'Accept origin save request ?', 'Are you sure to accept this origin save request ?', acceptOriginSaveRequestCallback); } } export function rejectOriginSaveRequest() { const selectedRow = pendingSaveRequestsTable.row('.selected'); if (selectedRow.length) { const rejectOriginSaveRequestCallback = async() => { const rowData = selectedRow.data(); const rejectSaveRequestUrl = Urls.admin_origin_save_request_reject(rowData['visit_type'], rowData['origin_url']); await csrfPost(rejectSaveRequestUrl); pendingSaveRequestsTable.ajax.reload(null, false); }; swh.webapp.showModalConfirm( 'Reject origin save request ?', 'Are you sure to reject this origin save request ?', rejectOriginSaveRequestCallback); } } function removeOriginSaveRequest(requestTable) { const selectedRow = requestTable.row('.selected'); if (selectedRow.length) { const requestId = selectedRow.data()['id']; const removeOriginSaveRequestCallback = async() => { const removeSaveRequestUrl = Urls.admin_origin_save_request_remove(requestId); await csrfPost(removeSaveRequestUrl); requestTable.ajax.reload(null, false); }; swh.webapp.showModalConfirm( 'Remove origin save request ?', 'Are you sure to remove this origin save request ?', removeOriginSaveRequestCallback); } } export function removePendingOriginSaveRequest() { removeOriginSaveRequest(pendingSaveRequestsTable); } export function removeAcceptedOriginSaveRequest() { removeOriginSaveRequest(acceptedSaveRequestsTable); } export function removeRejectedOriginSaveRequest() { removeOriginSaveRequest(rejectedSaveRequestsTable); } diff --git a/assets/src/bundles/save/index.js b/assets/src/bundles/save/index.js index 0a34d78f..06876012 100644 --- a/assets/src/bundles/save/index.js +++ b/assets/src/bundles/save/index.js @@ -1,566 +1,567 @@ /** * 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 {csrfPost, handleFetchError, isGitRepoUrl, htmlAlert, removeUrlFragment, getCanonicalOriginURL} from 'utils/functions'; import {swhSpinnerSrc} from 'utils/constants'; import artifactFormRowTemplate from './artifact-form-row.ejs'; let saveRequestsTable; async function originSaveRequest( originType, originUrl, extraData, acceptedCallback, pendingCallback, errorCallback ) { // Actually trigger the origin save request const addSaveOriginRequestUrl = Urls.api_1_save_origin(originType, originUrl); $('.swh-processing-save-request').css('display', 'block'); let headers = {}; let body = null; if (extraData !== {}) { body = JSON.stringify(extraData); headers = { 'Content-Type': 'application/json' }; }; try { const response = await csrfPost(addSaveOriginRequestUrl, headers, body); handleFetchError(response); const data = await response.json(); $('.swh-processing-save-request').css('display', 'none'); if (data.save_request_status === 'accepted') { acceptedCallback(); } else { pendingCallback(); } } catch (response) { $('.swh-processing-save-request').css('display', 'none'); const errorData = await response.json(); errorCallback(response.status, errorData); }; } function addArtifactVersionAutofillHandler(formId) { // autofill artifact version input with the filename from // the artifact url without extensions $(`#swh-input-artifact-url-${formId}`).on('input', function(event) { const artifactUrl = $(this).val().trim(); let filename = artifactUrl.split('/').slice(-1)[0]; if (filename !== artifactUrl) { filename = filename.replace(/tar.*$/, 'tar'); const filenameNoExt = filename.split('.').slice(0, -1).join('.'); const artifactVersion = $(`#swh-input-artifact-version-${formId}`); if (filenameNoExt !== filename) { artifactVersion.val(filenameNoExt); } } }); } export function maybeRequireExtraInputs() { // Read the actual selected value and depending on the origin type, display some extra // inputs or hide them. This makes the extra inputs disabled when not displayed. const originType = $('#swh-input-visit-type').val(); let display = 'none'; let disabled = true; if (originType === 'archives') { display = 'flex'; disabled = false; } $('.swh-save-origin-archives-form').css('display', display); if (!disabled) { // help paragraph must have block display for proper rendering $('#swh-save-origin-archives-help').css('display', 'block'); } $('.swh-save-origin-archives-form .form-control').prop('disabled', disabled); if (originType === 'archives' && $('.swh-save-origin-archives-form').length === 1) { // insert first artifact row when the archives visit type is selected for the first time $('.swh-save-origin-archives-form').last().after( artifactFormRowTemplate({deletableRow: false, formId: 0})); addArtifactVersionAutofillHandler(0); } } export function addArtifactFormRow() { const formId = $('.swh-save-origin-artifact-form').length; $('.swh-save-origin-artifact-form').last().after( artifactFormRowTemplate({ deletableRow: true, formId: formId }) ); addArtifactVersionAutofillHandler(formId); } export function deleteArtifactFormRow(event) { $(event.target).closest('.swh-save-origin-artifact-form').remove(); } const userRequestsFilterCheckbox = `
`; export function initOriginSave() { $(document).ready(async() => { $.fn.dataTable.ext.errMode = 'none'; const response = await fetch(Urls.origin_save_types_list()); const data = await response.json(); for (const originType of data) { $('#swh-input-visit-type').append(``); } // set git as the default value as before $('#swh-input-visit-type').val('git'); saveRequestsTable = $('#swh-origin-save-requests') .on('error.dt', (e, settings, techNote, message) => { $('#swh-origin-save-request-list-error').text('An error occurred while retrieving the save requests list'); console.log(message); }) .DataTable({ serverSide: true, processing: true, language: { processing: `` }, ajax: { url: Urls.origin_save_requests_list('all'), data: (d) => { if (swh.webapp.isUserLoggedIn() && $('#swh-save-requests-user-filter').prop('checked')) { d.user_requests_only = '1'; } } }, searchDelay: 1000, // see https://datatables.net/examples/advanced_init/dom_toolbar.html and the comments section // this option customizes datatables UI components by adding an extra checkbox above the table // while keeping bootstrap layout dom: '<"row"<"col-sm-3"l><"col-sm-6 text-left user-requests-filter"><"col-sm-3"f>>' + '<"row"<"col-sm-12"tr>>' + '<"row"<"col-sm-5"i><"col-sm-7"p>>', fnInitComplete: function() { if (swh.webapp.isUserLoggedIn()) { $('div.user-requests-filter').html(userRequestsFilterCheckbox); $('#swh-save-requests-user-filter').on('change', () => { saveRequestsTable.draw(); }); } }, columns: [ { data: 'save_request_date', name: 'request_date', render: (data, type, row) => { if (type === 'display') { const date = new Date(data); return date.toLocaleString(); } return data; } }, { data: 'visit_type', name: 'visit_type' }, { data: 'origin_url', name: 'origin_url', render: (data, type, row) => { if (type === 'display') { let html = ''; const sanitizedURL = $.fn.dataTable.render.text().display(data); if (row.save_task_status === 'succeeded') { let browseOriginUrl = `${Urls.browse_origin()}?origin_url=${encodeURIComponent(sanitizedURL)}`; if (row.visit_date) { browseOriginUrl += `&timestamp=${encodeURIComponent(row.visit_date)}`; } html += `${sanitizedURL}`; } else { html += sanitizedURL; } - html += ` `; + html += ` ` + + ''; return html; } return data; } }, { data: 'save_request_status', name: 'status' }, { data: 'save_task_status', name: 'loading_task_status' }, { name: 'info', render: (data, type, row) => { if (row.save_task_status === 'succeeded' || row.save_task_status === 'failed') { return ``; } else { return ''; } } }, { render: (data, type, row) => { if (row.save_request_status === 'accepted') { const saveAgainButton = ''; return saveAgainButton; } else { return ''; } } } ], scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']], responsive: { details: { type: 'none' } } }); swh.webapp.addJumpToPagePopoverToDataTable(saveRequestsTable); $('#swh-origin-save-requests-list-tab').on('shown.bs.tab', () => { saveRequestsTable.draw(); window.location.hash = '#requests'; }); $('#swh-origin-save-request-help-tab').on('shown.bs.tab', () => { removeUrlFragment(); $('.swh-save-request-info').popover('dispose'); }); const saveRequestAcceptedAlert = htmlAlert( 'success', 'The "save code now" request has been accepted and will be processed as soon as possible.', true ); const saveRequestPendingAlert = htmlAlert( 'warning', 'The "save code now" request has been put in pending state and may be accepted for processing after manual review.', true ); const saveRequestRateLimitedAlert = htmlAlert( 'danger', 'The rate limit for "save code now" requests has been reached. Please try again later.', true ); const saveRequestUnknownErrorAlert = htmlAlert( 'danger', 'An unexpected error happened when submitting the "save code now request".', true ); $('#swh-save-origin-form').submit(async event => { event.preventDefault(); event.stopPropagation(); $('.alert').alert('close'); if (event.target.checkValidity()) { $(event.target).removeClass('was-validated'); const originType = $('#swh-input-visit-type').val(); let originUrl = $('#swh-input-origin-url').val(); originUrl = await getCanonicalOriginURL(originUrl); // read the extra inputs for the 'archives' type const extraData = {}; if (originType === 'archives') { extraData['archives_data'] = []; for (let i = 0; i < $('.swh-save-origin-artifact-form').length; ++i) { extraData['archives_data'].push({ 'artifact_url': $(`#swh-input-artifact-url-${i}`).val(), 'artifact_version': $(`#swh-input-artifact-version-${i}`).val() }); } } originSaveRequest(originType, originUrl, extraData, () => $('#swh-origin-save-request-status').html(saveRequestAcceptedAlert), () => $('#swh-origin-save-request-status').html(saveRequestPendingAlert), (statusCode, errorData) => { $('#swh-origin-save-request-status').css('color', 'red'); if (statusCode === 403) { const errorAlert = htmlAlert('danger', `Error: ${errorData['reason']}`); $('#swh-origin-save-request-status').html(errorAlert); } else if (statusCode === 429) { $('#swh-origin-save-request-status').html(saveRequestRateLimitedAlert); } else if (statusCode === 400) { const errorAlert = htmlAlert('danger', errorData['reason']); $('#swh-origin-save-request-status').html(errorAlert); } else { $('#swh-origin-save-request-status').html(saveRequestUnknownErrorAlert); } }); } else { $(event.target).addClass('was-validated'); } }); $('#swh-show-origin-save-requests-list').on('click', (event) => { event.preventDefault(); $('.nav-tabs a[href="#swh-origin-save-requests-list"]').tab('show'); }); $('#swh-input-origin-url').on('input', function(event) { const originUrl = $(this).val().trim(); $(this).val(originUrl); $('#swh-input-visit-type option').each(function() { const val = $(this).val(); if (val && originUrl.includes(val)) { $(this).prop('selected', true); } }); }); if (window.location.hash === '#requests') { $('.nav-tabs a[href="#swh-origin-save-requests-list"]').tab('show'); } }); } export function validateSaveOriginUrl(input) { const originType = $('#swh-input-visit-type').val(); let originUrl = null; let validUrl = true; try { originUrl = new URL(input.value.trim()); } catch (TypeError) { validUrl = false; } if (validUrl) { const allowedProtocols = ['http:', 'https:', 'svn:', 'git:']; validUrl = ( allowedProtocols.find(protocol => protocol === originUrl.protocol) !== undefined ); } if (validUrl && originType === 'git') { // additional checks for well known code hosting providers switch (originUrl.hostname) { case 'github.com': validUrl = isGitRepoUrl(originUrl); break; case 'git.code.sf.net': validUrl = isGitRepoUrl(originUrl, '/p/'); break; case 'bitbucket.org': validUrl = isGitRepoUrl(originUrl); break; default: if (originUrl.hostname.startsWith('gitlab.')) { validUrl = isGitRepoUrl(originUrl); } break; } } if (validUrl) { input.setCustomValidity(''); } else { input.setCustomValidity('The origin url is not valid or does not reference a code repository'); } } export function initTakeNewSnapshot() { const newSnapshotRequestAcceptedAlert = htmlAlert( 'success', 'The "take new snapshot" request has been accepted and will be processed as soon as possible.', true ); const newSnapshotRequestPendingAlert = htmlAlert( 'warning', 'The "take new snapshot" request has been put in pending state and may be accepted for processing after manual review.', true ); const newSnapshotRequestRateLimitAlert = htmlAlert( 'danger', 'The rate limit for "take new snapshot" requests has been reached. Please try again later.', true ); const newSnapshotRequestUnknownErrorAlert = htmlAlert( 'danger', 'An unexpected error happened when submitting the "save code now request".', true ); $(document).ready(() => { $('#swh-take-new-snapshot-form').submit(event => { event.preventDefault(); event.stopPropagation(); const originType = $('#swh-input-visit-type').val(); const originUrl = $('#swh-input-origin-url').val(); const extraData = {}; originSaveRequest(originType, originUrl, extraData, () => $('#swh-take-new-snapshot-request-status').html(newSnapshotRequestAcceptedAlert), () => $('#swh-take-new-snapshot-request-status').html(newSnapshotRequestPendingAlert), (statusCode, errorData) => { $('#swh-take-new-snapshot-request-status').css('color', 'red'); if (statusCode === 403) { const errorAlert = htmlAlert('danger', `Error: ${errorData['detail']}`, true); $('#swh-take-new-snapshot-request-status').html(errorAlert); } else if (statusCode === 429) { $('#swh-take-new-snapshot-request-status').html(newSnapshotRequestRateLimitAlert); } else { $('#swh-take-new-snapshot-request-status').html(newSnapshotRequestUnknownErrorAlert); } }); }); }); } export function formatValuePerType(type, value) { // Given some typed value, format and return accordingly formatted value const mapFormatPerTypeFn = { 'json': (v) => JSON.stringify(v, null, 2), 'date': (v) => new Date(v).toLocaleString(), 'raw': (v) => v, 'duration': (v) => v + ' seconds' }; return value === null ? null : mapFormatPerTypeFn[type](value); } export async function displaySaveRequestInfo(event, saveRequestId) { event.stopPropagation(); const saveRequestTaskInfoUrl = Urls.origin_save_task_info(saveRequestId); // close popover when clicking again on the info icon if ($(event.target).data('bs.popover')) { $(event.target).popover('dispose'); return; } $('.swh-save-request-info').popover('dispose'); $(event.target).popover({ animation: false, boundary: 'viewport', container: 'body', title: 'Save request task information ' + '`, content: `

Fetching task information ...

`, html: true, placement: 'left', sanitizeFn: swh.webapp.filterXSS }); $(event.target).on('shown.bs.popover', function() { const popoverId = $(this).attr('aria-describedby'); $(`#${popoverId} .mdi-close`).click(() => { $(this).popover('dispose'); }); }); $(event.target).popover('show'); const response = await fetch(saveRequestTaskInfoUrl); const saveRequestTaskInfo = await response.json(); let content; if ($.isEmptyObject(saveRequestTaskInfo)) { content = 'Not available'; } else { const saveRequestInfo = []; const taskData = { 'Type': ['raw', 'type'], 'Visit status': ['raw', 'visit_status'], 'Arguments': ['json', 'arguments'], 'Id': ['raw', 'id'], 'Backend id': ['raw', 'backend_id'], 'Scheduling date': ['date', 'scheduled'], 'Start date': ['date', 'started'], 'Completion date': ['date', 'ended'], 'Duration': ['duration', 'duration'], 'Runner': ['raw', 'worker'], 'Log': ['raw', 'message'] }; for (const [title, [type, property]] of Object.entries(taskData)) { if (saveRequestTaskInfo.hasOwnProperty(property)) { saveRequestInfo.push({ key: title, value: formatValuePerType(type, saveRequestTaskInfo[property]) }); } } content = ''; for (const info of saveRequestInfo) { content += ``; } content += '
'; } $('.swh-popover').html(content); $(event.target).popover('update'); } export function fillSaveRequestFormAndScroll(visitType, originUrl) { $('#swh-input-origin-url').val(originUrl); let originTypeFound = false; $('#swh-input-visit-type option').each(function() { const val = $(this).val(); if (val && originUrl.includes(val)) { $(this).prop('selected', true); originTypeFound = true; } }); if (!originTypeFound) { $('#swh-input-visit-type option').each(function() { const val = $(this).val(); if (val === visitType) { $(this).prop('selected', true); } }); } window.scrollTo(0, 0); } diff --git a/cypress/integration/origin-save.spec.js b/cypress/integration/origin-save.spec.js index b2300530..0c245eb0 100644 --- a/cypress/integration/origin-save.spec.js +++ b/cypress/integration/origin-save.spec.js @@ -1,711 +1,711 @@ /** * 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 */ let url; let origin; const $ = Cypress.$; const saveCodeMsg = { 'success': 'The "save code now" request has been accepted and will be processed as soon as possible.', 'warning': 'The "save code now" request has been put in pending state and may be accepted for processing after manual review.', 'rejected': 'The "save code now" request has been rejected because the provided origin url is blacklisted.', 'rateLimit': 'The rate limit for "save code now" requests has been reached. Please try again later.', 'not-found': 'The provided url does not exist', 'unknownError': 'An unexpected error happened when submitting the "save code now request', 'csrfError': 'CSRF Failed: Referrer checking failed - no Referrer.' }; const anonymousVisitTypes = ['git', 'hg', 'svn']; const allVisitTypes = ['archives', 'git', 'hg', 'svn']; function makeOriginSaveRequest(originType, originUrl) { cy.get('#swh-input-origin-url') .type(originUrl) .get('#swh-input-visit-type') .select(originType) .get('#swh-save-origin-form') .submit(); } function checkAlertVisible(alertType, msg) { cy.get('#swh-origin-save-request-status') .should('be.visible') .find(`.alert-${alertType}`) .should('be.visible') .and('contain', msg); } // Stub requests to save an origin function stubSaveRequest({ requestUrl, visitType = 'git', saveRequestStatus, originUrl, saveTaskStatus, responseStatus = 200, // For error code with the error message in the 'reason' key response errorMessage = '', saveRequestDate = new Date(), visitDate = new Date(), visitStatus = null } = {}) { let response; if (responseStatus !== 200 && errorMessage) { response = { 'reason': errorMessage }; } else { response = genOriginSaveResponse({visitType: visitType, saveRequestStatus: saveRequestStatus, originUrl: originUrl, saveRequestDate: saveRequestDate, saveTaskStatus: saveTaskStatus, visitDate: visitDate, visitStatus: visitStatus }); } cy.intercept('POST', requestUrl, {body: response, statusCode: responseStatus}) .as('saveRequest'); } // Mocks API response : /save/(:visit_type)/(:origin_url) // visit_type : {'git', 'hg', 'svn', ...} function genOriginSaveResponse({ visitType = 'git', saveRequestStatus, originUrl, saveRequestDate = new Date(), saveTaskStatus, visitDate = new Date(), visitStatus } = {}) { return { 'visit_type': visitType, 'save_request_status': saveRequestStatus, 'origin_url': originUrl, 'id': 1, 'save_request_date': saveRequestDate ? saveRequestDate.toISOString() : null, 'save_task_status': saveTaskStatus, 'visit_date': visitDate ? visitDate.toISOString() : null, 'visit_status': visitStatus }; }; describe('Origin Save Tests', function() { before(function() { url = this.Urls.origin_save(); origin = this.origin[0]; this.originSaveUrl = this.Urls.api_1_save_origin(origin.type, origin.url); }); beforeEach(function() { cy.fixture('origin-save').as('originSaveJSON'); cy.fixture('save-task-info').as('saveTaskInfoJSON'); cy.visit(url); }); it('should format appropriately values depending on their type', function() { const inputValues = [ // null values stay null {type: 'json', value: null, expectedValue: null}, {type: 'date', value: null, expectedValue: null}, {type: 'raw', value: null, expectedValue: null}, {type: 'duration', value: null, expectedValue: null}, // non null values formatted depending on their type {type: 'json', value: '{}', expectedValue: '"{}"'}, {type: 'date', value: '04/04/2021 01:00:00', expectedValue: '4/4/2021, 1:00:00 AM'}, {type: 'raw', value: 'value-for-identity', expectedValue: 'value-for-identity'}, {type: 'duration', value: '10', expectedValue: '10 seconds'}, {type: 'duration', value: 100, expectedValue: '100 seconds'} ]; cy.window().then(win => { inputValues.forEach(function(input, index, array) { const actualValue = win.swh.save.formatValuePerType(input.type, input.value); assert.equal(actualValue, input.expectedValue); }); }); }); it('should display accepted message when accepted', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'accepted', originUrl: origin.url, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should validate gitlab subproject url', function() { const gitlabSubProjectUrl = 'https://gitlab.com/user/project/sub/'; const originSaveUrl = this.Urls.api_1_save_origin('git', gitlabSubProjectUrl); stubSaveRequest({requestUrl: originSaveUrl, saveRequestStatus: 'accepted', originurl: gitlabSubProjectUrl, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', gitlabSubProjectUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should validate project url with _ in username', function() { const gitlabSubProjectUrl = 'https://gitlab.com/user_name/project.git'; const originSaveUrl = this.Urls.api_1_save_origin('git', gitlabSubProjectUrl); stubSaveRequest({requestUrl: originSaveUrl, saveRequestStatus: 'accepted', originurl: gitlabSubProjectUrl, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', gitlabSubProjectUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should display warning message when pending', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'pending', originUrl: origin.url, saveTaskStatus: 'not created'}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('warning', saveCodeMsg['warning']); }); }); it('should show error when the origin does not exist (status: 400)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, originUrl: origin.url, responseStatus: 400, errorMessage: saveCodeMsg['not-found']}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['not-found']); }); }); it('should show error when csrf validation failed (status: 403)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'rejected', originUrl: origin.url, saveTaskStatus: 'not created', responseStatus: 403, errorMessage: saveCodeMsg['csrfError']}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['csrfError']); }); }); it('should show error when origin is rejected (status: 403)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'rejected', originUrl: origin.url, saveTaskStatus: 'not created', responseStatus: 403, errorMessage: saveCodeMsg['rejected']}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['rejected']); }); }); it('should show error when rate limited (status: 429)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'Request was throttled. Expected available in 60 seconds.', originUrl: origin.url, saveTaskStatus: 'not created', responseStatus: 429}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['rateLimit']); }); }); it('should show error when unknown error occurs (status other than 200, 403, 429)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'Error', originUrl: origin.url, saveTaskStatus: 'not created', responseStatus: 406}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['unknownError']); }); }); it('should display origin save info in the requests table', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}); cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('tbody tr').then(rows => { let i = 0; for (const row of rows) { const cells = row.cells; const requestDateStr = new Date(this.originSaveJSON.data[i].save_request_date).toLocaleString(); const saveStatus = this.originSaveJSON.data[i].save_task_status; assert.equal($(cells[0]).text(), requestDateStr); assert.equal($(cells[1]).text(), this.originSaveJSON.data[i].visit_type); let html = ''; if (saveStatus === 'succeeded') { let browseOriginUrl = `${this.Urls.browse_origin()}?origin_url=${encodeURIComponent(this.originSaveJSON.data[i].origin_url)}`; browseOriginUrl += `&timestamp=${encodeURIComponent(this.originSaveJSON.data[i].visit_date)}`; html += `${this.originSaveJSON.data[i].origin_url}`; } else { html += this.originSaveJSON.data[i].origin_url; } - html += ` `; + html += ` `; html += ''; assert.equal($(cells[2]).html(), html); assert.equal($(cells[3]).text(), this.originSaveJSON.data[i].save_request_status); assert.equal($(cells[4]).text(), saveStatus); ++i; } }); }); it('should not add timestamp to the browse origin URL is no visit date has been found', function() { const originUrl = 'https://git.example.org/example.git'; const saveRequestData = genOriginSaveResponse({ saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'succeeded', visitDate: null, visitStatus: 'full' }); const saveRequestsListData = { 'recordsTotal': 1, 'draw': 2, 'recordsFiltered': 1, 'data': [saveRequestData] }; cy.intercept('/save/requests/list/**', {body: saveRequestsListData}) .as('saveRequestsList'); cy.get('#swh-origin-save-requests-list-tab').click(); cy.wait('@saveRequestsList'); cy.get('tbody tr').then(rows => { const firstRowCells = rows[0].cells; const browseOriginUrl = `${this.Urls.browse_origin()}?origin_url=${encodeURIComponent(originUrl)}`; const browseOriginLink = `${originUrl}`; expect($(firstRowCells[2]).html()).to.have.string(browseOriginLink); }); }); it('should display/close task info popover when clicking on the info button', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}); cy.intercept('/save/task/info/**', {fixture: 'save-task-info'}); cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('.swh-save-request-info') .eq(0) .click(); cy.get('.swh-save-request-info-popover') .should('be.visible'); cy.get('.swh-save-request-info') .eq(0) .click(); cy.get('.swh-save-request-info-popover') .should('not.exist'); }); it('should hide task info popover when clicking on the close button', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}); cy.intercept('/save/task/info/**', {fixture: 'save-task-info'}); cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('.swh-save-request-info') .eq(0) .click(); cy.get('.swh-save-request-info-popover') .should('be.visible'); cy.get('.swh-save-request-info-close') .click(); cy.get('.swh-save-request-info-popover') .should('not.exist'); }); it('should fill save request form when clicking on "Save again" button', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}); cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('.swh-save-origin-again') .eq(0) .click(); cy.get('tbody tr').eq(0).then(row => { const cells = row[0].cells; cy.get('#swh-input-visit-type') .should('have.value', $(cells[1]).text()); cy.get('#swh-input-origin-url') .should('have.value', $(cells[2]).text().slice(0, -1)); }); }); it('should select correct visit type if possible when clicking on "Save again" button', function() { const originUrl = 'https://gitlab.inria.fr/solverstack/maphys/maphys/'; const badVisitType = 'hg'; const goodVisitType = 'git'; cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}); stubSaveRequest({requestUrl: this.Urls.api_1_save_origin(badVisitType, originUrl), visitType: badVisitType, saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'failed', visitStatus: 'failed', responseStatus: 200, errorMessage: saveCodeMsg['accepted']}); makeOriginSaveRequest(badVisitType, originUrl); cy.get('#swh-origin-save-requests-list-tab').click(); cy.wait('@saveRequest').then(() => { cy.get('.swh-save-origin-again') .eq(0) .click(); cy.get('tbody tr').eq(0).then(row => { const cells = row[0].cells; cy.get('#swh-input-visit-type') .should('have.value', goodVisitType); cy.get('#swh-input-origin-url') .should('have.value', $(cells[2]).text().slice(0, -1)); }); }); }); it('should create save request for authenticated user', function() { cy.userLogin(); cy.visit(url); const originUrl = 'https://git.example.org/account/repo'; stubSaveRequest({requestUrl: this.Urls.api_1_save_origin('git', originUrl), saveRequestStatus: 'accepted', originUrl: origin.url, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', originUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should not show user requests filter checkbox for anonymous users', function() { cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('#swh-save-requests-user-filter').should('not.exist'); }); it('should show user requests filter checkbox for authenticated users', function() { cy.userLogin(); cy.visit(url); cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('#swh-save-requests-user-filter').should('exist'); }); it('should show only user requests when filter is activated', function() { cy.intercept('POST', '/api/1/origin/save/**') .as('saveRequest'); const originAnonymousUser = 'https://some.git.server/project/'; const originAuthUser = 'https://other.git.server/project/'; // anonymous user creates a save request makeOriginSaveRequest('git', originAnonymousUser); cy.wait('@saveRequest'); // authenticated user creates another save request cy.userLogin(); cy.visit(url); makeOriginSaveRequest('git', originAuthUser); cy.wait('@saveRequest'); // user requests filter checkbox should be in the DOM cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('#swh-save-requests-user-filter').should('exist'); // check unfiltered user requests cy.get('tbody tr').then(rows => { expect(rows.length).to.eq(2); expect($(rows[0].cells[2]).text()).to.contain(originAuthUser); expect($(rows[1].cells[2]).text()).to.contain(originAnonymousUser); }); // activate filter and check filtered user requests cy.get('#swh-save-requests-user-filter') .click({force: true}); cy.get('tbody tr').then(rows => { expect(rows.length).to.eq(1); expect($(rows[0].cells[2]).text()).to.contain(originAuthUser); }); // deactivate filter and check unfiltered user requests cy.get('#swh-save-requests-user-filter') .click({force: true}); cy.get('tbody tr').then(rows => { expect(rows.length).to.eq(2); }); }); it('should list unprivileged visit types when not connected', function() { cy.visit(url); cy.get('#swh-input-visit-type').children('option').then(options => { const actual = [...options].map(o => o.value); expect(actual).to.deep.eq(anonymousVisitTypes); }); }); it('should list unprivileged visit types when connected as unprivileged user', function() { cy.userLogin(); cy.visit(url); cy.get('#swh-input-visit-type').children('option').then(options => { const actual = [...options].map(o => o.value); expect(actual).to.deep.eq(anonymousVisitTypes); }); }); it('should list privileged visit types when connected as ambassador', function() { cy.ambassadorLogin(); cy.visit(url); cy.get('#swh-input-visit-type').children('option').then(options => { const actual = [...options].map(o => o.value); expect(actual).to.deep.eq(allVisitTypes); }); }); it('should display extra inputs when dealing with \'archives\' visit type', function() { cy.ambassadorLogin(); cy.visit(url); for (const visitType of anonymousVisitTypes) { cy.get('#swh-input-visit-type').select(visitType); cy.get('.swh-save-origin-archives-form').should('not.be.visible'); } // this should display more inputs with the 'archives' type cy.get('#swh-input-visit-type').select('archives'); cy.get('.swh-save-origin-archives-form').should('be.visible'); }); it('should be allowed to submit \'archives\' save request when connected as ambassador', function() { const originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf'; const artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz'; const artifactVersion = '1.1.4'; stubSaveRequest({ requestUrl: this.Urls.api_1_save_origin('archives', originUrl), saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'not yet scheduled' }); cy.ambassadorLogin(); cy.visit(url); // input new 'archives' information and submit cy.get('#swh-input-origin-url') .type(originUrl) .get('#swh-input-visit-type') .select('archives') .get('#swh-input-artifact-url-0') .type(artifactUrl) .get('#swh-input-artifact-version-0') .clear() .type(artifactVersion) .get('#swh-save-origin-form') .submit(); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should submit multiple artifacts for the archives visit type', function() { const originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf'; const artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz'; const artifactVersion = '1.1.4'; const artifact2Url = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.5.tar.gz'; const artifact2Version = '1.1.5'; cy.ambassadorLogin(); cy.visit(url); cy.get('#swh-input-origin-url') .type(originUrl) .get('#swh-input-visit-type') .select('archives'); // fill first artifact info cy.get('#swh-input-artifact-url-0') .type(artifactUrl) .get('#swh-input-artifact-version-0') .clear() .type(artifactVersion); // add new artifact form row cy.get('#swh-add-archive-artifact') .click(); // check new row is displayed cy.get('#swh-input-artifact-url-1') .should('exist'); // request removal of newly added row cy.get('#swh-remove-archive-artifact-1') .click(); // check row has been removed cy.get('#swh-input-artifact-url-1') .should('not.exist'); // add new artifact form row cy.get('#swh-add-archive-artifact') .click(); // fill second artifact info cy.get('#swh-input-artifact-url-1') .type(artifact2Url) .get('#swh-input-artifact-version-1') .clear() .type(artifact2Version); // setup request interceptor to check POST data and stub response cy.intercept('POST', this.Urls.api_1_save_origin('archives', originUrl), (req) => { expect(req.body).to.deep.equal({ archives_data: [ {artifact_url: artifactUrl, artifact_version: artifactVersion}, {artifact_url: artifact2Url, artifact_version: artifact2Version} ] }); req.reply(genOriginSaveResponse({ visitType: 'archives', saveRequestStatus: 'accepted', originUrl: originUrl, saveRequestDate: new Date(), saveTaskStatus: 'not yet scheduled', visitDate: null, visitStatus: null })); }).as('saveRequest'); // submit form cy.get('#swh-save-origin-form') .submit(); // submission should be successful cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should autofill artifact version when pasting artifact url', function() { const originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf'; const artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz'; const artifactVersion = '3DLDF-1.1.4'; const artifact2Url = 'https://example.org/artifact/test/1.3.0.zip'; const artifact2Version = '1.3.0'; cy.ambassadorLogin(); cy.visit(url); cy.get('#swh-input-origin-url') .type(originUrl) .get('#swh-input-visit-type') .select('archives'); // fill first artifact info cy.get('#swh-input-artifact-url-0') .type(artifactUrl); // check autofilled version cy.get('#swh-input-artifact-version-0') .should('have.value', artifactVersion); // add new artifact form row cy.get('#swh-add-archive-artifact') .click(); // fill second artifact info cy.get('#swh-input-artifact-url-1') .type(artifact2Url); // check autofilled version cy.get('#swh-input-artifact-version-1') .should('have.value', artifact2Version); }); it('should use canonical URL for github repository to save', function() { const ownerRepo = 'BIC-MNI/mni_autoreg'; const canonicalOriginUrl = 'https://github.com/BIC-MNI/mni_autoreg'; // stub call to github Web API fetching canonical repo URL cy.intercept(`https://api.github.com/repos/${ownerRepo.toLowerCase()}`, (req) => { req.reply({html_url: canonicalOriginUrl}); }).as('ghWebApiRequest'); // stub save request creation with canonical URL of github repo cy.intercept('POST', this.Urls.api_1_save_origin('git', canonicalOriginUrl), (req) => { req.reply(genOriginSaveResponse({ visitType: 'git', saveRequestStatus: 'accepted', originUrl: canonicalOriginUrl, saveRequestDate: new Date(), saveTaskStatus: 'not yet scheduled', visitDate: null, visitStatus: null })); }).as('saveRequest'); for (const originUrl of ['https://github.com/BiC-MnI/MnI_AuToReG', 'https://github.com/BiC-MnI/MnI_AuToReG.git', 'https://github.com/BiC-MnI/MnI_AuToReG/']) { // enter non canonical URL of github repo cy.get('#swh-input-origin-url') .clear() .type(originUrl); // submit form cy.get('#swh-save-origin-form') .submit(); // submission should be successful cy.wait('@ghWebApiRequest') .wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); } }); }); diff --git a/swh/web/templates/includes/snapshot-context.html b/swh/web/templates/includes/snapshot-context.html index 17049a46..04c01a43 100644 --- a/swh/web/templates/includes/snapshot-context.html +++ b/swh/web/templates/includes/snapshot-context.html @@ -1,86 +1,87 @@ {% comment %} Copyright (C) 2017-2020 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 snapshot_context.origin_info %} {{ snapshot_context.origin_info.url }} {% if snapshot_context.origin_info.url|slice:"0:4" == "http" %} - + {% endif %} {% else %} {{ snapshot_context.snapshot_swhid }} {% endif %}
{% if snapshot_context.visit_info %}
{{ snapshot_context.visit_info.formatted_date }}
{% endif %} \ No newline at end of file