43
common/Domains/Actions/DeleteCustomDomains.php
Executable file
43
common/Domains/Actions/DeleteCustomDomains.php
Executable file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace Common\Domains\Actions;
|
||||
|
||||
|
||||
use Common\Domains\CustomDomain;
|
||||
use Common\Domains\DeletedCustomDomains;
|
||||
use Common\Settings\Settings;
|
||||
|
||||
class DeleteCustomDomains
|
||||
{
|
||||
/**
|
||||
* @var CustomDomain
|
||||
*/
|
||||
private $customDomain;
|
||||
|
||||
/**
|
||||
* @param CustomDomain $customDomain
|
||||
*/
|
||||
public function __construct(CustomDomain $customDomain)
|
||||
{
|
||||
$this->customDomain = $customDomain;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $domainIds
|
||||
*/
|
||||
public function execute($domainIds)
|
||||
{
|
||||
$hosts = $this->customDomain->whereIn('id', $domainIds)->pluck('host');
|
||||
|
||||
// unset default host, if matching custom_domain is removed
|
||||
$defaultHost = app(Settings::class)->get('custom_domains.default_host');
|
||||
if ($defaultHost && $hosts->contains($defaultHost)) {
|
||||
app(Settings::class)->save(['custom_domains.default_host' => null]);
|
||||
}
|
||||
|
||||
$this->customDomain->whereIn('id', $domainIds)->delete();
|
||||
|
||||
event(new DeletedCustomDomains($domainIds));
|
||||
}
|
||||
}
|
||||
110
common/Domains/CustomDomain.php
Executable file
110
common/Domains/CustomDomain.php
Executable file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Domains;
|
||||
|
||||
use App\Models\User;
|
||||
use Common\Core\BaseModel;
|
||||
use Common\Workspaces\Traits\BelongsToWorkspace;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class CustomDomain extends BaseModel
|
||||
{
|
||||
use HasFactory, BelongsToWorkspace;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
const MODEL_TYPE = 'customDomain';
|
||||
|
||||
protected $casts = [
|
||||
'id' => 'integer',
|
||||
'global' => 'boolean',
|
||||
'resource_id' => 'int',
|
||||
];
|
||||
|
||||
protected $appends = ['model_type'];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function resource(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit query to only custom domains specified user has access to.
|
||||
*/
|
||||
public function scopeForUser(Builder $query, int $userId): Builder
|
||||
{
|
||||
return $query
|
||||
->leftJoin(
|
||||
'workspace_user',
|
||||
'workspace_user.workspace_id',
|
||||
'=',
|
||||
'custom_domains.workspace_id',
|
||||
)
|
||||
->where($query->qualifyColumn('user_id'), $userId)
|
||||
->orWhere($query->qualifyColumn('global'), true)
|
||||
->orWhere('workspace_user.user_id', $userId);
|
||||
}
|
||||
|
||||
public function getHostAttribute(?string $value): ?string
|
||||
{
|
||||
return parse_url($value, PHP_URL_SCHEME) === null
|
||||
? "https://$value"
|
||||
: $value;
|
||||
}
|
||||
|
||||
public function setHostAttribute(string $value)
|
||||
{
|
||||
$this->attributes['host'] = trim($value, '/');
|
||||
}
|
||||
|
||||
public function toNormalizedArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->host,
|
||||
'model_type' => self::MODEL_TYPE,
|
||||
];
|
||||
}
|
||||
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'host' => $this->host,
|
||||
'user_id' => $this->user_id,
|
||||
'created_at' => $this->created_at->timestamp ?? '_null',
|
||||
'updated_at' => $this->updated_at->timestamp ?? '_null',
|
||||
'global' => $this->global,
|
||||
'workspace_id' => $this->workspace_id ?? '_null',
|
||||
];
|
||||
}
|
||||
|
||||
public static function filterableFields(): array
|
||||
{
|
||||
return [
|
||||
'id',
|
||||
'user_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'global',
|
||||
'workspace_id',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function newFactory(): CustomDomainFactory
|
||||
{
|
||||
return CustomDomainFactory::new();
|
||||
}
|
||||
|
||||
public static function getModelTypeAttribute(): string
|
||||
{
|
||||
return static::MODEL_TYPE;
|
||||
}
|
||||
}
|
||||
199
common/Domains/CustomDomainController.php
Executable file
199
common/Domains/CustomDomainController.php
Executable file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Domains;
|
||||
|
||||
use Arr;
|
||||
use Auth;
|
||||
use Common\Core\AppUrl;
|
||||
use Common\Core\BaseController;
|
||||
use Common\Database\Datasource\Datasource;
|
||||
use Common\Domains\Actions\DeleteCustomDomains;
|
||||
use Common\Domains\Validation\HostIsNotBlacklisted;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CustomDomainController extends BaseController
|
||||
{
|
||||
const VALIDATE_CUSTOM_DOMAIN_PATH = 'secure/custom-domain/validate/2BrM45vvfS';
|
||||
|
||||
public function __construct(
|
||||
protected CustomDomain $customDomain,
|
||||
protected Request $request,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->request->get('userId');
|
||||
$this->authorize('index', [get_class($this->customDomain), $userId]);
|
||||
|
||||
$builder = $this->customDomain->newQuery();
|
||||
if ($userId) {
|
||||
$builder->where('user_id', '=', $userId);
|
||||
}
|
||||
|
||||
$datasource = new Datasource($builder, $this->request->all());
|
||||
|
||||
return $this->success(['pagination' => $datasource->paginate()]);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$this->authorize('store', get_class($this->customDomain));
|
||||
|
||||
$this->validate($this->request, [
|
||||
'host' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:100',
|
||||
Rule::unique('custom_domains'),
|
||||
new HostIsNotBlacklisted(),
|
||||
],
|
||||
'global' => 'boolean',
|
||||
]);
|
||||
|
||||
$domain = $this->customDomain->create([
|
||||
'host' => $this->request->get('host'),
|
||||
'user_id' => Auth::id(),
|
||||
'global' => $this->request->get('global', false),
|
||||
]);
|
||||
|
||||
return $this->success(['domain' => $domain]);
|
||||
}
|
||||
|
||||
public function update(CustomDomain $customDomain)
|
||||
{
|
||||
$this->authorize('store', $customDomain);
|
||||
|
||||
$this->validate($this->request, [
|
||||
'host' => [
|
||||
'string',
|
||||
'max:100',
|
||||
Rule::unique('custom_domains')->ignore($customDomain->id),
|
||||
new HostIsNotBlacklisted(),
|
||||
],
|
||||
'global' => 'boolean',
|
||||
'resource_id' => 'nullable|integer',
|
||||
'resource_type' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$data = $this->request->all();
|
||||
$data['user_id'] = Auth::id();
|
||||
$data['global'] = $this->request->get('global', $customDomain->global);
|
||||
$customDomain->update($data);
|
||||
|
||||
return $this->success(['domain' => $customDomain]);
|
||||
}
|
||||
|
||||
public function destroy(string $ids)
|
||||
{
|
||||
$domainIds = explode(',', $ids);
|
||||
$this->authorize('destroy', [
|
||||
get_class($this->customDomain),
|
||||
$domainIds,
|
||||
]);
|
||||
|
||||
app(DeleteCustomDomains::class)->execute($domainIds);
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
public function authorizeCrupdate()
|
||||
{
|
||||
$this->authorize('store', get_class($this->customDomain));
|
||||
|
||||
$domainId = $this->request->get('domainId');
|
||||
|
||||
// don't allow attaching current site url as custom domain
|
||||
if (
|
||||
app(AppUrl::class)->requestHostMatches($this->request->get('host'))
|
||||
) {
|
||||
return $this->error('', [
|
||||
'host' => __(
|
||||
"Current site url can't be attached as custom domain.",
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->validate($this->request, [
|
||||
'host' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:100',
|
||||
Rule::unique('custom_domains')->ignore($domainId),
|
||||
new HostIsNotBlacklisted(),
|
||||
],
|
||||
]);
|
||||
|
||||
return $this->success([
|
||||
'serverIp' => $this->getServerIp(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy method for validation on frontend to avoid cross-domain issues.
|
||||
*/
|
||||
public function validateDomainApi()
|
||||
{
|
||||
$this->validate($this->request, [
|
||||
'host' => ['required', 'string', new HostIsNotBlacklisted()],
|
||||
]);
|
||||
|
||||
$failReason = '';
|
||||
|
||||
try {
|
||||
$host = parse_url($this->request->get('host'), PHP_URL_HOST);
|
||||
$dns = dns_get_record($host ?? $this->request->get('host'));
|
||||
} catch (Exception $e) {
|
||||
$dns = [];
|
||||
}
|
||||
|
||||
$recordWithIp = Arr::first($dns, fn($record) => isset($record['ip']));
|
||||
if (
|
||||
empty($dns) ||
|
||||
(isset($recordWithIp) &&
|
||||
$recordWithIp['ip'] !== $this->getServerIp())
|
||||
) {
|
||||
$failReason = 'dnsNotSetup';
|
||||
}
|
||||
|
||||
$host = trim($this->request->get('host'), '/');
|
||||
try {
|
||||
$response = Http::get(
|
||||
"$host/" . self::VALIDATE_CUSTOM_DOMAIN_PATH,
|
||||
)->json();
|
||||
} catch (ConnectionException $e) {
|
||||
$response = [];
|
||||
$failReason = 'serverNotConfigured';
|
||||
}
|
||||
|
||||
if (Arr::get($response, 'result') === 'connected') {
|
||||
return $response;
|
||||
} else {
|
||||
$failReason = 'serverNotConfigured';
|
||||
}
|
||||
|
||||
return $this->error(__('Could not validate domain.'), [], 422, [
|
||||
'failReason' => $failReason,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method for validating if CNAME for custom domain was attached properly.
|
||||
* @return Response
|
||||
*/
|
||||
public function validateDomain()
|
||||
{
|
||||
return $this->success(['result' => 'connected']);
|
||||
}
|
||||
|
||||
private function getServerIp(): string
|
||||
{
|
||||
return env('SERVER_IP') ??
|
||||
(env('SERVER_ADDR') ?? (env('LOCAL_ADDR') ?? env('REMOTE_ADDR')));
|
||||
}
|
||||
}
|
||||
25
common/Domains/CustomDomainFactory.php
Executable file
25
common/Domains/CustomDomainFactory.php
Executable file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Domains;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class CustomDomainFactory extends Factory
|
||||
{
|
||||
protected $model = CustomDomain::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'host' => $this->faker->unique()->domainName,
|
||||
'global' => false,
|
||||
'resource_id' => null,
|
||||
'resource_type' => null,
|
||||
'workspace_id' => 0,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
'user_id' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
46
common/Domains/CustomDomainPolicy.php
Executable file
46
common/Domains/CustomDomainPolicy.php
Executable file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Domains;
|
||||
|
||||
use App\Models\User;
|
||||
use Common\Core\Policies\BasePolicy;
|
||||
|
||||
class CustomDomainPolicy extends BasePolicy
|
||||
{
|
||||
public $permissionName = 'custom_domains';
|
||||
|
||||
public function index(User $user, int $userId = null)
|
||||
{
|
||||
return $user->hasPermission("$this->permissionName.view") ||
|
||||
$user->id === $userId;
|
||||
}
|
||||
|
||||
public function show(User $user, CustomDomain $customDomain)
|
||||
{
|
||||
return $user->hasPermission("$this->permissionName.view") ||
|
||||
$customDomain->user_id === $user->id;
|
||||
}
|
||||
|
||||
public function store(User $user)
|
||||
{
|
||||
return $this->storeWithCountRestriction($user, CustomDomain::class);
|
||||
}
|
||||
|
||||
public function update(User $user)
|
||||
{
|
||||
return $user->hasPermission("$this->permissionName.update");
|
||||
}
|
||||
|
||||
public function destroy(User $user, array $domainIds)
|
||||
{
|
||||
if ($user->hasPermission("$this->permissionName.delete")) {
|
||||
return true;
|
||||
} else {
|
||||
$dbCount = app(CustomDomain::class)
|
||||
->whereIn('id', $domainIds)
|
||||
->where('user_id', $user->id)
|
||||
->count();
|
||||
return $dbCount === count($domainIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
common/Domains/CustomDomainsEnabled.php
Executable file
18
common/Domains/CustomDomainsEnabled.php
Executable file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Domains;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CustomDomainsEnabled
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if ( ! config('common.site.enable_custom_domains')) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
20
common/Domains/DeletedCustomDomains.php
Executable file
20
common/Domains/DeletedCustomDomains.php
Executable file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Domains;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class DeletedCustomDomains
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public $domainIds;
|
||||
|
||||
/**
|
||||
* @param int[] $domainIds
|
||||
*/
|
||||
public function __construct($domainIds)
|
||||
{
|
||||
$this->domainIds = $domainIds;
|
||||
}
|
||||
}
|
||||
34
common/Domains/Validation/HostIsNotBlacklisted.php
Executable file
34
common/Domains/Validation/HostIsNotBlacklisted.php
Executable file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Domains\Validation;
|
||||
|
||||
use Illuminate\Contracts\Validation\InvokableRule;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class HostIsNotBlacklisted implements InvokableRule
|
||||
{
|
||||
public function __invoke($attribute, mixed $value, $fail): void
|
||||
{
|
||||
$message = __("$value can't be used as a branded domain.");
|
||||
$blacklist =
|
||||
settings('links.blacklist.domains') ??
|
||||
settings('blacklist.domains');
|
||||
if ($blacklist) {
|
||||
$blacklist = collect(explode(',', $blacklist))->map(
|
||||
fn($item) => trim($item),
|
||||
);
|
||||
|
||||
if ($blacklist->some(fn($item) => Str::contains($value, $item))) {
|
||||
$fail($message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!(new ValidateLinkWithGoogleSafeBrowsing())->execute($value)) {
|
||||
$fail($message);
|
||||
}
|
||||
|
||||
if (!(new ValidateLinkWithPhishtank())->execute($value)) {
|
||||
$fail($message);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
common/Domains/Validation/ValidateLinkWithGoogleSafeBrowsing.php
Executable file
43
common/Domains/Validation/ValidateLinkWithGoogleSafeBrowsing.php
Executable file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Domains\Validation;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class ValidateLinkWithGoogleSafeBrowsing
|
||||
{
|
||||
public function execute(string $url): bool
|
||||
{
|
||||
$key = settings('links.google_safe_browsing_key');
|
||||
if (!$key) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Referer' => config('app.url'),
|
||||
])
|
||||
->post(
|
||||
"https://safebrowsing.googleapis.com/v4/threatMatches:find?key=$key",
|
||||
[
|
||||
'client' => [
|
||||
'clientId' => config('app.name'),
|
||||
'clientVersion' => config('common.site.version'),
|
||||
],
|
||||
'threatInfo' => [
|
||||
'threatTypes' => [
|
||||
'MALWARE',
|
||||
'SOCIAL_ENGINEERING',
|
||||
'THREAT_TYPE_UNSPECIFIED',
|
||||
],
|
||||
'platformTypes' => ['ANY_PLATFORM'],
|
||||
'threatEntryTypes' => ['URL'],
|
||||
'threatEntries' => [['url' => $url]],
|
||||
],
|
||||
],
|
||||
)
|
||||
->throw();
|
||||
|
||||
return Arr::get($response, 'matches.0.threatType') === null;
|
||||
}
|
||||
}
|
||||
32
common/Domains/Validation/ValidateLinkWithPhishtank.php
Executable file
32
common/Domains/Validation/ValidateLinkWithPhishtank.php
Executable file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Domains\Validation;
|
||||
|
||||
use Common\Core\HttpClient;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ValidateLinkWithPhishtank
|
||||
{
|
||||
public function execute(string $url): bool
|
||||
{
|
||||
$key = settings('links.phishtank_key');
|
||||
if (!$key) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$appName = config('app.name');
|
||||
$response = HttpClient::post(
|
||||
'https://checkurl.phishtank.com/checkurl/',
|
||||
[
|
||||
'headers' => ['User-Agent' => "phishtank/$appName"],
|
||||
'form_params' => [
|
||||
'format' => 'json',
|
||||
'app_key' => $key,
|
||||
'url' => $url,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return Arr::get($response, 'results.valid', false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user