Page MenuHomePhorge

D937.1775156500.diff
No OneTemporary

Authored By
Unknown
Size
21 KB
Referenced Files
None
Subscribers
None

D937.1775156500.diff

diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -66,6 +66,11 @@
MAIL_REPLYTO_ADDRESS=null
MAIL_REPLYTO_NAME=null
+DNS_TTL=3600
+DNS_SPF="v=spf1 mx -all"
+DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com."
+DNS_COPY_FROM=null
+
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
diff --git a/src/app/Domain.php b/src/app/Domain.php
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -28,6 +28,10 @@
// zone registered externally
public const TYPE_EXTERNAL = 1 << 2;
+ public const HASH_CODE = 1;
+ public const HASH_TEXT = 2;
+ public const HASH_CNAME = 3;
+
public $incrementing = false;
protected $keyType = 'bigint';
@@ -201,14 +205,14 @@
return true;
}
- $hash = $this->hash();
+ $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 $domain");
+ throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
foreach ($records as $record) {
@@ -223,7 +227,7 @@
// 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(true) . '.' . $this->namespace;
+ $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace;
$records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME);
if ($records === false) {
@@ -249,15 +253,21 @@
/**
* Generate a verification hash for this domain
*
- * @param bool $short Return short version (with kolab-verify= prefix)
+ * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT
*
* @return string Verification hash
*/
- public function hash($short = false): string
+ public function hash($mod = null): string
{
+ $cname = 'kolab-verify';
+
+ if ($mod === self::HASH_CNAME) {
+ return $cname;
+ }
+
$hash = \md5('hkccp-verify-' . $this->namespace . $this->id);
- return $short ? $hash : "kolab-verify=$hash";
+ return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash;
}
/**
diff --git a/src/app/Http/Controllers/API/DomainsController.php b/src/app/Http/Controllers/API/DomainsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/DomainsController.php
@@ -0,0 +1,213 @@
+<?php
+
+namespace App\Http\Controllers\API;
+
+use App\Domain;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+
+class DomainsController extends Controller
+{
+ /**
+ * Display a listing of the resource.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function index()
+ {
+ //
+ }
+
+ /**
+ * Show the form for creating a new resource.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function create()
+ {
+ //
+ }
+
+ /**
+ * Confirm ownership of the specified domain (via DNS check).
+ *
+ * @param int $id Domain identifier
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function confirm($id)
+ {
+ $domain = Domain::findOrFail($id);
+
+ // Only owner (or admin) has access to the domain
+ if (!self::hasAccess($domain)) {
+ return abort(403);
+ }
+
+ if (!$domain->confirm()) {
+ return response()->json(['status' => 'error']);
+ }
+
+ return response()->json(['status' => 'success']);
+ }
+
+ /**
+ * Remove the specified resource from storage.
+ *
+ * @param int $id
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function destroy($id)
+ {
+ //
+ }
+
+ /**
+ * Show the form for editing the specified resource.
+ *
+ * @param int $id
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function edit($id)
+ {
+ //
+ }
+
+ /**
+ * Store a newly created resource in storage.
+ *
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function store(Request $request)
+ {
+ //
+ }
+
+ /**
+ * Get the information about the specified domain.
+ *
+ * @param int $id Domain identifier
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function show($id)
+ {
+ $domain = Domain::findOrFail($id);
+
+ // Only owner (or admin) has access to the domain
+ if (!self::hasAccess($domain)) {
+ return abort(403);
+ }
+
+ $response = $domain->toArray();
+
+ // 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['config'] = self::getMXConfig($domain->namespace);
+
+ $response['confirmed'] = $domain->isConfirmed();
+
+ return response()->json($response);
+ }
+
+ /**
+ * Update the specified resource in storage.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @param int $id
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function update(Request $request, $id)
+ {
+ //
+ }
+
+ /**
+ * 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}\"",
+ ];
+ }
+
+ /**
+ * Check if the current user has access to the domain
+ *
+ * @param \App\Domain Domain
+ *
+ * @return bool True if current user has access, False otherwise
+ */
+ protected static function hasAccess(Domain $domain): bool
+ {
+ $user = Auth::guard()->user();
+ $entitlement = $domain->entitlement()->first();
+
+ // TODO: Admins
+
+ return $entitlement && $entitlement->owner_id == $user->id;
+ }
+}
diff --git a/src/config/dns.php b/src/config/dns.php
new file mode 100644
--- /dev/null
+++ b/src/config/dns.php
@@ -0,0 +1,8 @@
+<?php
+
+return [
+ 'ttl' => env('DNS_TTL', 3600),
+ 'spf' => env('DNS_SPF', null),
+ 'static' => env('DNS_STATIC', null),
+ 'copyfrom' => env('DNS_COPY_FROM', null),
+];
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
@@ -85,11 +85,14 @@
$(form).find('.invalid-feedback').remove()
},
// Set user state to "logged in"
- loginUser(token) {
+ loginUser(token, dashboard) {
store.commit('loginUser')
localStorage.setItem('token', token)
axios.defaults.headers.common.Authorization = 'Bearer ' + token
- router.push({ name: 'dashboard' })
+
+ if (dashboard !== false) {
+ router.push({ name: 'dashboard' })
+ }
},
// Set user state to "not logged in"
logoutUser() {
diff --git a/src/resources/sass/_variables.scss b/src/resources/sass/_variables.scss
--- a/src/resources/sass/_variables.scss
+++ b/src/resources/sass/_variables.scss
@@ -17,3 +17,6 @@
$green: #38c172;
$teal: #4dc0b5;
$cyan: #6cb2eb;
+
+// App colors
+$menu-bg-color: #f6f5f3;
diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss
--- a/src/resources/sass/app.scss
+++ b/src/resources/sass/app.scss
@@ -20,6 +20,10 @@
margin-top: 120px;
}
+#app {
+ margin-bottom: 2rem;
+}
+
#error-page {
align-items: center;
display: flex;
@@ -57,3 +61,9 @@
color: #b2aa99;
}
}
+
+pre {
+ margin: 1rem 0;
+ padding: 1rem;
+ background-color: $menu-bg-color;
+}
diff --git a/src/resources/vue/components/App.vue b/src/resources/vue/components/App.vue
--- a/src/resources/vue/components/App.vue
+++ b/src/resources/vue/components/App.vue
@@ -21,7 +21,7 @@
this.$store.state.authInfo = response.data
this.isLoading = false
this.$root.stopLoading()
- this.$root.loginUser(token)
+ this.$root.loginUser(token, false)
})
.catch(error => {
this.isLoading = false
diff --git a/src/resources/vue/components/Dashboard.vue b/src/resources/vue/components/Dashboard.vue
--- a/src/resources/vue/components/Dashboard.vue
+++ b/src/resources/vue/components/Dashboard.vue
@@ -4,7 +4,7 @@
<div class="card-body">
<div class="card-title">Dashboard</div>
<div class="card-text">
- <p>{{ data }}</p>
+ <pre>{{ data }}</pre>
</div>
</div>
</div>
diff --git a/src/resources/vue/components/Domain.vue b/src/resources/vue/components/Domain.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/components/Domain.vue
@@ -0,0 +1,84 @@
+<template>
+ <div class="container">
+ <div v-if="domain && !is_confirmed" class="card" id="domain-verify">
+ <div class="card-body">
+ <div class="card-title">Domain verification</div>
+ <div class="card-text">
+ <p>In order to confirm that you're the actual holder of the domain,
+ we need to run a verification process before finally activating it for email delivery.</p>
+ <p>The domain <b>must have one of the following entries</b> in DNS:
+ <ul>
+ <li>TXT entry with value: <code>{{ domain.hash_text }}</code></li>
+ <li>or CNAME entry: <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
+ </ul>
+ When this is done press the button below to start the verification.</p>
+ <p>Here's a sample zone file for your domain:
+ <pre>{{ domain.dns.join("\n") }}</pre>
+ </p>
+ <button class="btn btn-primary" type="button" @click="confirm">Verify</button>
+ </div>
+ </div>
+ </div>
+ <div v-if="domain && is_confirmed" class="card" id="domain-config">
+ <div class="card-body">
+ <div class="card-title">Domain configuration</div>
+ <div class="card-text">
+ <p>In order to let {{ app_name }} receive email traffic for your domain you need to adjust
+ the DNS settings, more precisely the MX entries, accordingly.</p>
+ <p>Edit your domain's zone file and replace existing MX
+ entries with the following values:
+ <pre>{{ domain.config.join("\n") }}</pre>
+ </p>
+ <p>If you don't know how to set DNS entries for your domain,
+ please contact the registration service where you registered
+ the domain or your web hosting provider.
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import store from '../js/store'
+
+ export default {
+ data() {
+ return {
+ domain_id: null,
+ domain: null,
+ is_confirmed: false,
+ app_name: window.config['app.name'],
+ now: null
+ }
+ },
+ created() {
+ if (this.domain_id = this.$route.params.domain) {
+ axios.get('/api/v4/domains/' + this.domain_id).then(response => {
+ this.is_confirmed = response.data.confirmed
+ this.domain = response.data
+ if (!this.is_confirmed) {
+ $('#domain-verify button').focus()
+ }
+ })
+ } else {
+ // TODO: Find a way to display error page without changing the URL
+ // Maybe https://github.com/raniesantos/vue-error-page
+ this.$router.push({name: '404'})
+ }
+ },
+ mounted() {
+ this.now = (new Date()).toISOString().replace(/T.*/, '').replace(/-/g, '') + '01'
+ },
+ methods: {
+ confirm() {
+ axios.get('/api/v4/domains/' + this.domain_id + '/confirm')
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.is_confirmed = true
+ }
+ })
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/js/routes.js b/src/resources/vue/js/routes.js
--- a/src/resources/vue/js/routes.js
+++ b/src/resources/vue/js/routes.js
@@ -4,6 +4,7 @@
Vue.use(VueRouter)
import DashboardComponent from '../components/Dashboard'
+import DomainComponent from '../components/Domain'
import Error404Component from '../components/404'
import LoginComponent from '../components/Login'
import LogoutComponent from '../components/Logout'
@@ -23,6 +24,12 @@
component: DashboardComponent,
meta: { requiresAuth: true }
},
+ {
+ path: '/domain/:domain',
+ name: 'domain',
+ component: DomainComponent,
+ meta: { requiresAuth: true }
+ },
{
path: '/login',
name: 'login',
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -41,6 +41,9 @@
'prefix' => 'v4'
],
function () {
+ Route::apiResource('domains', API\DomainsController::class);
+ Route::get('domains/{id}/confirm', 'API\DomainsController@confirm');
+
Route::apiResource('entitlements', API\EntitlementsController::class);
Route::apiResource('users', API\UsersController::class);
Route::apiResource('wallets', API\WalletsController::class);
diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/DomainsTest.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Http\Controllers\API\DomainsController;
+use App\Domain;
+use App\Entitlement;
+use App\Sku;
+use App\User;
+use App\Wallet;
+use Illuminate\Support\Str;
+use Tests\TestCase;
+
+class DomainsTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ User::where('email', 'test1@domainscontroller.com')->delete();
+ Domain::where('namespace', 'domainscontroller.com')->delete();
+ }
+
+ /**
+ * Test domain confirm request
+ */
+ public function testConfirm(): void
+ {
+ $sku_domain = Sku::where('title', 'domain')->first();
+ $user = $this->getTestUser('test1@domainscontroller.com');
+ $domain = $this->getTestDomain('domainscontroller.com', [
+ 'status' => Domain::STATUS_NEW | Domain::STATUS_VERIFIED,
+ 'type' => Domain::TYPE_EXTERNAL,
+ ]);
+
+ // No entitlement (user has no access to this domain), expect 403
+ $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm");
+ $response->assertStatus(403);
+
+ Entitlement::create([
+ 'owner_id' => $user->id,
+ 'wallet_id' => $user->wallets()->first()->id,
+ 'sku_id' => $sku_domain->id,
+ 'entitleable_id' => $domain->id,
+ 'entitleable_type' => Domain::class
+ ]);
+
+ $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals('error', $json['status']);
+
+ $domain->status |= Domain::STATUS_CONFIRMED;
+ $domain->save();
+
+ $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals('success', $json['status']);
+ }
+
+ /**
+ * Test fetching domain info
+ */
+ public function testShow(): void
+ {
+ $sku_domain = Sku::where('title', 'domain')->first();
+ $user = $this->getTestUser('test1@domainscontroller.com');
+ $domain = $this->getTestDomain('domainscontroller.com', [
+ 'status' => Domain::STATUS_NEW | Domain::STATUS_VERIFIED,
+ 'type' => Domain::TYPE_EXTERNAL,
+ ]);
+
+ // No entitlement (user has no access to this domain), expect 403
+ $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}");
+ $response->assertStatus(403);
+
+ Entitlement::create([
+ 'owner_id' => $user->id,
+ 'wallet_id' => $user->wallets()->first()->id,
+ 'sku_id' => $sku_domain->id,
+ 'entitleable_id' => $domain->id,
+ 'entitleable_type' => Domain::class
+ ]);
+
+ $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($domain->id, $json['id']);
+ $this->assertEquals($domain->namespace, $json['namespace']);
+ $this->assertEquals($domain->status, $json['status']);
+ $this->assertEquals($domain->type, $json['type']);
+ $this->assertTrue($json['confirmed'] === false);
+ $this->assertSame($domain->hash(Domain::HASH_TEXT), $json['hash_text']);
+ $this->assertSame($domain->hash(Domain::HASH_CNAME), $json['hash_cname']);
+ $this->assertSame($domain->hash(Domain::HASH_CODE), $json['hash_code']);
+ $this->assertCount(4, $json['config']);
+ $this->assertTrue(strpos(implode("\n", $json['config']), $domain->namespace) !== false);
+ $this->assertCount(8, $json['dns']);
+ $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->namespace) !== false);
+ $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->hash()) !== false);
+ }
+}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -74,7 +74,7 @@
$this->markTestIncomplete();
}
- public function testShow(): void
+ public function testStatusInfo(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
diff --git a/src/tests/Unit/DomainTest.php b/src/tests/Unit/DomainTest.php
--- a/src/tests/Unit/DomainTest.php
+++ b/src/tests/Unit/DomainTest.php
@@ -96,14 +96,22 @@
'status' => Domain::STATUS_NEW,
]);
- $hash1 = $domain->hash(true);
+ $hash_code = $domain->hash();
- $this->assertRegExp('/^[a-f0-9]{32}$/', $hash1);
+ $this->assertRegExp('/^[a-f0-9]{32}$/', $hash_code);
- $hash2 = $domain->hash();
+ $hash_text = $domain->hash(Domain::HASH_TEXT);
- $this->assertRegExp('/^kolab-verify=[a-f0-9]{32}$/', $hash2);
+ $this->assertRegExp('/^kolab-verify=[a-f0-9]{32}$/', $hash_text);
- $this->assertSame($hash1, str_replace('kolab-verify=', '', $hash2));
+ $this->assertSame($hash_code, str_replace('kolab-verify=', '', $hash_text));
+
+ $hash_cname = $domain->hash(Domain::HASH_CNAME);
+
+ $this->assertSame('kolab-verify', $hash_cname);
+
+ $hash_code2 = $domain->hash(Domain::HASH_CODE);
+
+ $this->assertSame($hash_code, $hash_code2);
}
}

File Metadata

Mime Type
text/plain
Expires
Thu, Apr 2, 7:01 PM (1 d, 18 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18820391
Default Alt Text
D937.1775156500.diff (21 KB)

Event Timeline