Browse Source

Initial commit

master
siinus 2 years ago
commit
1abf940681
  1. 23
      .env
  2. 10
      .gitignore
  3. 13
      LICENSE
  4. 31
      README.md
  5. 17
      bin/console
  6. 79
      composer.json
  7. 5231
      composer.lock
  8. 9
      config/bundles.php
  9. 19
      config/packages/cache.yaml
  10. 6
      config/packages/dev/web_profiler.yaml
  11. 17
      config/packages/doctrine.yaml
  12. 6
      config/packages/doctrine_migrations.yaml
  13. 24
      config/packages/framework.yaml
  14. 17
      config/packages/prod/doctrine.yaml
  15. 12
      config/packages/routing.yaml
  16. 4
      config/packages/test/doctrine.yaml
  17. 3
      config/packages/test/validator.yaml
  18. 6
      config/packages/test/web_profiler.yaml
  19. 6
      config/packages/twig.yaml
  20. 8
      config/packages/validator.yaml
  21. 5
      config/preload.php
  22. 7
      config/routes.yaml
  23. 7
      config/routes/annotations.yaml
  24. 7
      config/routes/dev/web_profiler.yaml
  25. 4
      config/routes/framework.yaml
  26. 30
      config/services.yaml
  27. 42
      docker-compose.yml
  28. 26
      docker/nginx/default.conf
  29. 9
      docker/php/Dockerfile
  30. 0
      migrations/.gitignore
  31. 31
      migrations/Version20220129073043.php
  32. BIN
      public/favicon.ico
  33. 9
      public/index.php
  34. 0
      src/Controller/.gitignore
  35. 91
      src/Controller/LyhendiController.php
  36. 0
      src/Entity/.gitignore
  37. 69
      src/Entity/Url.php
  38. 11
      src/Exception/InvalidHashException.php
  39. 33
      src/Form/Type/LyhendiType.php
  40. 42
      src/Hasher.php
  41. 11
      src/Kernel.php
  42. 0
      src/Repository/.gitignore
  43. 43
      src/Repository/UrlRepository.php
  44. 29
      src/Twig/HasherExtension.php
  45. 17
      src/Validator/CustomStringNeverCollides.php
  46. 41
      src/Validator/CustomStringNeverCollidesValidator.php
  47. 301
      symfony.lock
  48. 18
      templates/base.html.twig
  49. 9
      templates/index.html.twig

23
.env

@ -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"

10
.gitignore vendored

@ -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 ###

13
LICENSE

@ -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.

31
README.md

@ -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*

17
bin/console

@ -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);
};

79
composer.json

@ -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.*"
}
}

5231
composer.lock generated

File diff suppressed because it is too large Load Diff

9
config/bundles.php

@ -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],
];

19
config/packages/cache.yaml

@ -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

6
config/packages/dev/web_profiler.yaml

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
web_profiler:
toolbar: true
intercept_redirects: false
framework:
profiler: { only_exceptions: false }

17
config/packages/doctrine.yaml

@ -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

6
config/packages/doctrine_migrations.yaml

@ -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%'

24
config/packages/framework.yaml

@ -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

17
config/packages/prod/doctrine.yaml

@ -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

12
config/packages/routing.yaml

@ -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

4
config/packages/test/doctrine.yaml

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'

3
config/packages/test/validator.yaml

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
framework:
validation:
not_compromised_password: false

6
config/packages/test/web_profiler.yaml

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
web_profiler:
toolbar: false
intercept_redirects: false
framework:
profiler: { collect: false }

6
config/packages/twig.yaml

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
twig:
default_path: '%kernel.project_dir%/templates'
when@test:
twig:
strict_variables: true

8
config/packages/validator.yaml

@ -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\: []

5
config/preload.php

@ -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';
}

7
config/routes.yaml

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
controllers:
resource: ../src/Controller/
type: annotation
kernel:
resource: ../src/Kernel.php
type: annotation

7
config/routes/annotations.yaml

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
controllers:
resource: ../../src/Controller/
type: annotation
kernel:
resource: ../../src/Kernel.php
type: annotation

7
config/routes/dev/web_profiler.yaml

@ -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

4
config/routes/framework.yaml

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error

30
config/services.yaml

@ -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%'

42
docker-compose.yml

@ -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:

26
docker/nginx/default.conf

@ -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;
}

9
docker/php/Dockerfile

@ -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
migrations/.gitignore vendored

31
migrations/Version20220129073043.php

@ -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');
}
}

BIN
public/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

9
public/index.php

@ -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
src/Controller/.gitignore vendored

91
src/Controller/LyhendiController.php

@ -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
src/Entity/.gitignore vendored

69
src/Entity/Url.php

@ -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;
}
}

11
src/Exception/InvalidHashException.php

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use RuntimeException;
class InvalidHashException extends RuntimeException
{
}

33
src/Form/Type/LyhendiType.php

@ -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,
]);
}
}

42
src/Hasher.php

@ -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;
}
}

11
src/Kernel.php

@ -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
src/Repository/.gitignore vendored

43
src/Repository/UrlRepository.php

@ -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;
}
}
}

29
src/Twig/HasherExtension.php

@ -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());
}
}

17
src/Validator/CustomStringNeverCollides.php

@ -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.';
}

41
src/Validator/CustomStringNeverCollidesValidator.php

@ -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();
}
}
}

301
symfony.lock

@ -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"
}
}

18
templates/base.html.twig

@ -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>

9
templates/index.html.twig

@ -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…
Cancel
Save