feat(domains): Domain model, DNS TXT verification action, pending verifier job

main
boban 2026-05-16 08:14:33 +02:00
parent a394e0ff18
commit 96ec518200
6 changed files with 146 additions and 0 deletions

View File

@ -0,0 +1,21 @@
<?php
namespace App\Domains\Domain\Actions;
use App\Domains\Domain\Models\Domain;
use App\Domains\Workspace\Models\Workspace;
use Illuminate\Support\Str;
class CreateDomain
{
public function handle(Workspace $workspace, string $hostname): Domain
{
return Domain::create([
'workspace_id' => $workspace->id,
'hostname' => strtolower(trim($hostname)),
'verification_token' => Str::random(32),
'ssl_status' => 'pending',
'is_default' => false,
]);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Domains\Domain\Actions;
use App\Domains\Domain\Models\Domain;
class VerifyDomain
{
public function handle(Domain $domain): bool
{
$records = @dns_get_record("_nimuli.{$domain->hostname}", DNS_TXT);
if (! is_array($records)) {
return false;
}
foreach ($records as $record) {
if (($record['txt'] ?? '') === "nimuli-verify={$domain->verification_token}") {
$domain->update([
'verified_at' => now(),
'ssl_status' => 'pending',
]);
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Domains\Domain\Jobs;
use App\Domains\Domain\Actions\VerifyDomain;
use App\Domains\Domain\Models\Domain;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
class VerifyPendingDomainsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public function handle(VerifyDomain $action): void
{
Domain::whereNull('verified_at')
->where('created_at', '>=', now()->subDays(30))
->each(fn(Domain $domain) => $action->handle($domain));
}
}

View File

@ -4,9 +4,13 @@ namespace App\Domains\Domain\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Str;
class Domain extends Model
{
use HasFactory;
protected $guarded = ['id'];
protected $casts = [
@ -25,4 +29,9 @@ class Domain extends Model
{
return $this->verified_at !== null;
}
protected static function newFactory(): \Database\Factories\DomainFactory
{
return \Database\Factories\DomainFactory::new();
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Domains\Domain\Models\Domain;
use App\Domains\Workspace\Models\Workspace;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class DomainFactory extends Factory
{
protected $model = Domain::class;
public function definition(): array
{
$user = User::factory()->create();
$workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']);
return [
'workspace_id' => $workspace->id,
'hostname' => $this->faker->domainName(),
'verification_token' => Str::random(32),
'ssl_status' => 'pending',
'is_default' => false,
];
}
}

View File

@ -0,0 +1,37 @@
<?php
use App\Domains\Domain\Actions\VerifyDomain;
use App\Domains\Domain\Models\Domain;
it('reports unverified domain as not verified', function () {
$domain = Domain::factory()->create([
'hostname' => 'example.com',
'verification_token' => 'abc123',
'verified_at' => null,
]);
expect($domain->isVerified())->toBeFalse();
});
it('reports verified domain as verified', function () {
$domain = Domain::factory()->create([
'hostname' => 'example.com',
'verification_token' => 'abc123',
'verified_at' => now(),
]);
expect($domain->isVerified())->toBeTrue();
});
it('does not verify domain with wrong TXT record', function () {
$domain = Domain::factory()->create([
'hostname' => 'nonexistent-domain-that-will-fail-dns.invalid',
'verification_token' => 'wrongtoken',
]);
$action = new VerifyDomain;
$result = $action->handle($domain);
expect($result)->toBeFalse()
->and($domain->fresh()->verified_at)->toBeNull();
});