Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.ci
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ QUEUE_CONNECTION=sync
SESSION_DRIVER=array
SESSION_LIFETIME=120
MAIL_MAILER=array

DB_CONNECTION=sqlite
DB_DATABASE=:memory:
DB_FOREIGN_KEYS=true
4 changes: 2 additions & 2 deletions .github/workflows/test-and-lint-php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
- '**.php'
- 'composer.json'
- 'composer.lock'
- 'phpunit.xml'
- 'phpunit.xml.ci'
- '.env.ci'

jobs:
Expand Down Expand Up @@ -65,4 +65,4 @@ jobs:
- name: Execute tests
run: |
cp .env.ci .env
php artisan test --colors=always
vendor/bin/phpunit -c phpunit.xml.ci --colors=always
18 changes: 16 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ tasks:
- task: _set-app-key
- cmd: "{{.DOCKER_EXEC_USER}} npm install"
- cmd: "{{.DOCKER_EXEC_USER}} php artisan migrate"
- task: create-test-db

frp:init:
desc: "Add default FRP domains (api, app, mail)"
Expand Down Expand Up @@ -110,9 +111,9 @@ tasks:
- cmd: "{{.DOCKER_EXEC_USER}} php artisan {{.CLI_ARGS}}"

test:
desc: "Run pest tests"
desc: "Run tests"
cmds:
- cmd: "{{.DOCKER_EXEC_USER}} vendor/bin/pest {{.CLI_ARGS}}"
- cmd: "{{.DOCKER_EXEC_USER}} composer test"

fix:
desc: "Run fixers"
Expand All @@ -135,3 +136,16 @@ tasks:
echo "APP_KEY is not set. Creating:"
docker compose exec {{.DOCKER_COMPOSE_APP_CONTAINER}} php artisan key:generate
fi

create-test-db:
desc: "Create Postgres test database inside db container (if not exists)"
vars:
DOCKER_COMPOSE_DATABASE_CONTAINER: database
DATABASE_USERNAME: '{{default .DB_USERNAME "MiniStravaAPI"}}'
TEST_DATABASE_NAME: '{{default .TEST_DB_DATABASE "MiniStravaAPI-test"}}'
cmds:
- |
docker compose exec {{.DOCKER_COMPOSE_DATABASE_CONTAINER}} \
bash -lc 'createdb --username={{.DATABASE_USERNAME}} {{.TEST_DATABASE_NAME}} &> /dev/null \
&& echo "Created database for tests ({{.TEST_DATABASE_NAME}})." \
|| echo "Database for tests ({{.TEST_DATABASE_NAME}}) exists."'
17 changes: 17 additions & 0 deletions app/Actions/Avatars/ChangeAvatarAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Strava\Actions\Avatars;

use Illuminate\Http\UploadedFile;

class ChangeAvatarAction
{
public function execute(UploadedFile $uploadedFile, int $userId): bool
{
$uploadedFile->storeAs("", $userId . ".png", "avatars");

return true;
}
}
23 changes: 23 additions & 0 deletions app/Actions/Avatars/DeleteAvatarAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Strava\Actions\Avatars;

use Illuminate\Support\Facades\Storage;

class DeleteAvatarAction
{
public function execute(int $userId): bool
{
$filename = $userId . ".png";

if (Storage::disk("avatars")->exists($filename)) {
Storage::disk("avatars")->delete($filename);

return true;
}

return false;
}
}
21 changes: 21 additions & 0 deletions app/Actions/Avatars/GetAvatarAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Strava\Actions\Avatars;

use Illuminate\Support\Facades\Storage;

class GetAvatarAction
{
public function execute(int $userId): ?string
{
$filename = $userId . ".png";

if (Storage::disk("avatars")->exists($filename)) {
return Storage::disk("avatars")->get($filename);
}

return null;
}
}
18 changes: 18 additions & 0 deletions app/Actions/Avatars/GetDefaultAvatarAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Strava\Actions\Avatars;

use Identicon\Generator\SvgGenerator;
use Identicon\Identicon;

class GetDefaultAvatarAction
{
public function execute(int $userId): ?string
{
$identicon = new Identicon(new SvgGenerator());

return $identicon->getImageData((string)$userId, 300);
}
}
18 changes: 18 additions & 0 deletions app/Actions/Profile/UpdateProfileAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Strava\Actions\Profile;

use Strava\Models\User;

class UpdateProfileAction
{
public function execute(User $user, array $data): ?User
{
$user->fill($data);
$user->save();

return $user->fresh();
}
}
11 changes: 11 additions & 0 deletions app/Enums/Gender.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Strava\Enums;

