Skip to content
This repository has been archived by the owner on Feb 1, 2024. It is now read-only.

Require authentication for facility CSV downloads and log requests #697

Merged
merged 5 commits into from
Jul 25, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Added

### Changed
- Require authentication for facility CSV downloads and log requests [#697](https://github.com/open-apparel-registry/open-apparel-registry/pull/697)

### Deprecated

Expand Down
44 changes: 44 additions & 0 deletions src/app/src/actions/logDownload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createAction } from 'redux-act';

import apiRequest from '../util/apiRequest';

import {
makeLogDownloadUrl,
logErrorAndDispatchFailure,
downloadFacilitiesCSV,
} from '../util/util';

export const startLogDownload =
createAction('START_LOG_DOWNLOAD');
export const failLogDownload =
createAction('FAIL_LOG_DOWNLOAD');
export const completeLogDownload =
createAction('COMPLETE_LOG_DOWNLOAD');

export function logDownload() {
return (dispatch, getState) => {
dispatch(startLogDownload());

const {
facilities: {
facilities: {
data: {
features: facilities,
},
},
},
} = getState();

const path = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const recordCount = facilities ? facilities.length : 0;
return apiRequest
.post(makeLogDownloadUrl(path, recordCount))
.then(() => downloadFacilitiesCSV(facilities))
.then(() => dispatch(completeLogDownload()))
.catch(err => dispatch(logErrorAndDispatchFailure(
err,
'An error prevented the download',
failLogDownload,
)));
};
}
98 changes: 95 additions & 3 deletions src/app/src/components/FilterSidebarFacilitiesTab.jsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import React, { Fragment } from 'react';
import React, { Fragment, useState, useEffect } from 'react';
import { arrayOf, bool, func, string } from 'prop-types';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import CircularProgress from '@material-ui/core/CircularProgress';
import Dialog from '@material-ui/core/Dialog';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent';
import DialogActions from '@material-ui/core/DialogActions';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import Divider from '@material-ui/core/Divider';
import get from 'lodash/get';
import { toast } from 'react-toastify';

import ControlledTextInput from './ControlledTextInput';

Expand All @@ -18,11 +23,17 @@ import {
updateSidebarFacilitiesTabTextFilter,
} from '../actions/ui';

import { logDownload } from '../actions/logDownload';

import { facilityCollectionPropType } from '../util/propTypes';

import {
authLoginFormRoute,
authRegisterFormRoute,
} from '../util/constants';

