siinus
3 years ago
commit
1abf940681
49 changed files with 6433 additions and 0 deletions
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
# In all environments, the following files are loaded if they exist, |
||||
# the latter taking precedence over the former: |
||||
# |
||||
# * .env contains default values for the environment variables needed by the app |
||||
# * .env.local uncommitted file with local overrides |
||||
# * .env.$APP_ENV committed environment-specific defaults |
||||
# * .env.$APP_ENV.local uncommitted environment-specific overrides |
||||
# |
||||
# Real environment variables win over .env files. |
||||
# |
||||
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. |
||||
# |
||||
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). |
||||
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration |
||||
|
||||
###> symfony/framework-bundle ### |
||||
APP_ENV=prod |
||||
APP_SECRET=28fae772fdc3e2610b7eb0c14f7c5291 |
||||
###< symfony/framework-bundle ### |
||||
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=mariadb-10.3.32" |
||||
|
||||
HASH_SALT="theQuickBrownFoxJumpsOverTheLazyDog" |
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
|
||||
###> symfony/framework-bundle ### |
||||
/.env.local |
||||
/.env.local.php |
||||
/.env.*.local |
||||
/config/secrets/prod/prod.decrypt.private.php |
||||
/public/bundles/ |
||||
/var/ |
||||
/vendor/ |
||||
###< symfony/framework-bundle ### |
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE |
||||
Version 2, December 2004 |
||||
|
||||
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net> |
||||
|
||||
Everyone is permitted to copy and distribute verbatim or modified |
||||
copies of this license document, and changing it is allowed as long |
||||
as the name is changed. |
||||
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE |
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION |
||||
|
||||
0. You just DO WHAT THE FUCK YOU WANT TO. |
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
# Lyhendi |
||||
|
||||
Demo/PoC app doing url shortening. Allows user to insert long url and (optionally) custom string. |
||||
|
||||
Generated url strings are based on auto increment id keys and collisions are *unlikely*. |
||||
|
||||
### Obviously not for production use |
||||
|
||||
- No authentication |
||||
- No administration |
||||
- No captcha |
||||
- Not tested by competent QA engineer |
||||
- Index page displays the last url shortened, for demo purposes |
||||
- Does not redirect to target url. For demo, we don't need it |
||||
|
||||
### Futureproof |
||||
|
||||
- Relatively fresh platform (PHP8.1, MariaDB 10.3, Symfony 6.0) |
||||
- Lightweight and ready for further development |
||||
- Easy to add features (auth, admin, design etc...) |
||||
- Internet is full of unreasonably lengthy urls, which should be shortened |
||||
|
||||
## Setup |
||||
|
||||
After cloning, cd to project dir and run ```docker-compose up -d``` |
||||
|
||||
Assume that the port 8080 is not in use, application should be available at http://localhost:8080 |
||||
|
||||
One can switch `APP_ENV` value to `dev` and enjoy the almighty Symfony debug bar and profiler. |
||||
|
||||
*Developed and tested only on linux machine, YMMV* |
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env php |
||||
<?php |
||||
|
||||
use App\Kernel; |
||||
use Symfony\Bundle\FrameworkBundle\Console\Application; |
||||
|
||||
if (!is_file(dirname(__DIR__) . '/vendor/autoload_runtime.php')) { |
||||
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".'); |
||||
} |
||||
|
||||
require_once dirname(__DIR__) . '/vendor/autoload_runtime.php'; |
||||
|
||||
return function (array $context) { |
||||
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); |
||||
|
||||
return new Application($kernel); |
||||
}; |
@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
|
||||
{ |
||||
"type": "project", |
||||
"license": "proprietary", |
||||
"minimum-stability": "stable", |
||||
"prefer-stable": true, |
||||
"require": { |
||||
"php": ">=8.1.2", |
||||
"ext-ctype": "*", |
||||
"ext-iconv": "*", |
||||
"doctrine/doctrine-bundle": "^2.5", |
||||
"doctrine/doctrine-migrations-bundle": "^3.2", |
||||
"doctrine/orm": "^2.11", |
||||
"hashids/hashids": "^4.1", |
||||
"symfony/console": "6.0.*", |
||||
"symfony/dotenv": "6.0.*", |
||||
"symfony/flex": "^2", |
||||
"symfony/form": "6.0.*", |
||||
"symfony/framework-bundle": "6.0.*", |
||||
"symfony/proxy-manager-bridge": "6.0.*", |
||||
"symfony/runtime": "6.0.*", |
||||
"symfony/validator": "6.0.*", |
||||
"symfony/yaml": "6.0.*" |
||||
}, |
||||
"config": { |
||||
"allow-plugins": { |
||||
"composer/package-versions-deprecated": true, |
||||
"symfony/flex": true, |
||||
"symfony/runtime": true |
||||
}, |
||||
"optimize-autoloader": true, |
||||
"preferred-install": { |
||||
"*": "dist" |
||||
}, |
||||
"sort-packages": true |
||||
}, |
||||
"autoload": { |
||||
"psr-4": { |
||||
"App\\": "src/" |
||||
} |
||||
}, |
||||
"autoload-dev": { |
||||
"psr-4": { |
||||
"App\\Tests\\": "tests/" |
||||
} |
||||
}, |
||||
"replace": { |
||||
"symfony/polyfill-ctype": "*", |
||||
"symfony/polyfill-iconv": "*", |
||||
"symfony/polyfill-php72": "*", |
||||
"symfony/polyfill-php73": "*", |
||||
"symfony/polyfill-php74": "*", |
||||
"symfony/polyfill-php80": "*" |
||||
}, |
||||
"scripts": { |
||||
"auto-scripts": { |
||||
"cache:clear": "symfony-cmd", |
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd" |
||||
}, |
||||
"post-install-cmd": [ |
||||
"@auto-scripts" |
||||
], |
||||
"post-update-cmd": [ |
||||
"@auto-scripts" |
||||
] |
||||
}, |
||||
"conflict": { |
||||
"symfony/symfony": "*" |
||||
}, |
||||
"extra": { |
||||
"symfony": { |
||||
"allow-contrib": false, |
||||
"require": "6.0.*" |
||||
} |
||||
}, |
||||
"require-dev": { |
||||
"symfony/stopwatch": "6.0.*", |
||||
"symfony/web-profiler-bundle": "6.0.*" |
||||
} |
||||
} |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
<?php |
||||
|
||||
return [ |
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], |
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], |
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], |
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], |
||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], |
||||
]; |
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
framework: |
||||
cache: |
||||
# Unique name of your app: used to compute stable namespaces for cache keys. |
||||
#prefix_seed: your_vendor_name/app_name |
||||
|
||||
# The "app" cache stores to the filesystem by default. |
||||
# The data in this cache should persist between deploys. |
||||
# Other options include: |
||||
|
||||
# Redis |
||||
#app: cache.adapter.redis |
||||
#default_redis_provider: redis://localhost |
||||
|
||||
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) |
||||
#app: cache.adapter.apcu |
||||
|
||||
# Namespaced pools use the above "app" backend by default |
||||
#pools: |
||||
#my.dedicated.cache: null |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
web_profiler: |
||||
toolbar: true |
||||
intercept_redirects: false |
||||
|
||||
framework: |
||||
profiler: { only_exceptions: false } |
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
doctrine: |
||||
dbal: |
||||
url: '%env(resolve:DATABASE_URL)%' |
||||
|
||||
# IMPORTANT: You MUST configure your server version, |
||||
# either here or in the DATABASE_URL env var (see .env file) |
||||
#server_version: '13' |
||||
orm: |
||||
auto_generate_proxy_classes: true |
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware |
||||
auto_mapping: true |
||||
mappings: |
||||
App: |
||||
is_bundle: false |
||||
dir: '%kernel.project_dir%/src/Entity' |
||||
prefix: 'App\Entity' |
||||
alias: App |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
doctrine_migrations: |
||||
migrations_paths: |
||||
# namespace is arbitrary but should be different from App\Migrations |
||||
# as migrations classes should NOT be autoloaded |
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations' |
||||
enable_profiler: '%kernel.debug%' |
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html |
||||
framework: |
||||
secret: '%env(APP_SECRET)%' |
||||
#csrf_protection: true |
||||
http_method_override: false |
||||
|
||||
# Enables session support. Note that the session will ONLY be started if you read or write from it. |
||||
# Remove or comment this section to explicitly disable session support. |
||||
session: |
||||
handler_id: null |
||||
cookie_secure: auto |
||||
cookie_samesite: lax |
||||
storage_factory_id: session.storage.factory.native |
||||
|
||||
#esi: true |
||||
#fragments: true |
||||
php_errors: |
||||
log: true |
||||
|
||||
when@test: |
||||
framework: |
||||
test: true |
||||
session: |
||||
storage_factory_id: session.storage.factory.mock_file |
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
doctrine: |
||||
orm: |
||||
auto_generate_proxy_classes: false |
||||
query_cache_driver: |
||||
type: pool |
||||
pool: doctrine.system_cache_pool |
||||
result_cache_driver: |
||||
type: pool |
||||
pool: doctrine.result_cache_pool |
||||
|
||||
framework: |
||||
cache: |
||||
pools: |
||||
doctrine.result_cache_pool: |
||||
adapter: cache.app |
||||
doctrine.system_cache_pool: |
||||
adapter: cache.system |
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
framework: |
||||
router: |
||||
utf8: true |
||||
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands. |
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands |
||||
#default_uri: http://localhost |
||||
|
||||
when@prod: |
||||
framework: |
||||
router: |
||||
strict_requirements: null |
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
doctrine: |
||||
dbal: |
||||
# "TEST_TOKEN" is typically set by ParaTest |
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%' |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
framework: |
||||
validation: |
||||
not_compromised_password: false |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
web_profiler: |
||||
toolbar: false |
||||
intercept_redirects: false |
||||
|
||||
framework: |
||||
profiler: { collect: false } |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
twig: |
||||
default_path: '%kernel.project_dir%/templates' |
||||
|
||||
when@test: |
||||
twig: |
||||
strict_variables: true |
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
framework: |
||||
validation: |
||||
email_validation_mode: html5 |
||||
|
||||
# Enables validator auto-mapping support. |
||||
# For instance, basic validation constraints will be inferred from Doctrine's metadata. |
||||
#auto_mapping: |
||||
# App\Entity\: [] |
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
<?php |
||||
|
||||
if (file_exists(dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php')) { |
||||
require dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php'; |
||||
} |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
controllers: |
||||
resource: ../src/Controller/ |
||||
type: annotation |
||||
|
||||
kernel: |
||||
resource: ../src/Kernel.php |
||||
type: annotation |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
controllers: |
||||
resource: ../../src/Controller/ |
||||
type: annotation |
||||
|
||||
kernel: |
||||
resource: ../../src/Kernel.php |
||||
type: annotation |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
web_profiler_wdt: |
||||
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' |
||||
prefix: /_wdt |
||||
|
||||
web_profiler_profiler: |
||||
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' |
||||
prefix: /_profiler |
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
when@dev: |
||||
_errors: |
||||
resource: '@FrameworkBundle/Resources/config/routing/errors.xml' |
||||
prefix: /_error |
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
# This file is the entry point to configure your own services. |
||||
# Files in the packages/ subdirectory configure your dependencies. |
||||
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed |
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration |
||||
parameters: |
||||
container.dumper.inline_factories: true |
||||
hasher.salt: '%env(resolve:HASH_SALT)%' |
||||
|
||||
services: |
||||
# default configuration for services in *this* file |
||||
_defaults: |
||||
autowire: true # Automatically injects dependencies in your services. |
||||
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. |
||||
|
||||
# makes classes in src/ available to be used as services |
||||
# this creates a service per class whose id is the fully-qualified class name |
||||
App\: |
||||
resource: '../src/' |
||||
exclude: |
||||
- '../src/DependencyInjection/' |
||||
- '../src/Entity/' |
||||
- '../src/Kernel.php' |
||||
|
||||
# add more service definitions when explicit configuration is needed |
||||
# please note that last definitions always *replace* previous ones |
||||
|
||||
App\Hasher: |
||||
arguments: |
||||
$salt: '%hasher.salt%' |
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
version: '3' |
||||
services: |
||||
# Nginx server configuration |
||||
web: |
||||
image: nginx:1.21.6 |
||||
depends_on: |
||||
- db |
||||
- php |
||||
links: |
||||
- db |
||||
- php |
||||
volumes: |
||||
- .:/var/www/html |
||||
- ./docker/nginx:/etc/nginx/conf.d/ |
||||
ports: |
||||
- "8080:80" |
||||
# Php-fpm configuration |
||||
php: |
||||
build: |
||||
context: . |
||||
dockerfile: docker/php/Dockerfile |
||||
volumes: |
||||
- .:/var/www/html |
||||
- ./docker/php:/usr/local/etc/php/php.ini |
||||
depends_on: |
||||
- db |
||||
# MariaDB configuration |
||||
db: |
||||
image: mariadb:10.3.32 |
||||
restart: always |
||||
environment: |
||||
- MYSQL_DATABASE=db |
||||
- MYSQL_ROOT_PASSWORD=db |
||||
- MYSQL_USER=db |
||||
- MYSQL_PASSWORD=db |
||||
ports: |
||||
- "3306:3306" |
||||
volumes: |
||||
- /dbdata:/var/lib/mysql |
||||
|
||||
volumes: |
||||
dbdata: |
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
server { |
||||
server_name _; |
||||
root /var/www/html/public; |
||||
fastcgi_buffers 16 16k; |
||||
fastcgi_buffer_size 32k; |
||||
|
||||
location / { |
||||
try_files $uri /index.php$is_args$args; |
||||
} |
||||
# PHP-FPM Configuration Nginx |
||||
location ~ ^/index\.php(/|$) { |
||||
try_files $uri = 404; |
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$; |
||||
fastcgi_pass php:9000; |
||||
include fastcgi_params; |
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; |
||||
fastcgi_param DOCUMENT_ROOT $realpath_root; |
||||
} |
||||
|
||||
location ~ \.php$ { |
||||
return 404; |
||||
} |
||||
|
||||
access_log /var/log/nginx/access.log; |
||||
error_log /var/log/nginx/error.log; |
||||
} |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
FROM php:8.1.2-fpm |
||||
|
||||
RUN apt-get update && apt-get install -y unzip \ |
||||
&& docker-php-ext-install opcache pdo pdo_mysql bcmath |
||||
|
||||
# Install Composer |
||||
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer |
||||
|
||||
CMD composer install ; bin/console doctrine:migrations:migrate ; php-fpm |
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace DoctrineMigrations; |
||||
|
||||
use Doctrine\DBAL\Schema\Schema; |
||||
use Doctrine\Migrations\AbstractMigration; |
||||
|
||||
/** |
||||
* Auto-generated Migration: Please modify to your needs! |
||||
*/ |
||||
final class Version20220129073043 extends AbstractMigration |
||||
{ |
||||
public function getDescription(): string |
||||
{ |
||||
return ''; |
||||
} |
||||
|
||||
public function up(Schema $schema): void |
||||
{ |
||||
// this up() migration is auto-generated, please modify it to your needs |
||||
$this->addSql('CREATE TABLE url (id INT AUTO_INCREMENT NOT NULL, long_url LONGTEXT NOT NULL, custom_string VARCHAR(11) DEFAULT NULL, UNIQUE INDEX UNIQ_F47645AEAA1366AD (custom_string), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); |
||||
} |
||||
|
||||
public function down(Schema $schema): void |
||||
{ |
||||
// this down() migration is auto-generated, please modify it to your needs |
||||
$this->addSql('DROP TABLE url'); |
||||
} |
||||
} |
After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
<?php |
||||
|
||||
use App\Kernel; |
||||
|
||||
require_once dirname(__DIR__) . '/vendor/autoload_runtime.php'; |
||||
|
||||
return function (array $context) { |
||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); |
||||
}; |
@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller; |
||||
|
||||
use App\Entity\Url; |
||||
use App\Form\Type\LyhendiType; |
||||
use App\Hasher; |
||||
use App\Repository\UrlRepository; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Doctrine\Persistence\ManagerRegistry; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\Form\FormInterface; |
||||
use Symfony\Component\HttpFoundation\RedirectResponse; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Annotation\Route; |
||||
|
||||
final class LyhendiController extends AbstractController |
||||
{ |
||||
public function __construct(private ManagerRegistry $managerRegistry, private Hasher $hasher) |
||||
{ |
||||
} |
||||
|
||||
/** |
||||
* @Route("/", methods={"GET"}) |
||||
*/ |
||||
public function index(): Response |
||||
{ |
||||
$form = $this->createLyhendiForm(); |
||||
$lastResult = $this->getRepository()->findOneBy([], ['id' => 'DESC']); |
||||
return $this->renderForm('index.html.twig', ['form' => $form, 'last' => $lastResult]); |
||||
} |
||||
|
||||
private function createLyhendiForm(): FormInterface |
||||
{ |
||||
return $this->createForm(LyhendiType::class); |
||||
} |
||||
|
||||
private function getRepository(): UrlRepository |
||||
{ |
||||
/** @noinspection PhpIncompatibleReturnTypeInspection */ |
||||
return $this->getEntityManager()->getRepository(Url::class); |
||||
} |
||||
|
||||
private function getEntityManager(): EntityManagerInterface |
||||
{ |
||||
/** @noinspection PhpIncompatibleReturnTypeInspection */ |
||||
return $this->managerRegistry->getManagerForClass(Url::class); |
||||
} |
||||
|
||||
/** |
||||
* @Route("/", methods={"POST"}) |
||||
*/ |
||||
public function insert(Request $request): Response |
||||
{ |
||||
$form = $this->createForm(LyhendiType::class); |
||||
$form->handleRequest($request); |
||||
if (!$form->isSubmitted()) { |
||||
return $this->getRedirectToHome(); |
||||
} |
||||
if ($form->isValid()) { |
||||
/** @var Url $url */ |
||||
$url = $form->getData(); |
||||
$manager = $this->getEntityManager(); |
||||
$manager->persist($url); |
||||
$manager->flush(); |
||||
$publicString = $url->getCustomString() ?? $this->hasher->encode($url->getId()); |
||||
return $this->redirectToRoute('app_lyhendi_get', ['string' => $publicString]); |
||||
} |
||||
return $this->renderForm('index.html.twig', ['form' => $form, 'last' => null]); |
||||
} |
||||
|
||||
private function getRedirectToHome(): RedirectResponse |
||||
{ |
||||
return $this->redirectToRoute('app_lyhendi_index'); |
||||
} |
||||
|
||||
/** |
||||
* @Route("/{string}", methods={"GET"}) |
||||
*/ |
||||
public function get(string $string): Response |
||||
{ |
||||
$url = $this->getRepository()->findOneByAny($string); |
||||
if ($url === null) { |
||||
return $this->getRedirectToHome(); |
||||
} |
||||
return new Response($url->getLongUrl()); |
||||
} |
||||
} |
@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Entity; |
||||
|
||||
use App\Validator as AppAssert; |
||||
use Doctrine\ORM\Mapping as ORM; |
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; |
||||
use Symfony\Component\Validator\Constraints as Assert; |
||||
|
||||
/** |
||||
* @ORM\Entity(repositoryClass="App\Repository\UrlRepository") |
||||
* @UniqueEntity("customString") |
||||
*/ |
||||
class Url |
||||
{ |
||||
/** |
||||
* @ORM\Id |
||||
* @ORM\GeneratedValue |
||||
* @ORM\Column(type="integer") |
||||
*/ |
||||
private ?int $id; |
||||
|
||||
/** |
||||
* @ORM\Column(type="text") |
||||
* @Assert\NotBlank |
||||
* @Assert\Url |
||||
*/ |
||||
private ?string $longUrl; |
||||
|
||||
/** |
||||
* @ORM\Column(type="string", length=11, nullable=true, unique=true) |
||||
* @Assert\Length(min=5, max=11) |
||||
* @AppAssert\CustomStringNeverCollides |
||||
*/ |
||||
private ?string $customString = null; |
||||
|
||||
public function getId(): int |
||||
{ |
||||
return $this->id; |
||||
} |
||||
|
||||
public function getLongUrl(): string |
||||
{ |
||||
return $this->longUrl; |
||||
} |
||||
|
||||
/** |
||||
* @param string|null $longUrl |
||||
* @return Url |
||||
*/ |
||||
public function setLongUrl(?string $longUrl): Url |
||||
{ |
||||
$this->longUrl = $longUrl; |
||||
return $this; |
||||
} |
||||
|
||||
public function getCustomString(): ?string |
||||
{ |
||||
return $this->customString; |
||||
} |
||||
|
||||
public function setCustomString(?string $customString): Url |
||||
{ |
||||
$this->customString = $customString; |
||||
return $this; |
||||
} |
||||
} |
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Exception; |
||||
|
||||
use RuntimeException; |
||||
|
||||
class InvalidHashException extends RuntimeException |
||||
{ |
||||
} |
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Form\Type; |
||||
|
||||
use App\Entity\Url; |
||||
use Symfony\Component\Form\AbstractType; |
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType; |
||||
use Symfony\Component\Form\Extension\Core\Type\TextType; |
||||
use Symfony\Component\Form\FormBuilderInterface; |
||||
use Symfony\Component\OptionsResolver\OptionsResolver; |
||||
|
||||
class LyhendiType extends AbstractType |
||||
{ |
||||
public function buildForm(FormBuilderInterface $builder, array $options): void |
||||
{ |
||||
$builder |
||||
->add('longUrl', TextType::class) |
||||
->add('customString', TextType::class, [ |
||||
'required' => false, |
||||
]) |
||||
->add('submit', SubmitType::class); |
||||
} |
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void |
||||
{ |
||||
$resolver->setDefaults([ |
||||
'data_class' => Url::class, |
||||
'csrf_protection' => true, |
||||
]); |
||||
} |
||||
} |
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App; |
||||
|
||||
use App\Exception\InvalidHashException; |
||||
use Hashids\Hashids; |
||||
use Hashids\HashidsInterface; |
||||
|
||||
class Hasher |
||||
{ |
||||
private HashidsInterface $hashids; |
||||
|
||||
public function __construct(?string $salt = null) |
||||
{ |
||||
$salt = $salt ?? 'default_salt'; |
||||
$this->hashids = new Hashids($salt, 11); |
||||
} |
||||
|
||||
public function encode(int $id): string |
||||
{ |
||||
return $this->hashids->encode($id); |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidHashException |
||||
*/ |
||||
public function decode(string $hash): int |
||||
{ |
||||
$decoded = $this->hashids->decode($hash); |
||||
if (count($decoded) === 0) { |
||||
throw new InvalidHashException('Hash not valid'); |
||||
} |
||||
return $decoded[0]; |
||||
} |
||||
|
||||
public function isValid(string $hash): bool |
||||
{ |
||||
return count($this->hashids->decode($hash)) !== 0; |
||||
} |
||||
} |
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
<?php |
||||
|
||||
namespace App; |
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; |
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel; |
||||
|
||||
class Kernel extends BaseKernel |
||||
{ |
||||
use MicroKernelTrait; |
||||
} |
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Repository; |
||||
|
||||
use App\Entity\Url; |
||||
use App\Exception\InvalidHashException; |
||||
use App\Hasher; |
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; |
||||
use Doctrine\Persistence\ManagerRegistry; |
||||
|
||||
final class UrlRepository extends ServiceEntityRepository |
||||
{ |
||||
public function __construct(ManagerRegistry $registry, private Hasher $hasher) |
||||
{ |
||||
parent::__construct($registry, Url::class); |
||||
} |
||||
|
||||
public function findOneByAny(string $string): ?Url |
||||
{ |
||||
$found = $this->findOneByCustomString($string); |
||||
if ($found === null) { |
||||
$found = $this->findOneByHash($string); |
||||
} |
||||
return $found; |
||||
} |
||||
|
||||
public function findOneByCustomString(string $customString): ?Url |
||||
{ |
||||
return $this->findOneBy(['customString' => $customString]); |
||||
} |
||||
|
||||
public function findOneByHash(string $hash): ?Url |
||||
{ |
||||
try { |
||||
$id = $this->hasher->decode($hash); |
||||
return $this->find($id); |
||||
} catch (InvalidHashException $e) { |
||||
return null; |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Twig; |
||||
|
||||
use App\Entity\Url; |
||||
use App\Hasher; |
||||
use Twig\Extension\AbstractExtension; |
||||
use Twig\TwigFunction; |
||||
|
||||
class HasherExtension extends AbstractExtension |
||||
{ |
||||
public function __construct(private Hasher $hasher) |
||||
{ |
||||
} |
||||
|
||||
public function getFunctions() |
||||
{ |
||||
return [ |
||||
new TwigFunction('hasher_encode', [$this, 'encode']), |
||||
]; |
||||
} |
||||
|
||||
public function encode(Url $url): string |
||||
{ |
||||
return $url->getCustomString() ?? $this->hasher->encode($url->getId()); |
||||
} |
||||
} |
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Validator; |
||||
|
||||
use Symfony\Component\Validator\Constraint; |
||||
|
||||
/** |
||||
* @Annotation |
||||
* @Target({"PROPERTY", "METHOD", "ANNOTATION"}) |
||||
*/ |
||||
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] |
||||
class CustomStringNeverCollides extends Constraint |
||||
{ |
||||
public string $message = 'This value already exists, or is reserved.'; |
||||
} |
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Validator; |
||||
|
||||
use App\Hasher; |
||||
use Symfony\Component\Validator\Constraint; |
||||
use Symfony\Component\Validator\ConstraintValidator; |
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException; |
||||
use Symfony\Component\Validator\Exception\UnexpectedValueException; |
||||
|
||||
final class CustomStringNeverCollidesValidator extends ConstraintValidator |
||||
{ |
||||
public function __construct(private Hasher $hasher) |
||||
{ |
||||
} |
||||
|
||||
/** |
||||
* @inheritDoc |
||||
*/ |
||||
public function validate(mixed $value, Constraint $constraint): void |
||||
{ |
||||
if (!$constraint instanceof CustomStringNeverCollides) { |
||||
throw new UnexpectedTypeException($constraint, CustomStringNeverCollides::class); |
||||
} |
||||
|
||||
if (null === $value || '' === $value) { |
||||
return; |
||||
} |
||||
|
||||
if (!is_string($value)) { |
||||
throw new UnexpectedValueException($value, 'string'); |
||||
} |
||||
|
||||
/** Can be decoded to id, so avoid it */ |
||||
if ($this->hasher->isValid($value)) { |
||||
$this->context->buildViolation($constraint->message)->addViolation(); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,301 @@
@@ -0,0 +1,301 @@
|
||||
{ |
||||
"doctrine/annotations": { |
||||
"version": "1.13", |
||||
"recipe": { |
||||
"repo": "github.com/symfony/recipes", |
||||
"branch": "master", |
||||
"version": "1.0", |
||||
"ref": "a2759dd6123694c8d901d0ec80006e044c2e6457" |
||||
}, |
||||
"files": [ |
||||
"config/routes/annotations.yaml" |
||||
] |
||||
}, |
||||
"doctrine/cache": { |
||||
"version": "2.1.1" |
||||
}, |
||||
"doctrine/collections": { |
||||
"version": "1.6.8" |
||||
}, |
||||
"doctrine/common": { |
||||
"version": "3.2.1" |
||||
}, |
||||
"doctrine/dbal": { |
||||
"version": "3.3.0" |
||||
}, |
||||
"doctrine/deprecations": { |
||||
"version": "v0.5.3" |
||||
}, |
||||
"doctrine/doctrine-bundle": { |
||||
"version": "2.5", |
||||
"recipe": { |
||||
"repo": "github.com/symfony/recipes", |
||||
"branch": "master", |
||||
"version": "2.4", |
||||
"ref": "f98f1affe028f8153a459d15f220ada3826b5aa2" |
||||
}, |
||||
"files": [ |
||||
"config/packages/doctrine.yaml", |
||||
"config/packages/prod/doctrine.yaml", |
||||
"config/packages/test/doctrine.yaml", |
||||
"src/Entity/.gitignore", |
||||
"src/Repository/.gitignore" |
||||
] |
||||
}, |
||||
"doctrine/doctrine-migrations-bundle": { |
||||
"version": "3.2", |
||||
"recipe": { |
||||
"repo": "github.com/symfony/recipes", |
||||
"branch": "master", |
||||
"version": "3.1", |
||||
"ref": "ee609429c9ee23e22d6fa5728211768f51ed2818" |
||||
}, |
||||
"files": [ |
||||
"config/packages/doctrine_migrations.yaml", |
||||
"migrations/.gitignore" |
||||
] |
||||
}, |
||||
"doctrine/event-manager": { |
||||
"version": "1.1.1" |
||||
}, |
||||
"doctrine/inflector": { |
||||
"version": "2.0.4" |
||||
}, |
||||
"doctrine/instantiator": { |
||||
"version": "1.4.0" |
||||
}, |
||||
"doctrine/lexer": { |
||||
"version": "1.2.2" |
||||
}, |
||||
"doctrine/migrations": { |
||||
"version": "3.4.0" |
||||
}, |
||||
"doctrine/orm": { |
||||
"version": "2.11.0" |
||||
}, |
||||
"doctrine/persistence": { |
||||
"version": "2.3.0" |
||||
}, |
||||
"doctrine/sql-formatter": { |
||||
"version": "1.1.2" |
||||
}, |
||||
"friendsofphp/proxy-manager-lts": { |
||||
"version": "v1.0.5" |
||||
}, |
||||
"hashids/hashids": { |
||||
"version": "4.1.0" |
||||
}, |
||||
"laminas/laminas-code": { |
||||
"version": "4.5.1" |
||||
}, |
||||
"psr/cache": { |
||||
"version": "3.0.0" |
||||
}, |
||||
"psr/container": { |
||||
"version": "2.0.2" |
||||
}, |
||||
"psr/event-dispatcher": { |
||||
"version": "1.0.0" |
||||
}, |
||||
"psr/log": { |
||||
"version": "3.0.0" |
||||
}, |
||||
"symfony/cache": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/cache-contracts": { |
||||
"version": "v3.0.0" |
||||
}, |
||||
"symfony/config": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/console": { |
||||
"version": "6.0", |
||||
"recipe": { |
||||
"repo": "github.com/symfony/recipes", |
||||
"branch": "master", |
||||
"version": "5.3", |
||||
"ref": "da0c8be8157600ad34f10ff0c9cc91232522e047" |
||||
}, |
||||
"files": [ |
||||
"bin/console" |
||||
] |
||||
}, |
||||
"symfony/dependency-injection": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/deprecation-contracts": { |
||||
"version": "v3.0.0" |
||||
}, |
||||
"symfony/doctrine-bridge": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/dotenv": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/error-handler": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/event-dispatcher": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/event-dispatcher-contracts": { |
||||
"version": "v3.0.0" |
||||
}, |
||||
"symfony/filesystem": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/finder": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/flex": { |
||||
"version": "2.1", |
||||
"recipe": { |
||||
"repo": "github.com/symfony/recipes", |
||||
"branch": "master", |
||||
"version": "1.0", |
||||
"ref": "c0eeb50665f0f77226616b6038a9b06c03752d8e" |
||||
}, |
||||
"files": [ |
||||
".env" |
||||
] |
||||
}, |
||||
"symfony/form": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/framework-bundle": { |
||||
"version": "6.0", |
||||
"recipe": { |
||||
"repo": "github.com/symfony/recipes", |
||||
"branch": "master", |
||||
"version": "5.4", |
||||
"ref": "3cd216a4d007b78d8554d44a5b1c0a446dab24fb" |
||||
}, |
||||
"files": [ |
||||
"config/packages/cache.yaml", |
||||
"config/packages/framework.yaml", |
||||
"config/preload.php", |
||||
"config/routes/framework.yaml", |
||||
"config/services.yaml", |
||||
"public/index.php", |
||||
"src/Controller/.gitignore", |
||||
"src/Kernel.php" |
||||
] |
||||
}, |
||||
"symfony/http-foundation": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/http-kernel": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/options-resolver": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/polyfill-intl-grapheme": { |
||||
"version": "v1.24.0" |
||||
}, |
||||
"symfony/polyfill-intl-icu": { |
||||
"version": "v1.24.0" |
||||
}, |
||||
"symfony/polyfill-intl-normalizer": { |
||||
"version": "v1.24.0" |
||||
}, |
||||
"symfony/polyfill-mbstring": { |
||||
"version": "v1.24.0" |
||||
}, |
||||
"symfony/polyfill-php81": { |
||||
"version": "v1.24.0" |
||||
}, |
||||
"symfony/property-access": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/property-info": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/proxy-manager-bridge": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/routing": { |
||||
"version": "6.0", |
||||
"recipe": { |
||||
"repo": "github.com/symfony/recipes", |
||||
"branch": "master", |
||||
"version": "6.0", |
||||
"ref": "ab9ad892b7bba7ac584f6dc2ccdb659d358c63c5" |
||||
}, |
||||
"files": [ |
||||
"config/packages/routing.yaml", |
||||
"config/routes.yaml" |
||||
] |
||||
}, |
||||
"symfony/runtime": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/service-contracts": { |
||||
"version": "v3.0.0" |
||||
}, |
||||
"symfony/stopwatch": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/string": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/translation-contracts": { |
||||
"version": "v3.0.0" |
||||
}, |
||||
"symfony/twig-bridge": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/twig-bundle": { |
||||
"version": "6.0", |
||||
"recipe": { |
||||
"repo": "github.com/symfony/recipes", |
||||
"branch": "master", |
||||
"version": "5.4", |
||||
"ref": "bffbb8f1a849736e64006735afae730cb428b6ff" |
||||
}, |
||||
"files": [ |
||||
"config/packages/twig.yaml", |
||||
"templates/base.html.twig" |
||||
] |
||||
}, |
||||
"symfony/validator": { |
||||
"version": "6.0", |
||||
"recipe": { |
||||
"repo": "github.com/symfony/recipes", |
||||
"branch": "master", |
||||
"version": "4.3", |
||||
"ref": "3eb8df139ec05414489d55b97603c5f6ca0c44cb" |
||||
}, |
||||
"files": [ |
||||
"config/packages/test/validator.yaml", |
||||
"config/packages/validator.yaml" |
||||
] |
||||
}, |
||||
"symfony/var-dumper": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/var-exporter": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"symfony/web-profiler-bundle": { |
||||
"version": "6.0", |
||||
"recipe": { |
||||
"repo": "github.com/symfony/recipes", |
||||
"branch": "master", |
||||
"version": "3.3", |
||||
"ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6" |
||||
}, |
||||
"files": [ |
||||
"config/packages/dev/web_profiler.yaml", |
||||
"config/packages/test/web_profiler.yaml", |
||||
"config/routes/dev/web_profiler.yaml" |
||||
] |
||||
}, |
||||
"symfony/yaml": { |
||||
"version": "v6.0.3" |
||||
}, |
||||
"twig/twig": { |
||||
"version": "v3.3.7" |
||||
} |
||||
} |
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html> |
||||
<html> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<title>{% block title %}Welcome!{% endblock %}</title> |
||||
{# Run `composer require symfony/webpack-encore-bundle` to start using Symfony UX #} |
||||
{% block stylesheets %} |
||||
{{ encore_entry_link_tags('app') }} |
||||
{% endblock %} |
||||
|
||||
{% block javascripts %} |
||||
{{ encore_entry_script_tags('app') }} |
||||
{% endblock %} |
||||
</head> |
||||
<body> |
||||
{% block body %}{% endblock %} |
||||
</body> |
||||
</html> |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
{% extends 'base.html.twig' %} |
||||
|
||||
{% block body %} |
||||
{{ form(form) }} |
||||
{% if last %} |
||||
<p>Last shortened url was: {{ url('app_lyhendi_get', {string: hasher_encode(last)}) }} |
||||
pointing {{ last.longUrl }}</p> |
||||
{% endif %} |
||||
{% endblock body %} |
Loading…
Reference in new issue