Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.86% covered (success)
92.86%
26 / 28
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProposalShareTokenService
92.86% covered (success)
92.86%
26 / 28
66.67% covered (warning)
66.67%
2 / 3
11.04
0.00% covered (danger)
0.00%
0 / 1
 issue
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 publicUrl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 resolve
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
8.23
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Proposals\Application\Services;
6
7use Illuminate\Support\Str;
8use ImovelZapAi\Proposals\Domain\Exceptions\ProposalException;
9use ImovelZapAi\Proposals\Infrastructure\Persistence\Eloquent\ProposalModel;
10use ImovelZapAi\Proposals\Infrastructure\Persistence\Eloquent\ProposalShareModel;
11
12final class ProposalShareTokenService
13{
14    /** @return array{plain: string, model: ProposalShareModel, url: string} */
15    public function issue(ProposalModel $proposal, string $versionId, ?\DateTimeInterface $expiresAt = null): array
16    {
17        $plain = Str::lower(Str::random(40));
18        $signature = hash_hmac('sha256', $proposal->id.'|'.$plain, (string) config('app.key'));
19        $expires = $expiresAt ?? now()->addDays((int) config('proposals.token_ttl_days', 14));
20
21        $model = ProposalShareModel::query()->create([
22            'tenant_id' => $proposal->tenant_id,
23            'proposal_id' => $proposal->id,
24            'proposal_version_id' => $versionId,
25            'token_prefix' => substr($plain, 0, 8),
26            'token_hash' => hash('sha256', $plain),
27            'signature' => $signature,
28            'expires_at' => $expires,
29        ]);
30
31        return ['plain' => $plain, 'model' => $model, 'url' => $this->publicUrl($plain)];
32    }
33
34    public function publicUrl(string $plain): string
35    {
36        $base = rtrim((string) (config('proposals.public_base_url') ?: config('app.url')), '/');
37
38        return $base.'/proposta/'.$plain;
39    }
40
41    /** @return array{share: ProposalShareModel, proposal: ProposalModel} */
42    public function resolve(string $plain): array
43    {
44        $share = ProposalShareModel::query()->where('token_hash', hash('sha256', $plain))->first();
45        if ($share === null) {
46            throw ProposalException::tokenInvalid();
47        }
48
49        $expected = hash_hmac('sha256', $share->proposal_id.'|'.$plain, (string) config('app.key'));
50        if (! hash_equals($share->signature, $expected)) {
51            throw ProposalException::tokenInvalid();
52        }
53        if ($share->revoked_at !== null || ($share->expires_at !== null && $share->expires_at->isPast())) {
54            throw ProposalException::tokenInvalid();
55        }
56
57        $proposal = ProposalModel::query()->whereKey($share->proposal_id)->first();
58        if ($proposal === null || ! $proposal->isPubliclyAccessible()) {
59            throw ProposalException::tokenInvalid();
60        }
61
62        $share->forceFill(['last_used_at' => now()])->save();
63
64        return ['share' => $share, 'proposal' => $proposal];
65    }
66}