import {
makeFacilityDetailLink,
downloadFacilitiesCSV,
getValueFromEvent,
caseInsensitiveIncludes,
sortFacilitiesAlphabeticallyByName,
Expand Down Expand Up @@ -79,10 +90,23 @@ function FilterSidebarFacilitiesTab({
fetching,
data,
error,
logDownloadError,
user,
returnToSearchTab,
filterText,
updateFilterText,
handleDownload,
}) {
const [loginRequiredDialogIsOpen, setLoginRequiredDialogIsOpen] = useState(false);
const [requestedDownload, setRequestedDownload] = useState(false);

useEffect(() => {
if (requestedDownload && logDownloadError) {
toast('A problem prevented downloading the facilities');
setRequestedDownload(false);
}
}, [logDownloadError, requestedDownload]);

if (fetching) {
return (
<div
Expand Down Expand Up @@ -184,6 +208,9 @@ function FilterSidebarFacilitiesTab({
? `Displaying ${filteredFacilities.length} facilities of ${facilitiesCount} results`
: `Displaying ${filteredFacilities.length} facilities`;

const LoginLink = props => <Link to={authLoginFormRoute} {...props} />;
const RegisterLink = props => <Link to={authRegisterFormRoute} {...props} />;

const listHeaderInsetComponent = (
<div style={facilitiesTabStyles.listHeaderStyles}>
<Typography
Expand All @@ -198,7 +225,16 @@ function FilterSidebarFacilitiesTab({
variant="outlined"
color="primary"
styles={facilitiesTabStyles.listHeaderButtonStyles}
onClick={() => downloadFacilitiesCSV(orderedFacilities)}
onClick={
() => {
if (user) {
setRequestedDownload(true);
handleDownload();
} else {
setLoginRequiredDialogIsOpen(true);
}
}
}
>
Download CSV
</Button>
Expand Down Expand Up @@ -256,25 +292,75 @@ function FilterSidebarFacilitiesTab({
}
</List>
</div>
<Dialog open={loginRequiredDialogIsOpen}>
{ loginRequiredDialogIsOpen ? (
<>
<DialogTitle>
Log In To Download
</DialogTitle>
<DialogContent>
<Typography>
You must log in with an Open Apparel Registry
account before downloading your search results.
</Typography>
</DialogContent>
<DialogActions>
<Button
variant="outlined"
color="secondary"
onClick={() => setLoginRequiredDialogIsOpen(false)}
>
Cancel
</Button>
<Button
variant="outlined"
color="primary"
onClick={() => setLoginRequiredDialogIsOpen(false)}
component={RegisterLink}
>
Register
</Button>
<Button
variant="outlined"
color="primary"
onClick={() => setLoginRequiredDialogIsOpen(false)}
component={LoginLink}
>
Log In
</Button>
</DialogActions>
</>
) : (
<div style={{ display: 'none' }} />
)}
</Dialog>
</Fragment>
);
}

FilterSidebarFacilitiesTab.defaultProps = {
data: null,
error: null,
logDownloadError: null,
};

FilterSidebarFacilitiesTab.propTypes = {
data: facilityCollectionPropType,
fetching: bool.isRequired,
error: arrayOf(string),
logDownloadError: arrayOf(string),
returnToSearchTab: func.isRequired,
filterText: string.isRequired,
updateFilterText: func.isRequired,
handleDownload: func.isRequired,
};

function mapStateToProps({
auth: {
user: {
user,
},
},
facilities: {
facilities: {
data,
Expand All @@ -287,12 +373,17 @@ function mapStateToProps({
filterText,
},
},
logDownload: {
error: logDownloadError,
},
}) {
return {
data,
error,
fetching,
filterText,
user,
logDownloadError,
};
}

Expand All @@ -301,6 +392,7 @@ function mapDispatchToProps(dispatch) {
returnToSearchTab: () => dispatch(makeSidebarSearchTabActive()),
updateFilterText: e =>
dispatch((updateSidebarFacilitiesTabTextFilter(getValueFromEvent(e)))),
handleDownload: () => dispatch(logDownload()),
};
}

Expand Down
28 changes: 28 additions & 0 deletions src/app/src/reducers/LogDownloadReducer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createReducer } from 'redux-act';
import update from 'immutability-helper';

import {
startLogDownload,
failLogDownload,
completeLogDownload,
} from '../actions/logDownload';

const initialState = Object.freeze({
fetching: false,
error: null,
});

export default createReducer({
[startLogDownload]: state => update(state, {
fetching: { $set: true },
error: { $set: initialState.error },
}),
[failLogDownload]: (state, payload) => update(state, {
fetching: { $set: false },
error: { $set: payload },
}),
[completeLogDownload]: state => update(state, {
fetching: { $set: false },
error: { $set: initialState.error },
}),
}, initialState);
6 changes: 4 additions & 2 deletions src/app/src/reducers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ import DashboardListsReducer from './DashboardListsReducer';
import DeleteFacilityReducer from './DeleteFacilityReducer';
import MergeFacilitiesReducer from './MergeFacilitiesReducer';
import AdjustFacilityMatchesReducer from './AdjustFacilityMatchesReducer';
import LogDownloadReducer from './LogDownloadReducer';

export default combineReducers((({
export default combineReducers({
auth: AuthReducer,
profile: ProfileReducer,
upload: UploadReducer,
Expand All @@ -48,4 +49,5 @@ export default combineReducers((({
deleteFacility: DeleteFacilityReducer,
mergeFacilities: MergeFacilitiesReducer,
adjustFacilityMatches: AdjustFacilityMatchesReducer,
})));
logDownload: LogDownloadReducer,
});
2 changes: 2 additions & 0 deletions src/app/src/util/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ export const makeGetClientInfoURL = () => {
return `${clientInfoURL}${clientInfoURLSuffix}`;
};

export const makeLogDownloadUrl = (path, recordCount) => `/api/log-download/?path=${path}&record_count=${recordCount}`;

export const getValueFromObject = ({ value }) => value;

export const createQueryStringFromSearchFilters = ({
Expand Down
5 changes: 5 additions & 0 deletions src/django/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,8 @@ class Certifications:
RESPONSIBLE_DOWN_STANDARD = 'Responsible Down Standard (RDS)'
RESPONSIBLE_WOOL_STANDARD = 'Responsible Wool Standard (RWS)'
SAB8000 = 'SA8000'


class LogDownloadQueryParams:
PATH = 'path'
RECORD_COUNT = 'record_count'
26 changes: 26 additions & 0 deletions src/django/api/migrations/0028_create_downloadlog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 2.2.3 on 2019-07-19 22:14

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('api', '0027_merge_20190716_2207'),
]

operations = [
migrations.CreateModel(
name='DownloadLog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(help_text='The requested resource path', max_length=2083)),
('record_count', models.IntegerField(help_text='The number of records in the downloaded file')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(help_text='The User account that made the request', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
23 changes: 23 additions & 0 deletions src/django/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1190,3 +1190,26 @@ class ProductionType(models.Model):
blank=False,
help_text='A suggested value for production type'
)


class DownloadLog(models.Model):
"""
Log CSV download requests from the web client
"""
user = models.ForeignKey(
'User',
null=False,
on_delete=models.CASCADE,
help_text='The User account that made the request'
)
path = models.CharField(
max_length=2083,
Copy link
Contributor

Choose a reason for hiding this comment

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

Is 2083 a maximum value for paths?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There is a well researched StackOverflow answer that contributed to this choice of maximum value
https://stackoverflow.com/a/417184

In summary, URLs larger than this bump into some limits in browsers and search crawling infrastructure so it is a reasonable maximum value.

null=False,
help_text='The requested resource path'
)
record_count = models.IntegerField(
null=False,
help_text='The number of records in the downloaded file'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
5 changes: 5 additions & 0 deletions src/django/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -847,3 +847,8 @@ def validate_merge(self, merge_id):
if not Facility.objects.filter(id=merge_id).exists():
raise ValidationError(
'Facility {} does not exist.'.format(merge_id))


class LogDownloadQueryParamsSerializer(Serializer):
path = CharField(required=True)
record_count = IntegerField(required=True)