from __future__ import annotations from sqlalchemy.orm import Session from govoplan_core.core.campaigns import ( CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider, ) from govoplan_core.core.modules import ( DocumentationCondition, DocumentationContext, DocumentationLink, DocumentationTopic, ) from govoplan_files.backend.storage.archives import ARCHIVE_UPLOAD_MAX_ENTRIES from govoplan_files.backend.storage.access import user_group_ids from govoplan_files.backend.storage.connector_visibility import ( connector_profile_usable_for_import, visible_connector_profiles_for_actor, ) _DEFAULT_UPLOAD_MAX_BYTES = 50 * 1024 * 1024 _DEFAULT_ZIP_MAX_BYTES = 250 * 1024 * 1024 _DEFAULT_ARCHIVE_MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 _DEFAULT_ARCHIVE_MAX_EXPANSION_RATIO = 100 _DEFAULT_ARCHIVE_PREVIEW_TTL_SECONDS = 30 * 60 _FILES_READ_SCOPE = "files:file:read" _FILES_UPLOAD_SCOPE = "files:file:upload" def documentation_topics( context: DocumentationContext, ) -> tuple[DocumentationTopic, ...]: if context.documentation_type != "user": return () upload_limit = _configured_positive_int( context.settings, "file_upload_max_bytes", default=_DEFAULT_UPLOAD_MAX_BYTES, ) zip_limit = _configured_positive_int( context.settings, "file_upload_zip_max_bytes", default=_DEFAULT_ZIP_MAX_BYTES, ) archive_expanded_limit = _configured_positive_int( context.settings, "file_archive_max_expanded_bytes", default=_DEFAULT_ARCHIVE_MAX_EXPANDED_BYTES, ) archive_entry_limit = _configured_positive_int( context.settings, "file_archive_max_entries", default=ARCHIVE_UPLOAD_MAX_ENTRIES, ) archive_ratio_limit = _configured_positive_int( context.settings, "file_archive_max_expansion_ratio", default=_DEFAULT_ARCHIVE_MAX_EXPANSION_RATIO, ) archive_preview_ttl = _configured_positive_int( context.settings, "file_archive_preview_ttl_seconds", default=_DEFAULT_ARCHIVE_PREVIEW_TTL_SECONDS, ) topics: list[DocumentationTopic] = [] if upload_limit is not None: topics.append(_upload_topic(upload_limit)) if ( upload_limit is not None and zip_limit is not None and archive_expanded_limit is not None and archive_entry_limit is not None and archive_ratio_limit is not None and archive_preview_ttl is not None ): topics.append( _archive_topic( upload_limit, zip_limit, archive_expanded_limit, archive_entry_limit, archive_ratio_limit, archive_preview_ttl, ) ) topics.append(_connector_import_topic(context)) return tuple(topics) def _upload_topic(max_bytes: int) -> DocumentationTopic: limit = _format_byte_limit(max_bytes) return DocumentationTopic( id="files.workflow.upload-managed-files", title="Upload managed files", summary=f"Upload files to a personal or accessible group space; each uploaded file may contain at most {limit}.", body=( f"The current deployment accepts at most {limit} for each ordinary upload. " "A name conflict is never resolved silently: reject stops the upload, rename chooses a copy name, and overwrite retires the old asset before creating a new one." ), layer="configured", documentation_types=("user",), audience=("file_user", "file_manager", "process_participant"), order=39, conditions=( DocumentationCondition( required_modules=("files",), required_scopes=(_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE), ), ), links=( DocumentationLink(label="Files", href="/files", kind="runtime"), DocumentationLink( label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository", ), ), related_modules=("campaigns",), unlocks=( "Users and connected processes can place governed content in managed storage.", ), source_module_id="files", metadata={ "kind": "workflow", "route": "/files", "screen": "Files", "help_contexts": ["files.list"], "prerequisites": [ "You may view and upload managed files.", "The destination personal or group space grants this account write access; upload permission alone does not grant access to every space.", ], "steps": [ "Open Files and choose My files or an accessible group space.", "Open the intended destination folder and choose Upload, or drag files into the file list.", "For every name conflict, explicitly reject, rename, overwrite, or skip the affected item.", "Wait for the upload to finish, then open the resulting file details.", ], "outcome": "Each accepted file is stored as a governed managed asset in the selected space.", "verification": "Confirm the owner, logical path, size, checksum, and current version in Files.", "constraints": [ { "id": "ordinary-upload-size", "label": "Maximum size per file", "description": f"The current safe upload limit is {limit} per file.", "values": [limit], }, ], "related_topic_ids": [ "files.workflow.upload-and-unpack-zip", "files.workflow.organize-managed-files", "files.workflow.find-and-download-files", ], }, ) def _archive_topic( max_file_bytes: int, max_archive_request_bytes: int, max_expanded_bytes: int, max_entries: int, max_expansion_ratio: int, preview_ttl_seconds: int, ) -> DocumentationTopic: member_limit = _format_byte_limit(max_file_bytes) request_limit = _format_byte_limit(max_archive_request_bytes) expanded_limit = _format_byte_limit(max_expanded_bytes) preview_minutes = max(1, preview_ttl_seconds // 60) return DocumentationTopic( id="files.workflow.upload-and-unpack-zip", title="Preview and unpack an archive", summary=( f"Review and selectively unpack up to {max_entries:,} entries from ZIP or TAR archives before any managed file is created." ), body=( f"The deployment accepts ZIP, TAR, TAR.GZ, TAR.BZ2, and TAR.XZ requests up to {request_limit}, limits actual expanded data to {expanded_limit}, " f"each member to {member_limit}, the archive to {max_entries:,} entries, and expansion to {max_expansion_ratio}:1. " f"The server-issued preview expires after {preview_minutes} minutes. Password-protected ZIP archives are supported; passwords remain request-only. " "Unsafe paths and special filesystem entries are rejected. Actual extracted bytes are counted instead of trusting archive headers." ), layer="configured", documentation_types=("user",), audience=("file_user", "file_manager", "process_participant"), order=40, conditions=( DocumentationCondition( required_modules=("files",), required_scopes=(_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE), ), ), links=( DocumentationLink(label="Files", href="/files", kind="runtime"), DocumentationLink( label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository", ), ), unlocks=( "A bounded archive can become governed managed files without trusting archive paths or size declarations.", ), source_module_id="files", metadata={ "kind": "workflow", "route": "/files", "screen": "Files", "help_contexts": ["files.list"], "prerequisites": [ "You may view and upload managed files.", "The archive fits the configured request, expansion, size, and entry limits.", "The destination personal or group space grants this account write access.", ], "steps": [ "Open Files and choose the managed destination space and folder.", "Enable Preview and unpack archive, then choose or drag one supported archive.", "Review the discovered entries, supply a ZIP password when required, and select the files or folders to import.", "Resolve every destination conflict explicitly.", "Confirm the selection, then wait for extraction and finalization to finish before leaving the page.", ], "outcome": "Accepted archive members are stored as separate governed managed assets below the selected folder.", "verification": "Confirm the expected member paths and inspect representative file sizes, checksums, and versions.", "constraints": [ { "id": "archive-request-and-total", "label": "Maximum archive request and expanded total", "description": f"The compressed request is limited to {request_limit}; actual expanded data is limited to {expanded_limit}.", "values": [request_limit, expanded_limit], }, { "id": "archive-member-size", "label": "Maximum extracted member size", "description": f"Each extracted file is limited to {member_limit}.", "values": [member_limit], }, { "id": "archive-entry-count", "label": "Maximum entry count", "description": f"An archive may contain at most {max_entries:,} declared entries.", "values": [f"{max_entries:,} entries"], }, { "id": "archive-expansion-ratio", "label": "Maximum expansion ratio", "description": f"Declared and actual output may not exceed {max_expansion_ratio} times the compressed request size.", "values": [f"{max_expansion_ratio}:1"], }, ], "related_topic_ids": [ "files.workflow.upload-managed-files", "files.workflow.organize-managed-files", "files.workflow.find-and-download-files", ], }, ) def _connector_import_topic(context: DocumentationContext) -> DocumentationTopic: principal = context.principal if not _has_all_scopes(principal, (_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE)): return _connector_import_limitation( "Connector import is not available to this account because both permission to view Files and permission to upload managed files are required." ) tenant_id = _safe_text_attribute(principal, "tenant_id") user_id = _safe_text_attribute(getattr(principal, "user", None), "id") session = context.session if not tenant_id or not user_id or not isinstance(session, Session): return _connector_import_limitation( "Connector import availability could not be safely evaluated for this request. Try again, or ask a Files administrator to verify an actor-visible connection." ) try: member_group_ids = _actor_group_ids( session, principal=principal, tenant_id=tenant_id, user_id=user_id, include_admin_groups=False, ) connector_group_ids = ( _actor_group_ids( session, principal=principal, tenant_id=tenant_id, user_id=user_id, include_admin_groups=True, ) if _has_all_scopes(principal, ("files:file:admin",)) else member_group_ids ) profiles = visible_connector_profiles_for_actor( session, tenant_id=tenant_id, user_id=user_id, group_ids=connector_group_ids, settings=context.settings, campaign_visible=_campaign_visibility( context, session=session, tenant_id=tenant_id, user_id=user_id, group_ids=member_group_ids, ), include_effective_policy=True, ) usable_profiles = tuple( profile for profile in profiles if connector_profile_usable_for_import(profile) ) except Exception: return _connector_import_limitation( "Connector import availability could not be safely evaluated for this request. Try again, or ask a Files administrator to verify an actor-visible connection." ) if not usable_profiles: return _connector_import_limitation( "No connection visible to this account is currently eligible to offer an enabled, credential-ready, policy-allowed browse/import path through a pinning-safe provider. Ask a Files administrator to configure or authorize one." ) return DocumentationTopic( id="files.workflow.import-managed-snapshot", title="Import an external file as a governed snapshot", summary="Browse an authorized connection read-only and import one selected file into managed storage as a frozen, traceable snapshot.", body=( "At least one connection visible to this account is currently eligible to offer the governed browse/import path. " "The selected remote path and item are re-authorized when used, and browse, import, and sync never mutate the remote source. " "The managed snapshot stays unchanged until an explicit manual sync." ), layer="configured", documentation_types=("user",), audience=("file_user", "campaign_manager", "report_author"), order=41, conditions=( DocumentationCondition( required_modules=("files",), required_scopes=(_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE), ), ), links=( DocumentationLink(label="Files", href="/files", kind="runtime"), DocumentationLink( label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository", ), ), related_modules=("campaigns",), unlocks=( "Campaigns, reports, and workflows can consume a managed snapshot with stable source evidence.", ), source_module_id="files", metadata={ "kind": "workflow", "route": "/files", "screen": "Files", "help_contexts": ["files.list", "files.connector-import"], "prerequisites": [ "You may view and upload managed files.", "At least one enabled, credential-ready, policy-allowed connection using a pinning-safe provider is visible to this account.", "The selected remote path and item must pass their operation-time policy checks.", "The managed destination space grants this account write access.", ], "steps": [ "Open Files and choose a managed destination space.", "Choose Sync from connection, select an available connection, and browse to the permitted remote file.", "Import the selected file and resolve any destination conflict explicitly.", "Review the managed file's source and current-version details before using it in another task.", ], "current_configuration": [ "At least one actor-visible connection is eligible to offer a safe browse/import operation; the selected endpoint, path, and item are still checked when used.", "Every selected remote path and item is re-authorized at operation time.", ], "outcome": "The external content is a tenant-managed snapshot with a checksum, exact version, and recorded source context.", "verification": "Reopen the managed file and confirm its recorded source context, source revision when available, checksum, and current version.", "related_topic_ids": [ "files.governed-connectors-and-provenance", "files.reference.integrity-recovery-and-fail-closed-transports", "files.reference.snapshot-provenance-and-capabilities", ], }, ) def _connector_import_limitation(message: str) -> DocumentationTopic: return DocumentationTopic( id="files.connector-import-unavailable", title="External file import is not currently available", summary=message, body=( f"{message} Files never exposes connection endpoints, storage paths, credential references, or raw connector policies in user documentation." ), layer="available", documentation_types=("user",), order=41, conditions=( DocumentationCondition( required_modules=("files",), any_scopes=(_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE), ), ), links=(DocumentationLink(label="Files", href="/files", kind="runtime"),), source_module_id="files", metadata={ "kind": "reference", "screen": "Files", "help_contexts": ["files.list", "files.connector-import"], "limitations": [message], }, ) def _configured_positive_int( settings: object | None, name: str, *, default: int ) -> int | None: raw_value = getattr(settings, name, default) if settings is not None else default try: value = int(raw_value) except (TypeError, ValueError): return None return value if value > 0 else None def _format_byte_limit(value: int) -> str: units = ((1024**3, "GiB"), (1024**2, "MiB"), (1024, "KiB")) for divisor, label in units: if value % divisor == 0: return f"{value // divisor:,} {label} ({value:,} bytes)" return f"{value:,} bytes" def _has_all_scopes(principal: object | None, scopes: tuple[str, ...]) -> bool: checker = getattr(principal, "has", None) if callable(checker): try: return all(bool(checker(scope)) for scope in scopes) except Exception: return False granted = {str(scope) for scope in getattr(principal, "scopes", ())} return all(scope in granted for scope in scopes) def _safe_text_attribute(value: object | None, name: str) -> str: try: result = getattr(value, name, "") except Exception: return "" return str(result or "") def _principal_group_ids(principal: object) -> tuple[str, ...]: try: values = getattr(principal, "group_ids", ()) except Exception: return () return tuple(str(value) for value in values if str(value)) def _actor_group_ids( session: Session, *, principal: object, tenant_id: str, user_id: str, include_admin_groups: bool, ) -> tuple[str, ...]: try: values = user_group_ids( session, tenant_id=tenant_id, user_id=user_id, include_admin_groups=include_admin_groups, ) except Exception: return _principal_group_ids(principal) return tuple(str(value) for value in values if str(value)) def _campaign_visibility( context: DocumentationContext, *, session: Session, tenant_id: str, user_id: str, group_ids: tuple[str, ...], ): principal = context.principal registry = context.registry if not _has_all_scopes(principal, ("campaigns:campaign:read",)): return None try: if not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS): return None capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS) except Exception: return None if not isinstance(capability, CampaignAccessProvider): return None def campaign_visible(campaign_id: str) -> bool: try: return capability.campaign_exists( session, tenant_id=tenant_id, campaign_id=campaign_id ) and capability.can_read_campaign( session, tenant_id=tenant_id, campaign_id=campaign_id, user_id=user_id, group_ids=group_ids, tenant_admin=_has_all_scopes(principal, ("tenant:*",)), ) except Exception: return False return campaign_visible