Page MenuHomePhorge

No OneTemporary

Size
150 KB
Referenced Files
None
Subscribers
None
diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php
index 7e5a3ccd..432c4bc8 100644
--- a/src/app/AuthAttempt.php
+++ b/src/app/AuthAttempt.php
@@ -1,196 +1,194 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Iatstuti\Database\Support\NullableFields;
use App\Traits\UuidStrKeyTrait;
use Carbon\Carbon;
/**
* The eloquent definition of an AuthAttempt.
*
* An AuthAttempt represents an authenticaton attempt from an application/client.
*/
class AuthAttempt extends Model
{
use NullableFields;
use UuidStrKeyTrait;
// No specific reason
public const REASON_NONE = '';
// Password mismatch
public const REASON_PASSWORD = 'password';
// Geolocation whitelist mismatch
public const REASON_GEOLOCATION = 'geolocation';
private const STATUS_ACCEPTED = 'ACCEPTED';
private const STATUS_DENIED = 'DENIED';
protected $nullable = [
'reason',
];
protected $fillable = [
'ip',
'user_id',
'status',
'reason',
'expires_at',
'last_seen',
];
protected $casts = [
'expires_at' => 'datetime',
'last_seen' => 'datetime'
];
/**
* Prepare a date for array / JSON serialization.
*
* Required to not omit timezone and match the format of update_at/created_at timestamps.
*
* @param \DateTimeInterface $date
* @return string
*/
protected function serializeDate(\DateTimeInterface $date): string
{
return Carbon::instance($date)->toIso8601ZuluString('microseconds');
}
/**
* Returns true if the authentication attempt is accepted.
*
* @return bool
*/
public function isAccepted(): bool
{
- if ($this->status == self::STATUS_ACCEPTED && Carbon::now() < $this->expires_at) {
- return true;
- }
- return false;
+ return $this->status == self::STATUS_ACCEPTED && Carbon::now() < $this->expires_at;
}
/**
* Returns true if the authentication attempt is denied.
*
* @return bool
*/
public function isDenied(): bool
{
- return ($this->status == self::STATUS_DENIED);
+ return $this->status == self::STATUS_DENIED;
}
/**
* Accept the authentication attempt.
*/
public function accept($reason = AuthAttempt::REASON_NONE)
{
$this->expires_at = Carbon::now()->addHours(8);
$this->status = self::STATUS_ACCEPTED;
$this->reason = $reason;
$this->save();
}
/**
* Deny the authentication attempt.
*/
public function deny($reason = AuthAttempt::REASON_NONE)
{
$this->status = self::STATUS_DENIED;
$this->reason = $reason;
$this->save();
}
/**
* Notify the user of this authentication attempt.
*
* @return bool false if there was no means to notify
*/
public function notify(): bool
{
return \App\CompanionApp::notifyUser($this->user_id, ['token' => $this->id]);
}
/**
* Notify the user and wait for a confirmation.
*/
private function notifyAndWait()
{
if (!$this->notify()) {
//FIXME if the webclient can confirm too we don't need to abort here.
\Log::warning("There is no 2fa device to notify.");
return false;
}
\Log::debug("Authentication attempt: {$this->id}");
$confirmationTimeout = 120;
$timeout = Carbon::now()->addSeconds($confirmationTimeout);
do {
if ($this->isDenied()) {
\Log::debug("The authentication attempt was denied {$this->id}");
return false;
}
if ($this->isAccepted()) {
\Log::debug("The authentication attempt was accepted {$this->id}");
return true;
}
if ($timeout < Carbon::now()) {
\Log::debug("The authentication attempt timed-out: {$this->id}");
return false;
}
sleep(2);
$this->refresh();
} while (true);
}
/**
* Record a new authentication attempt or update an existing one.
*
* @param \App\User $user The user attempting to authenticate.
* @param string $clientIP The ip the authentication attempt is coming from.
*
* @return \App\AuthAttempt
*/
public static function recordAuthAttempt(\App\User $user, $clientIP)
{
$authAttempt = \App\AuthAttempt::where('ip', $clientIP)->where('user_id', $user->id)->first();
if (!$authAttempt) {
$authAttempt = new \App\AuthAttempt();
$authAttempt->ip = $clientIP;
$authAttempt->user_id = $user->id;
}
$authAttempt->last_seen = Carbon::now();
$authAttempt->save();
return $authAttempt;
}
/**
* Trigger a notification if necessary and wait for confirmation.
*
* @return bool Returns true if the attempt is accepted on confirmation
*/
public function waitFor2FA(): bool
{
if ($this->isAccepted()) {
return true;
}
+
if ($this->isDenied()) {
return false;
}
if (!$this->notifyAndWait()) {
return false;
}
return $this->isAccepted();
}
}
diff --git a/src/app/Domain.php b/src/app/Domain.php
index 0162f9fa..d60f0e82 100644
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -1,480 +1,417 @@
<?php
namespace App;
use App\Wallet;
use App\Traits\BelongsToTenantTrait;
use App\Traits\DomainConfigTrait;
use App\Traits\EntitleableTrait;
use App\Traits\SettingsTrait;
+use App\Traits\StatusPropertyTrait;
use App\Traits\UuidIntKeyTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* The eloquent definition of a Domain.
*
* @property string $namespace
* @property int $status
* @property int $tenant_id
* @property int $type
*/
class Domain extends Model
{
use BelongsToTenantTrait;
use DomainConfigTrait;
use EntitleableTrait;
use SettingsTrait;
use SoftDeletes;
+ use StatusPropertyTrait;
use UuidIntKeyTrait;
// we've simply never heard of this domain
public const STATUS_NEW = 1 << 0;
// it's been activated
public const STATUS_ACTIVE = 1 << 1;
// domain has been suspended.
public const STATUS_SUSPENDED = 1 << 2;
// domain has been deleted
public const STATUS_DELETED = 1 << 3;
// ownership of the domain has been confirmed
public const STATUS_CONFIRMED = 1 << 4;
// domain has been verified that it exists in DNS
public const STATUS_VERIFIED = 1 << 5;
// domain has been created in LDAP
public const STATUS_LDAP_READY = 1 << 6;
// open for public registration
public const TYPE_PUBLIC = 1 << 0;
// zone hosted with us
public const TYPE_HOSTED = 1 << 1;
// zone registered externally
public const TYPE_EXTERNAL = 1 << 2;
public const HASH_CODE = 1;
public const HASH_TEXT = 2;
public const HASH_CNAME = 3;
protected $fillable = [
'namespace',
'status',
'type'
];
/**
* Assign a package to a domain. The domain should not belong to any existing entitlements.
*
* @param \App\Package $package The package to assign.
* @param \App\User $user The wallet owner.
*
* @return \App\Domain Self
*/
public function assignPackage($package, $user)
{
// If this domain is public it can not be assigned to a user.
if ($this->isPublic()) {
return $this;
}
// See if this domain is already owned by another user.
$wallet = $this->wallet();
if ($wallet) {
\Log::error(
"Domain {$this->namespace} is already assigned to {$wallet->owner->email}"
);
return $this;
}
return $this->assignPackageAndWallet($package, $user->wallets()->first());
}
/**
* Return list of public+active domain names (for current tenant)
*/
public static function getPublicDomains(): array
{
return self::withEnvTenantContext()
->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC))
->get(['namespace'])->pluck('namespace')->toArray();
}
- /**
- * Returns whether this domain is active.
- *
- * @return bool
- */
- public function isActive(): bool
- {
- return ($this->status & self::STATUS_ACTIVE) > 0;
- }
-
/**
* Returns whether this domain is confirmed the ownership of.
*
* @return bool
*/
public function isConfirmed(): bool
{
return ($this->status & self::STATUS_CONFIRMED) > 0;
}
- /**
- * Returns whether this domain is deleted.
- *
- * @return bool
- */
- public function isDeleted(): bool
- {
- return ($this->status & self::STATUS_DELETED) > 0;
- }
-
/**
* Returns whether this domain is registered with us.
*
* @return bool
*/
public function isExternal(): bool
{
return ($this->type & self::TYPE_EXTERNAL) > 0;
}
/**
* Returns whether this domain is hosted with us.
*
* @return bool
*/
public function isHosted(): bool
{
return ($this->type & self::TYPE_HOSTED) > 0;
}
- /**
- * Returns whether this domain is new.
- *
- * @return bool
- */
- public function isNew(): bool
- {
- return ($this->status & self::STATUS_NEW) > 0;
- }
-
/**
* Returns whether this domain is public.
*
* @return bool
*/
public function isPublic(): bool
{
return ($this->type & self::TYPE_PUBLIC) > 0;
}
- /**
- * Returns whether this domain is registered in LDAP.
- *
- * @return bool
- */
- public function isLdapReady(): bool
- {
- return ($this->status & self::STATUS_LDAP_READY) > 0;
- }
-
- /**
- * Returns whether this domain is suspended.
- *
- * @return bool
- */
- public function isSuspended(): bool
- {
- return ($this->status & self::STATUS_SUSPENDED) > 0;
- }
-
/**
* Returns whether this (external) domain has been verified
* to exist in DNS.
*
* @return bool
*/
public function isVerified(): bool
{
return ($this->status & self::STATUS_VERIFIED) > 0;
}
/**
* Ensure the namespace is appropriately cased.
*/
public function setNamespaceAttribute($namespace)
{
$this->attributes['namespace'] = strtolower($namespace);
}
/**
* Domain status mutator
*
* @throws \Exception
*/
public function setStatusAttribute($status)
{
$new_status = 0;
$allowed_values = [
self::STATUS_NEW,
self::STATUS_ACTIVE,
self::STATUS_SUSPENDED,
self::STATUS_DELETED,
self::STATUS_CONFIRMED,
self::STATUS_VERIFIED,
self::STATUS_LDAP_READY,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid domain status: {$status}");
}
if ($this->isPublic()) {
$this->attributes['status'] = $new_status;
return;
}
if ($new_status & self::STATUS_CONFIRMED) {
// if we have confirmed ownership of or management access to the domain, then we have
// also confirmed the domain exists in DNS.
$new_status |= self::STATUS_VERIFIED;
$new_status |= self::STATUS_ACTIVE;
}
if ($new_status & self::STATUS_DELETED && $new_status & self::STATUS_ACTIVE) {
$new_status ^= self::STATUS_ACTIVE;
}
if ($new_status & self::STATUS_SUSPENDED && $new_status & self::STATUS_ACTIVE) {
$new_status ^= self::STATUS_ACTIVE;
}
// if the domain is now active, it is not new anymore.
if ($new_status & self::STATUS_ACTIVE && $new_status & self::STATUS_NEW) {
$new_status ^= self::STATUS_NEW;
}
$this->attributes['status'] = $new_status;
}
/**
* Ownership verification by checking for a TXT (or CNAME) record
* in the domain's DNS (that matches the verification hash).
*
* @return bool True if verification was successful, false otherwise
* @throws \Exception Throws exception on DNS or DB errors
*/
public function confirm(): bool
{
if ($this->isConfirmed()) {
return true;
}
$hash = $this->hash(self::HASH_TEXT);
$confirmed = false;
// Get DNS records and find a matching TXT entry
$records = \dns_get_record($this->namespace, DNS_TXT);
if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
foreach ($records as $record) {
if ($record['txt'] === $hash) {
$confirmed = true;
break;
}
}
// Get DNS records and find a matching CNAME entry
// Note: some servers resolve every non-existing name
// so we need to define left and right side of the CNAME record
// i.e.: kolab-verify IN CNAME <hash>.domain.tld.
if (!$confirmed) {
$cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace;
$records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME);
if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
foreach ($records as $records) {
if ($records['target'] === $cname) {
$confirmed = true;
break;
}
}
}
if ($confirmed) {
$this->status |= Domain::STATUS_CONFIRMED;
$this->save();
}
return $confirmed;
}
/**
* Generate a verification hash for this domain
*
* @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT
*
* @return string Verification hash
*/
public function hash($mod = null): string
{
$cname = 'kolab-verify';
if ($mod === self::HASH_CNAME) {
return $cname;
}
$hash = \md5('hkccp-verify-' . $this->namespace);
return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash;
}
/**
* Checks if there are any objects (users/aliases/groups) in a domain.
* Note: Public domains are always reported not empty.
*
* @return bool True if there are no objects assigned, False otherwise
*/
public function isEmpty(): bool
{
if ($this->isPublic()) {
return false;
}
// FIXME: These queries will not use indexes, so maybe we should consider
// wallet/entitlements to search in objects that belong to this domain account?
$suffix = '@' . $this->namespace;
$suffixLen = strlen($suffix);
return !(
\App\User::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
|| \App\UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists()
|| \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
|| \App\Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
|| \App\SharedFolder::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
);
}
- /**
- * Suspend this domain.
- *
- * @return void
- */
- public function suspend(): void
- {
- if ($this->isSuspended()) {
- return;
- }
-
- $this->status |= Domain::STATUS_SUSPENDED;
- $this->save();
- }
-
/**
* Unsuspend this domain.
*
* The domain is unsuspended through either of the following courses of actions;
*
* * The account balance has been topped up, or
* * a suspected spammer has resolved their issues, or
* * the command-line is triggered.
*
* Therefore, we can also confidently set the domain status to 'active' should the ownership of or management
* access to have been confirmed before.
*
* @return void
*/
public function unsuspend(): void
{
if (!$this->isSuspended()) {
return;
}
$this->status ^= Domain::STATUS_SUSPENDED;
if ($this->isConfirmed() && $this->isVerified()) {
$this->status |= Domain::STATUS_ACTIVE;
}
$this->save();
}
/**
* List the users of a domain, so long as the domain is not a public registration domain.
* Note: It returns only users with a mailbox.
*
* @return \App\User[] A list of users
*/
public function users(): array
{
if ($this->isPublic()) {
return [];
}
$wallet = $this->wallet();
if (!$wallet) {
return [];
}
$mailboxSKU = \App\Sku::withObjectTenantContext($this)->where('title', 'mailbox')->first();
if (!$mailboxSKU) {
\Log::error("No mailbox SKU available.");
return [];
}
return $wallet->entitlements()
->where('entitleable_type', \App\User::class)
->where('sku_id', $mailboxSKU->id)
->get()
->pluck('entitleable')
->all();
}
/**
* Verify if a domain exists in DNS
*
* @return bool True if registered, False otherwise
* @throws \Exception Throws exception on DNS or DB errors
*/
public function verify(): bool
{
if ($this->isVerified()) {
return true;
}
$records = \dns_get_record($this->namespace, DNS_ANY);
if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
// It may happen that result contains other domains depending on the host DNS setup
// that's why in_array() and not just !empty()
if (in_array($this->namespace, array_column($records, 'host'))) {
$this->status |= Domain::STATUS_VERIFIED;
$this->save();
return true;
}
return false;
}
}
diff --git a/src/app/Group.php b/src/app/Group.php
index e442e4db..58e15649 100644
--- a/src/app/Group.php
+++ b/src/app/Group.php
@@ -1,235 +1,126 @@
<?php
namespace App;
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
use App\Traits\GroupConfigTrait;
use App\Traits\SettingsTrait;
+use App\Traits\StatusPropertyTrait;
use App\Traits\UuidIntKeyTrait;
use App\Wallet;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* The eloquent definition of a Group.
*
* @property int $id The group identifier
* @property string $email An email address
* @property string $members A comma-separated list of email addresses
* @property string $name The group name
* @property int $status The group status
* @property int $tenant_id Tenant identifier
*/
class Group extends Model
{
use BelongsToTenantTrait;
use EntitleableTrait;
use GroupConfigTrait;
use SettingsTrait;
use SoftDeletes;
+ use StatusPropertyTrait;
use UuidIntKeyTrait;
// we've simply never heard of this group
public const STATUS_NEW = 1 << 0;
// group has been activated
public const STATUS_ACTIVE = 1 << 1;
// group has been suspended.
public const STATUS_SUSPENDED = 1 << 2;
// group has been deleted
public const STATUS_DELETED = 1 << 3;
// group has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
protected $fillable = [
'email',
'members',
'name',
'status',
];
/**
* Returns group domain.
*
* @return ?\App\Domain The domain group belongs to, NULL if it does not exist
*/
public function domain(): ?Domain
{
list($local, $domainName) = explode('@', $this->email);
return Domain::where('namespace', $domainName)->first();
}
/**
* Find whether an email address exists as a group (including deleted groups).
*
* @param string $email Email address
* @param bool $return_group Return Group instance instead of boolean
*
* @return \App\Group|bool True or Group model object if found, False otherwise
*/
public static function emailExists(string $email, bool $return_group = false)
{
if (strpos($email, '@') === false) {
return false;
}
$email = \strtolower($email);
$group = self::withTrashed()->where('email', $email)->first();
if ($group) {
return $return_group ? $group : true;
}
return false;
}
/**
* Group members propert accessor. Converts internal comma-separated list into an array
*
* @param string $members Comma-separated list of email addresses
*
* @return array Email addresses of the group members, as an array
*/
public function getMembersAttribute($members): array
{
return $members ? explode(',', $members) : [];
}
- /**
- * Returns whether this group is active.
- *
- * @return bool
- */
- public function isActive(): bool
- {
- return ($this->status & self::STATUS_ACTIVE) > 0;
- }
-
- /**
- * Returns whether this group is deleted.
- *
- * @return bool
- */
- public function isDeleted(): bool
- {
- return ($this->status & self::STATUS_DELETED) > 0;
- }
-
- /**
- * Returns whether this group is new.
- *
- * @return bool
- */
- public function isNew(): bool
- {
- return ($this->status & self::STATUS_NEW) > 0;
- }
-
- /**
- * Returns whether this group is registered in LDAP.
- *
- * @return bool
- */
- public function isLdapReady(): bool
- {
- return ($this->status & self::STATUS_LDAP_READY) > 0;
- }
-
- /**
- * Returns whether this group is suspended.
- *
- * @return bool
- */
- public function isSuspended(): bool
- {
- return ($this->status & self::STATUS_SUSPENDED) > 0;
- }
-
/**
* Ensure the email is appropriately cased.
*
* @param string $email Group email address
*/
public function setEmailAttribute(string $email)
{
$this->attributes['email'] = strtolower($email);
}
/**
* Ensure the members are appropriately formatted.
*
* @param array $members Email addresses of the group members
*/
public function setMembersAttribute(array $members): void
{
$members = array_unique(array_filter(array_map('strtolower', $members)));
sort($members);
$this->attributes['members'] = implode(',', $members);
}
-
- /**
- * Group status mutator
- *
- * @throws \Exception
- */
- public function setStatusAttribute($status)
- {
- $new_status = 0;
-
- $allowed_values = [
- self::STATUS_NEW,
- self::STATUS_ACTIVE,
- self::STATUS_SUSPENDED,
- self::STATUS_DELETED,
- self::STATUS_LDAP_READY,
- ];
-
- foreach ($allowed_values as $value) {
- if ($status & $value) {
- $new_status |= $value;
- $status ^= $value;
- }
- }
-
- if ($status > 0) {
- throw new \Exception("Invalid group status: {$status}");
- }
-
- $this->attributes['status'] = $new_status;
- }
-
- /**
- * Suspend this group.
- *
- * @return void
- */
- public function suspend(): void
- {
- if ($this->isSuspended()) {
- return;
- }
-
- $this->status |= Group::STATUS_SUSPENDED;
- $this->save();
- }
-
- /**
- * Unsuspend this group.
- *
- * @return void
- */
- public function unsuspend(): void
- {
- if (!$this->isSuspended()) {
- return;
- }
-
- $this->status ^= Group::STATUS_SUSPENDED;
- $this->save();
- }
}
diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php
index ce67f32d..8fc7a4ac 100644
--- a/src/app/Http/Controllers/API/V4/DomainsController.php
+++ b/src/app/Http/Controllers/API/V4/DomainsController.php
@@ -1,360 +1,341 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Domain;
use App\Http\Controllers\RelationController;
use App\Backends\LDAP;
use App\Rules\UserEmailDomain;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class DomainsController extends RelationController
{
/** @var string Resource localization label */
protected $label = 'domain';
/** @var string Resource model name */
protected $model = Domain::class;
/** @var array Common object properties in the API response */
protected $objectProps = ['namespace', 'type'];
/** @var array Resource listing order (column names) */
protected $order = ['namespace'];
/** @var array Resource relation method arguments */
protected $relationArgs = [true, false];
/**
* Confirm ownership of the specified domain (via DNS check).
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function confirm($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
if (!$domain->confirm()) {
return response()->json([
'status' => 'error',
'message' => \trans('app.domain-verify-error'),
]);
}
return response()->json([
'status' => 'success',
'statusInfo' => self::statusInfo($domain),
'message' => \trans('app.domain-verify-success'),
]);
}
/**
* Remove the specified domain.
*
* @param string $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
{
$domain = Domain::withEnvTenantContext()->find($id);
if (empty($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canDelete($domain)) {
return $this->errorResponse(403);
}
// It is possible to delete domain only if there are no users/aliases/groups using it.
if (!$domain->isEmpty()) {
$response = ['status' => 'error', 'message' => \trans('app.domain-notempty-error')];
return response()->json($response, 422);
}
$domain->delete();
return response()->json([
'status' => 'success',
'message' => \trans('app.domain-delete-success'),
]);
}
/**
* Create a domain.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\JsonResponse
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->wallet()->owner;
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
// Validate the input
$v = Validator::make(
$request->all(),
[
'namespace' => ['required', 'string', new UserEmailDomain()]
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$namespace = \strtolower(request()->input('namespace'));
// Domain already exists
if ($domain = Domain::withTrashed()->where('namespace', $namespace)->first()) {
// Check if the domain is soft-deleted and belongs to the same user
$deleteBeforeCreate = $domain->trashed() && ($wallet = $domain->wallet())
&& $wallet->owner && $wallet->owner->id == $owner->id;
if (!$deleteBeforeCreate) {
$errors = ['namespace' => \trans('validation.domainnotavailable')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
}
if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) {
$errors = ['package' => \trans('validation.packagerequired')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if (!$package->isDomain()) {
$errors = ['package' => \trans('validation.packageinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// Force-delete the existing domain if it is soft-deleted and belongs to the same user
if (!empty($deleteBeforeCreate)) {
$domain->forceDelete();
}
// Create the domain
$domain = Domain::create([
'namespace' => $namespace,
'type' => \App\Domain::TYPE_EXTERNAL,
]);
$domain->assignPackage($package, $owner);
DB::commit();
return response()->json([
'status' => 'success',
'message' => __('app.domain-create-success'),
]);
}
/**
* Get the information about the specified domain.
*
* @param string $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function show($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
$response = $this->objectToClient($domain, true);
// Add hash information to the response
$response['hash_text'] = $domain->hash(Domain::HASH_TEXT);
$response['hash_cname'] = $domain->hash(Domain::HASH_CNAME);
$response['hash_code'] = $domain->hash(Domain::HASH_CODE);
// Add DNS/MX configuration for the domain
$response['dns'] = self::getDNSConfig($domain);
$response['mx'] = self::getMXConfig($domain->namespace);
// Domain configuration, e.g. spf whitelist
$response['config'] = $domain->getConfig();
// Status info
$response['statusInfo'] = self::statusInfo($domain);
// Entitlements info
$response['skus'] = \App\Entitlement::objectEntitlementsSummary($domain);
// Some basic information about the domain wallet
$wallet = $domain->wallet();
$response['wallet'] = $wallet->toArray();
if ($wallet->discount) {
$response['wallet']['discount'] = $wallet->discount->discount;
$response['wallet']['discount_description'] = $wallet->discount->description;
}
return response()->json($response);
}
/**
* Provide DNS MX information to configure specified domain for
*/
protected static function getMXConfig(string $namespace): array
{
$entries = [];
// copy MX entries from an existing domain
if ($master = \config('dns.copyfrom')) {
// TODO: cache this lookup
foreach ((array) dns_get_record($master, DNS_MX) as $entry) {
$entries[] = sprintf(
"@\t%s\t%s\tMX\t%d %s.",
\config('dns.ttl', $entry['ttl']),
$entry['class'],
$entry['pri'],
$entry['target']
);
}
} elseif ($static = \config('dns.static')) {
$entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace));
}
// display SPF settings
if ($spf = \config('dns.spf')) {
$entries[] = ';';
foreach (['TXT', 'SPF'] as $type) {
$entries[] = sprintf(
"@\t%s\tIN\t%s\t\"%s\"",
\config('dns.ttl'),
$type,
$spf
);
}
}
return $entries;
}
/**
* Provide sample DNS config for domain confirmation
*/
protected static function getDNSConfig(Domain $domain): array
{
$serial = date('Ymd01');
$hash_txt = $domain->hash(Domain::HASH_TEXT);
$hash_cname = $domain->hash(Domain::HASH_CNAME);
$hash = $domain->hash(Domain::HASH_CODE);
return [
"@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (",
" {$serial} 10800 3600 604800 86400 )",
";",
"@ IN A <some-ip>",
"www IN A <some-ip>",
";",
"{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.",
"@ 3600 TXT \"{$hash_txt}\"",
];
}
- /**
- * Prepare domain statuses for the UI
- *
- * @param \App\Domain $domain Domain object
- *
- * @return array Statuses array
- */
- protected static function objectState($domain): array
- {
- return [
- 'isLdapReady' => $domain->isLdapReady(),
- 'isConfirmed' => $domain->isConfirmed(),
- 'isVerified' => $domain->isVerified(),
- 'isSuspended' => $domain->isSuspended(),
- 'isActive' => $domain->isActive(),
- 'isDeleted' => $domain->isDeleted() || $domain->trashed(),
- ];
- }
-
/**
* Domain status (extended) information.
*
* @param \App\Domain $domain Domain object
*
* @return array Status information
*/
public static function statusInfo($domain): array
{
// If that is not a public domain, add domain specific steps
return self::processStateInfo(
$domain,
[
'domain-new' => true,
'domain-ldap-ready' => $domain->isLdapReady(),
'domain-verified' => $domain->isVerified(),
'domain-confirmed' => [$domain->isConfirmed(), "/domain/{$domain->id}"],
]
);
}
/**
* Execute (synchronously) specified step in a domain setup process.
*
* @param \App\Domain $domain Domain object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool True if the execution succeeded, False otherwise
*/
public static function execProcessStep(Domain $domain, string $step): bool
{
try {
switch ($step) {
case 'domain-ldap-ready':
// Domain not in LDAP, create it
if (!$domain->isLdapReady()) {
LDAP::createDomain($domain);
$domain->status |= Domain::STATUS_LDAP_READY;
$domain->save();
}
return $domain->isLdapReady();
case 'domain-verified':
// Domain existence not verified
$domain->verify();
return $domain->isVerified();
case 'domain-confirmed':
// Domain ownership confirmation
$domain->confirm();
return $domain->isConfirmed();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
}
diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php
index 55d8baf2..241abf81 100644
--- a/src/app/Http/Controllers/API/V4/GroupsController.php
+++ b/src/app/Http/Controllers/API/V4/GroupsController.php
@@ -1,344 +1,327 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\RelationController;
use App\Domain;
use App\Group;
use App\Rules\GroupName;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class GroupsController extends RelationController
{
/** @var string Resource localization label */
protected $label = 'distlist';
/** @var string Resource model name */
protected $model = Group::class;
/** @var array Resource listing order (column names) */
protected $order = ['name', 'email'];
/** @var array Common object properties in the API response */
protected $objectProps = ['email', 'name'];
/**
* Group status (extended) information
*
* @param \App\Group $group Group object
*
* @return array Status information
*/
public static function statusInfo($group): array
{
return self::processStateInfo(
$group,
[
'distlist-new' => true,
'distlist-ldap-ready' => $group->isLdapReady(),
]
);
}
/**
* Create a new group record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->wallet()->owner;
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
$email = $request->input('email');
$members = $request->input('members');
$errors = [];
$rules = [
'name' => 'required|string|max:191',
];
// Validate group address
if ($error = GroupsController::validateGroupEmail($email, $owner)) {
$errors['email'] = $error;
} else {
list(, $domainName) = explode('@', $email);
$rules['name'] = ['required', 'string', new GroupName($owner, $domainName)];
}
// Validate the group name
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = array_merge($errors, $v->errors()->toArray());
}
// Validate members' email addresses
if (empty($members) || !is_array($members)) {
$errors['members'] = \trans('validation.listmembersrequired');
} else {
foreach ($members as $i => $member) {
if (is_string($member) && !empty($member)) {
if ($error = GroupsController::validateMemberEmail($member, $owner)) {
$errors['members'][$i] = $error;
} elseif (\strtolower($member) === \strtolower($email)) {
$errors['members'][$i] = \trans('validation.memberislist');
}
} else {
unset($members[$i]);
}
}
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// Create the group
$group = new Group();
$group->name = $request->input('name');
$group->email = $email;
$group->members = $members;
$group->save();
$group->assignToWallet($owner->wallets->first());
DB::commit();
return response()->json([
'status' => 'success',
'message' => \trans('app.distlist-create-success'),
]);
}
/**
* Update a group.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id Group identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$group = Group::find($id);
if (!$this->checkTenant($group)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
if (!$current_user->canUpdate($group)) {
return $this->errorResponse(403);
}
$owner = $group->wallet()->owner;
$name = $request->input('name');
$members = $request->input('members');
$errors = [];
// Validate the group name
if ($name !== null && $name != $group->name) {
list(, $domainName) = explode('@', $group->email);
$rules = ['name' => ['required', 'string', new GroupName($owner, $domainName)]];
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = array_merge($errors, $v->errors()->toArray());
} else {
$group->name = $name;
}
}
// Validate members' email addresses
if (empty($members) || !is_array($members)) {
$errors['members'] = \trans('validation.listmembersrequired');
} else {
foreach ((array) $members as $i => $member) {
if (is_string($member) && !empty($member)) {
if ($error = GroupsController::validateMemberEmail($member, $owner)) {
$errors['members'][$i] = $error;
} elseif (\strtolower($member) === $group->email) {
$errors['members'][$i] = \trans('validation.memberislist');
}
} else {
unset($members[$i]);
}
}
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$group->members = $members;
$group->save();
return response()->json([
'status' => 'success',
'message' => \trans('app.distlist-update-success'),
]);
}
/**
* Execute (synchronously) specified step in a group setup process.
*
* @param \App\Group $group Group object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool|null True if the execution succeeded, False if not, Null when
* the job has been sent to the worker (result unknown)
*/
public static function execProcessStep(Group $group, string $step): ?bool
{
try {
if (strpos($step, 'domain-') === 0) {
return DomainsController::execProcessStep($group->domain(), $step);
}
switch ($step) {
case 'distlist-ldap-ready':
// Group not in LDAP, create it
$job = new \App\Jobs\Group\CreateJob($group->id);
$job->handle();
$group->refresh();
return $group->isLdapReady();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
- /**
- * Prepare group statuses for the UI
- *
- * @param \App\Group $group Group object
- *
- * @return array Statuses array
- */
- protected static function objectState($group): array
- {
- return [
- 'isLdapReady' => $group->isLdapReady(),
- 'isSuspended' => $group->isSuspended(),
- 'isActive' => $group->isActive(),
- 'isDeleted' => $group->isDeleted() || $group->trashed(),
- ];
- }
-
/**
* Validate an email address for use as a group email
*
* @param string $email Email address
* @param \App\User $user The group owner
*
* @return ?string Error message on validation error
*/
public static function validateGroupEmail($email, \App\User $user): ?string
{
if (empty($email)) {
return \trans('validation.required', ['attribute' => 'email']);
}
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
list($login, $domain) = explode('@', \strtolower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
// Check if domain exists
$domain = Domain::where('namespace', $domain)->first();
if (empty($domain)) {
return \trans('validation.domaininvalid');
}
$wallet = $domain->wallet();
// The domain must be owned by the user
if (!$wallet || !$user->wallets()->find($wallet->id)) {
return \trans('validation.domainnotavailable');
}
// Validate login part alone
$v = Validator::make(
['email' => $login],
['email' => [new \App\Rules\UserEmailLocal(true)]]
);
if ($v->fails()) {
return $v->errors()->toArray()['email'][0];
}
// Check if a user with specified address already exists
if (User::emailExists($email)) {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
// Check if an alias with specified address already exists.
if (User::aliasExists($email)) {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
if (Group::emailExists($email)) {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
return null;
}
/**
* Validate an email address for use as a group member
*
* @param string $email Email address
* @param \App\User $user The group owner
*
* @return ?string Error message on validation error
*/
public static function validateMemberEmail($email, \App\User $user): ?string
{
$v = Validator::make(
['email' => $email],
['email' => [new \App\Rules\ExternalEmail()]]
);
if ($v->fails()) {
return $v->errors()->toArray()['email'][0];
}
// A local domain user must exist
if (!User::where('email', \strtolower($email))->first()) {
list($login, $domain) = explode('@', \strtolower($email));
$domain = Domain::where('namespace', $domain)->first();
// We return an error only if the domain belongs to the group owner
if ($domain && ($wallet = $domain->wallet()) && $user->wallets()->find($wallet->id)) {
return \trans('validation.notalocaluser');
}
}
return null;
}
}
diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php
index 0e09118a..add86e72 100644
--- a/src/app/Http/Controllers/API/V4/ResourcesController.php
+++ b/src/app/Http/Controllers/API/V4/ResourcesController.php
@@ -1,209 +1,192 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\RelationController;
use App\Resource;
use App\Rules\ResourceName;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class ResourcesController extends RelationController
{
/** @var string Resource localization label */
protected $label = 'resource';
/** @var string Resource model name */
protected $model = Resource::class;
/** @var array Resource listing order (column names) */
protected $order = ['name'];
/** @var array Common object properties in the API response */
protected $objectProps = ['email', 'name'];
- /**
- * Prepare resource statuses for the UI
- *
- * @param \App\Resource $resource Resource object
- *
- * @return array Statuses array
- */
- protected static function objectState($resource): array
- {
- return [
- 'isLdapReady' => $resource->isLdapReady(),
- 'isImapReady' => $resource->isImapReady(),
- 'isActive' => $resource->isActive(),
- 'isDeleted' => $resource->isDeleted() || $resource->trashed(),
- ];
- }
-
/**
* Resource status (extended) information
*
* @param \App\Resource $resource Resource object
*
* @return array Status information
*/
public static function statusInfo($resource): array
{
return self::processStateInfo(
$resource,
[
'resource-new' => true,
'resource-ldap-ready' => $resource->isLdapReady(),
'resource-imap-ready' => $resource->isImapReady(),
]
);
}
/**
* Create a new resource record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->wallet()->owner;
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
$domain = request()->input('domain');
$rules = ['name' => ['required', 'string', new ResourceName($owner, $domain)]];
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
DB::beginTransaction();
// Create the resource
$resource = new Resource();
$resource->name = request()->input('name');
$resource->domain = $domain;
$resource->save();
$resource->assignToWallet($owner->wallets->first());
DB::commit();
return response()->json([
'status' => 'success',
'message' => \trans('app.resource-create-success'),
]);
}
/**
* Update a resource.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$resource = Resource::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
if (!$current_user->canUpdate($resource)) {
return $this->errorResponse(403);
}
$owner = $resource->wallet()->owner;
$name = $request->input('name');
$errors = [];
// Validate the resource name
if ($name !== null && $name != $resource->name) {
$domainName = explode('@', $resource->email, 2)[1];
$rules = ['name' => ['required', 'string', new ResourceName($owner, $domainName)]];
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = $v->errors()->toArray();
} else {
$resource->name = $name;
}
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$resource->save();
return response()->json([
'status' => 'success',
'message' => \trans('app.resource-update-success'),
]);
}
/**
* Execute (synchronously) specified step in a resource setup process.
*
* @param \App\Resource $resource Resource object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool|null True if the execution succeeded, False if not, Null when
* the job has been sent to the worker (result unknown)
*/
public static function execProcessStep(Resource $resource, string $step): ?bool
{
try {
if (strpos($step, 'domain-') === 0) {
return DomainsController::execProcessStep($resource->domain(), $step);
}
switch ($step) {
case 'resource-ldap-ready':
// Resource not in LDAP, create it
$job = new \App\Jobs\Resource\CreateJob($resource->id);
$job->handle();
$resource->refresh();
return $resource->isLdapReady();
case 'resource-imap-ready':
// Resource not in IMAP? Verify again
// Do it synchronously if the imap admin credentials are available
// otherwise let the worker do the job
if (!\config('imap.admin_password')) {
\App\Jobs\Resource\VerifyJob::dispatch($resource->id);
return null;
}
$job = new \App\Jobs\Resource\VerifyJob($resource->id);
$job->handle();
$resource->refresh();
return $resource->isImapReady();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
}
diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php
index 146a365e..2d86d1fe 100644
--- a/src/app/Http/Controllers/API/V4/SharedFoldersController.php
+++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php
@@ -1,214 +1,197 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\RelationController;
use App\SharedFolder;
use App\Rules\SharedFolderName;
use App\Rules\SharedFolderType;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class SharedFoldersController extends RelationController
{
/** @var string Resource localization label */
protected $label = 'shared-folder';
/** @var string Resource model name */
protected $model = SharedFolder::class;
/** @var array Resource listing order (column names) */
protected $order = ['name'];
/** @var array Common object properties in the API response */
protected $objectProps = ['email', 'name', 'type'];
- /**
- * Prepare shared folder statuses for the UI
- *
- * @param \App\SharedFolder $folder Shared folder object
- *
- * @return array Statuses array
- */
- protected static function objectState($folder): array
- {
- return [
- 'isLdapReady' => $folder->isLdapReady(),
- 'isImapReady' => $folder->isImapReady(),
- 'isActive' => $folder->isActive(),
- 'isDeleted' => $folder->isDeleted() || $folder->trashed(),
- ];
- }
-
/**
* SharedFolder status (extended) information
*
* @param \App\SharedFolder $folder SharedFolder object
*
* @return array Status information
*/
public static function statusInfo($folder): array
{
return self::processStateInfo(
$folder,
[
'shared-folder-new' => true,
'shared-folder-ldap-ready' => $folder->isLdapReady(),
'shared-folder-imap-ready' => $folder->isImapReady(),
]
);
}
/**
* Create a new shared folder record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->wallet()->owner;
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
$domain = request()->input('domain');
$rules = [
'name' => ['required', 'string', new SharedFolderName($owner, $domain)],
'type' => ['required', 'string', new SharedFolderType()]
];
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
DB::beginTransaction();
// Create the shared folder
$folder = new SharedFolder();
$folder->name = request()->input('name');
$folder->type = request()->input('type');
$folder->domain = $domain;
$folder->save();
$folder->assignToWallet($owner->wallets->first());
DB::commit();
return response()->json([
'status' => 'success',
'message' => \trans('app.shared-folder-create-success'),
]);
}
/**
* Update a shared folder.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id Shared folder identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$folder = SharedFolder::find($id);
if (!$this->checkTenant($folder)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
if (!$current_user->canUpdate($folder)) {
return $this->errorResponse(403);
}
$owner = $folder->wallet()->owner;
$name = $request->input('name');
$errors = [];
// Validate the folder name
if ($name !== null && $name != $folder->name) {
$domainName = explode('@', $folder->email, 2)[1];
$rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domainName)]];
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = $v->errors()->toArray();
} else {
$folder->name = $name;
}
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$folder->save();
return response()->json([
'status' => 'success',
'message' => \trans('app.shared-folder-update-success'),
]);
}
/**
* Execute (synchronously) specified step in a shared folder setup process.
*
* @param \App\SharedFolder $folder Shared folder object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool|null True if the execution succeeded, False if not, Null when
* the job has been sent to the worker (result unknown)
*/
public static function execProcessStep(SharedFolder $folder, string $step): ?bool
{
try {
if (strpos($step, 'domain-') === 0) {
return DomainsController::execProcessStep($folder->domain(), $step);
}
switch ($step) {
case 'shared-folder-ldap-ready':
// Shared folder not in LDAP, create it
$job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
$job->handle();
$folder->refresh();
return $folder->isLdapReady();
case 'shared-folder-imap-ready':
// Shared folder not in IMAP? Verify again
// Do it synchronously if the imap admin credentials are available
// otherwise let the worker do the job
if (!\config('imap.admin_password')) {
\App\Jobs\SharedFolder\VerifyJob::dispatch($folder->id);
return null;
}
$job = new \App\Jobs\SharedFolder\VerifyJob($folder->id);
$job->handle();
$folder->refresh();
return $folder->isImapReady();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
index 6669cde8..36d32ecc 100644
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -1,730 +1,726 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\RelationController;
use App\Domain;
use App\Group;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\Sku;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UsersController extends RelationController
{
/** @const array List of user setting keys available for modification in UI */
public const USER_SETTINGS = [
'billing_address',
'country',
'currency',
'external_email',
'first_name',
'last_name',
'organization',
'phone',
];
/**
* On user create it is filled with a user or group object to force-delete
* before the creation of a new user record is possible.
*
* @var \App\User|\App\Group|null
*/
protected $deleteBeforeCreate;
/** @var string Resource localization label */
protected $label = 'user';
/** @var string Resource model name */
protected $model = User::class;
/** @var array Common object properties in the API response */
protected $objectProps = ['email'];
/**
* Listing of users.
*
* The user-entitlements billed to the current user wallet(s)
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->guard()->user();
$search = trim(request()->input('search'));
$page = intval(request()->input('page')) ?: 1;
$pageSize = 20;
$hasMore = false;
$result = $user->users();
// Search by user email, alias or name
if (strlen($search) > 0) {
// thanks to cloning we skip some extra queries in $user->users()
$allUsers1 = clone $result;
$allUsers2 = clone $result;
$result->whereLike('email', $search)
->union(
$allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id')
->whereLike('alias', $search)
)
->union(
$allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id')
->whereLike('value', $search)
->whereIn('key', ['first_name', 'last_name'])
);
}
$result = $result->orderBy('email')
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
// Process the result
$result = $result->map(
function ($user) {
return $this->objectToClient($user);
}
);
$result = [
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
];
return response()->json($result);
}
/**
* Display information on the user account specified by $id.
*
* @param string $id The account to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($user)) {
return $this->errorResponse(403);
}
$response = $this->userResponse($user);
$response['skus'] = \App\Entitlement::objectEntitlementsSummary($user);
$response['config'] = $user->getConfig();
return response()->json($response);
}
/**
* User status (extended) information
*
* @param \App\User $user User object
*
* @return array Status information
*/
public static function statusInfo($user): array
{
$process = self::processStateInfo(
$user,
[
'user-new' => true,
'user-ldap-ready' => $user->isLdapReady(),
'user-imap-ready' => $user->isImapReady(),
]
);
// Check if the user is a controller of his wallet
$isController = $user->canDelete($user);
$hasCustomDomain = $user->wallet()->entitlements()
->where('entitleable_type', Domain::class)
->count() > 0;
// Get user's entitlements titles
$skus = $user->entitlements()->select('skus.title')
->join('skus', 'skus.id', '=', 'entitlements.sku_id')
->get()
->pluck('title')
->sort()
->unique()
->values()
->all();
$result = [
'skus' => $skus,
// TODO: This will change when we enable all users to create domains
'enableDomains' => $isController && $hasCustomDomain,
// TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners
'enableDistlists' => $isController && $hasCustomDomain && in_array('beta-distlists', $skus),
// TODO: Make 'enableFolders' working for wallet controllers that aren't account owners
'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus),
// TODO: Make 'enableResources' working for wallet controllers that aren't account owners
'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus),
'enableUsers' => $isController,
'enableWallets' => $isController,
];
return array_merge($process, $result);
}
/**
* Create a new user record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->wallet()->owner;
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
$this->deleteBeforeCreate = null;
if ($error_response = $this->validateUserRequest($request, null, $settings)) {
return $error_response;
}
if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) {
$errors = ['package' => \trans('validation.packagerequired')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if ($package->isDomain()) {
$errors = ['package' => \trans('validation.packageinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// @phpstan-ignore-next-line
if ($this->deleteBeforeCreate) {
$this->deleteBeforeCreate->forceDelete();
}
// Create user record
$user = User::create([
'email' => $request->email,
'password' => $request->password,
]);
$owner->assignPackage($package, $user);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->aliases)) {
$user->setAliases($request->aliases);
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => \trans('app.user-create-success'),
]);
}
/**
* Update user data.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$user = User::withEnvTenantContext()->find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
// TODO: Decide what attributes a user can change on his own profile
if (!$current_user->canUpdate($user)) {
return $this->errorResponse(403);
}
if ($error_response = $this->validateUserRequest($request, $user, $settings)) {
return $error_response;
}
// Entitlements, only controller can do that
if ($request->skus !== null && !$current_user->canDelete($user)) {
return $this->errorResponse(422, "You have no permission to change entitlements");
}
DB::beginTransaction();
$this->updateEntitlements($user, $request->skus);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->password)) {
$user->password = $request->password;
$user->save();
}
if (isset($request->aliases)) {
$user->setAliases($request->aliases);
}
// TODO: Make sure that UserUpdate job is created in case of entitlements update
// and no password change. So, for example quota change is applied to LDAP
// TODO: Review use of $user->save() in the above context
DB::commit();
$response = [
'status' => 'success',
'message' => \trans('app.user-update-success'),
];
// For self-update refresh the statusInfo in the UI
if ($user->id == $current_user->id) {
$response['statusInfo'] = self::statusInfo($user);
}
return response()->json($response);
}
/**
* Update user entitlements.
*
* @param \App\User $user The user
* @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty]
*/
protected function updateEntitlements(User $user, $rSkus)
{
if (!is_array($rSkus)) {
return;
}
// list of skus, [id=>obj]
$skus = Sku::withEnvTenantContext()->get()->mapWithKeys(
function ($sku) {
return [$sku->id => $sku];
}
);
// existing entitlement's SKUs
$eSkus = [];
$user->entitlements()->groupBy('sku_id')
->selectRaw('count(*) as total, sku_id')->each(
function ($e) use (&$eSkus) {
$eSkus[$e->sku_id] = $e->total;
}
);
foreach ($skus as $skuID => $sku) {
$e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0;
$r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0;
if ($sku->handler_class == \App\Handlers\Mailbox::class) {
if ($r != 1) {
throw new \Exception("Invalid quantity of mailboxes");
}
}
if ($e > $r) {
// remove those entitled more than existing
$user->removeSku($sku, ($e - $r));
} elseif ($e < $r) {
// add those requested more than entitled
$user->assignSku($sku, ($r - $e));
}
}
}
/**
* Create a response data array for specified user.
*
* @param \App\User $user User object
*
* @return array Response data
*/
public static function userResponse(User $user): array
{
$response = array_merge($user->toArray(), self::objectState($user));
// Settings
$response['settings'] = [];
foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) {
$response['settings'][$item->key] = $item->value;
}
// Aliases
$response['aliases'] = [];
foreach ($user->aliases as $item) {
$response['aliases'][] = $item->alias;
}
// Status info
$response['statusInfo'] = self::statusInfo($user);
// Add more info to the wallet object output
$map_func = function ($wallet) use ($user) {
$result = $wallet->toArray();
if ($wallet->discount) {
$result['discount'] = $wallet->discount->discount;
$result['discount_description'] = $wallet->discount->description;
}
if ($wallet->user_id != $user->id) {
$result['user_email'] = $wallet->owner->email;
}
$provider = \App\Providers\PaymentProvider::factory($wallet);
$result['provider'] = $provider->name();
return $result;
};
// Information about wallets and accounts for access checks
$response['wallets'] = $user->wallets->map($map_func)->toArray();
$response['accounts'] = $user->accounts->map($map_func)->toArray();
$response['wallet'] = $map_func($user->wallet());
return $response;
}
/**
* Prepare user statuses for the UI
*
* @param \App\User $user User object
*
* @return array Statuses array
*/
protected static function objectState($user): array
{
- return [
- 'isImapReady' => $user->isImapReady(),
- 'isLdapReady' => $user->isLdapReady(),
- 'isSuspended' => $user->isSuspended(),
- 'isActive' => $user->isActive(),
- 'isDeleted' => $user->isDeleted() || $user->trashed(),
- 'isDegraded' => $user->isDegraded(),
- 'isAccountDegraded' => $user->isDegraded(true),
- ];
+ $state = parent::objectState($user);
+
+ $state['isAccountDegraded'] = $user->isDegraded(true);
+
+ return $state;
}
/**
* Validate user input
*
* @param \Illuminate\Http\Request $request The API request.
* @param \App\User|null $user User identifier
* @param array $settings User settings (from the request)
*
* @return \Illuminate\Http\JsonResponse|null The error response on error
*/
protected function validateUserRequest(Request $request, $user, &$settings = [])
{
$rules = [
'external_email' => 'nullable|email',
'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/',
'first_name' => 'string|nullable|max:128',
'last_name' => 'string|nullable|max:128',
'organization' => 'string|nullable|max:512',
'billing_address' => 'string|nullable|max:1024',
'country' => 'string|nullable|alpha|size:2',
'currency' => 'string|nullable|alpha|size:3',
'aliases' => 'array|nullable',
];
if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) {
$rules['password'] = 'required|min:4|max:2048|confirmed';
}
$errors = [];
// Validate input
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = $v->errors()->toArray();
}
$controller = $user ? $user->wallet()->owner : $this->guard()->user();
// For new user validate email address
if (empty($user)) {
$email = $request->email;
if (empty($email)) {
$errors['email'] = \trans('validation.required', ['attribute' => 'email']);
} elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) {
$errors['email'] = $error;
}
}
// Validate aliases input
if (isset($request->aliases)) {
$aliases = [];
$existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : [];
foreach ($request->aliases as $idx => $alias) {
if (is_string($alias) && !empty($alias)) {
// Alias cannot be the same as the email address (new user)
if (!empty($email) && Str::lower($alias) == Str::lower($email)) {
continue;
}
// validate new aliases
if (
!in_array($alias, $existing_aliases)
&& ($error = self::validateAlias($alias, $controller))
) {
if (!isset($errors['aliases'])) {
$errors['aliases'] = [];
}
$errors['aliases'][$idx] = $error;
continue;
}
$aliases[] = $alias;
}
}
$request->aliases = $aliases;
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// Update user settings
$settings = $request->only(array_keys($rules));
unset($settings['password'], $settings['aliases'], $settings['email']);
return null;
}
/**
* Execute (synchronously) specified step in a user setup process.
*
* @param \App\User $user User object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool|null True if the execution succeeded, False if not, Null when
* the job has been sent to the worker (result unknown)
*/
public static function execProcessStep(User $user, string $step): ?bool
{
try {
if (strpos($step, 'domain-') === 0) {
list ($local, $domain) = explode('@', $user->email);
$domain = Domain::where('namespace', $domain)->first();
return DomainsController::execProcessStep($domain, $step);
}
switch ($step) {
case 'user-ldap-ready':
// User not in LDAP, create it
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
$user->refresh();
return $user->isLdapReady();
case 'user-imap-ready':
// User not in IMAP? Verify again
// Do it synchronously if the imap admin credentials are available
// otherwise let the worker do the job
if (!\config('imap.admin_password')) {
\App\Jobs\User\VerifyJob::dispatch($user->id);
return null;
}
$job = new \App\Jobs\User\VerifyJob($user->id);
$job->handle();
$user->refresh();
return $user->isImapReady();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
/**
* Email address validation for use as a user mailbox (login).
*
* @param string $email Email address
* @param \App\User $user The account owner
* @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group
* with the specified email address, if exists
*
* @return ?string Error message on validation error
*/
public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string
{
$deleted = null;
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
// Check if domain exists
$domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first();
if (empty($domain)) {
return \trans('validation.domaininvalid');
}
// Validate login part alone
$v = Validator::make(
['email' => $login],
['email' => ['required', new UserEmailLocal(!$domain->isPublic())]]
);
if ($v->fails()) {
return $v->errors()->toArray()['email'][0];
}
// Check if it is one of domains available to the user
if (!$user->domains()->where('namespace', $domain->namespace)->exists()) {
return \trans('validation.entryexists', ['attribute' => 'domain']);
}
// Check if a user with specified address already exists
if ($existing_user = User::emailExists($email, true)) {
// If this is a deleted user in the same custom domain
// we'll force delete him before
if (!$domain->isPublic() && $existing_user->trashed()) {
$deleted = $existing_user;
} else {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
}
// Check if an alias with specified address already exists.
if (User::aliasExists($email)) {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
// Check if a group or resource with specified address already exists
if (
($existing = Group::emailExists($email, true))
|| ($existing = \App\Resource::emailExists($email, true))
) {
// If this is a deleted group/resource in the same custom domain
// we'll force delete it before
if (!$domain->isPublic() && $existing->trashed()) {
$deleted = $existing;
} else {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
}
return null;
}
/**
* Email address validation for use as an alias.
*
* @param string $email Email address
* @param \App\User $user The account owner
*
* @return ?string Error message on validation error
*/
public static function validateAlias(string $email, \App\User $user): ?string
{
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'alias']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'alias']);
}
// Check if domain exists
$domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first();
if (empty($domain)) {
return \trans('validation.domaininvalid');
}
// Validate login part alone
$v = Validator::make(
['alias' => $login],
['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]]
);
if ($v->fails()) {
return $v->errors()->toArray()['alias'][0];
}
// Check if it is one of domains available to the user
if (!$user->domains()->where('namespace', $domain->namespace)->exists()) {
return \trans('validation.entryexists', ['attribute' => 'domain']);
}
// Check if a user with specified address already exists
if ($existing_user = User::emailExists($email, true)) {
// Allow an alias in a custom domain to an address that was a user before
if ($domain->isPublic() || !$existing_user->trashed()) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
}
// Check if an alias with specified address already exists
if (User::aliasExists($email)) {
// Allow assigning the same alias to a user in the same group account,
// but only for non-public domains
if ($domain->isPublic()) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
}
// Check if a group with specified address already exists
if (Group::emailExists($email)) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
return null;
}
}
diff --git a/src/app/Http/Controllers/RelationController.php b/src/app/Http/Controllers/RelationController.php
index e8451fcc..d9173e4c 100644
--- a/src/app/Http/Controllers/RelationController.php
+++ b/src/app/Http/Controllers/RelationController.php
@@ -1,335 +1,350 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Str;
class RelationController extends ResourceController
{
/** @var array Common object properties in the API response */
protected $objectProps = [];
/** @var string Resource localization label */
protected $label = '';
/** @var string Resource model name */
protected $model = '';
/** @var array Resource listing order (column names) */
protected $order = [];
/** @var array Resource relation method arguments */
protected $relationArgs = [];
/**
* Delete a resource.
*
* @param string $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function destroy($id)
{
$resource = $this->model::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canDelete($resource)) {
return $this->errorResponse(403);
}
$resource->delete();
return response()->json([
'status' => 'success',
'message' => \trans("app.{$this->label}-delete-success"),
]);
}
/**
* Listing of resources belonging to the authenticated user.
*
* The resource entitlements billed to the current user wallet(s)
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->guard()->user();
$method = Str::plural(\lcfirst(\class_basename($this->model)));
$query = call_user_func_array([$user, $method], $this->relationArgs);
if (!empty($this->order)) {
foreach ($this->order as $col) {
$query->orderBy($col);
}
}
$result = $query->get()
->map(function ($resource) {
return $this->objectToClient($resource);
});
return response()->json($result);
}
/**
* Prepare resource statuses for the UI
*
* @param object $resource Resource object
*
* @return array Statuses array
*/
protected static function objectState($resource): array
{
- return [];
+ $state = [];
+
+ $reflect = new \ReflectionClass(get_class($resource));
+
+ foreach (array_keys($reflect->getConstants()) as $const) {
+ if (strpos($const, 'STATUS_') === 0 && $const != 'STATUS_NEW') {
+ $method = Str::camel('is_' . strtolower(substr($const, 7)));
+ $state[$method] = $resource->{$method}();
+ }
+ }
+
+ if (empty($state['isDeleted']) && method_exists($resource, 'trashed')) {
+ $state['isDeleted'] = $resource->trashed();
+ }
+
+ return $state;
}
/**
* Prepare a resource object for the UI.
*
* @param object $object An object
* @param bool $full Include all object properties
*
* @return array Object information
*/
protected function objectToClient($object, bool $full = false): array
{
if ($full) {
$result = $object->toArray();
} else {
$result = ['id' => $object->id];
foreach ($this->objectProps as $prop) {
$result[$prop] = $object->{$prop};
}
}
$result = array_merge($result, $this->objectState($object));
return $result;
}
/**
* Object status' process information.
*
* @param object $object The object to process
* @param array $steps The steps definition
*
* @return array Process state information
*/
protected static function processStateInfo($object, array $steps): array
{
$process = [];
// Create a process check list
foreach ($steps as $step_name => $state) {
$step = [
'label' => $step_name,
'title' => \trans("app.process-{$step_name}"),
];
if (is_array($state)) {
$step['link'] = $state[1];
$state = $state[0];
}
$step['state'] = $state;
$process[] = $step;
}
// Add domain specific steps
if (method_exists($object, 'domain')) {
$domain = $object->domain();
// If that is not a public domain
if ($domain && !$domain->isPublic()) {
$domain_status = API\V4\DomainsController::statusInfo($domain);
$process = array_merge($process, $domain_status['process']);
}
}
$all = count($process);
$checked = count(array_filter($process, function ($v) {
return $v['state'];
}));
$state = $all === $checked ? 'done' : 'running';
// After 180 seconds assume the process is in failed state,
// this should unlock the Refresh button in the UI
if ($all !== $checked && $object->created_at->diffInSeconds(\Carbon\Carbon::now()) > 180) {
$state = 'failed';
}
return [
'process' => $process,
'processState' => $state,
'isReady' => $all === $checked,
];
}
/**
* Object status' process information update.
*
* @param object $object The object to process
*
* @return array Process state information
*/
protected function processStateUpdate($object): array
{
$response = $this->statusInfo($object);
if (!empty(request()->input('refresh'))) {
$updated = false;
$async = false;
$last_step = 'none';
foreach ($response['process'] as $idx => $step) {
$last_step = $step['label'];
if (!$step['state']) {
$exec = $this->execProcessStep($object, $step['label']); // @phpstan-ignore-line
if (!$exec) {
if ($exec === null) {
$async = true;
}
break;
}
$updated = true;
}
}
if ($updated) {
$response = $this->statusInfo($object);
}
$success = $response['isReady'];
$suffix = $success ? 'success' : 'error-' . $last_step;
$response['status'] = $success ? 'success' : 'error';
$response['message'] = \trans('app.process-' . $suffix);
if ($async && !$success) {
$response['processState'] = 'waiting';
$response['status'] = 'success';
$response['message'] = \trans('app.process-async');
}
}
return $response;
}
/**
* Set the resource configuration.
*
* @param int $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function setConfig($id)
{
$resource = $this->model::find($id);
if (!method_exists($this->model, 'setConfig')) {
return $this->errorResponse(404);
}
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($resource)) {
return $this->errorResponse(403);
}
$errors = $resource->setConfig(request()->input());
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
return response()->json([
'status' => 'success',
'message' => \trans("app.{$this->label}-setconfig-success"),
]);
}
/**
* Display information of a resource specified by $id.
*
* @param string $id The resource to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$resource = $this->model::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($resource)) {
return $this->errorResponse(403);
}
$response = $this->objectToClient($resource, true);
if (!empty($statusInfo = $this->statusInfo($resource))) {
$response['statusInfo'] = $statusInfo;
}
// Resource configuration, e.g. sender_policy, invitation_policy, acl
if (method_exists($resource, 'getConfig')) {
$response['config'] = $resource->getConfig();
}
return response()->json($response);
}
/**
* Fetch resource status (and reload setup process)
*
* @param int $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function status($id)
{
$resource = $this->model::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($resource)) {
return $this->errorResponse(403);
}
$response = $this->processStateUpdate($resource);
$response = array_merge($response, $this->objectState($resource));
return response()->json($response);
}
/**
* Resource status (extended) information
*
* @param object $resource Resource object
*
* @return array Status information
*/
public static function statusInfo($resource): array
{
return [];
}
}
diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php
index c1d60421..702bbdd0 100644
--- a/src/app/Jobs/WalletCheck.php
+++ b/src/app/Jobs/WalletCheck.php
@@ -1,405 +1,402 @@
<?php
namespace App\Jobs;
use App\Http\Controllers\API\V4\PaymentsController;
use App\Wallet;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class WalletCheck implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public const THRESHOLD_DEGRADE = 'degrade';
public const THRESHOLD_DEGRADE_REMINDER = 'degrade-reminder';
public const THRESHOLD_BEFORE_DEGRADE = 'before_degrade';
public const THRESHOLD_DELETE = 'delete';
public const THRESHOLD_BEFORE_DELETE = 'before_delete';
public const THRESHOLD_SUSPEND = 'suspend';
public const THRESHOLD_BEFORE_SUSPEND = 'before_suspend';
public const THRESHOLD_REMINDER = 'reminder';
public const THRESHOLD_BEFORE_REMINDER = 'before_reminder';
public const THRESHOLD_INITIAL = 'initial';
/** @var int The number of seconds to wait before retrying the job. */
public $retryAfter = 10;
/** @var int How many times retry the job if it fails. */
public $tries = 5;
/** @var bool Delete the job if the wallet no longer exist. */
public $deleteWhenMissingModels = true;
/** @var \App\Wallet A wallet object */
protected $wallet;
/**
* Create a new job instance.
*
* @param \App\Wallet $wallet The wallet that has been charged.
*
* @return void
*/
public function __construct(Wallet $wallet)
{
$this->wallet = $wallet;
}
/**
* Execute the job.
*
* @return ?string Executed action (THRESHOLD_*)
*/
public function handle()
{
if ($this->wallet->balance >= 0) {
return null;
}
$now = Carbon::now();
/*
// Steps for old "first suspend then delete" approach
$steps = [
// Send the initial reminder
self::THRESHOLD_INITIAL => 'initialReminder',
// Try to top-up the wallet before the second reminder
self::THRESHOLD_BEFORE_REMINDER => 'topUpWallet',
// Send the second reminder
self::THRESHOLD_REMINDER => 'secondReminder',
// Try to top-up the wallet before suspending the account
self::THRESHOLD_BEFORE_SUSPEND => 'topUpWallet',
// Suspend the account
self::THRESHOLD_SUSPEND => 'suspendAccount',
// Warn about the upcomming account deletion
self::THRESHOLD_BEFORE_DELETE => 'warnBeforeDelete',
// Delete the account
self::THRESHOLD_DELETE => 'deleteAccount',
];
*/
// Steps for "demote instead of suspend+delete" approach
$steps = [
// Send the initial reminder
self::THRESHOLD_INITIAL => 'initialReminderForDegrade',
// Try to top-up the wallet before the second reminder
self::THRESHOLD_BEFORE_REMINDER => 'topUpWallet',
// Send the second reminder
self::THRESHOLD_REMINDER => 'secondReminderForDegrade',
// Try to top-up the wallet before the account degradation
self::THRESHOLD_BEFORE_DEGRADE => 'topUpWallet',
// Degrade the account
self::THRESHOLD_DEGRADE => 'degradeAccount',
];
if ($this->wallet->owner && $this->wallet->owner->isDegraded()) {
$this->degradedReminder();
return self::THRESHOLD_DEGRADE_REMINDER;
}
foreach (array_reverse($steps, true) as $type => $method) {
if (self::threshold($this->wallet, $type) < $now) {
$this->{$method}();
return $type;
}
}
return null;
}
/**
* Send the initial reminder (for the suspend+delete process)
*/
protected function initialReminder()
{
if ($this->wallet->getSetting('balance_warning_initial')) {
return;
}
// TODO: Should we check if the account is already suspended?
$this->sendMail(\App\Mail\NegativeBalance::class, false);
$now = \Carbon\Carbon::now()->toDateTimeString();
$this->wallet->setSetting('balance_warning_initial', $now);
}
/**
* Send the initial reminder (for the process of degrading a account)
*/
protected function initialReminderForDegrade()
{
if ($this->wallet->getSetting('balance_warning_initial')) {
return;
}
if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) {
return;
}
$this->sendMail(\App\Mail\NegativeBalance::class, false);
$now = \Carbon\Carbon::now()->toDateTimeString();
$this->wallet->setSetting('balance_warning_initial', $now);
}
/**
* Send the second reminder (for the suspend+delete process)
*/
protected function secondReminder()
{
if ($this->wallet->getSetting('balance_warning_reminder')) {
return;
}
// TODO: Should we check if the account is already suspended?
$this->sendMail(\App\Mail\NegativeBalanceReminder::class, false);
$now = \Carbon\Carbon::now()->toDateTimeString();
$this->wallet->setSetting('balance_warning_reminder', $now);
}
/**
* Send the second reminder (for the process of degrading a account)
*/
protected function secondReminderForDegrade()
{
if ($this->wallet->getSetting('balance_warning_reminder')) {
return;
}
if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) {
return;
}
$this->sendMail(\App\Mail\NegativeBalanceReminderDegrade::class, true);
$now = \Carbon\Carbon::now()->toDateTimeString();
$this->wallet->setSetting('balance_warning_reminder', $now);
}
/**
* Suspend the account (and send the warning)
*/
protected function suspendAccount()
{
if ($this->wallet->getSetting('balance_warning_suspended')) {
return;
}
// Sanity check, already deleted
if (!$this->wallet->owner) {
return;
}
// Suspend the account
$this->wallet->owner->suspend();
foreach ($this->wallet->entitlements as $entitlement) {
- if (
- $entitlement->entitleable_type == \App\Domain::class
- || $entitlement->entitleable_type == \App\User::class
- ) {
+ if (method_exists($entitlement->entitleable_type, 'suspend')) {
$entitlement->entitleable->suspend();
}
}
$this->sendMail(\App\Mail\NegativeBalanceSuspended::class, true);
$now = \Carbon\Carbon::now()->toDateTimeString();
$this->wallet->setSetting('balance_warning_suspended', $now);
}
/**
* Send the last warning before delete
*/
protected function warnBeforeDelete()
{
if ($this->wallet->getSetting('balance_warning_before_delete')) {
return;
}
// Sanity check, already deleted
if (!$this->wallet->owner) {
return;
}
$this->sendMail(\App\Mail\NegativeBalanceBeforeDelete::class, true);
$now = \Carbon\Carbon::now()->toDateTimeString();
$this->wallet->setSetting('balance_warning_before_delete', $now);
}
/**
* Send the periodic reminder to the degraded account owners
*/
protected function degradedReminder()
{
// Sanity check
if (!$this->wallet->owner || !$this->wallet->owner->isDegraded()) {
return;
}
$now = \Carbon\Carbon::now();
$last = $this->wallet->getSetting('degraded_last_reminder');
if ($last) {
$last = new Carbon($last);
$period = 14;
if ($last->addDays($period) > $now) {
return;
}
$this->sendMail(\App\Mail\DegradedAccountReminder::class, true);
}
$this->wallet->setSetting('degraded_last_reminder', $now->toDateTimeString());
}
/**
* Degrade the account
*/
protected function degradeAccount()
{
// The account may be already deleted, or degraded
if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) {
return;
}
$email = $this->wallet->owner->email;
// The dirty work will be done by UserObserver
$this->wallet->owner->degrade();
\Log::info(
sprintf(
"[WalletCheck] Account degraded %s (%s)",
$this->wallet->id,
$email
)
);
$this->sendMail(\App\Mail\NegativeBalanceDegraded::class, true);
}
/**
* Delete the account
*/
protected function deleteAccount()
{
// TODO: This will not work when we actually allow multiple-wallets per account
// but in this case we anyway have to change the whole thing
// and calculate summarized balance from all wallets.
// The dirty work will be done by UserObserver
if ($this->wallet->owner) {
$email = $this->wallet->owner->email;
$this->wallet->owner->delete();
\Log::info(
sprintf(
"[WalletCheck] Account deleted %s (%s)",
$this->wallet->id,
$email
)
);
}
}
/**
* Send the email
*
* @param string $class Mailable class name
* @param bool $with_external Use users's external email
*/
protected function sendMail($class, $with_external = false): void
{
// TODO: Send the email to all wallet controllers?
$mail = new $class($this->wallet, $this->wallet->owner);
list($to, $cc) = \App\Mail\Helper::userEmails($this->wallet->owner, $with_external);
if (!empty($to) || !empty($cc)) {
$params = [
'to' => $to,
'cc' => $cc,
'add' => " for {$this->wallet->id}",
];
\App\Mail\Helper::sendMail($mail, $this->wallet->owner->tenant_id, $params);
}
}
/**
* Get the date-time for an action threshold. Calculated using
* the date when a wallet balance turned negative.
*
* @param \App\Wallet $wallet A wallet
* @param string $type Action type (one of self::THRESHOLD_*)
*
* @return \Carbon\Carbon The threshold date-time object
*/
public static function threshold(Wallet $wallet, string $type): ?Carbon
{
$negative_since = $wallet->getSetting('balance_negative_since');
// Migration scenario: balance<0, but no balance_negative_since set
if (!$negative_since) {
// 2h back from now, so first run can sent the initial notification
$negative_since = Carbon::now()->subHours(2);
$wallet->setSetting('balance_negative_since', $negative_since->toDateTimeString());
} else {
$negative_since = new Carbon($negative_since);
}
// Initial notification
// Give it an hour so the async recurring payment has a chance to be finished
if ($type == self::THRESHOLD_INITIAL) {
return $negative_since->addHours(1);
}
$thresholds = [
// A day before the second reminder
self::THRESHOLD_BEFORE_REMINDER => 7 - 1,
// Second notification
self::THRESHOLD_REMINDER => 7,
// A day before account suspension
self::THRESHOLD_BEFORE_SUSPEND => 14 + 7 - 1,
// Account suspension
self::THRESHOLD_SUSPEND => 14 + 7,
// Warning about the upcomming account deletion
self::THRESHOLD_BEFORE_DELETE => 21 + 14 + 7 - 3,
// Acount deletion
self::THRESHOLD_DELETE => 21 + 14 + 7,
// Last chance to top-up the wallet
self::THRESHOLD_BEFORE_DEGRADE => 13,
// Account degradation
self::THRESHOLD_DEGRADE => 14,
];
if (!empty($thresholds[$type])) {
return $negative_since->addDays($thresholds[$type]);
}
return null;
}
/**
* Try to automatically top-up the wallet
*/
protected function topUpWallet(): void
{
PaymentsController::topUpWallet($this->wallet);
}
}
diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php
index 009c22e5..e7dd89ee 100644
--- a/src/app/Observers/WalletObserver.php
+++ b/src/app/Observers/WalletObserver.php
@@ -1,111 +1,108 @@
<?php
namespace App\Observers;
use App\Wallet;
/**
* This is an observer for the Wallet model definition.
*/
class WalletObserver
{
/**
* Ensure the wallet ID is a custom ID (uuid).
*
* @param Wallet $wallet
*
* @return void
*/
public function creating(Wallet $wallet)
{
$wallet->currency = \config('app.currency');
}
/**
* Handle the wallet "deleting" event.
*
* Ensures that a wallet with a non-zero balance can not be deleted.
*
* Ensures that the wallet being deleted is not the last wallet for the user.
*
* Ensures that no entitlements are being billed to the wallet currently.
*
* @param Wallet $wallet The wallet being deleted.
*
* @return bool
*/
public function deleting(Wallet $wallet): bool
{
// can't delete a wallet that has any balance on it (positive and negative).
if ($wallet->balance != 0.00) {
return false;
}
if (!$wallet->owner) {
throw new \Exception("Wallet: " . var_export($wallet, true));
}
// can't remove the last wallet for the owner.
if ($wallet->owner->wallets()->count() <= 1) {
return false;
}
// can't remove a wallet that has billable entitlements attached.
if ($wallet->entitlements()->count() > 0) {
return false;
}
/*
// can't remove a wallet that has payments attached.
if ($wallet->payments()->count() > 0) {
return false;
}
*/
return true;
}
/**
* Handle the wallet "updated" event.
*
* @param \App\Wallet $wallet The wallet.
*
* @return void
*/
public function updated(Wallet $wallet)
{
$negative_since = $wallet->getSetting('balance_negative_since');
if ($wallet->balance < 0) {
if (!$negative_since) {
$now = \Carbon\Carbon::now()->toDateTimeString();
$wallet->setSetting('balance_negative_since', $now);
}
} elseif ($negative_since) {
$wallet->setSettings([
'balance_negative_since' => null,
'balance_warning_initial' => null,
'balance_warning_reminder' => null,
'balance_warning_suspended' => null,
'balance_warning_before_delete' => null,
]);
// FIXME: Since we use account degradation, should we leave suspended state untouched?
// Un-suspend and un-degrade the account owner
if ($wallet->owner) {
$wallet->owner->unsuspend();
$wallet->owner->undegrade();
}
// Un-suspend domains/users
foreach ($wallet->entitlements as $entitlement) {
- if (
- $entitlement->entitleable_type == \App\Domain::class
- || $entitlement->entitleable_type == \App\User::class
- ) {
+ if (method_exists($entitlement->entitleable_type, 'unsuspend')) {
$entitlement->entitleable->unsuspend();
}
}
}
}
}
diff --git a/src/app/Resource.php b/src/app/Resource.php
index e0f4cbff..de893241 100644
--- a/src/app/Resource.php
+++ b/src/app/Resource.php
@@ -1,176 +1,97 @@
<?php
namespace App;
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
use App\Traits\ResourceConfigTrait;
use App\Traits\SettingsTrait;
+use App\Traits\StatusPropertyTrait;
use App\Traits\UuidIntKeyTrait;
use App\Wallet;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* The eloquent definition of a Resource.
*
* @property int $id The resource identifier
* @property string $email An email address
* @property string $name The resource name
* @property int $status The resource status
* @property int $tenant_id Tenant identifier
*/
class Resource extends Model
{
use BelongsToTenantTrait;
use EntitleableTrait;
use ResourceConfigTrait;
use SettingsTrait;
use SoftDeletes;
+ use StatusPropertyTrait;
use UuidIntKeyTrait;
// we've simply never heard of this resource
public const STATUS_NEW = 1 << 0;
// resource has been activated
public const STATUS_ACTIVE = 1 << 1;
// resource has been suspended.
// public const STATUS_SUSPENDED = 1 << 2;
// resource has been deleted
public const STATUS_DELETED = 1 << 3;
// resource has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
// resource has been created in IMAP
public const STATUS_IMAP_READY = 1 << 8;
protected $fillable = [
'email',
'name',
'status',
];
/** @var ?string Domain name for a resource to be created */
public $domain;
/**
* Returns the resource domain.
*
* @return ?\App\Domain The domain to which the resource belongs to, NULL if it does not exist
*/
public function domain(): ?Domain
{
if (isset($this->domain)) {
$domainName = $this->domain;
} else {
list($local, $domainName) = explode('@', $this->email);
}
return Domain::where('namespace', $domainName)->first();
}
/**
* Find whether an email address exists as a resource (including deleted resources).
*
* @param string $email Email address
* @param bool $return_resource Return Resource instance instead of boolean
*
* @return \App\Resource|bool True or Resource model object if found, False otherwise
*/
public static function emailExists(string $email, bool $return_resource = false)
{
if (strpos($email, '@') === false) {
return false;
}
$email = \strtolower($email);
$resource = self::withTrashed()->where('email', $email)->first();
if ($resource) {
return $return_resource ? $resource : true;
}
return false;
}
-
- /**
- * Returns whether this resource is active.
- *
- * @return bool
- */
- public function isActive(): bool
- {
- return ($this->status & self::STATUS_ACTIVE) > 0;
- }
-
- /**
- * Returns whether this resource is deleted.
- *
- * @return bool
- */
- public function isDeleted(): bool
- {
- return ($this->status & self::STATUS_DELETED) > 0;
- }
-
- /**
- * Returns whether this resource's folder exists in IMAP.
- *
- * @return bool
- */
- public function isImapReady(): bool
- {
- return ($this->status & self::STATUS_IMAP_READY) > 0;
- }
-
- /**
- * Returns whether this resource is registered in LDAP.
- *
- * @return bool
- */
- public function isLdapReady(): bool
- {
- return ($this->status & self::STATUS_LDAP_READY) > 0;
- }
-
- /**
- * Returns whether this resource is new.
- *
- * @return bool
- */
- public function isNew(): bool
- {
- return ($this->status & self::STATUS_NEW) > 0;
- }
-
- /**
- * Resource status mutator
- *
- * @throws \Exception
- */
- public function setStatusAttribute($status)
- {
- $new_status = 0;
-
- $allowed_values = [
- self::STATUS_NEW,
- self::STATUS_ACTIVE,
- self::STATUS_DELETED,
- self::STATUS_IMAP_READY,
- self::STATUS_LDAP_READY,
- ];
-
- foreach ($allowed_values as $value) {
- if ($status & $value) {
- $new_status |= $value;
- $status ^= $value;
- }
- }
-
- if ($status > 0) {
- throw new \Exception("Invalid resource status: {$status}");
- }
-
- $this->attributes['status'] = $new_status;
- }
}
diff --git a/src/app/SharedFolder.php b/src/app/SharedFolder.php
index a8d68d88..d17ac9ca 100644
--- a/src/app/SharedFolder.php
+++ b/src/app/SharedFolder.php
@@ -1,196 +1,117 @@
<?php
namespace App;
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
use App\Traits\SharedFolderConfigTrait;
use App\Traits\SettingsTrait;
+use App\Traits\StatusPropertyTrait;
use App\Traits\UuidIntKeyTrait;
use App\Wallet;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* The eloquent definition of a SharedFolder.
*
* @property string $email An email address
* @property int $id The folder identifier
* @property string $name The folder name
* @property int $status The folder status
* @property int $tenant_id Tenant identifier
* @property string $type The folder type
*/
class SharedFolder extends Model
{
use BelongsToTenantTrait;
use EntitleableTrait;
use SharedFolderConfigTrait;
use SettingsTrait;
use SoftDeletes;
+ use StatusPropertyTrait;
use UuidIntKeyTrait;
// we've simply never heard of this folder
public const STATUS_NEW = 1 << 0;
// folder has been activated
public const STATUS_ACTIVE = 1 << 1;
// folder has been suspended.
// public const STATUS_SUSPENDED = 1 << 2;
// folder has been deleted
public const STATUS_DELETED = 1 << 3;
// folder has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
// folder has been created in IMAP
public const STATUS_IMAP_READY = 1 << 8;
/** @const array Supported folder type labels */
public const SUPPORTED_TYPES = ['mail', 'event', 'contact', 'task', 'note'];
/** @var array Mass-assignable properties */
protected $fillable = [
'email',
'name',
'status',
'type',
];
/** @var ?string Domain name for a shared folder to be created */
public $domain;
/**
* Returns the shared folder domain.
*
* @return ?\App\Domain The domain to which the folder belongs to, NULL if it does not exist
*/
public function domain(): ?Domain
{
if (isset($this->domain)) {
$domainName = $this->domain;
} else {
list($local, $domainName) = explode('@', $this->email);
}
return Domain::where('namespace', $domainName)->first();
}
/**
* Find whether an email address exists as a shared folder (including deleted folders).
*
* @param string $email Email address
* @param bool $return_folder Return SharedFolder instance instead of boolean
*
* @return \App\SharedFolder|bool True or Resource model object if found, False otherwise
*/
public static function emailExists(string $email, bool $return_folder = false)
{
if (strpos($email, '@') === false) {
return false;
}
$email = \strtolower($email);
$folder = self::withTrashed()->where('email', $email)->first();
if ($folder) {
return $return_folder ? $folder : true;
}
return false;
}
- /**
- * Returns whether this folder is active.
- *
- * @return bool
- */
- public function isActive(): bool
- {
- return ($this->status & self::STATUS_ACTIVE) > 0;
- }
-
- /**
- * Returns whether this folder is deleted.
- *
- * @return bool
- */
- public function isDeleted(): bool
- {
- return ($this->status & self::STATUS_DELETED) > 0;
- }
-
- /**
- * Returns whether this folder exists in IMAP.
- *
- * @return bool
- */
- public function isImapReady(): bool
- {
- return ($this->status & self::STATUS_IMAP_READY) > 0;
- }
-
- /**
- * Returns whether this folder is registered in LDAP.
- *
- * @return bool
- */
- public function isLdapReady(): bool
- {
- return ($this->status & self::STATUS_LDAP_READY) > 0;
- }
-
- /**
- * Returns whether this folder is new.
- *
- * @return bool
- */
- public function isNew(): bool
- {
- return ($this->status & self::STATUS_NEW) > 0;
- }
-
- /**
- * Folder status mutator
- *
- * @throws \Exception
- */
- public function setStatusAttribute($status)
- {
- $new_status = 0;
-
- $allowed_values = [
- self::STATUS_NEW,
- self::STATUS_ACTIVE,
- self::STATUS_DELETED,
- self::STATUS_IMAP_READY,
- self::STATUS_LDAP_READY,
- ];
-
- foreach ($allowed_values as $value) {
- if ($status & $value) {
- $new_status |= $value;
- $status ^= $value;
- }
- }
-
- if ($status > 0) {
- throw new \Exception("Invalid shared folder status: {$status}");
- }
-
- $this->attributes['status'] = $new_status;
- }
-
/**
* Folder type mutator
*
* @throws \Exception
*/
public function setTypeAttribute($type)
{
if (!in_array($type, self::SUPPORTED_TYPES)) {
throw new \Exception("Invalid shared folder type: {$type}");
}
$this->attributes['type'] = $type;
}
}
diff --git a/src/app/Traits/StatusPropertyTrait.php b/src/app/Traits/StatusPropertyTrait.php
new file mode 100644
index 00000000..5c0eb3cc
--- /dev/null
+++ b/src/app/Traits/StatusPropertyTrait.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace App\Traits;
+
+trait StatusPropertyTrait
+{
+ /**
+ * Returns whether this object is active.
+ *
+ * @return bool
+ */
+ public function isActive(): bool
+ {
+ return defined('static::STATUS_ACTIVE') && ($this->status & static::STATUS_ACTIVE) > 0;
+ }
+
+ /**
+ * Returns whether this object is deleted.
+ *
+ * @return bool
+ */
+ public function isDeleted(): bool
+ {
+ return defined('static::STATUS_DELETED') && ($this->status & static::STATUS_DELETED) > 0;
+ }
+
+ /**
+ * Returns whether this object is registered in IMAP.
+ *
+ * @return bool
+ */
+ public function isImapReady(): bool
+ {
+ return defined('static::STATUS_IMAP_READY') && ($this->status & static::STATUS_IMAP_READY) > 0;
+ }
+
+ /**
+ * Returns whether this object is registered in LDAP.
+ *
+ * @return bool
+ */
+ public function isLdapReady(): bool
+ {
+ return defined('static::STATUS_LDAP_READY') && ($this->status & static::STATUS_LDAP_READY) > 0;
+ }
+
+ /**
+ * Returns whether this object is new.
+ *
+ * @return bool
+ */
+ public function isNew(): bool
+ {
+ return defined('static::STATUS_NEW') && ($this->status & static::STATUS_NEW) > 0;
+ }
+
+ /**
+ * Returns whether this object is suspended.
+ *
+ * @return bool
+ */
+ public function isSuspended(): bool
+ {
+ return defined('static::STATUS_SUSPENDED') && ($this->status & static::STATUS_SUSPENDED) > 0;
+ }
+
+ /**
+ * Suspend this object.
+ *
+ * @return void
+ */
+ public function suspend(): void
+ {
+ if (!defined('static::STATUS_SUSPENDED') || $this->isSuspended()) {
+ return;
+ }
+
+ $this->status |= static::STATUS_SUSPENDED;
+ $this->save();
+ }
+
+ /**
+ * Unsuspend this object.
+ *
+ * @return void
+ */
+ public function unsuspend(): void
+ {
+ if (!defined('static::STATUS_SUSPENDED') || !$this->isSuspended()) {
+ return;
+ }
+
+ $this->status ^= static::STATUS_SUSPENDED;
+ $this->save();
+ }
+
+ /**
+ * Status property mutator
+ *
+ * @throws \Exception
+ */
+ public function setStatusAttribute($status)
+ {
+ $new_status = 0;
+
+ $allowed_states = [
+ 'STATUS_NEW',
+ 'STATUS_ACTIVE',
+ 'STATUS_SUSPENDED',
+ 'STATUS_DELETED',
+ 'STATUS_LDAP_READY',
+ 'STATUS_IMAP_READY',
+ ];
+
+ foreach ($allowed_states as $const) {
+ if (!defined("static::$const")) {
+ continue;
+ }
+
+ $value = constant("static::$const");
+
+ if ($status & $value) {
+ $new_status |= $value;
+ $status ^= $value;
+ }
+ }
+
+ if ($status > 0) {
+ throw new \Exception("Invalid status: {$status}");
+ }
+
+ $this->attributes['status'] = $new_status;
+ }
+}
diff --git a/src/app/User.php b/src/app/User.php
index 5d934d40..61777b60 100644
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -1,835 +1,747 @@
<?php
namespace App;
use App\UserAlias;
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
use App\Traits\UserAliasesTrait;
use App\Traits\UserConfigTrait;
use App\Traits\UuidIntKeyTrait;
use App\Traits\SettingsTrait;
+use App\Traits\StatusPropertyTrait;
use App\Wallet;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Iatstuti\Database\Support\NullableFields;
use Laravel\Passport\HasApiTokens;
use League\OAuth2\Server\Exception\OAuthServerException;
/**
* The eloquent definition of a User.
*
* @property string $email
* @property int $id
* @property string $password
* @property string $password_ldap
* @property int $status
* @property int $tenant_id
*/
class User extends Authenticatable
{
use BelongsToTenantTrait;
use EntitleableTrait;
use HasApiTokens;
use NullableFields;
use UserConfigTrait;
use UserAliasesTrait;
use UuidIntKeyTrait;
use SettingsTrait;
use SoftDeletes;
+ use StatusPropertyTrait;
// a new user, default on creation
public const STATUS_NEW = 1 << 0;
// it's been activated
public const STATUS_ACTIVE = 1 << 1;
// user has been suspended
public const STATUS_SUSPENDED = 1 << 2;
// user has been deleted
public const STATUS_DELETED = 1 << 3;
// user has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
// user mailbox has been created in IMAP
public const STATUS_IMAP_READY = 1 << 5;
// user in "limited feature-set" state
public const STATUS_DEGRADED = 1 << 6;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'id',
'email',
'password',
'password_ldap',
'status',
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'password',
'password_ldap',
'role'
];
protected $nullable = [
'password',
'password_ldap'
];
/**
* Any wallets on which this user is a controller.
*
* This does not include wallets owned by the user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function accounts()
{
return $this->belongsToMany(
'App\Wallet', // The foreign object definition
'user_accounts', // The table name
'user_id', // The local foreign key
'wallet_id' // The remote foreign key
);
}
/**
* Email aliases of this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function aliases()
{
return $this->hasMany('App\UserAlias', 'user_id');
}
/**
* Assign a package to a user. The user should not have any existing entitlements.
*
* @param \App\Package $package The package to assign.
* @param \App\User|null $user Assign the package to another user.
*
* @return \App\User
*/
public function assignPackage($package, $user = null)
{
if (!$user) {
$user = $this;
}
return $user->assignPackageAndWallet($package, $this->wallets()->first());
}
/**
* Assign a package plan to a user.
*
* @param \App\Plan $plan The plan to assign
* @param \App\Domain $domain Optional domain object
*
* @return \App\User Self
*/
public function assignPlan($plan, $domain = null): User
{
$this->setSetting('plan_id', $plan->id);
foreach ($plan->packages as $package) {
if ($package->isDomain()) {
$domain->assignPackage($package, $this);
} else {
$this->assignPackage($package);
}
}
return $this;
}
/**
* Check if current user can delete another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canDelete($object): bool
{
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
// TODO: For now controller can delete/update the account owner,
// this may change in future, controllers are not 0-regression feature
return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet));
}
/**
* Check if current user can read data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canRead($object): bool
{
if ($this->role == 'admin') {
return true;
}
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
if ($object instanceof Wallet) {
return $object->user_id == $this->id || $object->controllers->contains($this);
}
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet));
}
/**
* Check if current user can update data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canUpdate($object): bool
{
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'admin') {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
return $this->canDelete($object);
}
/**
* Degrade the user
*
* @return void
*/
public function degrade(): void
{
if ($this->isDegraded()) {
return;
}
$this->status |= User::STATUS_DEGRADED;
$this->save();
}
/**
* Return the \App\Domain for this user.
*
* @return \App\Domain|null
*/
public function domain()
{
list($local, $domainName) = explode('@', $this->email);
$domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first();
return $domain;
}
/**
* List the domains to which this user is entitled.
*
* @param bool $with_accounts Include domains assigned to wallets
* the current user controls but not owns.
* @param bool $with_public Include active public domains (for the user tenant).
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function domains($with_accounts = true, $with_public = true)
{
$domains = $this->entitleables(Domain::class, $with_accounts);
if ($with_public) {
$domains->orWhere(function ($query) {
if (!$this->tenant_id) {
$query->where('tenant_id', $this->tenant_id);
} else {
$query->withEnvTenantContext();
}
$query->whereRaw(sprintf('(domains.type & %s)', Domain::TYPE_PUBLIC))
->whereRaw(sprintf('(domains.status & %s)', Domain::STATUS_ACTIVE));
});
}
return $domains;
}
/**
* Find whether an email address exists as a user (including deleted users).
*
* @param string $email Email address
* @param bool $return_user Return User instance instead of boolean
*
* @return \App\User|bool True or User model object if found, False otherwise
*/
public static function emailExists(string $email, bool $return_user = false)
{
if (strpos($email, '@') === false) {
return false;
}
$email = \strtolower($email);
$user = self::withTrashed()->where('email', $email)->first();
if ($user) {
return $return_user ? $user : true;
}
return false;
}
/**
* Return entitleable objects of a specified type controlled by the current user.
*
* @param string $class Object class
* @param bool $with_accounts Include objects assigned to wallets
* the current user controls, but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
private function entitleables(string $class, bool $with_accounts = true)
{
$wallets = $this->wallets()->pluck('id')->all();
if ($with_accounts) {
$wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
}
$object = new $class();
$table = $object->getTable();
return $object->select("{$table}.*")
->whereExists(function ($query) use ($table, $wallets, $class) {
$query->select(DB::raw(1))
->from('entitlements')
->whereColumn('entitleable_id', "{$table}.id")
->whereIn('entitlements.wallet_id', $wallets)
->where('entitlements.entitleable_type', $class);
});
}
/**
* Helper to find user by email address, whether it is
* main email address, alias or an external email.
*
* If there's more than one alias NULL will be returned.
*
* @param string $email Email address
* @param bool $external Search also for an external email
*
* @return \App\User|null User model object if found
*/
public static function findByEmail(string $email, bool $external = false): ?User
{
if (strpos($email, '@') === false) {
return null;
}
$email = \strtolower($email);
$user = self::where('email', $email)->first();
if ($user) {
return $user;
}
$aliases = UserAlias::where('alias', $email)->get();
if (count($aliases) == 1) {
return $aliases->first()->user;
}
// TODO: External email
return null;
}
/**
* Return groups controlled by the current user.
*
* @param bool $with_accounts Include groups assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function groups($with_accounts = true)
{
return $this->entitleables(Group::class, $with_accounts);
}
- /**
- * Returns whether this user is active.
- *
- * @return bool
- */
- public function isActive(): bool
- {
- return ($this->status & self::STATUS_ACTIVE) > 0;
- }
-
/**
* Returns whether this user (or its wallet owner) is degraded.
*
* @param bool $owner Check also the wallet owner instead just the user himself
*
* @return bool
*/
public function isDegraded(bool $owner = false): bool
{
if ($this->status & self::STATUS_DEGRADED) {
return true;
}
if ($owner && ($wallet = $this->wallet())) {
return $wallet->owner && $wallet->owner->isDegraded();
}
return false;
}
- /**
- * Returns whether this user is deleted.
- *
- * @return bool
- */
- public function isDeleted(): bool
- {
- return ($this->status & self::STATUS_DELETED) > 0;
- }
-
- /**
- * Returns whether this user is registered in IMAP.
- *
- * @return bool
- */
- public function isImapReady(): bool
- {
- return ($this->status & self::STATUS_IMAP_READY) > 0;
- }
-
- /**
- * Returns whether this user is registered in LDAP.
- *
- * @return bool
- */
- public function isLdapReady(): bool
- {
- return ($this->status & self::STATUS_LDAP_READY) > 0;
- }
-
- /**
- * Returns whether this user is new.
- *
- * @return bool
- */
- public function isNew(): bool
- {
- return ($this->status & self::STATUS_NEW) > 0;
- }
-
- /**
- * Returns whether this user is suspended.
- *
- * @return bool
- */
- public function isSuspended(): bool
- {
- return ($this->status & self::STATUS_SUSPENDED) > 0;
- }
-
/**
* A shortcut to get the user name.
*
* @param bool $fallback Return "<aa.name> User" if there's no name
*
* @return string Full user name
*/
public function name(bool $fallback = false): string
{
$settings = $this->getSettings(['first_name', 'last_name']);
$name = trim($settings['first_name'] . ' ' . $settings['last_name']);
if (empty($name) && $fallback) {
return trim(\trans('app.siteuser', ['site' => \App\Tenant::getConfig($this->tenant_id, 'app.name')]));
}
return $name;
}
/**
* Return resources controlled by the current user.
*
* @param bool $with_accounts Include resources assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function resources($with_accounts = true)
{
return $this->entitleables(\App\Resource::class, $with_accounts);
}
/**
* Return shared folders controlled by the current user.
*
* @param bool $with_accounts Include folders assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function sharedFolders($with_accounts = true)
{
return $this->entitleables(\App\SharedFolder::class, $with_accounts);
}
public function senderPolicyFrameworkWhitelist($clientName)
{
$setting = $this->getSetting('spf_whitelist');
if (!$setting) {
return false;
}
$whitelist = json_decode($setting);
$matchFound = false;
foreach ($whitelist as $entry) {
if (substr($entry, 0, 1) == '/') {
$match = preg_match($entry, $clientName);
if ($match) {
$matchFound = true;
}
continue;
}
if (substr($entry, 0, 1) == '.') {
if (substr($clientName, (-1 * strlen($entry))) == $entry) {
$matchFound = true;
}
continue;
}
if ($entry == $clientName) {
$matchFound = true;
continue;
}
}
return $matchFound;
}
- /**
- * Suspend this user.
- *
- * @return void
- */
- public function suspend(): void
- {
- if ($this->isSuspended()) {
- return;
- }
-
- $this->status |= User::STATUS_SUSPENDED;
- $this->save();
- }
-
/**
* Un-degrade this user.
*
* @return void
*/
public function undegrade(): void
{
if (!$this->isDegraded()) {
return;
}
$this->status ^= User::STATUS_DEGRADED;
$this->save();
}
- /**
- * Unsuspend this user.
- *
- * @return void
- */
- public function unsuspend(): void
- {
- if (!$this->isSuspended()) {
- return;
- }
-
- $this->status ^= User::STATUS_SUSPENDED;
- $this->save();
- }
-
/**
* Return users controlled by the current user.
*
* @param bool $with_accounts Include users assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function users($with_accounts = true)
{
return $this->entitleables(User::class, $with_accounts);
}
/**
* Verification codes for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function verificationcodes()
{
return $this->hasMany('App\VerificationCode', 'user_id', 'id');
}
/**
* Wallets this user owns.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function wallets()
{
return $this->hasMany('App\Wallet');
}
/**
* User password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordAttribute($password)
{
if (!empty($password)) {
$this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]);
$this->attributes['password_ldap'] = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password))
);
}
}
/**
* User LDAP password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordLdapAttribute($password)
{
$this->setPasswordAttribute($password);
}
/**
* User status mutator
*
* @throws \Exception
*/
public function setStatusAttribute($status)
{
$new_status = 0;
$allowed_values = [
self::STATUS_NEW,
self::STATUS_ACTIVE,
self::STATUS_SUSPENDED,
self::STATUS_DELETED,
self::STATUS_LDAP_READY,
self::STATUS_IMAP_READY,
self::STATUS_DEGRADED,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid user status: {$status}");
}
$this->attributes['status'] = $new_status;
}
/**
* Validate the user credentials
*
* @param string $username The username.
* @param string $password The password in plain text.
* @param bool $updatePassword Store the password if currently empty
*
* @return bool true on success
*/
public function validateCredentials(string $username, string $password, bool $updatePassword = true): bool
{
$authenticated = false;
if ($this->email === \strtolower($username)) {
if (!empty($this->password)) {
if (Hash::check($password, $this->password)) {
$authenticated = true;
}
} elseif (!empty($this->password_ldap)) {
if (substr($this->password_ldap, 0, 6) == "{SSHA}") {
$salt = substr(base64_decode(substr($this->password_ldap, 6)), 20);
$hash = '{SSHA}' . base64_encode(
sha1($password . $salt, true) . $salt
);
if ($hash == $this->password_ldap) {
$authenticated = true;
}
} elseif (substr($this->password_ldap, 0, 9) == "{SSHA512}") {
$salt = substr(base64_decode(substr($this->password_ldap, 9)), 64);
$hash = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password . $salt)) . $salt
);
if ($hash == $this->password_ldap) {
$authenticated = true;
}
}
} else {
\Log::error("Incomplete credentials for {$this->email}");
}
}
if ($authenticated) {
\Log::info("Successful authentication for {$this->email}");
// TODO: update last login time
if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) {
$this->password = $password;
$this->save();
}
} else {
// TODO: Try actual LDAP?
\Log::info("Authentication failed for {$this->email}");
}
return $authenticated;
}
/**
* Retrieve and authenticate a user
*
* @param string $username The username.
* @param string $password The password in plain text.
* @param string $secondFactor The second factor (secondfactor from current request is used as fallback).
*
* @return array ['user', 'reason', 'errorMessage']
*/
public static function findAndAuthenticate($username, $password, $secondFactor = null): ?array
{
$user = User::where('email', $username)->first();
if (!$user) {
return ['reason' => 'notfound', 'errorMessage' => "User not found."];
}
if (!$user->validateCredentials($username, $password)) {
return ['reason' => 'credentials', 'errorMessage' => "Invalid password."];
}
if (!$secondFactor) {
// Check the request if there is a second factor provided
// as fallback.
$secondFactor = request()->secondfactor;
}
try {
(new \App\Auth\SecondFactor($user))->validate($secondFactor);
} catch (\Exception $e) {
return ['reason' => 'secondfactor', 'errorMessage' => $e->getMessage()];
}
return ['user' => $user];
}
/**
* Hook for passport
*
* @throws \Throwable
*
* @return \App\User User model object if found
*/
public function findAndValidateForPassport($username, $password): User
{
$result = self::findAndAuthenticate($username, $password);
if (isset($result['reason'])) {
if ($result['reason'] == 'secondfactor') {
// This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'}
throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401);
}
throw OAuthServerException::invalidCredentials();
}
return $result['user'];
}
}
diff --git a/src/database/migrations/2022_01_03_120000_signup_codes_indices.php b/src/database/migrations/2022_01_03_120000_signup_codes_indices.php
index 0e45729f..895d8413 100644
--- a/src/database/migrations/2022_01_03_120000_signup_codes_indices.php
+++ b/src/database/migrations/2022_01_03_120000_signup_codes_indices.php
@@ -1,43 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
// phpcs:ignore
class SignupCodesIndices extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table(
'signup_codes',
function (Blueprint $table) {
$table->index('email');
$table->index('ip_address');
$table->index('expires_at');
}
);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table(
'signup_codes',
function (Blueprint $table) {
- $table->dropIndex('email');
- $table->dropIndex('ip_address');
- $table->dropIndex('expires_at');
+ $table->dropIndex('signup_codes_email_index');
+ $table->dropIndex('signup_codes_ip_address_index');
+ $table->dropIndex('signup_codes_expires_at_index');
}
);
}
}
diff --git a/src/phpstan.neon b/src/phpstan.neon
index 539c683d..3e78fa38 100644
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -1,16 +1,17 @@
includes:
- ./vendor/nunomaduro/larastan/extension.neon
parameters:
ignoreErrors:
- '#Access to an undefined property [a-zA-Z\\]+::\$pivot#'
- '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenantContext\(\)#'
- '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withObjectTenantContext\(\)#'
- '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withSubjectTenantContext\(\)#'
- '#Call to an undefined method Tests\\Browser::#'
+ - '#Access to undefined constant static\(App\\[a-zA-Z]+\)::STATUS_[A-Z_]+#'
level: 4
parallel:
processTimeout: 300.0
paths:
- app/
- config/
- tests/

File Metadata

Mime Type
text/x-diff
Expires
Thu, Apr 9, 2:58 PM (1 d, 5 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
540645
Default Alt Text
(150 KB)

Event Timeline