diff --git a/passerelle/apps/filr_rest/__init__.py b/passerelle/apps/filr_rest/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/filr_rest/migrations/0001_initial.py b/passerelle/apps/filr_rest/migrations/0001_initial.py
new file mode 100644
index 00000000..5277eb68
--- /dev/null
+++ b/passerelle/apps/filr_rest/migrations/0001_initial.py
@@ -0,0 +1,67 @@
+# Generated by Django 2.2.26 on 2023-01-10 13:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('base', '0030_resourcelog_base_resour_appname_298cbc_idx'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Filr',
+ fields=[
+ (
+ 'id',
+ models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+ ),
+ ('title', models.CharField(max_length=50, verbose_name='Title')),
+ ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
+ ('description', models.TextField(verbose_name='Description')),
+ (
+ 'basic_auth_username',
+ models.CharField(
+ blank=True, max_length=128, verbose_name='Basic authentication username'
+ ),
+ ),
+ (
+ 'basic_auth_password',
+ models.CharField(
+ blank=True, max_length=128, verbose_name='Basic authentication password'
+ ),
+ ),
+ (
+ 'client_certificate',
+ models.FileField(
+ blank=True, null=True, upload_to='', verbose_name='TLS client certificate'
+ ),
+ ),
+ (
+ 'trusted_certificate_authorities',
+ models.FileField(blank=True, null=True, upload_to='', verbose_name='TLS trusted CAs'),
+ ),
+ (
+ 'verify_cert',
+ models.BooleanField(blank=True, default=True, verbose_name='TLS verify certificates'),
+ ),
+ (
+ 'http_proxy',
+ models.CharField(blank=True, max_length=128, verbose_name='HTTP and HTTPS proxy'),
+ ),
+ ('base_url', models.URLField(verbose_name='Webservice Base URL')),
+ (
+ 'users',
+ models.ManyToManyField(
+ blank=True, related_name='_filr_users_+', related_query_name='+', to='base.ApiUser'
+ ),
+ ),
+ ],
+ options={
+ 'verbose_name': 'Filr REST API',
+ },
+ ),
+ ]
diff --git a/passerelle/apps/filr_rest/migrations/__init__.py b/passerelle/apps/filr_rest/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/filr_rest/models.py b/passerelle/apps/filr_rest/models.py
new file mode 100644
index 00000000..3b7259c9
--- /dev/null
+++ b/passerelle/apps/filr_rest/models.py
@@ -0,0 +1,156 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2023 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import base64
+import binascii
+import json
+import urllib
+
+import requests
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+from passerelle.base.models import BaseResource, HTTPResource
+from passerelle.utils.api import endpoint
+from passerelle.utils.jsonresponse import APIError
+
+from . import schemas
+
+
+class Filr(BaseResource, HTTPResource):
+ base_url = models.URLField(
+ verbose_name=_('Webservice Base URL'),
+ )
+
+ category = _('File Storage')
+
+ class Meta:
+ verbose_name = _('Filr REST API')
+
+ def _call(self, path, method='get', json_data=None, data=None, headers=None, params=None):
+ kwargs = {}
+ if data:
+ kwargs['data'] = data
+ if json_data:
+ kwargs['json'] = json_data
+ if headers:
+ kwargs['headers'] = headers
+ if params:
+ kwargs['params'] = params
+ try:
+ resp = self.requests.request(
+ method=method, url=urllib.parse.urljoin(self.base_url, path), **kwargs
+ )
+ except (requests.Timeout, requests.RequestException) as e:
+ raise APIError(str(e))
+ try:
+ resp.raise_for_status()
+ except requests.RequestException as main_exc:
+ try:
+ err_data = resp.json()
+ except (json.JSONDecodeError, requests.exceptions.RequestException):
+ err_data = {'response_text': resp.text}
+ raise APIError(str(main_exc), data=err_data)
+
+ content_type = resp.headers.get('Content-Type')
+ if content_type and content_type.startswith('application/json') and resp.status_code != 204:
+ try:
+ return resp.json()
+ except (json.JSONDecodeError, requests.exceptions.JSONDecodeError) as e:
+ raise APIError(str(e))
+
+ return resp.text
+
+ @endpoint(
+ perm='can_access',
+ description=_('Upload a file'),
+ post={
+ 'request_body': {'schema': {'application/json': schemas.UPLOAD}},
+ },
+ )
+ def upload(self, request, post_data):
+ root_folder_id, folder_name = post_data['root_folder_id'], post_data['folder_name']
+ try:
+ file_content = base64.b64decode(post_data['file']['content'])
+ except (TypeError, binascii.Error):
+ raise APIError('invalid base64 string', http_status=400)
+ filename = post_data.get('filename') or post_data['file']['filename']
+
+ # get or create folder
+ folder_id = None
+ root_folder_info = self._call('rest/folders/%s/library_folders' % root_folder_id)
+ for folder in root_folder_info.get('items', []):
+ if folder.get('title') == folder_name:
+ folder_id = folder.get('id')
+ break
+ else:
+ folder_info = self._call(
+ 'rest/folders/%s/library_folders' % root_folder_id,
+ method='post',
+ json_data={'title': folder_name},
+ )
+ folder_id = folder_info['id']
+
+ # upload file
+ file_info = self._call(
+ 'rest/folders/%s/library_files' % folder_id,
+ method='post',
+ params={'file_name': filename},
+ headers={'Content-Type': 'application/octet-stream'},
+ data=file_content,
+ )
+ return {'data': {'folder_id': folder_id, 'file_info': file_info}}
+
+ @endpoint(
+ name='share-folder',
+ perm='can_access',
+ description=_('Share a folder to external users'),
+ post={
+ 'request_body': {'schema': {'application/json': schemas.SHARE_FOLDER}},
+ 'input_example': {
+ 'folder_id': '1234',
+ 'emails/0': 'foo@invalid',
+ 'emails/1': 'bar@invalid',
+ 'days_to_expire': '30',
+ },
+ },
+ )
+ def share_folder(self, request, post_data):
+ data = []
+ for email in post_data['emails']:
+ share_info = self._call(
+ 'rest/folders/%s/shares' % post_data['folder_id'],
+ method='post',
+ params={'notify': 'true'},
+ json_data={
+ 'days_to_expire': post_data['days_to_expire'],
+ 'recipient': {'type': 'external_user', 'email': email},
+ 'access': {'role': 'VIEWER'},
+ },
+ )
+ data.append(share_info)
+ return {'data': data}
+
+ @endpoint(
+ name="delete-folder",
+ perm='can_access',
+ methods=['post'],
+ description=_('Delete a folder'),
+ post={'request_body': {'schema': {'application/json': schemas.DELETE_FOLDER}}},
+ )
+ def delete_folder(self, request, post_data):
+ delete_infos = self._call('rest/folders/%s' % post_data['folder_id'], method='delete')
+ return {'data': delete_infos}
diff --git a/passerelle/apps/filr_rest/schemas.py b/passerelle/apps/filr_rest/schemas.py
new file mode 100644
index 00000000..784aefa6
--- /dev/null
+++ b/passerelle/apps/filr_rest/schemas.py
@@ -0,0 +1,91 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2023 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from django.utils.translation import gettext_lazy as _
+
+UPLOAD = {
+ 'type': 'object',
+ 'title': _('Upload file'),
+ 'properties': {
+ 'file': {
+ 'title': _('File object'),
+ 'type': 'object',
+ 'properties': {
+ 'filename': {
+ 'type': 'string',
+ 'description': _('Filename'),
+ },
+ 'content': {
+ 'type': 'string',
+ 'description': _('Content'),
+ },
+ 'content_type': {
+ 'type': 'string',
+ 'description': _('Content type'),
+ },
+ },
+ 'required': ['content', 'filename'],
+ },
+ 'filename': {
+ 'type': 'string',
+ 'description': _('Filename (takes precedence over filename in "file" object)'),
+ },
+ 'folder_name': {
+ 'type': 'string',
+ 'description': _('Folder name'),
+ },
+ 'root_folder_id': {
+ 'type': 'string',
+ 'description': _('Root folder identifier'),
+ 'pattern': '^[0-9]+$',
+ },
+ },
+ 'required': ['file', 'folder_name', 'root_folder_id'],
+}
+
+
+SHARE_FOLDER = {
+ 'type': 'object',
+ 'title': _('Share Folder'),
+ 'properties': {
+ 'folder_id': {
+ 'type': 'string',
+ 'description': _('Folder identifier'),
+ 'pattern': '^[0-9]+$',
+ },
+ 'emails': {
+ 'type': 'array',
+ 'description': _('Emails'),
+ 'items': {
+ 'type': 'string',
+ 'format': 'email',
+ },
+ },
+ 'days_to_expire': {'type': 'string', 'pattern': '^[0-9]+$'},
+ },
+ 'required': ['folder_id', 'emails', 'days_to_expire'],
+ 'unflatten': True,
+}
+
+
+DELETE_FOLDER = {
+ 'type': 'object',
+ 'title': _('Delete Folder'),
+ 'properties': {
+ 'folder_id': {'type': 'string', 'description': _('Folder identifier'), 'pattern': '^[0-9]+$'}
+ },
+ 'required': ['folder_id'],
+}
diff --git a/passerelle/settings.py b/passerelle/settings.py
index 464c3b0c..8210cf29 100644
--- a/passerelle/settings.py
+++ b/passerelle/settings.py
@@ -148,6 +148,7 @@ INSTALLED_APPS = (
'passerelle.apps.esirius',
'passerelle.apps.family',
'passerelle.apps.feeds',
+ 'passerelle.apps.filr_rest',
'passerelle.apps.franceconnect_data',
'passerelle.apps.gdc',
'passerelle.apps.gesbac',
diff --git a/tests/test_filr_rest.py b/tests/test_filr_rest.py
new file mode 100644
index 00000000..5d42342f
--- /dev/null
+++ b/tests/test_filr_rest.py
@@ -0,0 +1,105 @@
+import base64
+
+import pytest
+import responses
+from django.contrib.contenttypes.models import ContentType
+
+from passerelle.apps.filr_rest.models import Filr
+from passerelle.base.models import AccessRight, ApiUser
+
+
+@pytest.fixture()
+def connector(db):
+ api = ApiUser.objects.create(username='all', keytype='', key='')
+ connector = Filr.objects.create(
+ base_url='http://filr.invalid/',
+ basic_auth_username='foo',
+ basic_auth_password='bar',
+ slug='filr',
+ )
+ obj_type = ContentType.objects.get_for_model(connector)
+ AccessRight.objects.create(
+ codename='can_access', apiuser=api, resource_type=obj_type, resource_pk=connector.pk
+ )
+ return connector
+
+
+def test_upload(app, connector):
+ params = {
+ 'file': {
+ 'filename': 'bla',
+ 'content': base64.b64encode(b'who what').decode(),
+ 'content_type': 'text/plain',
+ },
+ 'root_folder_id': '1234',
+ 'folder_name': 'folder_foo',
+ }
+ with responses.RequestsMock() as rsps:
+ rsps.get(
+ 'http://filr.invalid/rest/folders/1234/library_folders',
+ status=200,
+ json={'items': [{'title': 'folder_foo', 'id': 5678}]},
+ )
+ rsps.post(
+ 'http://filr.invalid/rest/folders/5678/library_files?file_name=bla',
+ status=200,
+ json={'id': '09c1c3fb530f562401531018f4270000'},
+ )
+ resp = app.post_json('/filr-rest/filr/upload', params=params)
+ json_resp = resp.json
+ assert json_resp['err'] == 0
+ assert json_resp['data'] == {
+ 'folder_id': 5678,
+ 'file_info': {'id': '09c1c3fb530f562401531018f4270000'},
+ }
+
+
+def test_upload_with_folder_creation(app, connector):
+ params = {
+ 'file': {
+ 'filename': 'bla',
+ 'content': base64.b64encode(b'who what').decode(),
+ 'content_type': 'text/plain',
+ },
+ 'root_folder_id': '1234',
+ 'folder_name': 'folder_foo',
+ }
+ with responses.RequestsMock() as rsps:
+ rsps.get('http://filr.invalid/rest/folders/1234/library_folders', status=200, json={'items': []})
+ rsps.post('http://filr.invalid/rest/folders/1234/library_folders', status=200, json={'id': 82})
+ rsps.post(
+ 'http://filr.invalid/rest/folders/82/library_files?file_name=bla',
+ status=200,
+ json={'id': '09c1c3fb530f562401531018f4270000'},
+ )
+ resp = app.post_json('/filr-rest/filr/upload', params=params)
+ json_resp = resp.json
+ assert json_resp['err'] == 0
+ assert json_resp['data'] == {'folder_id': 82, 'file_info': {'id': '09c1c3fb530f562401531018f4270000'}}
+
+
+def test_share_folder(app, connector):
+ params = {
+ 'folder_id': '1234',
+ 'emails/0': 'foo@invalid',
+ 'emails/1': 'bar@invalid',
+ 'days_to_expire': '30',
+ }
+ with responses.RequestsMock() as rsps:
+ rsps.post('http://filr.invalid/rest/folders/1234/shares?notify=true', status=200, json={'id': 9})
+ resp = app.post_json('/filr-rest/filr/share-folder', params=params)
+ json_resp = resp.json
+ assert json_resp['err'] == 0
+ assert json_resp['data'] == [{'id': 9}, {'id': 9}]
+
+
+def test_delete_folder(app, connector):
+ params = {
+ 'folder_id': '1234',
+ }
+ with responses.RequestsMock() as rsps:
+ rsps.delete('http://filr.invalid/rest/folders/1234', status=204, content_type='application/json')
+ resp = app.post_json('/filr-rest/filr/delete-folder', params=params)
+ json_resp = resp.json
+ assert json_resp['err'] == 0
+ assert json_resp['data'] == ''