diff --git a/acore/urls.py b/acore/urls.py index 87a5066..d7b8ab0 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -32,6 +32,7 @@ urlpatterns = [ path('', include('apps.it_manager.urls')), path('', include('apps.mattermost_connector.urls')), path('', include('apps.utilities.urls')), + path('', include('apps.nextcloud_connector.urls')), path('tinymce/', include('tinymce.urls')), path('', views.index_view, name='index'), path('accounts/login/', views.login_view, name='login'), diff --git a/apps/nextcloud_connector/access.py b/apps/nextcloud_connector/access.py new file mode 100644 index 0000000..c0896b1 --- /dev/null +++ b/apps/nextcloud_connector/access.py @@ -0,0 +1,51 @@ +"""Авторизация доступа к документам аптек. + +Ключевой инвариант безопасности: путь к папке аптеки выводится ТОЛЬКО из +объекта Pharmacy на сервере (никогда из данных клиента), а имя файла проходит +safe_filename() — так исключён обход каталога (path traversal) и переход в чужую +аптеку. Сервисный аккаунт технически видит все папки аптек, поэтому весь скоуп +держится здесь, на стороне портала. +""" +import posixpath + +from apps.nextcloud_connector.services.sync_service import pharmacy_mountpoint + + +def pharmacy_folder_relpath(pharmacy) -> str: + """Относительный путь папки аптеки в DAV-дереве сервисного аккаунта.""" + return pharmacy_mountpoint(pharmacy) + + +def can_access_pharmacy_docs(user, pharmacy) -> bool: + """Кто вправе видеть/трогать документы конкретной аптеки. + + - офис/IT/руководство (is_staff) — все аптеки (позже сузить пермишеном/регионом); + - линейный персонал аптеки — ТОЛЬКО своя аптека (user_pharmacy); + - все остальные — нет. + """ + if not getattr(user, 'is_authenticated', False): + return False + if user.is_superuser or user.is_staff: + return True + return ( + getattr(user, 'user_location_type', None) == 'pharmacy' + and getattr(user, 'user_pharmacy_id', None) == pharmacy.pk + ) + + +def safe_filename(name: str | None) -> str | None: + """Очищенное имя файла или None, если оно небезопасно. + + Отклоняет разделители пути, '.'/'..', скрытые файлы и NUL — чтобы никакой + ввод пользователя не смог выйти за пределы папки аптеки. + """ + if not name: + return None + name = name.strip() + if not name or name in ('.', '..') or name.startswith('.'): + return None + if posixpath.basename(name) != name: # содержал '/' или иной разделитель + return None + if any(c in name for c in ('/', '\\', '\x00')): + return None + return name diff --git a/apps/nextcloud_connector/tests.py b/apps/nextcloud_connector/tests.py index fdac1f6..64d1b4e 100644 --- a/apps/nextcloud_connector/tests.py +++ b/apps/nextcloud_connector/tests.py @@ -71,3 +71,97 @@ class EnsureFolderTests(TestCase): self.assertEqual(fid, 7) self.assertNotIn(('create', '03_Отделы/HR'), svc.calls) self.assertIn(('perm', 7, 'nc-dept-hr', 15), svc.calls) + + +import datetime +from django.test import Client +from core.models import Directory, DirectoryType +from apps.locations.models import Pharmacy +from apps.employees.models import Employee +from apps.nextcloud_connector.access import ( + can_access_pharmacy_docs, safe_filename, +) + + +class SafeFilenameTests(TestCase): + def test_accepts_plain_names(self): + for n in ('licence.pdf', 'Договор 2026.docx', 'фото-1.jpg'): + self.assertEqual(safe_filename(n), n) + + def test_rejects_traversal_and_separators(self): + for n in ('../secret', 'a/b.txt', 'a\\b.txt', '..', '.', '', None, + '.hidden', 'x\x00y'): + self.assertIsNone(safe_filename(n), f'должно отклонить: {n!r}') + + +class AccessControlTests(TestCase): + @classmethod + def setUpTestData(cls): + org_type = DirectoryType.objects.create(code='organization', name='Орг') + region_type = DirectoryType.objects.create(code='region', name='Регион') + region = Directory.objects.create(directory_type=region_type, code='od', name='Одесса') + org = Directory.objects.create(directory_type=org_type, code='akc', name='АКС') + cls.ph_a = Pharmacy.objects.create(organization=org, location_region=region, + pharmacy_number=17, location_type='pharmacy') + cls.ph_b = Pharmacy.objects.create(organization=org, location_region=region, + pharmacy_number=18, location_type='pharmacy') + common = dict(mattermost_sync_enabled=False, ad_sync_enabled=False, + hire_date=datetime.date(2020, 1, 1)) + cls.emp_a = Employee.objects.create_user( + username='ph_a_emp', user_location_type='pharmacy', user_pharmacy=cls.ph_a, **common) + cls.emp_b = Employee.objects.create_user( + username='ph_b_emp', user_location_type='pharmacy', user_pharmacy=cls.ph_b, **common) + cls.staff = Employee.objects.create_user(username='office', is_staff=True, **common) + + def test_pharmacy_employee_sees_only_own(self): + self.assertTrue(can_access_pharmacy_docs(self.emp_a, self.ph_a)) + self.assertFalse(can_access_pharmacy_docs(self.emp_a, self.ph_b)) + + def test_staff_sees_all(self): + self.assertTrue(can_access_pharmacy_docs(self.staff, self.ph_a)) + self.assertTrue(can_access_pharmacy_docs(self.staff, self.ph_b)) + + +class DocumentsViewTests(TestCase): + @classmethod + def setUpTestData(cls): + org_type = DirectoryType.objects.create(code='organization', name='Орг') + region_type = DirectoryType.objects.create(code='region', name='Регион') + region = Directory.objects.create(directory_type=region_type, code='od', name='Одесса') + org = Directory.objects.create(directory_type=org_type, code='akc', name='АКС') + cls.ph_a = Pharmacy.objects.create(organization=org, location_region=region, + pharmacy_number=17, location_type='pharmacy') + cls.ph_b = Pharmacy.objects.create(organization=org, location_region=region, + pharmacy_number=18, location_type='pharmacy') + common = dict(mattermost_sync_enabled=False, ad_sync_enabled=False, + hire_date=datetime.date(2020, 1, 1)) + cls.emp_a = Employee.objects.create_user( + username='ph_a_emp', user_location_type='pharmacy', user_pharmacy=cls.ph_a, **common) + + def setUp(self): + self.client = Client() + + def test_own_pharmacy_ok_but_storage_not_configured(self): + self.client.force_login(self.emp_a) + r = self.client.get(f'/pharmacies/{self.ph_a.pk}/documents/') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'не настроен') # коннектор не сконфигурирован в тестах + + def test_other_pharmacy_forbidden(self): + self.client.force_login(self.emp_a) + r = self.client.get(f'/pharmacies/{self.ph_b.pk}/documents/') + self.assertEqual(r.status_code, 403) + + def test_anonymous_redirected(self): + r = self.client.get(f'/pharmacies/{self.ph_a.pk}/documents/') + self.assertEqual(r.status_code, 302) + + def test_download_rejects_bad_filename(self): + self.client.force_login(self.emp_a) + r = self.client.get(f'/pharmacies/{self.ph_a.pk}/documents/download/?name=../etc/passwd') + self.assertEqual(r.status_code, 400) + + def test_download_other_pharmacy_forbidden(self): + self.client.force_login(self.emp_a) + r = self.client.get(f'/pharmacies/{self.ph_b.pk}/documents/download/?name=x.pdf') + self.assertEqual(r.status_code, 403) diff --git a/apps/nextcloud_connector/urls.py b/apps/nextcloud_connector/urls.py new file mode 100644 index 0000000..8d87d88 --- /dev/null +++ b/apps/nextcloud_connector/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('pharmacies//documents/', + views.pharmacy_documents, name='pharmacy_documents'), + path('pharmacies//documents/download/', + views.pharmacy_document_download, name='pharmacy_document_download'), + path('pharmacies//documents/upload/', + views.pharmacy_document_upload, name='pharmacy_document_upload'), + path('pharmacies//documents/delete/', + views.pharmacy_document_delete, name='pharmacy_document_delete'), +] diff --git a/apps/nextcloud_connector/views.py b/apps/nextcloud_connector/views.py new file mode 100644 index 0000000..f53d69d --- /dev/null +++ b/apps/nextcloud_connector/views.py @@ -0,0 +1,123 @@ +"""Виджет «Документы» аптеки — HTMX-фрагменты поверх Nextcloud WebDAV. + +Каждый эндпоинт: (1) резолвит аптеку по pk, (2) проверяет can_access_pharmacy_docs, +(3) выводит путь из объекта аптеки на сервере, (4) имя файла — через safe_filename. +""" +import logging + +from django.contrib.auth.decorators import login_required +from django.http import ( + HttpResponse, HttpResponseForbidden, HttpResponseBadRequest, Http404, +) +from django.shortcuts import get_object_or_404, render +from django.views.decorators.http import require_POST + +from apps.locations.models import Pharmacy +from apps.nextcloud_connector.access import ( + can_access_pharmacy_docs, pharmacy_folder_relpath, safe_filename, +) +from apps.nextcloud_connector.nextcloud_service import ( + NextcloudConfigError, NextcloudServiceError, +) +from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService + +logger = logging.getLogger(__name__) + +MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 МБ + + +def _get_pharmacy_or_403(request, pk): + """Возвращает (pharmacy, None) или (None, HttpResponseForbidden).""" + pharmacy = get_object_or_404(Pharmacy, pk=pk) + if not can_access_pharmacy_docs(request.user, pharmacy): + return None, HttpResponseForbidden('Нет доступа к документам этой аптеки.') + return pharmacy, None + + +def _render_documents(request, pharmacy, message=None, message_type=None): + """Рендер фрагмента списка документов (общий для list и upload/delete).""" + relpath = pharmacy_folder_relpath(pharmacy) + files, error = None, None + try: + dav = NextcloudWebDAVService() + files = dav.list_dir(relpath) + except NextcloudConfigError: + error = 'not_configured' + except NextcloudServiceError as e: + error = str(e) + logger.warning(f'Nextcloud docs (аптека {pharmacy.pk}): {e}') + return render(request, 'partials/pharmacy-documents.html', { + 'pharmacy': pharmacy, + 'files': files, + 'error': error, + 'message': message, + 'message_type': message_type, + }) + + +@login_required +def pharmacy_documents(request, pk): + pharmacy, forbidden = _get_pharmacy_or_403(request, pk) + if forbidden: + return forbidden + return _render_documents(request, pharmacy) + + +@login_required +def pharmacy_document_download(request, pk): + pharmacy, forbidden = _get_pharmacy_or_403(request, pk) + if forbidden: + return forbidden + name = safe_filename(request.GET.get('name')) + if not name: + return HttpResponseBadRequest('Некорректное имя файла.') + relpath = f'{pharmacy_folder_relpath(pharmacy)}/{name}' + try: + content, ctype = NextcloudWebDAVService().download(relpath) + except (NextcloudConfigError, NextcloudServiceError) as e: + logger.warning(f'Nextcloud download (аптека {pharmacy.pk}, {name}): {e}') + raise Http404('Файл недоступен.') + resp = HttpResponse(content, content_type=ctype) + resp['Content-Disposition'] = f'attachment; filename*=UTF-8\'\'{name}' + return resp + + +@login_required +@require_POST +def pharmacy_document_upload(request, pk): + pharmacy, forbidden = _get_pharmacy_or_403(request, pk) + if forbidden: + return forbidden + f = request.FILES.get('file') + if not f: + return _render_documents(request, pharmacy, 'Файл не выбран.', 'error') + if f.size > MAX_UPLOAD_BYTES: + return _render_documents(request, pharmacy, 'Файл больше 100 МБ.', 'error') + name = safe_filename(f.name) + if not name: + return _render_documents(request, pharmacy, 'Недопустимое имя файла.', 'error') + relpath = f'{pharmacy_folder_relpath(pharmacy)}/{name}' + try: + NextcloudWebDAVService().upload(relpath, f.read(), f.content_type) + except (NextcloudConfigError, NextcloudServiceError) as e: + logger.warning(f'Nextcloud upload (аптека {pharmacy.pk}, {name}): {e}') + return _render_documents(request, pharmacy, 'Не удалось загрузить файл.', 'error') + return _render_documents(request, pharmacy, f'Файл «{name}» загружен.', 'success') + + +@login_required +@require_POST +def pharmacy_document_delete(request, pk): + pharmacy, forbidden = _get_pharmacy_or_403(request, pk) + if forbidden: + return forbidden + name = safe_filename(request.POST.get('name')) + if not name: + return _render_documents(request, pharmacy, 'Некорректное имя файла.', 'error') + relpath = f'{pharmacy_folder_relpath(pharmacy)}/{name}' + try: + NextcloudWebDAVService().delete(relpath) + except (NextcloudConfigError, NextcloudServiceError) as e: + logger.warning(f'Nextcloud delete (аптека {pharmacy.pk}, {name}): {e}') + return _render_documents(request, pharmacy, 'Не удалось удалить файл.', 'error') + return _render_documents(request, pharmacy, f'Файл «{name}» удалён.', 'success') diff --git a/apps/nextcloud_connector/webdav_service.py b/apps/nextcloud_connector/webdav_service.py new file mode 100644 index 0000000..7df2ebe --- /dev/null +++ b/apps/nextcloud_connector/webdav_service.py @@ -0,0 +1,139 @@ +"""WebDAV-клиент Nextcloud для файловых операций в папках аптек. + +Работает под сервисным аккаунтом (ConnectorConfig(nextcloud)). Аптечные +сотрудники собственных учёток Nextcloud не имеют — доступ к документам идёт +через портал, а портал ходит в Nextcloud этим сервисным аккаунтом. Group Folder +аптеки виден в DAV-дереве сервисного аккаунта по своей точке монтирования. + +ВАЖНО: относительные пути формирует ТОЛЬКО портал из данных аптеки. Имя файла, +пришедшее от пользователя, обязано пройти access.safe_filename() до попадания сюда. +""" +import logging +from urllib.parse import quote, unquote +from xml.etree import ElementTree as ET + +import httpx + +from apps.nextcloud_connector.nextcloud_service import ( + NextcloudConfigError, NextcloudServiceError, +) + +logger = logging.getLogger(__name__) + +DAV_NS = '{DAV:}' + +PROPFIND_BODY = ( + '' + '' + '' + '' + '' +) + + +class NextcloudWebDAVService: + def __init__(self): + from core.models import ConnectorConfig + cfg = ConnectorConfig.objects.filter(connector_type='nextcloud').first() + if not cfg: + raise NextcloudConfigError('Настройки коннектора Nextcloud не найдены.') + if not cfg.is_enabled: + raise NextcloudConfigError('Коннектор Nextcloud отключён.') + secret = cfg.token or cfg.password + if not cfg.host or not cfg.username or not secret: + raise NextcloudConfigError( + 'Для коннектора Nextcloud нужны host, username и token сервисного аккаунта.' + ) + + extra = cfg.extra_config or {} + scheme = 'https' if cfg.use_ssl else 'http' + port = f':{cfg.port}' if cfg.port else '' + base = f'{scheme}://{cfg.host}{port}' + self.username = cfg.username + self.dav_root = f'{base}/remote.php/dav/files/{quote(cfg.username)}' + + verify = bool(extra.get('verify_ssl', True)) + self._client = httpx.Client( + auth=(cfg.username, secret), + verify=verify, + timeout=extra.get('timeout', 30), + ) + + def _url(self, relpath: str) -> str: + return f'{self.dav_root}/{quote(relpath, safe="/")}' + + def _raise(self, resp, relpath): + raise NextcloudServiceError( + f'WebDAV {resp.request.method} «{relpath}»: HTTP {resp.status_code}' + ) + + # --- операции --- + + def list_dir(self, relpath: str) -> list[dict]: + """Содержимое папки (глубина 1). Сама папка из результата исключается.""" + url = self._url(relpath) + try: + resp = self._client.request( + 'PROPFIND', url, content=PROPFIND_BODY, + headers={'Depth': '1', 'Content-Type': 'application/xml'}, + ) + except httpx.HTTPError as e: + raise NextcloudServiceError(f'WebDAV PROPFIND «{relpath}»: {e}') from e + if resp.status_code == 404: + return [] + if resp.status_code != 207: + self._raise(resp, relpath) + + self_href = unquote(url.split('/remote.php')[1]).rstrip('/') + entries: list[dict] = [] + root = ET.fromstring(resp.content) + for r in root.findall(f'{DAV_NS}response'): + href_el = r.find(f'{DAV_NS}href') + if href_el is None or not href_el.text: + continue + href = unquote(href_el.text) + if href.rstrip('/').endswith(self_href): + continue # это сама папка, не её содержимое + prop = r.find(f'{DAV_NS}propstat/{DAV_NS}prop') + is_dir = prop is not None and prop.find( + f'{DAV_NS}resourcetype/{DAV_NS}collection') is not None + name = unquote(href.rstrip('/')).rsplit('/', 1)[-1] + size_el = prop.find(f'{DAV_NS}getcontentlength') if prop is not None else None + mod_el = prop.find(f'{DAV_NS}getlastmodified') if prop is not None else None + entries.append({ + 'name': name, + 'is_dir': is_dir, + 'size': int(size_el.text) if (size_el is not None and size_el.text) else None, + 'modified': mod_el.text if mod_el is not None else None, + }) + entries.sort(key=lambda e: (not e['is_dir'], e['name'].lower())) + return entries + + def download(self, relpath: str) -> tuple[bytes, str]: + try: + resp = self._client.get(self._url(relpath)) + except httpx.HTTPError as e: + raise NextcloudServiceError(f'WebDAV GET «{relpath}»: {e}') from e + if resp.status_code != 200: + self._raise(resp, relpath) + return resp.content, resp.headers.get('content-type', 'application/octet-stream') + + def upload(self, relpath: str, content: bytes, content_type: str | None = None) -> None: + headers = {'Content-Type': content_type} if content_type else {} + try: + resp = self._client.put(self._url(relpath), content=content, headers=headers) + except httpx.HTTPError as e: + raise NextcloudServiceError(f'WebDAV PUT «{relpath}»: {e}') from e + if resp.status_code not in (200, 201, 204): + self._raise(resp, relpath) + + def delete(self, relpath: str) -> None: + try: + resp = self._client.delete(self._url(relpath)) + except httpx.HTTPError as e: + raise NextcloudServiceError(f'WebDAV DELETE «{relpath}»: {e}') from e + if resp.status_code not in (200, 204, 404): + self._raise(resp, relpath) + + def close(self): + self._client.close() diff --git a/templates/pages/pharmacy-detail.html b/templates/pages/pharmacy-detail.html index 36b175f..6452b84 100644 --- a/templates/pages/pharmacy-detail.html +++ b/templates/pages/pharmacy-detail.html @@ -71,6 +71,8 @@ style="padding:0.375rem 1rem; font-size:0.8125rem; font-weight:500; border:none; border-radius:calc(var(--radius) - 2px); cursor:pointer; background:transparent; color:var(--color-muted-foreground);">{% trans "Интернет-провайдер" %} + @@ -328,6 +330,14 @@ + + + diff --git a/templates/partials/pharmacy-documents.html b/templates/partials/pharmacy-documents.html new file mode 100644 index 0000000..805eebd --- /dev/null +++ b/templates/partials/pharmacy-documents.html @@ -0,0 +1,79 @@ +{% load i18n %} +
+
+

{% trans "Документы аптеки" %}

+ Nextcloud +
+ + {% if message %} +
+ {{ message }} +
+ {% endif %} + + {% if error == 'not_configured' %} +
+ {% trans "Файловое хранилище пока не настроено." %} +
+ {% elif error %} +
+ {% trans "Не удалось получить список документов." %} +
+ {% else %} + {% if files %} +
+ + + {% for f in files %} + + + + + + {% endfor %} + +
+ {% if f.is_dir %}📁{% else %}📄{% endif %} + {% if f.is_dir %} + {{ f.name }} + {% else %} + {{ f.name }} + {% endif %} + + {% if f.size is not None %}{{ f.size|filesizeformat }}{% endif %} + + {% if not f.is_dir %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+
+ {% else %} +
+ {% trans "В папке аптеки пока нет файлов." %} +
+ {% endif %} + +
+ {% csrf_token %} + + +
+ {% endif %} +