Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117738811
D937.1775156500.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
21 KB
Referenced Files
None
Subscribers
None
D937.1775156500.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D937: Domain verification/information UI and API
Attached
Detach File
Event Timeline