"""Project and preview-domain use cases. This is the application layer: it orchestrates domain rules, repositories and the driven ports (Git, domain router). It knows nothing about HTTP. Security properties enforced here (not in the router, not only in the adapter): * a project repository is always created **private** * the repository name is collision-free across tenants * the per-project Git password is encrypted before it is stored and never returned to a caller * a preview host must be a single label under the configured root domain * a preview host is unique platform-wide * the proxy target port must be allocatable and must not be a protected platform port * a project may hold at most ``max_subdomains_per_project`` hosts """ from __future__ import annotations import hashlib from app.application.ports import ( DomainRouterPort, GitCredentials, GitProviderPort, RepoInfo, RepoStatus, ) from app.core.config import settings from app.core.crypto import decrypt, encrypt from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError from app.domain.base import ensure_unique_slug, slugify from app.domain.platform.rules import ( assert_not_protected_port, assert_port_allocatable, build_host, validate_subdomain_label, ) from app.domain.projects.models import ( Project, ProjectGitCredential, ProjectGitRepo, ) from app.infrastructure.repositories.projects import ( PreviewDomainRepository, ProjectGitCredentialRepository, ProjectGitRepoRepository, ProjectRepository, ) class ProjectService: def __init__( self, *, projects: ProjectRepository, repos: ProjectGitRepoRepository, credentials: ProjectGitCredentialRepository, domains: PreviewDomainRepository, git: GitProviderPort, router: DomainRouterPort, ) -> None: self.projects = projects self.repos = repos self.credentials = credentials self.domains = domains self.git = git self.router = router # -- projects ------------------------------------------------------------ async def create_project( self, *, organization_id: str, organization_slug: str, created_by_user_id: str, name: str, description: str = "", ) -> Project: slug = ensure_unique_slug(slugify(name), await self.projects.list_slugs(organization_id)) return await self.projects.create( Project( organization_id=organization_id, name=name.strip(), slug=slug, description=description, created_by_user_id=created_by_user_id, ) ) async def get_or_404(self, *, organization_id: str, project_id: str) -> Project: project = await self.projects.get(project_id) # Returning 404 (not 403) keeps the existence of other tenants' projects # out of the response. if project is None or project.organization_id != organization_id: raise NotFoundError("پروژه یافت نشد.") return project @staticmethod def repo_name_for(*, organization_slug: str, project: Project) -> str: """``orgslug-projectslug-shortid`` — stable and collision-free.""" digest = hashlib.sha1(f"{organization_slug}:{project.id}".encode()).hexdigest()[:6] raw = f"{organization_slug}-{project.slug}-{digest}" return raw[:100].strip("-") # -- git ----------------------------------------------------------------- async def ensure_repository( self, *, organization_slug: str, project: Project ) -> ProjectGitRepo: repo_name = self.repo_name_for(organization_slug=organization_slug, project=project) info: RepoInfo = await self.git.ensure_project_repo( project_id=project.id, repo_name=repo_name, description=f"مخزن پروژهٔ {project.name}", ) credentials: GitCredentials = await self.git.ensure_credentials( project_id=project.id, owner=info.owner, repo_name=info.repo_name ) await self.credentials.upsert( ProjectGitCredential( project_id=project.id, username=credentials.username, # Encrypted at rest. The plaintext only exists inside this method. encrypted_password=encrypt(credentials.password), ) ) return await self.repos.upsert( ProjectGitRepo( project_id=project.id, provider=settings.git_provider, owner=info.owner, repo_name=info.repo_name, clone_url=info.clone_url, web_url=info.web_url, default_branch=info.default_branch, ) ) async def repo_status(self, *, project: Project) -> RepoStatus: record = await self.repos.get_for_project(project.id) if record is None: return RepoStatus(exists=False) # Identity comes from our own record so the adapter stays stateless. return await self.git.get_repo_status( project_id=project.id, owner=record.owner, repo_name=record.repo_name ) async def clone_credentials(self, *, project: Project) -> tuple[str, str]: """``(username, password)`` for a one-off clone. Only the application layer may call this, and the router must never serialise the result. """ record = await self.credentials.get_for_project(project.id) if record is None: raise NotFoundError("اطلاعات دسترسی مخزن ثبت نشده است.") return record.username, decrypt(record.encrypted_password) # -- preview domains ----------------------------------------------------- async def allocate_port(self, *, project: Project, service_name: str = "web") -> int: existing = await self.domains.get_port(project.id, service_name) if existing is not None: return existing start, end = settings.preview_port_start, settings.preview_port_end used = await self.domains.list_used_ports(start=start, end=end) for port in range(start, end + 1): if port in used: continue try: assert_port_allocatable(port, start=start, end=end) except ValueError: continue await self.domains.add_port( organization_id=project.organization_id, project_id=project.id, service_name=service_name, port=port, ) return port raise ConflictError("پورت آزادی در بازهٔ تعیین‌شده وجود ندارد.") async def create_preview_domain( self, *, project: Project, requested_subdomain: str, created_by_user_id: str, port: int | None = None, ) -> dict: label = validate_subdomain_label(requested_subdomain) host = build_host(label, settings.preview_root_domain) if await self.domains.get_by_host(host) is not None: raise ConflictError(f"دامنهٔ {host} قبلاً ثبت شده است.") count = await self.domains.count_for_project(project.id) if count >= settings.max_subdomains_per_project: raise ConflictError( f"هر پروژه حداکثر {settings.max_subdomains_per_project} دامنه می‌تواند داشته باشد." ) target_port = port if port is not None else await self.allocate_port(project=project) # A tenant must never be able to expose an internal platform service. assert_not_protected_port(target_port) result = await self.router.configure_reverse_proxy(host=host, port=target_port) if not result.ok: raise ValidationError(result.message or "پیکربندی دامنه ناموفق بود.") record = await self.domains.create( organization_id=project.organization_id, project_id=project.id, host=host, port=target_port, created_by_user_id=created_by_user_id, ) record["url"] = result.url or f"https://{host}" return record async def remove_preview_domain(self, *, project: Project, domain_id: str) -> None: record = await self.domains.get(domain_id) if record is None or record["project_id"] != project.id: raise NotFoundError("دامنه یافت نشد.") if record["protected"]: raise PermissionDeniedError( "این دامنه محافظت‌شده است و فقط مالک سامانه می‌تواند آن را تغییر دهد." ) await self.router.remove_preview_domain(domain_id=record["host"]) await self.domains.delete(domain_id) async def list_preview_domains(self, *, project: Project) -> list[dict]: records = await self.domains.list_for_project(project.id) for record in records: record["url"] = f"https://{record['host']}" return records