diff --git a/backend/.env b/backend/.env index 828a504..b73fae4 100644 --- a/backend/.env +++ b/backend/.env @@ -52,6 +52,9 @@ APP_PORTAL_DOMAIN=localhost # Reverse-Proxy, dem X-Forwarded-* vertraut wird. Dev: kein echter Proxy → leer. # Prod (.env.prod.local): das private Netz von Caddy, z. B. 10.0.0.0/16. TRUSTED_PROXIES=127.0.0.1 +# Öffentliche IP der Caddy-Edge für Custom-Domain-Verifizierung. Optional: +# leer → es wird die DNS-Auflösung von APP_PORTAL_DOMAIN genutzt. +APP_EDGE_IP= ###< App ### ###> S3 / Object Storage (Druck-Assets) ### diff --git a/backend/src/Controller/DomainController.php b/backend/src/Controller/DomainController.php new file mode 100644 index 0000000..a13b52e --- /dev/null +++ b/backend/src/Controller/DomainController.php @@ -0,0 +1,196 @@ +target(); + $defaultHost = $entity instanceof Company + ? $this->resolver->companyHost($entity) + : $this->resolver->resellerHost($entity); + + return new JsonResponse([ + 'scope' => $scope, + 'defaultHost' => $defaultHost, + 'edgeTarget' => $this->portalDomain, + 'edgeIp' => $this->edgeIp(), + 'domains' => array_map([$this, 'serialize'], $this->ownDomains($entity)), + ]); + } + + #[Route('/api/my-domains', name: 'my_domains_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + [, $entity] = $this->target(); + $data = json_decode($request->getContent(), true) ?? []; + $hostname = $this->normalizeHostname((string) ($data['hostname'] ?? '')); + if (null === $hostname) { + throw new BadRequestHttpException('Ungültiger Hostname.'); + } + if (null !== $this->em->getRepository(Domain::class)->findOneBy(['hostname' => $hostname])) { + throw new BadRequestHttpException('Diese Domain ist bereits hinterlegt.'); + } + + $domain = (new Domain()) + ->setHostname($hostname) + ->setType(Domain::TYPE_CUSTOM) + ->setStatus(Domain::STATUS_PENDING); + if ($entity instanceof Company) { + $domain->setCompany($entity); + } else { + $domain->setReseller($entity); + } + $this->em->persist($domain); + $this->em->flush(); + + return new JsonResponse($this->serialize($domain), 201); + } + + #[Route('/api/my-domains/{id}/verify', name: 'my_domains_verify', methods: ['POST'])] + public function verify(string $id): JsonResponse + { + $domain = $this->ownDomain($id); + $verified = $this->dnsPointsToUs($domain->getHostname()); + $domain->setStatus($verified ? Domain::STATUS_VERIFIED : Domain::STATUS_FAILED); + $domain->setVerificationCheckedAt(new \DateTimeImmutable()); + $this->em->flush(); + + return new JsonResponse([ + 'domain' => $this->serialize($domain), + 'ok' => $verified, + 'message' => $verified + ? 'Domain verifiziert. Das TLS-Zertifikat wird beim ersten Aufruf automatisch ausgestellt.' + : 'DNS zeigt noch nicht auf die Plattform. Bitte CNAME/A-Eintrag prüfen (Propagierung kann dauern).', + ]); + } + + #[Route('/api/my-domains/{id}', name: 'my_domains_delete', methods: ['DELETE'])] + public function delete(string $id): JsonResponse + { + $domain = $this->ownDomain($id); + $this->em->remove($domain); + $this->em->flush(); + + return new JsonResponse(null, 204); + } + + /** @return array{0:string,1:Company|Reseller} */ + private function target(): array + { + $company = $this->tenant->getCompany(); + if ($company instanceof Company) { + return ['company', $company]; + } + $reseller = $this->tenant->getReseller(); + if ($reseller instanceof Reseller) { + return ['reseller', $reseller]; + } + throw new AccessDeniedHttpException('Kein Domain-Kontext.'); + } + + /** @return Domain[] */ + private function ownDomains(Company|Reseller $entity): array + { + $criteria = $entity instanceof Company ? ['company' => $entity] : ['reseller' => $entity]; + + return $this->em->getRepository(Domain::class)->findBy($criteria, ['hostname' => 'ASC']); + } + + private function ownDomain(string $id): Domain + { + $domain = $this->em->getRepository(Domain::class)->find(Uuid::fromString($id)); + if (!$domain instanceof Domain) { + throw new NotFoundHttpException('Domain nicht gefunden.'); + } + [, $entity] = $this->target(); + $owns = $entity instanceof Company + ? $domain->getCompany()?->getId()->equals($entity->getId()) + : $domain->getReseller()?->getId()->equals($entity->getId()) && null === $domain->getCompany(); + if (true !== $owns) { + throw new AccessDeniedHttpException('Domain gehört nicht zum eigenen Kontext.'); + } + + return $domain; + } + + /** Prüft, ob der Hostname (per A oder via CNAME) auf die Plattform-Edge zeigt. */ + private function dnsPointsToUs(string $hostname): bool + { + $hostIps = gethostbynamel($hostname); + if (false === $hostIps || [] === $hostIps) { + return false; + } + $ourIps = null !== $this->edgeIp() ? [$this->edgeIp()] : (gethostbynamel($this->portalDomain) ?: []); + + return [] !== array_intersect($hostIps, $ourIps); + } + + private function edgeIp(): ?string + { + $ip = trim((string) $this->edgeIp); + + return '' !== $ip ? $ip : null; + } + + private function normalizeHostname(string $host): ?string + { + $host = strtolower(trim($host)); + $host = preg_replace('#^https?://#', '', $host) ?? $host; + $host = rtrim($host, '/.'); + if (1 !== preg_match('/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/', $host)) { + return null; + } + + return $host; + } + + /** @return array */ + private function serialize(Domain $domain): array + { + return [ + 'id' => (string) $domain->getId(), + 'hostname' => $domain->getHostname(), + 'type' => $domain->getType(), + 'status' => $domain->getStatus(), + 'tlsStatus' => $domain->getTlsStatus(), + 'checkedAt' => $domain->getVerificationCheckedAt()?->format(\DateTimeInterface::ATOM), + ]; + } +} diff --git a/backend/src/Entity/Domain.php b/backend/src/Entity/Domain.php index 869cced..44f235f 100644 --- a/backend/src/Entity/Domain.php +++ b/backend/src/Entity/Domain.php @@ -3,6 +3,8 @@ namespace App\Entity; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use App\Repository\DomainRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Types\UuidType; @@ -13,8 +15,13 @@ use Symfony\Component\Uid\Uuid; * Gehört zu genau EINEM Reseller ODER einer Firma (Firmen-Domain ist via * company.reseller implizit auch reseller-zugeordnet). */ +// Schreiben läuft ausschließlich über DomainController (DNS-Verifizierung), +// damit keine 'verified'-Domain das On-Demand-TLS-Gate umgehen kann. #[ORM\Entity(repositoryClass: DomainRepository::class)] -#[ApiResource(security: "is_granted('ROLE_RESELLER_ADMIN')")] +#[ApiResource( + operations: [new GetCollection(), new Get()], + security: "is_granted('ROLE_RESELLER_ADMIN')", +)] class Domain implements ResellerOwnedInterface { public const TYPE_SUBDOMAIN = 'subdomain'; diff --git a/frontend/src/layouts/DashboardLayout.vue b/frontend/src/layouts/DashboardLayout.vue index 74cea9d..73f42c1 100644 --- a/frontend/src/layouts/DashboardLayout.vue +++ b/frontend/src/layouts/DashboardLayout.vue @@ -21,6 +21,7 @@ const topNav = computed(() => { { label: 'Produkte', to: '/app/products', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12', show: auth.isResellerAdmin }, { label: 'Bestellungen', to: '/app/orders', icon: 'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4zM3 6h18M16 10a4 4 0 0 1-8 0', show: auth.isResellerAdmin }, { label: 'Branding', to: '/app/branding', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: auth.isResellerAdmin }, + { label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: auth.isResellerAdmin }, { label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: above }, ].filter((i) => i.show) }) diff --git a/frontend/src/views/DomainsView.vue b/frontend/src/views/DomainsView.vue index 7f3b7b9..bc12b64 100644 --- a/frontend/src/views/DomainsView.vue +++ b/frontend/src/views/DomainsView.vue @@ -1,101 +1,131 @@