From ed7dcaae3b662f691c1ba011e0b30871c04d277e Mon Sep 17 00:00:00 2001 From: lokses Date: Tue, 28 Jul 2026 15:02:30 +0300 Subject: [PATCH] =?UTF-8?q?Nextcloud=20connector:=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=C2=AB=D0=94=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=C2=BB=20=D0=BF=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B0=20=D0=BA=20=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заменяет статичный макет documents.html реальным браузером Group Folders: - access.py: accessible_sections (разделы по правам: Компания/Обучение/Обмен, отдел сотрудника, Кадровые дела для суперюзера) + safe_relpath (посегментная валидация подпути от path traversal); раздел резолвится только из whitelist - views: documents_browse/download/upload с проверкой раздела и пути на каждом эндпоинте, запись — только в writable-разделы - documents.html: вкладки разделов + ленивый HTMX-браузер с хлебными крошками, навигацией по папкам, скачиванием и загрузкой - documents_view отдаёт доступные разделы; тесты разделов, путей и авторизации Co-Authored-By: Claude Opus 4.8 --- apps/nextcloud_connector/access.py | 51 ++++++++++ apps/nextcloud_connector/tests.py | 82 +++++++++++++++ apps/nextcloud_connector/urls.py | 5 + apps/nextcloud_connector/views.py | 95 ++++++++++++++++++ core/views.py | 5 +- templates/pages/documents.html | 116 +++++++--------------- templates/partials/documents-browser.html | 77 ++++++++++++++ 7 files changed, 350 insertions(+), 81 deletions(-) create mode 100644 templates/partials/documents-browser.html diff --git a/apps/nextcloud_connector/access.py b/apps/nextcloud_connector/access.py index c0896b1..77e4b78 100644 --- a/apps/nextcloud_connector/access.py +++ b/apps/nextcloud_connector/access.py @@ -33,6 +33,57 @@ def can_access_pharmacy_docs(user, pharmacy) -> bool: ) +# --- Общий браузер документов (страница /documents/) --- + +def accessible_sections(user) -> list[dict]: + """Корневые разделы хранилища, доступные пользователю. + + Каждый раздел — {key, label, relpath, writable}. Ключ приходит от клиента, + но раздел резолвится ТОЛЬКО из этого списка (get_section) — произвольный путь + подставить нельзя. Права на запись считаются здесь, а не на клиенте. + """ + if not getattr(user, 'is_authenticated', False): + return [] + is_staff = bool(user.is_staff or user.is_superuser) + sections = [ + {'key': 'company', 'label': 'Компания', 'relpath': '01_Компания', 'writable': is_staff}, + {'key': 'training', 'label': 'Обучение', 'relpath': '05_Обучение', 'writable': is_staff}, + ] + dept = getattr(user, 'department', None) + if dept is not None: + sections.append({'key': 'dept', 'label': dept.name, + 'relpath': f'03_Отделы/{dept.name}', 'writable': True}) + sections.append({'key': 'exchange', 'label': 'Обмен', 'relpath': '99_Обмен', 'writable': True}) + if user.is_superuser: + sections.append({'key': 'hr', 'label': 'Кадровые дела', + 'relpath': '04_Кадровые дела', 'writable': True}) + return sections + + +def get_section(user, key: str) -> dict | None: + for s in accessible_sections(user): + if s['key'] == key: + return s + return None + + +def safe_relpath(subpath: str | None) -> str | None: + """Разбирает подпуть внутри раздела. '' → корень. None, если небезопасно. + + Каждый сегмент проходит safe_filename — обход каталога (.., абсолютные пути) + исключён, выйти за пределы раздела нельзя. + """ + if not subpath: + return '' + parts = subpath.strip('/').split('/') + clean = [] + for p in parts: + if safe_filename(p) is None: + return None + clean.append(p) + return '/'.join(clean) + + def safe_filename(name: str | None) -> str | None: """Очищенное имя файла или None, если оно небезопасно. diff --git a/apps/nextcloud_connector/tests.py b/apps/nextcloud_connector/tests.py index 64d1b4e..d788d46 100644 --- a/apps/nextcloud_connector/tests.py +++ b/apps/nextcloud_connector/tests.py @@ -165,3 +165,85 @@ class DocumentsViewTests(TestCase): 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) + + +from apps.nextcloud_connector.access import ( + accessible_sections, get_section, safe_relpath, +) + + +class SafeRelpathTests(TestCase): + def test_empty_is_root(self): + self.assertEqual(safe_relpath(''), '') + self.assertEqual(safe_relpath(None), '') + + def test_valid_nested(self): + self.assertEqual(safe_relpath('Приказы/2026'), 'Приказы/2026') + self.assertEqual(safe_relpath('/Приказы/'), 'Приказы') + + def test_rejects_traversal(self): + for p in ('../etc', 'a/../b', 'a/./b', 'a//b', 'a/.hidden'): + self.assertIsNone(safe_relpath(p), f'должно отклонить: {p!r}') + + +class SectionsTests(TestCase): + @classmethod + def setUpTestData(cls): + common = dict(mattermost_sync_enabled=False, ad_sync_enabled=False, + hire_date=datetime.date(2020, 1, 1)) + cls.plain = Employee.objects.create_user(username='plain', **common) + cls.staff = Employee.objects.create_user(username='st', is_staff=True, **common) + cls.root = Employee.objects.create_user( + username='root', is_staff=True, is_superuser=True, **common) + + def _keys(self, user): + return {s['key'] for s in accessible_sections(user)} + + def test_plain_user_sections(self): + self.assertEqual(self._keys(self.plain), {'company', 'training', 'exchange'}) + + def test_company_writable_only_for_staff(self): + company_plain = get_section(self.plain, 'company') + company_staff = get_section(self.staff, 'company') + self.assertFalse(company_plain['writable']) + self.assertTrue(company_staff['writable']) + + def test_superuser_gets_hr(self): + self.assertIn('hr', self._keys(self.root)) + self.assertNotIn('hr', self._keys(self.staff)) + + +class DocumentsBrowserViewTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = Employee.objects.create_user( + username='browser', mattermost_sync_enabled=False, ad_sync_enabled=False, + hire_date=datetime.date(2020, 1, 1)) + + def setUp(self): + self.client = Client() + + def test_browse_valid_section_ok(self): + self.client.force_login(self.user) + r = self.client.get('/documents/browse/?section=company') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'не настроен') + + def test_browse_inaccessible_section_forbidden(self): + self.client.force_login(self.user) + r = self.client.get('/documents/browse/?section=hr') # не суперюзер + self.assertEqual(r.status_code, 403) + + def test_browse_traversal_path_bad_request(self): + self.client.force_login(self.user) + r = self.client.get('/documents/browse/?section=company&path=../04_Кадровые дела') + self.assertEqual(r.status_code, 400) + + def test_upload_to_readonly_section_forbidden(self): + self.client.force_login(self.user) # обычный юзер: company только чтение + r = self.client.post('/documents/upload/', {'section': 'company', 'path': ''}) + self.assertEqual(r.status_code, 403) + + def test_anonymous_redirected(self): + r = self.client.get('/documents/browse/?section=company') + self.assertEqual(r.status_code, 302) diff --git a/apps/nextcloud_connector/urls.py b/apps/nextcloud_connector/urls.py index 8d87d88..2342e05 100644 --- a/apps/nextcloud_connector/urls.py +++ b/apps/nextcloud_connector/urls.py @@ -11,4 +11,9 @@ urlpatterns = [ views.pharmacy_document_upload, name='pharmacy_document_upload'), path('pharmacies//documents/delete/', views.pharmacy_document_delete, name='pharmacy_document_delete'), + + # Общий браузер документов (страница /documents/) + path('documents/browse/', views.documents_browse, name='documents_browse'), + path('documents/download/', views.document_download, name='document_download'), + path('documents/upload/', views.document_upload, name='document_upload'), ] diff --git a/apps/nextcloud_connector/views.py b/apps/nextcloud_connector/views.py index f53d69d..d73039b 100644 --- a/apps/nextcloud_connector/views.py +++ b/apps/nextcloud_connector/views.py @@ -15,6 +15,7 @@ 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, + accessible_sections, get_section, safe_relpath, ) from apps.nextcloud_connector.nextcloud_service import ( NextcloudConfigError, NextcloudServiceError, @@ -121,3 +122,97 @@ def pharmacy_document_delete(request, pk): logger.warning(f'Nextcloud delete (аптека {pharmacy.pk}, {name}): {e}') return _render_documents(request, pharmacy, 'Не удалось удалить файл.', 'error') return _render_documents(request, pharmacy, f'Файл «{name}» удалён.', 'success') + + +# ── Общий браузер документов (страница /documents/) ─────────────────────────── + +def _resolve_section_path(request, method='GET'): + """(section, sub) или (None, HttpResponse-ошибка). sub — безопасный подпуть.""" + src = request.GET if method == 'GET' else request.POST + section = get_section(request.user, src.get('section', '')) + if not section: + return None, HttpResponseForbidden('Раздел недоступен.') + sub = safe_relpath(src.get('path', '')) + if sub is None: + return None, HttpResponseBadRequest('Некорректный путь.') + return (section, sub), None + + +def _section_relpath(section, sub, name=None): + relpath = section['relpath'] + (f'/{sub}' if sub else '') + return f'{relpath}/{name}' if name else relpath + + +def _render_browser(request, section, sub, message=None, message_type=None): + files, error = None, None + try: + files = NextcloudWebDAVService().list_dir(_section_relpath(section, sub)) + for f in files: # путь для навигации в подпапку / скачивания файла + f['path'] = f'{sub}/{f["name"]}' if sub else f['name'] + except NextcloudConfigError: + error = 'not_configured' + except NextcloudServiceError as e: + error = str(e) + logger.warning(f'Nextcloud docs ({section["key"]}/{sub}): {e}') + crumbs, acc = [], '' + for part in (sub.split('/') if sub else []): + acc = f'{acc}/{part}' if acc else part + crumbs.append({'name': part, 'path': acc}) + return render(request, 'partials/documents-browser.html', { + 'section': section, 'sub': sub, 'crumbs': crumbs, + 'files': files, 'error': error, + 'message': message, 'message_type': message_type, + }) + + +@login_required +def documents_browse(request): + resolved, err = _resolve_section_path(request) + if err: + return err + section, sub = resolved + return _render_browser(request, section, sub) + + +@login_required +def document_download(request): + resolved, err = _resolve_section_path(request) + if err: + return err + section, sub = resolved + name = safe_filename(request.GET.get('name')) + if not name: + return HttpResponseBadRequest('Некорректное имя файла.') + try: + content, ctype = NextcloudWebDAVService().download(_section_relpath(section, sub, name)) + except (NextcloudConfigError, NextcloudServiceError) as e: + logger.warning(f'Nextcloud download ({section["key"]}/{sub}/{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 document_upload(request): + resolved, err = _resolve_section_path(request, 'POST') + if err: + return err + section, sub = resolved + if not section['writable']: + return HttpResponseForbidden('Раздел доступен только для чтения.') + f = request.FILES.get('file') + if not f: + return _render_browser(request, section, sub, 'Файл не выбран.', 'error') + if f.size > MAX_UPLOAD_BYTES: + return _render_browser(request, section, sub, 'Файл больше 100 МБ.', 'error') + name = safe_filename(f.name) + if not name: + return _render_browser(request, section, sub, 'Недопустимое имя файла.', 'error') + try: + NextcloudWebDAVService().upload(_section_relpath(section, sub, name), f.read(), f.content_type) + except (NextcloudConfigError, NextcloudServiceError) as e: + logger.warning(f'Nextcloud upload ({section["key"]}/{sub}/{name}): {e}') + return _render_browser(request, section, sub, 'Не удалось загрузить файл.', 'error') + return _render_browser(request, section, sub, f'Файл «{name}» загружен.', 'success') diff --git a/core/views.py b/core/views.py index 781ec86..c164f29 100644 --- a/core/views.py +++ b/core/views.py @@ -373,7 +373,10 @@ def reports_view(request): @login_required def documents_view(request): - return render_page(request, 'documents.html') + from apps.nextcloud_connector.access import accessible_sections + return render_page(request, 'documents.html', { + 'sections': accessible_sections(request.user), + }) @login_required def recruiting_view(request): diff --git a/templates/pages/documents.html b/templates/pages/documents.html index 834f048..b5a5584 100644 --- a/templates/pages/documents.html +++ b/templates/pages/documents.html @@ -1,90 +1,46 @@ +{% load i18n %}
-
-
-

