نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user