Nextcloud connector: страница «Документы» подключена к хранилищу

Заменяет статичный макет 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 15:02:30 +03:00
parent b6ad270971
commit ed7dcaae3b
7 changed files with 350 additions and 81 deletions
+51
View File
@@ -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, если оно небезопасно.
+82
View File
@@ -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)
+5
View File
@@ -11,4 +11,9 @@ urlpatterns = [
views.pharmacy_document_upload, name='pharmacy_document_upload'),
path('pharmacies/<int:pk>/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'),
]
+95
View File
@@ -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')
+4 -1
View File
@@ -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):
+36 -80
View File
@@ -1,90 +1,46 @@
{% load i18n %}
<div class="page-content">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.5rem;">
<div>
<h1 style="font-size:1.25rem; font-weight:500; color:var(--color-foreground);">Документи</h1>
<p style="font-size:0.875rem; color:var(--color-muted-foreground); margin-top:0.25rem;">Шаблони, накази та кадрові документи</p>
</div>
<button class="btn btn-primary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" x2="12" y1="5" y2="19"/><line x1="5" x2="19" y1="12" y2="12"/>
</svg>
Завантажити
</button>
<div style="margin-bottom:1.5rem;">
<h1 style="font-size:1.25rem; font-weight:500; color:var(--color-foreground);">{% trans "Документы" %}</h1>
<p style="font-size:0.875rem; color:var(--color-muted-foreground); margin-top:0.25rem;">{% trans "Файловое хранилище: общие документы, обучение, отделы" %}</p>
</div>
<!-- Фільтри -->
<div style="display:flex; gap:2px; background:var(--color-muted); border-radius:var(--radius); padding:3px; width:fit-content; margin-bottom:1.25rem;">
<button style="padding:0.3125rem 1rem; border-radius:calc(var(--radius) - 2px); font-size:0.8125rem; font-weight:500; border:none; cursor:pointer; background:var(--color-card); color:var(--color-foreground);">Всі</button>
<button style="padding:0.3125rem 1rem; border-radius:calc(var(--radius) - 2px); font-size:0.8125rem; font-weight:500; border:none; cursor:pointer; background:transparent; color:var(--color-muted-foreground);">Накази</button>
<button style="padding:0.3125rem 1rem; border-radius:calc(var(--radius) - 2px); font-size:0.8125rem; font-weight:500; border:none; cursor:pointer; background:transparent; color:var(--color-muted-foreground);">Шаблони</button>
<button style="padding:0.3125rem 1rem; border-radius:calc(var(--radius) - 2px); font-size:0.8125rem; font-weight:500; border:none; cursor:pointer; background:transparent; color:var(--color-muted-foreground);">Договори</button>
{% if sections %}
<!-- Разделы хранилища -->
<div style="display:flex; gap:2px; background:var(--color-muted); border-radius:var(--radius); padding:3px; width:fit-content; margin-bottom:1.25rem; flex-wrap:wrap;">
{% for s in sections %}
<button type="button" class="doc-section-btn{% if forloop.first %} doc-section-active{% endif %}" data-section="{{ s.key }}"
onclick="docSectionSwitch('{{ s.key }}')"
style="padding:0.3125rem 1rem; border-radius:calc(var(--radius) - 2px); font-size:0.8125rem; font-weight:500; border:none; cursor:pointer; {% if forloop.first %}background:var(--color-card); color:var(--color-foreground);{% else %}background:transparent; color:var(--color-muted-foreground);{% endif %}">{{ s.label }}</button>
{% endfor %}
</div>
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0/0.05);">
<div style="overflow-x:auto;">
<table style="width:100%; border-collapse:collapse; font-size:0.875rem;">
<thead>
<tr style="border-bottom:1px solid var(--color-border);">
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Назва</th>
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Тип</th>
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Дата</th>
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Розмір</th>
<th style="padding:0.75rem 1rem;"></th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid var(--color-border);" class="tr-hover">
<td style="padding:1rem;">
<div style="display:flex; align-items:center; gap:0.625rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-muted-foreground)" stroke-width="2"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>
<span style="font-weight:500; color:var(--color-card-foreground);">Наказ про прийом на роботу №42</span>
</div>
</td>
<td style="padding:1rem;"><span class="badge badge-info">Наказ</span></td>
<td style="padding:1rem; color:var(--color-muted-foreground);">01.04.2026</td>
<td style="padding:1rem; color:var(--color-muted-foreground);">48 КБ</td>
<td style="padding:1rem; text-align:right;">
<button class="btn btn-ghost btn-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
</button>
</td>
</tr>
<tr style="border-bottom:1px solid var(--color-border);" class="tr-hover">
<td style="padding:1rem;">
<div style="display:flex; align-items:center; gap:0.625rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-muted-foreground)" stroke-width="2"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>
<span style="font-weight:500; color:var(--color-card-foreground);">Трудовий договір — шаблон</span>
</div>
</td>
<td style="padding:1rem;"><span class="badge badge-muted">Шаблон</span></td>
<td style="padding:1rem; color:var(--color-muted-foreground);">15.01.2026</td>
<td style="padding:1rem; color:var(--color-muted-foreground);">112 КБ</td>
<td style="padding:1rem; text-align:right;">
<button class="btn btn-ghost btn-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
</button>
</td>
</tr>
<tr class="tr-hover">
<td style="padding:1rem;">
<div style="display:flex; align-items:center; gap:0.625rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-muted-foreground)" stroke-width="2"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>
<span style="font-weight:500; color:var(--color-card-foreground);">Посадова інструкція — Фармацевт</span>
</div>
</td>
<td style="padding:1rem;"><span class="badge badge-muted">Шаблон</span></td>
<td style="padding:1rem; color:var(--color-muted-foreground);">10.12.2025</td>
<td style="padding:1rem; color:var(--color-muted-foreground);">78 КБ</td>
<td style="padding:1rem; text-align:right;">
<button class="btn btn-ghost btn-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
</button>
</td>
</tr>
</tbody>
</table>
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0/0.05); padding:1.25rem;">
<div id="documents-browser"
style="font-size:0.8125rem; color:var(--color-muted-foreground);"
hx-get="/documents/browse/?section={{ sections.0.key }}"
hx-trigger="load" hx-swap="innerHTML">
{% trans "Загрузка…" %}
</div>
</div>
{% else %}
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); padding:1.5rem; font-size:0.875rem; color:var(--color-muted-foreground);">
{% trans "Нет доступных разделов документов." %}
</div>
{% endif %}
</div>
<script>
function docSectionSwitch(key) {
document.querySelectorAll('.doc-section-btn').forEach(function (btn) {
var active = btn.getAttribute('data-section') === key;
btn.classList.toggle('doc-section-active', active);
btn.style.background = active ? 'var(--color-card)' : 'transparent';
btn.style.color = active ? 'var(--color-foreground)' : 'var(--color-muted-foreground)';
});
htmx.ajax('GET', '/documents/browse/?section=' + encodeURIComponent(key),
{ target: '#documents-browser', swap: 'innerHTML' });
}
</script>
+77
View File
@@ -0,0 +1,77 @@
{% load i18n %}
{% if message %}
<div style="margin-bottom:0.875rem; padding:0.625rem 0.875rem; border-radius:0.5rem; font-size:0.8125rem;
{% if message_type == 'success' %}color:#137333; background:rgba(19,115,51,0.1); border:1px solid rgba(19,115,51,0.25);
{% else %}color:#d63333; background:rgba(214,51,51,0.1); border:1px solid rgba(214,51,51,0.25);{% endif %}">
{{ message }}
</div>
{% endif %}
<!-- Хлебные крошки -->
<div style="display:flex; align-items:center; gap:0.375rem; flex-wrap:wrap; margin-bottom:1rem; font-size:0.8125rem;">
<a href="#" hx-get="/documents/browse/?section={{ section.key }}" hx-target="#documents-browser" hx-swap="innerHTML"
style="color:var(--color-primary); text-decoration:none; font-weight:500;">{{ section.label }}</a>
{% for c in crumbs %}
<span style="color:var(--color-muted-foreground);">/</span>
{% if forloop.last %}
<span style="color:var(--color-foreground);">{{ c.name }}</span>
{% else %}
<a href="#" hx-get="/documents/browse/?section={{ section.key }}&path={{ c.path|urlencode }}"
hx-target="#documents-browser" hx-swap="innerHTML"
style="color:var(--color-primary); text-decoration:none;">{{ c.name }}</a>
{% endif %}
{% endfor %}
</div>
{% if error == 'not_configured' %}
<div style="padding:0.875rem; font-size:0.8125rem; color:var(--color-muted-foreground);">
{% trans "Файловое хранилище пока не настроено." %}
</div>
{% elif error %}
<div style="padding:0.875rem; font-size:0.8125rem; color:#d63333;">
{% trans "Не удалось получить список документов." %}
</div>
{% else %}
{% if files %}
<div style="border:1px solid var(--color-border); border-radius:0.5rem; overflow:hidden; margin-bottom:1rem;">
<table style="width:100%; border-collapse:collapse; font-size:0.8125rem;">
<tbody>
{% for f in files %}
<tr style="border-bottom:1px solid var(--color-border);" class="tr-hover">
<td style="padding:0.5rem 0.75rem;">
{% if f.is_dir %}
<a href="#" hx-get="/documents/browse/?section={{ section.key }}&path={{ f.path|urlencode }}"
hx-target="#documents-browser" hx-swap="innerHTML"
style="color:var(--color-foreground); text-decoration:none; font-weight:500;">📁 {{ f.name }}</a>
{% else %}
<a href="/documents/download/?section={{ section.key }}&path={{ sub|urlencode }}&name={{ f.name|urlencode }}"
style="color:var(--color-primary); text-decoration:none;">📄 {{ f.name }}</a>
{% endif %}
</td>
<td style="padding:0.5rem 0.75rem; text-align:right; color:var(--color-muted-foreground); white-space:nowrap;">
{% if f.size is not None %}{{ f.size|filesizeformat }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="padding:0.875rem; font-size:0.8125rem; color:var(--color-muted-foreground); margin-bottom:1rem;">
{% trans "В этой папке пока нет файлов." %}
</div>
{% endif %}
{% if section.writable %}
<form method="post" action="/documents/upload/"
hx-post="/documents/upload/" hx-target="#documents-browser" hx-swap="innerHTML"
hx-encoding="multipart/form-data" enctype="multipart/form-data"
style="display:flex; gap:0.5rem; align-items:center;">
{% csrf_token %}
<input type="hidden" name="section" value="{{ section.key }}">
<input type="hidden" name="path" value="{{ sub }}">
<input type="file" name="file" required style="font-size:0.8125rem; flex:1; min-width:0;">
<button type="submit" class="btn btn-primary" style="font-size:0.8125rem; white-space:nowrap;">{% trans "Загрузить" %}</button>
</form>
{% endif %}
{% endif %}