Документи

-

Шаблони, накази та кадрові документи

-
- +
+

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

+

{% trans "Файловое хранилище: общие документы, обучение, отделы" %}

- -
- - - - + {% if sections %} + +
+ {% for s in sections %} + + {% endfor %}
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
НазваТипДатаРозмір
-
- - Наказ про прийом на роботу №42 -
-
Наказ01.04.202648 КБ - -
-
- - Трудовий договір — шаблон -
-
Шаблон15.01.2026112 КБ - -
-
- - Посадова інструкція — Фармацевт -
-
Шаблон10.12.202578 КБ - -
+
+
+ {% trans "Загрузка…" %}
+ {% else %} +
+ {% trans "Нет доступных разделов документов." %} +
+ {% endif %}
+ + diff --git a/templates/partials/documents-browser.html b/templates/partials/documents-browser.html new file mode 100644 index 0000000..db29c3e --- /dev/null +++ b/templates/partials/documents-browser.html @@ -0,0 +1,77 @@ +{% load i18n %} +{% if message %} +
+ {{ message }} +
+{% endif %} + + +
+ {{ section.label }} + {% for c in crumbs %} + / + {% if forloop.last %} + {{ c.name }} + {% else %} + {{ c.name }} + {% endif %} + {% endfor %} +
+ +{% if error == 'not_configured' %} +
+ {% trans "Файловое хранилище пока не настроено." %} +
+{% elif error %} +
+ {% trans "Не удалось получить список документов." %} +
+{% else %} + {% if files %} +
+ + + {% for f in files %} + + + + + {% endfor %} + +
+ {% if f.is_dir %} + 📁 {{ f.name }} + {% else %} + 📄 {{ f.name }} + {% endif %} + + {% if f.size is not None %}{{ f.size|filesizeformat }}{% endif %} +
+
+ {% else %} +
+ {% trans "В этой папке пока нет файлов." %} +
+ {% endif %} + + {% if section.writable %} +
+ {% csrf_token %} + + + + +
+ {% endif %} +{% endif %}