Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 api/src/Controllers/Profile/ProfileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ public function update(): void
continue;
}

if ($key === 'password') {
$value = password_hash($value, PASSWORD_DEFAULT);
}

$user->{$key} = $value;
}

Expand Down
77 changes: 77 additions & 0 deletions api/src/Controllers/User/PhotoController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

namespace Matcha\Api\Controllers\User;

use Flight;
use Matcha\Api\Model\Photo;
use Matcha\Api\Model\User;
use Matcha\Api\Resources\PhotoResource;

class PhotoController
{
public function index()
{
$photos = Photo::all([
'user_id' => Flight::user()->id,
]);

Flight::json(PhotoResource::collection($photos));
}

public function store()
{
/** @var User $user */
$user = Flight::user();
$files = Flight::request()->getUploadedFiles();
$photos = $files['photos'] ?? null;

if (!$photos) {
Flight::json(['success' => false, 'error' => 'Photos is required.'], 400);
return;
}
if (!is_array($photos)) {
$photos = [$photos];
}

if (count($user->getPhotosUrl()) + count($photos) > 5) {
Flight::json(['success' => false, 'error' => 'You cannot upload more than 5 photos.'], 422);
return;
}

foreach ($photos as $photo) {
if ($photo->getError() !== UPLOAD_ERR_OK) {
Flight::json(['success' => false, 'error' => 'Upload failed (code ' . $photo->getError() . ').'], 422);
return;
}
}

$p = [];
foreach ($photos as $photo) {
$p[] = Photo::upload($photo);
}

Flight::json(PhotoResource::collection($p), 201);
}


public function delete($id)
{
$photo = Photo::find(['id' => $id]);

if (is_null($photo)) {
Flight::json([
'success' => false,
'message' => "The photo does not exist.",
], 404);
return;
}

unlink(BASE_PATH . "/storage/photos/" . $photo->name . ".png");
$photo->delete();

Flight::json([
'success' => true,
'message' => "The photo has been deleted.",
], 201);
}
}
11 changes: 11 additions & 0 deletions api/src/Exceptions/PhotoException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Matcha\Api\Exceptions;

class PhotoException extends \Exception
{
public function __construct()
{
parent::__construct("Photo exception.");
}
}
19 changes: 19 additions & 0 deletions api/src/Model/Photo.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

namespace Matcha\Api\Model;

use Flight;
use flight\net\UploadedFile;
use Matcha\Api\Exceptions\PhotoException;
use Matcha\Api\Validator\Asserts\NotBlank;
use Matcha\Api\Services\Photo as PhotoService;

class Photo extends Model
{
Expand All @@ -19,4 +23,19 @@ public function user(): User
'id' => $this->user_id,
]);
}

public static function upload(UploadedFile $file): self
{
$service = PhotoService::new($file);

if (!$service->isValidExtension()) {
throw new PhotoException();
}

if (!is_dir(BASE_PATH . "/storage/photos")) {
mkdir(BASE_PATH . "/storage/photos", recursive: true);
}

return $service->save(Flight::user());
}
}
21 changes: 21 additions & 0 deletions api/src/Resources/PhotoResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Matcha\Api\Resources;

use Matcha\Api\Model\Photo;

class PhotoResource extends JsonResource
{
public function __construct(Photo $model)
{
parent::__construct($model);
}

public function jsonSerialize(): mixed
{
return [
'id' => $this->model->id,
'name' => $this->model->name,
];
}
}
3 changes: 1 addition & 2 deletions api/src/Resources/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ public function jsonSerialize(): mixed
'password',
'email_verified',
'temporary_email_token',
'lat',
'lon',
];
$data = $this->model->getData();

Expand All @@ -32,6 +30,7 @@ public function jsonSerialize(): mixed
$data['avatar'] = $this->model->getAvatar();
$data['id'] = $this->model->id;
$data['complete_profile'] = $this->model->hasCompleteProfile();
$data['verified'] = $this->model->email_verified;

return $data;
}
Expand Down
10 changes: 7 additions & 3 deletions api/src/Services/Photo.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Exception;
use flight\net\UploadedFile;
use Matcha\Api\Model\User;
use RuntimeException;

class Photo
{
Expand Down Expand Up @@ -38,7 +39,7 @@ private function testCreateByGD(string $extension): bool
};
}

