مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
244 lines
9.1 KiB
Python
244 lines
9.1 KiB
Python
"""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
|