enum Gender: string
{
case Male = "male";
case Female = "female";
}
39 changes: 39 additions & 0 deletions app/Helpers/IdenticonHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Strava\Helpers;

use Identicon\Identicon;
use Illuminate\Support\Facades\Storage;

class IdenticonHelper
{
private Identicon $identicon;

public function __construct()
{
$this->identicon = new Identicon();
}

public static function url(string|int $name): string
{
return url("/api/profiles/{$name}/avatar");
}

public function create(string|int $filename, string $data): string
{
$image = $this->identicon->getImageData($data, 300);

return $this->save($filename, $image);
}

private function save(string|int $name, string $imageData): string
{
$path = $name . ".png";

Storage::disk("avatars")->put($path, $imageData);

return Storage::disk("avatars")->url($path);
}
}
75 changes: 75 additions & 0 deletions app/Http/Controllers/Profile/ProfileController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace Strava\Http\Controllers\Profile;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Strava\Actions\Avatars\ChangeAvatarAction;
use Strava\Actions\Avatars\DeleteAvatarAction;
use Strava\Actions\Avatars\GetAvatarAction;
use Strava\Actions\Avatars\GetDefaultAvatarAction;
use Strava\Actions\Profile\UpdateProfileAction;
use Strava\Http\Controllers\Controller;
use Strava\Http\Requests\ChangeAvatarRequest;
use Strava\Http\Requests\UpdateProfileRequest;
use Strava\Http\Resources\UserResource;

class ProfileController extends Controller
{
public function update(UpdateProfileRequest $request, UpdateProfileAction $updateProfileAction): UserResource
{
$validated = $request->validated();

$user = $request->user();
$updated = $updateProfileAction->execute($user, $validated);

return UserResource::make($updated);
}

public function show(Request $request): UserResource
{
$user = $request->user();

return UserResource::make($user);
}

public function changeAvatar(ChangeAvatarRequest $request, ChangeAvatarAction $changeAvatarAction): UserResource
{
$user = $request->user();

$changeAvatarAction->execute(
$request->file("avatar"),
$user->id,
);

return UserResource::make($user);
}

public function getAvatar(int $userId, GetAvatarAction $getAvatarAction, GetDefaultAvatarAction $getDefaultAvatarAction): Response
{
$avatar = $getAvatarAction->execute($userId);

if ($avatar) {
return response($avatar)
->header("Content-Type", "image/png")
->header("Cache-Control", "max-age=31536000, public");
}

$defaultAvatar = $getDefaultAvatarAction->execute($userId);

return response($defaultAvatar)
->header("Content-Type", "image/svg+xml")
->header("Cache-Control", "max-age=86400, public");
}

public function deleteAvatar(Request $request, DeleteAvatarAction $deleteAvatarAction): UserResource
{
$user = $request->user();

$deleteAvatarAction->execute($user->id);

return UserResource::make($user);
}
}
42 changes: 42 additions & 0 deletions app/Http/Controllers/Profile/ProfilesController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Strava\Http\Controllers\Profile;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Strava\Http\Controllers\Controller;
use Strava\Http\Resources\UserResource;
use Strava\Models\User;
use Symfony\Component\HttpFoundation\Response;

class ProfilesController extends Controller
{
public function index(): AnonymousResourceCollection
{
$users = User::query()->select([
"id",
"name",
"first_name",
"last_name",
"birth_date",
"height",
"weight",
"gender",
])->paginate(10);

return UserResource::collection($users);
}

public function show(int $id): JsonResponse
{
$user = User::query()->find($id);

if ($user === null) {
return response()->json([], Response::HTTP_NOT_FOUND);
}

return response()->json(UserResource::make($user), Response::HTTP_OK);
}
}
31 changes: 31 additions & 0 deletions app/Http/Requests/ChangeAvatarRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Strava\Http\Requests;

use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

class ChangeAvatarRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return auth()->check();
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
"avatar" => ["required", "image", "mimes:png", "max:2048"], // max 2MB
];
}
}
29 changes: 29 additions & 0 deletions app/Http/Requests/UpdateProfileRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Strava\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
use Strava\Enums\Gender;

class UpdateProfileRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}

public function rules(): array
{
return [
"first_name" => ["nullable", "string", "max:255"],
"last_name" => ["nullable", "string", "max:255"],
"birth_date" => ["nullable", "date", "before:today"],
"height" => ["nullable", "integer", "min:50", "max:300"],
"weight" => ["nullable", "numeric", "min:10", "max:300"],
"gender" => ["nullable", new Enum(Gender::class)],
];
}
}
Loading