├── .env.dist ├── .gitignore ├── README.md ├── bin └── console ├── composer.json ├── composer.lock ├── config ├── bootstrap.php ├── bundles.php ├── packages │ ├── dev │ │ └── parameters.yml │ ├── doctrine.yml │ ├── framework.yaml │ └── security.yml ├── routes.yaml ├── routes │ ├── dev │ │ └── framework.yaml │ ├── notes.yml │ └── users.yml └── services.yaml ├── docker-compose.yml ├── docker ├── migration_and_fixtures │ ├── Dockerfile │ └── etc │ │ └── share │ │ └── entrypoint │ │ └── entrypoint.sh ├── nginx │ └── etc │ │ └── nginx │ │ ├── nginx.conf │ │ └── sites-enabled │ │ └── crudnotes.localhost.conf └── php-fpm │ └── Dockerfile ├── public └── index.php ├── src ├── Controller │ ├── AbstractController.php │ ├── NotesController.php │ └── UsersController.php ├── Entity │ ├── Note.php │ ├── Share.php │ └── User.php ├── Fixture │ ├── NotesFixture.php │ ├── SharesFixture.php │ └── UsersFixture.php ├── Kernel.php ├── Migrations │ └── Version20200715184503.php ├── Repository │ ├── NotesRepository.php │ ├── SharesRepository.php │ └── UsersRepository.php └── Service │ ├── AbstractService.php │ ├── NotesService.php │ ├── SharesService.php │ └── UsersService.php ├── var └── .gitignore └── vendor └── .gitignore /.env.dist: -------------------------------------------------------------------------------- 1 | APP_SOURCE_ROOT=/var/www/html 2 | COMPOSER_HOME=/YOUR/HOME/.config/composer 3 | COMPOSER_CACHE_DIR=/YOUR/HOME/.cache/composer 4 | COMPOSER_VERSION=1.8.6 5 | MYSQL_USER=crudnotes 6 | MYSQL_ROOT_PASSWORD=root 7 | MYSQL_VERSION=5.7.19 8 | MYSQL_HOST=mysql -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # crud notes # 2 | ## install and run ## 3 | ### create .env from .dist ### 4 | replace `/YOUR/HOME/` with **static** path to you home dir 5 | ### run ### 6 | ```bash 7 | export _UID="$(id -u)" \ 8 | && export _GID="$(id -g)" \ 9 | && time docker-compose run --rm --no-deps --user="${_UID}:${_GID}" composer \ 10 | && time docker-compose run --rm --user="${_UID}:${_GID}" migration_and_fixtures \ 11 | && docker-compose up --remove-orphans nginx 12 | ``` 13 | [click me](http://crudnotes.localhost) or use api helpers 14 | ## api helpers ## 15 | ### users ### 16 | #### create user #### 17 | ```bash 18 | curl -s http://crudnotes.localhost/users \ 19 | --user admin:admin \ 20 | --data '{"username":"username","fullname":"fullname"}' \ 21 | --request POST 22 | ``` 23 | #### read user #### 24 | ```bash 25 | curl -s http://crudnotes.localhost/users/21 \ 26 | --user admin:admin \ 27 | --request GET 28 | ``` 29 | #### update user #### 30 | ```bash 31 | curl -s http://crudnotes.localhost/users/21 \ 32 | --user admin:admin \ 33 | --data '{"fullname":"James Bond"}' \ 34 | --request PUT 35 | ``` 36 | #### delete user #### 37 | ```bash 38 | curl -s http://crudnotes.localhost/users/21 \ 39 | --user admin:admin \ 40 | --request DELETE 41 | ``` 42 | #### list users #### 43 | ```bash 44 | curl -s http://crudnotes.localhost/users \ 45 | --user admin:admin \ 46 | --request GET 47 | ``` 48 | ### notes ### 49 | #### create note #### 50 | ```bash 51 | curl -s http://crudnotes.localhost/notes \ 52 | --user note:note \ 53 | --data '{"i_am":"username_1","title":"title","body":"body"}' \ 54 | --request POST 55 | ``` 56 | #### read note #### 57 | ```bash 58 | curl -s http://crudnotes.localhost/notes/21 \ 59 | --user note:note \ 60 | --data '{"i_am":"username_1"}' \ 61 | --request GET 62 | ``` 63 | #### update note #### 64 | ```bash 65 | curl -s http://crudnotes.localhost/notes/1 \ 66 | --user note:note \ 67 | --data '{"i_am":"username_1","title":"Foo Bar","body":"Eu non diam phasellus vestibulum lorem sed risus ultricies tristiqu"}' \ 68 | --request PUT 69 | # or by share write access 70 | curl -s http://crudnotes.localhost/notes/1 \ 71 | --user note:note \ 72 | --data '{"i_am":"username_12","title":"Foobar","body":"The etymology of foobar is generally traced to the World War II military slang FUBAR"}' \ 73 | --request PUT 74 | ``` 75 | #### delete note #### 76 | ```bash 77 | curl -s http://crudnotes.localhost/notes/21 \ 78 | --user note:note \ 79 | --data '{"i_am":"username"}' \ 80 | --request DELETE 81 | ``` 82 | #### list notes #### 83 | ```bash 84 | curl -s http://crudnotes.localhost/notes \ 85 | --user note:note \ 86 | --data '{"i_am":"username_1"}' \ 87 | --request GET 88 | ``` 89 | #### available notes #### 90 | notes shared for this user by read or write access 91 | ```bash 92 | curl -s http://crudnotes.localhost/notes/available \ 93 | --user note:note \ 94 | --data '{"i_am":"username_1","access":"read"}' \ 95 | --request GET 96 | ``` 97 | #### share note #### 98 | ```bash 99 | curl -s http://crudnotes.localhost/notes/1/share \ 100 | --user note:note \ 101 | --data '{"i_am":"username_1","access":"read","usernames":["username_3","username_4"]}' \ 102 | --request PUT 103 | ``` 104 | #### deshare note #### 105 | ```bash 106 | curl -s http://crudnotes.localhost/notes/1/share \ 107 | --user note:note \ 108 | --data '{"i_am":"username_1","access":"read","usernames":["username_3","username_4"]}' \ 109 | --request DELETE 110 | ``` 111 | ## mysql/docker helpers ## 112 | ### create ### 113 | ```bash 114 | ( \ 115 | export NAME=crudnotes && docker run \ 116 | -e MYSQL_ROOT_PASSWORD=root \ 117 | -e MYSQL_DATABASE=${NAME} \ 118 | -e MYSQL_USER=${NAME} \ 119 | -e MYSQL_PASSWORD=${NAME} \ 120 | --rm -d --name ${NAME} mysql:5.7.31 \ 121 | ) 122 | ``` 123 | ### find ip ### 124 | ```bash 125 | ( \ 126 | export NAME=crudnotes && \ 127 | echo $(docker inspect --format '{{ .NetworkSettings.IPAddress }}' ${NAME}) \ 128 | ) 129 | ``` 130 | ### connect ### 131 | ```bash 132 | ( \ 133 | export NAME=crudnotes && mysql \ 134 | -h $(docker inspect --format '{{ .NetworkSettings.IPAddress }}' ${NAME}) \ 135 | -u ${NAME} \ 136 | -n ${NAME} \ 137 | --password=${NAME} \ 138 | ) 139 | ``` 140 | ### recreate database and load fixtures ### 141 | ```bash 142 | bin/console doctrine:database:drop --if-exists -f \ 143 | && bin/console doctrine:database:create --if-not-exists \ 144 | && bin/console doctrine:migrations:migrate -n \ 145 | && bin/console doctrine:fixtures:load -n 146 | ``` 147 | ### create migration ### 148 | ```bash 149 | bin/console doctrine:migrations:diff --allow-empty-diff --line-length=120 --formatted -n 150 | ``` -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], null, true)) { 23 | putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env); 24 | } 25 | 26 | if ($input->hasParameterOption('--no-debug', true)) { 27 | putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0'); 28 | } 29 | 30 | require dirname(__DIR__).'/config/bootstrap.php'; 31 | 32 | if ($_SERVER['APP_DEBUG']) { 33 | umask(0000); 34 | 35 | if (class_exists(Debug::class)) { 36 | Debug::enable(); 37 | } 38 | } 39 | 40 | $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); 41 | $application = new Application($kernel); 42 | $application->run($input); 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "project", 3 | "license": "MIT", 4 | "require": { 5 | "php": "^7.2", 6 | "doctrine/annotations": "^1.10", 7 | "symfony/console": "^5.0", 8 | "symfony/dotenv": "^5.0", 9 | "symfony/framework-bundle": "^5.0", 10 | "symfony/serializer-pack": "^1.0", 11 | "symfony/security-bundle": "^5.0", 12 | "doctrine/doctrine-bundle": "^2.0", 13 | "symfony/yaml": "^5.0", 14 | "ext-json": "^7.2" 15 | }, 16 | "replace": { 17 | "symfony/polyfill-php73": "*" 18 | }, 19 | "require-dev": { 20 | "doctrine/doctrine-fixtures-bundle": "^3.0", 21 | "doctrine/doctrine-migrations-bundle": "^3.0", 22 | "jdorn/sql-formatter": "^1.0", 23 | "symfony/web-server-bundle": "^4.4", 24 | "symfony/debug-bundle": "^5.0" 25 | }, 26 | "config": { 27 | "optimize-autoloader": true, 28 | "preferred-install": { 29 | "*": "dist" 30 | }, 31 | "platform": { 32 | "php": "7.4.1" 33 | } 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "App\\": "src/" 38 | } 39 | }, 40 | "name": "hanovruslan/crudnotes", 41 | "description": "hanovruslan crudnotes", 42 | "scripts": { 43 | "test:platform": "@composer check-platform-reqs --no-interaction --no-plugins", 44 | "test:composer": [ 45 | "@composer update --no-interaction --no-plugins --no-suggest --no-scripts --no-autoloader --ignore-platform-reqs --no-progress nothing --lock", 46 | "@composer validate --no-interaction --no-plugins --strict --no-check-all --no-check-publish" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /config/bootstrap.php: -------------------------------------------------------------------------------- 1 | =1.2) 13 | if (is_array($env = @include dirname(__DIR__).'/.env.local.php') && (!isset($env['APP_ENV']) || ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? $env['APP_ENV']) === $env['APP_ENV'])) { 14 | (new Dotenv(false))->populate($env); 15 | } else { 16 | // load all the .env files 17 | (new Dotenv(false))->loadEnv(dirname(__DIR__).'/.env'); 18 | } 19 | 20 | $_SERVER += $_ENV; 21 | $_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev'; 22 | $_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV']; 23 | $_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0'; 24 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | \Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 6 | \Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 7 | \Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['dev' => true], 8 | \Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true], 9 | \Symfony\Bundle\WebServerBundle\WebServerBundle::class => ['dev' => true], 10 | \Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], 11 | ]; 12 | -------------------------------------------------------------------------------- /config/packages/dev/parameters.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | env(APP_SECRET): 'NotSoSecret' 3 | env(APP_ENV): 'dev' 4 | -------------------------------------------------------------------------------- /config/packages/doctrine.yml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | host: '%env(resolve:MYSQL_HOST)%' 4 | user: '%env(resolve:MYSQL_USER)%' 5 | dbname: '%env(resolve:MYSQL_USER)%' 6 | password: '%env(resolve:MYSQL_USER)%' 7 | charset: utf8mb4 8 | default_table_options: 9 | charset: utf8mb4 10 | collate: utf8mb4_unicode_ci 11 | orm: 12 | auto_generate_proxy_classes: true 13 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 14 | auto_mapping: true 15 | mappings: 16 | App: 17 | is_bundle: false 18 | type: annotation 19 | dir: '%kernel.project_dir%/src/Entity' 20 | prefix: 'App\Entity' 21 | alias: App 22 | doctrine_migrations: 23 | migrations_paths: 24 | 'App\Migrations': 'src/Migrations' 25 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | annotations: 3 | enabled: true 4 | serializer: 5 | enabled: true 6 | secret: '%env(APP_SECRET)%' 7 | session: 8 | enabled: false 9 | validation: 10 | enabled: false 11 | php_errors: 12 | log: true 13 | secrets: 14 | enabled: false 15 | -------------------------------------------------------------------------------- /config/packages/security.yml: -------------------------------------------------------------------------------- 1 | security: 2 | role_hierarchy: 3 | ROLE_ADMIN: ROLE_USER 4 | encoders: 5 | Symfony\Component\Security\Core\User\User: plaintext 6 | providers: 7 | in_memory: 8 | memory: 9 | users: 10 | note: 11 | password: note 12 | roles: 'ROLE_USER' 13 | admin: 14 | password: admin 15 | roles: 'ROLE_ADMIN' 16 | firewalls: 17 | main: 18 | http_basic: 19 | realm: Secured Area 20 | access_control: 21 | - { path: ^/users, roles: ROLE_ADMIN, methods: [GET, POST, PUT, DELETE]} 22 | - { path: ^/notes, roles: ROLE_USER, methods: [GET, POST, PUT, DELETE]} 23 | -------------------------------------------------------------------------------- /config/routes.yaml: -------------------------------------------------------------------------------- 1 | notes: 2 | resource: routes/notes.yml 3 | users: 4 | resource: routes/users.yml 5 | -------------------------------------------------------------------------------- /config/routes/dev/framework.yaml: -------------------------------------------------------------------------------- 1 | _errors: 2 | resource: '@FrameworkBundle/Resources/config/routing/errors.xml' 3 | prefix: /_error 4 | -------------------------------------------------------------------------------- /config/routes/notes.yml: -------------------------------------------------------------------------------- 1 | notes_list: 2 | controller: 'app.controller.notes::list' 3 | path: /notes 4 | format: json 5 | methods: GET 6 | notes_available: 7 | controller: 'app.controller.notes::available' 8 | path: /notes/available 9 | format: json 10 | methods: GET 11 | notes_create: 12 | controller: 'app.controller.notes::create' 13 | path: /notes 14 | format: json 15 | methods: POST 16 | notes_read: 17 | controller: 'app.controller.notes::read' 18 | path: /notes/{id} 19 | format: json 20 | methods: GET 21 | requirements: 22 | 'id': '\d+' 23 | notes_update: 24 | controller: 'app.controller.notes::update' 25 | path: /notes/{id} 26 | format: json 27 | methods: PUT 28 | requirements: 29 | 'id': '\d+' 30 | notes_delete: 31 | controller: 'app.controller.notes::delete' 32 | path: /notes/{id} 33 | format: json 34 | methods: DELETE 35 | requirements: 36 | 'id': '\d+' 37 | notes_share: 38 | controller: 'app.controller.notes::share' 39 | path: /notes/{id}/share 40 | format: json 41 | methods: PUT 42 | requirements: 43 | 'id': '\d+' 44 | notes_deshare: 45 | controller: 'app.controller.notes::deshare' 46 | path: /notes/{id}/share 47 | format: json 48 | methods: DELETE 49 | requirements: 50 | 'id': '\d+' 51 | -------------------------------------------------------------------------------- /config/routes/users.yml: -------------------------------------------------------------------------------- 1 | users_list: 2 | controller: 'app.controller.users::list' 3 | path: /users 4 | format: json 5 | methods: GET 6 | users_create: 7 | controller: 'app.controller.users::create' 8 | path: /users 9 | format: json 10 | methods: POST 11 | users_read: 12 | controller: 'app.controller.users::read' 13 | path: /users/{id} 14 | format: json 15 | methods: GET 16 | requirements: 17 | 'id': '\d+' 18 | users_update: 19 | controller: 'app.controller.users::update' 20 | path: /users/{id} 21 | format: json 22 | methods: PUT 23 | requirements: 24 | 'id': '\d+' 25 | users_delete: 26 | controller: 'app.controller.users::delete' 27 | path: /users/{id} 28 | format: json 29 | methods: DELETE 30 | requirements: 31 | 'id': '\d+' 32 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: false 4 | autoconfigure: false 5 | app.fixture.users: 6 | class: App\Fixture\UsersFixture 7 | tags: 8 | - doctrine.fixture.orm 9 | app.fixture.notes: 10 | class: App\Fixture\NotesFixture 11 | tags: 12 | - doctrine.fixture.orm 13 | app.fixture.shares: 14 | class: App\Fixture\SharesFixture 15 | tags: 16 | - doctrine.fixture.orm 17 | app.repository.notes: 18 | class: App\Repository\NotesRepository 19 | factory: 20 | - '@doctrine' 21 | - 'getRepository' 22 | arguments: 23 | - 'App\Entity\Note' 24 | app.repository.users: 25 | class: App\Repository\UsersRepository 26 | factory: 27 | - '@doctrine' 28 | - 'getRepository' 29 | arguments: 30 | - 'App\Entity\User' 31 | app.repository.shares: 32 | class: App\Repository\SharesRepository 33 | factory: 34 | - '@doctrine' 35 | - 'getRepository' 36 | arguments: 37 | - 'App\Entity\Share' 38 | app.service.notes: 39 | class: App\Service\NotesService 40 | arguments: 41 | - '@doctrine' 42 | app.service.users: 43 | class: App\Service\UsersService 44 | arguments: 45 | - '@doctrine' 46 | app.service.shares: 47 | class: App\Service\SharesService 48 | arguments: 49 | - '@doctrine' 50 | - '@app.repository.notes' 51 | - '@app.repository.users' 52 | app.controller.users: 53 | class: App\Controller\UsersController 54 | calls: 55 | - ['setContainer', ['@service_container']] 56 | - ['setUsersService', ['@app.service.users']] 57 | tags: 58 | - 'controller.service_arguments' 59 | - 'controller.service_subscriber' 60 | app.controller.notes: 61 | class: App\Controller\NotesController 62 | calls: 63 | - ['setContainer', ['@service_container']] 64 | - ['setUsersService', ['@app.service.users']] 65 | - ['setNotesService', ['@app.service.notes']] 66 | - ['setSharesService', ['@app.service.shares']] 67 | tags: 68 | - 'controller.service_arguments' 69 | - 'controller.service_subscriber' 70 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.0' 2 | services: 3 | composer: 4 | env_file: .env 5 | user: "$_UID:$_GID" 6 | network_mode: "host" 7 | image: "composer:$COMPOSER_VERSION" 8 | working_dir: "$APP_SOURCE_ROOT" 9 | environment: 10 | COMPOSER_HOME: "$COMPOSER_HOME" 11 | COMPOSER_CACHE_DIR: "$COMPOSER_CACHE_DIR" 12 | volumes: 13 | - "$COMPOSER_HOME:$COMPOSER_HOME" 14 | - "$COMPOSER_CACHE_DIR:$COMPOSER_CACHE_DIR" 15 | - "./composer.json:$APP_SOURCE_ROOT/composer.json" 16 | - "./vendor:$APP_SOURCE_ROOT/vendor" 17 | command: "composer install" 18 | mysql: 19 | image: "mysql:$MYSQL_VERSION" 20 | env_file: .env 21 | healthcheck: 22 | test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${MYSQL_USER}", "-p${MYSQL_USER}"] 23 | interval: 3s 24 | retries: 10 25 | timeout: 1s 26 | environment: 27 | MYSQL_DATABASE: "$MYSQL_USER" 28 | MYSQL_USER: "$MYSQL_USER" 29 | MYSQL_PASSWORD: "$MYSQL_USER" 30 | MYSQL_ROOT_PASSWORD: "$MYSQL_ROOT_PASSWORD" 31 | ports: 32 | - "3306:3306" 33 | migration_and_fixtures: 34 | depends_on: 35 | - mysql 36 | build: 37 | context: docker/migration_and_fixtures 38 | args: 39 | PHP_VERSION: "$PHP_VERSION" 40 | env_file: .env 41 | user: "$_UID:$_GID" 42 | working_dir: "$APP_SOURCE_ROOT" 43 | volumes: 44 | - "./:$APP_SOURCE_ROOT" 45 | php-fpm: 46 | build: 47 | context: ./docker/php-fpm 48 | args: 49 | PHP_VERSION: "$PHP_VERSION" 50 | env_file: .env 51 | user: "$_UID:$_GID" 52 | depends_on: 53 | - mysql 54 | ports: 55 | - "9000:9000" 56 | links: 57 | - mysql 58 | volumes: 59 | - "./:$APP_SOURCE_ROOT" 60 | nginx: 61 | image: "nginx:$NGINX_VERSION-alpine" 62 | env_file: .env 63 | ports: 64 | - "80:80" 65 | volumes: 66 | - "./:$APP_SOURCE_ROOT" 67 | - "./docker/nginx/etc/nginx/nginx.conf:/etc/nginx/nginx.conf" 68 | - "./docker/nginx/etc/nginx/sites-enabled:/etc/nginx/sites-enabled" 69 | depends_on: 70 | - "php-fpm" 71 | -------------------------------------------------------------------------------- /docker/migration_and_fixtures/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PHP_VERSION 2 | FROM php:${PHP_VERSION}-alpine 3 | RUN docker-php-ext-install pdo_mysql 4 | COPY etc/share/ /etc/share 5 | WORKDIR /var/www/html 6 | ENTRYPOINT ["/etc/share/entrypoint/entrypoint.sh"] 7 | CMD [] 8 | -------------------------------------------------------------------------------- /docker/migration_and_fixtures/etc/share/entrypoint/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | #. .env 4 | 5 | ( \ 6 | cd "${_SOURCE_ROOT}" \ 7 | && bin/console doctrine:migrations:migrate -n \ 8 | && bin/console doctrine:fixtures:load -n \ 9 | ) 10 | -------------------------------------------------------------------------------- /docker/nginx/etc/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | error_log /dev/stderr warn; 4 | pid /var/run/nginx.pid; 5 | events { 6 | worker_connections 1024; 7 | } 8 | http { 9 | include /etc/nginx/mime.types; 10 | default_type application/octet-stream; 11 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 12 | '$status $body_bytes_sent "$http_referer" ' 13 | '"$http_user_agent" "$http_x_forwarded_for"'; 14 | access_log /dev/stdout main; 15 | sendfile on; 16 | keepalive_timeout 65; 17 | include /etc/nginx/conf.d/*.conf; 18 | include /etc/nginx/sites-enabled/*.conf; 19 | } 20 | -------------------------------------------------------------------------------- /docker/nginx/etc/nginx/sites-enabled/crudnotes.localhost.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_name crudnotes.localhost; 3 | listen 80; 4 | listen [::]:80; 5 | root /var/www/html/public; 6 | location / { 7 | # try to serve file directly, fallback to index.php 8 | try_files $uri /index.php$is_args$args; 9 | } 10 | location ~ ^/index\.php(/|$) { 11 | fastcgi_buffer_size 32k; 12 | fastcgi_buffers 4 32k; 13 | fastcgi_pass php-fpm:9000; 14 | fastcgi_split_path_info ^(.+\.php)(/.*)$; 15 | include fastcgi_params; 16 | fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; 17 | fastcgi_param DOCUMENT_ROOT $realpath_root; 18 | internal; 19 | } 20 | location ~ \.php$ { 21 | return 404; 22 | } 23 | 24 | error_log /var/log/nginx/project_error.log; 25 | access_log /var/log/nginx/project_access.log; 26 | } 27 | -------------------------------------------------------------------------------- /docker/php-fpm/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PHP_VERSION 2 | FROM php:${PHP_VERSION}-fpm-alpine 3 | RUN docker-php-ext-install pdo_mysql 4 | RUN docker-php-ext-install json 5 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handle($request); 26 | $response->send(); 27 | $kernel->terminate($request, $response); 28 | -------------------------------------------------------------------------------- /src/Controller/AbstractController.php: -------------------------------------------------------------------------------- 1 | usersService; 24 | } 25 | 26 | /** 27 | * @param null|UsersService $usersService 28 | * @return static 29 | */ 30 | public function setUsersService(UsersService $usersService = null) 31 | { 32 | $this->usersService = $usersService; 33 | return $this; 34 | } 35 | 36 | protected function getData(Request $request) : array { 37 | $result = []; 38 | try { 39 | $result = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); 40 | } catch (JsonException $e) { 41 | // do nothing 42 | } 43 | 44 | return $result; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Controller/NotesController.php: -------------------------------------------------------------------------------- 1 | getData($request); 38 | return $this->json( 39 | $this->getNotesService()->findByUsername($data['i_am'] ?? null) 40 | ); 41 | } catch (Throwable $exception) { 42 | throw new BadRequestHttpException($exception->getMessage()); 43 | } 44 | } 45 | 46 | /** 47 | * @param Request $request 48 | * @return JsonResponse 49 | */ 50 | public function available( 51 | Request $request 52 | ) : JsonResponse { 53 | try { 54 | $data = $this->getData($request); 55 | 56 | return $this->json( 57 | $this->getNotesService()->findAvailableBy($data['i_am'] ?? null, $data['access'] ?? 'read') 58 | ); 59 | } catch (Throwable $exception) { 60 | throw new BadRequestHttpException($exception->getMessage()); 61 | } 62 | } 63 | 64 | /** 65 | * @param Request $request 66 | * @return RedirectResponse 67 | */ 68 | public function create( 69 | Request $request 70 | ) : RedirectResponse { 71 | try { 72 | $data = $this->getData($request); 73 | $iAm = $this->getUsersService()->findOneByUsername($data['i_am'] ?? null); 74 | $note = $this->getNotesService()->create( 75 | $data['title'] ?? null, 76 | $data['body'] ?? null, 77 | $iAm 78 | ); 79 | return $this->redirectToRoute('notes_read', [ 80 | 'id' => $note->getId(), 81 | ]); 82 | } catch (Throwable $exception) { 83 | throw new BadRequestHttpException($exception->getMessage()); 84 | } 85 | } 86 | 87 | /** 88 | * @param Request $request 89 | * @param int $id 90 | * @return JsonResponse 91 | */ 92 | public function read( 93 | int $id, 94 | Request $request 95 | ) : JsonResponse { 96 | try { 97 | $data = $this->getData($request); 98 | $note = $this->getNotesService()->findOneByIdAndUsernameAndAccess($id, $data['i_am'] ?? null); 99 | if (!($note instanceof Note)) { 100 | throw new NotFoundHttpException(); 101 | } 102 | 103 | return $this->json([ 104 | $note 105 | ]); 106 | } catch (Throwable $exception) { 107 | throw new BadRequestHttpException($exception->getMessage()); 108 | } 109 | } 110 | 111 | /** 112 | * @param int $id 113 | * @param Request $request 114 | * @return RedirectResponse 115 | */ 116 | public function update( 117 | int $id, 118 | Request $request 119 | ) : RedirectResponse { 120 | try { 121 | $data = $this->getData($request); 122 | $note = $this->getNotesService()->findOneByIdAndUsernameAndAccess($id, $data['i_am'] ?? null, 'write'); 123 | if (!($note instanceof Note)) { 124 | throw new NotFoundHttpException(); 125 | } 126 | $this->getNotesService()->update( 127 | $note, 128 | $data['title'] ?? null, 129 | $data['body'] ?? null 130 | ); 131 | 132 | return $this->redirectToRoute('notes_read', [ 133 | 'id' => $id, 134 | ]); 135 | } catch (Throwable $exception) { 136 | throw new BadRequestHttpException($exception->getMessage()); 137 | } 138 | } 139 | 140 | /** 141 | * @param int $id 142 | * @param Request $request 143 | * @return JsonResponse 144 | */ 145 | public function delete( 146 | int $id, 147 | Request $request 148 | ) : JsonResponse { 149 | $result = $this->json([]); 150 | try { 151 | $data = $this->getData($request); 152 | $note = $this->getNotesService()->findOneByIdAndUsername($id, $data['i_am'] ?? null); 153 | if ($note instanceof Note) { 154 | $this->getNotesService()->delete($note); 155 | } 156 | } catch (NoResultException $exception) { 157 | // do nothing 158 | } catch (Throwable $exception) { 159 | throw new BadRequestHttpException($exception->getMessage()); 160 | } 161 | 162 | return $result; 163 | } 164 | 165 | /** 166 | * @param int $id 167 | * @param Request $request 168 | * @return JsonResponse 169 | */ 170 | public function share( 171 | int $id, 172 | Request $request 173 | ) : JsonResponse { 174 | try { 175 | [$access, $usernames] = $this->prepareShareable($request, $id); 176 | $this->getSharesService()->share($id, $usernames, $access); 177 | 178 | return $this->json([]); 179 | } catch (Throwable $exception) { 180 | throw new BadRequestHttpException($exception->getMessage()); 181 | } 182 | } 183 | 184 | /** 185 | * @param int $id 186 | * @param Request $request 187 | * @return JsonResponse 188 | */ 189 | public function deshare( 190 | int $id, 191 | Request $request 192 | ) : JsonResponse { 193 | $result = $this->json([]); 194 | try { 195 | [$access, $usernames] = $this->prepareShareable($request, $id); 196 | $this->getSharesService()->deshare($id, $usernames, $access); 197 | } catch (Throwable $exception) { 198 | throw new BadRequestHttpException($exception->getMessage()); 199 | } 200 | 201 | return $result; 202 | } 203 | 204 | /** 205 | * @param Request $request 206 | * @param int $id 207 | * @return array 208 | */ 209 | protected function prepareShareable( 210 | Request $request, 211 | int $id 212 | ): array { 213 | $data = $this->getData($request); 214 | if (!$this->getNotesService()->hasOneByIdAndUsername($id, $data['i_am'] ?? null)) { 215 | throw new NotFoundHttpException(); 216 | } 217 | $data['usernames'] = array_diff($data['usernames'], [$data['i_am']]); 218 | $usernames = $this->getUsersService()->filterUsernames($data['usernames'] ?? null); 219 | return [ 220 | $data['access'] ?? 'read', 221 | $usernames, 222 | ]; 223 | } 224 | 225 | 226 | /** 227 | * @return NotesService|null 228 | */ 229 | public function getNotesService(): ?NotesService 230 | { 231 | return $this->notesService; 232 | } 233 | 234 | /** 235 | * @param NotesService|null $notesService 236 | * @return static 237 | */ 238 | public function setNotesService(?NotesService $notesService = null) 239 | { 240 | $this->notesService = $notesService; 241 | return $this; 242 | } 243 | 244 | /** 245 | * @return SharesService|null 246 | */ 247 | public function getSharesService(): ?SharesService 248 | { 249 | return $this->sharesService; 250 | } 251 | 252 | /** 253 | * @param SharesService|null $sharesService 254 | * @return static 255 | */ 256 | public function setSharesService(?SharesService $sharesService = null) 257 | { 258 | $this->sharesService = $sharesService; 259 | return $this; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/Controller/UsersController.php: -------------------------------------------------------------------------------- 1 | json( 21 | $this->getUsersService()->list() 22 | ); 23 | } 24 | 25 | /** 26 | * @param Request $request 27 | * @return RedirectResponse 28 | * @throws BadRequestHttpException 29 | */ 30 | public function create(Request $request) : RedirectResponse 31 | { 32 | try { 33 | $data = $this->getData($request); 34 | $user = $this->getUsersService()->create( 35 | $data['username'] ?? null, 36 | $data['fullname'] ?? null 37 | ); 38 | return $this->redirectToRoute('users_read', [ 39 | 'id' => $user->getId(), 40 | ]); 41 | } catch (Throwable $exception) { 42 | throw new BadRequestHttpException($exception->getMessage()); 43 | } 44 | } 45 | 46 | /** 47 | * @param int $id 48 | * @return JsonResponse 49 | * @throws NotFoundHttpException 50 | */ 51 | public function read(int $id) : JsonResponse 52 | { 53 | $user = $this->getUsersService()->read($id); 54 | if (!($user instanceof User)) { 55 | throw new NotFoundHttpException(); 56 | } 57 | 58 | return $this->json([ 59 | $user 60 | ]); 61 | } 62 | 63 | /** 64 | * @param int $id 65 | * @param Request $request 66 | * @return RedirectResponse 67 | */ 68 | public function update(int $id, Request $request) : RedirectResponse 69 | { 70 | try { 71 | $data = $this->getData($request); 72 | $this->getUsersService()->update($id, $data['fullname'] ?? null); 73 | 74 | return $this->redirectToRoute('users_read', [ 75 | 'id' => $id, 76 | ]); 77 | } catch (Throwable $exception) { 78 | throw new BadRequestHttpException($exception->getMessage()); 79 | } 80 | } 81 | 82 | /** 83 | * @param int $id 84 | * @return JsonResponse 85 | */ 86 | public function delete(int $id): JsonResponse 87 | { 88 | $this->getUsersService()->delete($id); 89 | 90 | return $this->json([]); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Entity/Note.php: -------------------------------------------------------------------------------- 1 | shares = new ArrayCollection(); 63 | } 64 | 65 | /** 66 | * @return Share[]|ArrayCollection 67 | */ 68 | public function getShares() 69 | { 70 | return $this->shares; 71 | } 72 | 73 | /** 74 | * @param Share[]|ArrayCollection $shares 75 | * @return static 76 | */ 77 | public function setShares($shares) 78 | { 79 | foreach ($shares as $share) { 80 | $this->addShare($share); 81 | } 82 | return $this; 83 | } 84 | 85 | /** 86 | * @param Share $share 87 | * @return static 88 | */ 89 | public function addShare(Share $share) { 90 | if (!$this->shares->contains($share)) { 91 | $this->shares->add($share); 92 | $share->setNote($this); 93 | } 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * @param Share $share 100 | * @return static 101 | */ 102 | public function removeShare(Share $share) 103 | { 104 | if ($this->shares->contains($share)) { 105 | $share->setNote(null); 106 | $this->shares->removeElement($share); 107 | } 108 | 109 | return $this; 110 | } 111 | 112 | /** 113 | * @return User|null 114 | */ 115 | public function getUser(): ?User 116 | { 117 | return $this->user; 118 | } 119 | 120 | /** 121 | * @param User|null $user 122 | * @return static 123 | */ 124 | public function setUser(?User $user = null) 125 | { 126 | $this->user = $user; 127 | 128 | return $this; 129 | } 130 | 131 | public function getId(): ?int 132 | { 133 | return $this->id; 134 | } 135 | 136 | /** 137 | * @param int|null $id 138 | * @return static 139 | */ 140 | public function setId(?int $id = null) 141 | { 142 | $this->id = $id; 143 | 144 | return $this; 145 | } 146 | 147 | /** 148 | * @return string|null 149 | */ 150 | public function getTitle(): ?string 151 | { 152 | return $this->title; 153 | } 154 | 155 | /** 156 | * @param string|null $title 157 | * @return static 158 | */ 159 | public function setTitle(?string $title = null) 160 | { 161 | $this->title = $title; 162 | return $this; 163 | } 164 | 165 | /** 166 | * @return DateTimeImmutable|null 167 | */ 168 | public function getCreatedAt(): ?DateTimeImmutable 169 | { 170 | return $this->createdAt; 171 | } 172 | 173 | /** 174 | * @param DateTimeImmutable|null $createdAt 175 | * @return static 176 | */ 177 | public function setCreatedAt(?DateTimeImmutable $createdAt = null) 178 | { 179 | $this->createdAt = $createdAt; 180 | 181 | return $this; 182 | } 183 | 184 | /** 185 | * @return DateTime|null 186 | */ 187 | public function getUpdatedAt(): ?DateTime 188 | { 189 | return $this->updatedAt; 190 | } 191 | 192 | /** 193 | * @param DateTime|null $updatedAt 194 | * @return static 195 | */ 196 | public function setUpdatedAt(?DateTime $updatedAt = null) 197 | { 198 | $this->updatedAt = $updatedAt; 199 | 200 | return $this; 201 | } 202 | 203 | /** 204 | * @return string|null 205 | */ 206 | public function getBody(): ?string 207 | { 208 | return $this->body; 209 | } 210 | 211 | /** 212 | * @param string|null $body 213 | * @return static 214 | */ 215 | public function setBody(?string $body = null) 216 | { 217 | $this->body = $body; 218 | return $this; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/Entity/Share.php: -------------------------------------------------------------------------------- 1 | user; 61 | } 62 | 63 | /** 64 | * @param User|null $user 65 | * @return static 66 | */ 67 | public function setUser(?User $user = null) 68 | { 69 | if (($user instanceof User) && !($this->user instanceof User)) { 70 | $user->addShare($this); 71 | } 72 | $this->user = $user; 73 | 74 | return $this; 75 | } 76 | /** 77 | * @return Note|null 78 | */ 79 | public function getNote(): ?Note 80 | { 81 | return $this->note; 82 | } 83 | 84 | /** 85 | * @param Note|null $note 86 | * @return static 87 | */ 88 | public function setNote(?Note $note = null) 89 | { 90 | if (($note instanceof Note) && !($this->note instanceof Note)) { 91 | $note->addShare($this); 92 | } 93 | $this->note = $note; 94 | 95 | return $this; 96 | } 97 | 98 | public function getId() 99 | { 100 | return $this->id; 101 | } 102 | 103 | /** 104 | * @param int|null $id 105 | * @return static 106 | */ 107 | public function setId(?int $id = null) 108 | { 109 | $this->id = $id; 110 | 111 | return $this; 112 | } 113 | 114 | /** 115 | * @return string|null 116 | */ 117 | public function getAccess(): ?string 118 | { 119 | return $this->access; 120 | } 121 | 122 | /** 123 | * @param string|null $access 124 | * @return static 125 | */ 126 | public function setAccess(?string $access = 'read') 127 | { 128 | $this->access = $access; 129 | return $this; 130 | } 131 | 132 | /** 133 | * @return DateTimeImmutable|null 134 | */ 135 | public function getCreatedAt(): ?DateTimeImmutable 136 | { 137 | return $this->createdAt; 138 | } 139 | 140 | /** 141 | * @param DateTimeImmutable|null $createdAt 142 | * @return static 143 | */ 144 | public function setCreatedAt(?DateTimeImmutable $createdAt = null) 145 | { 146 | $this->createdAt = $createdAt; 147 | 148 | return $this; 149 | } 150 | 151 | /** 152 | * @return DateTime|null 153 | */ 154 | public function getUpdatedAt(): ?DateTime 155 | { 156 | return $this->updatedAt; 157 | } 158 | 159 | /** 160 | * @param DateTime|null $updatedAt 161 | * @return static 162 | */ 163 | public function setUpdatedAt(?DateTime $updatedAt = null) 164 | { 165 | $this->updatedAt = $updatedAt; 166 | 167 | return $this; 168 | } 169 | } -------------------------------------------------------------------------------- /src/Entity/User.php: -------------------------------------------------------------------------------- 1 | notes = new ArrayCollection(); 64 | $this->shares = new ArrayCollection(); 65 | } 66 | 67 | /** 68 | * @return Share[]|ArrayCollection 69 | */ 70 | public function getShares() 71 | { 72 | return $this->shares; 73 | } 74 | 75 | /** 76 | * @param Share[]|ArrayCollection $shares 77 | * @return static 78 | */ 79 | public function setShares($shares) 80 | { 81 | foreach ($shares as $share) { 82 | $this->addShare($share); 83 | } 84 | return $this; 85 | } 86 | 87 | /** 88 | * @param Share $share 89 | * @return static 90 | */ 91 | public function addShare(Share $share) { 92 | if (!$this->shares->contains($share)) { 93 | $this->shares->add($share); 94 | $share->setUser($this); 95 | } 96 | 97 | return $this; 98 | } 99 | 100 | /** 101 | * @param Share $share 102 | * @return static 103 | */ 104 | public function removeShare(Share $share) 105 | { 106 | if ($this->shares->contains($share)) { 107 | $share->setUser(null); 108 | $this->shares->removeElement($share); 109 | } 110 | 111 | return $this; 112 | } 113 | 114 | /** 115 | * @return Note[]|ArrayCollection 116 | */ 117 | public function getNotes() 118 | { 119 | return $this->notes; 120 | } 121 | 122 | /** 123 | * @param Note[]|ArrayCollection $notes 124 | * @return static 125 | */ 126 | public function setNotes($notes) 127 | { 128 | $this->notes = $notes; 129 | return $this; 130 | } 131 | 132 | /** 133 | * @param Note $note 134 | * @return static 135 | */ 136 | public function addNote(Note $note) { 137 | if (!$this->notes->contains($note)) { 138 | $this->notes->add($note); 139 | $note->setUser($this); 140 | } 141 | 142 | return $this; 143 | } 144 | 145 | /** 146 | * @param Note $note 147 | * @return static 148 | */ 149 | public function removeNote(Note $note) 150 | { 151 | if ($this->notes->contains($note)) { 152 | $note->setUser(null); 153 | $this->notes->removeElement($note); 154 | } 155 | 156 | return $this; 157 | } 158 | 159 | /** 160 | * @return string|null 161 | */ 162 | public function getFullname(): ?string 163 | { 164 | return $this->fullname; 165 | } 166 | 167 | /** 168 | * @param string|null $fullname 169 | * @return static 170 | */ 171 | public function setFullname(?string $fullname = null) 172 | { 173 | $this->fullname = $fullname; 174 | 175 | return $this; 176 | } 177 | 178 | public function getId(): ?int 179 | { 180 | return $this->id; 181 | } 182 | 183 | /** 184 | * @param int|null $id 185 | * @return static 186 | */ 187 | public function setId(?int $id = null) 188 | { 189 | $this->id = $id; 190 | 191 | return $this; 192 | } 193 | 194 | /** 195 | * @return string|null 196 | */ 197 | public function getUsername(): ?string 198 | { 199 | return $this->username; 200 | } 201 | 202 | /** 203 | * @param string|null $username 204 | * @return static 205 | */ 206 | public function setUsername(?string $username = null) 207 | { 208 | $this->username = $username; 209 | return $this; 210 | } 211 | 212 | /** 213 | * @return DateTimeImmutable|null 214 | */ 215 | public function getCreatedAt(): ?DateTimeImmutable 216 | { 217 | return $this->createdAt; 218 | } 219 | 220 | /** 221 | * @param DateTimeImmutable|null $createdAt 222 | * @return static 223 | */ 224 | public function setCreatedAt(?DateTimeImmutable $createdAt = null) 225 | { 226 | $this->createdAt = $createdAt; 227 | 228 | return $this; 229 | } 230 | 231 | /** 232 | * @return DateTime|null 233 | */ 234 | public function getUpdatedAt(): ?DateTime 235 | { 236 | return $this->updatedAt; 237 | } 238 | 239 | /** 240 | * @param DateTime|null $updatedAt 241 | * @return static 242 | */ 243 | public function setUpdatedAt(?DateTime $updatedAt = null) 244 | { 245 | $this->updatedAt = $updatedAt; 246 | 247 | return $this; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/Fixture/NotesFixture.php: -------------------------------------------------------------------------------- 1 | getReference('user_' . $i); 39 | $fixture = (new Note()) 40 | ->setTitle($title) 41 | ->setBody($body) 42 | ->setUser($user) 43 | ->setCreatedAt(new DateTimeImmutable('- ' . mt_rand($minSeconds, $maxSeconds) . ' seconds')) 44 | ->setUpdatedAt(new DateTime('- ' . mt_rand(1, $minSeconds) . ' seconds')) 45 | ; 46 | $manager->persist($fixture); 47 | $this->setReference('note_' . $i, $fixture); 48 | } 49 | 50 | $manager->flush(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Fixture/SharesFixture.php: -------------------------------------------------------------------------------- 1 | getReference('note_' . $i), 37 | ]; 38 | $sharedUsers = [ 39 | 'read' => $this->getReference('user_' . ($i+1)), 40 | 'write' => $this->getReference('user_' . (10+$i+1)), 41 | ]; 42 | foreach ($sharedNotes as $note) { 43 | foreach ($sharedUsers as $access => $user) { 44 | $fixture = (new Share()) 45 | ->setUser($user) 46 | ->setNote($note) 47 | ->setAccess($access) 48 | ->setCreatedAt(new DateTimeImmutable('- ' . mt_rand($minSeconds, $maxSeconds) . ' seconds')) 49 | ->setUpdatedAt(new DateTime('- ' . mt_rand(1, $minSeconds) . ' seconds')) 50 | ; 51 | $manager->persist($fixture); 52 | } 53 | } 54 | } 55 | 56 | $manager->flush(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Fixture/UsersFixture.php: -------------------------------------------------------------------------------- 1 | setUsername('username_' . $i) 43 | ->setFullname($names[mt_rand(0, count($names) - 1)]) 44 | ->setCreatedAt(new DateTimeImmutable('- ' . mt_rand($minSeconds, $maxSeconds) . ' seconds')) 45 | ->setUpdatedAt(new DateTime('- ' . mt_rand(1, $minSeconds) . ' seconds')) 46 | ; 47 | $manager->persist($fixture); 48 | $this->setReference('user_' . $i, $fixture); 49 | } 50 | 51 | $manager->flush(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | getProjectDir().'/config/bundles.php'; 21 | foreach ($contents as $class => $envs) { 22 | if ($envs[$this->environment] ?? $envs['all'] ?? false) { 23 | yield new $class(); 24 | } 25 | } 26 | } 27 | 28 | public function getProjectDir(): string 29 | { 30 | return \dirname(__DIR__); 31 | } 32 | 33 | protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void 34 | { 35 | $container->addResource(new FileResource($this->getProjectDir().'/config/bundles.php')); 36 | $container->setParameter('container.dumper.inline_class_loader', \PHP_VERSION_ID < 70400 || $this->debug); 37 | $container->setParameter('container.dumper.inline_factories', true); 38 | $confDir = $this->getProjectDir().'/config'; 39 | 40 | $loader->load($confDir.'/{packages}/*'.self::CONFIG_EXTS, 'glob'); 41 | $loader->load($confDir.'/{packages}/'.$this->environment.'/*'.self::CONFIG_EXTS, 'glob'); 42 | $loader->load($confDir.'/{services}'.self::CONFIG_EXTS, 'glob'); 43 | $loader->load($confDir.'/{services}_'.$this->environment.self::CONFIG_EXTS, 'glob'); 44 | } 45 | 46 | protected function configureRoutes(RouteCollectionBuilder $routes): void 47 | { 48 | $confDir = $this->getProjectDir().'/config'; 49 | 50 | $routes->import($confDir.'/{routes}/'.$this->environment.'/*'.self::CONFIG_EXTS, '/', 'glob'); 51 | $routes->import($confDir.'/{routes}/*'.self::CONFIG_EXTS, '/', 'glob'); 52 | $routes->import($confDir.'/{routes}'.self::CONFIG_EXTS, '/', 'glob'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Migrations/Version20200715184503.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE note ( 24 | id INT AUTO_INCREMENT NOT NULL, 25 | user_id INT DEFAULT NULL, 26 | title VARCHAR(64) NOT NULL, 27 | created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', 28 | updated_at DATETIME NOT NULL, 29 | body LONGTEXT DEFAULT NULL, 30 | INDEX IDX_CFBDFA14A76ED395 (user_id), 31 | PRIMARY KEY(id) 32 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 33 | $this->addSql('CREATE TABLE share ( 34 | id INT AUTO_INCREMENT NOT NULL, 35 | note_id INT DEFAULT NULL, 36 | user_id INT DEFAULT NULL, 37 | created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', 38 | updated_at DATETIME NOT NULL, 39 | access VARCHAR(64) NOT NULL, 40 | INDEX IDX_EF069D5A26ED0855 (note_id), 41 | INDEX IDX_EF069D5AA76ED395 (user_id), 42 | PRIMARY KEY(id) 43 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 44 | $this->addSql('CREATE TABLE `user` ( 45 | id INT AUTO_INCREMENT NOT NULL, 46 | username VARCHAR(255) NOT NULL, 47 | created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', 48 | updated_at DATETIME NOT NULL, 49 | fullname VARCHAR(255) DEFAULT NULL, 50 | UNIQUE INDEX UNIQ_8D93D649F85E0677 (username), 51 | PRIMARY KEY(id) 52 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 53 | $this->addSql('ALTER TABLE 54 | note 55 | ADD 56 | CONSTRAINT FK_CFBDFA14A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)'); 57 | $this->addSql('ALTER TABLE 58 | share 59 | ADD 60 | CONSTRAINT FK_EF069D5A26ED0855 FOREIGN KEY (note_id) REFERENCES note (id)'); 61 | $this->addSql('ALTER TABLE 62 | share 63 | ADD 64 | CONSTRAINT FK_EF069D5AA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)'); 65 | } 66 | 67 | public function down(Schema $schema) : void 68 | { 69 | // this down() migration is auto-generated, please modify it to your needs 70 | $this->addSql('ALTER TABLE share DROP FOREIGN KEY FK_EF069D5A26ED0855'); 71 | $this->addSql('ALTER TABLE note DROP FOREIGN KEY FK_CFBDFA14A76ED395'); 72 | $this->addSql('ALTER TABLE share DROP FOREIGN KEY FK_EF069D5AA76ED395'); 73 | $this->addSql('DROP TABLE note'); 74 | $this->addSql('DROP TABLE share'); 75 | $this->addSql('DROP TABLE `user`'); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Repository/NotesRepository.php: -------------------------------------------------------------------------------- 1 | createQueryBuilder('n') 18 | ->leftJoin('n.user', 'nu'); 19 | if ($withShare) { 20 | $qb->leftJoin('n.shares', 's') 21 | ->leftJoin('s.user', 'su'); 22 | } 23 | 24 | return $qb; 25 | } 26 | 27 | public function findOneByIdAndUsername(int $id, string $username) : ?Note { 28 | return $this->queryBuilderOneByIdAndUsername($id, $username) 29 | ->getQuery() 30 | ->getSingleResult(); 31 | } 32 | 33 | public function findByUsername(string $username) : array { 34 | $qb = $this->queryBuilder(); 35 | $qb->andWhere('nu.username = :username'); 36 | $qb->setParameters([ 37 | 'username' => $username, 38 | ]); 39 | 40 | return $qb->getQuery() 41 | ->getResult(); 42 | } 43 | 44 | protected function queryBuilderOneByIdAndUsername(int $id, string $username) : QueryBuilder { 45 | $qb = $this->queryBuilder(); 46 | $qb->setMaxResults(1); 47 | $qb->where('n.id = :id'); 48 | $qb->andWhere('nu.username = :username'); 49 | $qb->setParameters([ 50 | 'id' => $id, 51 | 'username' => $username, 52 | ]); 53 | 54 | return $qb; 55 | } 56 | 57 | /** 58 | * @param int $id 59 | * @param string $username 60 | * @param string $access 61 | * @return Note|null 62 | * @throws NoResultException|NonUniqueResultException 63 | */ 64 | public function findOneByIdAndUsernameAndAccess(int $id, string $username, string $access = 'read') : Note { 65 | $parameters = [ 66 | 'id' => $id, 67 | 'username' => $username, 68 | 'read' => 'read', 69 | ]; 70 | if ('write' === $access) { 71 | $parameters = array_merge($parameters, [ 72 | $access => $access, 73 | ]); 74 | } 75 | $qb = $this->queryBuilder(true); 76 | $qb->setMaxResults(1); 77 | $qb->where('n.id = :id'); 78 | $qb->andWhere($this->buildAccessExpr($qb, $access)); 79 | $qb->setParameters($parameters); 80 | 81 | return $qb->getQuery()->getSingleResult(); 82 | } 83 | 84 | /** 85 | * @param int $id 86 | * @param string $username 87 | * @return bool 88 | * @throws NoResultException 89 | * @throws NonUniqueResultException 90 | */ 91 | public function hasOneByIdAndUsername(int $id, string $username) : bool { 92 | return 1 === (int) ($this->queryBuilderOneByIdAndUsername($id, $username) 93 | ->select('count(1)') 94 | ->getQuery()->getSingleScalarResult()); 95 | } 96 | 97 | /** 98 | * @param QueryBuilder $qb 99 | * @param string $access 100 | * @return Composite 101 | */ 102 | protected function buildAccessExpr(QueryBuilder $qb, string $access) : Composite { 103 | $expr = []; 104 | $expr[] = $qb->expr()->eq('nu.username', ':username'); 105 | $expr[] = $qb->expr()->andX( 106 | $qb->expr()->eq('su.username', ':username'), 107 | $qb->expr()->eq('s.access', ':read') 108 | ); 109 | if ('write' === $access) { 110 | $expr[] = $qb->expr()->andX( 111 | $qb->expr()->eq('su.username', ':username'), 112 | $qb->expr()->eq('s.access', ':write') 113 | ); 114 | } 115 | 116 | return $qb->expr()->orX(...$expr); 117 | } 118 | 119 | /** 120 | * @param User $user 121 | * @param string $access 122 | * @return Note[] 123 | */ 124 | public function findByUserAndAccess(User $user, string $access): array 125 | { 126 | return $this->queryBuilder() 127 | ->andWhere('s.access = :access') 128 | ->andWhere('s.user = :user') 129 | ->setParameters([ 130 | 'access' => $access, 131 | 'user' => $user, 132 | ])->getQuery()->getResult(); 133 | } 134 | 135 | /** 136 | * @param string $username 137 | * @param string $access 138 | * @return Note[]|ArrayCollection 139 | */ 140 | public function findByUsernameAndAccess(string $username, string $access) : array 141 | { 142 | $parameters = [ 143 | 'username' => $username, 144 | 'access' => $access, 145 | ]; 146 | $qb = $this->queryBuilder(true); 147 | $qb->andWhere($qb->expr()->andX( 148 | $qb->expr()->eq('su.username', ':username'), 149 | $qb->expr()->eq('s.access', ':access') 150 | )); 151 | $qb->setParameters($parameters); 152 | 153 | return $qb->getQuery()->getResult(); 154 | } 155 | 156 | public function findByIdAndUsernamesAndWithoutAccess(int $id, array $usernames, string $access = 'read') : array { 157 | 158 | $parameters = [ 159 | 'id' => $id, 160 | 'usernames' => $usernames, 161 | 'access' => $access, 162 | ]; 163 | $qb = $this->createQueryBuilder('n'); 164 | $qb->join('n.user', 'nu'); 165 | $qb->join('n.shares', 's'); 166 | $qb->join('s.user', 'su'); 167 | $qb->andWhere('n.id = :id'); 168 | $qb->andWhere('s.access = :access'); 169 | $qb->andWhere( 170 | $qb->expr()->not( 171 | $qb->expr()->in('su.username', ':usernames'), 172 | ) 173 | ); 174 | // can you find a bug? 175 | $qb->setParameters($parameters); 176 | 177 | return $qb->getQuery()->getResult(); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/Repository/SharesRepository.php: -------------------------------------------------------------------------------- 1 | createQueryBuilder('s'); 11 | $qb->join('s.note', 'n'); 12 | $qb->join('s.user', 'u'); 13 | $qb->andWhere('n.id = :id'); 14 | $qb->andWhere('s.access = :access'); 15 | $qb->andWhere('u.username in (:usernames)'); 16 | $qb->setParameters([ 17 | 'id' => $id, 18 | 'usernames' => $usernames, 19 | 'access' => $access, 20 | ]); 21 | 22 | return $qb->getQuery()->getResult(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Repository/UsersRepository.php: -------------------------------------------------------------------------------- 1 | addScalarResult($key = 'username', $key); 14 | $query = $this->_em->createNativeQuery($sql, $rsm) 15 | ->setParameter('usernames', $usernames); 16 | 17 | return array_map(static function (array $raw) use ($key) { 18 | return $raw[$key]; 19 | }, $query->getScalarResult()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Service/AbstractService.php: -------------------------------------------------------------------------------- 1 | managerRegistry = $managerRegistry; 18 | } 19 | 20 | abstract protected function getClassName() : string; 21 | 22 | protected function getRepository() : ObjectRepository 23 | { 24 | return $this->managerRegistry 25 | ->getRepository($this->getClassName()); 26 | } 27 | 28 | protected function getManager() : ObjectManager { 29 | return $this->managerRegistry->getManager(); 30 | } 31 | 32 | /** 33 | * @param mixed $object 34 | * @param bool $andFlush 35 | * @return void 36 | */ 37 | protected function remove($object, bool $andFlush = true) : void { 38 | $this->getManager()->remove($object); 39 | if ($andFlush) { 40 | $this->getManager()->flush(); 41 | } 42 | } 43 | 44 | /** 45 | * @param mixed $object 46 | * @param bool $andFlush 47 | */ 48 | protected function persist($object, bool $andFlush = true) : void { 49 | $this->getManager()->persist($object); 50 | if ($andFlush) { 51 | $this->getManager()->flush(); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /src/Service/NotesService.php: -------------------------------------------------------------------------------- 1 | getRepository()->findAll(); 24 | } 25 | 26 | /** 27 | * @param string|null $title 28 | * @param string|null $body 29 | * @param User|null $author 30 | * @return Note 31 | */ 32 | public function create(?string $title = null, ?string $body = null, ?User $author = null) : Note { 33 | if (null === $title) { 34 | throw new RuntimeException('empty title'); 35 | } 36 | if (!($author instanceof User)) { 37 | throw new RuntimeException('empty user'); 38 | } 39 | $note = (new Note()) 40 | ->setTitle($title) 41 | ->setBody($body) 42 | ->setUser($author) 43 | ->setCreatedAt(new DateTimeImmutable()) 44 | ->setUpdatedAt(new DateTime()) 45 | ; 46 | $this->persist($note); 47 | 48 | return $note; 49 | } 50 | 51 | /** 52 | * @param Note $note 53 | * @param string $title 54 | * @param string|null $body 55 | */ 56 | public function update(Note $note, string $title, ?string $body = null) : void { 57 | $note 58 | ->setTitle($title) 59 | ->setBody($body) 60 | ->setUpdatedAt(new DateTime()) 61 | ; 62 | 63 | $this->persist($note); 64 | } 65 | 66 | /** 67 | * @param Note $note 68 | * @return void 69 | */ 70 | public function delete(Note $note) : void { 71 | $this->remove($note); 72 | } 73 | 74 | /** 75 | * @param string|null $username 76 | * @return array|Note[] 77 | */ 78 | public function findByUsername(string $username = null) : array { 79 | if (null === $username) { 80 | throw new RuntimeException('empty username'); 81 | } 82 | 83 | return $this->getRepository()->findByUsername($username); 84 | } 85 | 86 | /** 87 | * @param int $id 88 | * @param string|null $username 89 | * @return bool 90 | */ 91 | public function hasOneByIdAndUsername(int $id, string $username = null) : bool { 92 | $result = false; 93 | if (null === $username) { 94 | throw new RuntimeException('empty username'); 95 | } 96 | try { 97 | $result = $this->getRepository()->hasOneByIdAndUsername($id, $username); 98 | } catch (UnexpectedResultException $e) { 99 | // do nothing 100 | } 101 | 102 | return $result; 103 | } 104 | 105 | /** 106 | * @param int $id 107 | * @param string|null $username 108 | * @return Note|null 109 | */ 110 | public function findOneByIdAndUsername(int $id, string $username = null) : ?Note { 111 | if (null === $username) { 112 | throw new RuntimeException('empty username'); 113 | } 114 | 115 | return $this->getRepository() ->findOneByIdAndUsername($id, $username); 116 | } 117 | 118 | /** 119 | * @param int $id 120 | * @param string|null $username 121 | * @param string $access 122 | * @return Note|null 123 | * @throws UnexpectedResultException 124 | */ 125 | public function findOneByIdAndUsernameAndAccess(int $id, string $username = null, string $access = 'read') : ?Note { 126 | $result = null; 127 | try { 128 | if (null === $username) { 129 | throw new RuntimeException('empty username'); 130 | } 131 | $result = $this->getRepository() ->findOneByIdAndUsernameAndAccess( 132 | $id, 133 | $username, 134 | $access 135 | ); 136 | } catch (NoResultException $exception) { 137 | // do nothing 138 | } 139 | 140 | return $result; 141 | } 142 | 143 | /** 144 | * @param string|null $username 145 | * @param string|null $access 146 | * @return Note[] 147 | */ 148 | public function findAvailableBy(string $username = null, string $access = 'read'): array 149 | { 150 | if (null === $username) { 151 | throw new RuntimeException('empty username'); 152 | } 153 | return $this->getRepository()->findByUsernameAndAccess($username, $access); 154 | } 155 | 156 | protected function getClassName(): string 157 | { 158 | return Note::class; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/Service/SharesService.php: -------------------------------------------------------------------------------- 1 | notesRepository = $notesRepository; 36 | $this->usersRepository = $usersRepository; 37 | } 38 | 39 | 40 | 41 | /** 42 | * @return array|User[] 43 | */ 44 | public function list() : array { 45 | return $this->getRepository()->findAll(); 46 | } 47 | 48 | /** 49 | * @param int $id 50 | * @param string[] $usernames 51 | * @param string $access 52 | */ 53 | public function share(int $id, array $usernames, string $access = 'read') : void { 54 | $notes = $this->notesRepository->findByIdAndUsernamesAndWithoutAccess($id, $usernames, $access); 55 | $users = $this->usersRepository->findBy(['username' => $usernames]); 56 | foreach ($notes as $note) { 57 | foreach ($users as $user) { 58 | $share = (new Share) 59 | ->setNote($note) 60 | ->setAccess($access) 61 | ->setCreatedAt(new DateTimeImmutable()) 62 | ->setUpdatedAt(new DateTime()) 63 | ->setUser($user); 64 | $this->persist($share, false); 65 | } 66 | } 67 | 68 | $this->getManager()->flush(); 69 | } 70 | 71 | public function deshare(int $id, array $usernames, string $access) : void { 72 | $shares = $this->getRepository()->findByIdAndUsernamesAndAccess($id, $usernames, $access); 73 | 74 | foreach ($shares as $share) { 75 | $this->remove($share); 76 | } 77 | 78 | $this->getManager()->flush(); 79 | } 80 | 81 | protected function getClassName(): string 82 | { 83 | return Share::class; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Service/UsersService.php: -------------------------------------------------------------------------------- 1 | getRepository()->findAll(); 26 | } 27 | 28 | /** 29 | * @param string|null $username 30 | * @param string|null $fullname 31 | * @return User 32 | */ 33 | public function create(?string $username = null, ?string $fullname = null) : User { 34 | if (null === $username) { 35 | throw new RuntimeException('empty username'); 36 | } 37 | if ($this->getRepository()->findBy(['username' => $username])) { 38 | throw new RuntimeException('found duplicated username ' . $username); 39 | } 40 | $user = (new User()) 41 | ->setUsername($username) 42 | ->setFullname($fullname) 43 | ->setCreatedAt(new DateTimeImmutable()) 44 | ->setUpdatedAt(new DateTime()); 45 | $this->persist($user); 46 | 47 | return $user; 48 | } 49 | 50 | /** 51 | * @param int $id 52 | * @return User|object|null 53 | */ 54 | public function read(int $id) : ?User { 55 | return $this->getRepository() ->find($id); 56 | } 57 | 58 | /** 59 | * @param string|null $username 60 | * @return User|object|null 61 | */ 62 | public function findOneByUsername(string $username = null) : ?User { 63 | if (null === $username) { 64 | throw new RuntimeException('empty username'); 65 | } 66 | 67 | return $this->getRepository() ->findOneBy(['username' => $username]); 68 | } 69 | 70 | /** 71 | * @param string[] $usernames 72 | * @return User[] 73 | */ 74 | public function filterUsernames(array $usernames = []) : array { 75 | return $this->getRepository()->filterByUsernames($usernames); 76 | } 77 | 78 | /** 79 | * @param int $id 80 | * @param string|null $fullname 81 | */ 82 | public function update(int $id, ?string $fullname = null) : void { 83 | $user = $this->read($id); 84 | if ($user instanceof User) { 85 | if (null !== $fullname) { 86 | $user->setFullname($fullname); 87 | $this->persist($user); 88 | } 89 | } else { 90 | throw new RuntimeException('user not found by id ' . $id); 91 | } 92 | } 93 | 94 | /** 95 | * @param int $id 96 | * @return void 97 | */ 98 | public function delete(int $id) : void { 99 | $user = $this->read($id); 100 | if ($user instanceof User) { 101 | $this->remove($user); 102 | } 103 | } 104 | } -------------------------------------------------------------------------------- /var/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /vendor/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | --------------------------------------------------------------------------------