diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c0d1bc9..2ad7b915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# v1.0.2 +## New +- Add `php artisan bar:scrap` command to scrape recipes from the supported websites + - Support for TuxedoNo2 + - Support for A Couple Cooks +- Add cocktails thumbnail generation endpoint +- Enabled GD extension in docker + +## Fixes +- Sort ingredient categories by name +- Sort related cocktails in ingredient resource by name + +## Changes +- Use `docker-php-extension-installer` for docker image + # v1.0.1 ## New - Make cocktail `id` attribute filterable in cocktails index diff --git a/Dockerfile b/Dockerfile index 010fd2de..ccba0a89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,9 +10,9 @@ RUN apt update \ && apt-get autoremove -y \ && apt-get clean -RUN docker-php-ext-install opcache \ - && pecl install redis \ - && docker-php-ext-enable redis +ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ +RUN chmod +x /usr/local/bin/install-php-extensions && \ + install-php-extensions gd opcache redis # Setup default apache stuff RUN echo "ServerName localhost" >> /etc/apache2/apache2.conf diff --git a/README.md b/README.md index 7fe905e2..19bf78f6 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ This application is made with Laravel, so you should check out [deployment requi The basic requirements are: - PHP >= 8.1 + - GD Extension - Sqlite 3 - Working [Meilisearch server](https://github.com/meilisearch) instance (v0.29) - (Optional) Redis server instance @@ -79,6 +80,33 @@ Docker image exposes the `/var/www/cocktails/storage` volume, and there is curre Bar Assistant is using Meilisearch as a primary [Scout driver](https://laravel.com/docs/9.x/scout). It's main purpose is to index cocktails and ingredients and power filtering and searching on the frontend. Checkout [this guide here](https://docs.meilisearch.com/learn/cookbooks/docker.html) on how to setup Meilisearch docker instance. +### Database file backup + +You can copy the whole .sqlite file database with the following: + +``` bash +# Via docker +$ docker cp bar-assistant:/var/www/cocktails/storage/database.sqlite /path/on/host + +# Via docker compose +$ docker compose cp bar-assistant:/var/www/cocktails/storage/database.sqlite /path/on/host +``` + +### Database dump SQL + +You can dump your database to .sql file using the following: + +``` bash +# Via cli +$ sqlite3 /var/www/cocktails/storage/database.sqlite .dump > mydump.sql + +# Via docker +$ docker exec bar-assistant sqlite3 /var/www/cocktails/storage/database.sqlite .dump > mydump.sql + +# Via docker compose +$ docker compose exec bar-assistant sqlite3 /var/www/cocktails/storage/database.sqlite .dump > mydump.sql +``` + ## Manual setup After cloning the repository, you should do the following: @@ -117,6 +145,9 @@ $ php artisan migrate --force # To fill the database with data $ php artisan bar:open + +# Or with specific email and password +$ php artisan bar:open --email=my@email.com --pass=12345 ``` ## Usage diff --git a/app/Console/Commands/BarScrape.php b/app/Console/Commands/BarScrape.php index 229ccc81..73eafdcd 100644 --- a/app/Console/Commands/BarScrape.php +++ b/app/Console/Commands/BarScrape.php @@ -1,16 +1,12 @@ argument('url')); $scrapedData = $scraper->toArray(); - /** @var IngredientService */ - $ingredientService = app(IngredientService::class); - /** @var CocktailService */ - $cocktailService = app(CocktailService::class); - - $dbIngredients = DB::table('ingredients')->select('id', DB::raw('LOWER(name) AS name'))->get()->keyBy('name'); - $dbGlasses = DB::table('glasses')->select('id', DB::raw('LOWER(name) AS name'))->get()->keyBy('name'); - - $cocktailImages = []; - if ($scrapedData['image']['url']) { - $memImage = InterventionImage::make($scrapedData['image']['url']); - - $filepath = 'temp/' . Str::random(40) . '.jpg'; - $memImage->save(storage_path('uploads/' . $filepath)); - - $image = new Image(); - $image->copyright = $scrapedData['image']['copyright'] ?? null; - $image->file_path = $filepath; - $image->file_extension = 'jpg'; - $image->save(); - - $cocktailImages[] = $image->id; - } - - // Match ingredients - foreach ($scrapedData['ingredients'] as &$scrapedIngredient) { - if ($dbIngredients->has(strtolower($scrapedIngredient['name']))) { - $scrapedIngredient['ingredient_id'] = $dbIngredients->get(strtolower($scrapedIngredient['name']))->id; - } else { - $this->info('Creating a new ingredient: ' . $scrapedIngredient['name']); - $newIngredient = $ingredientService->createIngredient(ucfirst($scrapedIngredient['name']), 1, 1, description: 'Created by scraper from ' . $scrapedData['source']); - $dbIngredients->put(strtolower($scrapedIngredient['name']), $newIngredient->id); - $scrapedIngredient['ingredient_id'] = $newIngredient->id; - } + if ($this->option('skip-ingredients')) { + $scrapedData['ingredients'] = []; } - // Match glass - $glassId = null; - if ($dbGlasses->has(strtolower($scrapedData['glass']))) { - $glassId = $dbGlasses->get(strtolower($scrapedData['glass']))->id; - } elseif ($scrapedData['glass'] !== null) { - $this->info('Creating a new glass type: ' . $scrapedData['glass']); - $newGlass = new Glass(); - $newGlass->name = ucfirst($scrapedData['glass']); - $newGlass->description = 'Created by scraper from ' . $scrapedData['source']; - $newGlass->save(); - $dbGlasses->put(strtolower($scrapedData['glass']), $newGlass->id); - $glassId = $newGlass->id; + if ($this->option('tags')) { + $scrapedData['tags'] = explode(',', $this->option('tags')); } - $cocktailService->createCocktail( - $scrapedData['name'], - $scrapedData['instructions'], - $scrapedData['ingredients'], - 1, - $scrapedData['description'], - $scrapedData['garnish'], - $scrapedData['source'], - $cocktailImages, - $scrapedData['tags'], - $glassId - ); + resolve(ImportService::class)->import($scrapedData); return Command::SUCCESS; } diff --git a/app/Http/Controllers/ImageController.php b/app/Http/Controllers/ImageController.php index 1fba46d8..872d7baf 100644 --- a/app/Http/Controllers/ImageController.php +++ b/app/Http/Controllers/ImageController.php @@ -7,7 +7,10 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Kami\Cocktail\Models\Image; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Storage; use Kami\Cocktail\Services\ImageService; +use Intervention\Image\ImageManagerStatic; use Kami\Cocktail\Http\Requests\ImageRequest; use Kami\Cocktail\Http\Resources\ImageResource; use Illuminate\Http\Resources\Json\JsonResource; @@ -41,4 +44,26 @@ public function delete(int $id): Response return response(null, 204); } + + public function thumb(int $id) + { + [$content, $etag] = Cache::remember('image_thumb_' . $id, 1 * 24 * 60 * 60, function () use ($id) { + $dbImage = Image::findOrFail($id); + $disk = Storage::disk('app_images'); + $responseContent = (string) ImageManagerStatic::make($disk->get($dbImage->file_path))->fit(200, 200)->encode(); + $etag = md5($dbImage->id . '-' . $dbImage->updated_at->format('Y-m-d H:i:s')); + + return [$responseContent, $etag]; + }); + + $mime = finfo_buffer(finfo_open(FILEINFO_MIME_TYPE), $content); + $notModified = isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] == $etag; + $statusCode = $notModified ? 304 : 200; + + return new Response($content, $statusCode, [ + 'Content-Type' => $mime, + 'Content-Length' => strlen($content), + 'Etag' => $etag + ]); + } } diff --git a/app/Http/Controllers/IngredientCategoryController.php b/app/Http/Controllers/IngredientCategoryController.php index 4fea2823..769c15a0 100644 --- a/app/Http/Controllers/IngredientCategoryController.php +++ b/app/Http/Controllers/IngredientCategoryController.php @@ -15,7 +15,7 @@ class IngredientCategoryController extends Controller { public function index(): JsonResource { - $categories = IngredientCategory::all(); + $categories = IngredientCategory::orderBy('name')->get(); return IngredientCategoryResource::collection($categories); } diff --git a/app/Http/Resources/IngredientResource.php b/app/Http/Resources/IngredientResource.php index c693a407..20ad3d32 100644 --- a/app/Http/Resources/IngredientResource.php +++ b/app/Http/Resources/IngredientResource.php @@ -24,7 +24,7 @@ public function toArray($request) 'slug' => $this->slug, 'name' => $this->name, 'strength' => $this->strength, - 'description' => $this->description, + 'description' => e($this->description), 'origin' => $this->origin, 'main_image_id' => $this->images->first()->id ?? null, 'images' => ImageResource::collection($this->images), @@ -48,7 +48,7 @@ public function toArray($request) 'slug' => $c->slug, 'name' => $c->name, ]; - }); + })->sortBy('name')->toArray(); }) ]; } diff --git a/app/Models/Cocktail.php b/app/Models/Cocktail.php index 95b0ac94..7b6020d7 100644 --- a/app/Models/Cocktail.php +++ b/app/Models/Cocktail.php @@ -88,6 +88,7 @@ public function toSearchableArray(): array 'source' => $this->source, 'garnish' => $this->garnish, 'image_url' => $this->getMainImageUrl(), + 'main_image_id' => $this->images->first()?->id ?? null, 'short_ingredients' => $this->ingredients->pluck('ingredient.name'), 'user_id' => $this->user_id, 'tags' => $this->tags->pluck('name'), diff --git a/app/Scraper/HasJsonLd.php b/app/Scraper/HasJsonLd.php new file mode 100644 index 00000000..5b82363e --- /dev/null +++ b/app/Scraper/HasJsonLd.php @@ -0,0 +1,15 @@ +crawler->filterXPath('//script[@type="application/ld+json"]')->first()->text(); + + return json_decode($jsonLdSchema, true); + } +} diff --git a/app/Scraper/Manager.php b/app/Scraper/Manager.php index 0422d5bd..63a83843 100644 --- a/app/Scraper/Manager.php +++ b/app/Scraper/Manager.php @@ -9,7 +9,9 @@ final class Manager { private $supportedSites = [ + \Kami\Cocktail\Scraper\Sites\ACoupleCooks::class, \Kami\Cocktail\Scraper\Sites\TuxedoNo2::class, + // \Kami\Cocktail\Scraper\Sites\Imbibe::class, ]; public function __construct(private readonly string $url) diff --git a/app/Scraper/Sites/ACoupleCooks.php b/app/Scraper/Sites/ACoupleCooks.php new file mode 100644 index 00000000..2c4e24a8 --- /dev/null +++ b/app/Scraper/Sites/ACoupleCooks.php @@ -0,0 +1,116 @@ +getTypeFromSchema('Recipe')['name']; + } + + public function description(): ?string + { + return $this->getTypeFromSchema('Recipe')['description']; + } + + public function source(): ?string + { + return $this->getTypeFromSchema('Recipe')['url']; + } + + public function instructions(): ?string + { + $result = ''; + $instructions = $this->getTypeFromSchema('Recipe')['recipeInstructions']; + $i = 1; + foreach ($instructions as $step) { + $result .= $i . ". " . $step['text'] . "\n\n"; + $i++; + } + + return $result; + } + + public function tags(): array + { + return []; + } + + public function glass(): ?string + { + return null; + } + + public function ingredients(): array + { + $result = []; + + $this->crawler->filter('div.tasty-recipes-ingredients ul')->first()->filter('li')->each(function ($node) use (&$result) { + $amount = 0; + $units = ''; + $name = $node->text(); + + if ($node->filter('span')->count() > 0) { + $amount = $node->filter('span')->first()->attr('data-amount'); + $units = $node->filter('span')->first()->attr('data-unit'); + + if ($units && ($units === 'ounce' || $units === 'ounces')) { + ['amount' => $amount, 'units' => $units] = Utils::parseIngredientAmount($amount . ' oz'); + } + + $name = explode($node->filter('span')->last()->text(), $node->text()); + $name = trim($name[1], " \n\r\t\v\x00\(\)"); + } + + $result[] = [ + 'amount' => $amount, + 'units' => $units ?? '', + 'name' => $name, + 'optional' => false, + ]; + }); + + return $result; + } + + public function garnish(): ?string + { + return null; + } + + public function image(): ?array + { + return [ + 'url' => $this->getTypeFromSchema('ImageObject')['url'], + 'copyright' => 'A Couple Cooks', + ]; + } + + private function getTypeFromSchema(string $type): ?array + { + $schema = $this->parseSchema(); + foreach ($schema['@graph'] as $node) { + if ($node['@type'] === $type) { + return $node; + } + } + + return null; + } +} diff --git a/app/Scraper/Sites/TuxedoNo2.php b/app/Scraper/Sites/TuxedoNo2.php index af5a7850..a672fdfe 100644 --- a/app/Scraper/Sites/TuxedoNo2.php +++ b/app/Scraper/Sites/TuxedoNo2.php @@ -132,7 +132,7 @@ private function hintCommonIngredients(string $ingredientName): string 'rye' => 'Rye whiskey', 'bourbon' => 'Bourbon whiskey', 'scotch' => 'Scotch whiskey', - 'angostura bitters' => 'Angostura aromatic bitters', + 'angostura bitters', 'aromatic bitters' => 'Angostura aromatic bitters', 'orange liqueur' => 'Triple Sec', 'heavy cream' => 'Cream', 'soda water' => 'Club soda', @@ -141,6 +141,7 @@ private function hintCommonIngredients(string $ingredientName): string 'fernet' => 'Fernet Branca', 'benedictine' => 'Bénédictine', 'herbsaint' => 'Absinthe', + 'blanco tequila' => 'Tequila', 'peychaud\'s bitters' => 'Peychauds Bitters', default => $ingredientName }; diff --git a/app/Services/CocktailService.php b/app/Services/CocktailService.php index 3d33f3e2..9be1ea5f 100644 --- a/app/Services/CocktailService.php +++ b/app/Services/CocktailService.php @@ -238,41 +238,23 @@ public function updateCocktail( */ public function getCocktailsByUserIngredients(int $userId): Collection { - // https://stackoverflow.com/questions/19930070/mysql-query-to-select-all-except-something $cocktailIds = $this->db->table('cocktails AS c') ->select('c.id') ->join('cocktail_ingredients AS ci', 'ci.cocktail_id', '=', 'c.id') - ->where('ci.optional', false) + ->join('ingredients AS i', 'i.id', '=', 'ci.ingredient_id') + ->leftJoin('cocktail_ingredient_substitutes AS cis', 'cis.cocktail_ingredient_id', '=', 'ci.id') + ->where('optional', false) + ->whereIn('i.id', function ($query) use ($userId) { + $query->select('ingredient_id')->from('user_ingredients')->where('user_id', $userId); + }) + ->orWhereIn('cis.ingredient_id', function ($query) use ($userId) { + $query->select('ingredient_id')->from('user_ingredients')->where('user_id', $userId); + }) ->groupBy('c.id') - ->havingRaw('SUM(CASE WHEN ci.ingredient_id IN (SELECT ingredient_id FROM user_ingredients WHERE user_id = ?) THEN 1 ELSE 0 END) = COUNT(*)', [$userId]) + ->havingRaw('COUNT(*) >= (SELECT COUNT(*) FROM cocktail_ingredients WHERE cocktail_id = c.id AND optional = false)') ->pluck('id'); - - // Programatically find cocktails that match your ingredients with possible substituted ingredients. - // This is currently probably not really performant - $userIngredients = $this->db->table('user_ingredients')->select('ingredient_id')->where('user_id', $userId)->pluck('ingredient_id'); // TODO: extract, and reuse - $possibleCocktailsWithSubstitutes = Cocktail::select('cocktails.*') - ->join('cocktail_ingredients AS ci', 'ci.cocktail_id', '=', 'cocktails.id') - ->join('cocktail_ingredient_substitutes AS cis', 'cis.cocktail_ingredient_id', '=', 'ci.id') - ->join('user_ingredients AS ui', 'ui.ingredient_id', '=', 'cis.ingredient_id') - ->where('ui.user_id', $userId) - ->get(); - - $subCocktails = []; - foreach ($possibleCocktailsWithSubstitutes as $cocktail) { - $ingredientsCount = 0; - foreach ($cocktail->ingredients as $cocktailIngredient) { - if ($userIngredients->contains($cocktailIngredient->ingredient_id)) { // User has original ingredient - $ingredientsCount++; - } elseif ($userIngredients->intersect($cocktailIngredient->substitutes->pluck('ingredient_id'))->count() > 0) { // User has one of the substitiute ingredients - $ingredientsCount++; - } - } - if ($ingredientsCount === $cocktail->ingredients->count()) { // User can make this cocktail - $subCocktails[] = $cocktail->id; - } - } - return Cocktail::orderBy('name')->find(array_merge($cocktailIds->toArray(), $subCocktails)); + return Cocktail::orderBy('name')->find($cocktailIds); } /** diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index 237a3704..c4868b6f 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -8,6 +8,7 @@ use Kami\Cocktail\Models\Image; use Illuminate\Http\UploadedFile; use Kami\Cocktail\Exceptions\ImageUploadException; +use Intervention\Image\ImageManagerStatic as InterventionImage; class ImageService { @@ -58,4 +59,20 @@ public function updateImage(int $imageId, ?UploadedFile $file = null, ?string $c return $image; } + + public function uploadImage(string $imageSource, ?string $copyright = null): Image + { + $tempImage = InterventionImage::make($imageSource); + + $filepath = 'temp/' . Str::random(40) . '.jpg'; + $tempImage->save(storage_path('uploads/' . $filepath)); + + $image = new Image(); + $image->copyright = $copyright; + $image->file_path = $filepath; + $image->file_extension = 'jpg'; + $image->save(); + + return $image; + } } diff --git a/app/Services/ImportService.php b/app/Services/ImportService.php new file mode 100644 index 00000000..791970b1 --- /dev/null +++ b/app/Services/ImportService.php @@ -0,0 +1,78 @@ +select('id', DB::raw('LOWER(name) AS name'))->get()->keyBy('name'); + $dbGlasses = DB::table('glasses')->select('id', DB::raw('LOWER(name) AS name'))->get()->keyBy('name'); + + // Add images + $cocktailImages = []; + if ($sourceData['image']['url']) { + $cocktailImages[] = $this->imageService->uploadImage( + $sourceData['image']['url'], + $sourceData['image']['copyright'] ?? null + )->id; + } + + // Match glass + $glassId = null; + if ($sourceData['glass']) { + $glassNameLower = strtolower($sourceData['glass']); + if ($dbGlasses->has($glassNameLower)) { + $glassId = $dbGlasses->get($glassNameLower)->id; + } elseif ($sourceData['glass'] !== null) { + $newGlass = new Glass(); + $newGlass->name = ucfirst($sourceData['glass']); + $newGlass->description = 'Created by scraper from ' . $sourceData['source']; + $newGlass->save(); + $dbGlasses->put($glassNameLower, $newGlass->id); + $glassId = $newGlass->id; + } + } + + // Match ingredients + foreach ($sourceData['ingredients'] as &$scrapedIngredient) { + if ($dbIngredients->has(strtolower($scrapedIngredient['name']))) { + $scrapedIngredient['ingredient_id'] = $dbIngredients->get(strtolower($scrapedIngredient['name']))->id; + } else { + $newIngredient = $this->ingredientService->createIngredient(ucfirst($scrapedIngredient['name']), 1, 1, description: 'Created by scraper from ' . $sourceData['source']); + $dbIngredients->put(strtolower($scrapedIngredient['name']), $newIngredient->id); + $scrapedIngredient['ingredient_id'] = $newIngredient->id; + } + } + + // Add cocktail + return $this->cocktailService->createCocktail( + $sourceData['name'], + $sourceData['instructions'], + $sourceData['ingredients'], + 1, + $sourceData['description'], + $sourceData['garnish'], + $sourceData['source'], + $cocktailImages, + $sourceData['tags'], + $glassId + ); + } +} diff --git a/composer.json b/composer.json index 08125e49..02fdb056 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,8 @@ "meilisearch/meilisearch-php": "^0.25.0", "spatie/laravel-responsecache": "^7.4", "spatie/laravel-sluggable": "^3.4", + "symfony/browser-kit": "^6.2", + "symfony/http-client": "^6.2", "symfony/yaml": "^6.1" }, "require-dev": { @@ -26,8 +28,6 @@ "phpstan/phpstan": "^1.8", "phpunit/phpunit": "^9.5.10", "spatie/laravel-ignition": "^1.0", - "symfony/browser-kit": "^6.1", - "symfony/http-client": "^6.1", "symplify/easy-coding-standard": "^11.1" }, "autoload": { diff --git a/composer.lock b/composer.lock index 9a33bb82..e2152b8d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0b5e5376fe978d7dabc4d977985d1bff", + "content-hash": "d7c0e8474e378ee9c8b6d98d7bb0c1f0", "packages": [ { "name": "brick/math", @@ -1891,6 +1891,75 @@ ], "time": "2022-04-17T13:12:02+00:00" }, + { + "name": "masterminds/html5", + "version": "2.7.6", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "897eb517a343a2281f11bc5556d6548db7d93947" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/897eb517a343a2281f11bc5556d6548db7d93947", + "reference": "897eb517a343a2281f11bc5556d6548db7d93947", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-dom": "*", + "ext-libxml": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.7.6" + }, + "time": "2022-08-18T16:18:26+00:00" + }, { "name": "meilisearch/meilisearch-php", "version": "v0.25.1", @@ -3774,6 +3843,77 @@ ], "time": "2022-03-28T11:21:33+00:00" }, + { + "name": "symfony/browser-kit", + "version": "v6.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "5602fcc9a2a696f1743050ffcafa30741da94227" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/5602fcc9a2a696f1743050ffcafa30741da94227", + "reference": "5602fcc9a2a696f1743050ffcafa30741da94227", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/dom-crawler": "^5.4|^6.0" + }, + "require-dev": { + "symfony/css-selector": "^5.4|^6.0", + "symfony/http-client": "^5.4|^6.0", + "symfony/mime": "^5.4|^6.0", + "symfony/process": "^5.4|^6.0" + }, + "suggest": { + "symfony/process": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v6.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-02T09:08:04+00:00" + }, { "name": "symfony/console", "version": "v6.2.0", @@ -4002,6 +4142,76 @@ ], "time": "2022-02-25T11:15:52+00:00" }, + { + "name": "symfony/dom-crawler", + "version": "v6.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "c079db42bed39928fc77a24307cbfff7ac7757f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/c079db42bed39928fc77a24307cbfff7ac7757f7", + "reference": "c079db42bed39928fc77a24307cbfff7ac7757f7", + "shasum": "" + }, + "require": { + "masterminds/html5": "^2.6", + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^5.4|^6.0" + }, + "suggest": { + "symfony/css-selector": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v6.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-02T09:08:04+00:00" + }, { "name": "symfony/error-handler", "version": "v6.2.0", @@ -4300,43 +4510,50 @@ "time": "2022-10-09T08:55:40+00:00" }, { - "name": "symfony/http-foundation", + "name": "symfony/http-client", "version": "v6.2.0", "source": { "type": "git", - "url": "https://github.com/symfony/http-foundation.git", - "reference": "edc56ed49a2955383d59e9b7043fd3bbc26f1854" + "url": "https://github.com/symfony/http-client.git", + "reference": "153540b6ed72eecdcb42dc847f8d8cf2e2516e8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/edc56ed49a2955383d59e9b7043fd3bbc26f1854", - "reference": "edc56ed49a2955383d59e9b7043fd3bbc26f1854", + "url": "https://api.github.com/repos/symfony/http-client/zipball/153540b6ed72eecdcb42dc847f8d8cf2e2516e8e", + "reference": "153540b6ed72eecdcb42dc847f8d8cf2e2516e8e", "shasum": "" }, "require": { "php": ">=8.1", + "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-mbstring": "~1.1" + "symfony/http-client-contracts": "^3", + "symfony/service-contracts": "^1.0|^2|^3" }, - "conflict": { - "symfony/cache": "<6.2" + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" }, "require-dev": { - "predis/predis": "~1.0", - "symfony/cache": "^5.4|^6.0", + "amphp/amp": "^2.5", + "amphp/http-client": "^4.2.1", + "amphp/http-tunnel": "^1.0", + "amphp/socket": "^1.1", + "guzzlehttp/promises": "^1.4", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", "symfony/dependency-injection": "^5.4|^6.0", - "symfony/expression-language": "^5.4|^6.0", - "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4", - "symfony/mime": "^5.4|^6.0", - "symfony/rate-limiter": "^5.2|^6.0" - }, - "suggest": { - "symfony/mime": "To use the file extension guesser" + "symfony/http-kernel": "^5.4|^6.0", + "symfony/process": "^5.4|^6.0", + "symfony/stopwatch": "^5.4|^6.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\HttpFoundation\\": "" + "Symfony\\Component\\HttpClient\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -4348,9 +4565,168 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-client/tree/v6.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-14T10:13:36+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "fd038f08c623ab5d22b26e9ba35afe8c79071800" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/fd038f08c623ab5d22b26e9ba35afe8c79071800", + "reference": "fd038f08c623ab5d22b26e9ba35afe8c79071800", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "suggest": { + "symfony/http-client-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.1-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-04-22T07:30:54+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v6.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "edc56ed49a2955383d59e9b7043fd3bbc26f1854" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/edc56ed49a2955383d59e9b7043fd3bbc26f1854", + "reference": "edc56ed49a2955383d59e9b7043fd3bbc26f1854", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-mbstring": "~1.1" + }, + "conflict": { + "symfony/cache": "<6.2" + }, + "require-dev": { + "predis/predis": "~1.0", + "symfony/cache": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/expression-language": "^5.4|^6.0", + "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4", + "symfony/mime": "^5.4|^6.0", + "symfony/rate-limiter": "^5.2|^6.0" + }, + "suggest": { + "symfony/mime": "To use the file extension guesser" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" @@ -7129,75 +7505,6 @@ }, "time": "2022-11-21T16:19:18+00:00" }, - { - "name": "masterminds/html5", - "version": "2.7.6", - "source": { - "type": "git", - "url": "https://github.com/Masterminds/html5-php.git", - "reference": "897eb517a343a2281f11bc5556d6548db7d93947" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/897eb517a343a2281f11bc5556d6548db7d93947", - "reference": "897eb517a343a2281f11bc5556d6548db7d93947", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-dom": "*", - "ext-libxml": "*", - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.7-dev" - } - }, - "autoload": { - "psr-4": { - "Masterminds\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Matt Butcher", - "email": "technosophos@gmail.com" - }, - { - "name": "Matt Farina", - "email": "matt@mattfarina.com" - }, - { - "name": "Asmir Mustafic", - "email": "goetas@gmail.com" - } - ], - "description": "An HTML5 parser and serializer.", - "homepage": "http://masterminds.github.io/html5-php", - "keywords": [ - "HTML5", - "dom", - "html", - "parser", - "querypath", - "serializer", - "xml" - ], - "support": { - "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.7.6" - }, - "time": "2022-08-18T16:18:26+00:00" - }, { "name": "maximebf/debugbar", "version": "v1.18.1", @@ -9693,313 +10000,6 @@ ], "time": "2022-10-26T17:39:54+00:00" }, - { - "name": "symfony/browser-kit", - "version": "v6.2.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/browser-kit.git", - "reference": "5602fcc9a2a696f1743050ffcafa30741da94227" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/5602fcc9a2a696f1743050ffcafa30741da94227", - "reference": "5602fcc9a2a696f1743050ffcafa30741da94227", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/dom-crawler": "^5.4|^6.0" - }, - "require-dev": { - "symfony/css-selector": "^5.4|^6.0", - "symfony/http-client": "^5.4|^6.0", - "symfony/mime": "^5.4|^6.0", - "symfony/process": "^5.4|^6.0" - }, - "suggest": { - "symfony/process": "" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\BrowserKit\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/browser-kit/tree/v6.2.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-11-02T09:08:04+00:00" - }, - { - "name": "symfony/dom-crawler", - "version": "v6.2.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/dom-crawler.git", - "reference": "c079db42bed39928fc77a24307cbfff7ac7757f7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/c079db42bed39928fc77a24307cbfff7ac7757f7", - "reference": "c079db42bed39928fc77a24307cbfff7ac7757f7", - "shasum": "" - }, - "require": { - "masterminds/html5": "^2.6", - "php": ">=8.1", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.0" - }, - "require-dev": { - "symfony/css-selector": "^5.4|^6.0" - }, - "suggest": { - "symfony/css-selector": "" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\DomCrawler\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Eases DOM navigation for HTML and XML documents", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.2.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-11-02T09:08:04+00:00" - }, - { - "name": "symfony/http-client", - "version": "v6.2.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-client.git", - "reference": "153540b6ed72eecdcb42dc847f8d8cf2e2516e8e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/153540b6ed72eecdcb42dc847f8d8cf2e2516e8e", - "reference": "153540b6ed72eecdcb42dc847f8d8cf2e2516e8e", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/http-client-contracts": "^3", - "symfony/service-contracts": "^1.0|^2|^3" - }, - "provide": { - "php-http/async-client-implementation": "*", - "php-http/client-implementation": "*", - "psr/http-client-implementation": "1.0", - "symfony/http-client-implementation": "3.0" - }, - "require-dev": { - "amphp/amp": "^2.5", - "amphp/http-client": "^4.2.1", - "amphp/http-tunnel": "^1.0", - "amphp/socket": "^1.1", - "guzzlehttp/promises": "^1.4", - "nyholm/psr7": "^1.0", - "php-http/httplug": "^1.0|^2.0", - "psr/http-client": "^1.0", - "symfony/dependency-injection": "^5.4|^6.0", - "symfony/http-kernel": "^5.4|^6.0", - "symfony/process": "^5.4|^6.0", - "symfony/stopwatch": "^5.4|^6.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\HttpClient\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/http-client/tree/v6.2.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-11-14T10:13:36+00:00" - }, - { - "name": "symfony/http-client-contracts", - "version": "v3.1.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "fd038f08c623ab5d22b26e9ba35afe8c79071800" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/fd038f08c623ab5d22b26e9ba35afe8c79071800", - "reference": "fd038f08c623ab5d22b26e9ba35afe8c79071800", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "suggest": { - "symfony/http-client-implementation": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.1-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\HttpClient\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to HTTP clients", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.1.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-04-22T07:30:54+00:00" - }, { "name": "symplify/easy-coding-standard", "version": "11.1.17", diff --git a/config/bar-assistant.php b/config/bar-assistant.php index 6627800f..ce20561a 100644 --- a/config/bar-assistant.php +++ b/config/bar-assistant.php @@ -11,7 +11,7 @@ | */ - 'version' => 'v1.0.1', + 'version' => 'v1.0.2', /* |-------------------------------------------------------------------------- diff --git a/database/factories/ImageFactory.php b/database/factories/ImageFactory.php new file mode 100644 index 00000000..29b9d84e --- /dev/null +++ b/database/factories/ImageFactory.php @@ -0,0 +1,25 @@ + + */ +class ImageFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + 'copyright' => fake()->paragraph(), + 'file_path' => fake()->filePath(), + 'file_extension' => fake()->fileExtension(), + ]; + } +} diff --git a/docs/open-api-spec.yml b/docs/open-api-spec.yml index 30d4651b..34482816 100644 --- a/docs/open-api-spec.yml +++ b/docs/open-api-spec.yml @@ -614,6 +614,28 @@ paths: description: Successful response '404': $ref: '#/components/responses/NotFound' + /images/{id}/thumb: + parameters: + - in: path + name: id + description: 'Database id of the image' + schema: + type: integer + required: true + get: + tags: + - Images + summary: Get a thumbnail of a response + responses: + '200': + description: Successful response + content: + image/jpeg: + schema: + type: string + format: binary + '404': + $ref: '#/components/responses/NotFound' /ingredient-categories: get: tags: diff --git a/routes/api.php b/routes/api.php index 5a27a013..64bad9af 100644 --- a/routes/api.php +++ b/routes/api.php @@ -33,6 +33,10 @@ Route::get('/openapi', [ServerController::class, 'openApi']); }); +Route::prefix('images')->group(function() { + Route::get('/{id}/thumb', [ImageController::class, 'thumb']); +}); + Route::middleware('auth:sanctum')->group(function() { Route::post('logout', [AuthController::class, 'logout'])->name('auth.logout'); @@ -77,6 +81,7 @@ Route::prefix('images')->group(function() { Route::get('/{id}', [ImageController::class, 'show']); + // Route::get('/{id}/thumb', [ImageController::class, 'thumb']); Route::post('/', [ImageController::class, 'store']); Route::post('/{id}', [ImageController::class, 'update']); Route::delete('/{id}', [ImageController::class, 'delete']); diff --git a/storage/http_cache/.gitignore b/storage/http_cache/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/storage/http_cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Feature/ImageControllerTest.php b/tests/Feature/ImageControllerTest.php index 0a821b19..4e06a103 100644 --- a/tests/Feature/ImageControllerTest.php +++ b/tests/Feature/ImageControllerTest.php @@ -4,7 +4,9 @@ use Tests\TestCase; use Kami\Cocktail\Models\User; +use Kami\Cocktail\Models\Image; use Illuminate\Http\UploadedFile; +use Kami\Cocktail\Models\Cocktail; use Illuminate\Support\Facades\Storage; use Illuminate\Testing\Fluent\AssertableJson; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -81,4 +83,19 @@ public function test_multiple_image_upload() Storage::disk('app_images')->assertExists($response->json('data.1.file_path')); Storage::disk('app_images')->assertExists($response->json('data.2.file_path')); } + + public function test_image_thumb() + { + Storage::fake('app_images'); + $imageFile = UploadedFile::fake()->image('image1.jpg'); + + $cocktailImage = Image::factory()->for(Cocktail::factory(), 'imageable')->create([ + 'file_path' => $imageFile->storeAs('temp', 'image1.jpg', 'app_images'), + 'file_extension' => $imageFile->extension() + ]); + + $response = $this->get('/api/images/' . $cocktailImage->id . '/thumb'); + + $response->assertOk(); + } }