first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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
View 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;
}
}

View 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')));
}
}

View 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,
];
}
}

View 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);
}
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}
}

View 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;
}
}

View 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);
}
}