Proje dosyaları eklendi
This commit is contained in:
@@ -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);}}
|
||||
@@ -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()];}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]));});}}
|
||||
@@ -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'));});}}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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 UpdateProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$project = $this->route('customerProject');
|
||||
return $project instanceof Project && ($this->user()?->can('update', $project) ?? 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 {
|
||||
$project = $this->route('customerProject');
|
||||
$normalized = app(ProjectUrlNormalizer::class)->normalize((string) $this->input('website_url'));
|
||||
if ($this->user()->projects()->where('domain', $normalized->domain)->whereKeyNot($project->id)->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,3 @@
|
||||
<?php
|
||||
namespace App\Http\Requests;use App\Enums\{KeywordDevice,SearchEngine};use App\Support\KeywordTargetOptions;use Illuminate\Foundation\Http\FormRequest;use Illuminate\Validation\Rule;
|
||||
class VisibilityReportRequest 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),$now],'90d'=>[$now->copy()->subDays(89),$now],'this_month'=>[$now->copy()->startOfMonth(),$now],'last_month'=>[$now->copy()->subMonthNoOverflow()->startOfMonth(),$now->copy()->subMonthNoOverflow()->endOfMonth()],'custom'=>[$this->input('from'),$this->input('to')],default=>[$now->copy()->subDays(29),$now]};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'],'compare'=>['nullable',Rule::in(['previous','none'])],'project'=>['nullable','string'],'country'=>['nullable',Rule::in(array_keys(KeywordTargetOptions::countries()))],'language'=>['nullable',Rule::in(array_keys(KeywordTargetOptions::languages()))],'device'=>['nullable',Rule::enum(KeywordDevice::class)],'engine'=>['nullable',Rule::enum(SearchEngine::class)],'q'=>['nullable','string','max:190'],'min_gained'=>['nullable','integer','min:1','max:100'],'sort'=>['nullable',Rule::in(['current','gained','contribution','lost','visibility_loss','checked_at'])]];}}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use Filament\Auth\Http\Responses\Contracts\LoginResponse as LoginResponseContract;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Livewire\Features\SupportRedirects\Redirector;
|
||||
|
||||
class AdminLoginResponse implements LoginResponseContract
|
||||
{
|
||||
public function toResponse($request): RedirectResponse|Redirector
|
||||
{
|
||||
session()->forget('url.intended');
|
||||
|
||||
return redirect()->to(Filament::getUrl());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user