Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Feat: 3pa Attachment Token Visual #2097

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/api/chemotion/third_party_app_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class ThirdPartyAppAPI < Grape::API
get 'token' do
prepare_payload
parse_payload
update_cached_user_tokens
encode_and_cache_token
return error!('No read access to attachment', 403) unless read_access?(@attachment, @current_user)

Expand All @@ -106,6 +107,22 @@ class ThirdPartyAppAPI < Grape::API
"#{@app.url}?url=#{url}"
end

desc 'list of TPA token in a collection'
get 'collection_tpa_tokens' do
token_list = []
cache_user_keys = cache.read(current_user.id)
return { token_list: [] } if cache_user_keys.blank?

cache_user_keys&.each do |token_key|
cached_value = cache.read(token_key)
token_list
.push({
"#{token_key}": cached_value,
})
end
{ token_list: token_list }
end

desc 'get chemotion handler url'
params do
requires :attID, type: Integer, desc: 'Attachment ID'
Expand Down
36 changes: 25 additions & 11 deletions app/api/helpers/third_party_app_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ def cache

# desc: fetch the token and download/upload counters from the cache
def cached_token
@cached_token ||= cache.read(cache_key)
@cached_token ||= cache.read(cache_key[1])
end

# desc: define the cache key based on the attachment/user/app ids
def cache_key
@cache_key ||= "#{@attachment&.id}/#{@user&.id}/#{@app&.id}"
@user_key ||= @user&.id
@cache_key_attachment_app ||= "#{@attachment&.id}/#{@app&.id}"
[@user_key, @cache_key_attachment_app]
end

# desc: prepare the token payload from the params
Expand All @@ -44,14 +46,15 @@ def parse_payload(payload = @payload)

# desc: decrement the counters / check if token permission is expired
def update_cache(key)
return error!('Invalid token', 403) if cached_token.nil? || cached_token[:token] != params[:token]

# TODO: expire token when both counters reach 0
# IDEA: split counters into two caches?
return error!("Token #{key} permission expired", 403) if cached_token[key].negative?

cached_token[key] -= 1
cache.write(cache_key, cached_token)
if cached_token.nil?
cache.delete(cache_key[1])
error!('Invalid token', 403)
elsif cached_token[key].to_i < 1
error!("Token #{key} permission expired", 403)
else
cached_token[key] -= 1
cache.write(cache_key[1], cached_token)
end
end

# desc: return file for download to third party app
Expand Down Expand Up @@ -90,10 +93,21 @@ def upload_attachment_from_third_party_app
{ message: 'File uploaded successfully' }
end

def update_cached_user_tokens
current_state = cache.read(cache_key[0])
new_state = if current_state
idx = current_state.index(cache_key[1])
idx.nil? ? current_state.push(cache_key[1]) : current_state
else
[cache_key[1]]
end
cache.write(cache_key[0], new_state)
end

