SFM/app/opencloud_access.py
Raykov-MS ef0f955585 fix
2026-10-16 11:19:18 +03:00

529 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import quote, unquote, urlparse
import requests
from requests.auth import HTTPBasicAuth
OPENCLOUD_DOMAIN = "docs.raisa.go.rshbank.ru"
OPENCLOUD_UPLOAD_TIMEOUT_SECONDS = 30
# Content-Type подобран по расширению — сервер OpenCloud (как и обычный WebDAV)
# не требует конкретного значения для PUT, но так удобнее просматривать файлы
# в интерфейсе.
_CONTENT_TYPES = {
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
".log": "text/plain; charset=utf-8",
}
_DEFAULT_CONTENT_TYPE = "application/octet-stream"
_OC_NAMESPACE = "http://owncloud.org/ns"
# Свойства, по которым OpenCloud (как и ownCloud) отдаёт ссылку для
# веб-интерфейса: oc:privatelink — готовая ссылка, oc:fileid — ID, из которого
# её можно собрать (см. build_opencloud_private_link).
_PRIVATE_LINK_PROPFIND_BODY = (
'<?xml version="1.0" encoding="UTF-8"?>'
f'<d:propfind xmlns:d="DAV:" xmlns:oc="{_OC_NAMESPACE}">'
"<d:prop><oc:privatelink/><oc:fileid/></d:prop>"
"</d:propfind>"
)
logger = logging.getLogger(__name__)
class OpenCloudUploadError(RuntimeError):
"""Ошибка загрузки файла в OpenCloud по WebDAV."""
@dataclass(frozen=True)
class DriveQuota:
"""Квота личного пространства в байтах (поле quota диска в Graph API)."""
used: int
total: int | None = None
remaining: int | None = None
@dataclass(frozen=True)
class PersonalDrive:
id: str
quota: DriveQuota | None = None
# driveAlias из Graph API ("personal/<логин>") — часть пути к пространству
# в веб-интерфейсе: /files/spaces/personal/<логин>/...
alias: str | None = None
@dataclass(frozen=True)
class OpenCloudUploadResult:
"""Куда загружен файл.
dav_url — WebDAV-адрес (/dav/spaces/...): по нему файл грузили, его же
пишем в протокол. Пользователю он бесполезен: браузер по нему просит
логин или скачивает файл. web_url — ссылка вида https://<домен>/f/<fileid>,
которая открывает файл в веб-интерфейсе OpenCloud; None, если её не
удалось получить.
"""
dav_url: str
web_url: str | None = None
def resolve_personal_drive_id(username: str, token: str) -> str:
"""ID личного пространства пользователя — см. resolve_personal_drive."""
return resolve_personal_drive(username, token).id
def resolve_personal_drive(username: str, token: str) -> PersonalDrive:
"""Находит личное пространство пользователя через Graph API.
Больше не грузим файлы в общее тестовое пространство по фиксированному
SPACE_ID — у каждого заказчика есть личное пространство (driveType ==
"personal" в GET /graph/v1.0/me/drives), и его ID нужно получать заново
перед каждой загрузкой: пространство может быть пересоздано, а хранить
потенциально устаревший ID рискованнее, чем сделать лишний HTTP-запрос.
Авторизация — та же Basic Auth (username + App Token), что и для самой
загрузки по WebDAV, см. upload_local_file_to_opencloud.
"""
if not username:
raise OpenCloudUploadError(
"Не задан username для поиска личного пространства в OpenCloud."
)
if not token:
raise OpenCloudUploadError(
"Отсутствует App Token для поиска личного пространства в OpenCloud."
)
url = f"https://{OPENCLOUD_DOMAIN}/graph/v1.0/me/drives"
try:
response = requests.get(
url,
auth=HTTPBasicAuth(username, token),
timeout=OPENCLOUD_UPLOAD_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise OpenCloudUploadError(
f"Ошибка сети при поиске личного пространства OpenCloud: {exc}"
) from exc
if response.status_code != 200:
raise OpenCloudUploadError(
"Не удалось получить список пространств OpenCloud: "
f"HTTP {response.status_code}, body={response.text[:500]!r}"
)
drives = response.json().get("value", [])
personal_drives = [
drive for drive in drives if drive.get("driveType") == "personal"
]
if not personal_drives:
raise OpenCloudUploadError(
f"Не найдено личное пространство (driveType='personal') для "
f"пользователя '{username}' в OpenCloud."
)
personal_drive = personal_drives[0]
drive_id = personal_drive.get("id")
if not drive_id:
raise OpenCloudUploadError(
"Личное пространство найдено в OpenCloud, но у него отсутствует id."
)
drive_alias = personal_drive.get("driveAlias")
return PersonalDrive(
id=str(drive_id),
quota=_parse_drive_quota(personal_drive.get("quota")),
alias=str(drive_alias) if drive_alias else None,
)
def _parse_drive_quota(raw_quota: object) -> DriveQuota | None:
"""Разбирает поле quota диска из Graph API (used/total/remaining, байты).
Наличие и формат поля на нашем OpenCloud не проверены, поэтому всё, что не
похоже на квоту, даёт None — квота нужна только для отображения и не
должна ломать поиск пространства перед загрузкой.
"""
if not isinstance(raw_quota, Mapping):
logger.info("OpenCloud не вернул quota у личного пространства: %r", raw_quota)
return None
used = _non_negative_int(raw_quota.get("used"))
if used is None:
logger.info("В quota личного пространства нет поля used: %r", raw_quota)
return None
total = _non_negative_int(raw_quota.get("total"))
return DriveQuota(
used=used,
# 0 в total — квота не задана (безлимит), а не «0 байт».
total=total or None,
remaining=_non_negative_int(raw_quota.get("remaining")),
)
def _non_negative_int(value: object) -> int | None:
if isinstance(value, bool) or not isinstance(value, int | float):
return None
if value < 0:
return None
return int(value)
def next_opencloud_run_number(
space_id: str,
day_folder_segments: tuple[str, ...] | list[str],
username: str,
token: str,
) -> int:
"""Определяет следующий номер запуска внутри Out/год/месяц/день.
Листит папку дня через WebDAV PROPFIND (Depth: 1) и берёт максимальное
числовое имя подпапки + 1. Если папки дня ещё нет (первый запуск за
день) — PROPFIND вернёт 404, считаем, что это первый запуск.
"""
encoded_path = "/".join(
quote(segment, safe="") for segment in day_folder_segments if segment
)
url = f"https://{OPENCLOUD_DOMAIN}/dav/spaces/{space_id}/{encoded_path}"
try:
response = requests.request(
"PROPFIND",
url,
auth=HTTPBasicAuth(username, token),
headers={"Depth": "1"},
timeout=OPENCLOUD_UPLOAD_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise OpenCloudUploadError(
f"Ошибка сети при определении номера запуска в OpenCloud: {exc}"
) from exc
if response.status_code == 404:
return 1
if response.status_code != 207:
raise OpenCloudUploadError(
"Не удалось получить содержимое папки дня в OpenCloud: "
f"HTTP {response.status_code}, url='{url}', "
f"body={response.text[:500]!r}"
)
try:
root = ET.fromstring(response.content)
except ET.ParseError as exc:
raise OpenCloudUploadError(
f"Не удалось разобрать ответ PROPFIND от OpenCloud: {exc}"
) from exc
requested_path = urlparse(url).path.rstrip("/")
numbers: list[int] = []
for element in root.iter():
if element.tag.rsplit("}", 1)[-1] != "href":
continue
href_path = urlparse(element.text or "").path.rstrip("/")
if href_path == requested_path:
# Первая запись PROPFIND (Depth: 1) — сама папка дня, не ребёнок.
continue
name = unquote(href_path.rsplit("/", 1)[-1])
if name.isdigit():
numbers.append(int(name))
return max(numbers, default=0) + 1
def build_opencloud_folder_url(
space_id: str, folder_segments: tuple[str, ...] | list[str]
) -> str:
"""WebDAV-адрес папки /dav/spaces/{SPACE_ID}/{FOLDER}; сегменты кодируются
так же, как в build_opencloud_url."""
if not space_id:
raise OpenCloudUploadError("Не задан SPACE_ID для OpenCloud.")
encoded_folder = "/".join(
quote(segment, safe="") for segment in folder_segments if segment
)
path = f"/dav/spaces/{space_id}"
if encoded_folder:
path += f"/{encoded_folder}"
return f"https://{OPENCLOUD_DOMAIN}{path}"
def build_opencloud_web_folder_url(
drive_alias: str,
folder_segments: tuple[str, ...] | list[str],
file_id: str | None = None,
) -> str:
"""Ссылка на папку в веб-интерфейсе OpenCloud.
Формат взят из адресной строки браузера на нашем OpenCloud (29.09):
https://<домен>/files/spaces/personal/<логин>/Out/2026/9/29/1?fileId=...
Собирается без запросов к серверу, поэтому есть всегда. fileId папки
(если известен) добавляется так же, как его добавляет сам интерфейс.
"""
encoded_folder = "/".join(
quote(segment, safe="") for segment in folder_segments if segment
)
url = f"https://{OPENCLOUD_DOMAIN}/files/spaces/{quote(drive_alias, safe='/')}"
if encoded_folder:
url += f"/{encoded_folder}"
if file_id:
url += f"?fileId={_encode_file_id(file_id)}"
return url
def build_opencloud_private_link(file_id: str) -> str:
"""Ссылка вида https://<домен>/f/<fileid> на файл или папку.
Такую же ссылку OpenCloud отдаёт в oc:privatelink: она открывает ресурс в
веб-интерфейсе (с обычным входом через браузер), а не скачивает его.
"""
return f"https://{OPENCLOUD_DOMAIN}/f/{_encode_file_id(file_id)}"
def _encode_file_id(file_id: str) -> str:
# fileid в OpenCloud составной ("storageId$spaceId!opaqueId"); веб-интерфейс
# кодирует его целиком: "$" -> %24, "!" -> %21.
return quote(file_id, safe="")
def resolve_opencloud_web_link(dav_url: str, username: str, token: str) -> str | None:
"""Ссылка на ресурс в веб-интерфейсе OpenCloud через PROPFIND (Depth: 0).
privatelink, если он пришёл, возвращается как есть, иначе ссылка
собирается из fileid. Исключений не бросает (см. _propfind_link_properties):
при неудаче возвращает None, и вызывающий код подставляет запасную ссылку.
"""
properties = _propfind_link_properties(dav_url, username, token)
if properties is None:
return None
private_link, file_id = properties
if private_link:
return private_link
if file_id:
return build_opencloud_private_link(file_id)
logger.warning(
"PROPFIND не вернул ни oc:privatelink, ни oc:fileid: url='%s'", dav_url
)
return None
def resolve_opencloud_file_id(dav_url: str, username: str, token: str) -> str | None:
"""oc:fileid ресурса через PROPFIND (Depth: 0); None, если не удалось."""
properties = _propfind_link_properties(dav_url, username, token)
if properties is None:
return None
_, file_id = properties
if not file_id:
logger.warning("PROPFIND не вернул oc:fileid: url='%s'", dav_url)
return file_id
def _propfind_link_properties(
dav_url: str, username: str, token: str
) -> tuple[str | None, str | None] | None:
"""(oc:privatelink, oc:fileid) ресурса; None, если PROPFIND не удался.
Исключений не бросает: ссылки нужны только для удобства, и их отсутствие
не должно ронять обработку — любую проблему пишем в лог.
"""
try:
response = requests.request(
"PROPFIND",
dav_url,
auth=HTTPBasicAuth(username, token),
headers={"Depth": "0", "Content-Type": "application/xml; charset=utf-8"},
data=_PRIVATE_LINK_PROPFIND_BODY.encode("utf-8"),
timeout=OPENCLOUD_UPLOAD_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
logger.warning("PROPFIND за ссылкой не удался: url='%s', %s", dav_url, exc)
return None
if response.status_code != 207:
logger.warning(
"PROPFIND за ссылкой вернул HTTP %s: url='%s'",
response.status_code,
dav_url,
)
return None
try:
root = ET.fromstring(response.content)
except ET.ParseError as exc:
logger.warning(
"Не удалось разобрать ответ PROPFIND за ссылкой: url='%s', %s",
dav_url,
exc,
)
return None
# Отсутствующие свойства сервер возвращает пустыми элементами в propstat
# со статусом 404 — пустой текст означает «значения нет».
return _find_oc_property(root, "privatelink"), _find_oc_property(root, "fileid")
def _find_oc_property(root: ET.Element, name: str) -> str | None:
for element in root.iter(f"{{{_OC_NAMESPACE}}}{name}"):
text = (element.text or "").strip()
if text:
return text
return None
def _file_id_from_headers(headers: object) -> str | None:
"""Достаёт OC-FileId из заголовков ответа без учёта регистра имени.
requests отдаёт CaseInsensitiveDict, но на это не полагаемся: сервер или
прокси перед ним может прислать имя в любом регистре ("Oc-Fileid").
"""
if not isinstance(headers, Mapping):
return None
for name, value in headers.items():
if str(name).lower() == "oc-fileid" and value and str(value).strip():
return str(value).strip()
return None
def build_opencloud_url(
space_id: str, folder_segments: tuple[str, ...] | list[str], file_name: str
) -> str:
"""Собирает URL вида /dav/spaces/{SPACE_ID}/{FOLDER}/{FILENAME}.
Каждый сегмент папки и имя файла кодируются ОТДЕЛЬНО через urllib.parse.quote —
requests не кодирует кириллицу/пробелы в уже собранном URL так, как нужно
серверу (проверено вручную, см. open_cloud_upload_fixed.py).
space_id передаётся как есть — по факту наблюдений это составной идентификатор
"storageId$spaceId" (обе части через "$"), но эта функция его не разбирает
и не проверяет формат, чтобы не завязываться на недокументированное поведение.
"""
if not space_id:
raise OpenCloudUploadError("Не задан SPACE_ID для OpenCloud.")
if not file_name:
raise OpenCloudUploadError("Не задано имя файла для загрузки в OpenCloud.")
folder_url = build_opencloud_folder_url(space_id, folder_segments)
return f"{folder_url}/{quote(file_name, safe='')}"
def ensure_opencloud_folder(
space_id: str,
folder_segments: tuple[str, ...] | list[str],
username: str,
token: str,
) -> None:
"""Создаёт папку folder_segments в личном пространстве через MKCOL.
WebDAV PUT возвращает 409 Conflict, если хотя бы одна промежуточная
коллекция ещё не существует, поэтому перед первой загрузкой в новый
путь (Out/год/месяц/день/номер_запуска) создаём его сегмент за
сегментом.
"""
segments = [segment for segment in folder_segments if segment]
accumulated: list[str] = []
for segment in segments:
accumulated.append(segment)
encoded_path = "/".join(quote(part, safe="") for part in accumulated)
url = f"https://{OPENCLOUD_DOMAIN}/dav/spaces/{space_id}/{encoded_path}"
try:
response = requests.request(
"MKCOL",
url,
auth=HTTPBasicAuth(username, token),
timeout=OPENCLOUD_UPLOAD_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise OpenCloudUploadError(
f"Ошибка сети при создании папки в OpenCloud: url='{url}', {exc}"
) from exc
# 201 — папка создана, 405 — уже существует (MKCOL на существующую
# коллекцию запрещён спецификацией WebDAV, это не ошибка для нас).
if response.status_code not in (201, 405):
raise OpenCloudUploadError(
"Не удалось создать папку в OpenCloud: "
f"HTTP {response.status_code}, url='{url}', "
f"body={response.text[:500]!r}"
)
def upload_local_file_to_opencloud(
local_path: Path,
space_id: str,
folder_segments: tuple[str, ...] | list[str],
file_name: str,
username: str,
token: str,
) -> OpenCloudUploadResult:
"""Загружает локальный файл в OpenCloud через WebDAV PUT (просто attach).
Возвращает WebDAV-адрес, по которому файл загружен (для протокола), и
ссылку на файл в веб-интерфейсе OpenCloud (для пользователя). Ссылка
собирается из заголовка OC-FileId ответа на PUT, а если его нет — из
одного PROPFIND (resolve_opencloud_web_link). Если ссылку получить не
удалось, загрузка не падает: web_url будет None.
АВТОРИЗАЦИЯ (подтверждено на проде 16.09, см. debug_access_token.py):
используется НЕ Bearer/ID-токен Keycloak, а HTTP Basic Auth — username +
долгоживущий App Token, который заказчик сам создаёт в веб-интерфейсе
OpenCloud. Bearer-токен от клиента datalab получал 401 от Graph API
OpenCloud (несовпадение audience) — App Token через Basic Auth работает,
т.к. идёт отдельным от OIDC путём авторизации.
Промежуточные папки (folder_segments) должны быть уже созданы через
ensure_opencloud_folder — сам PUT их не создаёт и вернёт 409, если
хотя бы одна коллекция на пути отсутствует.
"""
if not username:
raise OpenCloudUploadError(
"Не задан username для авторизации в OpenCloud (Basic Auth)."
)
if not token:
raise OpenCloudUploadError(
"Отсутствует App Token для загрузки в OpenCloud. "
"Проверьте, что для этого пользователя сохранён токен "
"(app.opencloud_token_store)."
)
if not local_path.exists():
raise OpenCloudUploadError(f"Локальный файл не найден: {local_path}")
url = build_opencloud_url(space_id, folder_segments, file_name)
content_type = _CONTENT_TYPES.get(local_path.suffix.lower(), _DEFAULT_CONTENT_TYPE)
headers = {"Content-Type": content_type}
try:
with local_path.open("rb") as file_data:
response = requests.put(
url,
data=file_data,
headers=headers,
auth=HTTPBasicAuth(username, token),
timeout=OPENCLOUD_UPLOAD_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise OpenCloudUploadError(
f"Ошибка сети при загрузке в OpenCloud: url='{url}', {exc}"
) from exc
if response.status_code not in (200, 201, 204):
raise OpenCloudUploadError(
"Загрузка в OpenCloud не удалась: "
f"HTTP {response.status_code}, url='{url}', "
f"body={response.text[:500]!r}"
)
file_id = _file_id_from_headers(getattr(response, "headers", None))
if file_id:
return OpenCloudUploadResult(
dav_url=url, web_url=build_opencloud_private_link(file_id)
)
logger.info(
"В ответе на PUT нет заголовка OC-FileId, запрашиваю ссылку через "
"PROPFIND: url='%s'",
url,
)
return OpenCloudUploadResult(
dav_url=url, web_url=resolve_opencloud_web_link(url, username, token)
)