diff --git a/src/app/Domain.php b/src/app/Domain.php --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -3,6 +3,8 @@ namespace App; use App\Wallet; +use App\Traits\DomainConfigTrait; +use App\Traits\SettingsTrait; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -13,6 +15,8 @@ */ class Domain extends Model { + use DomainConfigTrait; + use SettingsTrait; use SoftDeletes; // we've simply never heard of this domain @@ -361,6 +365,16 @@ return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } + /** + * Any (additional) properties of this domain. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function settings() + { + return $this->hasMany('App\DomainSetting', 'domain_id'); + } + /** * Suspend this domain. * diff --git a/src/app/DomainSetting.php b/src/app/DomainSetting.php new file mode 100644 --- /dev/null +++ b/src/app/DomainSetting.php @@ -0,0 +1,34 @@ +belongsTo( + '\App\Domain', + 'domain_id', /* local */ + 'id' /* remote */ + ); + } +} diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -96,6 +96,39 @@ return $this->errorResponse(404); } + /** + * Set the domain configuration. + * + * @param int $id Domain identifier + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function setConfig($id) + { + $domain = Domain::find($id); + + if (empty($domain)) { + return $this->errorResponse(404); + } + + // Only owner (or admin) has access to the domain + if (!Auth::guard()->user()->canRead($domain)) { + return $this->errorResponse(403); + } + + $errors = $domain->setConfig(request()->input()); + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.domain-setconfig-success'), + ]); + } + + /** * Store a newly created resource in storage. * @@ -133,7 +166,10 @@ // Add DNS/MX configuration for the domain $response['dns'] = self::getDNSConfig($domain); - $response['config'] = self::getMXConfig($domain->namespace); + $response['mx'] = self::getMXConfig($domain->namespace); + + // Domain configuration, e.g. spf whitelist + $response['config'] = $domain->getConfig(); // Status info $response['statusInfo'] = self::statusInfo($domain); diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -77,6 +77,37 @@ return response()->json($result); } + /** + * Set user config. + * + * @param int $id The user + * + * @return \Illuminate\Http\JsonResponse + */ + public function setConfig($id) + { + $user = User::find($id); + + if (empty($user)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($user)) { + return $this->errorResponse(403); + } + + $errors = $user->setConfig(request()->input()); + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + return response()->json([ + 'status' => 'success', + 'message' => __('app.user-setconfig-success'), + ]); + } + /** * Display information on the user account specified by $id. * @@ -109,6 +140,8 @@ ]; } + $response['config'] = $user->getConfig(); + return response()->json($response); } diff --git a/src/app/Traits/DomainConfigTrait.php b/src/app/Traits/DomainConfigTrait.php new file mode 100644 --- /dev/null +++ b/src/app/Traits/DomainConfigTrait.php @@ -0,0 +1,60 @@ +getSetting('spf_whitelist'); + + $config['spf_whitelist'] = $spf ? json_decode($spf, true) : []; + + return $config; + } + + /** + * A helper to update domain configuration. + * + * @param array $config An array of configuration options + * + * @return array A list of input validation errors + */ + public function setConfig(array $config): array + { + $errors = []; + + foreach ($config as $key => $value) { + // validate and save SPF whitelist entries + if ($key === 'spf_whitelist') { + if (!is_array($value)) { + $value = (array) $value; + } + + foreach ($value as $i => $v) { + if (empty($v)) { + unset($value[$i]); + continue; + } + + if ($v[0] !== '.' || !filter_var(substr($v, 1), FILTER_VALIDATE_DOMAIN)) { + $errors[$key][$i] = \trans('validation.spf-entry-invalid'); + } + } + + if (empty($errors[$key])) { + $this->setSetting($key, json_encode($value)); + } + } else { + $errors[$key] = \trans('validation.invalid-config-parameter'); + } + } + + return $errors; + } +} diff --git a/src/app/Traits/UserConfigTrait.php b/src/app/Traits/UserConfigTrait.php new file mode 100644 --- /dev/null +++ b/src/app/Traits/UserConfigTrait.php @@ -0,0 +1,42 @@ +getSetting('greylisting') !== 'false'; + + return $config; + } + + /** + * A helper to update user configuration. + * + * @param array $config An array of configuration options + * + * @return array A list of input validation error messages + */ + public function setConfig(array $config): array + { + $errors = []; + + foreach ($config as $key => $value) { + if ($key == 'greylisting') { + $this->setSetting('greylisting', $value ? 'true' : 'false'); + } else { + $errors[$key] = \trans('validation.invalid-config-parameter'); + } + } + + return $errors; + } +} diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -5,6 +5,7 @@ use App\Entitlement; use App\UserAlias; use App\Sku; +use App\Traits\UserConfigTrait; use App\Traits\UserAliasesTrait; use App\Traits\SettingsTrait; use App\Wallet; @@ -26,6 +27,7 @@ { use Notifiable; use NullableFields; + use UserConfigTrait; use UserAliasesTrait; use SettingsTrait; use SoftDeletes; diff --git a/src/database/migrations/2020_11_20_120000_add_domains_primary_key.php b/src/database/migrations/2020_11_20_120000_add_domains_primary_key.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2020_11_20_120000_add_domains_primary_key.php @@ -0,0 +1,39 @@ +primary('id'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'domains', + function (Blueprint $table) { + $table->dropPrimary('id'); + } + ); + } +} diff --git a/src/database/migrations/2020_11_20_130000_create_domain_settings_table.php b/src/database/migrations/2020_11_20_130000_create_domain_settings_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2020_11_20_130000_create_domain_settings_table.php @@ -0,0 +1,44 @@ +bigIncrements('id'); + $table->bigInteger('domain_id'); + $table->string('key'); + $table->text('value'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent(); + + $table->foreign('domain_id')->references('id')->on('domains') + ->onDelete('cascade')->onUpdate('cascade'); + + $table->unique(['domain_id', 'key']); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('domain_settings'); + } +} diff --git a/src/database/migrations/2020_11_20_140000_extend_settings_value_column.php b/src/database/migrations/2020_11_20_140000_extend_settings_value_column.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2020_11_20_140000_extend_settings_value_column.php @@ -0,0 +1,41 @@ +text('value')->change(); + } + ); + + Schema::table( + 'wallet_settings', + function (Blueprint $table) { + $table->text('value')->change(); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // do nothing + } +} diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -130,6 +130,10 @@ $('#app > .app-loader').addClass('fadeOut') this.isLoading = false }, + tab(e) { + e.preventDefault() + $(e.target).tab('show') + }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -34,6 +34,7 @@ 'domain-verify-error' => 'Domain ownership verification failed.', 'domain-suspend-success' => 'Domain suspended successfully.', 'domain-unsuspend-success' => 'Domain unsuspended successfully.', + 'domain-setconfig-success' => 'Domain settings updated successfully.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', @@ -41,6 +42,7 @@ 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', + 'user-setconfig-success' => 'User settings updated successfully.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxusers' => ':x user accounts have been found.', diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -134,6 +134,8 @@ 'entryexists' => 'The specified :attribute is not available.', 'minamount' => 'Minimum amount for a single payment is :amount.', 'minamountdebt' => 'The specified amount does not cover the balance on the account.', + 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.', + 'invalid-config-parameter' => 'The requested configuration parameter is not supported.', /* |-------------------------------------------------------------------------- diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -305,13 +305,8 @@ padding: 0.5rem 0; } - .form-group { - margin-bottom: 0.5rem; - } - .nav-tabs { flex-wrap: nowrap; - overflow-x: auto; .nav-link { white-space: nowrap; @@ -319,27 +314,6 @@ } } - .tab-content { - margin-top: 0.5rem; - } - - .col-form-label { - color: #666; - font-size: 95%; - } - - .form-group.plaintext .col-form-label { - padding-bottom: 0; - } - - form.read-only.short label { - width: 35%; - - & + * { - width: 65%; - } - } - #app > div.container { margin-bottom: 1rem; margin-top: 1rem; @@ -420,3 +394,9 @@ } } } + +@include media-breakpoint-down(sm) { + .tab-pane > .card-body { + padding: 0.5rem; + } +} diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -45,3 +45,48 @@ margin-bottom: 0; } } + +// Various improvements for mobile +@include media-breakpoint-down(sm) { + .form-group { + margin-bottom: 0.5rem; + } + + .form-group.plaintext .col-form-label { + padding-bottom: 0; + } + + form.read-only.short label { + width: 35%; + + & + * { + width: 65%; + } + } +} + +@include media-breakpoint-down(xs) { + .col-form-label { + color: #666; + font-size: 95%; + } + + .form-group.checkbox { + position: relative; + + & > div { + position: initial; + padding-top: 0 !important; + + input { + position: absolute; + top: 0.5rem; + right: 1rem; + } + } + + label { + padding-right: 2.5rem; + } + } +} diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue --- a/src/resources/vue/Admin/Domain.vue +++ b/src/resources/vue/Admin/Domain.vue @@ -31,10 +31,15 @@
@@ -43,7 +48,23 @@

Domain DNS verification sample:

{{ domain.dns.join("\n") }}

Domain DNS configuration sample:

-

{{ domain.config.join("\n") }}

+

{{ domain.mx.join("\n") }}

+
+
+ +
+
+
+
+
+ +
+ + {{ domain.config && domain.config.spf_whitelist.length ? domain.config.spf_whitelist.join(', ') : 'none' }} + +
+
+
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -108,6 +108,11 @@ Users ({{ users.length }}) +
@@ -259,6 +264,23 @@
+
+
+
+
+
+ +
+ + enabled + disabled + +
+
+
+
+
+