├── .editorconfig ├── .gitattributes ├── .gitignore ├── .htaccess ├── .travis.yml ├── LICENSE.txt ├── bin ├── cake ├── cake.bat └── cake.php ├── composer.json ├── composer.lock ├── config ├── Migrations │ ├── 20170428140000_initial_users.php │ ├── 20170428153210_initial_schema.php │ ├── 20170428153225_create_tags.php │ └── schema-dump-default.lock ├── Seeds │ └── RandomSchemaSeed.php ├── api.php ├── app.default.php ├── bootstrap.php ├── bootstrap_cli.php ├── paths.php ├── requirements.php ├── routes.php └── schema │ ├── i18n.sql │ └── sessions.sql ├── index.php ├── logo.png ├── phpunit.xml.dist ├── readme.md ├── src ├── Application.php ├── Authentication │ └── Authenticator │ │ └── AppFormAuthenticator.php ├── Console │ └── Installer.php ├── Controller │ ├── AppController.php │ ├── ErrorController.php │ └── PagesController.php ├── Model │ ├── Entity │ │ ├── Article.php │ │ ├── Comment.php │ │ ├── Favorite.php │ │ ├── Follow.php │ │ ├── Tag.php │ │ └── User.php │ ├── Factory │ │ ├── ArticlesFactory.php │ │ ├── CommentsFactory.php │ │ ├── TagsFactory.php │ │ └── UsersFactory.php │ └── Table │ │ ├── ArticlesTable.php │ │ ├── CommentsTable.php │ │ ├── FavoritesTable.php │ │ ├── FollowsTable.php │ │ ├── TagsTable.php │ │ └── UsersTable.php ├── Service │ ├── Action │ │ ├── Article │ │ │ ├── ArticleAddAction.php │ │ │ ├── ArticleDeleteAction.php │ │ │ ├── ArticleEditAction.php │ │ │ ├── ArticleFavoriteAction.php │ │ │ ├── ArticleFeedAction.php │ │ │ ├── ArticleIndexAction.php │ │ │ ├── ArticleIndexBase.php │ │ │ ├── ArticleUnfavoriteAction.php │ │ │ └── ArticleViewAction.php │ │ ├── ChildArticleAction.php │ │ ├── Comment │ │ │ ├── CommentAddAction.php │ │ │ ├── CommentDeleteAction.php │ │ │ └── CommentIndexAction.php │ │ ├── DummyAction.php │ │ ├── Extension │ │ │ └── AppPaginateExtension.php │ │ ├── Profile │ │ │ ├── ProfileFollowAction.php │ │ │ ├── ProfileUnfollowAction.php │ │ │ └── ProfileViewAction.php │ │ ├── Tag │ │ │ └── TagIndexAction.php │ │ └── User │ │ │ ├── LoginAction.php │ │ │ ├── RegisterAction.php │ │ │ ├── UserEditAction.php │ │ │ └── UserViewAction.php │ ├── ArticlesService.php │ ├── CommentsService.php │ ├── ProfilesService.php │ ├── Renderer │ │ └── AppJsonRenderer.php │ ├── TagsService.php │ ├── UserService.php │ └── UsersService.php ├── Shell │ └── ConsoleShell.php ├── Template │ ├── Element │ │ └── Flash │ │ │ ├── default.ctp │ │ │ ├── error.ctp │ │ │ └── success.ctp │ ├── Error │ │ ├── error400.ctp │ │ └── error500.ctp │ ├── Layout │ │ ├── Email │ │ │ ├── html │ │ │ │ └── default.ctp │ │ │ └── text │ │ │ │ └── default.ctp │ │ ├── ajax.ctp │ │ ├── default.ctp │ │ ├── error.ctp │ │ └── rss │ │ │ └── default.ctp │ └── Pages │ │ └── home.ctp ├── Utility │ └── Formatter.php └── View │ ├── AjaxView.php │ └── AppView.php ├── tests ├── Fixture │ ├── ArticlesFixture.php │ ├── CommentsFixture.php │ ├── FavoritesFixture.php │ ├── FollowsFixture.php │ ├── SocialAccountsFixture.php │ ├── TaggedFixture.php │ ├── TagsFixture.php │ └── UsersFixture.php ├── FixturesTrait.php ├── TestCase │ ├── IntegrationTestCase.php │ └── Service │ │ ├── ArticlesServiceTest.php │ │ ├── CommentsServiceTest.php │ │ ├── FavoritesServiceTest.php │ │ ├── LoginTest.php │ │ ├── ProfilesServiceTest.php │ │ ├── RegisterTest.php │ │ ├── TagsServiceTest.php │ │ └── UserServiceTest.php └── bootstrap.php └── webroot ├── .htaccess ├── css ├── base.css ├── cake.css └── home.css ├── favicon.ico ├── font ├── cakedingbats-webfont.eot ├── cakedingbats-webfont.svg ├── cakedingbats-webfont.ttf ├── cakedingbats-webfont.woff └── cakedingbats-webfont.woff2 ├── img ├── cake-logo.png ├── cake.icon.png ├── cake.logo.svg └── cake.power.gif ├── index.php └── js └── empty /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.bat] 14 | end_of_line = crlf 15 | 16 | [*.yml] 17 | indent_style = space 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Define the line ending behavior of the different file extensions 2 | # Set default behaviour, in case users don't have core.autocrlf set. 3 | * text=auto 4 | * text eol=lf 5 | 6 | # Explicitly declare text files we want to always be normalized and converted 7 | # to native line endings on checkout. 8 | *.php text 9 | *.default text 10 | *.ctp text 11 | *.sql text 12 | *.md text 13 | *.po text 14 | *.js text 15 | *.css text 16 | *.ini text 17 | *.properties text 18 | *.txt text 19 | *.xml text 20 | *.svg text 21 | *.yml text 22 | .htaccess text 23 | 24 | # Declare files that will always have CRLF line endings on checkout. 25 | *.bat eol=crlf 26 | 27 | # Declare files that will always have LF line endings on checkout. 28 | *.pem eol=lf 29 | 30 | # Denote all files that are truly binary and should not be modified. 31 | *.png binary 32 | *.jpg binary 33 | *.gif binary 34 | *.ico binary 35 | *.mo binary 36 | *.pdf binary 37 | *.phar binary 38 | *.woff binary 39 | *.woff2 binary 40 | *.ttf binary 41 | *.otf binary 42 | *.eot binary 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # app 2 | /config/app.php 3 | /tmp/* 4 | /logs/* 5 | /config/app_local.php 6 | /config/bootstrap.local.php 7 | 8 | 9 | # dependencies 10 | /vendor/* 11 | /node_modules 12 | /bower_components 13 | 14 | # IDEs and editors 15 | /.idea 16 | pmip 17 | .project 18 | .classpath 19 | *.launch 20 | .settings/ 21 | 22 | 23 | #System Files 24 | .DS_Store 25 | Thumbs.db 26 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | # Uncomment the following to prevent the httpoxy vulnerability 2 | # See: https://httpoxy.org/ 3 | # 4 | # RequestHeader unset Proxy 5 | # 6 | 7 | 8 | RewriteEngine on 9 | RewriteRule ^$ webroot/ [L] 10 | RewriteRule (.*) webroot/$1 [L] 11 | 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | sudo: false 4 | 5 | dist: xenial 6 | 7 | php: 8 | - 7.3 9 | - 7.4 10 | 11 | services: 12 | - postgresql 13 | - mysql 14 | 15 | cache: 16 | directories: 17 | - vendor 18 | - $HOME/.composer/cache 19 | 20 | env: 21 | matrix: 22 | - DB=mysql DATABASE_TEST_URL='mysql://root:@127.0.0.1/cakephp_test?init[]=SET sql_mode = "STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION"' 23 | - DB=pgsql DATABASE_TEST_URL='postgres://postgres@127.0.0.1/cakephp_test' 24 | - DB=sqlite DATABASE_TEST_URL='sqlite:///:memory:' 25 | 26 | global: 27 | - DEFAULT=1 28 | 29 | matrix: 30 | fast_finish: true 31 | 32 | include: 33 | - php: 7.3 34 | env: PHPCS=1 DEFAULT=0 35 | 36 | before_script: 37 | - composer self-update 38 | - composer install --prefer-dist --no-interaction 39 | - if [[ $DB == 'mysql' ]]; then mysql -u root -e 'CREATE DATABASE cakephp_test;'; fi 40 | - if [[ $DB == 'pgsql' ]]; then psql -c 'CREATE DATABASE cakephp_test;' -U postgres; fi 41 | - if [[ $PHPSTAN = 1 ]]; then composer stan-setup; fi 42 | 43 | script: 44 | - if [[ $DEFAULT = 1 ]]; then composer test; fi 45 | - if [[ $COVERAGE = 1 ]]; then composer coverage-test; fi 46 | - if [[ $PHPCS = 1 ]]; then composer cs-check; fi 47 | - if [[ $PHPSTAN = 1 ]]; then composer stan; fi 48 | 49 | after_success: 50 | - if [[ $COVERAGE = 1 ]]; then bash <(curl -s https://codecov.io/bash); fi 51 | 52 | notifications: 53 | email: false 54 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2009-2016 4 | Cake Development Corporation 5 | 1785 E. Sahara Avenue, Suite 490-423 6 | Las Vegas, Nevada 89104 7 | Phone: +1 702 425 5085 8 | https://cakedc.com 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a 11 | copy of this software and associated documentation files (the "Software"), 12 | to deal in the Software without restriction, including without limitation 13 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 14 | and/or sell copies of the Software, and to permit persons to whom the 15 | Software is furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in 18 | all copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 25 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 26 | DEALINGS IN THE SOFTWARE. 27 | -------------------------------------------------------------------------------- /bin/cake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | ################################################################################ 3 | # 4 | # Cake is a shell script for invoking CakePHP shell commands 5 | # 6 | # CakePHP(tm) : Rapid Development Framework (http://cakephp.org) 7 | # Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) 8 | # 9 | # Licensed under The MIT License 10 | # For full copyright and license information, please see the LICENSE.txt 11 | # Redistributions of files must retain the above copyright notice. 12 | # 13 | # @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) 14 | # @link http://cakephp.org CakePHP(tm) Project 15 | # @since 1.2.0 16 | # @license http://www.opensource.org/licenses/mit-license.php MIT License 17 | # 18 | ################################################################################ 19 | 20 | # Canonicalize by following every symlink of the given name recursively 21 | canonicalize() { 22 | NAME="$1" 23 | if [ -f "$NAME" ] 24 | then 25 | DIR=$(dirname -- "$NAME") 26 | NAME=$(cd -P "$DIR" > /dev/null && pwd -P)/$(basename -- "$NAME") 27 | fi 28 | while [ -h "$NAME" ]; do 29 | DIR=$(dirname -- "$NAME") 30 | SYM=$(readlink "$NAME") 31 | NAME=$(cd "$DIR" > /dev/null && cd $(dirname -- "$SYM") > /dev/null && pwd)/$(basename -- "$SYM") 32 | done 33 | echo "$NAME" 34 | } 35 | 36 | CONSOLE=$(dirname -- "$(canonicalize "$0")") 37 | APP=$(dirname "$CONSOLE") 38 | 39 | if [ $(basename $0) != 'cake' ] 40 | then 41 | exec php "$CONSOLE"/cake.php $(basename $0) "$@" 42 | else 43 | exec php "$CONSOLE"/cake.php "$@" 44 | fi 45 | 46 | exit 47 | -------------------------------------------------------------------------------- /bin/cake.bat: -------------------------------------------------------------------------------- 1 | :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: 2 | :: 3 | :: Cake is a Windows batch script for invoking CakePHP shell commands 4 | :: 5 | :: CakePHP(tm) : Rapid Development Framework (http://cakephp.org) 6 | :: Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) 7 | :: 8 | :: Licensed under The MIT License 9 | :: Redistributions of files must retain the above copyright notice. 10 | :: 11 | :: @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) 12 | :: @link http://cakephp.org CakePHP(tm) Project 13 | :: @since 2.0.0 14 | :: @license http://www.opensource.org/licenses/mit-license.php MIT License 15 | :: 16 | :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: 17 | 18 | @echo off 19 | 20 | SET app=%0 21 | SET lib=%~dp0 22 | 23 | php "%lib%cake.php" %* 24 | 25 | echo. 26 | 27 | exit /B %ERRORLEVEL% 28 | -------------------------------------------------------------------------------- /bin/cake.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php -q 2 | run($argv)); 13 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cakephp/app", 3 | "description": "CakePHP skeleton app", 4 | "homepage": "http://cakephp.org", 5 | "type": "project", 6 | "license": "MIT", 7 | "require": { 8 | "php": ">=5.6", 9 | "cakephp/cakephp": "3.8.*", 10 | "mobiledetect/mobiledetectlib": "2.*", 11 | "cakephp/migrations": "^2.0.0", 12 | "cakedc/cakephp-api": "7.0.*", 13 | "cakedc/users": "^8.0", 14 | "cakephp/authentication": "*", 15 | "firebase/php-jwt": "~4.0", 16 | "muffin/slug": "1.*", 17 | "muffin/tags": "dev-master", 18 | "skie/cakephp-factory-muffin": "*", 19 | "cakephp/plugin-installer": "*" 20 | }, 21 | "require-dev": { 22 | "psy/psysh": "@stable", 23 | "cakephp\/debug_kit": "^3.17.0", 24 | "phpunit/phpunit": "6.*", 25 | "cakephp\/cakephp-codesniffer": "^3.0", 26 | "cakephp\/bake": "^1.9.0" 27 | }, 28 | "suggest": { 29 | "markstory/asset_compress": "An asset compression plugin which provides file concatenation and a flexible filter system for preprocessing and minification." 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "App\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "App\\Test\\": "tests", 39 | "Cake\\Test\\": "./vendor/cakephp/cakephp/tests" 40 | } 41 | }, 42 | "scripts": { 43 | "post-install-cmd": "App\\Console\\Installer::postInstall", 44 | "post-create-project-cmd": "App\\Console\\Installer::postInstall", 45 | "post-autoload-dump": "Cake\\Composer\\Installer\\PluginInstaller::postAutoloadDump", 46 | "check": [ 47 | "@test", 48 | "@cs-check" 49 | ], 50 | "cs-check": "phpcs --colors -p --standard=vendor/cakephp/cakephp-codesniffer/CakePHP ./src ./tests", 51 | "cs-fix": "phpcbf --colors --standard=vendor/cakephp/cakephp-codesniffer/CakePHP ./src ./tests", 52 | "test": "phpunit --colors=always" 53 | }, 54 | "config": { 55 | "minimum-stability": "dev" 56 | }, 57 | "repositories": [ 58 | { 59 | "type": "vcs", 60 | "url": "git@github.com:skie/Tags-1.git" 61 | } 62 | ], 63 | "minimum-stability": "dev", 64 | "prefer-stable": true 65 | } 66 | -------------------------------------------------------------------------------- /config/Migrations/20170428153210_initial_schema.php: -------------------------------------------------------------------------------- 1 | table('articles', ['id' => false, 'primary_key' => ['id']]) 26 | ->addColumn('id', 'uuid', [ 27 | 'null' => false, 28 | ]) 29 | ->addColumn('title', 'string', [ 30 | 'limit' => 255, 31 | 'null' => false, 32 | ]) 33 | ->addColumn('slug', 'string', [ 34 | 'default' => null, 35 | 'limit' => 255, 36 | 'null' => true, 37 | ]) 38 | ->addColumn('description', 'text', [ 39 | 'null' => true, 40 | ]) 41 | ->addColumn('body', 'text', [ 42 | 'null' => true, 43 | ]) 44 | ->addColumn('author_id', 'uuid', [ 45 | 'default' => null, 46 | 'null' => false, 47 | ]) 48 | ->addColumn('favorites_count', 'integer', [ 49 | 'default' => 0, 50 | 'null' => false, 51 | 'limit' => 11, 52 | ]) 53 | ->addColumn('tag_count', 'integer', [ 54 | 'default' => 0, 55 | 'null' => false, 56 | 'limit' => 11, 57 | ]) 58 | ->addColumn('created', 'datetime', [ 59 | 'null' => false, 60 | ]) 61 | ->addColumn('modified', 'datetime', [ 62 | 'null' => false, 63 | ]) 64 | ->addIndex(['author_id']) 65 | ->addIndex(['slug'], [ 66 | 'unique' => true, 67 | ]) 68 | ->create(); 69 | 70 | $this->table('comments', ['id' => false, 'primary_key' => ['id']]) 71 | ->addColumn('id', 'uuid', [ 72 | 'null' => false, 73 | ]) 74 | ->addColumn('body', 'text', [ 75 | 'null' => true, 76 | ]) 77 | ->addColumn('article_id', 'uuid', [ 78 | 'default' => null, 79 | 'null' => false, 80 | ]) 81 | ->addColumn('author_id', 'uuid', [ 82 | 'default' => null, 83 | 'null' => false, 84 | ]) 85 | ->addColumn('created', 'datetime', [ 86 | 'null' => false, 87 | ]) 88 | ->addColumn('modified', 'datetime', [ 89 | 'null' => false, 90 | ]) 91 | ->addIndex(['article_id']) 92 | ->create(); 93 | 94 | $this->table('follows', ['id' => false, 'primary_key' => ['id']]) 95 | ->addColumn('id', 'uuid', [ 96 | 'null' => false, 97 | ]) 98 | ->addColumn('followable_id', 'uuid', [ 99 | 'null' => false, 100 | ]) 101 | ->addColumn('follower_id', 'uuid', [ 102 | 'null' => false, 103 | ]) 104 | ->addColumn('blocked', 'boolean', [ 105 | 'null' => false, 106 | ]) 107 | ->addColumn('created', 'datetime', [ 108 | 'null' => false, 109 | ]) 110 | ->addColumn('modified', 'datetime', [ 111 | 'null' => false, 112 | ]) 113 | ->addIndex(['followable_id']) 114 | ->addIndex(['follower_id', 'followable_id']) 115 | ->create(); 116 | 117 | $this->table('favorites', ['id' => false, 'primary_key' => ['id']]) 118 | ->addColumn('id', 'uuid', [ 119 | 'null' => false, 120 | ]) 121 | ->addColumn('user_id', 'uuid', [ 122 | 'null' => false, 123 | ]) 124 | ->addColumn('article_id', 'uuid', [ 125 | 'null' => false, 126 | ]) 127 | ->addColumn('created', 'datetime', [ 128 | 'null' => false, 129 | ]) 130 | ->addColumn('modified', 'datetime', [ 131 | 'null' => false, 132 | ]) 133 | ->addIndex(['article_id', 'user_id']) 134 | ->addIndex(['user_id']) 135 | ->create(); 136 | } 137 | } 138 | // @codingStandardsIgnoreEnd 139 | -------------------------------------------------------------------------------- /config/Migrations/20170428153225_create_tags.php: -------------------------------------------------------------------------------- 1 | table('tags_tags', ['id' => false, 'primary_key' => ['id']]); 26 | $table->addColumn('id', 'uuid', [ 27 | 'null' => false, 28 | ]); 29 | $table->addColumn('namespace', 'string', [ 30 | 'default' => null, 31 | 'limit' => 255, 32 | 'null' => true, 33 | ]); 34 | $table->addColumn('slug', 'string', [ 35 | 'default' => null, 36 | 'limit' => 255, 37 | 'null' => false, 38 | ]); 39 | $table->addColumn('label', 'string', [ 40 | 'default' => null, 41 | 'limit' => 255, 42 | 'null' => false, 43 | ]); 44 | $table->addColumn('counter', 'integer', [ 45 | 'default' => 0, 46 | 'length' => 11, 47 | 'null' => false, 48 | 'signed' => false, 49 | ]); 50 | $table->addColumn('created', 'datetime', [ 51 | 'default' => null, 52 | 'null' => true, 53 | ]); 54 | $table->addColumn('modified', 'datetime', [ 55 | 'default' => null, 56 | 'null' => true, 57 | ]); 58 | $table->create(); 59 | 60 | $table = $this->table('tags_tagged', ['id' => false, 'primary_key' => ['id']]); 61 | $table->addColumn('id', 'uuid', [ 62 | 'null' => false, 63 | ]); 64 | $table->addColumn('tag_id', 'uuid', [ 65 | 'default' => null, 66 | 'null' => true, 67 | ]); 68 | $table->addColumn('fk_id', 'uuid', [ 69 | 'default' => null, 70 | 'null' => true, 71 | ]); 72 | $table->addColumn('fk_table', 'string', [ 73 | 'default' => null, 74 | 'limit' => 255, 75 | 'null' => false, 76 | ]); 77 | $table->addColumn('created', 'datetime', [ 78 | 'default' => null, 79 | 'null' => true, 80 | ]); 81 | $table->addColumn('modified', 'datetime', [ 82 | 'default' => null, 83 | 'null' => true, 84 | ]); 85 | $table->create(); 86 | 87 | $table = $this->table('tags_tags'); 88 | $table->addColumn('tag_key', 'string', [ 89 | 'default' => null, 90 | 'limit' => 255, 91 | 'null' => false, 92 | ]); 93 | $table->addIndex(['tag_key', 'label', 'namespace'], ['unique' => true]); 94 | $table->update(); 95 | $table = $this->table('tags_tagged'); 96 | $table->addIndex(['tag_id', 'fk_id', 'fk_table'], ['unique' => true]); 97 | $table->update(); 98 | } 99 | } 100 | // @codingStandardsIgnoreEnd 101 | -------------------------------------------------------------------------------- /config/Seeds/RandomSchemaSeed.php: -------------------------------------------------------------------------------- 1 | totalTags, 'Tags'); 84 | $users = FactoryLoader::seed($this->totalUsers, 'Users'); 85 | 86 | collection($users)->filter(function ($item) { 87 | return rand(1, $this->totalUsers) < $this->userWithArticleRatio * $this->totalUsers; 88 | })->each(function ($user) { 89 | $articles = FactoryLoader::seed(rand(1, $this->maxArticlesByUser), 'Articles', ['author_id' => $user->id]); 90 | collection($articles)->each(function ($article) { 91 | $comments = FactoryLoader::seed(rand(1, $this->maxCommentsInArticle), 'Comments', [ 92 | 'article_id' => $article->id, 93 | ]); 94 | }); 95 | }); 96 | 97 | $articles = TableRegistry::getTableLocator()->get('Articles')->find()->select(['id'])->all(); 98 | 99 | $favoritesCount = count($users) * $this->usersWithFavoritesRatio; 100 | $Favorites = TableRegistry::getTableLocator()->get('Favorites'); 101 | for ($i = 1; $i < $favoritesCount; $i++) { 102 | $user = $users[$i]; 103 | $articles->shuffle()->take(rand(1, floor($articles->count() / 2)))->each( 104 | function ($article) use ($Favorites, $user) { 105 | $Favorites->save($Favorites->newEntity([ 106 | 'user_id' => $user['id'], 107 | 'article_id' => $article['id'], 108 | ])); 109 | } 110 | ); 111 | } 112 | 113 | $followingCount = count($users) * $this->usersWithFollowingRatio; 114 | $Follows = \Cake\ORM\TableRegistry::get('Follows'); 115 | for ($i = 1; $i < $followingCount; $i++) { 116 | $user = $users[$i]; 117 | collection($users) 118 | ->reject(function ($item) use ($user) { 119 | return $item->id === $user->id; 120 | }) 121 | ->shuffle() 122 | ->take(rand(1, (int)(count($users) - 1) * 0.2)) 123 | ->each( 124 | function ($follow) use ($Follows, $user) { 125 | $Follows->save($Follows->newEntity([ 126 | 'follower_id' => $user['id'], 127 | 'followable_id' => $follow['id'], 128 | ])); 129 | } 130 | ); 131 | } 132 | } 133 | } 134 | // @codingStandardsIgnoreEnd 135 | -------------------------------------------------------------------------------- /config/api.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'ServiceFallback' => '\\CakeDC\\Api\\Service\\FallbackService', 15 | 'renderer' => 'AppJson', 16 | 'parser' => 'CakeDC/Api.Form', 17 | 18 | 'useVersioning' => false, 19 | 'versionPrefix' => 'v', 20 | 21 | 'Auth' => [ 22 | 'Crud' => [ 23 | 'default' => 'auth' 24 | ], 25 | ], 26 | 27 | 'Service' => [ 28 | 'default' => [ 29 | 'options' => [ 30 | 'Extension' => [ 31 | 'CakeDC/Api.OptionsHandler' 32 | ], 33 | ], 34 | 'Action' => [ 35 | 'default' => [ 36 | 'Auth' => [ 37 | 'authenticate' => [ 38 | 'CakeDC/Api.Psr7' 39 | ], 40 | ], 41 | 'Extension' => [ 42 | 'CakeDC/Api.Cors', 43 | ] 44 | ], 45 | ], 46 | ], 47 | ], 48 | ] 49 | ]; 50 | -------------------------------------------------------------------------------- /config/bootstrap_cli.php: -------------------------------------------------------------------------------- 1 | connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']); 44 | 45 | /** 46 | * ...and connect the rest of 'Pages' controller's URLs. 47 | */ 48 | $routes->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']); 49 | 50 | /** 51 | * Connect catchall routes for all controllers. 52 | * 53 | * Using the argument `DashedRoute`, the `fallbacks` method is a shortcut for 54 | * `$routes->connect('/:controller', ['action' => 'index'], ['routeClass' => 'DashedRoute']);` 55 | * `$routes->connect('/:controller/:action/*', [], ['routeClass' => 'DashedRoute']);` 56 | * 57 | * Any route class can be used with this method, such as: 58 | * - DashedRoute 59 | * - InflectedRoute 60 | * - Route 61 | * - Or your own route class 62 | * 63 | * You can remove these routes once you've connected the 64 | * routes you want in your application. 65 | */ 66 | $routes->fallbacks(DashedRoute::class); 67 | }); 68 | -------------------------------------------------------------------------------- /config/schema/i18n.sql: -------------------------------------------------------------------------------- 1 | # Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) 2 | # 3 | # Licensed under The MIT License 4 | # For full copyright and license information, please see the LICENSE.txt 5 | # Redistributions of files must retain the above copyright notice. 6 | # MIT License (http://www.opensource.org/licenses/mit-license.php) 7 | 8 | CREATE TABLE i18n ( 9 | id int NOT NULL auto_increment, 10 | locale varchar(6) NOT NULL, 11 | model varchar(255) NOT NULL, 12 | foreign_key int(10) NOT NULL, 13 | field varchar(255) NOT NULL, 14 | content text, 15 | PRIMARY KEY (id), 16 | UNIQUE INDEX I18N_LOCALE_FIELD(locale, model, foreign_key, field), 17 | INDEX I18N_FIELD(model, foreign_key, field) 18 | ); 19 | -------------------------------------------------------------------------------- /config/schema/sessions.sql: -------------------------------------------------------------------------------- 1 | # Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) 2 | # 3 | # Licensed under The MIT License 4 | # For full copyright and license information, please see the LICENSE.txt 5 | # Redistributions of files must retain the above copyright notice. 6 | # MIT License (http://www.opensource.org/licenses/mit-license.php) 7 | 8 | CREATE TABLE sessions ( 9 | id char(40) NOT NULL, 10 | data text, 11 | expires INT(11) NOT NULL, 12 | PRIMARY KEY (id) 13 | ); 14 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ./tests/TestCase 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ./src/ 37 | ./plugins/*/src/ 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ![CakePHP RealWorld Example App](logo.png) 2 | 3 | [![Build Status](https://img.shields.io/travis/gothinkster/cakephp-realworld-example-app/master.svg)](https://travis-ci.org/gothinkster/cakephp-realworld-example-app) [![GitHub stars](https://img.shields.io/github/stars/gothinkster/cakephp-realworld-example-app.svg)](https://github.com/gothinkster/cakephp-realworld-example-app/stargazers) [![GitHub license](https://img.shields.io/badge/License-MIT-green.svg)](https://raw.githubusercontent.com/gothinkster/cakephp-realworld-example-app/master/LICENSE.txt) 4 | 5 | > ### Example CakePHP codebase containing real world examples (CRUD, auth, advanced patterns and more) that adheres to the [RealWorld](https://github.com/gothinkster/realworld-example-apps) spec and API. 6 | 7 | This repo is functionality complete — PRs and issues welcome! 8 | 9 | ---------- 10 | 11 | # Getting started 12 | 13 | ## Installation 14 | 15 | Please check the official cakephp installation guide for server requirements before you start. [Official Documentation](https://book.cakephp.org/3.0/en/installation.html) 16 | 17 | Clone the repository 18 | 19 | git clone git@github.com:gothinkster/cakephp-realworld-example-app.git 20 | 21 | Switch to the repo folder 22 | 23 | cd cakephp-realworld-example-app 24 | 25 | Install all the dependencies using composer 26 | 27 | composer install 28 | 29 | Configure your database settings in the `config/app.php` file(See: Datasource/default) 30 | 31 | vi config/app.php 32 | 33 | Run the database migrations (**Set the database connection in app.php**) 34 | 35 | bin/cake migrations migrate 36 | 37 | Start the local development server 38 | 39 | bin/cake server 40 | 41 | You can now access the server at http://localhost:8765 42 | 43 | ## Database seeding 44 | 45 | **Populate the database with seed data with relationships which includes users, articles, comments, tags, favorites and follows. This can help you to quickly start testing the api or couple a frontend and start using it with ready content.** 46 | 47 | Run the database seeder and you're done 48 | 49 | bin/cake migrations seed 50 | 51 | ## API Specification 52 | 53 | This application adheres to the api specifications set by the [Thinkster](https://github.com/gothinkster) team. This helps mix and match any backend with any other frontend without conflicts. 54 | 55 | > [Full API Spec](https://github.com/gothinkster/realworld/tree/master/api) 56 | 57 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 58 | 59 | 60 | # How it works 61 | 62 | ---------- 63 | 64 | # Code overview 65 | 66 | ## Dependencies 67 | 68 | - [cakedc/cakephp-api](https://github.com/cakedc/cakephp-api) - For easy REST api implementation. 69 | - [muffin/slug](https://github.com/UseMuffin/Slug) - For auto generation of unique slugs. 70 | - [muffin/tags](https://github.com/UseMuffin/Tags) - For tags managements. 71 | - [cakephp/authentication](https://github.com/cakephp/authentication) - For authentication using JSON Web Tokens 72 | 73 | ## Folders 74 | 75 | - `src` - Contains all the application logic. 76 | - `config` - Contains all the application configuration files. 77 | - `src/Model/Entity` - Contains all cakephp ORM entites. 78 | - `src/Model/Table` - Contains all cakephp ORM tables. 79 | - `src/Service` - Contains application services that represents root api endpoints. 80 | - `src/Service/Action` - Contains application endpoints logic logic. 81 | - `src/Service/Renderer` - Contains the final api response formatter. 82 | - `/config/Migrations` - Contains all the database migrations. 83 | 84 | ## Environment configuration 85 | 86 | - `config/app.php` - Configuration settings can be set in this file 87 | 88 | ***Note*** : You can quickly set the database information and other variables in this file and have the application fully working. 89 | 90 | ---------- 91 | 92 | # Testing API 93 | 94 | Run the cakephp development server 95 | 96 | bin/cake server 97 | 98 | The api can now be accessed at 99 | 100 | http://localhost:8765/api 101 | 102 | Request headers 103 | 104 | | **Required** | **Key** | **Value** | 105 | |---------- |------------------ |------------------ | 106 | | Yes | Content-Type | application/json | 107 | | Yes | X-Requested-With | XMLHttpRequest | 108 | | Optional | Authorization | Token {JWT} | 109 | 110 | Refer the [api specification](#api-specification) for more info. 111 | 112 | ---------- 113 | 114 | # Authentication 115 | 116 | This applications uses JSON Web Token (JWT) to handle authentication. The token is passed with each request using the `Authorization` header with `Token` scheme. The cakephp authenticate middleware configured for handling JWT authentication and validation and authentication of the token. Please check the following sources to learn more about JWT. 117 | 118 | - https://jwt.io/introduction/ 119 | - https://self-issued.info/docs/draft-ietf-oauth-json-web-token.html 120 | 121 | ---------- 122 | 123 | # Cross-Origin Resource Sharing (CORS) 124 | 125 | This applications has CORS enabled by default on all API endpoints. Please check the following sources to learn more about CORS. 126 | 127 | - https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS 128 | - https://en.wikipedia.org/wiki/Cross-origin_resource_sharing 129 | - https://www.w3.org/TR/cors 130 | -------------------------------------------------------------------------------- /src/Application.php: -------------------------------------------------------------------------------- 1 | addPlugin('Bake'); 43 | } catch (MissingPluginException $e) { 44 | // Do not halt if the plugin is missing 45 | } 46 | } 47 | 48 | $this->addPlugin('Migrations'); 49 | 50 | $this->addPlugin('Muffin/Slug'); 51 | $this->addPlugin('Muffin/Tags'); 52 | 53 | if (Configure::read('debug')) { 54 | $this->addPlugin('DebugKit', ['bootstrap' => true]); 55 | } 56 | 57 | $this->addPlugin('CakeDC/Api', ['bootstrap' => false, 'routes' => true]); 58 | } 59 | 60 | /** 61 | * Setup the middleware your application will use. 62 | * 63 | * @param \Cake\Http\MiddlewareQueue $middleware The middleware queue to setup. 64 | * @return \Cake\Http\MiddlewareQueue The updated middleware. 65 | */ 66 | public function middleware($middleware) 67 | { 68 | $service = new AuthenticationService(); 69 | 70 | $service->loadIdentifier('Authentication.JwtSubject', [ 71 | 'dataField' => 'sub', 72 | ]); 73 | $service->loadAuthenticator('Authentication.Jwt', [ 74 | 'tokenPrefix' => 'Token', 75 | 'returnPayload' => false, 76 | ]); 77 | 78 | $service->loadAuthenticator(AppFormAuthenticator::class, [ 79 | 'baseModel' => 'user', 80 | 'fields' => [ 81 | 'username' => 'email', 82 | 'password' => 'password', 83 | ], 84 | ]); 85 | $service->loadIdentifier(\Authentication\Identifier\PasswordIdentifier::class, [ 86 | 'fields' => [ 87 | 'username' => 'email', 88 | 'password' => 'password', 89 | ], 90 | ]); 91 | 92 | // Add it to the authentication middleware 93 | $authentication = new AuthenticationMiddleware($service); 94 | 95 | $middleware 96 | // Catch any exceptions in the lower layers, 97 | // and make an error page/response 98 | ->add(ErrorHandlerMiddleware::class) 99 | 100 | ->add(new RequestHandlerMiddleware()) 101 | ->add($authentication) 102 | ->add(new ApiMiddleware()) 103 | 104 | // Handle plugin/theme assets like CakePHP normally does. 105 | ->add(AssetMiddleware::class) 106 | 107 | // Apply routing 108 | ->add(RoutingMiddleware::class); 109 | 110 | return $middleware; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Authentication/Authenticator/AppFormAuthenticator.php: -------------------------------------------------------------------------------- 1 | _checkUrl($request)) { 40 | $errors = [ 41 | sprintf( 42 | 'Login URL %s did not match %s', 43 | $request->getUri()->getPath(), 44 | $this->getConfig('loginUrl') 45 | ), 46 | ]; 47 | 48 | return new Result(null, Result::FAILURE_CREDENTIALS_INVALID, $errors); 49 | } 50 | 51 | $data = $this->_getData($request); 52 | if ($data === null) { 53 | return new Result(null, Result::FAILURE_CREDENTIALS_MISSING, [ 54 | 'Login credentials not found', 55 | ]); 56 | } 57 | 58 | $user = $this->getIdentifier()->identify($data); 59 | 60 | if (empty($user)) { 61 | return new Result(null, Result::FAILURE_CREDENTIALS_MISSING, $this->getIdentifier()->getErrors()); 62 | } 63 | 64 | return new Result($user, Result::SUCCESS); 65 | } 66 | 67 | /** 68 | * Checks the fields to ensure they are supplied. 69 | * 70 | * @param \Psr\Http\Message\ServerRequestInterface $request The request that contains login information. 71 | * @return array|null Username and password retrieved from a request body. 72 | */ 73 | protected function _getData(ServerRequestInterface $request) 74 | { 75 | $fields = $this->_config['fields']; 76 | $body = Hash::get($request->getParsedBody(), $this->getConfig('baseModel')); 77 | 78 | $data = []; 79 | foreach ($fields as $key => $field) { 80 | if (!isset($body[$field])) { 81 | return null; 82 | } 83 | 84 | $value = $body[$field]; 85 | if (!is_string($value) || !strlen($value)) { 86 | return null; 87 | } 88 | 89 | $data[$key] = $value; 90 | } 91 | 92 | return $data; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Controller/AppController.php: -------------------------------------------------------------------------------- 1 | loadComponent('Security');` 37 | * 38 | * @return void 39 | */ 40 | public function initialize() 41 | { 42 | parent::initialize(); 43 | 44 | $this->loadComponent('RequestHandler'); 45 | $this->loadComponent('Flash'); 46 | 47 | /* 48 | * Enable the following components for recommended CakePHP security settings. 49 | * see http://book.cakephp.org/3.0/en/controllers/components/security.html 50 | */ 51 | //$this->loadComponent('Security'); 52 | //$this->loadComponent('Csrf'); 53 | } 54 | 55 | /** 56 | * Before render callback. 57 | * 58 | * @param \Cake\Event\Event $event The beforeRender event. 59 | * @return \Cake\Http\Response|null|void 60 | */ 61 | public function beforeRender(Event $event) 62 | { 63 | if ( 64 | !array_key_exists('_serialize', $this->viewVars) && 65 | in_array($this->response->type(), ['application/json', 'application/xml']) 66 | ) { 67 | $this->set('_serialize', true); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Controller/ErrorController.php: -------------------------------------------------------------------------------- 1 | loadComponent('RequestHandler'); 34 | } 35 | 36 | /** 37 | * beforeFilter callback. 38 | * 39 | * @param \Cake\Event\Event $event Event. 40 | * @return \Cake\Http\Response|null|void 41 | */ 42 | public function beforeFilter(Event $event) 43 | { 44 | } 45 | 46 | /** 47 | * beforeRender callback. 48 | * 49 | * @param \Cake\Event\Event $event Event. 50 | * @return \Cake\Http\Response|null|void 51 | */ 52 | public function beforeRender(Event $event) 53 | { 54 | parent::beforeRender($event); 55 | 56 | $this->viewBuilder()->setTemplatePath('Error'); 57 | } 58 | 59 | /** 60 | * afterFilter callback. 61 | * 62 | * @param \Cake\Event\Event $event Event. 63 | * @return \Cake\Http\Response|null|void 64 | */ 65 | public function afterFilter(Event $event) 66 | { 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Controller/PagesController.php: -------------------------------------------------------------------------------- 1 | redirect('/'); 46 | } 47 | if (in_array('..', $path, true) || in_array('.', $path, true)) { 48 | throw new ForbiddenException(); 49 | } 50 | $page = $subpage = null; 51 | 52 | if (!empty($path[0])) { 53 | $page = $path[0]; 54 | } 55 | if (!empty($path[1])) { 56 | $subpage = $path[1]; 57 | } 58 | $this->set(compact('page', 'subpage')); 59 | 60 | try { 61 | $this->render(implode('/', $path)); 62 | } catch (MissingTemplateException $e) { 63 | if (Configure::read('debug')) { 64 | throw $e; 65 | } 66 | throw new NotFoundException(); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Model/Entity/Article.php: -------------------------------------------------------------------------------- 1 | true, 46 | 'id' => false, 47 | ]; 48 | } 49 | -------------------------------------------------------------------------------- /src/Model/Entity/Comment.php: -------------------------------------------------------------------------------- 1 | true, 43 | 'id' => false, 44 | ]; 45 | } 46 | -------------------------------------------------------------------------------- /src/Model/Entity/Favorite.php: -------------------------------------------------------------------------------- 1 | true, 42 | 'id' => false, 43 | ]; 44 | } 45 | -------------------------------------------------------------------------------- /src/Model/Entity/Follow.php: -------------------------------------------------------------------------------- 1 | true, 40 | 'id' => false, 41 | ]; 42 | } 43 | -------------------------------------------------------------------------------- /src/Model/Entity/Tag.php: -------------------------------------------------------------------------------- 1 | true, 41 | 'id' => false, 42 | ]; 43 | 44 | /** 45 | * Label getter. 46 | * 47 | * @return string 48 | */ 49 | // @codingStandardsIgnoreStart 50 | public function _getTag() 51 | { 52 | return $this->get('label'); 53 | } 54 | // @codingStandardsIgnoreEnd 55 | } 56 | -------------------------------------------------------------------------------- /src/Model/Entity/User.php: -------------------------------------------------------------------------------- 1 | true, 57 | 'id' => false, 58 | ]; 59 | 60 | /** 61 | * Fields that are excluded from JSON versions of the entity. 62 | * 63 | * @var array 64 | */ 65 | protected $_hidden = [ 66 | 'password', 67 | 'token', 68 | ]; 69 | 70 | /** 71 | * @param string $password password that will be set. 72 | * @return bool|string 73 | */ 74 | protected function _setPassword($password) 75 | { 76 | return $this->hashPassword($password); 77 | } 78 | 79 | /** 80 | * Hash a password using the configured password hasher, 81 | * use DefaultPasswordHasher if no one was configured 82 | * 83 | * @param string $password password to be hashed 84 | * @return mixed 85 | */ 86 | public function hashPassword($password) 87 | { 88 | $PasswordHasher = $this->getPasswordHasher(); 89 | 90 | return $PasswordHasher->hash($password); 91 | } 92 | 93 | /** 94 | * Return the configured Password Hasher 95 | * 96 | * @return mixed 97 | */ 98 | public function getPasswordHasher() 99 | { 100 | $passwordHasher = Configure::read('Users.passwordHasher'); 101 | if (!class_exists($passwordHasher)) { 102 | $passwordHasher = '\Cake\Auth\DefaultPasswordHasher'; 103 | } 104 | 105 | return new $passwordHasher(); 106 | } 107 | 108 | /** 109 | * Builds jwt token. 110 | * 111 | * @return string 112 | */ 113 | // @codingStandardsIgnoreStart 114 | public function _getToken() 115 | { 116 | $content = [ 117 | 'id' => $this->get('id'), 118 | 'sub' => $this->get('id'), 119 | 'exp' => time() + 60 * DAY 120 | ]; 121 | return JWT::encode($content, Security::getSalt()); 122 | } 123 | // @codingStandardsIgnoreEnd 124 | } 125 | -------------------------------------------------------------------------------- /src/Model/Factory/ArticlesFactory.php: -------------------------------------------------------------------------------- 1 | true, 21 | 'title' => Faker::sentence(), 22 | 'description_length' => Faker::numberBetween(3, 7), 23 | 'description' => function ($item) { 24 | $paragraphs = Faker::paragraphs($item['description_length'], true); 25 | 26 | return $paragraphs(); 27 | }, 28 | 'body_length' => Faker::numberBetween(2, 5), 29 | 'body' => function ($item) { 30 | $paragraphs = Faker::paragraphs($item['body_length'], true); 31 | 32 | return $paragraphs(); 33 | }, 34 | 'created' => Faker::dateTimeBetween('-2 year', 'now'), 35 | 'modified' => Faker::dateTimeBetween('-2 year', 'now'), 36 | 'tagList' => function ($item) { 37 | $tags = TableRegistry::getTableLocator()->get('Tags')->find()->select(['label']) 38 | ->all() 39 | ->shuffle() 40 | ->take(5) 41 | ->map(function ($i) { 42 | return $i['label']; 43 | }) 44 | ->reduce(function ($accum, $item) { 45 | return ($accum ? $item . ' ' . $accum : $item); 46 | }); 47 | 48 | return $tags; 49 | }, 50 | ]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Model/Factory/CommentsFactory.php: -------------------------------------------------------------------------------- 1 | Faker::numberBetween(2, 5), 21 | 'body' => function ($item) { 22 | $paragraphs = Faker::paragraphs($item['body_length'], true); 23 | 24 | return $paragraphs(); 25 | }, 26 | 'created' => Faker::dateTimeBetween('-2 year', 'now'), 27 | 'modified' => Faker::dateTimeBetween('-2 year', 'now'), 28 | // 'author_id' => function ($item) { 29 | // $users = TableRegistry::get('Users')->find()->select(['id'])->all()->toArray(); 30 | // 31 | // return $users[rand(0, count($users) - 1)]->id; 32 | // } 33 | 'author_id' => 'factory|' . UsersFactory::class, 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Model/Factory/TagsFactory.php: -------------------------------------------------------------------------------- 1 | Faker::unique()->word(), 20 | 'tag_key' => function ($item) { 21 | return $item['label']; 22 | }, 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Model/Factory/UsersFactory.php: -------------------------------------------------------------------------------- 1 | Faker::firstName(), 20 | 'last_name' => Faker::lastName(), 21 | 'username' => Faker::unique()->firstName(), 22 | 'active' => true, 23 | 'password' => 'passwd', 24 | 'email' => Faker::unique()->safeEmail(), 25 | 'bio' => Faker::sentence(), 26 | 'image' => Faker::imageUrl(100, 100, 'people'), 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Model/Table/CommentsTable.php: -------------------------------------------------------------------------------- 1 | setTable('comments'); 51 | $this->setDisplayField('id'); 52 | $this->setPrimaryKey('id'); 53 | 54 | $this->addBehavior('Timestamp'); 55 | 56 | $this->belongsTo('Articles', [ 57 | 'foreignKey' => 'article_id', 58 | 'joinType' => 'INNER', 59 | ]); 60 | $this->belongsTo('Authors', [ 61 | 'className' => 'Users', 62 | 'foreignKey' => 'author_id', 63 | 'joinType' => 'INNER', 64 | ]); 65 | } 66 | 67 | /** 68 | * Default validation rules. 69 | * 70 | * @param \Cake\Validation\Validator $validator Validator instance. 71 | * @return \Cake\Validation\Validator 72 | */ 73 | public function validationDefault(Validator $validator) 74 | { 75 | $validator 76 | ->uuid('id') 77 | ->allowEmpty('id', 'create'); 78 | 79 | $validator 80 | ->allowEmpty('body'); 81 | 82 | return $validator; 83 | } 84 | 85 | /** 86 | * Returns a rules checker object that will be used for validating 87 | * application integrity. 88 | * 89 | * @param \Cake\ORM\RulesChecker $rules The rules object to be modified. 90 | * @return \Cake\ORM\RulesChecker 91 | */ 92 | public function buildRules(RulesChecker $rules) 93 | { 94 | $rules->add($rules->existsIn(['article_id'], 'Articles')); 95 | $rules->add($rules->existsIn(['author_id'], 'Authors')); 96 | 97 | return $rules; 98 | } 99 | 100 | /** 101 | * Api finder and endpoint formatter. 102 | * 103 | * @param \Cake\ORM\Query $query Query object. 104 | * @param array $options Query options. 105 | * @return \Cake\ORM\Query The query builder. 106 | */ 107 | public function findApiFormat(Query $query, array $options) 108 | { 109 | return $query 110 | ->select(['id', 'body', 'created', 'modified', 'author_id']) 111 | ->order(['Comments.created' => 'desc']) 112 | ->formatResults(function ($results) use ($options) { 113 | return $results->map(function ($row) use ($options) { 114 | if ($row === null) { 115 | return $row; 116 | } 117 | $row = Formatter::dateFormat($row); 118 | $row['author'] = TableRegistry::getTableLocator()->get('Users')->getFormatted($row['author_id'], $options); 119 | unset($row['author_id']); 120 | 121 | return $row; 122 | }); 123 | }); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Model/Table/FavoritesTable.php: -------------------------------------------------------------------------------- 1 | setTable('favorites'); 49 | $this->setDisplayField('id'); 50 | $this->setPrimaryKey('id'); 51 | 52 | $this->addBehavior('Timestamp'); 53 | $this->addBehavior('CounterCache', [ 54 | 'Articles' => ['favorites_count'], 55 | ]); 56 | 57 | $this->belongsTo('Users', [ 58 | 'foreignKey' => 'user_id', 59 | 'joinType' => 'INNER', 60 | ]); 61 | $this->belongsTo('Articles', [ 62 | 'foreignKey' => 'article_id', 63 | 'joinType' => 'INNER', 64 | ]); 65 | } 66 | 67 | /** 68 | * Default validation rules. 69 | * 70 | * @param \Cake\Validation\Validator $validator Validator instance. 71 | * @return \Cake\Validation\Validator 72 | */ 73 | public function validationDefault(Validator $validator) 74 | { 75 | $validator 76 | ->uuid('id') 77 | ->allowEmpty('id', 'create'); 78 | 79 | return $validator; 80 | } 81 | 82 | /** 83 | * Returns a rules checker object that will be used for validating 84 | * application integrity. 85 | * 86 | * @param \Cake\ORM\RulesChecker $rules The rules object to be modified. 87 | * @return \Cake\ORM\RulesChecker 88 | */ 89 | public function buildRules(RulesChecker $rules) 90 | { 91 | $rules->add($rules->existsIn(['user_id'], 'Users')); 92 | $rules->add($rules->existsIn(['article_id'], 'Articles')); 93 | 94 | return $rules; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Model/Table/FollowsTable.php: -------------------------------------------------------------------------------- 1 | setTable('follows'); 46 | $this->setDisplayField('id'); 47 | $this->setPrimaryKey('id'); 48 | 49 | $this->addBehavior('Timestamp'); 50 | $this->belongsTo('Followers', [ 51 | 'className' => 'Users', 52 | 'foreignKey' => 'follower_id', 53 | 'joinType' => 'INNER', 54 | ]); 55 | $this->belongsTo('Followables', [ 56 | 'className' => 'Users', 57 | 'foreignKey' => 'followable_id', 58 | 'joinType' => 'INNER', 59 | ]); 60 | } 61 | 62 | /** 63 | * Default validation rules. 64 | * 65 | * @param \Cake\Validation\Validator $validator Validator instance. 66 | * @return \Cake\Validation\Validator 67 | */ 68 | public function validationDefault(Validator $validator) 69 | { 70 | $validator 71 | ->uuid('id') 72 | ->allowEmpty('id', 'create'); 73 | 74 | $validator 75 | ->requirePresence('followable_id', 'create') 76 | ->notEmpty('followable_id'); 77 | 78 | $validator 79 | ->requirePresence('follower_id', 'create') 80 | ->notEmpty('follower_id'); 81 | 82 | $validator 83 | ->requirePresence('blocked', 'create'); 84 | 85 | return $validator; 86 | } 87 | 88 | /** 89 | * Checks if user following another user. 90 | * 91 | * @param string $followerId Follower User id. 92 | * @param string $followableId Followable User id. 93 | * @return bool 94 | */ 95 | public function following($followerId, $followableId) 96 | { 97 | return $this->find() 98 | ->where([ 99 | 'follower_id' => $followerId, 100 | 'followable_id' => $followableId, 101 | 'blocked' => false, 102 | ]) 103 | ->count() > 0; 104 | } 105 | 106 | /** 107 | * Makes one user following another user. 108 | * 109 | * @param string $followerId Follower User id. 110 | * @param string $followableId Followable User id. 111 | * @return bool 112 | */ 113 | public function follow($followerId, $followableId) 114 | { 115 | $exists = $this->find() 116 | ->where([ 117 | 'follower_id' => $followerId, 118 | 'followable_id' => $followableId, 119 | 'blocked' => false, 120 | ]) 121 | ->first(); 122 | if (!$exists) { 123 | return $this->save($this->newEntity([ 124 | 'follower_id' => $followerId, 125 | 'followable_id' => $followableId, 126 | 'blocked' => false, 127 | ])); 128 | } 129 | 130 | return true; 131 | } 132 | 133 | /** 134 | * Makes one user not following another user. 135 | * 136 | * @param string $followerId Follower User id. 137 | * @param string $followableId Followable User id. 138 | * @return bool 139 | */ 140 | public function unfollow($followerId, $followableId) 141 | { 142 | $exists = $this->find() 143 | ->where([ 144 | 'follower_id' => $followerId, 145 | 'followable_id' => $followableId, 146 | 'blocked' => false, 147 | ]) 148 | ->first(); 149 | if ($exists) { 150 | return $this->delete($exists); 151 | } 152 | 153 | return true; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Model/Table/TagsTable.php: -------------------------------------------------------------------------------- 1 | setTable('tags_tags'); 48 | $this->setPrimaryKey('id'); 49 | } 50 | 51 | /** 52 | * Default validation rules. 53 | * 54 | * @param \Cake\Validation\Validator $validator Validator instance. 55 | * @return \Cake\Validation\Validator 56 | */ 57 | public function validationDefault(Validator $validator) 58 | { 59 | $validator 60 | ->uuid('id') 61 | ->allowEmpty('id', 'create'); 62 | 63 | return $validator; 64 | } 65 | 66 | /** 67 | * Api finder and endpoint formatter. 68 | * 69 | * @param \Cake\ORM\Query $query Query object. 70 | * @param array $options Query options. 71 | * @return \Cake\ORM\Query The query builder. 72 | */ 73 | public function findApiFormat(Query $query, array $options) 74 | { 75 | return $query 76 | ->order(['counter' => 'desc']) 77 | ->limit(20) 78 | ->formatResults(function ($results) use ($options) { 79 | return $results->map(function ($row) { 80 | if ($row === null) { 81 | return $row; 82 | } 83 | 84 | return Formatter::dateFormat($row); 85 | }); 86 | }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Service/Action/Article/ArticleAddAction.php: -------------------------------------------------------------------------------- 1 | getTable()->getValidator(); 27 | $data = $this->getData(); 28 | if (!array_key_exists('article', $data)) { 29 | throw new ValidationException(__('Validation failed'), 0, null, ['article root does not exists']); 30 | } 31 | $errors = $validator->errors($data['article']); 32 | if (!empty($errors)) { 33 | throw new ValidationException(__('Validation failed'), 0, null, $errors); 34 | } 35 | 36 | return true; 37 | } 38 | 39 | /** 40 | * Execute action. 41 | * 42 | * @return mixed 43 | */ 44 | public function execute() 45 | { 46 | $entity = $this->_newEntity(); 47 | $data = $this->_articleData(); 48 | $data['author_id'] = $this->Auth->user('id'); 49 | $entity = $this->_patchEntity($entity, $data); 50 | 51 | $result = $this->_save($entity); 52 | if ($result) { 53 | return ['article' => $this->_getEntity($result['slug'])]; 54 | } 55 | 56 | return null; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Service/Action/Article/ArticleDeleteAction.php: -------------------------------------------------------------------------------- 1 | _getEntity($this->_id); 29 | if ($record['author_id'] != $this->Auth->user('id')) { 30 | throw new ForbiddenException(); 31 | } 32 | 33 | return true; 34 | } 35 | 36 | /** 37 | * Execute action. 38 | * 39 | * @return mixed 40 | */ 41 | public function execute() 42 | { 43 | $record = $this->_getEntity($this->_id); 44 | if ($record) { 45 | $result = $this->getTable()->delete($record); 46 | } 47 | 48 | return !empty($result); 49 | } 50 | 51 | /** 52 | * Returns single entity by id. 53 | * 54 | * @param mixed $primaryKey Primary key. 55 | * @return \Cake\Collection\Collection 56 | */ 57 | protected function _getEntity($primaryKey) 58 | { 59 | return $this->getTable() 60 | ->find() 61 | ->where(['Articles.slug' => $primaryKey]) 62 | ->firstOrFail(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Service/Action/Article/ArticleEditAction.php: -------------------------------------------------------------------------------- 1 | getData(); 31 | if (!array_key_exists('article', $data)) { 32 | throw new ValidationException(__('Validation failed'), 0, null, ['article root does not exists']); 33 | } 34 | $data = Hash::get($data, 'article'); 35 | unset($data['author']); 36 | $entity = $this->_patchEntity($this->_getEntityBySlug(), $data); 37 | 38 | if ($entity['author_id'] != $this->Auth->user('id')) { 39 | throw new ForbiddenException(); 40 | } 41 | 42 | $errors = $entity->getErrors(); 43 | if (!empty($errors)) { 44 | throw new ValidationException(__('Validation failed'), 0, null, $errors); 45 | } 46 | 47 | return true; 48 | } 49 | 50 | /** 51 | * Execute action. 52 | * 53 | * @return mixed 54 | */ 55 | public function execute() 56 | { 57 | $data = $this->_articleData(); 58 | $entity = $this->_patchEntity($this->_getEntityBySlug(), $data); 59 | 60 | if ($this->_save($entity)) { 61 | return ['article' => $this->_getEntity($this->_id)]; 62 | } 63 | 64 | return false; 65 | } 66 | 67 | /** 68 | * Returns single entity by id. 69 | * 70 | * @param mixed $primaryKey Primary key. 71 | * @return \Cake\Collection\Collection 72 | */ 73 | protected function _getEntity($primaryKey) 74 | { 75 | return $this->getTable() 76 | ->find('apiFormat') 77 | ->where(['Articles.slug' => $primaryKey]) 78 | ->firstOrFail(); 79 | } 80 | 81 | /** 82 | * @return mixed 83 | */ 84 | protected function _getEntityBySlug() 85 | { 86 | return $this->getTable()->find()->where(['Articles.slug' => $this->_id])->firstOrFail(); 87 | } 88 | 89 | /** 90 | * @return array 91 | */ 92 | protected function _articleData() 93 | { 94 | $data = Hash::get($this->getData(), 'article'); 95 | unset($data['author']); 96 | if (!empty($data['tagList']) && is_array($data['tagList'])) { 97 | $data['tagList'] = join(' ', $data['tagList']); 98 | } 99 | 100 | return $data; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Service/Action/Article/ArticleFavoriteAction.php: -------------------------------------------------------------------------------- 1 | _getEntity($this->_id); 37 | $this->getTable()->favorite($article['id'], $this->Auth->user('id')); 38 | 39 | return $this->_viewArticle(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Service/Action/Article/ArticleFeedAction.php: -------------------------------------------------------------------------------- 1 | Auth->identify(); 27 | 28 | return $this->getTable()->find('apiFormat', [ 29 | 'feed_by' => $this->Auth->user('id'), 30 | 'currentUser' => $this->Auth->user('id'), 31 | ]); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Service/Action/Article/ArticleIndexAction.php: -------------------------------------------------------------------------------- 1 | Auth->allow($this->getName()); 29 | } 30 | 31 | /** 32 | * Returns query object 33 | * 34 | * @return Query 35 | */ 36 | protected function _getQuery() 37 | { 38 | $options = $this->getData(); 39 | $user = $this->Auth->identify(); 40 | if ($user) { 41 | $options['currentUser'] = $user['id']; 42 | } 43 | 44 | return $this->getTable()->find('apiFormat', $options); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Service/Action/Article/ArticleIndexBase.php: -------------------------------------------------------------------------------- 1 | _getEntities(); 37 | $pagination = $this->getService()->getResult()->getPayload('pagination'); 38 | 39 | return [ 40 | 'articles' => $entities, 41 | 'articlesCount' => Hash::get($pagination, 'count'), 42 | ]; 43 | } 44 | 45 | /** 46 | * Builds entities list 47 | * 48 | * @return \Cake\Collection\Collection 49 | */ 50 | protected function _getEntities() 51 | { 52 | $query = $this->_getQuery(); 53 | // debug($query); 54 | 55 | $event = $this->dispatchEvent('Action.Crud.onFindEntities', compact('query')); 56 | // debug($event); 57 | if ($event->result) { 58 | $query = $event->result; 59 | } 60 | $records = $query->all(); 61 | $this->dispatchEvent('Action.Crud.afterFindEntities', compact('query', 'records')); 62 | 63 | return $records; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Service/Action/Article/ArticleUnfavoriteAction.php: -------------------------------------------------------------------------------- 1 | _getEntity($this->_id); 37 | $this->getTable()->unfavorite($article['id'], $this->Auth->user('id')); 38 | 39 | return $this->_viewArticle(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Service/Action/Article/ArticleViewAction.php: -------------------------------------------------------------------------------- 1 | isPublic) { 33 | $this->Auth->allow($this->getName()); 34 | } 35 | } 36 | 37 | /** 38 | * Execute action. 39 | * 40 | * @return mixed 41 | */ 42 | public function execute() 43 | { 44 | return $this->_viewArticle(); 45 | } 46 | 47 | /** 48 | * Returns current article. 49 | * 50 | * @return array 51 | */ 52 | protected function _viewArticle() 53 | { 54 | $record = $this->getTable() 55 | ->find('apiFormat', [ 56 | 'currentUser' => $this->Auth->user('id'), 57 | ]) 58 | ->where(['Articles.slug' => $this->_id]) 59 | ->firstOrFail(); 60 | 61 | return ['article' => $record]; 62 | } 63 | 64 | /** 65 | * Build condition for get entity method. 66 | * 67 | * @param string $primaryKey Record id. 68 | * @return array 69 | */ 70 | protected function _buildViewCondition($primaryKey) 71 | { 72 | return ['Articles.slug' => $this->_id]; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Service/Action/ChildArticleAction.php: -------------------------------------------------------------------------------- 1 | get('Articles')->find()->where(['slug' => $this->_parentId])->firstOrFail(); 32 | if ($article) { 33 | $this->_parentId = $article->id; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Service/Action/Comment/CommentAddAction.php: -------------------------------------------------------------------------------- 1 | getTable()->getValidator(); 29 | $data = $this->getData(); 30 | if (!array_key_exists('comment', $data)) { 31 | throw new ValidationException(__('Validation failed'), 0, null, ['comment root does not exists']); 32 | } 33 | $errors = $validator->errors($data['comment']); 34 | if (!empty($errors)) { 35 | throw new ValidationException(__('Validation failed'), 0, null, $errors); 36 | } 37 | 38 | return true; 39 | } 40 | 41 | /** 42 | * Execute action. 43 | * 44 | * @return mixed 45 | */ 46 | public function execute() 47 | { 48 | $entity = $this->_newEntity(); 49 | $data = Hash::get($this->getData(), 'comment'); 50 | $data['author_id'] = $this->Auth->user('id'); 51 | $data['article_id'] = $this->_parentId; 52 | $entity = $this->_patchEntity($entity, $data); 53 | 54 | $result = $this->_save($entity); 55 | if ($result) { 56 | $comment = $this->getTable()->find('apiFormat')->where(['Comments.id' => $result->id])->first(); 57 | 58 | return ['comment' => $comment]; 59 | } 60 | 61 | return null; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Service/Action/Comment/CommentDeleteAction.php: -------------------------------------------------------------------------------- 1 | getTable()->find()->where(['id' => $this->_id])->firstOrFail(); 28 | 29 | if ($record['author_id'] != $this->Auth->user('id')) { 30 | throw new ForbiddenException(); 31 | } 32 | 33 | return true; 34 | } 35 | 36 | /** 37 | * Execute action. 38 | * 39 | * @return mixed 40 | */ 41 | public function execute() 42 | { 43 | $record = $this->_getEntity($this->_id); 44 | if ($record) { 45 | $result = $this->getTable()->delete($record); 46 | } 47 | 48 | return !empty($result); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Service/Action/Comment/CommentIndexAction.php: -------------------------------------------------------------------------------- 1 | Auth->allow($this->getName()); 34 | } 35 | 36 | /** 37 | * Execute action. 38 | * 39 | * @return mixed 40 | */ 41 | public function execute() 42 | { 43 | $entities = $this->_getEntities()->toArray(); 44 | 45 | $pagination = $this->getService()->getResult()->getPayload('pagination'); 46 | 47 | return [ 48 | 'comments' => $entities, 49 | 'commentsCount' => Hash::get($pagination, 'count'), 50 | ]; 51 | } 52 | 53 | /** 54 | * Builds entities list 55 | * 56 | * @return \Cake\Collection\Collection 57 | */ 58 | protected function _getEntities() 59 | { 60 | $options = $this->getData(); 61 | $user = $this->Auth->identify(); 62 | if ($user) { 63 | $options['currentUser'] = $user['id']; 64 | } 65 | $query = $this->getTable()->find('apiFormat', $options); 66 | 67 | $event = $this->dispatchEvent('Action.Crud.onFindEntities', compact('query')); 68 | if ($event->result) { 69 | $query = $event->result; 70 | } 71 | $records = $query->all(); 72 | $this->dispatchEvent('Action.Crud.afterFindEntities', compact('query', 'records')); 73 | 74 | return $records; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Service/Action/DummyAction.php: -------------------------------------------------------------------------------- 1 | 20, 30 | 'limitField' => 'limit', 31 | 'offsetField' => 'offset', 32 | ]; 33 | 34 | /** 35 | * Returns a list of events this object is implementing. When the class is registered 36 | * in an event manager, each individual method will be associated with the respective event. 37 | * 38 | * @return array 39 | */ 40 | public function implementedEvents() 41 | { 42 | return [ 43 | 'Action.Crud.onFindEntities' => 'findEntities', 44 | 'Action.Crud.afterFindEntities' => 'afterFind', 45 | ]; 46 | } 47 | 48 | /** 49 | * Find entities 50 | * 51 | * @param Event $event An Event instance 52 | * @return Entity 53 | */ 54 | public function findEntities(Event $event) 55 | { 56 | $action = $event->getSubject(); 57 | $query = $event->getData('query'); 58 | if ($event->result) { 59 | $query = $event->result; 60 | } 61 | $query->limit($this->_limit($action)); 62 | $query->offset($this->_offset($action)); 63 | 64 | return $query; 65 | } 66 | 67 | /** 68 | * Returns current offset. 69 | * 70 | * @param Action $action An Action instance 71 | * @return int 72 | */ 73 | protected function _offset(Action $action) 74 | { 75 | $data = $action->getData(); 76 | $offsetField = $this->getConfig('offsetField'); 77 | if (!empty($data[$offsetField]) && is_numeric($data[$offsetField])) { 78 | return (int)$data[$offsetField]; 79 | } else { 80 | return 0; 81 | } 82 | } 83 | 84 | /** 85 | * Returns current limit 86 | * 87 | * @param Action $action An Action instance 88 | * @return mixed 89 | */ 90 | protected function _limit(Action $action) 91 | { 92 | $data = $action->getData(); 93 | $limitField = $this->getConfig('limitField'); 94 | $maxLimit = $action->getConfig($limitField); 95 | if (empty($maxLimit)) { 96 | $maxLimit = $this->getConfig('defaultLimit'); 97 | } 98 | if (!empty($limitField) && !empty($data[$limitField]) && is_numeric($data[$limitField])) { 99 | $limit = min((int)$data[$limitField], $maxLimit); 100 | 101 | return $limit; 102 | } else { 103 | return $maxLimit; 104 | } 105 | } 106 | 107 | /** 108 | * after find entities 109 | * 110 | * @param Event $event An Event instance 111 | * @return void 112 | */ 113 | public function afterFind(Event $event) 114 | { 115 | $action = $event->getSubject(); 116 | $query = $event->getData('query'); 117 | $result = $action->getService()->getResult(); 118 | $count = $query->count(); 119 | $limit = $this->_limit($action); 120 | $pagination = [ 121 | 'offset' => $this->_offset($action), 122 | 'limit' => $limit, 123 | 'pages' => ceil($count / $limit), 124 | 'count' => $count, 125 | ]; 126 | $result->appendPayload('pagination', $pagination); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Service/Action/Profile/ProfileFollowAction.php: -------------------------------------------------------------------------------- 1 | _table = TableRegistry::getTableLocator()->get('Users'); 31 | } 32 | 33 | /** 34 | * Apply validation process. 35 | * 36 | * @return bool 37 | */ 38 | public function validates() 39 | { 40 | return true; 41 | } 42 | 43 | /** 44 | * Execute action. 45 | * 46 | * @return mixed 47 | */ 48 | public function execute() 49 | { 50 | $record = $this->getTable() 51 | ->find() 52 | ->where(['Users.username' => $this->_id]) 53 | ->firstOrFail(); 54 | TableRegistry::getTableLocator()->get('Follows')->follow($this->Auth->user('id'), $record['id']); 55 | 56 | return $this->_viewProfile(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Service/Action/Profile/ProfileUnfollowAction.php: -------------------------------------------------------------------------------- 1 | _table = TableRegistry::getTableLocator()->get('Users'); 31 | } 32 | 33 | /** 34 | * Apply validation process. 35 | * 36 | * @return bool 37 | */ 38 | public function validates() 39 | { 40 | return true; 41 | } 42 | 43 | /** 44 | * Execute action. 45 | * 46 | * @return mixed 47 | */ 48 | public function execute() 49 | { 50 | $record = $this->getTable() 51 | ->find() 52 | ->where(['Users.username' => $this->_id]) 53 | ->firstOrFail(); 54 | 55 | if ($record) { 56 | $Follows = TableRegistry::getTableLocator()->get('Follows'); 57 | $current = $Follows->find()->where([ 58 | 'follower_id' => $this->Auth->user('id'), 59 | 'followable_id' => $record['id'], 60 | ])->first(); 61 | if ($current) { 62 | $Follows->delete($current); 63 | } 64 | } 65 | TableRegistry::getTableLocator()->get('Follows')->unfollow($this->Auth->user('id'), $record['id']); 66 | 67 | return $this->_viewProfile(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Service/Action/Profile/ProfileViewAction.php: -------------------------------------------------------------------------------- 1 | _table = TableRegistry::getTableLocator()->get('Users'); 34 | if ($this->isPublic) { 35 | $this->Auth->allow($this->getName()); 36 | } 37 | } 38 | 39 | /** 40 | * Execute action. 41 | * 42 | * @return mixed 43 | */ 44 | public function execute() 45 | { 46 | return $this->_viewProfile(); 47 | } 48 | 49 | /** 50 | * Builds profile view data. 51 | * 52 | * @return array 53 | */ 54 | protected function _viewProfile() 55 | { 56 | $user = $this->Auth->identify(); 57 | if ($user) { 58 | $options = [ 59 | 'currentUser' => $user['id'], 60 | ]; 61 | } else { 62 | $options = []; 63 | } 64 | $record = $this->getTable() 65 | ->find('apiFormat', $options) 66 | ->where(['Users.username' => $this->_id]) 67 | ->firstOrFail(); 68 | 69 | return ['profile' => $record]; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Service/Action/Tag/TagIndexAction.php: -------------------------------------------------------------------------------- 1 | Auth->allow($this->getName()); 31 | } 32 | 33 | /** 34 | * Execute action. 35 | * 36 | * @return mixed 37 | */ 38 | public function execute() 39 | { 40 | $this->_finder = 'apiFormat'; 41 | $entities = $this->_getEntities()->map(function ($item) { 42 | return $item->tag; 43 | })->toArray(); 44 | 45 | return [ 46 | 'tags' => $entities, 47 | ]; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Service/Action/User/LoginAction.php: -------------------------------------------------------------------------------- 1 | Auth->allow($this->getName()); 47 | } 48 | 49 | /** 50 | * Apply validation process. 51 | * 52 | * @return bool 53 | */ 54 | public function validates() 55 | { 56 | $validator = TableRegistry::getTableLocator()->get('Users')->getValidator(); 57 | $data = $this->getData(); 58 | if (!array_key_exists('user', $data)) { 59 | throw new ValidationException(__('Validation failed'), 0, null, ['user root does not exists']); 60 | } 61 | $data = Hash::get($this->getData(), 'user'); 62 | $errors = $validator->errors($data); 63 | if (!empty($errors)) { 64 | throw new ValidationException(__('Validation failed'), 0, null, $errors); 65 | } 66 | 67 | return true; 68 | } 69 | 70 | /** 71 | * Execute action. 72 | * 73 | * @return mixed 74 | */ 75 | public function execute() 76 | { 77 | $user = $this->Auth->identify(); 78 | if (!empty($user)) { 79 | $this->Auth->setUser($user); 80 | $this->dispatchEvent(UsersAuthComponent::EVENT_AFTER_LOGIN, ['user' => $user]); 81 | $user = TableRegistry::getTableLocator()->get('Users')->loginFormat($user['id']); 82 | } else { 83 | throw new UserNotFoundException(__d('CakeDC/Api', 'User not found')); 84 | } 85 | 86 | return ['user' => $user]; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Service/Action/User/RegisterAction.php: -------------------------------------------------------------------------------- 1 | Auth->allow($this->getName()); 33 | $this->setTable(TableRegistry::getTableLocator()->get('Users')); 34 | } 35 | 36 | /** 37 | * Apply validation process. 38 | * 39 | * @return bool 40 | */ 41 | public function validates() 42 | { 43 | $validator = $this->getTable()->getValidator('register'); 44 | $data = $this->getData(); 45 | if (!array_key_exists('user', $data)) { 46 | throw new ValidationException(__('Validation failed'), 0, null, ['user root does not exists']); 47 | } 48 | $data = Hash::get($this->getData(), 'user'); 49 | $errors = $validator->errors($data); 50 | if (!empty($errors)) { 51 | throw new ValidationException(__('Validation failed'), 0, null, $errors); 52 | } 53 | 54 | return true; 55 | } 56 | 57 | /** 58 | * Execute action. 59 | * 60 | * @return mixed 61 | */ 62 | public function execute() 63 | { 64 | $entity = $this->_newEntity(); 65 | $data = Hash::get($this->getData(), 'user'); 66 | $entity = $this->_patchEntity($entity, $data, [ 67 | 'validate' => 'register', 68 | ]); 69 | 70 | $record = $this->_save($entity); 71 | if ($record) { 72 | $user = $this->getTable()->loginFormat($record['id']); 73 | 74 | return ['user' => $user]; 75 | } 76 | } 77 | 78 | /** 79 | * Returns action input params 80 | * 81 | * @return mixed 82 | */ 83 | public function getData() 84 | { 85 | $data = parent::getData(); 86 | $data['user']['active'] = true; 87 | $data['user']['is_superuser'] = false; 88 | $data['user']['role'] = 'user'; 89 | 90 | return $data; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Service/Action/User/UserEditAction.php: -------------------------------------------------------------------------------- 1 | setTable(TableRegistry::getTableLocator()->get('Users')); 33 | } 34 | 35 | /** 36 | * Apply validation process. 37 | * 38 | * @return bool 39 | */ 40 | public function validates() 41 | { 42 | $data = $this->getData(); 43 | if (!array_key_exists('user', $data)) { 44 | throw new ValidationException(__('Validation failed'), 0, null, ['user root does not exists']); 45 | } 46 | $userId = $this->Auth->user('id'); 47 | $entity = $this->_getEntity($userId); 48 | $data = Hash::get($this->getData(), 'user'); 49 | $entity = $this->_patchEntity($entity, $data, ['validate' => 'register']); 50 | $errors = $entity->getErrors(); 51 | if (!empty($errors)) { 52 | throw new ValidationException(__('Validation failed'), 0, null, $errors); 53 | } 54 | 55 | return true; 56 | } 57 | 58 | /** 59 | * Execute action. 60 | * 61 | * @return mixed 62 | */ 63 | public function execute() 64 | { 65 | $userId = $this->Auth->user('id'); 66 | 67 | $entity = $this->_getEntity($userId); 68 | $data = Hash::get($this->getData(), 'user'); 69 | $entity = $this->_patchEntity($entity, $data); 70 | 71 | $record = $this->_save($entity); 72 | if ($record) { 73 | $user = TableRegistry::getTableLocator()->get('Users')->loginFormat($this->Auth->user('id')); 74 | 75 | return ['user' => $user]; 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Service/Action/User/UserViewAction.php: -------------------------------------------------------------------------------- 1 | get('Users')->loginFormat($this->Auth->user('id')); 46 | if (empty($user)) { 47 | throw new UserNotFoundException(__d('CakeDC/Api', 'User not found')); 48 | } else { 49 | return ['user' => $user]; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Service/ArticlesService.php: -------------------------------------------------------------------------------- 1 | '\App\Service\Action\Article\ArticleIndexAction', 31 | 'view' => '\App\Service\Action\Article\ArticleViewAction', 32 | 'add' => '\App\Service\Action\Article\ArticleAddAction', 33 | 'edit' => '\App\Service\Action\Article\ArticleEditAction', 34 | 'delete' => '\App\Service\Action\Article\ArticleDeleteAction', 35 | ]; 36 | 37 | /** 38 | * Initialize method 39 | * 40 | * @return void 41 | */ 42 | public function initialize() 43 | { 44 | parent::initialize(); 45 | $this->mapAction('feed', ArticleFeedAction::class, ['method' => ['GET'], 'mapCors' => true]); 46 | $this->mapAction('favorite', ArticleFavoriteAction::class, [ 47 | 'method' => ['POST'], 48 | 'mapCors' => true, 49 | 'path' => ':id/favorite', 50 | ]); 51 | $this->mapAction('unfavorite', ArticleUnfavoriteAction::class, [ 52 | 'method' => ['DELETE'], 53 | 'mapCors' => true, 54 | 'path' => ':id/favorite', 55 | ]); 56 | $this->_innerServices = [ 57 | 'comments', 58 | ]; 59 | } 60 | 61 | /** 62 | * Initialize service level routes 63 | * 64 | * @return void 65 | */ 66 | public function loadRoutes() 67 | { 68 | $defaultOptions = $this->routerDefaultOptions(); 69 | $defaultOptions['id'] = '[a-z0-9_-]+'; 70 | ApiRouter::scope('/', $defaultOptions, function (RouteBuilder $routes) use ($defaultOptions) { 71 | $routes->setExtensions($this->_routeExtensions); 72 | $routes->resources($this->getName(), $defaultOptions, function ($routes) { 73 | if (is_array($this->_routeExtensions)) { 74 | $routes->setExtensions($this->_routeExtensions); 75 | $routes->resources('Comments'); 76 | } 77 | }); 78 | }); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Service/CommentsService.php: -------------------------------------------------------------------------------- 1 | '\App\Service\Action\Comment\CommentIndexAction', 31 | 'add' => '\App\Service\Action\Comment\CommentAddAction', 32 | 'delete' => '\App\Service\Action\Comment\CommentDeleteAction', 33 | ]; 34 | 35 | /** 36 | * Initialize service level routes 37 | * 38 | * @return void 39 | */ 40 | public function loadRoutes() 41 | { 42 | $defaultOptions = $this->routerDefaultOptions(); 43 | ApiRouter::scope('/', $defaultOptions, function (RouteBuilder $routes) use ($defaultOptions) { 44 | $routes->setExtensions($this->_routeExtensions); 45 | $options = $defaultOptions; 46 | $routes->resources($this->getName(), $options); 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Service/ProfilesService.php: -------------------------------------------------------------------------------- 1 | '\App\Service\Action\Profile\ProfileViewAction', 30 | ]; 31 | 32 | /** 33 | * Initialize method 34 | * 35 | * @return void 36 | */ 37 | public function initialize() 38 | { 39 | parent::initialize(); 40 | $this->mapAction('follow', ProfileFollowAction::class, ['method' => ['POST'], 'mapCors' => true, 'path' => ':id/follow']); 41 | $this->mapAction('unfollow', ProfileUnfollowAction::class, ['method' => ['DELETE'], 'mapCors' => true, 'path' => ':id/follow']); 42 | } 43 | 44 | /** 45 | * Initialize service level routes 46 | * 47 | * @return void 48 | */ 49 | public function loadRoutes() 50 | { 51 | $defaultOptions = $this->routerDefaultOptions(); 52 | $defaultOptions['id'] = '[A-Za-z0-9_-]+'; 53 | ApiRouter::scope('/', $defaultOptions, function (RouteBuilder $routes) use ($defaultOptions) { 54 | $routes->setExtensions($this->_routeExtensions); 55 | $routes->resources($this->getName(), $defaultOptions); 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Service/Renderer/AppJsonRenderer.php: -------------------------------------------------------------------------------- 1 | _service->getResponse(); 39 | $data = $result->getData(); 40 | $this->_service->setResponse($response->withStringBody($this->_encode($data))->withStatus($result->getCode()) 41 | ->withType('application/json')); 42 | 43 | return true; 44 | } 45 | 46 | /** 47 | * Processes an exception thrown while processing the request. 48 | * 49 | * @param Exception $exception The exception object. 50 | * @return void 51 | */ 52 | public function error(Exception $exception) 53 | { 54 | $response = $this->_service->getResponse(); 55 | $data = [ 56 | 'error' => [ 57 | 'code' => $exception->getCode(), 58 | 'message' => $this->_buildMessage($exception), 59 | ], 60 | ]; 61 | if ($exception instanceof ValidationException) { 62 | $data['errors'] = []; 63 | unset($data['error']); 64 | foreach ($exception->getValidationErrors() as $field => $errors) { 65 | if (is_array($errors)) { 66 | $data['errors'][$field] = array_values($errors); 67 | } else { 68 | $data['errors'][$field] = [$errors]; 69 | } 70 | } 71 | } 72 | $this->_service->setResponse($response 73 | ->withStringBody($this->_encode($data)) 74 | ->withType('application/json') 75 | ->withStatus($exception->getCode())); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Service/TagsService.php: -------------------------------------------------------------------------------- 1 | '\App\Service\Action\Tag\TagIndexAction', 31 | ]; 32 | } 33 | -------------------------------------------------------------------------------- /src/Service/UserService.php: -------------------------------------------------------------------------------- 1 | UserViewAction::class, 28 | ]; 29 | 30 | /** 31 | * Initialize method 32 | * 33 | * @return void 34 | */ 35 | public function initialize() 36 | { 37 | parent::initialize(); 38 | $this->mapAction('update', UserEditAction::class, [ 39 | 'method' => ['PUT', 'PATCH'], 40 | 'path' => '', 41 | 'mapCors' => true, 42 | ]); 43 | $this->_table = 'users'; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Service/UsersService.php: -------------------------------------------------------------------------------- 1 | RegisterAction::class, 33 | ]; 34 | 35 | /** 36 | * Initialize method 37 | * 38 | * @return void 39 | */ 40 | public function initialize() 41 | { 42 | parent::initialize(); 43 | $this->mapAction('login', LoginAction::class, ['method' => ['POST'], 'mapCors' => true, 'path' => 'login']); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Shell/ConsoleShell.php: -------------------------------------------------------------------------------- 1 | err('Unable to load Psy\Shell.'); 37 | $this->err(''); 38 | $this->err('Make sure you have installed psysh as a dependency,'); 39 | $this->err('and that Psy\Shell is registered in your autoloader.'); 40 | $this->err(''); 41 | $this->err('If you are using composer run'); 42 | $this->err(''); 43 | $this->err('$ php composer.phar require --dev psy/psysh'); 44 | $this->err(''); 45 | 46 | return self::CODE_ERROR; 47 | } 48 | 49 | $this->out("You can exit with `CTRL-C` or `exit`"); 50 | $this->out(''); 51 | 52 | Log::drop('debug'); 53 | Log::drop('error'); 54 | $this->_io->setLoggers(false); 55 | restore_error_handler(); 56 | restore_exception_handler(); 57 | 58 | $psy = new PsyShell(); 59 | $psy->run(); 60 | } 61 | 62 | /** 63 | * Display help for this console. 64 | * 65 | * @return \Cake\Console\ConsoleOptionParser 66 | */ 67 | public function getOptionParser() 68 | { 69 | $parser = new ConsoleOptionParser('console'); 70 | $parser->setDescription( 71 | 'This shell provides a REPL that you can use to interact ' . 72 | 'with your application in an interactive fashion. You can use ' . 73 | 'it to run adhoc queries with your models, or experiment ' . 74 | 'and explore the features of CakePHP and your application.' . 75 | "\n\n" . 76 | 'You will need to have psysh installed for this Shell to work.' 77 | ); 78 | 79 | return $parser; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Template/Element/Flash/default.ctp: -------------------------------------------------------------------------------- 1 | 21 |
22 | -------------------------------------------------------------------------------- /src/Template/Element/Flash/error.ctp: -------------------------------------------------------------------------------- 1 | 17 |
18 | -------------------------------------------------------------------------------- /src/Template/Element/Flash/success.ctp: -------------------------------------------------------------------------------- 1 | 17 |
18 | -------------------------------------------------------------------------------- /src/Template/Error/error400.ctp: -------------------------------------------------------------------------------- 1 | layout = 'error'; 17 | 18 | if (Configure::read('debug')): 19 | $this->layout = 'dev_error'; 20 | 21 | $this->assign('title', $message); 22 | $this->assign('templateName', 'error400.ctp'); 23 | 24 | $this->start('file'); 25 | ?> 26 | queryString)) : ?> 27 |

