Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117819440
D5568.1775292126.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
13 KB
Referenced Files
None
Subscribers
None
D5568.1775292126.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D5568: Device registration
Attached
Detach File
Event Timeline