Page MenuHomePhorge

D5568.1775292126.diff
No OneTemporary

Authored By
Unknown
Size
13 KB
Referenced Files
None
Subscribers
None

D5568.1775292126.diff

diff --git a/config.demo/src/database/seeds/SkuSeeder.php b/config.demo/src/database/seeds/SkuSeeder.php
--- a/config.demo/src/database/seeds/SkuSeeder.php
+++ b/config.demo/src/database/seeds/SkuSeeder.php
@@ -160,6 +160,16 @@
'handler_class' => 'App\Handlers\Room',
'active' => true,
],
+ [
+ 'title' => 'device',
+ 'name' => 'User device',
+ 'description' => 'Just a device',
+ 'cost' => 100,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Device',
+ 'active' => true,
+ ],
];
foreach ($skus as $sku) {
diff --git a/src/app/Device.php b/src/app/Device.php
new file mode 100644
--- /dev/null
+++ b/src/app/Device.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace App;
+
+use App\Traits\BelongsToTenantTrait;
+use App\Traits\EntitleableTrait;
+use App\Traits\UuidIntKeyTrait;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * The eloquent definition of a Device
+ *
+ * @property string $hash
+ * @property int $id
+ * @property int $tenant_id
+ */
+class Device extends Model
+{
+ use BelongsToTenantTrait;
+ use EntitleableTrait;
+ use SoftDeletes;
+ use UuidIntKeyTrait;
+
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ 'deleted_at' => 'datetime:Y-m-d H:i:s',
+ 'updated_at' => 'datetime:Y-m-d H:i:s',
+ ];
+
+ /** @var list<string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'hash',
+ ];
+
+ /**
+ * Assign device to (another) real user account
+ */
+ public function bindTo(User $user)
+ {
+ $wallet = $user->wallets()->first();
+
+ // TODO: What if the device is already used by another (real) user?
+
+ $this->entitlements()->update(['wallet_id' => $wallet->id]);
+
+ // TODO: Update created_at/updated_at accordingly
+ // TODO: Delete the dummy user record?
+ }
+
+ /**
+ * Get device information
+ */
+ public function info(): array
+ {
+ $result = [
+ 'created_at' => (string) $this->created_at,
+ ];
+
+ // TODO: Include other information about the wallet/payments state
+
+ return $result;
+ }
+
+ /**
+ * Register a device
+ */
+ public static function register(string $hash): self
+ {
+ DB::beginTransaction();
+
+ // Create a device record
+ $device = self::create(['hash' => $hash]);
+
+ // Create a special account (and wallet)
+ $user = User::create([
+ 'email' => $device->id . '@' . \config('app.domain'),
+ 'password' => '',
+ 'role' => User::ROLE_DEVICE,
+ ]);
+
+ // Assign an entitlement
+ $device->assignToWallet($user->wallets()->first(), 'device');
+
+ DB::commit();
+
+ return $device;
+ }
+
+ /**
+ * Returns entitleable object title (e.g. email or domain name).
+ *
+ * @return string|null An object title/name
+ */
+ public function toString(): ?string
+ {
+ // TODO: Something more human-friendly?
+ return $this->id . '@' . \config('app.domain');
+ }
+}
diff --git a/src/app/Handlers/Device.php b/src/app/Handlers/Device.php
new file mode 100644
--- /dev/null
+++ b/src/app/Handlers/Device.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Handlers;
+
+class Device extends Base
+{
+ /**
+ * The entitleable class for this handler.
+ */
+ public static function entitleableClass(): string
+ {
+ return \App\Device::class;
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/DeviceController.php b/src/app/Http/Controllers/API/V4/DeviceController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/DeviceController.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Device;
+use App\Http\Controllers\Controller;
+use App\SignupToken;
+use Illuminate\Http\JsonResponse;
+
+class DeviceController extends Controller
+{
+ /**
+ * User claims the device ownership.
+ *
+ * @param string $hash Device secret identifier
+ *
+ * @return JsonResponse The response
+ */
+ public function claim(string $hash)
+ {
+ if (strlen($hash) != 64) {
+ return $this->errorResponse(404);
+ }
+
+ $device = Device::where('hash', $hash)->first();
+
+ if (empty($device)) {
+ return $this->errorResponse(404);
+ }
+
+ $device->bindTo($this->guard()->user());
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => self::trans('app.device-claim-success'),
+ ]);
+ }
+
+ /**
+ * Get the device information.
+ *
+ * @param string $hash Device secret identifier
+ *
+ * @return JsonResponse The response
+ */
+ public function info(string $hash)
+ {
+ if (strlen($hash) != 64) {
+ return $this->errorResponse(404);
+ }
+
+ $device = Device::where('hash', $hash)->first();
+
+ // Register a device
+ if (!$device) {
+ // Only possible if a signup token exists?
+ if (!SignupToken::where('id', $hash)->exists()) {
+ return $this->errorResponse(404);
+ }
+
+ $device = Device::register($hash);
+ }
+
+ $response = $device->info();
+
+ return response()->json($response);
+ }
+}
diff --git a/src/app/Jobs/User/CreateJob.php b/src/app/Jobs/User/CreateJob.php
--- a/src/app/Jobs/User/CreateJob.php
+++ b/src/app/Jobs/User/CreateJob.php
@@ -40,7 +40,7 @@
return;
}
- if ($user->role == User::ROLE_SERVICE) {
+ if ($user->role == User::ROLE_SERVICE || $user->role == User::ROLE_DEVICE) {
return;
}
diff --git a/src/app/Jobs/User/DeleteJob.php b/src/app/Jobs/User/DeleteJob.php
--- a/src/app/Jobs/User/DeleteJob.php
+++ b/src/app/Jobs/User/DeleteJob.php
@@ -23,7 +23,7 @@
return;
}
- if ($user->role == User::ROLE_SERVICE) {
+ if ($user->role == User::ROLE_SERVICE || $user->role == User::ROLE_DEVICE) {
return;
}
diff --git a/src/app/Jobs/User/ResyncJob.php b/src/app/Jobs/User/ResyncJob.php
--- a/src/app/Jobs/User/ResyncJob.php
+++ b/src/app/Jobs/User/ResyncJob.php
@@ -24,7 +24,7 @@
return;
}
- if ($user->role == User::ROLE_SERVICE) {
+ if ($user->role == User::ROLE_SERVICE || $user->role == User::ROLE_DEVICE) {
return;
}
diff --git a/src/app/Jobs/User/UpdateJob.php b/src/app/Jobs/User/UpdateJob.php
--- a/src/app/Jobs/User/UpdateJob.php
+++ b/src/app/Jobs/User/UpdateJob.php
@@ -29,7 +29,7 @@
return;
}
- if ($user->role == User::ROLE_SERVICE) {
+ if ($user->role == User::ROLE_SERVICE || $user->role == User::ROLE_DEVICE) {
// Admins/resellers don't reside in LDAP (for now)
return;
}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -72,6 +72,7 @@
public const ROLE_ADMIN = 'admin';
public const ROLE_RESELLER = 'reseller';
public const ROLE_SERVICE = 'service';
+ public const ROLE_DEVICE = 'device';
/** @var int The allowed states for this object used in StatusPropertyTrait */
private int $allowed_states = self::STATUS_NEW
diff --git a/src/database/migrations/2025_08_27_100000_create_devices_table.php b/src/database/migrations/2025_08_27_100000_create_devices_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2025_08_27_100000_create_devices_table.php
@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+ /**
+ * Run the migrations.
+ */
+ public function up()
+ {
+ Schema::create(
+ 'devices',
+ static function (Blueprint $table) {
+ $table->bigInteger('id')->unsigned()->primary();
+ $table->string('hash', 64)->unique();
+ $table->bigInteger('tenant_id')->unsigned()->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->foreign('tenant_id')->references('id')->on('tenants')
+ ->onDelete('set null')->onUpdate('cascade');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down()
+ {
+ Schema::dropIfExists('devices');
+ }
+};
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
@@ -25,6 +25,8 @@
'companion-create-success' => 'Companion app has been created.',
'companion-delete-success' => 'Companion app has been removed.',
+ 'device-claim-success' => 'The device has been claimed successfully.',
+
'event-suspended' => 'Suspended',
'event-unsuspended' => 'Unsuspended',
'event-comment' => 'Commented',
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -199,6 +199,10 @@
Route::get('config/webmail', [API\V4\ConfigController::class, 'webmail']);
+ Route::post('device/{hash}/claim', [API\V4\DeviceController::class, 'claim']);
+ Route::get('device/{hash}', [API\V4\DeviceController::class, 'info'])
+ ->withoutMiddleware(['auth:api', 'scope:api']);
+
Route::apiResource('domains', API\V4\DomainsController::class);
Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']);
Route::get('domains/{id}/skus', [API\V4\DomainsController::class, 'skus']);
diff --git a/src/tests/Feature/Controller/DeviceTest.php b/src/tests/Feature/Controller/DeviceTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/DeviceTest.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Device;
+use App\Plan;
+use App\SignupToken;
+use App\Sku;
+use App\User;
+use Tests\TestCase;
+
+class DeviceTest extends TestCase
+{
+ protected $hash;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->hash = str_repeat('1', 64);
+
+ SignupToken::query()->delete();
+ Device::query()->forceDelete();
+ User::where('role', User::ROLE_DEVICE)->forceDelete();
+ $this->deleteTestUser('jane@kolabnow.com');
+ }
+
+ protected function tearDown(): void
+ {
+ SignupToken::query()->delete();
+ Device::query()->forceDelete();
+ User::where('role', User::ROLE_DEVICE)->forceDelete();
+ $this->deleteTestUser('jane@kolabnow.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test device registration and info (GET /api/v4/device/<hash>)
+ */
+ public function testInfo(): void
+ {
+ // Unknown hash (invalid hash)
+ $response = $this->get('api/v4/device/unknown');
+ $response->assertStatus(404);
+
+ // Unknown hash (valid hash)
+ $response = $this->get('api/v4/device/' . $this->hash);
+ $response->assertStatus(404);
+
+ $sku = Sku::withEnvTenantContext()->where('title', 'device')->first();
+ $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
+ $plan->signupTokens()->create(['id' => $this->hash]);
+
+ // First registration
+ $response = $this->get('api/v4/device/' . $this->hash);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertStringContainsString(date('Y-m-d'), $json['created_at']);
+
+ $device = Device::where('hash', $this->hash)->first();
+ $this->assertTrue(!empty($device));
+ $entitlements = $device->wallet()->entitlements()->get();
+ $this->assertCount(1, $entitlements);
+ $this->assertSame($sku->id, $entitlements[0]->sku_id);
+
+ // Getting info of a registered device
+ $response = $this->get('api/v4/device/' . $this->hash);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertStringContainsString(date('Y-m-d'), $json['created_at']);
+ }
+
+ /**
+ * Test claiming a device (POST /api/v4/device/<hash>/claim)
+ */
+ public function testClaim(): void
+ {
+ // Unauthenticated
+ $response = $this->post('api/v4/device/unknown/claim');
+ $response->assertStatus(401);
+
+ $user = $this->getTestUser('jane@kolabnow.com');
+
+ // Unknown device hash
+ $response = $this->actingAs($user)->post('api/v4/device/' . $this->hash . '/claim', []);
+ $response->assertStatus(404);
+
+ $device = Device::register($this->hash);
+
+ // Claim an existing device
+ $response = $this->actingAs($user)->post('api/v4/device/' . $this->hash . '/claim', []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The device has been claimed successfully.', $json['message']);
+ $this->assertCount(1, $device->entitlements);
+ $this->assertSame($user->wallets()->first()->id, $device->entitlements[0]->wallet_id);
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 4, 8:42 AM (15 h, 59 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18826188
Default Alt Text
D5568.1775292126.diff (13 KB)

Event Timeline