28 | SQL Query: 29 | queryString) ?> 30 |

31 | 32 | params)) : ?> 33 | SQL Query Params: 34 | params) ?> 35 | 36 | element('auto_table_warning') ?> 37 | end(); 43 | endif; 44 | ?> 45 |

46 |

47 | : 48 | '{$url}'") ?> 49 |

50 | -------------------------------------------------------------------------------- /src/Template/Error/error500.ctp: -------------------------------------------------------------------------------- 1 | layout = 'error'; 17 | 18 | if (Configure::read('debug')): 19 | $this->layout = 'dev_error'; 20 | 21 | $this->assign('title', $message); 22 | $this->assign('templateName', 'error500.ctp'); 23 | 24 | $this->start('file'); 25 | ?> 26 | queryString)) : ?> 27 |

28 | SQL Query: 29 | queryString) ?> 30 |

31 | 32 | params)) : ?> 33 | SQL Query Params: 34 | params) ?> 35 | 36 | 37 | Error in: 38 | getFile()), $error->getLine()) ?> 39 | 40 | element('auto_table_warning'); 42 | 43 | if (extension_loaded('xdebug')): 44 | xdebug_print_function_stack(); 45 | endif; 46 | 47 | $this->end(); 48 | endif; 49 | ?> 50 |

