Proje dosyaları eklendi

This commit is contained in:
2026-08-29 15:53:53 +03:00
commit d3313400ca
440 changed files with 24827 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
[Mon Aug 10 13:02:44 2026] PHP 8.4.0 Development Server (http://127.0.0.1:8798) started
[Mon Aug 10 13:02:46 2026] 127.0.0.1:54324 Accepted
[Mon Aug 10 13:02:46 2026] 127.0.0.1:54324 Closing
[Mon Aug 10 13:02:46 2026] 127.0.0.1:54326 Accepted
[Mon Aug 10 13:02:46 2026] 127.0.0.1:54326 Closing
[Mon Aug 10 13:02:46 2026] 127.0.0.1:54327 Accepted
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54327 Closing
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54328 Accepted
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54328 Closing
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54329 Accepted
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54329 Closing
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54330 Accepted
[Mon Aug 10 13:02:47 2026] 127.0.0.1:54330 Closing
View File
View File
+7
View File
@@ -0,0 +1,7 @@
INFO Server running on [http://127.0.0.1:8788].
Press Ctrl+C to stop the server
2026-08-10 15:41:30 /up .................................................................................. ~ 0.86ms
2026-08-10 15:41:30 / .................................................................................... ~ 0.54ms
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4
+132
View File
@@ -0,0 +1,132 @@
APP_NAME=TrafficPumper
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=tr
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=tr_TR
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=trafficpumper
DB_USERNAME=trafficpumper
DB_PASSWORD=
DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci
LEGACY_SQLITE_DATABASE=database/database.sqlite
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_SECURE_COOKIE=false
SESSION_HTTP_ONLY=true
SESSION_SAME_SITE=lax
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=redis
CACHE_PREFIX=trafficpumper_cache_
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0
REDIS_CACHE_DB=1
HORIZON_NAME=TrafficPumper
HORIZON_DOMAIN=null
HORIZON_PATH=horizon
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# SERP çalıştırma özelliği, üretim kimlik bilgileri ve operasyon ayarları onaylanana kadar kapalı kalır.
# Yazar: Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
SERP_CHECKS_ENABLED=false
SERP_OPERATION_MODE=disabled
SERP_PROVIDER=dataforseo
SERP_ALLOW_FAKE_LOCAL=false
SERP_QUEUE=serp
SERP_DAILY_USER_QUOTA=25
SERP_KEYWORD_COOLDOWN_MINUTES=15
SERP_MANUAL_RATE_LIMIT=5
SERP_MANUAL_RATE_DECAY_SECONDS=60
DATAFORSEO_LOGIN=
DATAFORSEO_PASSWORD=
DATAFORSEO_BASE_URL=https://api.dataforseo.com
DATAFORSEO_AUTOMATIC_MODE=standard
DATAFORSEO_MANUAL_MODE=live
DATAFORSEO_RESULT_DEPTH=10
DATAFORSEO_CONNECT_TIMEOUT=10
DATAFORSEO_TIMEOUT=45
DATAFORSEO_POLL_INITIAL_DELAY=30
DATAFORSEO_POLL_INTERVAL=30
DATAFORSEO_POLL_MAX_ATTEMPTS=20
DATAFORSEO_MAX_TASK_AGE_MINUTES=30
SERP_DAILY_BUDGET_USD=1.00
SERP_MONTHLY_BUDGET_USD=20.00
SERP_BUDGET_WARNING_PERCENT=80
SERP_ESTIMATED_LIVE_COST_USD=0.003
SERP_ESTIMATED_STANDARD_COST_USD=0.001
DATAFORSEO_LOW_BALANCE_USD=10.00
DATAFORSEO_CRITICAL_BALANCE_USD=2.00
DATAFORSEO_HEALTH_TTL_SECONDS=300
SERP_CANARY_KEYWORD=trafficpumper canary
SERP_CANARY_COUNTRY=TR
SERP_CANARY_LANGUAGE=tr
SERP_CANARY_DEVICE=desktop
SERP_CANARY_DAILY_LIMIT=1
SERP_CIRCUIT_FAILURE_THRESHOLD=5
SERP_CIRCUIT_WINDOW_MINUTES=5
SERP_CIRCUIT_COOLDOWN_MINUTES=10
SERP_STUCK_AFTER_MINUTES=10
SERP_MAX_POLL_ATTEMPTS=20
SERP_MAX_POLL_AGE_MINUTES=60
SERP_RAMP_FIRST_HOUR_LIMIT=5
SERP_RAMP_SIX_HOUR_LIMIT=25
SERP_RAMP_FIRST_DAY_LIMIT=100
EARN_DEVICE_HASH_KEY=
TEST_TASKS_CUSTOMER_EMAIL=
EARN_MAX_TOTAL_TOKENS=5
EARN_DEVICE_BIND_RATE_LIMIT=5
EARN_TASKS_RATE_LIMIT=30
EARN_TASK_RESULTS_RATE_LIMIT=30
EARN_TASK_RESULT_MAX_ERROR_LENGTH=500
EARN_FAILED_DEVICE_RATE_LIMIT=3
EARN_API_ACCESS_LOG_RETENTION_DAYS=90
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+24
View File
@@ -0,0 +1,24 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db
+5
View File
@@ -0,0 +1,5 @@
[Sun Aug 9 19:45:09 2026] PHP 8.4.0 Development Server (http://127.0.0.1:8797) started
[Sun Aug 9 19:45:11 2026] 127.0.0.1:33023 Accepted
[Sun Aug 9 19:45:11 2026] 127.0.0.1:33023 Closing
[Sun Aug 9 19:45:11 2026] 127.0.0.1:33024 Accepted
[Sun Aug 9 19:45:11 2026] 127.0.0.1:33024 Closing
View File
View File
+5
View File
@@ -0,0 +1,5 @@
INFO Server running on [http://127.0.0.1:8796].
Press Ctrl+C to stop the server
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

+108
View File
@@ -0,0 +1,108 @@
# TrafficPumper — AŞAMA 1
Laravel 13 tabanlı uygulama temeli; Filament 5, Livewire, Sanctum, Horizon,
Redis/Predis, Bootstrap 5.3 ve Vite ile hazırlanmıştır.
Tema tercihi `trafficpumper.theme` anahtarında `light`, `dark` veya `system`
olarak tutulur. `resources/js/theme-manager.js` hem Blade yerleşiminde ilk boyadan
önce hem de Filament head hook'unda kullanılır. Filament'in kendi seçicisi ortak
tercihle senkronize edilir.
Yerel Windows ortamında Horizon worker çalıştırmak için gerekli `pcntl` ve `posix`
PHP uzantıları bulunmaz. Horizon, Linux üretim ortamı için kurulu ve yapılandırılmıştır.
```powershell
composer install
npm install
php artisan test
npm test
npm run build
```
API iş rotaları ileride `routes/api/v1.php` altında ve `/api/v1/...` önekiyle
eklenecektir.
## İlk süper admin
Gerçek parola seed dosyalarına yazılmaz. İlk yetkili hesabı interaktif ve gizli
parola girişiyle oluşturun:
```powershell
php artisan trafficpumper:make-super-admin
```
Public `/register` yalnızca `customer` rolü oluşturur. `/dashboard` aktif ve
e-postası doğrulanmış oturum; `/admin` ayrıca `admin.access` yetkisi ve Filament
`canAccessPanel()` kontrolü gerektirir.
## AŞAMA 3 — Proje / Website yönetimi
Müşteri proje ekranları /projects altında çalışır. Projeler dahili integer anahtarın
yanında URL'lerde kullanılan ULID taşır ve user_id + domain birleşik benzersizlik
kuralıyla korunur. Yalnızca HTTP/HTTPS kabul eden merkezi URL normalizasyonu
www. önekini kaldırır; localhost ve IP adreslerini reddeder. Kayıt sırasında
herhangi bir ağ isteği yapılmaz.
Müşteri işlemleri sorgu seviyesinde oturum sahibine sınırlandırılır ve ayrıca
ProjectPolicy ile yetkilendirilir. Başka müşteriye ait bir ULID için tutarlı 404
döner. Silme yerine açık arşivleme/geri yükleme uçları kullanılır. Backoffice
liste/detay görünümü /admin/projects rotasındadır; sahip değiştirme ve hard delete
sunulmaz.
Doğrulama komutları: php artisan migrate, php artisan test, npm test ve npm run build.
## AŞAMA 4 — Keyword ve SERP veri altyapısı
Project → Keyword ilişkisi ULID public kimlikleri ve User → Project → Keyword
sahiplik zinciriyle kuruldu. Hedef kimliği normalize edilmiş ifade, arama motoru,
ülke, dil ve cihaz birleşimidir; aynı Project içinde DB seviyesinde benzersizdir.
Whitespace tekilleştirilir, locale bağımsız küçük harf dönüşümü uygulanır ve Türkçe
karakterler korunur. Arşivlenen hedef kimliği tutulur ve yeni kayıt yerine restore
edilir. Customer nested rotaları sorgu seviyesinde sahipliğe, Filament görünümü ise
keywords.view / keywords.manage izinlerine bağlıdır.
serp_checks hedef snapshotlarını, serp_results organik sonuç satırlarını saklayacak
veri altyapısıdır. Bu aşamada dış arama motoru sorgusu, scraping, provider, queue,
scheduler veya customer SERP endpointi eklenmemiştir.
## AŞAMA 5 — Provider-neutral SERP çalıştırma
SERP sağlayıcıları SerpProvider sözleşmesi üzerinden registry tarafından çözülür;
domain kodu provider SDK veya response biçimi bilmez. SerpQuery, SerpResponse ve
SerpResultData immutable sınırı oluşturur. Fake provider yalnız testing veya açıkça
izin verilmiş local ortamda çalışır; production ortamında sonuç üretemez. Bu aşamada
production provider ve dış SERP HTTP isteği yoktur.
Kontroller serp kuyruğunda RunSerpCheck job'ıyla çalışır: 3 deneme, 60 saniye
timeout ve 10/60/300 saniye backoff. Transient timeout/rate-limit hataları yeniden
denenir; kalıcı hatalar güvenli kodla failed olur. Keyword başına atomik cache lock,
DB active-slot unique constraint ve job lock birlikte duplicate/eşzamanlı çalışmayı
engeller. Provider çağrısı transaction dışında, sonuç tamamlama transaction içindedir.
Manuel kontroller kullanıcı rate-limit'i, 15 dakika keyword cooldown'u, günlük 25
kontrol kotası ve pending/running engeline tabidir. Otomatik takip daily/weekly olup
UTC next_check_at kullanır. Scheduler her dakika due kayıtları chunk ile kuyruğa
alır, overlap ve tek-sunucu kilidi kullanır. Domain eşleşmesi exact domain veya
gerçek subdomain kabul eder; www eşdeğerdir, metinsel sahte suffix eşleşmez.
Production provider eklemek için SerpProvider adaptörü yazılır, service container
registry'sine kaydedilir ve SERP_PROVIDER değeri adaptör anahtarına ayarlanır.
## AŞAMA 6 — DataForSEO Google Organic adaptörü
Production adaptörü `dataforseo` anahtarıyla kayıtlıdır. Manuel kontroller Google Organic Live Advanced, zamanlanmış daily/weekly kontroller Standard `task_post` ve gecikmeli `task_get/advanced` poll akışını kullanır. Payload ülke, dil ve cihaz allowlist değerlerinden sunucu tarafında üretilir; `depth` daima 10'dur. Organik sonuç sırası DataForSEO `rank_group` alanından normalize edilir; sağlayıcıya özgü JSON domain modeline veya veritabanına yazılmaz.
HTTP istemcisi yalnız `https://api.dataforseo.com` taban adresini, Basic Auth kimlik bilgilerini, TLS doğrulamasını, bağlantı/istek timeout'larını ve redirectsiz çağrıları kullanır. HTTP status ile DataForSEO `status_code` zarfı ayrı doğrulanır. Kimlik bilgileri yalnız environment/config üzerinden alınır ve loglanmaz.
Kurulum güvenli varsayılanla gelir: `SERP_CHECKS_ENABLED=false`. Production kimlik bilgileri girilse bile operasyonel onay verilmeden bu değer açılmamalıdır. Gerçek API çağrısı testlerde yapılmaz; `Http::preventStrayRequests()` ve `Http::fake()` kullanılır.
## ASAMA 7 — Kontrollu production aktivasyonu
SERP operasyonu disabled, validation_only, canary, enabled ve DB kill-switch ile emergency_stopped durumlarini ayirir. Normal ucretli akis icin hem SERP_CHECKS_ENABLED=true hem SERP_OPERATION_MODE=enabled gerekir; bilinmeyen durum fail-closed davranir.
Maliyetler mikro-USD ledgerda UTC gunluk/aylik atomik rezervasyon ve gercek maliyetle tekil reconciliation kullanir. Kill switch ve circuit breaker yeni Live/submit islemlerini engeller, mevcut Standard task polling sonucunu kurtarir. Remote health ve canary execute calistirilmamistir.
Filament /admin/serp-operations support/admin icin salt okunur, emergency stop/clear yalniz super_admin icindir. Ayrintilar: docs/production-serp-runbook.md.
## AŞAMA 8 — Güvenli production hazırlığı
Dış HTTP yapmayan production preflight ve 24 saatlik scrubbed operasyon snapshot komutları ile atomik 5/25/100 ramp-up kapısı eklendi. Başlangıç ve mevcut durum güvenli biçimde `disabled` kalır; remote health, tek canary ve limited production birbirinden ayrı açık onay kapılarıdır. Deployment ve recovery ayrıntıları `docs/production-serp-runbook.md` içindedir.
@@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Models\SerpOperationAudit;
use App\Serp\Data\SerpQuery;
use App\Serp\Exceptions\SerpProviderException;
use App\Serp\Operations\ActivationManager;
use App\Serp\Operations\BudgetManager;
use App\Serp\Operations\CircuitBreaker;
use App\Serp\Operations\OperationAuditLogger;
use App\Serp\Operations\OperationBlockedException;
use App\Serp\SerpProviderRegistry;
use Illuminate\Console\Command;
use Throwable;
class DataForSeoCanaryCommand extends Command
{
protected $signature = 'serp:dataforseo:canary {--execute : Tek ücretli canary isteğini bilinçli olarak çalıştır} {--json}';
protected $description = 'DataForSEO canary kontrolünü varsayılan olarak dry-run yapar';
public function handle(ActivationManager $activation, BudgetManager $budget, CircuitBreaker $circuit, SerpProviderRegistry $providers, OperationAuditLogger $audit): int
{
$base = ['mode' => $activation->state()->value, 'execute' => (bool) $this->option('execute'), 'depth' => 10, 'provider' => 'dataforseo'];
if (! $this->option('execute')) {
$base['status'] = 'dry_run';
$this->output($base);
return self::SUCCESS;
}try {
$activation->assertPaid(true);
$circuit->assertClosed();
$count = SerpOperationAudit::where('action', 'canary_executed')->where('created_at', '>=', now('UTC')->startOfDay())->count();
if ($count >= (int) config('serp.canary.daily_limit')) {
throw new OperationBlockedException('canary_limit');
}$key = 'canary:'.now('UTC')->format('Y-m-d');
$budget->reserve($key, 'live');
$q = new SerpQuery((string) config('serp.canary.keyword'), 'google', (string) config('serp.canary.country'), (string) config('serp.canary.language'), (string) config('serp.canary.device'), 10);
$started = microtime(true);
$r = $providers->resolve('dataforseo')->search($q);
$budget->reconcile($key, $r->cost, $r->referenceId);
$circuit->success();
$audit->record('canary_executed', ['provider' => 'dataforseo', 'mode' => 'live', 'duration_ms' => (int) ((microtime(true) - $started) * 1000), 'result_count' => count($r->results)]);
$base += ['status' => 'completed', 'result_count' => count($r->results), 'duration_ms' => (int) ((microtime(true) - $started) * 1000)];
$this->output($base);
return self::SUCCESS;
} catch (Throwable $e) {
$base += ['status' => 'blocked_or_failed', 'category' => $e instanceof SerpProviderException ? $e->safeCode() : 'unexpected'];
$this->output($base);
return self::FAILURE;
}
}
private function output(array $data): void
{
$this->option('json') ? $this->line(json_encode($data)) : $this->info('Canary: '.$data['status']);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use App\Serp\Exceptions\SerpProviderException;
use App\Serp\Operations\DataForSeoHealthService;
use Illuminate\Console\Command;
use Throwable;
class DataForSeoHealthCommand extends Command
{
protected $signature = 'serp:dataforseo:health {--remote : Açık izinle ücretsiz hesap/bakiye doğrulaması} {--json}';
protected $description = 'DataForSEO yapılandırmasını güvenli biçimde doğrular';
public function handle(DataForSeoHealthService $health): int
{
try {
$data = $health->validateConfig();
if ($this->option('remote')) {
$r = $health->remote();
$data += ['remote' => 'ok', 'balance_micros' => $r->balanceMicros, 'status_code' => $r->statusCode, 'checked_at' => $r->checkedAt->format(DATE_ATOM)];
} else {
$data += ['remote' => 'not_requested'];
}$this->option('json') ? $this->line(json_encode($data, JSON_THROW_ON_ERROR)) : $this->info('DataForSEO health: OK (uzak çağrı: '.$data['remote'].')');
return self::SUCCESS;
} catch (Throwable $e) {
$safe = ['status' => 'failed', 'category' => $e instanceof SerpProviderException ? $e->safeCode() : 'configuration_invalid'];
$this->option('json') ? $this->line(json_encode($safe)) : $this->error('DataForSEO health doğrulanamadı.');
return self::FAILURE;
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use App\Enums\KeywordStatus;
use App\Enums\UserStatus;
use App\Models\Keyword;
use App\Serp\Operations\ActivationManager;
use App\Services\NextCheckAtCalculator;
use App\Services\SerpCheckCreator;
use Illuminate\Console\Command;
class DispatchDueSerpChecks extends Command
{
protected $signature = 'serp:dispatch-due';
protected $description = 'Due SERP kontrollerini kuyruğa alır';
public function handle(SerpCheckCreator $creator, NextCheckAtCalculator $next, ActivationManager $activation): int
{
if (! config('serp.enabled') || ! config('serp.provider') || (config('serp.provider') === 'dataforseo' && ! $activation->allowsNormal())) {
return self::SUCCESS;
}Keyword::query()->with('project.user')->where('tracking_enabled', true)->where('status', KeywordStatus::Active)->where('next_check_at', '<=', now())->whereHas('project.user', fn ($q) => $q->where('status', UserStatus::Active))->whereDoesntHave('serpChecks', fn ($q) => $q->whereIn('status', ['pending', 'running']))->chunkById(100, function ($keywords) use ($creator, $next) {
foreach ($keywords as $k) {
try {
$creator->create($k->project->user, $k, true, 'scheduled');
$k->forceFill(['last_queued_at' => now(), 'next_check_at' => $next->calculate($k->check_frequency)])->save();
} catch (\Throwable) {
}
}
});
return self::SUCCESS;
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Console\Commands;
use App\Models\Role;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
class MakeSuperAdmin extends Command
{
protected $signature = 'trafficpumper:make-super-admin';
protected $description = 'İlk TrafficPumper süper admin hesabını güvenli şekilde oluşturur';
public function handle(): int
{
$name = (string) $this->ask('Ad Soyad');
$email = mb_strtolower((string) $this->ask('E-posta'));
if (User::where('email', $email)->exists()) {
$this->error('Bu e-posta zaten kullanımda. Mevcut hesap otomatik olarak yükseltilmedi.');
return self::FAILURE;
}
$password = (string) $this->secret('Şifre');
$confirmation = (string) $this->secret('Şifre tekrar');
$validator = Validator::make(compact('name', 'email', 'password') + ['password_confirmation' => $confirmation], [
'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return self::FAILURE;
}
$user = User::create(['name' => $name, 'email' => $email, 'password' => Hash::make($password)]);
$user->forceFill(['email_verified_at' => now()])->saveQuietly();
$user->roles()->attach(Role::where('name', 'super_admin')->sole());
$this->info('Süper admin hesabı oluşturuldu.');
return self::SUCCESS;
}
}
@@ -0,0 +1,264 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
use Throwable;
class MigrateSqliteToMariaDb extends Command
{
protected $signature = 'trafficpumper:migrate-sqlite-to-mariadb {--execute} {--chunk=500} {--include-operational} {--replace-migrated-seeds}';
protected $description = 'Inspect or copy legacy SQLite business data to an already migrated MariaDB database';
private const OPERATIONAL = ['cache', 'cache_locks', 'sessions', 'jobs', 'job_batches', 'failed_jobs'];
/** @var array<string, true> */
private array $alreadyPresent = [];
/** @var array<string, list<string>> */
private array $replaceSeeds = [];
public function handle(): int
{
try {
$source = DB::connection('sqlite_legacy');
$target = DB::connection('mysql');
$this->assertConnections($source, $target);
$tables = $this->orderedTables($source);
$this->assertTargetSchema($target, $tables);
$this->reportPlan($source, $target, $tables);
if (! $this->option('execute')) {
$this->info('Dry-run completed. No rows were written. Use --execute to transfer.');
return self::SUCCESS;
}
$this->assertEmptyTarget($target, $tables);
$this->copy($source, $target, $tables);
$this->verify($source, $target, $tables);
$this->info('Transfer and integrity verification completed successfully.');
return self::SUCCESS;
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
}
private function assertConnections(Connection $source, Connection $target): void
{
if ($source->getDriverName() !== 'sqlite') {
throw new RuntimeException('sqlite_legacy must use SQLite.');
}
if (! in_array($target->getDriverName(), ['mysql', 'mariadb'], true)) {
throw new RuntimeException('The mysql connection must point to MariaDB/MySQL.');
}
$path = (string) config('database.connections.sqlite_legacy.database');
if (! is_file($path) || ! is_readable($path)) {
throw new RuntimeException('The legacy SQLite database is missing or unreadable.');
}
$server = $target->selectOne('select version() version, @@character_set_database charset, @@collation_database collation');
if ($server->charset !== 'utf8mb4' || $server->collation !== 'utf8mb4_unicode_ci') {
throw new RuntimeException('Target database must use utf8mb4 and utf8mb4_unicode_ci.');
}
$this->info("Target server: {$server->version}; {$server->charset}/{$server->collation}");
}
/** @return list<string> */
private function orderedTables(Connection $source): array
{
$excluded = ['migrations', ...($this->option('include-operational') ? [] : self::OPERATIONAL)];
$tables = collect(Schema::connection($source->getName())->getTables())
->map(fn (array $table): string => (string) ($table['name'] ?? ''))
->filter(fn (string $table): bool => $table !== '' && ! in_array($table, $excluded, true))->values()->all();
$pending = array_fill_keys($tables, true);
$ordered = [];
while ($pending !== []) {
$progress = false;
foreach (array_keys($pending) as $table) {
$dependencies = collect(Schema::connection($source->getName())->getForeignKeys($table))
->map(fn (array $key): string => (string) ($key['foreign_table'] ?? ''))
->filter(fn (string $dependency): bool => isset($pending[$dependency]) && $dependency !== $table);
if ($dependencies->isNotEmpty()) {
continue;
}
$ordered[] = $table;
unset($pending[$table]);
$progress = true;
}
if (! $progress) {
throw new RuntimeException('Unresolved foreign-key dependencies: '.implode(', ', array_keys($pending)));
}
}
return $ordered;
}
/** @param list<string> $tables */
private function assertTargetSchema(Connection $target, array $tables): void
{
$missing = collect($tables)->reject(fn (string $table): bool => Schema::connection($target->getName())->hasTable($table));
if ($missing->isNotEmpty()) {
throw new RuntimeException('Run target migrations first. Missing tables: '.$missing->implode(', '));
}
}
/** @param list<string> $tables */
private function reportPlan(Connection $source, Connection $target, array $tables): void
{
$this->table(['Table', 'SQLite', 'MariaDB'], collect($tables)->map(fn (string $table): array => [$table, $source->table($table)->count(), $target->table($table)->count()])->all());
if (! $this->option('include-operational')) {
$this->warn('Operational tables are excluded; database sessions will require users to sign in again.');
}
}
/** @param list<string> $tables */
private function assertEmptyTarget(Connection $target, array $tables): void
{
$source = DB::connection('sqlite_legacy');
foreach ($tables as $table) {
if (! $target->table($table)->exists()) {
continue;
}
if ($source->table($table)->count() === $target->table($table)->count()
&& hash_equals($this->tableChecksum($source, $table), $this->tableChecksum($target, $table))) {
$this->alreadyPresent[$table] = true;
continue;
}
$primary = collect(Schema::connection($source->getName())->getIndexes($table))->first(fn (array $index): bool => (bool) ($index['primary'] ?? false));
$primaryColumns = (array) ($primary['columns'] ?? []);
if ($this->option('replace-migrated-seeds') && $primaryColumns !== []
&& $source->table($table)->count() === $target->table($table)->count()
&& hash_equals($this->keyChecksum($source, $table, $primaryColumns), $this->keyChecksum($target, $table, $primaryColumns))) {
$this->replaceSeeds[$table] = $primaryColumns;
continue;
}
throw new RuntimeException("Target table {$table} is non-empty and does not exactly match the source.");
}
}
/** @param list<string> $tables */
private function copy(Connection $source, Connection $target, array $tables): void
{
$chunk = max(1, min(5000, (int) $this->option('chunk')));
foreach ($tables as $table) {
if (isset($this->alreadyPresent[$table])) {
$this->line("Kept identical migrated seed data in {$table}");
continue;
}
$columns = collect(Schema::connection($source->getName())->getColumns($table))->pluck('name')->all();
$primary = collect(Schema::connection($source->getName())->getIndexes($table))->first(fn (array $index): bool => (bool) ($index['primary'] ?? false));
$order = ((array) ($primary['columns'] ?? []))[0] ?? $columns[0] ?? null;
if ($order === null) {
continue;
}
$target->transaction(function () use ($source, $target, $table, $order, $chunk): void {
$source->table($table)->orderBy($order)->chunk($chunk, function ($rows) use ($target, $table): void {
$data = $rows->map(fn (object $row): array => (array) $row)->all();
if ($data !== []) {
if (isset($this->replaceSeeds[$table])) {
$target->table($table)->upsert($data, $this->replaceSeeds[$table]);
} else {
$target->table($table)->insert($data);
}
}
});
});
$this->line("Copied {$table}");
}
}
/** @param list<string> $tables */
private function verify(Connection $source, Connection $target, array $tables): void
{
foreach ($tables as $table) {
if ($source->table($table)->count() !== $target->table($table)->count()) {
throw new RuntimeException("Row-count mismatch for {$table}.");
}
$columns = collect(Schema::connection($source->getName())->getColumns($table))->pluck('name');
foreach ($columns->filter(fn (string $column): bool => preg_match('/(^|_)(password|token|ulid|uuid)(_hash)?$/', $column) === 1) as $column) {
if (! hash_equals($this->checksum($source, $table, $column), $this->checksum($target, $table, $column))) {
throw new RuntimeException("Sensitive checksum mismatch for {$table}.{$column}.");
}
}
}
foreach ($tables as $table) {
foreach (Schema::connection($target->getName())->getForeignKeys($table) as $key) {
$local = (array) ($key['columns'] ?? []);
$foreign = (array) ($key['foreign_columns'] ?? []);
$parent = (string) ($key['foreign_table'] ?? '');
if (count($local) !== 1 || count($foreign) !== 1 || $parent === '') {
continue;
}
$orphans = $target->table("{$table} as child")
->leftJoin("{$parent} as parent", "child.{$local[0]}", '=', "parent.{$foreign[0]}")
->whereNotNull("child.{$local[0]}")->whereNull("parent.{$foreign[0]}")->count();
if ($orphans > 0) {
throw new RuntimeException("Foreign-key orphans found in {$table}.{$local[0]}.");
}
}
}
}
private function checksum(Connection $connection, string $table, string $column): string
{
$hash = hash_init('sha256');
$connection->table($table)->orderBy($column)->select($column)->chunk(1000, function ($rows) use ($hash, $column): void {
foreach ($rows as $row) {
$value = (string) $row->{$column};
hash_update($hash, strlen($value).':'.$value."\n");
}
});
return hash_final($hash);
}
private function tableChecksum(Connection $connection, string $table): string
{
$columns = collect(Schema::connection($connection->getName())->getColumns($table))->pluck('name')->all();
$primary = collect(Schema::connection($connection->getName())->getIndexes($table))->first(fn (array $index): bool => (bool) ($index['primary'] ?? false));
$order = (array) ($primary['columns'] ?? $columns);
$hash = hash_init('sha256');
$query = $connection->table($table)->select($columns);
foreach ($order as $column) {
$query->orderBy($column);
}
$query->chunk(1000, function ($rows) use ($hash, $columns): void {
foreach ($rows as $row) {
foreach ($columns as $column) {
$value = $row->{$column};
hash_update($hash, $value === null ? '-1:' : strlen((string) $value).':'.(string) $value);
}
hash_update($hash, "\n");
}
});
return hash_final($hash);
}
/** @param list<string> $columns */
private function keyChecksum(Connection $connection, string $table, array $columns): string
{
$hash = hash_init('sha256');
$query = $connection->table($table)->select($columns);
foreach ($columns as $column) {
$query->orderBy($column);
}
foreach ($query->get() as $row) {
foreach ($columns as $column) {
hash_update($hash, strlen((string) $row->{$column}).':'.(string) $row->{$column});
}
hash_update($hash, "\n");
}
return hash_final($hash);
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Jobs\PollSerpCheck;
use App\Models\SerpCheck;
use App\Serp\Operations\OperationAuditLogger;
use Illuminate\Console\Command;
class ReconcileSerpChecksCommand extends Command
{
protected $signature = 'serp:reconcile {--execute : Düzeltmeleri uygula}';
protected $description = 'Takılı SERP kontrollerini güvenli biçimde uzlaştırır';
public function handle(OperationAuditLogger $audit): int
{
$count = 0;
SerpCheck::query()->whereIn('status', ['pending', 'running'])->where('updated_at', '<', now()->subMinutes((int) config('serp.stuck_after_minutes')))->chunkById(100, function ($checks) use (&$count, $audit) {
foreach ($checks as $c) {
$count++;
if (! $this->option('execute')) {
continue;
}if ($c->execution_mode === 'standard' && $c->provider_reference) {
PollSerpCheck::dispatch($c->ulid)->onQueue(config('serp.queue'));
$audit->record('reconciliation_dispatched', ['check_ulid' => $c->ulid, 'mode' => 'standard']);
} elseif (! $c->provider_reference) {
$c->fail('stuck_without_task', 'SERP kontrolü zamanında tamamlanamadı.');
$audit->record('reconciliation_failed_stuck', ['check_ulid' => $c->ulid, 'error_category' => 'poll_exhausted']);
}
}
});
$this->info(($this->option('execute') ? 'İşlenen' : 'Bulunan').' kayıt: '.$count);
return self::SUCCESS;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Serp\Operations\SerpOperationsMetrics;
use Illuminate\Console\Command;
class SerpOperationsSnapshotCommand extends Command
{
protected $signature = 'serp:operations:snapshot {--window=24h} {--json}';
protected $description = 'Scrub edilmiş SERP operasyon özetini gösterir';
public function handle(SerpOperationsMetrics $metrics): int
{
if ($this->option('window') !== '24h') {
$this->error('Desteklenen pencere: 24h');
return self::FAILURE;
}
$data = $metrics->summary() + ['window' => '24h', 'generated_at' => now('UTC')->toAtomString()];
$this->option('json') ? $this->line(json_encode($data, JSON_THROW_ON_ERROR)) : $this->table(['Alan', 'Değer'], collect($data)->except('budget')->map(fn ($v, $k) => [$k, is_scalar($v) || $v === null ? (string) $v : json_encode($v)])->values()->all());
return self::SUCCESS;
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Console\Commands;
use App\Models\SerpCheck;
use App\Models\SerpOperationState;
use App\Serp\Operations\ActivationManager;
use App\Serp\Operations\CircuitBreaker;
use App\Serp\Operations\DataForSeoHealthService;
use App\Serp\Operations\Money;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Throwable;
class SerpProductionPreflightCommand extends Command
{
protected $signature = 'serp:production:preflight {--json}';
protected $description = 'Production SERP geçiş kapılarını dış HTTP olmadan doğrular';
public function handle(ActivationManager $activation, DataForSeoHealthService $health, CircuitBreaker $circuit): int
{
$gates = [];
$gate = function (string $name, bool $pass, mixed $value = null, bool $critical = true) use (&$gates) {
$gates[$name] = ['status' => $pass ? 'pass' : 'fail', 'critical' => $critical, 'value' => $value];
};
$configured = (string) config('serp.dataforseo.login') !== '' && (string) config('serp.dataforseo.password') !== '';
$gate('environment', app()->environment('production'), app()->environment());
$gate('debug', ! config('app.debug'), config('app.debug') ? 'enabled' : 'disabled');
$gate('provider', config('serp.provider') === 'dataforseo', config('serp.provider'));
$state = $activation->state();
$consistent = (! config('serp.enabled') && in_array($state->value, ['disabled', 'validation_only', 'canary'], true)) || (config('serp.enabled') && in_array($state->value, ['canary', 'enabled', 'emergency_stopped'], true));
$gate('activation_consistency', $consistent, ['enabled' => (bool) config('serp.enabled'), 'mode' => $state->value]);
$gate('credentials', $configured, $configured ? 'configured' : 'not_configured');
try {
$local = $health->validateConfig();
$gate('provider_security', true, ['host' => $local['host'], 'tls' => $local['tls']]);
} catch (Throwable) {
$gate('provider_security', false, 'invalid');
}$pending = $this->pendingMigrations();
$gate('migrations', $pending === 0, ['pending' => $pending]);
try {
$lock = Cache::lock('serp:preflight-lock', 5);
$acquired = $lock->get();
if ($acquired) {
$lock->release();
}$gate('cache_lock', $acquired, config('cache.default'));
} catch (Throwable) {
$gate('cache_lock', false, 'unavailable');
}$gate('queue', in_array(config('queue.default'), ['redis', 'database'], true), ['connection' => config('queue.default'), 'serp_queue' => config('serp.queue')]);
$console = file_get_contents(base_path('routes/console.php'));
$gate('scheduler', str_contains($console, 'serp:dispatch-due') && str_contains($console, 'serp:reconcile'), ['defined' => true]);
$stateRow = SerpOperationState::find(1);
$gate('kill_switch', ! $stateRow?->emergency_stopped_at, $stateRow?->emergency_stopped_at ? 'active' : 'clear');
$gate('circuit', ! $circuit->open(), $circuit->open() ? 'open' : 'closed');
foreach (['daily_budget_usd', 'monthly_budget_usd', 'low_balance_usd', 'critical_balance_usd'] as $key) {
try {
$gate($key, Money::micros((string) config('serp.'.$key)) > 0, (string) config('serp.'.$key));
} catch (Throwable) {
$gate($key, false, 'invalid');
}
}$gate('depth', config('serp.dataforseo.result_depth') === 10, config('serp.dataforseo.result_depth'));
$gate('provider_modes', config('serp.manual_mode') === 'live' && config('serp.automatic_mode') === 'standard', ['manual' => config('serp.manual_mode'), 'automatic' => config('serp.automatic_mode')]);
$summary = ['status' => collect($gates)->contains(fn ($g) => $g['critical'] && $g['status'] === 'fail') ? 'failed' : 'passed', 'remote' => 'not_requested', 'gates' => $gates, 'operations' => ['last_health_at' => $stateRow?->last_health_at, 'last_provider_success_at' => $stateRow?->last_provider_success_at, 'stuck' => SerpCheck::whereIn('status', ['pending', 'running'])->where('updated_at', '<', now()->subMinutes((int) config('serp.stuck_after_minutes')))->count(), 'pending' => SerpCheck::where('status', 'pending')->count(), 'running' => SerpCheck::where('status', 'running')->count()]];
$this->option('json') ? $this->line(json_encode($summary, JSON_THROW_ON_ERROR)) : $this->line('Preflight: '.$summary['status']);
return $summary['status'] === 'passed' ? self::SUCCESS : self::FAILURE;
}
private function pendingMigrations(): int
{
if (! Schema::hasTable('migrations')) {
return count(glob(database_path('migrations/*.php')));
}$ran = DB::table('migrations')->pluck('migration')->all();
return count(array_filter(glob(database_path('migrations/*.php')), fn ($f) => ! in_array(pathinfo($f, PATHINFO_FILENAME), $ran, true)));
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace App\Enums;
enum CheckFrequency:string {case Daily='daily';case Weekly='weekly';public function label():string{return match($this){self::Daily=>__('states.daily'),self::Weekly=>__('states.weekly')};}}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum EarnApiTokenStatus: string
{
case Active = 'active';
case Inactive = 'inactive';
case Expired = 'expired';
case Revoked = 'revoked';
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum EarnLegalDocumentType: string
{
case Terms = 'terms';
case PrivacyNotice = 'privacy_notice';
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum EarnMemberStatus: string
{
case Pending = 'pending';
case Active = 'active';
case Suspended = 'suspended';
case Closed = 'closed';
}
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace App\Enums;
enum KeywordDevice:string { case Desktop='desktop'; case Mobile='mobile'; public function label():string{return match($this){self::Desktop=>__('states.desktop'),self::Mobile=>__('states.mobile')};} public static function options():array{return collect(self::cases())->mapWithKeys(fn($v)=>[$v->value=>$v->label()])->all();} }
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace App\Enums;
enum KeywordStatus:string { case Active='active'; case Inactive='inactive'; case Archived='archived'; public function label():string{return match($this){self::Active=>__('states.active'),self::Inactive=>__('states.inactive'),self::Archived=>__('states.archived')};} }
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum ProjectStatus: string
{
case Active = 'active';
case Archived = 'archived';
public function label(): string
{
return match ($this) {
self::Active => __('states.active'),
self::Archived => __('states.archived'),
};
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum RoleName: string
{
case SuperAdmin = 'super_admin';
case Admin = 'admin';
case Support = 'support';
case Customer = 'customer';
public function label(): string
{
return match ($this) {
self::SuperAdmin => 'Süper Admin', self::Admin => 'Admin',
self::Support => 'Destek', self::Customer => 'Müşteri',
};
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace App\Enums;
enum SearchEngine:string { case Google='google'; case Bing='bing'; public function label():string{return match($this){self::Google=>'Google',self::Bing=>'Bing'};} public static function options():array{return collect(self::cases())->mapWithKeys(fn($v)=>[$v->value=>$v->label()])->all();} }
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace App\Enums;
enum SerpCheckStatus:string {case Pending='pending';case Running='running';case Completed='completed';case Failed='failed';public function label():string{return match($this){self::Pending=>__('states.pending'),self::Running=>__('states.running'),self::Completed=>__('states.completed'),self::Failed=>__('states.failed')};}}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum UserStatus: string
{
case Active = 'active';
case Suspended = 'suspended';
public function label(): string
{
return match ($this) {
self::Active => __('states.active'),
self::Suspended => __('states.suspended'),
};
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Filament\Pages\Auth;
use Filament\Auth\Pages\Login;
use Illuminate\Validation\ValidationException;
class AdminLogin extends Login
{
protected function throwFailureValidationException(): never
{
throw ValidationException::withMessages(['data.email' => __('auth.portal_denied')]);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Filament\Pages;
use App\Serp\Operations\KillSwitch;
use App\Serp\Operations\SerpOperationsMetrics;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
class SerpOperations extends Page
{
protected string $view = 'filament.pages.serp-operations';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
public static function getNavigationLabel(): string
{
return __('ui.serp_operations');
}
public function getTitle(): string
{
return __('ui.serp_operations');
}
public array $summary = [];
public static function canAccess(): bool
{
return auth()->user()?->hasPermission('serp_operations.view') ?? false;
}
public function mount(SerpOperationsMetrics $metrics): void
{
$this->summary = $metrics->summary();
}
public function emergencyStop(KillSwitch $kill): void
{
$this->authorizeManage();
$kill->activate('manual_emergency_stop', auth()->user());
$this->summary = app(SerpOperationsMetrics::class)->summary();
Notification::make()->title(__('operations.stopped'))->warning()->send();
}
public function clearEmergencyStop(KillSwitch $kill): void
{
$this->authorizeManage();
$kill->clear(auth()->user(), 'manual_clear');
$this->summary = app(SerpOperationsMetrics::class)->summary();
Notification::make()->title(__('operations.resumed'))->success()->send();
}
private function authorizeManage(): void
{
abort_unless(auth()->user()?->hasRole('super_admin') && auth()->user()?->hasPermission('serp_operations.emergency_stop'), 403);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Filament\Resources\Keywords;
use App\Filament\Resources\Keywords\Pages\ListKeywords;
use App\Filament\Resources\Keywords\Pages\ViewKeyword;
use App\Filament\Resources\Keywords\Schemas\KeywordInfolist;
use App\Filament\Resources\Keywords\Tables\KeywordsTable;
use App\Models\Keyword;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class KeywordResource extends Resource
{
protected static ?string $model = Keyword::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedMagnifyingGlass;
public static function getNavigationLabel(): string
{
return __('ui.keywords');
}
public static function getModelLabel(): string
{
return __('resources.keyword');
}
public static function getPluralModelLabel(): string
{
return __('ui.keywords');
}
public static function infolist(Schema $s): Schema
{
return KeywordInfolist::configure($s);
}
public static function table(Table $t): Table
{
return KeywordsTable::configure($t);
}
public static function getPages(): array
{
return ['index' => ListKeywords::route('/'), 'view' => ViewKeyword::route('/{record}')];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Keywords\Pages;
use App\Filament\Resources\Keywords\KeywordResource;
use Filament\Resources\Pages\ListRecords;
class ListKeywords extends ListRecords
{
protected static string $resource = KeywordResource::class;
protected function getHeaderActions(): array
{
return [];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Keywords\Pages;
use App\Filament\Resources\Keywords\KeywordResource;
use Filament\Resources\Pages\ViewRecord;
class ViewKeyword extends ViewRecord
{
protected static string $resource = KeywordResource::class;
protected function getHeaderActions(): array
{
return [];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\Keywords\Schemas;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class KeywordInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
TextEntry::make('phrase')->label(__('admin.keyword')),
TextEntry::make('project.name')->label(__('admin.project')),
TextEntry::make('project.domain')->label(__('admin.domain')),
TextEntry::make('project.user.email')->label(__('admin.customer')),
TextEntry::make('search_engine')->label(__('admin.engine'))->formatStateUsing(fn ($state) => $state->label()),
TextEntry::make('country_code')->label(__('admin.country')),
TextEntry::make('language_code')->label(__('admin.language')),
TextEntry::make('device')->label(__('admin.device'))->formatStateUsing(fn ($state) => $state->label()),
TextEntry::make('status')->label(__('admin.status'))->badge()->formatStateUsing(fn ($state) => $state->label()),
TextEntry::make('ulid')->label('ULID'),
]);
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Filament\Resources\Keywords\Tables;
use App\Enums\KeywordStatus;
use App\Models\Keyword;
use App\Support\KeywordTargetOptions;
use Filament\Actions\Action;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class KeywordsTable
{
public static function configure(Table $table): Table
{
return $table->modifyQueryUsing(fn ($query) => $query->with('project.user'))
->columns([
TextColumn::make('phrase')->label(__('admin.keyword'))->searchable()->sortable(),
TextColumn::make('project.name')->label(__('admin.project'))->searchable(),
TextColumn::make('project.domain')->label(__('admin.domain'))->searchable(),
TextColumn::make('project.user.email')->label(__('admin.customer'))->searchable(),
TextColumn::make('search_engine')->label(__('admin.engine'))->formatStateUsing(fn ($state) => $state->label()),
TextColumn::make('country_code')->label(__('admin.country')),
TextColumn::make('language_code')->label(__('admin.language')),
TextColumn::make('device')->label(__('admin.device'))->formatStateUsing(fn ($state) => $state->label()),
TextColumn::make('status')->label(__('admin.status'))->badge()->formatStateUsing(fn ($state) => $state->label()),
TextColumn::make('created_at')->label(__('admin.created_at'))->dateTime('d.m.Y H:i')->sortable(),
TextColumn::make('updated_at')->label(__('admin.updated_at'))->dateTime('d.m.Y H:i')->sortable(),
])
->filters([
SelectFilter::make('status')->options(['active' => __('states.active'), 'archived' => __('states.archived')]),
SelectFilter::make('search_engine')->options(['google' => 'Google', 'bing' => 'Bing']),
SelectFilter::make('country_code')->options(KeywordTargetOptions::countries()),
SelectFilter::make('language_code')->options(KeywordTargetOptions::languages()),
SelectFilter::make('device')->options(['desktop' => __('states.desktop'), 'mobile' => __('states.mobile')]),
])
->recordActions([
ViewAction::make(),
Action::make('archive')->label(__('admin.archive'))->requiresConfirmation()
->visible(fn (Keyword $record) => $record->status === KeywordStatus::Active && auth()->user()->can('archive', $record))
->action(fn (Keyword $record) => $record->archive()),
Action::make('restore')->label(__('admin.restore'))
->visible(fn (Keyword $record) => $record->status === KeywordStatus::Archived && auth()->user()->can('restore', $record))
->action(fn (Keyword $record) => $record->restoreKeyword()),
])->toolbarActions([]);
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Projects\Pages;
use App\Filament\Resources\Projects\ProjectResource;
use Filament\Resources\Pages\ListRecords;
class ListProjects extends ListRecords
{
protected static string $resource = ProjectResource::class;
protected function getHeaderActions(): array
{
return [];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Projects\Pages;
use App\Filament\Resources\Projects\ProjectResource;
use Filament\Resources\Pages\ViewRecord;
class ViewProject extends ViewRecord
{
protected static string $resource = ProjectResource::class;
protected function getHeaderActions(): array
{
return [];
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Filament\Resources\Projects;
use App\Filament\Resources\Projects\Pages\ListProjects;
use App\Filament\Resources\Projects\Pages\ViewProject;
use App\Filament\Resources\Projects\Schemas\ProjectInfolist;
use App\Filament\Resources\Projects\Tables\ProjectsTable;
use App\Models\Project;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class ProjectResource extends Resource
{
protected static ?string $model = Project::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedGlobeAlt;
public static function getNavigationLabel(): string
{
return __('ui.projects');
}
public static function getModelLabel(): string
{
return __('resources.project');
}
public static function getPluralModelLabel(): string
{
return __('ui.projects');
}
public static function infolist(Schema $schema): Schema
{
return ProjectInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return ProjectsTable::configure($table);
}
public static function getPages(): array
{
return ['index' => ListProjects::route('/'), 'view' => ViewProject::route('/{record}')];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Filament\Resources\Projects\Schemas;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class ProjectInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
TextEntry::make('name')->label(__('admin.project')),
TextEntry::make('domain')->label(__('admin.domain')),
TextEntry::make('url')->label(__('admin.root_url'))->url(fn ($record) => $record->url)->openUrlInNewTab(),
TextEntry::make('user.name')->label(__('admin.customer')),
TextEntry::make('user.email')->label(__('admin.email')),
TextEntry::make('status')->label(__('admin.status'))->badge()->formatStateUsing(fn ($state) => $state->label()),
TextEntry::make('ulid')->label(__('admin.project_id')),
TextEntry::make('created_at')->label(__('admin.created_at'))->dateTime('d.m.Y H:i'),
TextEntry::make('updated_at')->label(__('admin.updated_at'))->dateTime('d.m.Y H:i'),
]);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Filament\Resources\Projects\Tables;
use App\Enums\ProjectStatus;
use App\Models\Project;
use Filament\Actions\Action;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class ProjectsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')->label(__('admin.project'))->searchable()->sortable(),
TextColumn::make('domain')->label(__('admin.domain'))->searchable()->sortable(),
TextColumn::make('user.name')->label(__('admin.customer'))->searchable()->sortable(),
TextColumn::make('user.email')->label(__('admin.email'))->searchable(),
TextColumn::make('status')->label(__('admin.status'))->badge()->formatStateUsing(fn ($state) => $state->label()),
TextColumn::make('created_at')->label(__('admin.created_at'))->dateTime('d.m.Y H:i')->sortable(),
TextColumn::make('updated_at')->label(__('admin.updated_at'))->dateTime('d.m.Y H:i')->sortable(),
])
->filters([SelectFilter::make('status')->label(__('admin.status'))->options(['active' => __('states.active'), 'archived' => __('states.archived')])])
->recordActions([
ViewAction::make(),
Action::make('archive')->label(__('admin.archive'))->requiresConfirmation()
->visible(fn (Project $record): bool => $record->status === ProjectStatus::Active && auth()->user()->can('archive', $record))
->action(fn (Project $record) => $record->archive()),
Action::make('restore')->label(__('admin.restore'))
->visible(fn (Project $record): bool => $record->status === ProjectStatus::Archived && auth()->user()->can('restore', $record))
->action(fn (Project $record) => $record->restoreProject()),
])
->toolbarActions([]);
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\SerpChecks\Pages;
use App\Filament\Resources\SerpChecks\SerpCheckResource;
use Filament\Resources\Pages\ListRecords;
class ListSerpChecks extends ListRecords
{
protected static string $resource = SerpCheckResource::class;
protected function getHeaderActions(): array
{
return [];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\SerpChecks\Pages;
use App\Filament\Resources\SerpChecks\SerpCheckResource;
use Filament\Resources\Pages\ViewRecord;
class ViewSerpCheck extends ViewRecord
{
protected static string $resource = SerpCheckResource::class;
protected function getHeaderActions(): array
{
return [];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\SerpChecks\Schemas;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class SerpCheckInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
TextEntry::make('ulid')->label('ULID'),
TextEntry::make('phrase')->label(__('admin.keyword')),
TextEntry::make('keyword.project.name')->label(__('admin.project')),
TextEntry::make('keyword.project.domain')->label(__('admin.domain')),
TextEntry::make('keyword.project.user.email')->label(__('admin.customer')),
TextEntry::make('provider')->label(__('admin.provider'))->placeholder('—'),
TextEntry::make('status')->label(__('admin.status'))->formatStateUsing(fn ($state) => $state->label()),
TextEntry::make('result_count')->label(__('admin.result_count')),
TextEntry::make('error_code')->label(__('admin.error_code'))->placeholder('—'),
TextEntry::make('error_message')->label(__('admin.error_message'))->placeholder('—'),
]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Filament\Resources\SerpChecks;
use App\Filament\Resources\SerpChecks\Pages\ListSerpChecks;
use App\Filament\Resources\SerpChecks\Pages\ViewSerpCheck;
use App\Filament\Resources\SerpChecks\Schemas\SerpCheckInfolist;
use App\Filament\Resources\SerpChecks\Tables\SerpChecksTable;
use App\Models\SerpCheck;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class SerpCheckResource extends Resource
{
protected static ?string $model = SerpCheck::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
public static function getNavigationLabel(): string
{
return __('ui.serp_checks');
}
public static function getModelLabel(): string
{
return __('resources.serp_check');
}
public static function getPluralModelLabel(): string
{
return __('ui.serp_checks');
}
public static function infolist(Schema $s): Schema
{
return SerpCheckInfolist::configure($s);
}
public static function table(Table $t): Table
{
return SerpChecksTable::configure($t);
}
public static function getPages(): array
{
return ['index' => ListSerpChecks::route('/'), 'view' => ViewSerpCheck::route('/{record}')];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Filament\Resources\SerpChecks\Tables;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class SerpChecksTable
{
public static function configure(Table $table): Table
{
return $table->modifyQueryUsing(fn ($query) => $query->with('keyword.project.user'))
->columns([
TextColumn::make('ulid')->label('ULID')->searchable(),
TextColumn::make('phrase')->label(__('admin.keyword'))->searchable(),
TextColumn::make('keyword.project.name')->label(__('admin.project'))->searchable(),
TextColumn::make('keyword.project.domain')->label(__('admin.domain'))->searchable(),
TextColumn::make('keyword.project.user.email')->label(__('admin.customer'))->searchable(),
TextColumn::make('provider')->label(__('admin.provider'))->placeholder('—'),
TextColumn::make('status')->label(__('admin.status'))->badge()->formatStateUsing(fn ($state) => $state->label()),
TextColumn::make('result_count')->label(__('admin.result_count')),
TextColumn::make('created_at')->label(__('admin.created_at'))->dateTime('d.m.Y H:i')->sortable(),
])
->filters([
SelectFilter::make('status')->label(__('admin.status'))->options([
'pending' => __('states.pending'), 'running' => __('states.running'),
'completed' => __('states.completed'), 'failed' => __('states.failed'),
]),
SelectFilter::make('provider')->label(__('admin.provider')),
SelectFilter::make('search_engine')->label(__('admin.search_engine'))->options(['google' => 'Google', 'bing' => 'Bing']),
])->recordActions([ViewAction::make()])->toolbarActions([]);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Resources\Pages\CreateRecord;
use App\Models\User;
use App\Services\RoleAssignmentService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected function handleRecordCreation(array $data): Model
{
return DB::transaction(function () use ($data) {
$roles = $data['roles'] ?? []; unset($data['roles']);
$user = User::create($data);
return app(RoleAssignmentService::class)->update(auth()->user(), $user, [], $roles);
});
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use App\Services\RoleAssignmentService;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
public function getTitle(): string
{
return __('roles.edit_user');
}
protected function mutateFormDataBeforeFill(array $data): array
{
$data['roles'] = $this->record->roles()->pluck('roles.id')->all();
return $data;
}
protected function handleRecordUpdate(Model $record, array $data): Model
{
$roles = $data['roles'] ?? [];
unset($data['roles']);
return app(RoleAssignmentService::class)->update(auth()->user(), $record, $data, $roles);
}
protected function getHeaderActions(): array
{
return [];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Filament\Resources\Users\Schemas;
use App\Enums\RoleName;
use App\Enums\UserStatus;
use App\Models\Role;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class UserForm
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
TextInput::make('name')->label(__('roles.fields.name'))->required()->maxLength(255),
TextInput::make('email')->label(__('roles.fields.email'))->email()->required()->unique(ignoreRecord: true),
TextInput::make('password')->label(__('roles.fields.password'))->password()->revealable()->required(fn (string $operation): bool => $operation === 'create')->minLength(8)->dehydrated(fn (?string $state): bool => filled($state)),
Select::make('status')->label(__('roles.fields.status'))->required()->options(collect(UserStatus::cases())->mapWithKeys(fn ($s) => [$s->value => $s->label()])),
Select::make('roles')->label(__('roles.fields.roles'))->multiple()->searchable()->preload()->required()
->options(function (): array {
$q = Role::query();
if (! auth()->user()?->hasRole(RoleName::SuperAdmin)) {
$q->where('name', '!=', RoleName::SuperAdmin->value);
}
return $q->get()->sortBy(fn ($r) => array_search($r->name, ['super_admin', 'admin', 'support', 'customer'], true))->mapWithKeys(fn ($r) => [$r->id => __('roles.names.'.$r->name)])->all();
})
->helperText(__('roles.descriptions.help'))->visible(fn () => auth()->user()?->hasPermission('roles.assign') ?? false),
]);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\Users\Tables;
use App\Services\RoleAssignmentService;
use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Validation\ValidationException;
class UsersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')->label(__('roles.fields.name'))->searchable()->sortable(),
TextColumn::make('email')->label(__('roles.fields.email'))->searchable()->sortable(),
TextColumn::make('roles.name')->label(__('roles.fields.roles'))->badge()->formatStateUsing(fn ($state) => __('roles.names.'.$state))->wrap(),
TextColumn::make('status')->label(__('roles.fields.status'))->badge()->formatStateUsing(fn ($state) => $state->label()),
TextColumn::make('email_verified_at')->label(__('roles.fields.verified'))->dateTime('d.m.Y H:i')->placeholder(__('roles.not_verified'))->sortable(),
TextColumn::make('last_login_at')->label(__('roles.fields.last_login'))->dateTime('d.m.Y H:i')->placeholder(__('roles.never'))->sortable(),
TextColumn::make('created_at')->label(__('roles.fields.created_at'))->dateTime('d.m.Y H:i')->sortable(),
])
->filters([
SelectFilter::make('role')->label(__('roles.fields.roles'))->relationship('roles', 'name')->multiple()
->getOptionLabelFromRecordUsing(fn ($record) => __('roles.names.'.$record->name)),
SelectFilter::make('status')->label(__('roles.fields.status'))->options(['active' => __('states.active'), 'suspended' => __('states.suspended')]),
TernaryFilter::make('verified')->label(__('roles.fields.verified'))
->queries(true: fn ($query) => $query->whereNotNull('email_verified_at'), false: fn ($query) => $query->whereNull('email_verified_at')),
])
->recordActions(ActionGroup::make([
EditAction::make()->label(__('roles.edit_user')),
DeleteAction::make()
->label(__('roles.delete'))
->authorize('delete')
->modalHeading(__('roles.delete_confirmation'))
->modalDescription(fn ($record) => $record->name.' - '.$record->email)
->modalSubmitActionLabel(__('roles.delete_user'))
->modalCancelActionLabel(__('roles.cancel'))
->successNotificationTitle(__('roles.deleted'))
->action(function ($record): void {
try {
app(RoleAssignmentService::class)->delete(auth()->user(), $record);
Notification::make()->success()->title(__('roles.deleted'))->send();
} catch (ValidationException $exception) {
Notification::make()->danger()->title(collect($exception->errors())->flatten()->first() ?? __('roles.delete_failed'))->send();
}
}),
]))
->toolbarActions([]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Filament\Resources\Users;
use App\Filament\Resources\Users\Pages\CreateUser;
use App\Filament\Resources\Users\Pages\EditUser;
use App\Filament\Resources\Users\Pages\ListUsers;
use App\Filament\Resources\Users\Schemas\UserForm;
use App\Filament\Resources\Users\Tables\UsersTable;
use App\Models\User;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function getNavigationLabel(): string { return __('ui.users'); }
public static function getModelLabel(): string { return __('resources.user'); }
public static function getPluralModelLabel(): string { return __('ui.users'); }
public static function form(Schema $schema): Schema
{
return UserForm::configure($schema);
}
public static function table(Table $table): Table
{
return UsersTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListUsers::route('/'),
'create' => CreateUser::route('/create'),
'edit' => EditUser::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\RoleAssignmentService;
use Illuminate\Http\RedirectResponse;
class UserDeletionController extends Controller
{
public function __invoke(User $user, RoleAssignmentService $service): RedirectResponse
{
$service->delete(request()->user(), $user);
return redirect()->route('filament.admin.resources.users.index')
->with('status', __('roles.deleted'));
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Middleware\SetLocale;
use App\Support\AuthenticatedRedirect;
use App\Support\PortalSession;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
public function create(): View
{
return view('auth.login');
}
/**
* Kimlik bilgilerini ve customer rolünü doğrulayıp müşteri oturumunu güvenle başlatır.
*
* @param Request $request Müşteri giriş formu ve oturum bilgileri.
* @param AuthenticatedRedirect $redirect Güvenli müşteri yönlendirmesini belirleyen yardımcı.
* @return RedirectResponse Güvenli intended adresine veya müşteri paneline yönlendirme.
*
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
*/
public function store(Request $request, AuthenticatedRedirect $redirect): RedirectResponse
{
$credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
$key = Str::transliterate(Str::lower($credentials['email']).'|'.$request->ip());
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages(['email' => __('ui.errors.throttle')]);
}
if (! Auth::attempt([...$credentials, 'status' => 'active'], $request->boolean('remember'))) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['email' => __('ui.errors.credentials')]);
}
if (! Auth::user()->canAccessCustomerPortal()) {
RateLimiter::hit($key, 60);
app(PortalSession::class)->terminate($request);
throw ValidationException::withMessages(['email' => __('auth.portal_denied')]);
}
RateLimiter::clear($key);
$request->session()->regenerate();
if (! in_array(Auth::user()->locale, SetLocale::SUPPORTED, true)) {
Auth::user()->forceFill(['locale' => $request->session()->get('locale', 'tr')])->save();
}
return redirect()->to($redirect->intended($request) ?? $redirect->landing());
}
public function destroy(Request $request, PortalSession $sessions): RedirectResponse
{
$sessions->terminate($request);
return redirect()->route('home');
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationController extends Controller
{
public function notice(Request $request): View|RedirectResponse
{
return $request->user()->hasVerifiedEmail() ? redirect()->route('dashboard') : view('auth.verify-email');
}
public function verify(EmailVerificationRequest $request): RedirectResponse
{
$request->fulfill();
return redirect()->route('dashboard')->with('status', __('ui.status.verified'));
}
public function send(Request $request): RedirectResponse
{
if (! $request->user()->hasVerifiedEmail()) $request->user()->sendEmailVerificationNotification();
return back()->with('status', __('ui.status.verification_sent'));
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
public function create(Request $request): View { return view('auth.reset-password', ['request' => $request]); }
public function store(Request $request): RedirectResponse
{
$request->validate(['token' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', PasswordRule::defaults()]]);
$status = Password::reset($request->only('email', 'password', 'password_confirmation', 'token'), function (User $user, string $password) {
$user->forceFill(['password' => Hash::make($password), 'remember_token' => Str::random(60)])->save();
event(new PasswordReset($user));
});
return $status === Password::PasswordReset
? redirect()->route('login')->with('status', __('ui.status.password_reset'))
: back()->withInput($request->only('email'))->withErrors(['email' => __($status)]);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
public function create(): View { return view('auth.forgot-password'); }
public function store(Request $request): RedirectResponse
{
$request->validate(['email' => ['required', 'email']]);
Password::sendResetLink($request->only('email'));
return back()->with('status', __('ui.status.reset_link_sent'));
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Role;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
public function create(): View { return view('auth.register'); }
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$user = User::create($data);
$user->roles()->attach(Role::where('name', 'customer')->sole());
event(new Registered($user));
Auth::login($user);
return redirect()->route('verification.notice');
}
}
@@ -0,0 +1,3 @@
<?php
namespace App\Http\Controllers;use App\Http\Requests\{StoreCompetitorRequest,VisibilityReportRequest};use App\Models\{Project,ProjectCompetitor};use App\Services\{CompetitorAuditLogger,CompetitorReportService};use Illuminate\Database\QueryException;use Illuminate\Http\RedirectResponse;use Illuminate\Validation\ValidationException;use Illuminate\View\View;
class CompetitorController extends Controller{public function index(VisibilityReportRequest$r,CompetitorReportService$s):View{$projects=$r->user()->projects()->active()->with(['competitors'=>fn($q)=>$q->orderBy('name')])->orderBy('name')->get();$project=$r->filled('project')?$projects->firstWhere('ulid',$r->input('project')):$projects->first();if($r->filled('project'))abort_unless($project,404);$competitors=$project?->competitors?->where('status','active')->values()??collect();$data=$project?$s->compare($project,$competitors,now()->parse($r->input('from'))->startOfDay(),now()->parse($r->input('to'))->endOfDay()):null;return view('reports.competitors',compact('projects','project','competitors','data'));}public function store(StoreCompetitorRequest$r,Project$p,CompetitorAuditLogger$a):RedirectResponse{$this->guard($r,$p);if($p->competitors()->active()->count()>=(int)config('serp.competitor_limit',10))throw ValidationException::withMessages(['domain'=>__('competitors.limit')]);if($r->input('normalized_domain')===$p->domain)throw ValidationException::withMessages(['domain'=>__('competitors.self_domain')]);try{$c=$p->competitors()->create($r->safe()->only(['name','domain','normalized_domain'])+['created_by'=>$r->user()->id]);}catch(QueryException){throw ValidationException::withMessages(['domain'=>__('competitors.duplicate')]);}$a->record('created',$c,$r->user());return back()->with('status',__('competitors.created'));}public function update(StoreCompetitorRequest$r,Project$p,ProjectCompetitor$c,CompetitorAuditLogger$a):RedirectResponse{$this->guard($r,$p,$c);if($r->input('normalized_domain')===$p->domain)throw ValidationException::withMessages(['domain'=>__('competitors.self_domain')]);$before=$c->attributesToArray();try{$c->update($r->safe()->only(['name','domain','normalized_domain']));}catch(QueryException){throw ValidationException::withMessages(['domain'=>__('competitors.duplicate')]);}$a->record('updated',$c,$r->user(),$before);return back()->with('status',__('competitors.updated'));}public function archive(VisibilityReportRequest$r,Project$p,ProjectCompetitor$c,CompetitorAuditLogger$a):RedirectResponse{$this->guard($r,$p,$c);$before=$c->attributesToArray();$c->forceFill(['status'=>'archived','archived_at'=>now()])->save();$a->record('archived',$c,$r->user(),$before);return back();}public function restore(VisibilityReportRequest$r,Project$p,ProjectCompetitor$c,CompetitorAuditLogger$a):RedirectResponse{$this->guard($r,$p,$c);$before=$c->attributesToArray();$c->forceFill(['status'=>'active','archived_at'=>null])->save();$a->record('restored',$c,$r->user(),$before);return back();}public function show(VisibilityReportRequest$r,Project$p,ProjectCompetitor$c,CompetitorReportService$s):View{$this->guard($r,$p,$c);$data=$s->compare($p,collect([$c]),now()->subDays(29),now());return view('reports.competitor-show',compact('p','c','data'));}private function guard($r,Project$p,?ProjectCompetitor$c=null):void{abort_unless($p->user_id===$r->user()->id,404);if($c)abort_unless($c->project_id===$p->id,404);}}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers;
use App\Enums\{KeywordStatus,ProjectStatus,SerpCheckStatus}; use App\Models\Project; use Illuminate\Http\Request; use Illuminate\View\View;
class DashboardController extends Controller
{
public function __invoke(Request $request):View
{
$days=in_array((int)$request->input('days'),[7,30,90],true)?(int)$request->input('days'):30;
$projects=$request->user()->projects()->active()->orderBy('name')->get(['id','ulid','name']);
$selected=null;if($request->filled('project')){$selected=$projects->firstWhere('ulid',$request->string('project')->toString());abort_unless($selected,404);}
$projectIds=$selected?collect([$selected->id]):$projects->pluck('id');
$keywords=$request->user()->projects()->whereIn('projects.id',$projectIds)->with(['keywords'=>fn($q)=>$q->where('status',KeywordStatus::Active)->with(['serpChecks'=>fn($q)=>$q->where('status',SerpCheckStatus::Completed)->where('checked_at','>=',now()->subDays($days))->latest('checked_at')->with(['results'=>fn($r)=>$r->where('is_project_domain',true)->orderBy('position')])])])->get()->flatMap(fn($p)=>$p->keywords->each->setRelation('project',$p));
$rows=$keywords->map(function($keyword){$checks=$keyword->serpChecks->filter(fn($c)=>$c->results->isNotEmpty())->values();$current=$checks->get(0);$previous=$checks->get(1);$rank=$current?->results->min('position');$prior=$previous?->results->min('position');return ['keyword'=>$keyword,'project'=>$keyword->project,'current'=>$rank,'previous'=>$prior,'change'=>($rank&&$prior)?$prior-$rank:null,'checked_at'=>$current?->checked_at];})->sortByDesc('checked_at')->values();
$measured=$rows->whereNotNull('current');
return view('dashboard',['projects'=>$projects,'selectedProject'=>$selected,'days'=>$days,'rows'=>$rows->take(12),'winners'=>$rows->where('change','>',0)->sortByDesc('change')->take(5),'losers'=>$rows->where('change','<',0)->sortBy('change')->take(5),'metrics'=>['projects'=>$projects->count(),'keywords'=>$keywords->count(),'top10'=>$measured->where('current','<=',10)->count(),'average'=>$measured->isNotEmpty()?round($measured->avg('current'),1):null,'winners'=>$rows->where('change','>',0)->count(),'losers'=>$rows->where('change','<',0)->count()]]);
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Earn;
use App\Enums\EarnApiTokenStatus;
use App\Http\Controllers\Controller;
use App\Models\EarnApiToken;
use App\Models\EarnMember;
use App\Services\EarnAuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class EarnApiKeyController extends Controller
{
public function index(Request $request): Response
{
$member = $this->member($request);
$tokens = $member->tokens()->latest()->get();
return response()->view('earn-panel.api-keys', [
'tokens' => $tokens,
'maxTotal' => (int) config('earn.api_keys.max_total'),
'durations' => config('earn.api_keys.allowed_durations'),
'defaultDuration' => config('earn.api_keys.default_duration'),
'tasksUrl' => route('test-tasks.index'),
])->withHeaders(['Cache-Control' => 'no-store, private', 'Pragma' => 'no-cache']);
}
public function store(Request $request, EarnAuditLogger $audit): RedirectResponse
{
$request->merge(['name' => trim(strip_tags((string) $request->input('name')))]);
$data = $request->validate([
'name' => ['required', 'string', 'min:3', 'max:80'],
'duration' => ['required', 'string', Rule::in(config('earn.api_keys.allowed_durations'))],
]);
$member = $this->member($request);
$result = DB::transaction(function () use ($member, $data, $request, $audit): array {
EarnMember::query()->whereKey($member->id)->lockForUpdate()->firstOrFail();
if ($member->tokens()->count() >= (int) config('earn.api_keys.max_total')) {
return ['error' => 'total'];
}
$plainText = 'tpe_'.rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '=');
$expiresAt = $this->expiresAt($data['duration']);
$token = $member->tokens()->create([
'name' => $data['name'], 'token' => hash('sha256', $plainText), 'token_hint' => 'tpe_'.substr($plainText, -4),
'abilities' => ['tasks:read'], 'is_active' => true, 'expires_at' => $expiresAt,
]);
$audit->record('api_key_created', $member, $request, $this->metadata($token));
return ['token' => $plainText];
}, 3);
if (isset($result['error'])) {
$audit->record('api_key_limit_rejected', $member, $request, ['device_name' => $data['name'], 'abilities' => ['tasks:read'], 'limit' => $result['error']]);
throw ValidationException::withMessages(['api_key' => __('earn_portal.total_limit_reached')]);
}
return redirect()->route('earn.api-keys.index')->with('new_api_key', $result['token']);
}
public function status(Request $request, int $apiKey, EarnAuditLogger $audit): RedirectResponse
{
$data = $request->validate(['is_active' => ['required', 'boolean']]);
$member = $this->member($request);
DB::transaction(function () use ($member, $apiKey, $data, $request, $audit): void {
EarnMember::query()->whereKey($member->id)->lockForUpdate()->firstOrFail();
$token = $member->tokens()->whereKey($apiKey)->lockForUpdate()->firstOrFail();
abort_if(in_array($token->status(), [EarnApiTokenStatus::Expired, EarnApiTokenStatus::Revoked], true), 422);
$activate = (bool) $data['is_active'];
if ($token->is_active === $activate) {
return;
}
$token->forceFill(['is_active' => $activate])->save();
$audit->record($activate ? 'api_key_activated' : 'api_key_deactivated', $member, $request, $this->metadata($token));
}, 3);
return back()->with('status', __('earn_portal.status_updated'));
}
public function revoke(Request $request, int $apiKey, EarnAuditLogger $audit): RedirectResponse
{
$member = $this->member($request);
DB::transaction(function () use ($member, $apiKey, $request, $audit): void {
$token = $member->tokens()->whereKey($apiKey)->lockForUpdate()->firstOrFail();
abort_if($token->revoked_at !== null, 404);
$token->forceFill(['revoked_at' => now(), 'is_active' => false])->save();
$audit->record('api_key_revoked', $member, $request, $this->metadata($token));
}, 3);
return back()->with('status', __('earn_portal.key_revoked'));
}
public function destroy(Request $request, int $apiKey, EarnAuditLogger $audit): RedirectResponse
{
$member = $this->member($request);
DB::transaction(function () use ($member, $apiKey, $request, $audit): void {
$token = $member->tokens()->whereKey($apiKey)->lockForUpdate()->firstOrFail();
$token->forceFill(['revoked_at' => $token->revoked_at ?? now(), 'is_active' => false])->save();
$metadata = $this->metadata($token) + ['deleted_at' => now()->toIso8601String(), 'actor_earn_member_id' => $member->id];
$audit->record('api_key_deleted', $member, $request, $metadata);
$token->delete();
}, 3);
return back()->with('status', __('earn_portal.key_deleted'));
}
private function expiresAt(string $duration): ?Carbon
{
return match ($duration) {
'1_day' => now()->addDay(), '7_days' => now()->addDays(7), '30_days' => now()->addDays(30),
'90_days' => now()->addDays(90), '180_days' => now()->addDays(180), '1_year' => now()->addYear(),
'unlimited' => null,
};
}
private function metadata(EarnApiToken $token): array
{
return ['token_id' => $token->id, 'device_name' => $token->name, 'abilities' => $token->abilities,
'created_at' => $token->created_at?->toIso8601String(), 'expires_at' => $token->expires_at?->toIso8601String(),
'revoked_at' => $token->revoked_at?->toIso8601String()];
}
private function member(Request $request): EarnMember
{
$member = $request->user('earn');
abort_unless($member instanceof EarnMember, 403);
return $member;
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Earn;
use App\Enums\EarnLegalDocumentType;
use App\Enums\EarnMemberStatus;
use App\Http\Controllers\Controller;
use App\Models\EarnMember;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class EarnAuthController extends Controller
{
public function loginForm(): View
{
return view('earn-auth.login');
}
public function registerForm(): View
{
return view('earn-auth.register');
}
public function register(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:earn_members,email'],
'password' => ['required', 'confirmed', Password::defaults()],
'terms_accepted' => ['accepted'],
'privacy_notice_acknowledged' => ['accepted'],
], [
'terms_accepted.accepted' => __('earn_legal.validation.terms_accepted'),
'privacy_notice_acknowledged.accepted' => __('earn_legal.validation.privacy_notice_acknowledged'),
]);
$member = DB::transaction(function () use ($data, $request): EarnMember {
$member = EarnMember::query()->create([
'name' => trim($data['name']),
'email' => Str::lower($data['email']),
'password' => $data['password'],
'status' => EarnMemberStatus::Pending,
]);
$context = ['accepted_at' => now(), 'ip_address' => $request->ip(), 'user_agent' => Str::limit((string) $request->userAgent(), 1000, '')];
$member->legalAcceptances()->create($context + ['document_type' => EarnLegalDocumentType::Terms, 'document_version' => config('earn.legal.terms.version')]);
$member->legalAcceptances()->create($context + ['document_type' => EarnLegalDocumentType::PrivacyNotice, 'document_version' => config('earn.legal.privacy.version')]);
return $member;
});
event(new Registered($member));
Auth::guard('earn')->login($member);
$request->session()->regenerate();
return redirect()->route('earn.verification.notice');
}
public function login(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
$key = 'earn-login:'.Str::transliterate(Str::lower($credentials['email']).'|'.$request->ip());
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages(['email' => __('ui.errors.throttle')]);
}
if (! Auth::guard('earn')->attempt($credentials, $request->boolean('remember'))) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['email' => __('ui.errors.credentials')]);
}
$member = Auth::guard('earn')->user();
if (! $member->canUseApi()) {
Auth::guard('earn')->logout();
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['email' => __('earn_portal.account_unavailable')]);
}
RateLimiter::clear($key);
$request->session()->regenerate();
$member->forceFill(['last_login_at' => now(), 'last_login_ip' => $request->ip()])->saveQuietly();
return redirect()->intended(route('earn.dashboard'));
}
public function logout(Request $request): RedirectResponse
{
Auth::guard('earn')->logout();
$request->session()->regenerate();
$request->session()->regenerateToken();
return redirect()->route('earn.login');
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\Earn;
use App\Http\Controllers\Controller;
use Illuminate\View\View;
class EarnDashboardController extends Controller
{
public function __invoke(): View
{
$member = auth('earn')->user();
$activeTokenCount = $member->tokens()
->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->count();
return view('earn-panel.dashboard', compact('member', 'activeTokenCount'));
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Earn;
use App\Http\Controllers\Controller;
use Illuminate\View\View;
class EarnLegalPageController extends Controller
{
public function terms(): View
{
return $this->page('terms');
}
public function privacy(): View
{
return $this->page('privacy');
}
private function page(string $document): View
{
return view('earn-legal.document', [
'document' => $document,
'content' => __('earn_legal.'.$document),
'version' => config('earn.legal.'.$document.'.version'),
'effectiveDate' => config('earn.legal.'.$document.'.effective_date'),
]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Earn;
use App\Enums\EarnMemberStatus;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EarnVerificationController extends Controller
{
public function notice(): View|RedirectResponse
{
if (auth('earn')->user()->hasVerifiedEmail()) {
return redirect()->route('earn.dashboard');
}
return view('earn-auth.verify');
}
public function send(): RedirectResponse
{
if (! auth('earn')->user()->hasVerifiedEmail()) {
auth('earn')->user()->sendEmailVerificationNotification();
}
return back()->with('status', __('earn_portal.verification_sent'));
}
public function verify(Request $request): RedirectResponse
{
$member = auth('earn')->user();
abort_unless((string) $member->getKey() === (string) $request->route('id'), 403);
abort_unless(hash_equals((string) $request->route('hash'), sha1($member->getEmailForVerification())), 403);
if (! $member->hasVerifiedEmail()) {
$member->forceFill(['email_verified_at' => now(), 'status' => EarnMemberStatus::Active])->save();
}
return redirect()->route('earn.dashboard');
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Models\EarnApiToken;
use App\Services\EarnApiAccessLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
class EarnDeviceController extends Controller
{
public function bind(Request $request, EarnApiAccessLogger $logs): JsonResponse
{
$token = $request->attributes->get('earn_api_token');
abort_unless($token instanceof EarnApiToken, 401);
$hash = $request->attributes->get('earn_device_hash');
$preview = $request->attributes->get('earn_device_preview');
$result = DB::transaction(function () use ($token, $hash, $preview, $request): array {
$locked = EarnApiToken::query()->whereKey($token->id)->lockForUpdate()->firstOrFail();
if ($locked->device_id_hash !== null) {
$match = hash_equals($locked->device_id_hash, $hash);
if ($match) {
$locked->forceFill(['last_ip' => $request->ip(), 'last_seen_at' => now()])->save();
}
return ['token' => $locked, 'match' => $match];
}
$locked->forceFill(['device_id_hash' => $hash, 'device_id_preview' => $preview, 'device_bound_at' => now(),
'first_ip' => $request->ip(), 'last_ip' => $request->ip(), 'last_seen_at' => now()])->save();
return ['token' => $locked, 'match' => true, 'created' => true];
}, 3);
if (! $result['match']) {
$failedKey = 'earn-api:failed-device:'.hash('sha256', $result['token']->id.'|'.$request->ip());
$failedLimit = (int) config('earn.api.failed_device_per_minute');
if (RateLimiter::tooManyAttempts($failedKey, $failedLimit)) {
$logs->record($request, 'rate_limited', 429, 'RATE_LIMITED', $result['token'], $hash, $preview);
return $this->error('RATE_LIMITED', 429)->header('Retry-After', (string) RateLimiter::availableIn($failedKey));
}
RateLimiter::hit($failedKey, 60);
$result['token']->forceFill(['device_mismatch_seen' => true])->saveQuietly();
$logs->record($request, 'device_mismatch', 409, 'DEVICE_MISMATCH', $result['token'], $hash, $preview);
return $this->error('DEVICE_MISMATCH', 409);
}
$already = ! ($result['created'] ?? false);
$logs->record($request, $already ? 'device_bind_repeated' : 'device_bound', 200, null, $result['token'], $hash, $preview);
return response()->json(['success' => true, 'data' => ['device_bound' => true, 'already_bound' => $already,
'device_id_preview' => $result['token']->device_id_preview, 'bound_at' => $result['token']->device_bound_at?->toISOString()]])
->header('Cache-Control', 'no-store');
}
private function error(string $code, int $status): JsonResponse
{
return response()->json(['success' => false, 'error' => ['code' => $code, 'message' => __('earn_api.'.$code)]], $status)->header('Cache-Control', 'no-store');
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreEarnTaskResultsRequest;
use App\Services\EarnApiAccessLogger;
use App\Services\EarnTaskResultService;
use Illuminate\Http\JsonResponse;
class EarnTaskResultController extends Controller
{
/**
* Doğrulanmış istemciden gelen görev sonuçlarını kısmi kabul özetiyle kaydeder.
*
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
*/
public function store(StoreEarnTaskResultsRequest $request, EarnTaskResultService $service, EarnApiAccessLogger $logs): JsonResponse
{
$items = $request->validated('tasks');
$logs->record($request, 'task_results_received', 200);
$data = $service->store($request, $items);
return response()->json(['success' => true, 'data' => $data])->header('Cache-Control', 'no-store');
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Enums\{KeywordStatus,ProjectStatus,SerpCheckStatus};use App\Http\Requests\{StoreKeywordRequest,UpdateKeywordRequest};use App\Models\{Keyword,Project};use App\Services\{KeywordAuditLogger,KeywordPhraseNormalizer,NextCheckAtCalculator};use App\Support\KeywordTargetOptions;use Illuminate\Database\QueryException;use Illuminate\Http\{RedirectResponse,Request};use Illuminate\Support\Facades\DB;use Illuminate\Validation\{Rule,ValidationException};use Illuminate\View\View;
class KeywordController extends Controller{
public function __construct(private readonly KeywordAuditLogger $keywordAuditLogger,private readonly NextCheckAtCalculator $nextCheckAtCalculator){}
public function legacyShow(Project $customerProject,Keyword $customerKeyword):View{return $this->show(request(),$customerKeyword);}
public function legacyEdit(Project $customerProject,Keyword $customerKeyword):View{return $this->edit(request(),$customerKeyword);}
public function legacyUpdate(UpdateKeywordRequest $r,Project $customerProject,Keyword $customerKeyword,KeywordPhraseNormalizer $n):RedirectResponse{return $this->update($r,$n);}
public function legacyArchive(Request $r,Project $customerProject,Keyword $customerKeyword):RedirectResponse{return $this->archive($r,$customerKeyword);}
public function legacyRestore(Request $r,Project $customerProject,Keyword $customerKeyword):RedirectResponse{return $this->restore($r,$customerKeyword);}
public function index(Request $r):View{
$this->authorize('viewAny',Keyword::class);$projects=$r->user()->projects()->orderBy('name')->get();$nested=$r->route('customerProject');$projectUlid=$nested?->ulid?:$r->string('project')->toString();
$q=Keyword::query()->whereHas('project',fn($x)=>$x->where('user_id',$r->user()->id))->with(['project','serpChecks'=>fn($x)=>$x->where('status',SerpCheckStatus::Completed)->latest('checked_at')->with(['results'=>fn($y)=>$y->where('is_project_domain',true)->orderBy('position')])]);
if($projectUlid)$q->whereHas('project',fn($x)=>$x->where('ulid',$projectUlid));if($s=trim($r->string('q')->toString()))$q->where('phrase','like','%'.addcslashes($s,'%_').'%');
foreach(['country_code'=>'country','language_code'=>'language','device'=>'device','search_engine'=>'engine'] as $column=>$key)if($v=$r->string($key)->toString())$q->where($column,$v);
$requestedStatus=$r->input('status',$r->input('filter'));$status=in_array($requestedStatus,['active','inactive','archived'],true)?$requestedStatus:null;if($status==='archived')$q->where('status',KeywordStatus::Archived)->whereNotNull('archived_at');elseif($status)$q->where('status',$status)->whereNull('archived_at');else$q->whereIn('status',[KeywordStatus::Active,KeywordStatus::Inactive])->whereNull('archived_at');$sort=in_array($r->input('sort'),['phrase','created_at','updated_at'],true)?$r->input('sort'):'created_at';$direction=$r->input('direction')==='asc'?'asc':'desc';$keywords=$q->orderBy($sort,$direction)->paginate(15)->withQueryString();
$keywords->getCollection()->each(fn($k)=>$this->rankData($k));$all=(clone $q)->get();$all->each(fn($k)=>$this->rankData($k));$ranked=$all->whereNotNull('current_rank');$summary=['total'=>$all->count(),'active'=>$all->where('status',KeywordStatus::Active)->count(),'top10'=>$ranked->where('current_rank','<=',10)->count(),'average'=>$ranked->isEmpty()?null:round($ranked->avg('current_rank'),1),'winners'=>$all->where('movement','winner')->count(),'losers'=>$all->where('movement','loser')->count()];
return view('keywords.index',compact('keywords','projects','summary','projectUlid')+$this->options());
}
public function create(Request $r):View{$project=$r->route('customerProject')?:$r->user()->projects()->active()->where('ulid',$r->input('project'))->first();$projects=$r->user()->projects()->active()->orderBy('name')->get();abort_if($projects->isEmpty(),404);$project??=$projects->first();$this->authorize('create',[Keyword::class,$project]);return view('keywords.create',compact('project','projects')+$this->options());}
public function store(StoreKeywordRequest $r,KeywordPhraseNormalizer $normalizer):RedirectResponse{
$project=$r->project();$seen=[];$targets=[];$duplicates=0;$rejected=0;foreach(preg_split('/\R/u',$r->string('phrases')->toString())?:[] as $line){if(trim($line)==='')continue;try{$data=$normalizer->normalize($line);}catch(\InvalidArgumentException){$rejected++;continue;}$key=$data['normalized_phrase'];if(isset($seen[$key])){$duplicates++;continue;}$seen[$key]=true;$target=$data+['search_engine'=>$r->input('search_engine'),'country_code'=>strtoupper($r->input('country_code')),'language_code'=>strtolower($r->input('language_code')),'device'=>$r->input('device')];if($project->keywords()->where($target)->exists()){$duplicates++;continue;}$targets[]=$target;}
$errorKey=$r->has('phrase')?'phrase':'phrases';if(!$targets)throw ValidationException::withMessages([$errorKey=>__('keywords.validation.none_created')]);$created=DB::transaction(function()use($targets,$project,$r,$errorKey){$created=[];foreach($targets as$target){try{$created[]=$project->keywords()->create($target);}catch(QueryException){continue;}}if(!$created)throw ValidationException::withMessages([$errorKey=>__('keywords.validation.none_created')]);foreach($created as$k)$this->keywordAuditLogger->record(count($created)>1?'bulk_created':'created',$k,$r->user());return$created;});return redirect()->route('keywords.index',['project'=>$project->ulid])->with('status',__('keywords.created_result',['created'=>count($created),'duplicates'=>$duplicates,'rejected'=>$rejected]));
}
public function show(Request $r,?Keyword $keyword=null):View{$keyword=$this->keyword($r,$keyword);$this->authorize('view',$keyword);$keyword->load(['project','serpChecks'=>fn($q)=>$q->where('status',SerpCheckStatus::Completed)->latest('checked_at')->with(['results'=>fn($x)=>$x->orderBy('position')])]);$this->rankData($keyword);$history=$keyword->serpChecks->map(fn($c)=>['date'=>$c->checked_at?->toIso8601String(),'rank'=>$c->results->firstWhere('is_project_domain',true)?->position])->filter(fn($x)=>$x['rank']!==null)->reverse()->values();$best=$history->min('rank');return view('keywords.show',compact('keyword','history','best')+$this->options());}
public function edit(Request $r,?Keyword $keyword=null):View{$keyword=$this->keyword($r,$keyword);$this->authorize('update',$keyword);abort_if($keyword->status===KeywordStatus::Archived||$keyword->archived_at!==null,422);return view('keywords.edit',['keyword'=>$keyword,'project'=>$keyword->project]+$this->options());}
public function update(UpdateKeywordRequest $r,KeywordPhraseNormalizer $n):RedirectResponse{$k=$r->keyword();$before=$k->attributesToArray();$status=KeywordStatus::from($r->input('status'));$data=$n->normalize($r->string('phrase'))+['country_code'=>strtoupper($r->input('country_code')),'language_code'=>strtolower($r->input('language_code')),'device'=>$r->input('device')];$changed=collect($data)->contains(function($v,$key)use($k){$current=$k->{$key};return($current instanceof \BackedEnum?$current->value:(string)$current)!==$v;});try{DB::transaction(function()use($k,$data,$r,$before,$changed,$status){if($changed&&$k->serpChecks()->exists()){$k->archive();$this->keywordAuditLogger->record('archived_for_retarget',$k,$r->user());$new=$k->project->keywords()->create($data+['search_engine'=>$k->search_engine->value]);$new->forceFill(['status'=>$status,'tracking_enabled'=>$k->tracking_enabled,'check_frequency'=>$k->check_frequency,'next_check_at'=>$status===KeywordStatus::Active&&$k->tracking_enabled?$this->nextCheckAtCalculator->calculate($k->check_frequency):null])->save();$this->keywordAuditLogger->record('created_from_retarget',$new,$r->user());}else{$k->forceFill($data+['status'=>$status,'next_check_at'=>$status===KeywordStatus::Active&&$k->tracking_enabled?$this->nextCheckAtCalculator->calculate($k->check_frequency):null])->save();$this->keywordAuditLogger->record('updated',$k,$r->user(),$before);}});}catch(QueryException){throw ValidationException::withMessages(['phrase'=>__('keywords.validation.duplicate')]);}return redirect()->route('keywords.index',['project'=>$k->project->ulid])->with('status',__('keywords.updated'));}
public function status(Request $r,Keyword $keyword):RedirectResponse{$this->authorize('update',$keyword);abort_if($keyword->status===KeywordStatus::Archived||$keyword->archived_at!==null,422);$data=$r->validate(['status'=>['required',Rule::in([KeywordStatus::Active->value,KeywordStatus::Inactive->value])]]);$status=KeywordStatus::from($data['status']);if($keyword->status===$status)return back()->with('status',__('keywords.updated'));DB::transaction(function()use($keyword,$status,$r){$before=$keyword->attributesToArray();$keyword->forceFill(['status'=>$status,'next_check_at'=>$status===KeywordStatus::Active&&$keyword->tracking_enabled?$this->nextCheckAtCalculator->calculate($keyword->check_frequency):null])->save();$this->keywordAuditLogger->record('status_changed',$keyword,$r->user(),$before);});return back()->with('status',__('keywords.updated'));}
public function archive(Request $r,?Keyword $keyword=null):RedirectResponse{$k=$this->keyword($r,$keyword);$this->authorize('update',$k);if($k->status===KeywordStatus::Archived&&$k->archived_at!==null)return back()->with('status',__('keywords.archived'));DB::transaction(function()use($k,$r){$before=$k->attributesToArray();$k->archive();$this->keywordAuditLogger->record('archived',$k,$r->user(),$before);});return back()->with('status',__('keywords.archived'));}
public function restore(Request $r,?Keyword $keyword=null):RedirectResponse{$k=$this->keyword($r,$keyword);$this->authorize('update',$k);if($k->status===KeywordStatus::Active&&$k->archived_at===null)return back()->with('status',__('keywords.restored'));abort_unless($k->project->status===ProjectStatus::Active&&$k->project->archived_at===null,422);$duplicate=$k->project->keywords()->active()->where('normalized_phrase',$k->normalized_phrase)->where('search_engine',$k->search_engine)->where('country_code',$k->country_code)->where('language_code',$k->language_code)->where('device',$k->device)->whereKeyNot($k->id)->exists();if($duplicate)throw ValidationException::withMessages(['keyword'=>__('keywords.validation.restore_duplicate')]);DB::transaction(function()use($k,$r){$before=$k->attributesToArray();$k->restoreKeyword();$this->keywordAuditLogger->record('restored',$k,$r->user(),$before);});return back()->with('status',__('keywords.restored'));}
private function keyword(Request $r,?Keyword $keyword):Keyword{return $keyword?:($r->route('customerKeyword')?:$r->route('keyword'));}
private function rankData(Keyword $k):void{$checks=$k->serpChecks->values();$current=$checks->get(0)?->results->firstWhere('is_project_domain',true)?->position;$previous=$checks->get(1)?->results->firstWhere('is_project_domain',true)?->position;$k->setAttribute('current_rank',$current);$k->setAttribute('previous_rank',$previous);$k->setAttribute('rank_change',$current&&$previous?$previous-$current:null);$k->setAttribute('movement',$current===null||$previous===null?'new':($current<$previous?'winner':($current>$previous?'loser':'unchanged')));$k->setAttribute('last_checked_at',$checks->get(0)?->checked_at);}
private function options():array{return ['countries'=>KeywordTargetOptions::countries(),'languages'=>KeywordTargetOptions::languages()];}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Http\Middleware\SetLocale;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Str;
class LocaleController extends Controller
{
public function __invoke(Request $request): RedirectResponse
{
$data = $request->validate([
'locale' => ['required', 'in:'.implode(',', SetLocale::SUPPORTED)],
'return_to' => ['nullable', 'string', 'max:2048'],
]);
$locale = $data['locale'];
$request->session()->put('locale', $locale);
Cookie::queue(cookie('locale', $locale, 60 * 24 * 365, '/', null, $request->isSecure(), true, false, 'lax'));
if ($request->user()) {
$request->user()->forceFill(['locale' => $locale])->save();
}
$target = $this->safeRelativeTarget($data['return_to'] ?? null)
?? $this->safeRefererTarget($request)
?? $this->fallbackTarget($request);
return redirect()->to($target);
}
private function safeRelativeTarget(?string $target): ?string
{
if (! is_string($target) || ! str_starts_with($target, '/') || str_starts_with($target, '//')) return null;
$decoded = $target;
for ($i = 0; $i < 3; $i++) $decoded = rawurldecode($decoded);
if (! str_starts_with($decoded, '/') || str_starts_with($decoded, '//') || Str::contains($decoded, ["\r", "\n", '\\'])) return null;
if (parse_url($decoded, PHP_URL_HOST) !== null || parse_url($decoded, PHP_URL_SCHEME) !== null) return null;
return $target;
}
private function safeRefererTarget(Request $request): ?string
{
$referer = $request->headers->get('referer');
if (! $referer) return null;
$parts = parse_url($referer);
$requestPort = $request->getPort();
$refererPort = $parts['port'] ?? (($parts['scheme'] ?? '') === 'https' ? 443 : 80);
if (($parts['scheme'] ?? null) !== $request->getScheme() || ($parts['host'] ?? null) !== $request->getHost() || $refererPort !== $requestPort) return null;
return $this->safeRelativeTarget(($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : ''));
}
private function fallbackTarget(Request $request): string
{
if ($request->user()?->canAccessPanel(filament()->getPanel('admin'))) return '/admin';
if ($request->user()) return route('dashboard', absolute: false);
return route('login', absolute: false);
}
}
@@ -0,0 +1,3 @@
<?php
namespace App\Http\Controllers;use App\Http\Requests\LosersReportRequest;use App\Models\Keyword;use App\Services\LosersReportService;use App\Support\KeywordTargetOptions;use Illuminate\Pagination\LengthAwarePaginator;use Illuminate\View\View;
class LosersReportController extends Controller{public function __invoke(LosersReportRequest$r,LosersReportService$s):View{$projects=$r->user()->projects()->active()->orderBy('name')->get(['id','ulid','name']);$keywords=Keyword::query()->active()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id)->whereNull('archived_at'))->with('project:id,ulid,name')->get();if($r->filled('project')){$p=$projects->firstWhere('ulid',$r->input('project'));abort_unless($p,404);$keywords=$keywords->where('project_id',$p->id)->values();}foreach(['country_code'=>'country','language_code'=>'language','device'=>'device','search_engine'=>'engine']as$column=>$key)if($r->filled($key))$keywords=$keywords->where($column,$r->input($key))->values();if($q=mb_strtolower(trim($r->string('q')->toString())))$keywords=$keywords->filter(fn($k)=>str_contains(mb_strtolower($k->phrase),$q))->values();$all=$s->rows($keywords->pluck('id'),now()->parse($r->input('from'))->startOfDay(),now()->parse($r->input('to'))->endOfDay());$rows=$s->losers($all);if($r->filled('min_lost'))$rows=$rows->filter(fn($x)=>($x['lost']??0)>=(int)$r->input('min_lost'))->values();if($r->filled('min_visibility_loss'))$rows=$rows->where('visibility_loss','>=',(float)$r->input('min_visibility_loss'))->values();if($type=$r->input('loss_type'))$rows=$rows->filter(fn($x)=>match($type){'top3'=>$x['category']==='dropped_top3','top10'=>in_array($x['category'],['dropped_top3','dropped_top10'],true),'top100'=>$x['dropped_out'],'dropped_out'=>$x['dropped_out']});$sort=in_array($r->input('sort'),['current','lost','visibility_loss','checked_at'],true)?$r->input('sort'):'lost';$rows=$rows->sortByDesc(fn($x)=>$x['dropped_out']?PHP_INT_MAX:($x[$sort]??-1))->values();$page=max(1,(int)$r->input('page',1));$pager=new LengthAwarePaginator($rows->forPage($page,25)->values(),$rows->count(),25,$page,['path'=>$r->url(),'query'=>$r->query()]);return view('reports.losers',['projects'=>$projects,'rows'=>$pager,'summary'=>$s->summary($rows),'distribution'=>$s->distribution($rows),'critical'=>$s->critical($rows),'countries'=>KeywordTargetOptions::countries(),'languages'=>KeywordTargetOptions::languages()]);}}
@@ -0,0 +1,3 @@
<?php
namespace App\Http\Controllers;use App\Services\ProfileAuditLogger;use Illuminate\Auth\Events\Registered;use Illuminate\Http\{RedirectResponse,Request};use Illuminate\Support\Facades\{Auth,DB,Hash};use Illuminate\Validation\Rules\Password;use Illuminate\Validation\ValidationException;use Illuminate\View\View;
class ProfileController extends Controller{public function show(Request$r):View{$u=$r->user()->load('roles');$sessions=config('session.driver')==='database'?DB::table('sessions')->where('user_id',$u->id)->orderByDesc('last_activity')->get()->map(fn($s)=>['id'=>$s->id,'current'=>$s->id===$r->session()->getId(),'agent'=>mb_substr((string)$s->user_agent,0,100),'last_activity'=>date('d.m.Y H:i',$s->last_activity)]):collect();return view('profile.show',compact('u','sessions'));}public function update(Request$r,ProfileAuditLogger$a):RedirectResponse{$data=$r->validate(['name'=>['required','string','min:2','max:150','not_regex:/[\x00-\x1F\x7F]/']]);$before=$r->user()->name;$r->user()->update(['name'=>trim($data['name'])]);$a->record('profile_updated',$r->user(),['name'=>$before],['name'=>$r->user()->name]);return back()->with('status',__('profile.updated'));}public function email(Request$r,ProfileAuditLogger$a):RedirectResponse{$data=$r->validate(['email'=>['required','email:rfc','max:254','unique:users,email,'.$r->user()->id],'current_password'=>['required','current_password']]);$email=mb_strtolower(trim($data['email']));if($email===$r->user()->email)return back();$before=$r->user()->email;$r->user()->forceFill(['email'=>$email,'email_verified_at'=>null])->save();$r->user()->sendEmailVerificationNotification();$a->record('email_changed',$r->user(),['email'=>$a->mask($before)],['email'=>$a->mask($email)]);return back()->with('status',__('profile.email_updated'));}public function password(Request$r,ProfileAuditLogger$a):RedirectResponse{$data=$r->validate(['current_password'=>['required','current_password'],'password'=>['required','confirmed',Password::defaults()]]);if(Hash::check($data['password'],$r->user()->password))throw ValidationException::withMessages(['password'=>__('profile.same_password')]);$r->user()->forceFill(['password'=>Hash::make($data['password']),'remember_token'=>str()->random(60)])->save();$a->record('password_changed',$r->user());return back()->with('status',__('profile.password_updated'));}public function resend(Request$r,ProfileAuditLogger$a):RedirectResponse{if(!$r->user()->hasVerifiedEmail()){$r->user()->sendEmailVerificationNotification();$a->record('verification_resent',$r->user());}return back()->with('status',__('profile.verification_sent'));}public function logoutOthers(Request$r,ProfileAuditLogger$a):RedirectResponse{$r->validate(['current_password'=>['required','current_password']]);if(config('session.driver')==='database')DB::table('sessions')->where('user_id',$r->user()->id)->where('id','!=',$r->session()->getId())->delete();$a->record('other_sessions_logged_out',$r->user());return back()->with('status',__('profile.sessions_closed'));}}
@@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers;
use App\Enums\ProjectStatus;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Models\Project;
use App\Services\ProjectUrlNormalizer;
use Illuminate\Database\QueryException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ProjectController extends Controller
{
public function index(Request $request): View
{
$this->authorize('viewAny', Project::class);
$archived = $request->string('filter')->toString() === 'archived';
$projects = $request->user()->projects()
->where('status', $archived ? ProjectStatus::Archived : ProjectStatus::Active)
->withCount(['keywords' => fn ($query) => $query->active()])
->orderByDesc('id')->paginate(15)->withQueryString();
return view('projects.index', compact('projects', 'archived'));
}
public function create(): View
{
$this->authorize('create', Project::class);
return view('projects.create');
}
public function store(StoreProjectRequest $request, ProjectUrlNormalizer $normalizer): RedirectResponse
{
$normalized = $normalizer->normalize($request->string('website_url')->toString());
try {
$project = $request->user()->projects()->create([
'name' => $request->string('name')->trim()->toString(),
'domain' => $normalized->domain,
'url' => $normalized->url,
]);
} catch (QueryException $exception) {
$this->throwDuplicateValidation($exception);
}
return redirect()->route('projects.show', $project)->with('status', 'Proje oluşturuldu.');
}
public function show(Project $customerProject): View
{
$this->authorize('view', $customerProject);
$customerProject->load(['keywords' => fn ($q) => $q->active()->orderBy('phrase'), 'keywords.serpChecks' => fn ($q) => $q->where('status', 'completed')->latest('checked_at')->with(['results' => fn ($r) => $r->where('is_project_domain', true)->orderBy('position')])]);
$rows = $customerProject->keywords->map(function ($keyword) { $checks=$keyword->serpChecks->filter(fn($c)=>$c->results->isNotEmpty())->values();$current=$checks->get(0);$previous=$checks->get(1);$rank=$current?->results->min('position');$prior=$previous?->results->min('position');return ['current'=>$rank,'change'=>($rank&&$prior)?$prior-$rank:null,'checked_at'=>$current?->checked_at]; });
$measured=$rows->whereNotNull('current');
return view('projects.show', ['project'=>$customerProject,'summary'=>['keywords'=>$customerProject->keywords->count(),'last_check'=>$rows->max('checked_at'),'average'=>$measured->isNotEmpty()?round($measured->avg('current'),1):null,'top10'=>$measured->where('current','<=',10)->count(),'winners'=>$rows->where('change','>',0)->count(),'losers'=>$rows->where('change','<',0)->count()]]);
}
public function edit(Project $customerProject): View
{
$this->authorize('update', $customerProject);
return view('projects.edit', ['project' => $customerProject]);
}
public function update(UpdateProjectRequest $request, Project $customerProject, ProjectUrlNormalizer $normalizer): RedirectResponse
{
$normalized = $normalizer->normalize($request->string('website_url')->toString());
try {
$customerProject->update([
'name' => $request->string('name')->trim()->toString(),
'domain' => $normalized->domain,
'url' => $normalized->url,
]);
} catch (QueryException $exception) {
$this->throwDuplicateValidation($exception);
}
return redirect()->route('projects.show', $customerProject)->with('status', 'Proje güncellendi.');
}
public function archive(Project $customerProject): RedirectResponse
{
$this->authorize('archive', $customerProject);
$customerProject->archive();
return redirect()->route('projects.index')->with('status', 'Proje arşivlendi.');
}
public function restore(Project $customerProject): RedirectResponse
{
$this->authorize('restore', $customerProject);
$customerProject->restoreProject();
return redirect()->route('projects.show', $customerProject)->with('status', 'Proje yeniden etkinleştirildi.');
}
private function throwDuplicateValidation(QueryException $exception): never
{
if (! in_array((string) $exception->getCode(), ['23000', '19'], true)) throw $exception;
throw ValidationException::withMessages(['website_url' => 'Bu alan adı hesabınızda zaten kayıtlı.']);
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\RankingHistoryRequest;use App\Models\{Keyword,SerpCheck};use App\Services\RankingHistoryService;use App\Support\KeywordTargetOptions;use Illuminate\Http\Request;use Illuminate\View\View;
class RankingHistoryController extends Controller{
public function index(RankingHistoryRequest $r,RankingHistoryService $service):View{$projects=$r->user()->projects()->orderBy('name')->get(['id','ulid','name']);$keywords=Keyword::query()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id))->with('project:id,ulid,name,domain')->orderBy('phrase')->get();if($r->filled('project')){$project=$projects->firstWhere('ulid',$r->input('project'));abort_unless($project,404);$keywords=$keywords->where('project_id',$project->id)->values();}$selected=collect($r->input('keywords',[]));if($selected->isNotEmpty()){abort_if($selected->diff($keywords->pluck('ulid'))->isNotEmpty(),404);$keywords=$keywords->whereIn('ulid',$selected)->values();}foreach(['search_engine'=>'engine','country_code'=>'country','language_code'=>'language','device'=>'device'] as $column=>$key)if($r->filled($key))$keywords=$keywords->where($column,$r->input($key))->values();$checks=$service->checksFor($keywords->pluck('id'),now()->parse($r->input('from'))->startOfDay(),now()->parse($r->input('to'))->endOfDay());$rows=$service->rows($checks);if($r->filled('movement'))$rows=$rows->where('movement',$r->input('movement'))->values();$page=max(1,(int)$r->input('page',1));$perPage=25;$paginator=new \Illuminate\Pagination\LengthAwarePaginator($rows->forPage($page,$perPage)->values(),$rows->count(),$perPage,$page,['path'=>$r->url(),'query'=>$r->query()]);$chart=$rows->whereNotNull('rank')->sortBy(fn($x)=>$x['check']->checked_at)->groupBy(fn($x)=>$x['keyword']->ulid)->take(10)->map(fn($series)=>['keyword'=>$series->first()['keyword']->phrase,'project'=>$series->first()['project']->name,'points'=>$series->map(fn($x)=>['date'=>$x['check']->checked_at->toIso8601String(),'rank'=>$x['rank'],'check'=>$x['check']->ulid])->values()])->values();return view('rankings.index',['projects'=>$projects,'keywords'=>$keywords,'rows'=>$paginator,'chart'=>$chart,'summary'=>$service->summary($rows,$keywords->count()),'distribution'=>$service->distribution($rows),'countries'=>KeywordTargetOptions::countries(),'languages'=>KeywordTargetOptions::languages()]);}
public function keyword(Request $r,Keyword $keyword,RankingHistoryService $service):View{$this->ownedKeyword($r,$keyword);$from=now()->subYears(2);$checks=$service->checksFor(collect([$keyword->id]),$from,now());$rows=$service->rows($checks);$ranked=$rows->whereNotNull('rank');return view('rankings.keyword',['keyword'=>$keyword->load('project'),'rows'=>$rows,'metrics'=>['current'=>$ranked->first()['rank']??null,'previous'=>$ranked->skip(1)->first()['rank']??null,'best'=>$ranked->min('rank'),'worst'=>$ranked->max('rank'),'count'=>$ranked->count(),'top10'=>$ranked->count()?round($ranked->where('rank','<=',10)->count()/$ranked->count()*100):null],'countries'=>KeywordTargetOptions::countries(),'languages'=>KeywordTargetOptions::languages()]);}
public function check(Request $r,SerpCheck $serpCheck,RankingHistoryService $service):View{abort_unless($serpCheck->keyword()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id))->exists(),404);$serpCheck->load(['keyword.project','results'=>fn($q)=>$q->where('result_type','organic')->orderBy('position')->limit(10)]);return view('rankings.check',['check'=>$serpCheck,'rank'=>$serpCheck->results->where('is_project_domain',true)->min('position')]);}
private function ownedKeyword(Request $r,Keyword $keyword):void{abort_unless($keyword->project()->where('user_id',$r->user()->id)->exists(),404);}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers;
use App\Enums\{CheckFrequency,SerpCheckStatus};use App\Models\{Keyword,Project,SerpCheck};use App\Services\{NextCheckAtCalculator,RankingHistoryService,SerpCheckCreator};use Illuminate\Http\{RedirectResponse,Request};use Illuminate\Validation\Rule;use Illuminate\View\View;
class SerpCheckController extends Controller{
public function customerIndex(Request $r,RankingHistoryService $ranking):View{$this->authorize('viewAny',SerpCheck::class);$projects=$r->user()->projects()->orderBy('name')->get(['id','ulid','name']);$keywords=Keyword::query()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id))->with('project:id,ulid,name')->orderBy('phrase')->get();if($r->filled('project')){$project=$projects->firstWhere('ulid',$r->input('project'));abort_unless($project,404);$keywords=$keywords->where('project_id',$project->id)->values();}if($r->filled('keyword')){abort_unless($keywords->contains('ulid',$r->input('keyword')),404);$keywords=$keywords->where('ulid',$r->input('keyword'))->values();}$q=SerpCheck::query()->whereIn('keyword_id',$keywords->pluck('id'))->with(['keyword.project','results'=>fn($x)=>$x->where('result_type','organic')->where('is_project_domain',true)->orderBy('position')]);if($r->filled('status')){abort_unless(in_array($r->input('status'),array_column(SerpCheckStatus::cases(),'value'),true),422);$q->where('status',$r->input('status'));}if($r->filled('device')){$r->validate(['device'=>[Rule::in(['desktop','mobile'])]]);$q->where('device',$r->input('device'));}if($r->filled('from'))$q->whereDate('created_at','>=',$r->date('from'));if($r->filled('to'))$q->whereDate('created_at','<=',$r->date('to'));if($search=trim($r->string('q')->toString()))$q->where('phrase','like','%'.addcslashes($search,'%_').'%');$all=(clone $q)->get();$summary=['total'=>$all->count(),'pending'=>$all->where('status',SerpCheckStatus::Pending)->count(),'running'=>$all->where('status',SerpCheckStatus::Running)->count(),'completed'=>$all->where('status',SerpCheckStatus::Completed)->count(),'failed'=>$all->where('status',SerpCheckStatus::Failed)->count(),'last24'=>$all->where('created_at','>=',now()->subDay())->count()];$checks=$q->latest('created_at')->paginate(25)->withQueryString();$checks->getCollection()->each(fn($check)=>$check->setAttribute('detected_rank',$check->status===SerpCheckStatus::Completed?$ranking->rank($check):null));return view('serp-checks.customer-index',compact('projects','keywords','checks','summary'));}
public function customerShow(Request $r,SerpCheck $serpCheck,RankingHistoryService $ranking):View{abort_unless($serpCheck->keyword()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id))->exists(),404);$serpCheck->load(['keyword.project','results'=>fn($q)=>$q->where('result_type','organic')->orderBy('position')]);$previous=SerpCheck::query()->where('keyword_id',$serpCheck->keyword_id)->where('status',SerpCheckStatus::Completed)->where('checked_at','<',$serpCheck->checked_at)->latest('checked_at')->with(['results'=>fn($q)=>$q->where('result_type','organic')->where('is_project_domain',true)])->first();$rank=$serpCheck->status===SerpCheckStatus::Completed?$serpCheck->results->where('is_project_domain',true)->min('position'):null;$prior=$previous?$ranking->rank($previous):null;return view('serp-checks.customer-show',['check'=>$serpCheck,'rank'=>$rank,'previous'=>$prior,'change'=>$rank&&$prior?$prior-$rank:null]);}
public function customerStore(Request $r,Keyword $keyword,SerpCheckCreator $creator):RedirectResponse{abort_unless($keyword->project()->where('user_id',$r->user()->id)->exists(),404);$this->authorize('create',[SerpCheck::class,$keyword]);try{$check=$creator->create($r->user(),$keyword);return redirect()->route('serp-checks.show',$check)->with('status',__('serp_checks.requested'));}catch(\Illuminate\Validation\ValidationException){return back()->withErrors(['serp'=>__('serp_checks.unavailable')]);}}
public function index(Project $customerProject,Keyword $customerKeyword):View{$this->authorize('viewAny',SerpCheck::class);$checks=$customerKeyword->serpChecks()->latest('id')->paginate(15);return view('serp-checks.index',['project'=>$customerProject,'keyword'=>$customerKeyword,'checks'=>$checks]);}
public function store(Request $r,Project $customerProject,Keyword $customerKeyword,SerpCheckCreator $creator):RedirectResponse{$this->authorize('create',[SerpCheck::class,$customerKeyword]);$check=$creator->create($r->user(),$customerKeyword);return redirect()->route('projects.keywords.serp-checks.show',[$customerProject,$customerKeyword,$check])->with('status',__('serp_checks.requested'));}
public function show(Project $customerProject,Keyword $customerKeyword,SerpCheck $customerSerpCheck):View{$this->authorize('view',$customerSerpCheck);$customerSerpCheck->load(['results'=>fn($q)=>$q->orderBy('position')]);$best=$customerSerpCheck->status===SerpCheckStatus::Completed?$customerSerpCheck->results->where('is_project_domain',true)->min('position'):null;return view('serp-checks.show',['project'=>$customerProject,'keyword'=>$customerKeyword,'check'=>$customerSerpCheck,'best'=>$best]);}
public function tracking(Request $r,Project $customerProject,Keyword $customerKeyword,NextCheckAtCalculator $next):RedirectResponse{$this->authorize('update',$customerKeyword);$data=$r->validate(['tracking_enabled'=>['nullable','boolean'],'check_frequency'=>['required',Rule::enum(CheckFrequency::class)]]);$enabled=$r->boolean('tracking_enabled');$customerKeyword->forceFill(['tracking_enabled'=>$enabled,'check_frequency'=>$data['check_frequency'],'next_check_at'=>$enabled?$next->calculate($data['check_frequency']):null])->save();return back()->with('status',__('serp_checks.tracking_updated'));}
}
@@ -0,0 +1,3 @@
<?php
namespace App\Http\Controllers;use App\Models\UserSetting;use App\Services\ProfileAuditLogger;use Illuminate\Http\{RedirectResponse,Request};use Illuminate\Validation\Rule;use Illuminate\View\View;
class SettingsController extends Controller{private function setting(Request$r):UserSetting{return$r->user()->setting()->firstOrCreate([],UserSetting::defaults());}public function show(Request$r):View{$setting=$this->setting($r);$projects=$r->user()->projects()->active()->orderBy('name')->get();return view('settings.show',compact('setting','projects'));}public function appearance(Request$r,ProfileAuditLogger$a):RedirectResponse{$data=$r->validate(['locale'=>['required',Rule::in(['tr','en'])],'theme'=>['required',Rule::in(['light','dark','system'])],'reduced_motion'=>['nullable','boolean']]);$s=$this->setting($r);$before=$s->only(['theme','reduced_motion']);$s->update(['theme'=>$data['theme'],'reduced_motion'=>$r->boolean('reduced_motion')]);$r->user()->forceFill(['locale'=>$data['locale']])->save();$r->session()->put('locale',$data['locale']);$a->record('settings_appearance_updated',$r->user(),$before,$s->only(['theme','reduced_motion']));return back()->with('status',__('settings.saved'));}public function regional(Request$r,ProfileAuditLogger$a):RedirectResponse{$data=$r->validate(['timezone'=>['required',Rule::in(\DateTimeZone::listIdentifiers())],'date_format'=>['required',Rule::in(['DD.MM.YYYY','YYYY-MM-DD','MM/DD/YYYY'])],'time_format'=>['required',Rule::in(['24','12'])]]);return$this->save($r,$a,'settings_regional_updated',$data);}public function dashboard(Request$r,ProfileAuditLogger$a):RedirectResponse{$data=$r->validate(['default_report_range'=>['required',Rule::in(['7_days','30_days','90_days','this_month'])],'default_project'=>['nullable','string'],'per_page'=>['required',Rule::in([10,25,50,100])]]);$project=null;if($r->filled('default_project'))$project=$r->user()->projects()->active()->where('ulid',$r->input('default_project'))->firstOrFail();return$this->save($r,$a,'settings_dashboard_updated',['default_report_range'=>$data['default_report_range'],'default_project_id'=>$project?->id,'per_page'=>(int)$data['per_page']]);}public function notifications(Request$r,ProfileAuditLogger$a):RedirectResponse{$r->validate(['in_app_notifications'=>['nullable','boolean']]);return$this->save($r,$a,'settings_notifications_updated',['in_app_notifications'=>$r->boolean('in_app_notifications')]);}public function reset(Request$r,ProfileAuditLogger$a):RedirectResponse{$s=$this->setting($r);$before=$s->toArray();$s->update(UserSetting::defaults());$r->user()->forceFill(['locale'=>'tr'])->save();$r->session()->put('locale','tr');$a->record('settings_reset',$r->user(),$before,$s->fresh()->toArray());return back()->with('status',__('settings.reset_done'));}private function save(Request$r,ProfileAuditLogger$a,string$action,array$data):RedirectResponse{$s=$this->setting($r);$before=$s->only(array_keys($data));$s->update($data);$a->record($action,$r->user(),$before,$s->only(array_keys($data)));return back()->with('status',__('settings.saved'));}}
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers;
use App\Enums\RoleName;
use App\Models\EarnApiToken;
use App\Models\EarnTaskAssignment;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Kodular istemcisine cihazla eşleştirilmiş JSON görev akışı sağlar.
*
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
*/
class TestTaskController extends Controller
{
/**
* Yapılandırılmış görevleri gerçek müşteri sahibiyle doğrulayıp token ve cihaza tekil olarak atar.
*
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
*/
public function index(Request $request): JsonResponse
{
$allowedHosts = config('test_tasks.allowed_hosts', []);
$tasks = config('test_tasks.tasks', []);
$customerEmail = config('test_tasks.customer_email');
$token = $request->attributes->get('earn_api_token');
$deviceHash = $request->attributes->get('earn_device_hash');
if (! $token instanceof EarnApiToken || ! is_string($deviceHash) || ! is_string($customerEmail)) {
throw new RuntimeException('Test task ownership is not configured.');
}
$customer = User::query()->where('email', $customerEmail)->first();
if (! $customer?->hasRole(RoleName::Customer)) {
throw new RuntimeException('Test task customer is invalid.');
}
$payload = [];
foreach ($tasks as $task) {
$url = $task['url'] ?? null;
if (! is_string($url) || parse_url($url, PHP_URL_SCHEME) !== 'https' || ! in_array(parse_url($url, PHP_URL_HOST), $allowedHosts, true)) {
throw new RuntimeException('Test task configuration contains a URL outside the HTTPS allowlist.');
}
// Görevi ilk istemciye atomik olarak kilitler.
// Yazar: Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
$assignment = DB::transaction(function () use ($task, $customer, $token, $deviceHash): EarnTaskAssignment {
return EarnTaskAssignment::query()->lockForUpdate()->firstOrCreate(
['task_id' => $task['task_id']],
['user_id' => $customer->id, 'earn_member_id' => $token->tokenable_id,
'personal_access_token_id' => $token->id, 'device_id_hash' => $deviceHash,
'url' => $task['url'], 'duration_seconds' => $task['duration_seconds'], 'assigned_at' => now()],
);
}, 3);
if ($assignment->personal_access_token_id !== $token->id
|| ! hash_equals($assignment->device_id_hash, $deviceHash)
|| $assignment->result()->exists()) {
continue;
}
$payload[] = ['CID' => $assignment->user_id, 'task_id' => $assignment->task_id,
'url' => $assignment->url, 'duration_seconds' => $assignment->duration_seconds,
'completed' => false, 'responseCode' => null, 'error' => null];
}
return response()
->json(['tasks' => $payload])
->header('Cache-Control', 'no-store');
}
}
@@ -0,0 +1,4 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\VisibilityReportRequest;use App\Models\Keyword;use App\Services\VisibilityReportService;use App\Support\KeywordTargetOptions;use Illuminate\View\View;
class VisibilityReportController extends Controller{public function __invoke(VisibilityReportRequest $r,VisibilityReportService $service):View{$projects=$r->user()->projects()->active()->orderBy('name')->get(['id','ulid','name']);$keywords=Keyword::query()->active()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id)->whereNull('archived_at'))->with('project:id,ulid,name')->get();if($r->filled('project')){$project=$projects->firstWhere('ulid',$r->input('project'));abort_unless($project,404);$keywords=$keywords->where('project_id',$project->id)->values();}foreach(['country_code'=>'country','language_code'=>'language','device'=>'device','search_engine'=>'engine']as$column=>$key)if($r->filled($key))$keywords=$keywords->where($column,$r->input($key))->values();$from=now()->parse($r->input('from'))->startOfDay();$to=now()->parse($r->input('to'))->endOfDay();$days=$from->diffInDays($to)+1;$previousFrom=$from->copy()->subDays($days);$previousTo=$from->copy()->subSecond();$checks=$service->measurements($keywords->pluck('id'),$from,$to);$previousChecks=$r->input('compare','previous')==='none'?collect():$service->measurements($keywords->pluck('id'),$previousFrom,$previousTo);$latest=$service->latest($checks);$previousLatest=$service->latest($previousChecks);$current=$service->score($latest);$previous=$service->score($previousLatest);$contributions=$service->contributions($latest,$previousLatest);$distribution=$service->distribution($latest,$keywords->count());$metrics=['current'=>$current,'previous'=>$previous,'change'=>$current!==null&&$previous!==null?round($current-$previous,2):null,'measured'=>$latest->count(),'top10'=>$distribution['top3']+$distribution['4_10'],'unranked'=>$distribution['unranked']];return view('reports.visibility',compact('projects','keywords','metrics','distribution')+['trend'=>$service->trend($checks),'previousTrend'=>$service->trend($previousChecks),'winners'=>$contributions->where('delta','>',0)->sortByDesc('delta')->take(5),'losers'=>$contributions->where('delta','<',0)->sortBy('delta')->take(5),'coverage'=>[$latest->count(),$keywords->count()],'countries'=>KeywordTargetOptions::countries(),'languages'=>KeywordTargetOptions::languages(),'formulaVersion'=>VisibilityReportService::FORMULA_VERSION]);}}
@@ -0,0 +1,3 @@
<?php
namespace App\Http\Controllers;use App\Http\Requests\VisibilityReportRequest;use App\Models\Keyword;use App\Services\WinnersReportService;use App\Support\KeywordTargetOptions;use Illuminate\Pagination\LengthAwarePaginator;use Illuminate\View\View;
class WinnersReportController extends Controller{public function __invoke(VisibilityReportRequest$r,WinnersReportService$s):View{$projects=$r->user()->projects()->active()->orderBy('name')->get(['id','ulid','name']);$keywords=Keyword::query()->active()->whereHas('project',fn($q)=>$q->where('user_id',$r->user()->id)->whereNull('archived_at'))->with('project:id,ulid,name')->get();if($r->filled('project')){$p=$projects->firstWhere('ulid',$r->input('project'));abort_unless($p,404);$keywords=$keywords->where('project_id',$p->id)->values();}foreach(['country_code'=>'country','language_code'=>'language','device'=>'device','search_engine'=>'engine']as$column=>$key)if($r->filled($key))$keywords=$keywords->where($column,$r->input($key))->values();if($search=trim($r->string('q')->toString()))$keywords=$keywords->filter(fn($k)=>str_contains(mb_strtolower($k->phrase),mb_strtolower($search)))->values();$rows=$s->winners($s->rows($keywords->pluck('id'),now()->parse($r->input('from'))->startOfDay(),now()->parse($r->input('to'))->endOfDay()));if($r->filled('min_gained'))$rows=$rows->filter(fn($x)=>($x['gained']??0)>=(int)$r->input('min_gained'))->values();$sort=in_array($r->input('sort'),['current','gained','contribution','checked_at'],true)?$r->input('sort'):'gained';$rows=$rows->sortByDesc($sort)->values();$page=max(1,(int)$r->input('page',1));$pager=new LengthAwarePaginator($rows->forPage($page,25)->values(),$rows->count(),25,$page,['path'=>$r->url(),'query'=>$r->query()]);return view('reports.winners',['projects'=>$projects,'rows'=>$pager,'summary'=>$s->summary($rows),'distribution'=>$s->distribution($rows),'countries'=>KeywordTargetOptions::countries(),'languages'=>KeywordTargetOptions::languages()]);}}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Support\PortalSession;
use Closure;
use Filament\Facades\Filament;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateAdminPortal
{
public function __construct(private readonly PortalSession $sessions) {}
public function handle(Request $request, Closure $next): Response
{
$guard = Filament::auth();
if (! $guard->check()) {
return redirect()->guest(Filament::getLoginUrl());
}
$user = $guard->user();
if (! $user->canAccessAdminPortal()) {
$this->sessions->terminate($request);
return redirect()->route('login')->withErrors(['email' => __('auth.portal_denied')]);
}
Auth::shouldUse(Filament::getAuthGuard());
return $next($request);
}
}
@@ -0,0 +1,118 @@
<?php
namespace App\Http\Middleware;
use App\Enums\EarnApiTokenStatus;
use App\Models\EarnApiToken;
use App\Models\EarnMember;
use App\Services\EarnApiAccessLogger;
use App\Services\EarnDeviceIdentity;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateEarnApiToken
{
public function __construct(private EarnDeviceIdentity $devices, private EarnApiAccessLogger $logs) {}
/**
* Earn token yaşam döngüsünü, yeteneğini, cihaz bağını ve moda özel hız sınırını doğrular.
*
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
*/
public function handle(Request $request, Closure $next, string $ability, string $mode = 'tasks'): Response
{
$plain = $request->bearerToken();
if (! is_string($plain) || $plain === '') {
return $this->deny($request, 'TOKEN_MISSING', 401);
}
$token = EarnApiToken::findToken($plain);
if (! $token instanceof EarnApiToken || ! $token->tokenable instanceof EarnMember) {
return $this->deny($request, 'TOKEN_INVALID', 401);
}
$request->attributes->set('earn_api_token', $token);
$code = match ($token->status()) {
EarnApiTokenStatus::Inactive => 'TOKEN_INACTIVE', EarnApiTokenStatus::Expired => 'TOKEN_EXPIRED',
EarnApiTokenStatus::Revoked => 'TOKEN_REVOKED', default => null,
};
if ($code) {
return $this->deny($request, $code, 401, 'token_'.strtolower(substr($code, 6)));
}
$member = $token->tokenable;
if ($member->trashed() || $member->status->value !== 'active') {
return $this->deny($request, 'MEMBER_INACTIVE', 403, 'member_inactive');
}
if (! $member->hasVerifiedEmail()) {
return $this->deny($request, 'EMAIL_NOT_VERIFIED', 403);
}
if (! $token->can($ability)) {
return $this->deny($request, 'TOKEN_INVALID', 401);
}
$deviceId = $request->header('X-Device-ID');
if (! is_string($deviceId)) {
return $this->deny($request, 'DEVICE_ID_MISSING', 422);
}
$deviceId = $this->devices->normalize($deviceId);
if (! $this->devices->valid($deviceId)) {
return $this->deny($request, 'DEVICE_ID_INVALID', 422);
}
$hash = $this->devices->hash($deviceId);
$preview = $this->devices->preview($deviceId);
$request->attributes->set('earn_device_hash', $hash);
$request->attributes->set('earn_device_preview', $preview);
$limit = (int) config(match ($mode) {
'bind' => 'earn.api.bind_per_minute',
'results' => 'earn.api.task_results_per_minute',
default => 'earn.api.tasks_per_minute',
});
$rateKey = 'earn-api:'.$mode.':'.hash('sha256', $token->id.'|'.$hash.'|'.$request->ip());
if (RateLimiter::tooManyAttempts($rateKey, $limit)) {
$this->logs->record($request, $mode === 'results' ? 'task_result_rate_limited' : 'rate_limited', 429, 'RATE_LIMITED', $token, $hash, $preview);
return $this->error('RATE_LIMITED', 429)->header('Retry-After', (string) RateLimiter::availableIn($rateKey));
}
RateLimiter::hit($rateKey, 60);
if (in_array($mode, ['tasks', 'results'], true) && $token->device_id_hash === null) {
return $this->deny($request, 'DEVICE_NOT_BOUND', 409, 'device_not_bound', $hash, $preview);
}
if (in_array($mode, ['tasks', 'results'], true) && ! hash_equals($token->device_id_hash, $hash)) {
$failedKey = 'earn-api:failed-device:'.hash('sha256', $token->id.'|'.$request->ip());
$failedLimit = (int) config('earn.api.failed_device_per_minute');
if (RateLimiter::tooManyAttempts($failedKey, $failedLimit)) {
$this->logs->record($request, 'rate_limited', 429, 'RATE_LIMITED', $token, $hash, $preview);
return $this->error('RATE_LIMITED', 429)->header('Retry-After', (string) RateLimiter::availableIn($failedKey));
}
RateLimiter::hit($failedKey, 60);
$token->forceFill(['device_mismatch_seen' => true])->saveQuietly();
return $this->deny($request, 'DEVICE_MISMATCH', 409, 'device_mismatch', $hash, $preview);
}
$member->withAccessToken($token);
$request->setUserResolver(fn () => $member);
$response = $next($request);
if (in_array($mode, ['tasks', 'results'], true) && $response->isSuccessful()) {
if ($token->last_ip !== null && $token->last_ip !== $request->ip()) {
$this->logs->record($request, 'ip_changed', $response->getStatusCode(), null, $token, $hash, $preview);
}
$token->forceFill(['last_used_at' => now(), 'last_seen_at' => now(), 'last_ip' => $request->ip()])->saveQuietly();
$this->logs->record($request, $mode === 'results' ? 'task_results_access_granted' : 'tasks_access_granted', $response->getStatusCode(), null, $token, $hash, $preview);
}
return $response;
}
private function deny(Request $request, string $code, int $status, string $event = 'tasks_access_denied', ?string $hash = null, ?string $preview = null): JsonResponse
{
$this->logs->record($request, $event, $status, $code, null, $hash, $preview);
return $this->error($code, $status);
}
private function error(string $code, int $status): JsonResponse
{
return response()->json(['success' => false, 'error' => ['code' => $code, 'message' => __('earn_api.'.$code)]], $status)->header('Cache-Control', 'no-store');
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use App\Support\PortalSession;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdminPortalAccess
{
public function __construct(private readonly PortalSession $sessions) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user || $user->canAccessAdminPortal()) {
return $next($request);
}
$this->sessions->terminate($request);
return redirect()->route('login')->withErrors(['email' => __('auth.portal_denied')]);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Middleware;
use App\Support\PortalSession;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureCustomerPortalAccess
{
public function __construct(private readonly PortalSession $sessions) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user?->canAccessCustomerPortal()) {
return $next($request);
}
$admin = $user?->canAccessAdminPortal() ?? false;
$this->sessions->terminate($request);
return redirect()->to($admin ? '/admin/login' : route('login'))
->withErrors(['email' => __('auth.portal_denied')]);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureEarnEmailIsVerified
{
public function handle(Request $request, Closure $next): Response
{
if (! auth('earn')->user()?->hasVerifiedEmail()) {
return redirect()->route('earn.verification.notice');
}
return $next($request);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use App\Enums\EarnMemberStatus;
use App\Models\EarnMember;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureEarnMemberActive
{
public function handle(Request $request, Closure $next): Response
{
$member = auth('earn')->user();
if (! $member instanceof EarnMember || $member->trashed() || $member->status !== EarnMemberStatus::Active) {
auth('earn')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('earn.login')->withErrors([
'email' => __('earn_portal.account_unavailable'),
]);
}
return $next($request);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsActive
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user() && ! $request->user()->isActive()) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->withErrors(['email' => __('auth.inactive')]);
}
return $next($request);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class SetLocale
{
public const SUPPORTED = ['tr', 'en'];
public function handle(Request $request, Closure $next): Response
{
$userLocale = $request->user()?->locale;
$storedLocale = $request->session()->get('locale', $request->cookie('locale'));
$locale = in_array($userLocale, self::SUPPORTED, true)
? $userLocale
: (in_array($storedLocale, self::SUPPORTED, true) ? $storedLocale : config('app.locale', 'tr'));
if (! in_array($locale, self::SUPPORTED, true)) {
$locale = 'tr';
}
App::setLocale($locale);
$request->session()->put('locale', $locale);
return $next($request);
}
}
@@ -0,0 +1,3 @@
<?php
namespace App\Http\Requests;use Illuminate\Validation\Rule;
class LosersReportRequest extends VisibilityReportRequest{public function rules():array{return parent::rules()+['loss_type'=>['nullable',Rule::in(['top3','top10','top100','dropped_out'])],'min_lost'=>['nullable','integer','min:1','max:100'],'min_visibility_loss'=>['nullable','numeric','min:0','max:100'],'rank_range'=>['nullable',Rule::in(['top3','top10','top20','top100','unranked'])]];}}
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Requests;
use App\Enums\{KeywordDevice,SearchEngine};use App\Support\KeywordTargetOptions;use Illuminate\Foundation\Http\FormRequest;use Illuminate\Validation\Rule;
class RankingHistoryRequest extends FormRequest{
public function authorize():bool{return $this->user()!==null;}
protected function prepareForValidation():void{$range=$this->input('range','30d');$now=now();[$from,$to]=match($range){'7d'=>[$now->copy()->subDays(6)->startOfDay(),$now->copy()->endOfDay()],'90d'=>[$now->copy()->subDays(89)->startOfDay(),$now->copy()->endOfDay()],'this_month'=>[$now->copy()->startOfMonth(),$now->copy()->endOfDay()],'last_month'=>[$now->copy()->subMonthNoOverflow()->startOfMonth(),$now->copy()->subMonthNoOverflow()->endOfMonth()],'custom'=>[$this->input('from'),$this->input('to')],default=>[$now->copy()->subDays(29)->startOfDay(),$now->copy()->endOfDay()]};if($range!=='custom')$this->merge(['range'=>in_array($range,['7d','30d','90d','this_month','last_month'],true)?$range:'30d','from'=>$from->toDateString(),'to'=>$to->toDateString()]);}
public function rules():array{return ['range'=>['required',Rule::in(['7d','30d','90d','this_month','last_month','custom'])],'from'=>['required','date','before_or_equal:to','before_or_equal:today','after_or_equal:'.now()->subYears(2)->toDateString()],'to'=>['required','date','after_or_equal:from','before_or_equal:today'],'project'=>['nullable','string'],'keywords'=>['nullable','array','max:10'],'keywords.*'=>['string'],'engine'=>['nullable',Rule::enum(SearchEngine::class)],'country'=>['nullable',Rule::in(array_keys(KeywordTargetOptions::countries()))],'language'=>['nullable',Rule::in(array_keys(KeywordTargetOptions::languages()))],'device'=>['nullable',Rule::enum(KeywordDevice::class)],'movement'=>['nullable',Rule::in(['winner','loser','unchanged','new','unranked'])]];}
}
@@ -0,0 +1,3 @@
<?php
namespace App\Http\Requests;use App\Services\ProjectUrlNormalizer;use Illuminate\Foundation\Http\FormRequest;use Illuminate\Validation\ValidationException;
class StoreCompetitorRequest extends FormRequest{public function authorize():bool{return$this->user()!==null;}protected function prepareForValidation():void{try{$n=app(ProjectUrlNormalizer::class)->normalize((string)$this->input('domain'));$this->merge(['normalized_domain'=>$n->domain,'domain'=>$n->domain,'name'=>trim((string)$this->input('name'))?:$n->domain]);}catch(\InvalidArgumentException$e){throw ValidationException::withMessages(['domain'=>__('competitors.invalid_domain')]);}}public function rules():array{return['name'=>['required','string','max:150'],'domain'=>['required','string','max:253'],'normalized_domain'=>['required','string','max:253']];}}
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
class StoreEarnTaskResultsRequest extends FormRequest
{
/**
* Token ve cihaz yetkilendirmesi middleware'de tamamlandığı için isteğin doğrulanmasına izin verir.
*
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
*/
public function authorize(): bool
{
return true;
}
/**
* Görev sonucu listesinin tür, boyut ve HTTP durum alanlarını katı biçimde doğrular.
*
* @return array<string, mixed>
*
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
*/
public function rules(): array
{
return [
'tasks' => ['required', 'array', 'min:1', 'max:50'],
'tasks.*.CID' => ['required', 'integer', 'min:1'],
'tasks.*.task_id' => ['required', 'string', 'max:64'],
'tasks.*.completed' => ['required'],
'tasks.*.responseCode' => ['nullable', 'integer', 'between:100,599'],
'tasks.*.error' => ['nullable', 'string', 'max:'.config('earn.api.task_result_max_error_length')],
];
}
/**
* Boolean ve başarı alanlarının sessiz tür dönüşümü olmadan tutarlı olmasını denetler.
*
* @return array<int, callable(Validator): void>
*
* @author Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
*/
public function after(): array
{
// Tür dönüşümü yapmadan kayıtlar arası tutarlılığı denetler.
// Yazar: Gökhan Engin - İletişim : https://bit.ly/YazılımUzmanı
return [function (Validator $validator): void {
foreach ((array) $this->input('tasks', []) as $index => $task) {
if (! is_int($task['CID'] ?? null)) {
$validator->errors()->add("tasks.$index.CID", 'The CID field must be a JSON integer.');
}
if (array_key_exists('responseCode', $task) && $task['responseCode'] !== null && ! is_int($task['responseCode'])) {
$validator->errors()->add("tasks.$index.responseCode", 'The responseCode field must be a JSON integer or null.');
}
if (! is_bool($task['completed'] ?? null)) {
$validator->errors()->add("tasks.$index.completed", 'The completed field must be a JSON boolean.');
continue;
}
if ($task['completed'] === true && (($task['error'] ?? null) !== null
|| ! isset($task['responseCode']) || $task['responseCode'] < 200 || $task['responseCode'] > 399)) {
$validator->errors()->add("tasks.$index.completed", 'A completed task requires a 2xx/3xx response and no error.');
}
}
}];
}
}
@@ -0,0 +1,4 @@
<?php
namespace App\Http\Requests;
use App\Enums\{KeywordDevice,SearchEngine};use App\Models\{Keyword,Project};use App\Support\KeywordTargetOptions;use Illuminate\Foundation\Http\FormRequest;use Illuminate\Validation\{Rule,Validator};
class StoreKeywordRequest extends FormRequest{protected function prepareForValidation():void{if(!$this->has('phrases')&&$this->has('phrase'))$this->merge(['phrases'=>$this->input('phrase')]);}public function project():?Project{return $this->route('customerProject')?:$this->user()?->projects()->active()->where('ulid',$this->input('project'))->first();}public function authorize():bool{$p=$this->project();return $p&&($this->user()?->can('create',[Keyword::class,$p])??false);}public function rules():array{$legacy=$this->has('phrase');return ['project'=>['nullable','string'],'phrases'=>$legacy?['nullable','string']:['required','string','max:20000'],'phrase'=>$legacy?['required','string','max:190']:['nullable'],'search_engine'=>['required',Rule::enum(SearchEngine::class)],'country_code'=>['required',Rule::in(array_keys(KeywordTargetOptions::countries()))],'language_code'=>['required',Rule::in(array_keys(KeywordTargetOptions::languages()))],'device'=>['required',Rule::enum(KeywordDevice::class)]];}public function withValidator(Validator $v):void{$v->after(function(Validator $v){if(!$this->project())$v->errors()->add('project',__('keywords.validation.project'));if($this->filled(['country_code','language_code'])&&!KeywordTargetOptions::supports($this->input('country_code'),$this->input('language_code')))$v->errors()->add('language_code',__('keywords.validation.target_pair'));$lines=preg_split('/\R/u',(string)$this->input('phrases'))?:[];if(count(array_filter($lines,fn($x)=>trim($x)!==''))>100)$v->errors()->add($this->has('phrase')?'phrase':'phrases',__('keywords.validation.limit',['count'=>100]));});}}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use App\Services\ProjectUrlNormalizer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use InvalidArgumentException;
class StoreProjectRequest extends FormRequest
{
public function authorize(): bool { return $this->user()?->can('create', Project::class) ?? false; }
public function rules(): array { return ['name' => ['required', 'string', 'max:150'], 'website_url' => ['required', 'string', 'max:2048']]; }
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
if ($validator->errors()->has('website_url')) return;
try {
$normalized = app(ProjectUrlNormalizer::class)->normalize((string) $this->input('website_url'));
if ($this->user()->projects()->where('domain', $normalized->domain)->exists()) {
$validator->errors()->add('website_url', 'Bu alan adı hesabınızda zaten kayıtlı.');
}
} catch (InvalidArgumentException $exception) {
$validator->errors()->add('website_url', $exception->getMessage());
}
});
}
}
@@ -0,0 +1,4 @@
<?php
namespace App\Http\Requests;
use App\Enums\{KeywordDevice,KeywordStatus};use App\Models\Keyword;use App\Support\KeywordTargetOptions;use Illuminate\Foundation\Http\FormRequest;use Illuminate\Validation\{Rule,Validator};
class UpdateKeywordRequest extends FormRequest{public function keyword():?Keyword{return $this->route('customerKeyword')?:$this->route('keyword');}public function authorize():bool{$k=$this->keyword();return $k&&$k->archived_at===null&&$k->status!==KeywordStatus::Archived&&($this->user()?->can('update',$k)??false);}public function rules():array{return ['phrase'=>['required','string','max:190'],'country_code'=>['required',Rule::in(array_keys(KeywordTargetOptions::countries()))],'language_code'=>['required',Rule::in(array_keys(KeywordTargetOptions::languages()))],'device'=>['required',Rule::enum(KeywordDevice::class)],'status'=>['required',Rule::in([KeywordStatus::Active->value,KeywordStatus::Inactive->value])]];}public function withValidator(Validator $v):void{$v->after(function(Validator $v){if($this->filled(['country_code','language_code'])&&!KeywordTargetOptions::supports($this->input('country_code'),$this->input('language_code')))$v->errors()->add('language_code',__('keywords.validation.target_pair'));});}}

Some files were not shown because too many files have changed in this diff Show More