From fb131cd7fa30933186f25c540ac045615ac78bf8 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 8 Jun 2026 11:00:11 +0000 Subject: [PATCH] Add service API for Ladill Mail large attachment uploads. Webmail can POST /api/v1/transfers with a mailbox address to create share links for files above 25 MB. Co-authored-by: Cursor --- .env.example | 4 + .../Controllers/Api/TransferController.php | 76 +++++++++++++++++++ app/Http/Middleware/AuthenticateService.php | 37 +++++++++ app/Services/Identity/MailboxUserResolver.php | 58 ++++++++++++++ bootstrap/app.php | 3 + config/transfer.php | 10 +++ deploy/shared.env.example | 4 + routes/api.php | 5 ++ tests/Feature/TransferApiTest.php | 62 +++++++++++++++ 9 files changed, 259 insertions(+) create mode 100644 app/Http/Controllers/Api/TransferController.php create mode 100644 app/Http/Middleware/AuthenticateService.php create mode 100644 app/Services/Identity/MailboxUserResolver.php create mode 100644 tests/Feature/TransferApiTest.php diff --git a/.env.example b/.env.example index a11d44f..0b3a756 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,10 @@ TRANSFER_PRICE_PER_GB_MONTH=0.15 TRANSFER_MAX_FILE_BYTES=524288000 TRANSFER_MAX_FILES=20 TRANSFER_DEFAULT_RETENTION_DAYS=30 +TRANSFER_MAIL_RETENTION_DAYS=30 + +# Ladill Mail — large attachment uploads (service-to-service). +TRANSFER_API_KEY_WEBMAIL= AFIA_ENABLED=true AFIA_PRODUCT=transfer diff --git a/app/Http/Controllers/Api/TransferController.php b/app/Http/Controllers/Api/TransferController.php new file mode 100644 index 0000000..8df4c6e --- /dev/null +++ b/app/Http/Controllers/Api/TransferController.php @@ -0,0 +1,76 @@ +attributes->get('service_caller') !== 'webmail') { + return response()->json(['message' => 'Forbidden.'], 403); + } + + $maxKb = (int) ceil(((int) config('transfer.max_file_bytes', 524288000)) / 1024); + $maxFiles = (int) config('transfer.max_files_per_transfer', 20); + + $data = $request->validate([ + 'mailbox' => ['required', 'email', 'max:255'], + 'title' => ['nullable', 'string', 'max:120'], + 'message' => ['nullable', 'string', 'max:2000'], + 'retention_days' => ['nullable', 'integer', 'min:1', 'max:365'], + 'files' => ['required', 'array', 'min:1', 'max:'.$maxFiles], + 'files.*' => ['required', 'file', 'max:'.$maxKb], + ]); + + try { + $user = $this->users->resolve((string) $data['mailbox']); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + $title = trim((string) ($data['title'] ?? '')); + if ($title === '') { + $title = 'Email attachment'; + } + + $retentionDays = (int) ($data['retention_days'] ?? config('transfer.mail_retention_days', config('transfer.default_retention_days', 30))); + + try { + $transfer = $this->transfers->create($user, [ + 'title' => $title, + 'message' => $data['message'] ?? null, + 'retention_days' => $retentionDays, + ], $request->file('files', [])); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + $transfer->load(['files', 'qrCode']); + + return response()->json([ + 'data' => [ + 'id' => $transfer->id, + 'title' => $transfer->title, + 'public_url' => $transfer->qrCode?->publicUrl(), + 'expires_at' => $transfer->expires_at?->toIso8601String(), + 'files' => $transfer->files->map(fn ($file) => [ + 'name' => $file->original_name, + 'size_bytes' => $file->size_bytes, + ])->values(), + ], + ], 201); + } +} diff --git a/app/Http/Middleware/AuthenticateService.php b/app/Http/Middleware/AuthenticateService.php new file mode 100644 index 0000000..9540df6 --- /dev/null +++ b/app/Http/Middleware/AuthenticateService.php @@ -0,0 +1,37 @@ +bearerToken(); + $caller = null; + + if ($token !== '') { + foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) { + if (is_string($key) && $key !== '' && hash_equals($key, $token)) { + $caller = $name; + break; + } + } + } + + if ($caller === null) { + return response()->json(['error' => 'Unauthorized.'], 401); + } + + $request->attributes->set('service_caller', $caller); + + return $next($request); + } +} diff --git a/app/Services/Identity/MailboxUserResolver.php b/app/Services/Identity/MailboxUserResolver.php new file mode 100644 index 0000000..9f829e5 --- /dev/null +++ b/app/Services/Identity/MailboxUserResolver.php @@ -0,0 +1,58 @@ +where('email', $mailbox)->first(); + if ($existing) { + return $existing; + } + + $base = rtrim((string) config('identity.api_url'), '/'); + $key = (string) config('identity.api_key'); + if ($base === '' || $key === '') { + throw new RuntimeException('Identity API is not configured.'); + } + + $res = Http::withToken($key) + ->acceptJson() + ->timeout(10) + ->get("{$base}/identity/profile", ['mailbox' => $mailbox]); + + if ($res->failed()) { + throw new RuntimeException('Could not resolve the Ladill account for this mailbox.'); + } + + $data = $res->json('data'); + if (! is_array($data)) { + throw new RuntimeException('Could not resolve the Ladill account for this mailbox.'); + } + + $publicId = trim((string) ($data['public_id'] ?? '')); + if ($publicId === '') { + throw new RuntimeException('No Ladill account is linked to this mailbox.'); + } + + return User::updateOrCreate( + ['public_id' => $publicId], + [ + 'name' => trim((string) ($data['name'] ?? '')) ?: $mailbox, + 'email' => $mailbox, + 'avatar_url' => ($picture = trim((string) ($data['picture'] ?? ''))) !== '' ? $picture : null, + ], + ); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 8e63454..3981b86 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -16,6 +16,9 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [ 'redirect' => $request->fullUrl(), ])); + $middleware->alias([ + 'auth.service' => \App\Http\Middleware\AuthenticateService::class, + ]); $middleware->web(append: [ \App\Http\Middleware\SetActingAccount::class, ]); diff --git a/config/transfer.php b/config/transfer.php index e6eb959..fa92206 100644 --- a/config/transfer.php +++ b/config/transfer.php @@ -1,6 +1,13 @@ array_filter([ + 'webmail' => env('TRANSFER_API_KEY_WEBMAIL'), + ]), + // GHS per GB per month of retention (see qr-suite-decomposition.md §7.3). 'price_per_gb_month' => (float) env('TRANSFER_PRICE_PER_GB_MONTH', 0.15), @@ -12,4 +19,7 @@ return [ // Default retention when not specified (days). 'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30), + + // Retention for files uploaded via Ladill Mail (large attachments). + 'mail_retention_days' => (int) env('TRANSFER_MAIL_RETENTION_DAYS', 30), ]; diff --git a/deploy/shared.env.example b/deploy/shared.env.example index 62bd8f3..8f1102d 100644 --- a/deploy/shared.env.example +++ b/deploy/shared.env.example @@ -41,6 +41,10 @@ TRANSFER_PRICE_PER_GB_MONTH=0.15 TRANSFER_MAX_FILE_BYTES=524288000 TRANSFER_MAX_FILES=20 TRANSFER_DEFAULT_RETENTION_DAYS=30 +TRANSFER_MAIL_RETENTION_DAYS=30 + +# Ladill Mail — large attachment uploads (must match WEBMAIL_TRANSFER_API_KEY). +TRANSFER_API_KEY_WEBMAIL= AFIA_ENABLED=true AFIA_PRODUCT=transfer diff --git a/routes/api.php b/routes/api.php index abcace7..008f774 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,8 +2,13 @@ use App\Http\Controllers\Api\MeController; use App\Http\Controllers\Api\QrCodeController; +use App\Http\Controllers\Api\TransferController; use Illuminate\Support\Facades\Route; +Route::middleware('auth.service:transfer')->prefix('v1')->group(function () { + Route::post('/transfers', [TransferController::class, 'store']); +}); + Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->prefix('v1')->group(function () { Route::get('/me', MeController::class); diff --git a/tests/Feature/TransferApiTest.php b/tests/Feature/TransferApiTest.php new file mode 100644 index 0000000..408229f --- /dev/null +++ b/tests/Feature/TransferApiTest.php @@ -0,0 +1,62 @@ + ['webmail' => 'test-webmail-key'], + 'identity.api_url' => 'https://ladill.com/api', + 'identity.api_key' => 'test-identity-key', + ]); + } + + public function test_webmail_service_can_create_transfer_for_mailbox(): void + { + Http::fake([ + rtrim((string) config('identity.api_url'), '/').'/identity/profile*' => Http::response([ + 'data' => [ + 'public_id' => (string) Str::uuid(), + 'name' => 'Mail User', + 'email' => 'sender@acme.com', + 'picture' => null, + ], + ]), + ]); + + $file = UploadedFile::fake()->create('large.zip', 30000, 'application/zip'); + + $this->withToken('test-webmail-key') + ->post('/api/v1/transfers', [ + 'mailbox' => 'sender@acme.com', + 'title' => 'Email: Quarterly report', + 'files' => [$file], + ]) + ->assertCreated() + ->assertJsonPath('data.title', 'Email: Quarterly report') + ->assertJsonStructure(['data' => ['public_url', 'files']]); + + $this->assertDatabaseHas('users', ['email' => 'sender@acme.com']); + $this->assertDatabaseCount('transfers', 1); + } + + public function test_transfer_api_rejects_unauthorized_callers(): void + { + $this->post('/api/v1/transfers', ['mailbox' => 'x@y.com']) + ->assertUnauthorized(); + } +}