def encode_and_cache_token(payload = @payload)
@token = JsonWebToken.encode(payload, expiry_time)
cache.write(
cache_key,
cache_key[1],
{ token: @token, download: 3, upload: 10 },
expires_at: expiry_time,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,33 @@
/* eslint-disable lines-between-class-members */
/* eslint-disable no-param-reassign */
/* eslint-disable react/destructuring-assignment */
import { StoreContext } from 'src/stores/mobx/RootStore';
import EditorFetcher from 'src/fetchers/EditorFetcher';
import ElementActions from 'src/stores/alt/actions/ElementActions';
import LoadingActions from 'src/stores/alt/actions/LoadingActions';
import UIStore from 'src/stores/alt/stores/UIStore';
import PropTypes from 'prop-types';
import { findKey, last } from 'lodash';
import { observer } from 'mobx-react';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ImageAnnotationModalSVG from 'src/apps/mydb/elements/details/researchPlans/ImageAnnotationModalSVG';
import { Button } from 'react-bootstrap';
import { last, findKey } from 'lodash';
import AttachmentFetcher from 'src/fetchers/AttachmentFetcher';
import ImageAttachmentFilter from 'src/utilities/ImageAttachmentFilter';
import ImageAnnotationModalSVG from 'src/apps/mydb/elements/details/researchPlans/ImageAnnotationModalSVG';
import SaveEditedImageWarning from 'src/apps/mydb/elements/details/researchPlans/SaveEditedImageWarning';
import {
downloadButton,
removeButton,
annotateButton,
attachmentThumbnail,
customDropzone,
downloadButton,
editButton,
formatFileSize,
importButton,
customDropzone,
removeButton,
sortingAndFilteringUI,
formatFileSize,
attachmentThumbnail,
ThirdPartyAppButton,
} from 'src/apps/mydb/elements/list/AttachmentList';
import ThirdPartyAppButton from 'src/apps/mydb/elements/list/ThirdPartyAppButton';
import AttachmentFetcher from 'src/fetchers/AttachmentFetcher';
import EditorFetcher from 'src/fetchers/EditorFetcher';
import ThirdPartyAppFetcher from 'src/fetchers/ThirdPartyAppFetcher';
import ElementActions from 'src/stores/alt/actions/ElementActions';
import LoadingActions from 'src/stores/alt/actions/LoadingActions';
import UIStore from 'src/stores/alt/stores/UIStore';
import { StoreContext } from 'src/stores/mobx/RootStore';
import ImageAttachmentFilter from 'src/utilities/ImageAttachmentFilter';
import { formatDate, parseDate } from 'src/utilities/timezoneHelper';

class ResearchPlanDetailsAttachments extends Component {
Expand All @@ -47,6 +48,7 @@ class ResearchPlanDetailsAttachments extends Component {
filterText: '',
sortBy: 'name',
sortDirection: 'asc',
tokenList: []
};
this.editorInitial = this.editorInitial.bind(this);
this.createAttachmentPreviews = this.createAttachmentPreviews.bind(this);
Expand All @@ -63,16 +65,25 @@ class ResearchPlanDetailsAttachments extends Component {
componentDidMount() {
this.editorInitial();
this.createAttachmentPreviews();
this.fetch3paTokenByUserId();
}

componentDidUpdate(prevProps) {
const { attachments } = this.props;

if (attachments !== prevProps.attachments) {
this.createAttachmentPreviews();
this.setState({ filteredAttachments: [...attachments] }, this.filterAndSortAttachments);
}
}

async fetch3paTokenByUserId() {
const res = await ThirdPartyAppFetcher.fetchCollectionAttachmentTokensByCollectionId();
if (this.state.tokenList.length !== res.token_list.length) {
this.setState({ tokenList: res?.token_list });
}
};

handleEdit(attachment) {
const fileType = last(attachment.filename.split('.'));
const docType = this.documentType(attachment.filename);
Expand Down Expand Up @@ -244,15 +255,14 @@ class ResearchPlanDetailsAttachments extends Component {
const { researchPlan } = this.props;

//Ugly temporary hack to avoid tests failling because the context is not accessable in tests with the enzyme framework

let combinedAttachments = filteredAttachments;
if(this.context.attachmentNotificationStore ){
combinedAttachments = this.context.attachmentNotificationStore.getCombinedAttachments(filteredAttachments,"ResearchPlan",researchPlan);
if (this.context.attachmentNotificationStore) {
combinedAttachments = this.context.attachmentNotificationStore.getCombinedAttachments(filteredAttachments, "ResearchPlan", researchPlan);
}

const { onUndoDelete, attachments } = this.props;
const thirdPartyApps = this.thirdPartyApps;


return (
<div className="attachment-main-container">
{this.renderImageEditModal()}
Expand All @@ -262,13 +272,13 @@ class ResearchPlanDetailsAttachments extends Component {
</div>
<div style={{ marginLeft: '20px', alignSelf: 'center' }}>
{attachments.length > 0
&& sortingAndFilteringUI(
sortDirection,
this.handleSortChange,
this.toggleSortDirection,
this.handleFilterChange,
true
)}
&& sortingAndFilteringUI(
sortDirection,
this.handleSortChange,
this.toggleSortDirection,
this.handleFilterChange,
true
)}
</div>
</div>
{combinedAttachments.length === 0 ? (
Expand Down Expand Up @@ -312,15 +322,20 @@ class ResearchPlanDetailsAttachments extends Component {
) : (
<>
{downloadButton(attachment)}
<ThirdPartyAppButton attachment={attachment} options={thirdPartyApps} />
<ThirdPartyAppButton
attachment={attachment}
options={this.thirdPartyApps}
tokenList={this.state.tokenList}
onChangeRecall={() => this.fetch3paTokenByUserId()}
/>
{editButton(
attachment,
extension,
attachmentEditor,
attachment.aasm_state === 'oo_editing' && new Date().getTime()
< (new Date(attachment.updated_at).getTime() + 15 * 60 * 1000),
< (new Date(attachment.updated_at).getTime() + 15 * 60 * 1000),
!attachmentEditor || attachment.aasm_state === 'oo_editing'
|| attachment.is_new || this.documentType(attachment.filename) === null,
|| attachment.is_new || this.documentType(attachment.filename) === null,
this.handleEdit
)}
{annotateButton(attachment, this)}
Expand Down
59 changes: 59 additions & 0 deletions app/packs/src/apps/mydb/elements/list/ThirdPartyAppButton.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import {
Dropdown, MenuItem
} from 'react-bootstrap';
import ThirdPartyAppFetcher from 'src/fetchers/ThirdPartyAppFetcher';
import uuid from 'uuid';

const ThirdPartyAppButton = ({ attachment, options, tokenList, onChangeRecall }) => {

const handleFetchAttachToken = (option) => {
ThirdPartyAppFetcher.fetchAttachmentToken(attachment.id, option.id)
.then((result) => {
onChangeRecall();
window.open(result, '_blank');
});
};

const tpaTokenExists = (attachment_id, tpa) => {
let status = false;
tokenList?.map((item) => {
const keySplit = Object.keys(item)[0].split('/');
const attachment_id_match = keySplit[0] == attachment_id;
const tpa_id = keySplit[1] == tpa.id;
if (tpa_id) {
if (attachment_id_match) {
status = true;
}
}
});
return status;
};

return (
<Dropdown id={`dropdown-TPA-attachment${attachment.id}`} style={{ float: 'right' }}>
<Dropdown.Toggle style={{ height: '30px' }} bsSize="xs" bsStyle="primary">
<i className="fa fa-external-link " aria-hidden="true" />
</Dropdown.Toggle>
<Dropdown.Menu>
{options.map((option) => {
const status = tpaTokenExists(attachment.id, option);
return (
< MenuItem
key={uuid.v4()}
eventKey={option.id}
onClick={() => handleFetchAttachToken(option)}
>
<div style={{ display: 'flex' }}>
<div style={{ width: '90%' }}>{option.name}</div>
{status && <i className="fa fa-key" />}
</div>
</MenuItem>
);
})}
</Dropdown.Menu>
</Dropdown >
);
};

export default ThirdPartyAppButton;
11 changes: 10 additions & 1 deletion app/packs/src/fetchers/ThirdPartyAppFetcher.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'whatwg-fetch';
import { ThirdPartyAppServices } from 'src/endpoints/ApiServices';
import 'whatwg-fetch';

const { TPA_ENDPOINT } = ThirdPartyAppServices;
const TPA_ENDPOINT_ADMIN = `${TPA_ENDPOINT}/admin`;
Expand Down Expand Up @@ -49,6 +49,15 @@ export default class ThirdPartyAppFetcher {
.catch((errorMessage) => { console.log(errorMessage); });
}

static fetchCollectionAttachmentTokensByCollectionId() {
const url = `${TPA_ENDPOINT}/collection_tpa_tokens`;
return fetch(url, {
credentials: 'same-origin'
}).then(response => response.json())
.then(json => json)
.catch((errorMessage) => { console.log(errorMessage); });
}

static getHandlerUrl(attID, type) {
const queryParams = new URLSearchParams({ attID, type }).toString();
const url = `${TPA_ENDPOINT}/url?${queryParams}`;
Expand Down
28 changes: 24 additions & 4 deletions spec/api/chemotion/third_party_app_api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
describe Chemotion::ThirdPartyAppAPI do
include_context 'api request authorization context'
let!(:admin1) { create(:admin) }
let(:user) { create(:user) }

before do
allow_any_instance_of(WardenAuthentication).to receive(:current_user).and_return(admin1) # rubocop:disable RSpec/AnyInstance
Expand Down Expand Up @@ -256,7 +257,7 @@

let(:third_party_app) { create(:third_party_app) }
let(:cache) { ActiveSupport::Cache::FileStore.new('tmp/ThirdPartyApp', expires_in: 1.hour) }
let(:cache_key) { "#{attachment.id}/#{user.id}/#{third_party_app.id}" }
let(:cache_key) { "#{attachment.id}/#{third_party_app.id}" }
let(:secret) { Rails.application.secrets.secret_key_base }
let(:token) { JWT.encode(payload, secret, 'HS256') }
let(:allowed_uploads) { 1 }
Expand Down Expand Up @@ -303,7 +304,7 @@
end

before do
cache.write(cache_key, { token: token, upload: allowed_uploads }, expires_in: 1.hour)
cache.write(cache_key, { token: token, upload: allowed_uploads, download: 1 }, expires_in: 1.hour)
post "/api/v1/public/third_party_apps/#{token}", params: params
end

Expand Down Expand Up @@ -365,7 +366,7 @@
end

context 'when amount of uploads exceeded' do
let(:allowed_uploads) { -1 }
let(:allowed_uploads) { 0 }

before do
cache.write(cache_key, { token: token, upload: allowed_uploads }, expires_in: 1.hour)
Expand All @@ -385,7 +386,7 @@
parts.split('/').last
end

let(:tpa) { create((:third_party_app)) }
let(:tpa) { create(:third_party_app) }

let!(:research_plan) do
create(:research_plan, creator: admin1, collections: [collection], attachments: [attachment])
Expand Down Expand Up @@ -423,5 +424,24 @@
end
end
end

describe 'GET /collection_tpa_tokens' do
let(:cache) { ActiveSupport::Cache::FileStore.new('tmp/ThirdPartyApp', expires_in: 1.hour) }

context 'when the user has no cached tokens' do
before do
cache.delete(user.id)
end

it 'returns the empty list of TPA tokens' do
get '/api/v1/third_party_apps/collection_tpa_tokens'

expect(response).to have_http_status(:ok)

json_response = JSON.parse(response.body).deep_symbolize_keys
expect(json_response[:token_list]).to be_empty
end
end
end
end
# rubocop:enable RSpec/LetSetup,RSpec/MultipleExpectations,RSpec/NestedGroups,RSpec/MultipleMemoizedHelpers
Loading