public function save(User $user): void
public function save(User $user): \Matcha\Api\Model\Photo
{
try {
$name = bin2hex(random_bytes(15));
Expand All @@ -51,7 +52,10 @@ public function save(User $user): void
$photo->name = $name;
$photo->user_id = $user->id;

$photo->save();
$this->file->moveTo(BASE_PATH . "/storage/photos/" . $name . ".png");
if (!move_uploaded_file($this->file->getTempName(), BASE_PATH . "/storage/photos/" . $name . ".png")) {
throw new RuntimeException('Failed to store photo.');
}

return $photo->save();
}
}
5 changes: 5 additions & 0 deletions api/src/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Matcha\Api\Controllers\RegisterController;
use Matcha\Api\Controllers\SearchProfileController;
use Matcha\Api\Controllers\TagsController;
use Matcha\Api\Controllers\User\PhotoController;
use Matcha\Api\Controllers\ViewController;
use Matcha\Api\Middleware\AuthMiddleware;
use Matcha\Api\Middleware\CompleteProfileMiddleware;
Expand Down Expand Up @@ -93,6 +94,10 @@
Flight::route('POST /notifications/@id/view', [NotificationsController::class, 'viewed']);
Flight::route('GET /notifications', [NotificationsController::class, 'index']);

Flight::route('POST /photos', [PhotoController::class, 'store']);
Flight::route('GET /photos', [PhotoController::class, 'index']);
Flight::route('DELETE /photos/@id', [PhotoController::class, 'delete']);

Flight::group('/matches', function () {
Flight::route('GET /', [ChatController::class, 'index']);
Flight::route('GET /@username', [ChatController::class, 'show']);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface User {
created_at: string
last_connection: string
profile_complete: boolean
verified: boolean
}

export interface LoginCredentials {
Expand Down
68 changes: 49 additions & 19 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import { useAuth } from '@/composable/useAuth'
import { ApiError, type ApiErrorBody, ApiErrorCode } from '@/api/errors'
import { useAuthStore } from '@/store/useAuthStore'

type Body = Record<string, any> | FormData | null

export class ApiClient<T> {
method = ''
path = ''
headers: Record<string, string> = {
'Content-Type': 'application/json',
}
private refreshPromise: Promise<boolean> | null = null
private retried = false
private static refreshPromise: Promise<boolean> | null = null

static get<T>(
path: string,
Expand All @@ -24,10 +27,11 @@ export class ApiClient<T> {
return this.request<T>('DELETE', path, body)
}

static post<T>(
path: string,
body: Record<string, any> | null = null,
): Promise<ApiResponse<T>> {
post(path: string, body: Body = null): Promise<ApiResponse<T>> {
return this.request('POST', path, body)
}

static post<T>(path: string, body: Body = null): Promise<ApiResponse<T>> {
return this.request<T>('POST', path, body)
}

Expand All @@ -38,17 +42,25 @@ export class ApiClient<T> {
return this.request<T>('PUT', path, body)
}

async request(
method: string,
path: string,
body: Body = null,
): Promise<ApiResponse<T>> {
this.method = method
this.path = path.replace(/^\/+|\/+$/g, '')

return await this.send(body)
}

static async request<T>(
method: string,
path: string,
body: Record<string, any> | null = null,
): Promise<ApiResponse<T>> {
const self = new this<T>()

self.method = method
self.path = path.replace(/^\/+|\/+$/g, '')

return await self.send(body)
return self.request(method, path, body)
}

header(name: string, value: string): this {
Expand All @@ -57,9 +69,14 @@ export class ApiClient<T> {
return this
}

private async send(
body: Record<string, any> | null = null,
): Promise<ApiResponse<T>> {
static header<T>(name: string, value: string): ApiClient<T> {
const self = new this<T>()
self.headers[name] = value

return self
}

private async send(body: Body = null): Promise<ApiResponse<T>> {
const jwt = localStorage.getItem('jwt')

if (jwt != null) {
Expand All @@ -74,20 +91,33 @@ export class ApiClient<T> {
if (query) url += `?${query}`
}

const headers = { ...this.headers }
let payload: BodyInit | null = null

if (body != null && this.method !== 'GET') {
if (body instanceof FormData) {
delete headers['Content-Type']
payload = body
} else {
payload = JSON.stringify(body)
}
}

const res = await fetch(url, {
method: this.method,
headers: this.headers,
body: body != null && this.method !== 'GET' ? JSON.stringify(body) : null,
headers: headers,
body: payload,
})

if (res.status === 401 && this.path !== 'auth/refresh') {
this.refreshPromise ??= useAuth()
if (res.status === 401 && this.path !== 'auth/refresh' && !this.retried) {
this.retried = true
ApiClient.refreshPromise ??= useAuth()
.refreshSession()
.finally(() => {
this.refreshPromise = null
ApiClient.refreshPromise = null
})
await this.refreshPromise
return await this.send(body)
const ok = await ApiClient.refreshPromise
if (ok) return await this.send(body)
}

if (res.status === 403) {
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/api/photos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ApiClient } from '@/api/client'

export interface Photo {
id: number
name: string
}

export const photosApi = {
async getPhotos() {
return ApiClient.get<Photo[]>('/users/me/photos').then(res => res.json())
},

async uploadPhoto(fd: FormData) {
return ApiClient.post<Photo[]>(
'/users/me/photos',
fd,
).then(res => res.json())
},

async deletePhoto(id: number) {
return ApiClient.delete(`/users/me/photos/${id}`).then(res => res.json())
}
}
Loading
Loading