نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
mjalmoudy94
2026-10-13 00:39:28 +03:30
commit 717e53a7ce
185 changed files with 39910 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Cross-cutting concerns: configuration, database, security, crypto, errors."""
+152
View File
@@ -0,0 +1,152 @@
"""Application configuration.
All external providers default to ``mock`` so the whole stack runs with zero
credentials. Real adapters stay behind the ``*_PROVIDER`` switches.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# web/apps/api/app/core/config.py -> web/
WEB_ROOT = Path(__file__).resolve().parents[4]
#: Values that are obviously placeholders. Never acceptable in production.
_INSECURE_SECRETS = frozenset(
{"", "change-me", "changeme", "secret", "test", "dev", "password"}
)
#: HS256 signs with a 256-bit key; anything shorter weakens the signature.
MIN_SECRET_LENGTH = 32
#: The development CORS allowlist. ``localhost`` and ``127.0.0.1`` are distinct
#: browser origins, so both spellings are listed; leaving one out makes the dev
#: UI fail with an opaque "Disallowed CORS origin".
DEVELOPMENT_CORS_ORIGINS = "http://localhost:3000,http://127.0.0.1:3000"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(WEB_ROOT / ".env", WEB_ROOT / ".env.local"),
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# --- app -----------------------------------------------------------------
app_env: str = "development"
app_name: str = "AGY Web"
app_url: str = "http://localhost:3000"
api_url: str = "http://localhost:8000"
#: Both loopback spellings, on purpose. ``localhost`` and ``127.0.0.1`` are
#: *different origins* to a browser, so listing only one produces a bare
#: "Disallowed CORS origin" with nothing to hint at the cause — the classic
#: failure being a dev server started with ``-H 127.0.0.1``. Override with
#: ``CORS_ORIGINS``; production must set it explicitly (a wildcard is
#: refused outright by the validator below).
cors_origins: str = DEVELOPMENT_CORS_ORIGINS
# --- data stores ---------------------------------------------------------
database_url: str = "sqlite+aiosqlite:///./agy_web.db"
redis_url: str = "redis://localhost:6379/0"
# --- security ------------------------------------------------------------
jwt_secret: str = "change-me"
jwt_algorithm: str = "HS256"
access_token_ttl_minutes: int = 60 * 24
encryption_key: str = ""
# --- AI models (hidden from customers) -----------------------------------
ai_default_provider: str = "mock"
openai_compatible_base_url: str = ""
openai_compatible_api_key: str = ""
# --- git -----------------------------------------------------------------
git_provider: str = "mock"
gitea_url: str = ""
gitea_internal_url: str = ""
gitea_admin_user: str = "root"
gitea_admin_token: str = ""
# --- preview domain routing ----------------------------------------------
domain_router: str = "mock"
preview_root_domain: str = "msa.artacloud.ir"
preview_port_start: int = 8080
preview_port_end: int = 8999
caddyfile_path: str = "/etc/caddy/Caddyfile"
max_subdomains_per_project: int = 5
# --- sandbox -------------------------------------------------------------
sandbox_provider: str = "mock"
daytona_api_key: str = ""
daytona_api_url: str = ""
e2b_api_key: str = ""
sandbox_command_timeout_seconds: int = 120
sandbox_max_output_bytes: int = 200_000
# --- payments ------------------------------------------------------------
payment_provider: str = "mock"
stripe_secret_key: str = ""
stripe_webhook_secret: str = ""
# --- error logging -------------------------------------------------------
#: Master switch. When off, failures are still logged to stderr but nothing
#: is written to ``error_events``.
error_log_enabled: bool = True
#: Whether 4xx responses are persisted too. Off by default: a public API
#: takes a constant stream of 404s from scanners, and recording them all
#: buries the genuine bugs. Turn it on to debug a misbehaving client.
#: 5xx responses are recorded regardless of this setting.
error_log_client_errors: bool = False
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@property
def is_production(self) -> bool:
return self.app_env.lower() in {"production", "prod"}
@model_validator(mode="after")
def _refuse_insecure_production_secrets(self) -> Settings:
"""Fail closed instead of booting a production API with a weak secret.
A short or placeholder ``JWT_SECRET`` lets anyone mint a token for any
tenant — the single worst failure mode this service has. Development
keeps working with the documented default; production must be explicit.
"""
if not self.is_production:
return self
problems: list[str] = []
if self.jwt_secret.strip().lower() in _INSECURE_SECRETS:
problems.append("JWT_SECRET is still the placeholder value")
elif len(self.jwt_secret) < MIN_SECRET_LENGTH:
problems.append(
f"JWT_SECRET is {len(self.jwt_secret)} chars; "
f"at least {MIN_SECRET_LENGTH} are required"
)
if not self.encryption_key.strip():
problems.append("ENCRYPTION_KEY is empty; stored credentials cannot be encrypted")
if self.cors_origins.strip() == "*":
problems.append("CORS_ORIGINS must not be '*' in production")
elif self.cors_origins.strip() == DEVELOPMENT_CORS_ORIGINS:
problems.append(
"CORS_ORIGINS is still the development default; "
"set it to the real frontend origin"
)
if problems:
raise ValueError(
"Unsafe production configuration: " + "; ".join(problems) + "."
)
return self
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+54
View File
@@ -0,0 +1,54 @@
"""Symmetric encryption for secrets at rest (provider API keys, git credentials).
Fernet (AES-128-CBC + HMAC) with a key derived from ``ENCRYPTION_KEY``. The
derivation is deterministic so a rotated key invalidates stored ciphertexts
loudly instead of silently returning garbage.
"""
from __future__ import annotations
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
from app.core.errors import AppError
def _derive_key(raw: str) -> bytes:
"""Fernet needs exactly 32 url-safe base64 bytes."""
digest = hashlib.sha256(raw.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)
_fernet: Fernet | None = None
def _cipher() -> Fernet:
global _fernet
if _fernet is None:
material = settings.encryption_key.strip() or settings.jwt_secret
_fernet = Fernet(_derive_key(material))
return _fernet
def encrypt(plaintext: str) -> str:
return _cipher().encrypt(plaintext.encode("utf-8")).decode("ascii")
def decrypt(ciphertext: str) -> str:
try:
return _cipher().decrypt(ciphertext.encode("ascii")).decode("utf-8")
except (InvalidToken, ValueError) as exc:
raise AppError(
"رمزنگاری ناموفق بود؛ ممکن است ENCRYPTION_KEY تغییر کرده باشد."
) from exc
def mask(secret: str | None, keep: int = 4) -> str:
"""Safe display form: ``••••1234``. Never returns the full secret."""
if not secret:
return ""
tail = secret[-keep:] if len(secret) > keep else ""
return "•" * 6 + tail
+51
View File
@@ -0,0 +1,51 @@
"""Async SQLAlchemy engine, session factory and declarative base.
The ORM lives in ``infrastructure``; ``domain`` never imports this module.
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
class Base(DeclarativeBase):
"""Declarative base for every ORM table."""
def _engine_kwargs() -> dict:
url = settings.database_url
if url.startswith("sqlite"):
return {"echo": False, "future": True}
return {
"echo": False,
"future": True,
"pool_pre_ping": True,
"pool_size": 10,
"max_overflow": 20,
}
engine = create_async_engine(settings.database_url, **_engine_kwargs())
SessionFactory = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency yielding one transactional session per request."""
async with SessionFactory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
+63
View File
@@ -0,0 +1,63 @@
"""Domain-level errors and their HTTP mapping.
Domain and application layers raise these; only ``interfaces/http`` knows about
status codes. That keeps FastAPI out of the inner layers.
"""
from __future__ import annotations
from typing import Any
class AppError(Exception):
"""Base class for every expected, user-facing failure."""
status_code = 400
code = "app_error"
def __init__(self, message: str, *, details: Any = None) -> None:
super().__init__(message)
self.message = message
self.details = details
class ValidationError(AppError):
status_code = 422
code = "validation_error"
class AuthenticationError(AppError):
status_code = 401
code = "authentication_error"
class PermissionDeniedError(AppError):
status_code = 403
code = "permission_denied"
class NotFoundError(AppError):
status_code = 404
code = "not_found"
class ConflictError(AppError):
status_code = 409
code = "conflict"
class QuotaExceededError(AppError):
status_code = 402
code = "quota_exceeded"
class ProviderUnavailableError(AppError):
status_code = 503
code = "provider_unavailable"
class ApprovalRequiredError(AppError):
"""A dangerous operation was attempted without an approval record."""
status_code = 409
code = "approval_required"
+293
View File
@@ -0,0 +1,293 @@
"""Scrub secrets out of anything before it is persisted.
This module exists because of one hard rule: **an error log that stores a
password is worse than no error log at all.** Error records are written from
inside exception handlers, where the values at hand are exactly the values most
likely to carry credentials — request bodies, ``Authorization`` headers, DSNs,
provider API keys, decrypted git tokens. Anything that reaches the database must
pass through here first.
Two independent layers, because either one alone leaks:
1. **Structural** — a key that *means* a secret (``password``, ``api_key``,
``refresh_token``, …) has its whole value replaced, whatever the value looks
like. This catches secrets in shapes no pattern would recognise.
2. **Lexical** — value patterns that are recognisable regardless of their key:
PEM blocks, JWTs, ``scheme://user:pass@host`` URLs, ``Bearer`` headers,
``sk-…``/``ghp_…``/``AKIA…`` provider keys, and ``password=…`` fragments
embedded in a larger string.
On top of both sits a third net: the **configured secret values themselves**
(``JWT_SECRET``, ``ENCRYPTION_KEY``, every ``*_API_KEY``/``*_TOKEN``) are
replaced literally wherever they appear. A secret that is only ever compared by
pattern will eventually be logged verbatim; a secret that is matched by value
cannot be.
Deliberately dependency-free and framework-free: the recorder, the HTTP layer
and the workers all import it, and it must never fail — a redactor that raises
inside an exception handler turns a 500 into a hang.
"""
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
#: What replaces a scrubbed value. Deliberately obvious in a log so a reader can
#: tell "we removed this" from "this was empty".
REDACTED = "[REDACTED]"
#: Longest string we will hand to the regex engine. Error payloads can contain
#: multi-megabyte blobs; redaction cost must stay bounded and predictable.
MAX_INPUT_CHARS = 64_000
# ---------------------------------------------------------------------------
# Layer 1 — structural: keys that mean "secret"
# ---------------------------------------------------------------------------
#: Exact key names (after normalisation) whose value is never safe to store.
_SENSITIVE_KEYS = frozenset(
{
"password",
"passwd",
"pwd",
"passphrase",
"secret",
"client_secret",
"token",
"api_key",
"apikey",
"authorization",
"auth",
"credentials",
"credential",
"private_key",
"access_token",
"refresh_token",
"id_token",
"session",
"session_id",
"session_token",
"cookie",
"set_cookie",
"jwt",
"bearer",
"encryption_key",
"jwt_secret",
"signing_key",
"webhook_secret",
"signature",
}
)
#: Suffixes that make a key sensitive however it is prefixed: ``db_password``,
#: ``gitea_admin_token``, ``stripe_secret_key``, ``openai_api_key``.
#:
#: Suffix — not substring — matching on purpose. ``token_count`` and
#: ``input_tokens`` are *usage* fields, and redacting them would silently
#: destroy the billing data these same records are meant to help debug.
_SENSITIVE_SUFFIXES = (
"_password",
"_passwd",
"_secret",
"_secret_key",
"_token",
"_api_key",
"_apikey",
"_private_key",
"_credential",
"_credentials",
)
#: Keys that look sensitive by suffix rule but are known-safe counters.
_SAFE_KEYS = frozenset(
{
"token_count",
"token_limit",
"tokens",
"input_tokens",
"output_tokens",
"total_tokens",
"prompt_tokens",
"completion_tokens",
"max_tokens",
"cached_tokens",
"secret_count",
}
)
_NORMALISE = re.compile(r"[^a-z0-9]+")
#: Splits camelCase so ``refreshToken`` and ``apiKey`` normalise the same way as
#: their snake_case spellings, instead of collapsing to one unguessable word.
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
def _normalise_key(key: str) -> str:
"""``X-API-Key`` / ``apiKey`` / ``api-key`` all become ``api_key``."""
spaced = _CAMEL_BOUNDARY.sub("_", key.strip())
return _NORMALISE.sub("_", spaced.lower()).strip("_")
def is_sensitive_key(key: str) -> bool:
"""True when a value stored under ``key`` must be replaced wholesale."""
normalised = _normalise_key(key)
if not normalised or normalised in _SAFE_KEYS:
return False
if normalised in _SENSITIVE_KEYS:
return True
return normalised.endswith(_SENSITIVE_SUFFIXES)
# ---------------------------------------------------------------------------
# Layer 2 — lexical: values that are secrets whatever their key
# ---------------------------------------------------------------------------
_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
# PEM / OpenSSH private keys, possibly multi-line.
(
re.compile(
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
re.DOTALL,
),
"[REDACTED_PRIVATE_KEY]",
),
# scheme://user:password@host — DSNs and git remotes. Keeps the scheme and
# the host so the record stays useful; drops the userinfo.
(
re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]*://)[^/\s:@]+:[^/\s@]+@"),
r"\g<scheme>[REDACTED]@",
),
# "Authorization: Bearer eyJ…" and friends. Keeps the scheme.
(
re.compile(r"(?i)\b(bearer|basic|token|apikey)\s+[A-Za-z0-9\-._~+/=]{8,}"),
r"\1 [REDACTED]",
),
# JSON Web Tokens: three base64url segments, always starting "eyJ".
(
re.compile(r"\beyJ[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\b"),
"[REDACTED_JWT]",
),
# Provider key shapes worth naming individually.
(re.compile(r"\bsk-[A-Za-z0-9_\-]{16,}\b"), "[REDACTED_KEY]"),
(re.compile(r"\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b"), "[REDACTED_KEY]"),
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), "[REDACTED_KEY]"),
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[REDACTED_KEY]"),
(re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"), "[REDACTED_KEY]"),
(re.compile(r"\bya29\.[0-9A-Za-z_\-]+\b"), "[REDACTED_KEY]"),
# "password=hunter2", '"api_key": "…"', "secret: abc" inside a longer text.
# The value is a quoted run, a scheme-prefixed token ("Bearer eyJ…"), or a
# bare run up to the next delimiter.
(
re.compile(
r"(?i)\b(password|passwd|pwd|secret|token|api[_\-]?key|client[_\-]?secret"
r"|access[_\-]?token|refresh[_\-]?token|authorization|private[_\-]?key)"
r"\b(\s*[:=]\s*)"
r"(\"[^\"]{0,512}\""
r"|'[^']{0,512}'"
r"|(?:bearer|basic|token|apikey)\s+\S{1,512}"
r"|[^\s,;&)\]}\"']{1,512})"
),
r"\1\2[REDACTED]",
),
)
def _configured_secret_values() -> tuple[str, ...]:
"""Literal secrets from settings, longest first.
Read lazily on every call rather than cached at import: the test suite
rewrites ``settings.jwt_secret`` in a fixture, and a stale cache would keep
the old value while the new one leaked.
"""
from app.core.config import settings
candidates = (
settings.jwt_secret,
settings.encryption_key,
settings.openai_compatible_api_key,
settings.gitea_admin_token,
settings.daytona_api_key,
settings.e2b_api_key,
settings.stripe_secret_key,
settings.stripe_webhook_secret,
)
# Short strings and documented placeholders are not secrets. Replacing
# "change-me" would garble messages for no security gain.
placeholders = {"change-me", "changeme", "secret", "test", "dev", "password", "none"}
usable = {
value
for value in candidates
if isinstance(value, str) and len(value) >= 8 and value.lower() not in placeholders
}
return tuple(sorted(usable, key=len, reverse=True))
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def redact_text(value: str) -> str:
"""Scrub secrets from a free-form string. Never raises."""
try:
text = value if len(value) <= MAX_INPUT_CHARS else value[:MAX_INPUT_CHARS] + "…[truncated]"
for secret in _configured_secret_values():
if secret in text:
text = text.replace(secret, REDACTED)
for pattern, replacement in _PATTERNS:
text = pattern.sub(replacement, text)
return text
except Exception: # pragma: no cover - redaction must never break a handler
# If a pattern ever explodes, drop the text rather than the request.
return REDACTED
def redact(value: Any) -> Any:
"""Recursively scrub an arbitrary JSON-ish value. Never raises."""
try:
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, str):
return redact_text(value)
if isinstance(value, (bytes, bytearray, memoryview)):
return f"<{len(bytes(value))} bytes>"
if isinstance(value, Mapping):
return redact_mapping(value)
if isinstance(value, (list, tuple, set, frozenset)):
return [redact(item) for item in value]
return redact_text(str(value))
except Exception: # pragma: no cover - see redact_text
return REDACTED
def redact_mapping(data: Mapping[Any, Any] | None) -> dict[str, Any]:
"""Scrub a mapping, replacing any sensitive key's value outright."""
if not data:
return {}
try:
result: dict[str, Any] = {}
for key, item in data.items():
name = str(key)
result[name] = REDACTED if is_sensitive_key(name) else redact(item)
return result
except Exception: # pragma: no cover - see redact_text
return {"_redaction_failed": True}
def redact_headers(headers: Mapping[Any, Any] | None) -> dict[str, str]:
"""Scrub HTTP headers, keeping the shape of the request for debugging.
Header names are normalised to lower-case; ``authorization``, ``cookie`` and
``x-api-key`` fall out as sensitive through the same key rules as JSON.
"""
if not headers:
return {}
try:
result: dict[str, str] = {}
for key, value in headers.items():
name = str(key).lower()
result[name] = REDACTED if is_sensitive_key(name) else str(redact(value))
return result
except Exception: # pragma: no cover - see redact_text
return {}
+72
View File
@@ -0,0 +1,72 @@
"""Password hashing and JWT issuance/verification.
Argon2id for passwords, HS256 JWTs for sessions. No secret ever leaves this module.
"""
from __future__ import annotations
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from app.core.config import settings
from app.core.errors import AuthenticationError
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except (VerifyMismatchError, InvalidHashError, ValueError):
return False
def needs_rehash(password_hash: str) -> bool:
try:
return _hasher.check_needs_rehash(password_hash)
except (InvalidHashError, ValueError):
return True
def create_access_token(user_id: str, *, extra: dict[str, Any] | None = None) -> str:
now = datetime.now(UTC)
payload: dict[str, Any] = {
"sub": str(user_id),
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=settings.access_token_ttl_minutes)).timestamp()),
"jti": uuid.uuid4().hex,
}
if extra:
payload.update(extra)
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except jwt.ExpiredSignatureError as exc:
raise AuthenticationError("نشست منقضی شده است.") from exc
except jwt.PyJWTError as exc:
raise AuthenticationError("توکن نامعتبر است.") from exc
def generate_token(nbytes: int = 32) -> str:
"""Opaque token for invites / one-time links."""
return secrets.token_urlsafe(nbytes)
def hash_token(token: str) -> str:
"""Invite tokens are stored hashed, never in plaintext."""
import hashlib
return hashlib.sha256(token.encode("utf-8")).hexdigest()