Files

1178 lines
41 KiB
Python

import asyncio
import hashlib
import hmac
import json
import os
import re
import unicodedata
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import PurePath
from typing import Any
from sqlalchemy import case, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Document, UserIdentity
from app.integrations import S3Client, SafetyClient
from app.notification_models import (
ClientDocument,
ClientUploadDraft,
GuestNotification,
Notification,
NotificationButton,
NotificationCtaAction,
NotificationDocument,
NotificationSource,
NotificationType,
uuid7,
)
from app.notification_schemas import (
NotificationCancelRequest,
NotificationCreateRequest,
UploadCompleteRequest,
UploadInitRequest,
)
from app.realtime import RealtimeFanout
from app.services import AuditContext, DomainError, SettingsSnapshot, audit
def fingerprint(body: NotificationCreateRequest) -> str:
canonical = json.dumps(
body.model_dump(mode="json"),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
)
return hashlib.sha256(canonical.encode()).hexdigest()
def source_token_hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def synchronize_source_tokens(session: AsyncSession) -> None:
token = os.getenv("NOTIFICATIONS_TOKEN_PRODUCER_TEST")
if not token:
return
source = await session.scalar(
select(NotificationSource).where(NotificationSource.code == "producer_test")
)
digest = source_token_hash(token)
if source and not hmac.compare_digest(source.token_hash, digest):
source.token_hash = digest
source.token_rotated_at = datetime.now(UTC)
await session.commit()
async def authenticate_source(
session: AsyncSession, authorization: str | None
) -> NotificationSource:
if not authorization or not authorization.startswith("Bearer "):
raise DomainError("unauthorized", 401, "Authentication failed")
token = authorization.removeprefix("Bearer ").strip()
if not token:
raise DomainError("unauthorized", 401, "Authentication failed")
supplied = source_token_hash(token)
sources = (
(
await session.execute(
select(NotificationSource).where(NotificationSource.record_status == "A")
)
)
.scalars()
.all()
)
matched: NotificationSource | None = None
for source in sources:
if hmac.compare_digest(source.token_hash, supplied):
matched = source
if matched is None:
raise DomainError("unauthorized", 401, "Authentication failed")
return matched
async def _type(session: AsyncSession, code: str, contour: str | None = None) -> NotificationType:
query = select(NotificationType).where(
NotificationType.code == code, NotificationType.record_status == "A"
)
if contour:
query = query.where(NotificationType.contour == contour)
item = await session.scalar(query)
if item is None:
raise DomainError(
"validation_error",
400,
"Notification type is unavailable",
{"fields": ["notification_type"]},
)
return item
def _details_dict(body: NotificationCreateRequest) -> dict[str, Any] | None:
return (
body.details.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
if body.details
else None
)
def validate_create(
body: NotificationCreateRequest,
kind: NotificationType,
action: NotificationCtaAction,
) -> None:
values: dict[str, Any] = {
"details": body.details,
"payment_url": body.payment_url,
"chat_message_text": body.chat_message_text,
}
required_fields = set(action.required_instance_fields)
errors: list[str] = []
for field in values:
if field in required_fields and values[field] is None:
errors.append(field)
elif field not in required_fields and values[field] is not None:
errors.append(field)
details = _details_dict(body)
if "details" in required_fields and not details:
errors.append("details")
if details is not None:
for block in kind.required_detail_blocks:
if not details.get(block):
errors.append(f"details.{block}")
documents = details.get("documents") or []
if documents and not kind.documents_allowed:
errors.append("details.documents")
if details.get("send_documents") and "send_docs" not in {
kind.button_primary_code,
kind.button_secondary_code,
}:
errors.append("details.send_documents")
if errors:
raise DomainError(
"validation_error",
400,
"Notification fields do not match catalog rules",
{"fields": sorted(set(errors))},
)
async def create_notification(
session: AsyncSession,
body: NotificationCreateRequest,
source: NotificationSource,
s3: S3Client,
fanout: RealtimeFanout,
context: AuditContext,
) -> tuple[dict[str, Any], int]:
if body.source != source.code:
raise DomainError("forbidden", 403, "Source does not match service token")
digest = fingerprint(body)
existing = await session.scalar(
select(Notification).where(
Notification.source == source.code,
Notification.external_id == body.external_id,
)
)
if existing:
if hmac.compare_digest(existing.request_fingerprint, digest):
return await notification_dto(session, existing), 200
session.add(
audit(
"notification.create_conflict",
context,
None,
"notification",
existing.id,
metadata={"source": source.code},
outcome="failed",
)
)
await session.commit()
raise DomainError(
"notification_conflict",
409,
"External id already belongs to another request",
{"notification_id": str(existing.id)},
)
kind = await _type(session, body.notification_type, "P")
action = await session.scalar(
select(NotificationCtaAction).where(
NotificationCtaAction.code == kind.cta_action,
NotificationCtaAction.record_status == "A",
)
)
if action is None:
raise DomainError("validation_error", 400, "Notification CTA is unavailable")
validate_create(body, kind, action)
if (
await session.scalar(
select(UserIdentity.id).where(
UserIdentity.id == body.user_id, UserIdentity.record_status == "A"
)
)
is None
):
raise DomainError("validation_error", 400, "User is unavailable", {"fields": ["user_id"]})
details = _details_dict(body)
document_inputs = list(details.pop("documents", [])) if details else []
item = Notification(
id=uuid7(),
user_id=body.user_id,
notification_type=body.notification_type,
source=body.source,
external_id=body.external_id,
request_fingerprint=digest,
notification_datetime=body.notification_datetime,
header=body.header,
text=body.text,
priority_override=body.priority_override,
date_expired=body.date_expired,
price=body.price,
old_price=body.old_price,
payment_url=str(body.payment_url) if body.payment_url else None,
details=details,
chat_message_text=body.chat_message_text,
)
session.add(item)
for order, document_input in enumerate(document_inputs):
bucket = s3.settings.selectel_s3_bucket_documents
try:
metadata = await s3.head(bucket, document_input["object_key"])
except Exception as exc:
raise DomainError(
"validation_error",
400,
"Company document object is unavailable",
{"fields": [f"details.documents.{order}.object_key"]},
) from exc
if (
int(metadata["ContentLength"]) != document_input["size_bytes"]
or metadata.get("ContentType") != document_input["mime_type"]
):
raise DomainError(
"validation_error",
400,
"Company document metadata differs",
{"fields": [f"details.documents.{order}"]},
)
document = await session.scalar(
select(Document).where(
Document.storage_bucket == bucket,
Document.object_key == document_input["object_key"],
)
)
if document is None:
document = Document(
id=uuid7(),
user_id=body.user_id,
name=document_input["title"],
mime_type=document_input["mime_type"],
size_bytes=document_input["size_bytes"],
checksum_sha256=document_input["checksum_sha256"],
storage_bucket=bucket,
object_key=document_input["object_key"],
sent_at=datetime.now(UTC),
)
session.add(document)
elif document.user_id != body.user_id:
raise DomainError("validation_error", 400, "Company document belongs to another user")
session.add(
NotificationDocument(
id=uuid7(),
notification_id=item.id,
document_id=document.id,
sort_order=order,
)
)
session.add(
audit(
"notification.created",
context,
None,
"notification",
item.id,
metadata={"source": source.code, "notification_type": item.notification_type},
)
)
try:
await session.commit()
except IntegrityError:
await session.rollback()
raced = await session.scalar(
select(Notification).where(
Notification.source == source.code,
Notification.external_id == body.external_id,
)
)
if raced and hmac.compare_digest(raced.request_fingerprint, digest):
return await notification_dto(session, raced), 200
raise DomainError("notification_conflict", 409, "External id is already used") from None
result = await notification_dto(session, item)
unread = await unread_count(session, item.user_id, 15)
await fanout.publish_user(
item.user_id,
{
"type": "notification.created",
"occurred_at": datetime.now(UTC).isoformat(),
"notification": result,
"unread_count": unread,
},
)
return result, 201
async def cancel_notification(
session: AsyncSession,
body: NotificationCancelRequest,
source: NotificationSource,
fanout: RealtimeFanout,
context: AuditContext,
) -> dict[str, Any]:
if body.source != source.code:
raise DomainError("forbidden", 403, "Source does not match service token")
item = await session.scalar(
select(Notification)
.where(
Notification.source == source.code,
Notification.external_id == body.external_id,
)
.with_for_update()
)
if item is None:
raise DomainError("not_found", 404, "Resource was not found")
changed = item.lifecycle_status != "closed"
if changed:
item.lifecycle_status = "closed"
item.close_reason = body.close_reason
item.closed_at = datetime.now(UTC)
session.add(
audit(
"notification.cancelled",
context,
None,
"notification",
item.id,
metadata={"source": source.code, "close_reason": body.close_reason},
)
)
await session.commit()
result = await notification_dto(session, item)
if changed:
await fanout.publish_user(
item.user_id,
{
"type": "notification.closed",
"occurred_at": datetime.now(UTC).isoformat(),
"notification_id": str(item.id),
"close_reason": item.close_reason,
"unread_count": await unread_count(session, item.user_id, 15),
},
)
return result
async def catalog(session: AsyncSession) -> list[dict[str, Any]]:
rows = (
(
await session.execute(
select(NotificationType).where(NotificationType.record_status == "A")
)
)
.scalars()
.all()
)
button_codes = {
code
for item in rows
for code in (item.button_primary_code, item.button_secondary_code)
if code
}
buttons = {
item.code: item
for item in (
(
await session.execute(
select(NotificationButton).where(
NotificationButton.code.in_(button_codes),
NotificationButton.record_status == "A",
)
)
)
.scalars()
.all()
if button_codes
else []
)
}
def button(code: str | None) -> dict[str, str] | None:
item = buttons.get(code or "")
return {"code": item.code, "label": item.label} if item else None
return [
{
"code": item.code,
"label": item.label,
"color_token": item.color_token,
"icon_code": item.icon_code,
"cta_text": item.cta_text,
"cta_action": item.cta_action,
"countable": item.countable,
"contour": item.contour,
"button_primary": button(item.button_primary_code),
"button_secondary": button(item.button_secondary_code),
}
for item in sorted(rows, key=lambda value: (value.contour, value.priority, value.code))
]
async def public_notifications(session: AsyncSession, limit: int) -> list[dict[str, Any]]:
now = datetime.now(UTC)
rows = (
await session.execute(
select(GuestNotification, NotificationType)
.join(
NotificationType,
NotificationType.code == GuestNotification.notification_type,
)
.where(
GuestNotification.record_status == "A",
GuestNotification.lifecycle_status == "active",
(GuestNotification.date_expired.is_(None)) | (GuestNotification.date_expired > now),
NotificationType.record_status == "A",
NotificationType.contour == "G",
)
.order_by(
func.coalesce(GuestNotification.priority_override, NotificationType.priority),
GuestNotification.notification_datetime.desc(),
GuestNotification.id.desc(),
)
.limit(limit)
)
).all()
return [
{
"id": item.id,
"notification_type": item.notification_type,
"notification_datetime": item.notification_datetime,
"header": item.header,
"text": item.text,
"price": item.price,
"old_price": item.old_price,
"instruction_url": item.instruction_url,
"instruction_open_mode": "new_tab" if item.instruction_url else None,
"chat_message_text": item.chat_message_text,
}
for item, _kind in rows
]
def _active_notification_query(user_id: uuid.UUID):
now = datetime.now(UTC)
return (
select(Notification, NotificationType)
.join(NotificationType, NotificationType.code == Notification.notification_type)
.where(
Notification.user_id == user_id,
Notification.record_status == "A",
Notification.lifecycle_status == "active",
(Notification.date_expired.is_(None)) | (Notification.date_expired > now),
NotificationType.contour == "P",
)
)
async def list_notifications(
session: AsyncSession, user_id: uuid.UUID, place: str, limit: int
) -> list[dict[str, Any]]:
query = _active_notification_query(user_id)
priority = func.coalesce(Notification.priority_override, NotificationType.priority)
if place == "home":
query = query.where(Notification.visibility == "visible").order_by(
priority, Notification.notification_datetime.desc(), Notification.id.desc()
)
else:
query = query.order_by(
priority,
case((Notification.is_read.is_(False), 0), else_=1),
Notification.notification_datetime.desc(),
Notification.id.desc(),
)
rows = (await session.execute(query.limit(limit))).all()
return [await notification_dto(session, item, kind) for item, kind in rows]
async def unread_count(session: AsyncSession, user_id: uuid.UUID, limit: int) -> int:
rows = await list_notifications(session, user_id, "center", limit)
return sum(1 for item in rows if item["countable"] and not item["is_read"])
async def owned_notification(
session: AsyncSession,
user_id: uuid.UUID,
notification_id: uuid.UUID,
*,
action: bool = False,
) -> tuple[Notification, NotificationType]:
statement = (
select(Notification, NotificationType)
.join(NotificationType, NotificationType.code == Notification.notification_type)
.where(
Notification.id == notification_id,
Notification.user_id == user_id,
Notification.record_status == "A",
)
)
if action:
statement = statement.with_for_update()
row = (await session.execute(statement)).one_or_none()
if row is None:
raise DomainError("not_found", 404, "Resource was not found")
item, kind = row
if item.lifecycle_status == "closed" or (
item.date_expired is not None and item.date_expired <= datetime.now(UTC)
):
if action:
raise DomainError("notification_closed", 409, "Notification is closed")
raise DomainError("not_found", 404, "Resource was not found")
return item, kind
async def notification_dto(
session: AsyncSession, item: Notification, kind: NotificationType | None = None
) -> dict[str, Any]:
kind = kind or await _type(session, item.notification_type)
details = dict(item.details or {})
links = (
await session.execute(
select(NotificationDocument, Document)
.join(Document, Document.id == NotificationDocument.document_id)
.where(
NotificationDocument.notification_id == item.id,
NotificationDocument.record_status == "A",
Document.record_status == "A",
)
.order_by(NotificationDocument.sort_order, NotificationDocument.id)
)
).all()
if links:
details["documents"] = [
{
"document_id": document.id,
"title": document.name,
"mime_type": document.mime_type,
"size_bytes": document.size_bytes,
}
for _link, document in links
]
if details.get("send_documents"):
drafts = (
(
await session.execute(
select(ClientUploadDraft).where(
ClientUploadDraft.user_id == item.user_id,
ClientUploadDraft.context_type == "notification",
ClientUploadDraft.context_id == item.id,
ClientUploadDraft.state == "draft",
)
)
)
.scalars()
.all()
)
details["pending_documents"] = [upload_dto(draft) for draft in drafts]
return {
"id": item.id,
"notification_type": item.notification_type,
"notification_datetime": item.notification_datetime,
"header": item.header,
"text": item.text,
"priority": item.priority_override if item.priority_override is not None else kind.priority,
"date_expired": item.date_expired,
"price": item.price,
"old_price": item.old_price,
"details": details or None,
"lifecycle_status": item.lifecycle_status,
"visibility": item.visibility,
"is_read": item.is_read,
"close_reason": item.close_reason,
"countable": kind.countable,
"cta_action": kind.cta_action,
}
def _apply_hidden_ttl(
item: Notification, kind: NotificationType, snapshot: SettingsSnapshot
) -> None:
item.visibility = "hidden"
if item.date_expired is None:
days = kind.hidden_ttl_days or snapshot.integer("notification.hidden.default_ttl_days")
item.date_expired = datetime.now(UTC) + timedelta(days=days)
async def state_response(
session: AsyncSession, item: Notification, center_limit: int, result: Any = None
) -> dict[str, Any]:
return {
"notification_id": item.id,
"lifecycle_status": item.lifecycle_status,
"visibility": item.visibility,
"is_read": item.is_read,
"close_reason": item.close_reason,
"date_expired": item.date_expired,
"unread_count": await unread_count(session, item.user_id, center_limit),
"result": result,
}
async def apply_read_or_hide(
session: AsyncSession,
user_id: uuid.UUID,
notification_id: uuid.UUID,
operation: str,
snapshot: SettingsSnapshot,
fanout: RealtimeFanout,
context: AuditContext,
) -> dict[str, Any]:
item, kind = await owned_notification(session, user_id, notification_id, action=True)
center_limit = snapshot.integer("notification.center.max_items")
changed: dict[str, Any] = {}
if operation == "read" and not item.is_read:
item.is_read = True
changed["is_read"] = True
if operation == "hide" and item.visibility != "hidden":
_apply_hidden_ttl(item, kind, snapshot)
changed["visibility"] = "hidden"
changed["date_expired"] = item.date_expired
if changed:
session.add(
audit(
f"notification.{operation}",
context,
user_id,
"notification",
item.id,
)
)
await session.commit()
changed["unread_count"] = await unread_count(session, user_id, center_limit)
await fanout.publish_user(
user_id,
{
"type": "notification.updated",
"occurred_at": datetime.now(UTC).isoformat(),
"notification_id": str(item.id),
**changed,
},
)
return await state_response(session, item, center_limit)
async def press_button(
session: AsyncSession,
user_id: uuid.UUID,
notification_id: uuid.UUID,
code: str,
snapshot: SettingsSnapshot,
fanout: RealtimeFanout,
context: AuditContext,
) -> dict[str, Any]:
item, kind = await owned_notification(session, user_id, notification_id, action=True)
if code not in {kind.button_primary_code, kind.button_secondary_code}:
raise DomainError("button_not_allowed", 422, "Button is not allowed")
button = await session.scalar(
select(NotificationButton).where(
NotificationButton.code == code, NotificationButton.record_status == "A"
)
)
if button is None:
raise DomainError("button_not_allowed", 422, "Button is not allowed")
submission_id: uuid.UUID | None = None
submitted = 0
if button.submits_documents:
drafts = (
(
await session.execute(
select(ClientUploadDraft)
.where(
ClientUploadDraft.user_id == user_id,
ClientUploadDraft.context_type == "notification",
ClientUploadDraft.context_id == item.id,
ClientUploadDraft.state == "draft",
ClientUploadDraft.scan_status == "clean",
)
.with_for_update()
)
)
.scalars()
.all()
)
if not drafts:
raise DomainError("validation_error", 400, "At least one clean document is required")
submission_id = uuid7()
for draft in drafts:
session.add(
ClientDocument(
id=uuid7(),
user_id=user_id,
context_type=draft.context_type,
context_id=draft.context_id,
submission_id=submission_id,
source_draft_id=draft.id,
original_file_name=draft.original_file_name,
safe_file_name=draft.safe_file_name,
mime_type=draft.mime_type,
size_bytes=draft.size_bytes,
checksum_sha256=draft.checksum_sha256 or "",
storage_bucket=draft.storage_bucket,
object_key=draft.object_key,
submitted_at=datetime.now(UTC),
)
)
draft.state = "submitted"
draft.submission_id = submission_id
submitted = len(drafts)
if button.sets_hidden:
if button.applies_hidden_ttl:
_apply_hidden_ttl(item, kind, snapshot)
else:
item.visibility = "hidden"
if button.close_reason:
item.lifecycle_status = "closed"
item.close_reason = button.close_reason
item.closed_at = datetime.now(UTC)
session.add(
audit(
"notification.button_pressed",
context,
user_id,
"notification",
item.id,
metadata={
"button_code": code,
"submission_id": str(submission_id) if submission_id else None,
"submitted_count": submitted,
},
)
)
await session.commit()
unread = await unread_count(session, user_id, snapshot.integer("notification.center.max_items"))
event_type = (
"notification.closed" if item.lifecycle_status == "closed" else "notification.updated"
)
await fanout.publish_user(
user_id,
{
"type": event_type,
"occurred_at": datetime.now(UTC).isoformat(),
"notification_id": str(item.id),
"close_reason": item.close_reason,
"visibility": item.visibility,
"date_expired": item.date_expired,
"unread_count": unread,
},
)
return await state_response(session, item, snapshot.integer("notification.center.max_items"))
async def invoke_cta_state(
session: AsyncSession,
user_id: uuid.UUID,
notification_id: uuid.UUID,
snapshot: SettingsSnapshot,
fanout: RealtimeFanout,
context: AuditContext,
*,
chat_result: Any = None,
) -> tuple[Notification, NotificationType, dict[str, Any]]:
item, kind = await owned_notification(session, user_id, notification_id, action=True)
item.is_read = True
result: dict[str, Any]
if kind.cta_action == "open_detail":
result = {"action": "open_detail", "notification_id": str(item.id)}
elif kind.cta_action == "open_payment_url":
result = {"action": "open_url", "url": item.payment_url}
elif kind.cta_action == "send_chat_message":
if chat_result is None:
result = {"action": "send_chat_message", "text": item.chat_message_text}
else:
result = {"action": "chat_message_sent", "message": chat_result}
else:
raise DomainError("validation_error", 400, "CTA is unavailable for personal contour")
if kind.cta_sets_hidden:
item.visibility = "hidden"
if kind.cta_close_reason:
item.lifecycle_status = "closed"
item.close_reason = kind.cta_close_reason
item.closed_at = datetime.now(UTC)
session.add(
audit(
"notification.cta_invoked",
context,
user_id,
"notification",
item.id,
metadata={"cta_action": kind.cta_action},
)
)
await session.commit()
unread = await unread_count(session, user_id, snapshot.integer("notification.center.max_items"))
await fanout.publish_user(
user_id,
{
"type": (
"notification.closed"
if item.lifecycle_status == "closed"
else "notification.updated"
),
"occurred_at": datetime.now(UTC).isoformat(),
"notification_id": str(item.id),
"is_read": True,
"visibility": item.visibility,
"close_reason": item.close_reason,
"unread_count": unread,
},
)
return (
item,
kind,
await state_response(
session,
item,
snapshot.integer("notification.center.max_items"),
result,
),
)
async def document_download(
session: AsyncSession,
user_id: uuid.UUID,
notification_id: uuid.UUID,
document_id: uuid.UUID,
snapshot: SettingsSnapshot,
s3: S3Client,
fanout: RealtimeFanout,
context: AuditContext,
) -> dict[str, Any]:
item, kind = await owned_notification(session, user_id, notification_id, action=True)
row = (
await session.execute(
select(NotificationDocument, Document)
.join(Document, Document.id == NotificationDocument.document_id)
.where(
NotificationDocument.notification_id == item.id,
NotificationDocument.document_id == document_id,
NotificationDocument.record_status == "A",
Document.user_id == user_id,
Document.record_status == "A",
)
.with_for_update()
)
).one_or_none()
if row is None:
raise DomainError("not_found", 404, "Resource was not found")
link, document = row
first_download = (
await session.scalar(
select(func.count(NotificationDocument.id)).where(
NotificationDocument.notification_id == item.id,
NotificationDocument.download_url_issued_at.is_not(None),
)
)
== 0
)
link.download_url_issued_at = link.download_url_issued_at or datetime.now(UTC)
changed = False
if first_download and kind.hide_on_document_download:
item.is_read = True
_apply_hidden_ttl(item, kind, snapshot)
changed = True
session.add(
audit(
"notification.document.download_url_issued",
context,
user_id,
"document",
document.id,
metadata={"notification_id": str(item.id), "expires_in_seconds": 300},
)
)
await session.commit()
if changed:
await fanout.publish_user(
user_id,
{
"type": "notification.updated",
"occurred_at": datetime.now(UTC).isoformat(),
"notification_id": str(item.id),
"is_read": True,
"visibility": item.visibility,
"date_expired": item.date_expired,
"unread_count": await unread_count(
session, user_id, snapshot.integer("notification.center.max_items")
),
},
)
return {
"download_url": await s3.presign_get(document.storage_bucket, document.object_key),
"expires_at": datetime.now(UTC) + timedelta(seconds=300),
}
async def init_upload(
session: AsyncSession,
user_id: uuid.UUID,
body: UploadInitRequest,
snapshot: SettingsSnapshot,
s3: S3Client,
) -> dict[str, Any]:
item, _kind = await owned_notification(session, user_id, body.context_id)
if not (item.details or {}).get("send_documents"):
raise DomainError("validation_error", 400, "Notification does not accept documents")
current = await session.scalar(
select(func.count(ClientUploadDraft.id)).where(
ClientUploadDraft.user_id == user_id,
ClientUploadDraft.context_type == body.context_type,
ClientUploadDraft.context_id == body.context_id,
ClientUploadDraft.state == "draft",
)
)
if int(current or 0) >= snapshot.integer("notification.documents.max_files"):
raise DomainError("attachment_invalid", 400, "Document limit reached")
extension = PurePath(body.file_name).suffix.lower().lstrip(".")
if (
extension not in snapshot.strings("chat.attachments.allowed_extensions")
or extension in snapshot.strings("chat.attachments.disallowed_extensions")
or body.mime_type not in snapshot.strings("chat.attachments.allowed_mime_types")
or body.size_bytes > snapshot.integer("chat.attachments.max_size_mb") * 1024 * 1024
):
raise DomainError("attachment_invalid", 400, "File type or size is not allowed")
draft_id = uuid7()
key = f"quarantine/users/{user_id}/uploads/{draft_id}"
ttl = snapshot.integer("chat.attachments.presigned_upload_ttl_seconds")
expires = datetime.now(UTC) + timedelta(seconds=ttl)
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", unicodedata.normalize("NFKC", body.file_name))
draft = ClientUploadDraft(
id=draft_id,
user_id=user_id,
context_type=body.context_type,
context_id=body.context_id,
original_file_name=body.file_name,
safe_file_name=safe_name,
mime_type=body.mime_type,
size_bytes=body.size_bytes,
storage_bucket=s3.settings.selectel_s3_bucket_quarantine,
object_key=key,
quarantine_object_key=key,
upload_expires_at=expires,
)
session.add(draft)
await session.commit()
return {
"draft_id": draft.id,
"upload_url": await s3.presign_put(key, body.mime_type, ttl),
"upload_headers": {"Content-Type": body.mime_type},
"expires_at": expires,
}
async def complete_upload(
session: AsyncSession,
user_id: uuid.UUID,
draft_id: uuid.UUID,
body: UploadCompleteRequest,
s3: S3Client,
safety: SafetyClient,
request_id: str,
) -> dict[str, Any]:
draft = await _owned_draft(session, user_id, draft_id)
checksum = body.checksum.removeprefix("sha256:")
if draft.completed_at:
if draft.checksum_sha256 != checksum:
raise DomainError("resource_state_conflict", 409, "Checksum changed")
return upload_dto(draft)
try:
metadata = await s3.head(draft.storage_bucket, draft.object_key)
except Exception as exc:
raise DomainError("dependency_unavailable", 503, "Object storage unavailable") from exc
if (
int(metadata["ContentLength"]) != draft.size_bytes
or metadata.get("ContentType") != draft.mime_type
):
raise DomainError("attachment_invalid", 400, "Uploaded metadata differs")
version_id, etag = metadata.get("VersionId"), metadata.get("ETag")
if not version_id or not etag:
raise DomainError(
"dependency_unavailable", 503, "Versioned object metadata is unavailable"
)
draft.checksum_sha256 = checksum
draft.quarantine_version_id = str(version_id)
draft.quarantine_etag = str(etag)
verdict = await safety.check(
{
"message_id": str(draft.id),
"content_kind": "file",
"text": "",
"attachment": {
"attachment_id": str(draft.id),
"quarantine_object_key": draft.quarantine_object_key,
"quarantine_version_id": draft.quarantine_version_id,
"quarantine_etag": draft.quarantine_etag,
"checksum": body.checksum,
"mime_type": draft.mime_type,
"size_bytes": draft.size_bytes,
},
},
request_id,
)
if verdict["_status"] == 202:
deadline = datetime.now(UTC) + timedelta(
seconds=safety.settings.message_safety_task_poll_max_sec
)
while verdict["_status"] == 202 and datetime.now(UTC) < deadline:
await asyncio.sleep(safety.settings.message_safety_task_poll_interval_sec)
verdict = await safety.poll(verdict["_location"], request_id)
draft.safety_processing_mode = verdict.get("processing_mode")
draft.safety_config_version = verdict.get("config_version")
draft.safety_rules_version = verdict.get("rules_version")
if verdict["_status"] == 200:
destination = (
f"attachments/users/{user_id}/{draft.context_type}/{draft.context_id}/{draft.id}"
)
await s3.promote(
draft.quarantine_object_key or draft.object_key,
destination,
version_id=draft.quarantine_version_id or "",
etag=draft.quarantine_etag or "",
)
draft.storage_bucket = s3.settings.selectel_s3_bucket_attachments
draft.object_key = destination
draft.quarantine_object_key = None
draft.scan_status = (
"bypassed" if verdict["processing_mode"] == "mock" else "clean"
)
elif verdict["_status"] == 403:
if draft.quarantine_object_key:
await s3.delete_quarantine(draft.quarantine_object_key)
draft.scan_status = "infected"
else:
draft.scan_status = "failed"
draft.completed_at = datetime.now(UTC)
await session.commit()
return upload_dto(draft)
async def _owned_draft(
session: AsyncSession, user_id: uuid.UUID, draft_id: uuid.UUID
) -> ClientUploadDraft:
draft = await session.scalar(
select(ClientUploadDraft).where(
ClientUploadDraft.id == draft_id,
ClientUploadDraft.user_id == user_id,
)
)
if draft is None:
raise DomainError("not_found", 404, "Resource was not found")
return draft
def upload_dto(draft: ClientUploadDraft) -> dict[str, Any]:
return {
"draft_id": draft.id,
"context_type": draft.context_type,
"context_id": draft.context_id,
"title": draft.safe_file_name,
"mime_type": draft.mime_type,
"size_bytes": draft.size_bytes,
"scan_status": draft.scan_status,
"state": draft.state,
}
async def list_uploads(
session: AsyncSession, user_id: uuid.UUID, context_type: str, context_id: uuid.UUID
) -> list[dict[str, Any]]:
await owned_notification(session, user_id, context_id)
drafts = (
(
await session.execute(
select(ClientUploadDraft)
.where(
ClientUploadDraft.user_id == user_id,
ClientUploadDraft.context_type == context_type,
ClientUploadDraft.context_id == context_id,
ClientUploadDraft.state == "draft",
)
.order_by(ClientUploadDraft.created_at, ClientUploadDraft.id)
)
)
.scalars()
.all()
)
return [upload_dto(item) for item in drafts]
async def discard_upload(
session: AsyncSession, user_id: uuid.UUID, draft_id: uuid.UUID, s3: S3Client
) -> None:
draft = await _owned_draft(session, user_id, draft_id)
if draft.state == "submitted":
raise DomainError("resource_state_conflict", 409, "Document is already submitted")
if draft.state != "discarded":
if draft.quarantine_object_key:
await s3.delete_quarantine(draft.quarantine_object_key)
elif draft.storage_bucket and draft.object_key:
await s3.delete(draft.storage_bucket, draft.object_key)
draft.state = "discarded"
await session.commit()
async def expire_notifications(session: AsyncSession) -> tuple[int, int]:
now = datetime.now(UTC)
locked = await session.scalar(select(func.pg_try_advisory_xact_lock(0x48414E4E4F544946)))
if not locked:
return 0, 0
personal = await session.execute(
update(Notification)
.where(
Notification.record_status == "A",
Notification.lifecycle_status == "active",
Notification.date_expired <= now,
)
.values(lifecycle_status="closed", close_reason="expired", closed_at=now)
)
guest = await session.execute(
update(GuestNotification)
.where(
GuestNotification.record_status == "A",
GuestNotification.lifecycle_status == "active",
GuestNotification.date_expired <= now,
)
.values(lifecycle_status="closed", closed_at=now)
)
personal_count = int(getattr(personal, "rowcount", 0) or 0)
guest_count = int(getattr(guest, "rowcount", 0) or 0)
session.add(
audit(
"notification.expired_batch",
AuditContext(
request_id=f"notification-expire-{uuid7()}",
trace_id=str(uuid7()),
ux_session_id=None,
user_agent_hash=None,
client_ip=None,
),
None,
metadata={
"personal_count": personal_count,
"guest_count": guest_count,
},
)
)
await session.commit()
return personal_count, guest_count