51 |

52 | : 53 | 54 |

55 | -------------------------------------------------------------------------------- /src/Template/Layout/Email/html/default.ctp: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | <?= $this->fetch('title') ?> 20 | 21 | 22 | fetch('content') ?> 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Template/Layout/Email/text/default.ctp: -------------------------------------------------------------------------------- 1 | 16 | fetch('content') ?> 17 | -------------------------------------------------------------------------------- /src/Template/Layout/ajax.ctp: -------------------------------------------------------------------------------- 1 | 16 | fetch('content') ?> 17 | -------------------------------------------------------------------------------- /src/Template/Layout/default.ctp: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | Html->charset() ?> 22 | 23 | 24 | <?= $cakeDescription ?>: 25 | <?= $this->fetch('title') ?> 26 | 27 | Html->meta('icon') ?> 28 | 29 | Html->css('base.css') ?> 30 | Html->css('cake.css') ?> 31 | 32 | fetch('meta') ?> 33 | fetch('css') ?> 34 | fetch('script') ?> 35 | 36 | 37 | 50 | Flash->render() ?> 51 |
52 | fetch('content') ?> 53 |
54 |
55 |
56 | 57 | 58 | -------------------------------------------------------------------------------- /src/Template/Layout/error.ctp: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | Html->charset() ?> 20 | 21 | <?= $this->fetch('title') ?> 22 | 23 | Html->meta('icon') ?> 24 | 25 | Html->css('base.css') ?> 26 | Html->css('cake.css') ?> 27 | 28 | fetch('meta') ?> 29 | fetch('css') ?> 30 | fetch('script') ?> 31 | 32 | 33 |
34 | 37 |
38 | Flash->render() ?> 39 | 40 | fetch('content') ?> 41 |
42 | 45 |
46 | 47 | 48 | -------------------------------------------------------------------------------- /src/Template/Layout/rss/default.ctp: -------------------------------------------------------------------------------- 1 | fetch('title'); 18 | endif; 19 | 20 | echo $this->Rss->document( 21 | $this->Rss->channel( 22 | [], $channel, $this->fetch('content') 23 | ) 24 | ); 25 | ?> 26 | -------------------------------------------------------------------------------- /src/Utility/Formatter.php: -------------------------------------------------------------------------------- 1 | format('Y-m-d\TH:i:s.000\Z'); 29 | unset($row['created']); 30 | } 31 | if (isset($row['modified']) && $row['modified'] instanceof \DateTimeInterface) { 32 | $row['updatedAt'] = $row['modified']->format('Y-m-d\TH:i:s.000\Z'); 33 | unset($row['modified']); 34 | } 35 | 36 | return $row; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/View/AjaxView.php: -------------------------------------------------------------------------------- 1 | response->type('ajax'); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/View/AppView.php: -------------------------------------------------------------------------------- 1 | loadHelper('Html');` 34 | * 35 | * @return void 36 | */ 37 | public function initialize() 38 | { 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Fixture/ArticlesFixture.php: -------------------------------------------------------------------------------- 1 | ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 21 | 'title' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 22 | 'slug' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 23 | 'description' => ['type' => 'text', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 24 | 'body' => ['type' => 'text', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 25 | 'author_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 26 | 'favorites_count' => ['type' => 'integer', 'length' => 11, 'unsigned' => false, 'null' => false, 'default' => '0', 'comment' => '', 'precision' => null, 'autoIncrement' => null], 27 | 'tag_count' => ['type' => 'integer', 'length' => 11, 'unsigned' => false, 'null' => true, 'default' => '0', 'comment' => '', 'precision' => null, 'autoIncrement' => null], 28 | 'created' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 29 | 'modified' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 30 | '_constraints' => [ 31 | 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], 32 | ], 33 | ]; 34 | // @codingStandardsIgnoreEnd 35 | 36 | /** 37 | * Records 38 | * 39 | * @var array 40 | */ 41 | public $records = []; 42 | } 43 | -------------------------------------------------------------------------------- /tests/Fixture/CommentsFixture.php: -------------------------------------------------------------------------------- 1 | ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 32 | 'body' => ['type' => 'text', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 33 | 'article_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 34 | 'author_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 35 | 'created' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 36 | 'modified' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 37 | '_constraints' => [ 38 | 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], 39 | ], 40 | ]; 41 | // @codingStandardsIgnoreEnd 42 | 43 | /** 44 | * Records 45 | * 46 | * @var array 47 | */ 48 | public $records = []; 49 | } 50 | -------------------------------------------------------------------------------- /tests/Fixture/FavoritesFixture.php: -------------------------------------------------------------------------------- 1 | ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 32 | 'user_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 33 | 'article_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 34 | 'created' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 35 | 'modified' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 36 | '_constraints' => [ 37 | 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], 38 | ], 39 | ]; 40 | // @codingStandardsIgnoreEnd 41 | 42 | /** 43 | * Records 44 | * 45 | * @var array 46 | */ 47 | public $records = []; 48 | } 49 | -------------------------------------------------------------------------------- /tests/Fixture/FollowsFixture.php: -------------------------------------------------------------------------------- 1 | ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 32 | 'followable_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 33 | 'follower_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 34 | 'blocked' => ['type' => 'boolean', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 35 | 'created' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 36 | 'modified' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 37 | '_constraints' => [ 38 | 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], 39 | ], 40 | ]; 41 | // @codingStandardsIgnoreEnd 42 | 43 | /** 44 | * Records 45 | * 46 | * @var array 47 | */ 48 | public $records = []; 49 | } 50 | -------------------------------------------------------------------------------- /tests/Fixture/SocialAccountsFixture.php: -------------------------------------------------------------------------------- 1 | ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 32 | 'user_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 33 | 'provider' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 34 | 'username' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 35 | 'reference' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 36 | 'avatar' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 37 | 'description' => ['type' => 'text', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 38 | 'link' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 39 | 'token' => ['type' => 'string', 'length' => 500, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 40 | 'token_secret' => ['type' => 'string', 'length' => 500, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 41 | 'token_expires' => ['type' => 'datetime', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 42 | 'active' => ['type' => 'boolean', 'length' => null, 'null' => false, 'default' => '1', 'comment' => '', 'precision' => null], 43 | 'data' => ['type' => 'text', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 44 | 'created' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 45 | 'modified' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 46 | '_indexes' => [ 47 | 'user_id' => ['type' => 'index', 'columns' => ['user_id'], 'length' => []], 48 | ], 49 | '_constraints' => [ 50 | 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], 51 | 'social_accounts_ibfk_1' => ['type' => 'foreign', 'columns' => ['user_id'], 'references' => ['users', 'id'], 'update' => 'cascade', 'delete' => 'cascade', 'length' => []], 52 | ], 53 | ]; 54 | // @codingStandardsIgnoreEnd 55 | 56 | /** 57 | * Records 58 | * 59 | * @var array 60 | */ 61 | public $records = []; 62 | } 63 | -------------------------------------------------------------------------------- /tests/Fixture/TaggedFixture.php: -------------------------------------------------------------------------------- 1 | ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 23 | 'tag_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 24 | 'fk_id' => ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 25 | 'fk_table' => ['type' => 'string', 'limit' => 255, 'null' => false], 26 | 'created' => ['type' => 'datetime', 'null' => true], 27 | 'modified' => ['type' => 'datetime', 'null' => true], 28 | '_constraints' => [ 29 | 'primary' => ['type' => 'primary', 'columns' => ['id']], 30 | ], 31 | ]; 32 | 33 | /** 34 | * Records 35 | * 36 | * @var array 37 | */ 38 | public $records = []; 39 | } 40 | // @codingStandardsIgnoreEnd 41 | -------------------------------------------------------------------------------- /tests/Fixture/TagsFixture.php: -------------------------------------------------------------------------------- 1 | ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 23 | 'namespace' => ['type' => 'string', 'length' => 255, 'null' => true], 24 | 'tag_key' => ['type' => 'string', 'length' => 255], 25 | 'slug' => ['type' => 'string', 'length' => 255], 26 | 'label' => ['type' => 'string', 'length' => 255], 27 | 'counter' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'null' => true], 28 | 'created' => ['type' => 'datetime', 'null' => true], 29 | 'modified' => ['type' => 'datetime', 'null' => true], 30 | '_constraints' => [ 31 | 'primary' => ['type' => 'primary', 'columns' => ['id']], 32 | ], 33 | ]; 34 | 35 | /** 36 | * Records 37 | * 38 | * @var array 39 | */ 40 | public $records = []; 41 | } 42 | // @codingStandardsIgnoreEnd 43 | -------------------------------------------------------------------------------- /tests/Fixture/UsersFixture.php: -------------------------------------------------------------------------------- 1 | ['type' => 'uuid', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 32 | 'username' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 33 | 'email' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 34 | 'password' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 35 | 'first_name' => ['type' => 'string', 'length' => 50, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 36 | 'last_name' => ['type' => 'string', 'length' => 50, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 37 | 'token' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 38 | 'token_expires' => ['type' => 'datetime', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 39 | 'api_token' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 40 | 'activation_date' => ['type' => 'datetime', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 41 | 'tos_date' => ['type' => 'datetime', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 42 | 'active' => ['type' => 'boolean', 'length' => null, 'null' => false, 'default' => '0', 'comment' => '', 'precision' => null], 43 | 'is_superuser' => ['type' => 'boolean', 'length' => null, 'null' => false, 'default' => '0', 'comment' => '', 'precision' => null], 44 | 'role' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => 'user', 'comment' => '', 'precision' => null, 'fixed' => null], 45 | 'secret' => ['type' => 'string', 'length' => 32, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 46 | 'secret_verified' => ['type' => 'boolean', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 47 | 'bio' => ['type' => 'text', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], 48 | 'image' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null, 'fixed' => null], 49 | 'created' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 50 | 'modified' => ['type' => 'datetime', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], 51 | '_constraints' => [ 52 | 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], 53 | ], 54 | ]; 55 | // @codingStandardsIgnoreEnd 56 | 57 | /** 58 | * Records 59 | * 60 | * @var array 61 | */ 62 | public $records = []; 63 | } 64 | -------------------------------------------------------------------------------- /tests/FixturesTrait.php: -------------------------------------------------------------------------------- 1 | useHttpServer(true); 25 | 26 | $users = FactoryLoader::seed(2, 'Users'); 27 | 28 | $this->loggedInUser = $users[0]; 29 | 30 | $this->user = $users[1]; 31 | 32 | $this->headers = [ 33 | 'Authorization' => "Token {$this->loggedInUser->token}", 34 | 'Content-Type' => 'application/json', 35 | ]; 36 | } 37 | 38 | /** 39 | * Send json request. 40 | * 41 | * @param string $url Request api url. 42 | * @param string $method Request method. 43 | * @param array $data Request data. 44 | * @param EntityInterface $authorizeWithUser User to authorize with. 45 | * @return void 46 | */ 47 | public function sendAuthJsonRequest($url, $method, $data = []) 48 | { 49 | $this->sendJsonRequest($url, $method, $data, $this->loggedInUser); 50 | } 51 | 52 | /** 53 | * Send json request. 54 | * 55 | * @param string $url Request api url. 56 | * @param string $method Request method. 57 | * @param array $data Request data. 58 | * @param EntityInterface $authWithUser User to authorize with. 59 | * @return void 60 | */ 61 | public function sendJsonRequest($url, $method, $data = [], $authWithUser = null) 62 | { 63 | $headers = []; 64 | if ($method != 'GET' && is_array($data)) { 65 | $data = json_encode($data); 66 | } 67 | $headers['Content-Type'] = 'application/json'; 68 | if ($authWithUser !== null) { 69 | $headers['Authorization'] = "Token {$authWithUser->token}"; 70 | } 71 | $this->configRequest(['headers' => $headers]); 72 | $this->sendRequest($url, $method, $data); 73 | } 74 | 75 | /** 76 | * @inheritdoc 77 | */ 78 | public function tearDown() 79 | { 80 | FactoryLoader::flush(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/TestCase/Service/CommentsServiceTest.php: -------------------------------------------------------------------------------- 1 | article = FactoryLoader::create('Articles', ['author_id' => $this->user->id]); 21 | } 22 | 23 | public function testSuccessAddComment() 24 | { 25 | $data = [ 26 | 'comment' => [ 27 | 'body' => 'This is a comment', 28 | ], 29 | ]; 30 | 31 | $this->sendAuthJsonRequest("/articles/{$this->article->slug}/comments", 'POST', $data); 32 | $this->assertResponseSuccess(); 33 | 34 | $this->assertArraySubset([ 35 | 'comment' => [ 36 | 'body' => 'This is a comment', 37 | 'author' => [ 38 | 'username' => $this->loggedInUser->username, 39 | ], 40 | ], 41 | ], $this->getJsonResponse()); 42 | } 43 | 44 | public function testRemoveExistsComment() 45 | { 46 | $comment = $this->_generateComment($this->loggedInUser->id); 47 | $this->sendAuthJsonRequest("/articles/{$this->article->slug}/comments/{$comment->id}", 'DELETE'); 48 | $this->assertResponseSuccess(); 49 | 50 | $this->assertEquals(0, TableRegistry::get('Comments')->find()->where(['article_id' => $this->article->id])->count()); 51 | } 52 | 53 | public function testReturnAllArticleComments() 54 | { 55 | $comments = FactoryLoader::seed(2, 'Comments', [ 56 | 'author_id' => $this->user->id, 57 | 'article_id' => $this->article->id, 58 | ]); 59 | $this->sendAuthJsonRequest("/articles/{$this->article->slug}/comments", 'GET'); 60 | $this->assertResponseSuccess(); 61 | $response = $this->getJsonResponse(); 62 | 63 | $comments = collection($comments)->sortBy(function ($comment) { 64 | return $comment->created->format('Y-m-d H:i:s'); 65 | }, SORT_DESC, SORT_STRING)->toList(); 66 | 67 | $this->assertArraySubset([ 68 | 'comments' => [ 69 | [ 70 | 'id' => $comments[0]['id'], 71 | 'body' => $comments[0]['body'], 72 | 'author' => [ 73 | 'username' => $this->user->username, 74 | ], 75 | ], 76 | [ 77 | 'id' => $comments[1]['id'], 78 | 'body' => $comments[1]['body'], 79 | 'author' => [ 80 | 'username' => $this->user->username, 81 | ], 82 | ], 83 | ], 84 | ], $response); 85 | } 86 | 87 | public function testUnauthenticatedErrorOnDeleteIfNotLoggedIn() 88 | { 89 | $comment = $this->_generateComment($this->user->id); 90 | $this->sendJsonRequest("/articles/{$this->article->slug}/comments/{$comment->id}", 'DELETE'); 91 | $this->assertStatus(401); 92 | 93 | $this->assertEquals(1, TableRegistry::get('Comments')->find()->where(['article_id' => $this->article->id])->count()); 94 | } 95 | 96 | public function testDeleteNotExistsComment() 97 | { 98 | $this->sendAuthJsonRequest("/articles/{$this->article->slug}/comments/b66bfa63-460f-4652-add3-c039b0620b4e", 'DELETE'); 99 | $this->assertStatus(404); 100 | 101 | $this->sendAuthJsonRequest("/articles/unknown/comments/b66bfa63-460f-4652-add3-c039b0620b4e", 'DELETE'); 102 | $this->assertStatus(404); 103 | } 104 | 105 | public function testListCommentsForNonExistsArticle() 106 | { 107 | $this->sendAuthJsonRequest("/articles/unknown/comments", 'GET'); 108 | $this->assertStatus(404); 109 | } 110 | 111 | public function testForbiddenErrorIfDeleteOtherUserComment() 112 | { 113 | $comment = $this->_generateComment($this->user->id); 114 | $this->sendAuthJsonRequest("/articles/{$this->article->slug}/comments/{$comment->id}", 'DELETE'); 115 | $this->assertStatus(403); 116 | $this->assertEquals(1, TableRegistry::get('Comments')->find()->where(['article_id' => $this->article->id])->count()); 117 | } 118 | 119 | /** 120 | * @param $userId 121 | * @return \Cake\Datasource\EntityInterface 122 | */ 123 | protected function _generateComment($userId) 124 | { 125 | $comment = FactoryLoader::create('Comments', [ 126 | 'author_id' => $userId, 127 | 'article_id' => $this->article->id, 128 | ]); 129 | 130 | return $comment; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /tests/TestCase/Service/FavoritesServiceTest.php: -------------------------------------------------------------------------------- 1 | article = FactoryLoader::create('Articles', ['author_id' => $this->user->id]); 21 | } 22 | 23 | public function testSuccessAddAndRemoveFavorite() 24 | { 25 | $this->sendAuthJsonRequest("/articles/{$this->article->slug}/favorite", 'POST'); 26 | $this->assertResponseSuccess(); 27 | $this->assertIsFavorited(); 28 | 29 | $this->sendAuthJsonRequest("/articles/{$this->article->slug}/favorite", 'DELETE'); 30 | $this->assertResponseSuccess(); 31 | $this->assertIsNotFavorited(); 32 | } 33 | 34 | public function testFavoriteCountersIsCorrect() 35 | { 36 | $this->sendAuthJsonRequest("/articles/{$this->article->slug}", 'GET'); 37 | $this->assertResponseSuccess(); 38 | $this->assertIsNotFavorited(); 39 | 40 | $Articles = TableRegistry::get('Articles'); 41 | $Articles->favorite($this->article->id, $this->user->id); 42 | 43 | $this->sendAuthJsonRequest("/articles/{$this->article->slug}/favorite", 'POST'); 44 | $this->assertResponseSuccess(); 45 | $this->assertIsFavorited(2); 46 | 47 | $this->sendAuthJsonRequest("/articles/{$this->article->slug}/favorite", 'DELETE'); 48 | $this->assertResponseSuccess(); 49 | $this->assertIsNotFavorited(1); 50 | 51 | $Articles->unfavorite($this->article->id, $this->user->id); 52 | 53 | $this->sendAuthJsonRequest("/articles/{$this->article->slug}", 'GET'); 54 | $this->assertResponseSuccess(); 55 | $this->assertIsNotFavorited(); 56 | } 57 | 58 | public function testUnauthenticatedErrorIfNotLoggedIn() 59 | { 60 | $this->sendJsonRequest("/articles/{$this->article->slug}/favorite", 'POST'); 61 | $this->assertStatus(401); 62 | 63 | $this->sendJsonRequest("/articles/{$this->article->slug}/favorite", 'DELETE'); 64 | $this->assertStatus(401); 65 | } 66 | 67 | protected function assertIsFavorited($count = 1) 68 | { 69 | $this->assertArraySubset([ 70 | 'article' => [ 71 | 'favorited' => true, 72 | 'favoritesCount' => $count, 73 | ], 74 | ], $this->getJsonResponse()); 75 | } 76 | 77 | protected function assertIsNotFavorited($count = 0) 78 | { 79 | $this->assertArraySubset([ 80 | 'article' => [ 81 | 'favorited' => false, 82 | 'favoritesCount' => $count, 83 | ], 84 | ], $this->getJsonResponse()); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/TestCase/Service/LoginTest.php: -------------------------------------------------------------------------------- 1 | [ 16 | 'email' => $this->user->email, 17 | 'password' => 'passwd', 18 | ], 19 | ]; 20 | 21 | $this->sendJsonRequest("/users/login", 'POST', $data); 22 | $result = $this->getJsonResponse(); 23 | $this->assertResponseOk(); 24 | $this->assertArraySubset([ 25 | 'user' => [ 26 | 'email' => $this->user->email, 27 | 'username' => $this->user->username, 28 | 'bio' => $this->user->bio, 29 | 'image' => $this->user->image, 30 | ], 31 | ], $result); 32 | 33 | $this->assertArrayHasKey('token', $result['user'], 'Token not found'); 34 | } 35 | 36 | public function testNoDataReturnsValidationErrors() 37 | { 38 | $data = [ 39 | 'user' => [], 40 | ]; 41 | 42 | $this->sendJsonRequest("/users/login", 'POST', $data); 43 | $this->assertStatus(422); 44 | $this->assertEquals([ 45 | 'errors' => [ 46 | 'email' => ['This field is required'], 47 | 'password' => ['This field is required'], 48 | ], 49 | ], $this->getJsonResponse()); 50 | } 51 | 52 | public function testPreciseValidationErrors() 53 | { 54 | $data = [ 55 | 'user' => [ 56 | 'email' => 'invalid email', 57 | 'password' => 'secret', 58 | ], 59 | ]; 60 | 61 | $this->sendJsonRequest("/users/login", 'POST', $data); 62 | $this->assertStatus(422); 63 | $this->assertEquals([ 64 | 'errors' => [ 65 | 'email' => ['This field must be a valid email address.'], 66 | ], 67 | ], $this->getJsonResponse()); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/TestCase/Service/ProfilesServiceTest.php: -------------------------------------------------------------------------------- 1 | sendAuthJsonRequest("/profiles/{$this->user->username}", 'GET'); 16 | $this->assertResponseOk(); 17 | 18 | $this->assertEquals([ 19 | 'profile' => [ 20 | 'username' => $this->user->username, 21 | 'bio' => $this->user->bio, 22 | 'image' => $this->user->image, 23 | 'following' => false, 24 | ], 25 | ], $this->getJsonResponse()); 26 | } 27 | 28 | public function testNotFoundInvalidProfile() 29 | { 30 | $this->sendAuthJsonRequest("/profiles/unknown", 'GET'); 31 | $this->assertStatus(404); 32 | } 33 | 34 | public function testFollowAndUnfollow() 35 | { 36 | $this->sendAuthJsonRequest("/profiles/{$this->user->username}/follow", 'POST'); 37 | $this->assertResponseSuccess(); 38 | $this->assertEquals([ 39 | 'profile' => [ 40 | 'username' => $this->user->username, 41 | 'bio' => $this->user->bio, 42 | 'image' => $this->user->image, 43 | 'following' => true, 44 | ], 45 | ], $this->getJsonResponse()); 46 | $Follows = TableRegistry::get('Follows'); 47 | $this->assertTrue($Follows->following($this->loggedInUser->id, $this->user->id), 'Failed to follow user'); 48 | 49 | $this->sendAuthJsonRequest("/profiles/{$this->user->username}/follow", 'DELETE'); 50 | $this->assertResponseSuccess(); 51 | $this->assertEquals([ 52 | 'profile' => [ 53 | 'username' => $this->user->username, 54 | 'bio' => $this->user->bio, 55 | 'image' => $this->user->image, 56 | 'following' => false, 57 | ], 58 | ], $this->getJsonResponse()); 59 | $this->assertFalse($Follows->following($this->loggedInUser->id, $this->user->id), 'Failed to unfollow user'); 60 | } 61 | 62 | public function testFollowAndUnfollowNotExistsProfiles() 63 | { 64 | $this->sendAuthJsonRequest("/profiles/unknown/follow", 'POST'); 65 | $this->assertStatus(404); 66 | 67 | $this->sendAuthJsonRequest("/profiles/unknown/follow", 'DELETE'); 68 | $this->assertStatus(404); 69 | } 70 | 71 | public function testFollowAndUnfollowUnauthorized() 72 | { 73 | $this->sendJsonRequest("/profiles/{$this->user->username}/follow", 'POST'); 74 | $this->assertStatus(401); 75 | 76 | $this->sendJsonRequest("/profiles/{$this->user->username}/follow", 'DELETE'); 77 | $this->assertStatus(401); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/TestCase/Service/RegisterTest.php: -------------------------------------------------------------------------------- 1 | [ 16 | 'username' => 'test', 17 | 'email' => 'test@test.com', 18 | 'password' => 'secret', 19 | ], 20 | ]; 21 | $this->sendAuthJsonRequest("/users", 'POST', ($data)); 22 | $result = $this->getJsonResponse(); 23 | $this->assertResponseOk(); 24 | $this->assertArraySubset([ 25 | 'user' => [ 26 | 'email' => 'test@test.com', 27 | 'username' => 'test', 28 | 'bio' => null, 29 | 'image' => null, 30 | ], 31 | ], $result); 32 | $this->assertArrayHasKey('token', $result['user'], 'Token not found'); 33 | } 34 | 35 | public function testNoDataReturnsValidationErrors() 36 | { 37 | $this->sendAuthJsonRequest("/users", 'POST', []); 38 | $this->assertStatus(422); 39 | $this->assertEquals([ 40 | 'errors' => [ 41 | 'username' => ['This field is required'], 42 | 'email' => ['This field is required'], 43 | 'password' => ['This field is required'], 44 | ], 45 | ], $this->getJsonResponse()); 46 | } 47 | 48 | public function testPreciseValidationErrors() 49 | { 50 | $data = [ 51 | 'user' => [ 52 | 'username' => 'invalid username', 53 | 'email' => 'invalid email', 54 | 'password' => '1', 55 | ], 56 | ]; 57 | $this->sendAuthJsonRequest("/users", 'POST', $data); 58 | $this->assertStatus(422, "Status invalid"); 59 | $this->assertEquals([ 60 | 'errors' => [ 61 | 'username' => ['Username may only contain letters and numbers.'], 62 | 'email' => ['This field must be a valid email address.'], 63 | 'password' => ['Password must be at least 6 characters.'], 64 | ], 65 | ], $this->getJsonResponse()); 66 | } 67 | 68 | public function testDuplicationValidationErrors() 69 | { 70 | $data = [ 71 | 'user' => [ 72 | 'username' => $this->user->username, 73 | 'email' => $this->user->email, 74 | 'password' => 'secret', 75 | ], 76 | ]; 77 | $this->sendAuthJsonRequest("/users", 'POST', $data); 78 | $this->assertStatus(422); 79 | $this->assertEquals([ 80 | 'errors' => [ 81 | 'username' => ['Username has already been taken.'], 82 | 'email' => ['Email has already been taken.'], 83 | ], 84 | ], $this->getJsonResponse()); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/TestCase/Service/TagsServiceTest.php: -------------------------------------------------------------------------------- 1 | sendJsonRequest("/tags", 'GET'); 18 | $this->assertResponseOk(); 19 | $response = $this->getJsonResponse(); 20 | sort($response['tags']); 21 | $expected = Hash::extract($tags, '{n}.label'); 22 | sort($expected); 23 | $this->assertEquals([ 24 | 'tags' => $expected, 25 | ], $response); 26 | } 27 | 28 | public function testEmptyTagList() 29 | { 30 | $this->sendJsonRequest("/tags", 'GET'); 31 | $this->assertResponseOk(); 32 | $this->assertEquals([ 33 | 'tags' => [], 34 | ], $this->getJsonResponse()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/TestCase/Service/UserServiceTest.php: -------------------------------------------------------------------------------- 1 | sendAuthJsonRequest("/user", 'GET'); 15 | $this->assertResponseSuccess(); 16 | 17 | $this->assertArraySubset([ 18 | 'user' => [ 19 | 'email' => $this->loggedInUser->email, 20 | 'username' => $this->loggedInUser->username, 21 | 'bio' => $this->loggedInUser->bio, 22 | 'image' => $this->loggedInUser->image, 23 | ], 24 | ], $this->getJsonResponse()); 25 | } 26 | 27 | public function testUnauthorizedIdNotLoggedIn() 28 | { 29 | $this->sendJsonRequest("/user", 'GET'); 30 | $this->assertStatus(401); 31 | } 32 | 33 | public function testUpdateUser() 34 | { 35 | $data = [ 36 | 'user' => [ 37 | 'username' => 'user123', 38 | 'email' => 'user123@world.com', 39 | 'password' => 'secretpassword', 40 | 'bio' => 'hello', 41 | 'image' => 'http://image.com/user.jpg', 42 | ], 43 | ]; 44 | $this->sendAuthJsonRequest("/user", 'PUT', $data); 45 | $this->assertResponseSuccess(); 46 | 47 | $this->assertArraySubset([ 48 | 'user' => [ 49 | 'username' => 'user123', 50 | 'email' => 'user123@world.com', 51 | 'bio' => 'hello', 52 | 'image' => 'http://image.com/user.jpg', 53 | ], 54 | ], $this->getJsonResponse()); 55 | 56 | $this->sendAuthJsonRequest("/user", 'GET'); 57 | $this->assertResponseSuccess(); 58 | $this->assertArraySubset([ 59 | 'user' => [ 60 | 'username' => 'user123', 61 | 'email' => 'user123@world.com', 62 | 'bio' => 'hello', 63 | 'image' => 'http://image.com/user.jpg', 64 | ], 65 | ], $this->getJsonResponse()); 66 | } 67 | 68 | public function testValidationErrorsOnUpdate() 69 | { 70 | $data = [ 71 | 'user' => [ 72 | 'username' => 'invalid username', 73 | 'email' => 'invalid email', 74 | 'password' => '1', 75 | 'bio' => 'bio data', 76 | 'image' => 'invalid url', 77 | ], 78 | ]; 79 | 80 | $this->sendAuthJsonRequest("/user", 'PUT', $data); 81 | $this->assertStatus(422); 82 | 83 | $this->assertEquals([ 84 | 'errors' => [ 85 | 'email' => ['This field must be a valid email address.'], 86 | 'password' => ['Password must be at least 6 characters.'], 87 | 'image' => ['Invalid url'], 88 | 'username' => ['Username may only contain letters and numbers.'], 89 | ], 90 | ], $this->getJsonResponse()); 91 | } 92 | 93 | public function testValidationErrorsOnUpdateToExistsUsernameOrEmail() 94 | { 95 | $data = [ 96 | 'user' => [ 97 | 'username' => $this->user->username, 98 | 'email' => $this->user->email, 99 | 'password' => 'passwd', 100 | ], 101 | ]; 102 | 103 | $this->sendAuthJsonRequest("/user", 'PUT', $data); 104 | $this->assertStatus(422); 105 | $this->assertEquals([ 106 | 'errors' => [ 107 | 'username' => ['Username has already been taken.'], 108 | 'email' => ['Email has already been taken.'], 109 | ], 110 | ], $this->getJsonResponse()); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 2 | RewriteEngine On 3 | RewriteCond %{REQUEST_FILENAME} !-f 4 | RewriteRule ^ index.php [L] 5 | 6 | -------------------------------------------------------------------------------- /webroot/css/home.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'cakefont'; 3 | src: url('../font/cakedingbats-webfont.eot'); 4 | src: url('../font/cakedingbats-webfont.eot?#iefix') format('embedded-opentype'), 5 | url('../font/cakedingbats-webfont.woff2') format('woff2'), 6 | url('../font/cakedingbats-webfont.woff') format('woff'), 7 | url('../font/cakedingbats-webfont.ttf') format('truetype'), 8 | url('../font/cakedingbats-webfont.svg#cake_dingbatsregular') format('svg'); 9 | font-weight: normal; 10 | font-style: normal; 11 | } 12 | 13 | .home { 14 | font-family: 'Roboto', sans-serif; 15 | font-size: 14px; 16 | line-height: 27px; 17 | color: #404041; 18 | } 19 | 20 | a { 21 | color: #0071BC; 22 | -webkit-transition: all 0.2s; 23 | -moz-transition: all 0.2s; 24 | -ms-transition: all 0.2s; 25 | -o-transition: all 0.2s; 26 | transition: all 0.2s; 27 | } 28 | 29 | a:hover, a:active { 30 | color: #d33d44; 31 | -webkit-transition: all 0.2s; 32 | -moz-transition: all 0.2s; 33 | -ms-transition: all 0.2s; 34 | -o-transition: all 0.2s; 35 | transition: all 0.2s; 36 | } 37 | 38 | ul, ol, dl, p { 39 | font-size: 0.85rem; 40 | } 41 | 42 | p { 43 | line-height: 2; 44 | } 45 | 46 | header { 47 | height: auto; 48 | line-height: 1em; 49 | padding: 0; 50 | box-shadow: none; 51 | } 52 | 53 | header.row { 54 | margin-bottom: 30px; 55 | } 56 | 57 | header .header-image { 58 | text-align: center; 59 | padding: 64px 0; 60 | } 61 | 62 | header .header-title { 63 | padding: 0; 64 | display: block; 65 | background: #404041; 66 | text-align: center; 67 | } 68 | 69 | header .header-title h1 { 70 | font-family: 'Raleway', sans-serif; 71 | margin: 0; 72 | font-style: italic; 73 | font-size: 18px; 74 | font-weight: 500; 75 | padding: 18px 30px; 76 | color: #DEDED5; 77 | } 78 | 79 | header h1 { 80 | color: #fff; 81 | } 82 | 83 | h3, h4 { 84 | font-family: 'Roboto', sans-serif; 85 | font-size: 27px; 86 | line-height: 30px; 87 | font-weight: 300; 88 | -webkit-font-smoothing: antialiased; 89 | margin-top: 0; 90 | margin-bottom: 20px; 91 | } 92 | 93 | .more { 94 | color: #ffffff; 95 | background-color: #d33d44; 96 | padding: 15px; 97 | margin-top: 10px; 98 | } 99 | 100 | .row { 101 | max-width: 1000px; 102 | } 103 | 104 | .alert { 105 | background-color: #fff9e1; 106 | font-size: 12px; 107 | text-align: center; 108 | display: block; 109 | padding: 12px; 110 | border-bottom: 2px solid #ffcf06; 111 | } 112 | 113 | .alert { 114 | background-color: #fff9e1; 115 | font-size: 12px; 116 | display: block; 117 | padding: 12px; 118 | border-bottom: 2px solid #ffcf06; 119 | margin-bottom: 30px; 120 | color: #404041; 121 | } 122 | 123 | .alert p { 124 | margin: 0; 125 | font-size: 12px; 126 | line-height: 1.4; 127 | } 128 | 129 | .alert p:before { 130 | color: #ffcf06; 131 | content: "\0055"; 132 | font-family: 'cakefont', sans-serif; 133 | font-size: 21px; 134 | margin-left: -0.8em; 135 | width: 2.3em; 136 | -webkit-font-smoothing: antialiased; 137 | -moz-osx-font-smoothing: grayscale; 138 | padding: 0 10px 0 15px; 139 | vertical-align: -2px; 140 | } 141 | 142 | .alert ul { 143 | margin: 0; 144 | font-size: 12px; 145 | } 146 | 147 | .alert.url-rewriting { 148 | background-color: #F0F0F0; 149 | border-color: #cccccc; 150 | display: none; 151 | } 152 | 153 | .text-center { 154 | text-align: center; 155 | } 156 | 157 | ul { 158 | list-style-type: none; 159 | margin: 0 0 30px 0; 160 | } 161 | 162 | li { 163 | padding-left: 1.8em; 164 | } 165 | 166 | ul li ul, ul li ul li { 167 | margin: 0; 168 | padding: 0; 169 | } 170 | 171 | .bullet:before { 172 | font-family: 'cakefont', sans-serif; 173 | font-size: 18px; 174 | display: inline-block; 175 | margin-left: -1.3em; 176 | width: 1.2em; 177 | -webkit-font-smoothing: antialiased; 178 | -moz-osx-font-smoothing: grayscale; 179 | vertical-align: -1px; 180 | } 181 | 182 | .success:before { 183 | color: #88c671; 184 | content: "\0056"; 185 | } 186 | 187 | .problem:before { 188 | color: #d33d44; 189 | content: "\0057"; 190 | } 191 | 192 | .cutlery:before { 193 | color: #404041; 194 | content: "\0059"; 195 | } 196 | 197 | .book:before { 198 | color: #404041; 199 | content: "\0042"; 200 | width: 1.7em; 201 | } 202 | 203 | hr { 204 | border-bottom: 1px solid #e7e7e7; 205 | border-top: 0; 206 | margin-bottom: 35px; 207 | margin-left: 30px; 208 | margin-right: 30px; 209 | } 210 | 211 | 212 | .icon { 213 | color: #404041; 214 | font-style: normal; 215 | font-family: 'cakefont', sans-serif; 216 | -webkit-font-smoothing: antialiased; 217 | -moz-osx-font-smoothing: grayscale; 218 | } 219 | .icon.support { 220 | font-size: 60px; 221 | } 222 | .icon.docs { 223 | font-size: 57px; 224 | } 225 | .icon.training { 226 | font-size: 39px; 227 | } 228 | 229 | @media (min-width: 768px) { 230 | .columns { 231 | padding-left: 30px; 232 | padding-right: 30px; 233 | } 234 | } 235 | 236 | @media (min-width: 992px) { 237 | header.row { 238 | max-width: 940px; 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /webroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/cakephp-realworld-example-app/de154c8c5cbff108b07ad2abe4fc623ec56b5ad8/webroot/favicon.ico -------------------------------------------------------------------------------- /webroot/font/cakedingbats-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/cakephp-realworld-example-app/de154c8c5cbff108b07ad2abe4fc623ec56b5ad8/webroot/font/cakedingbats-webfont.eot -------------------------------------------------------------------------------- /webroot/font/cakedingbats-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/cakephp-realworld-example-app/de154c8c5cbff108b07ad2abe4fc623ec56b5ad8/webroot/font/cakedingbats-webfont.ttf -------------------------------------------------------------------------------- /webroot/font/cakedingbats-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/cakephp-realworld-example-app/de154c8c5cbff108b07ad2abe4fc623ec56b5ad8/webroot/font/cakedingbats-webfont.woff -------------------------------------------------------------------------------- /webroot/font/cakedingbats-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/cakephp-realworld-example-app/de154c8c5cbff108b07ad2abe4fc623ec56b5ad8/webroot/font/cakedingbats-webfont.woff2 -------------------------------------------------------------------------------- /webroot/img/cake-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/cakephp-realworld-example-app/de154c8c5cbff108b07ad2abe4fc623ec56b5ad8/webroot/img/cake-logo.png -------------------------------------------------------------------------------- /webroot/img/cake.icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/cakephp-realworld-example-app/de154c8c5cbff108b07ad2abe4fc623ec56b5ad8/webroot/img/cake.icon.png -------------------------------------------------------------------------------- /webroot/img/cake.logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 10 | 12 | 13 | 16 | 21 | 22 | 24 | 25 | 29 | 32 | 33 | 35 | 36 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /webroot/img/cake.power.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/cakephp-realworld-example-app/de154c8c5cbff108b07ad2abe4fc623ec56b5ad8/webroot/img/cake.power.gif -------------------------------------------------------------------------------- /webroot/index.php: -------------------------------------------------------------------------------- 1 | emit($server->run()); 41 | -------------------------------------------------------------------------------- /webroot/js/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/cakephp-realworld-example-app/de154c8c5cbff108b07ad2abe4fc623ec56b5ad8/webroot/js/empty --------------------------------------------------------------------------------