Repository: appkr/l5essential
Branch: master
Commit: 428a0051c85a
Files: 287
Total size: 2.8 MB
Directory structure:
gitextract_5wn34sgq/
├── .bowerrc
├── .editorconfig
├── .gitattributes
├── .gitignore
├── .travis.yml
├── CONTRIBUTING.md
├── Envoy.blade.php
├── LICENSE
├── Vagrantfile
├── apiary.apib
├── app/
│ ├── Article.php
│ ├── Attachment.php
│ ├── AuthorTrait.php
│ ├── Comment.php
│ ├── Console/
│ │ ├── Commands/
│ │ │ ├── BackupDb.php
│ │ │ ├── ClearLog.php
│ │ │ ├── Inspire.php
│ │ │ ├── PruneRelease.php
│ │ │ └── UpdateLessonsTable.php
│ │ └── Kernel.php
│ ├── Events/
│ │ ├── ArticleConsumed.php
│ │ ├── Event.php
│ │ └── ModelChanged.php
│ ├── Exceptions/
│ │ └── Handler.php
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── Api/
│ │ │ │ ├── PasswordsController.php
│ │ │ │ ├── SessionsController.php
│ │ │ │ ├── UsersController.php
│ │ │ │ ├── V1/
│ │ │ │ │ ├── ArticlesController.php
│ │ │ │ │ ├── CommentsController.php
│ │ │ │ │ └── WelcomeController.php
│ │ │ │ └── WelcomeController.php
│ │ │ ├── ArticlesController.php
│ │ │ ├── AttachmentsController.php
│ │ │ ├── Cacheable.php
│ │ │ ├── CommentsController.php
│ │ │ ├── Controller.php
│ │ │ ├── LessonsController.php
│ │ │ ├── PasswordsController.php
│ │ │ ├── SessionsController.php
│ │ │ ├── SocialController.php
│ │ │ ├── UsersController.php
│ │ │ └── WelcomeController.php
│ │ ├── Kernel.php
│ │ ├── Middleware/
│ │ │ ├── Authenticate.php
│ │ │ ├── AuthorOnly.php
│ │ │ ├── EncryptCookies.php
│ │ │ ├── GetUserFromToken.php
│ │ │ ├── ObfuscateId.php
│ │ │ ├── RedirectIfAuthenticated.php
│ │ │ ├── RefreshToken.php
│ │ │ ├── ThrottleApiRequests.php
│ │ │ └── VerifyCsrfToken.php
│ │ ├── Requests/
│ │ │ ├── ArticlesRequest.php
│ │ │ ├── FilterArticlesRequest.php
│ │ │ └── Request.php
│ │ └── routes.php
│ ├── Jobs/
│ │ └── Job.php
│ ├── Lesson.php
│ ├── Listeners/
│ │ ├── .gitkeep
│ │ ├── CacheHandler.php
│ │ ├── CommentsHandler.php
│ │ ├── UserEventsHandler.php
│ │ └── ViewCountHandler.php
│ ├── Model.php
│ ├── Policies/
│ │ └── .gitkeep
│ ├── Providers/
│ │ ├── AppServiceProvider.php
│ │ ├── AuthServiceProvider.php
│ │ ├── EventServiceProvider.php
│ │ └── RouteServiceProvider.php
│ ├── Reporters/
│ │ ├── ErrorReport.php
│ │ └── MonologSlackReport.php
│ ├── Repositories/
│ │ ├── LessonRepository.php
│ │ ├── MarkdownRepository.php
│ │ └── RepositoryInterface.php
│ ├── Services/
│ │ └── Markdown.php
│ ├── Tag.php
│ ├── Transformers/
│ │ ├── ArticleTransformer.php
│ │ ├── AttachmentTransformer.php
│ │ ├── CommentTransformer.php
│ │ ├── TagTransformer.php
│ │ └── UserTransformer.php
│ ├── User.php
│ ├── Vote.php
│ └── helpers.php
├── artisan
├── bootstrap/
│ ├── app.php
│ ├── autoload.php
│ └── cache/
│ └── .gitignore
├── bower.json
├── composer.json
├── config/
│ ├── api.php
│ ├── app.php
│ ├── auth.php
│ ├── broadcasting.php
│ ├── cache.php
│ ├── compile.php
│ ├── cors.php
│ ├── database.php
│ ├── filesystems.php
│ ├── icons.php
│ ├── jwt.php
│ ├── mail.php
│ ├── project.php
│ ├── queue.php
│ ├── roles.php
│ ├── services.php
│ ├── session.php
│ ├── slack.php
│ └── view.php
├── database/
│ ├── .gitignore
│ ├── factories/
│ │ └── ModelFactory.php
│ ├── migrations/
│ │ ├── .gitkeep
│ │ ├── 2014_10_12_000000_create_users_table.php
│ │ ├── 2014_10_12_100000_create_password_resets_table.php
│ │ ├── 2015_01_15_105324_create_roles_table.php
│ │ ├── 2015_01_15_114412_create_role_user_table.php
│ │ ├── 2015_01_26_115212_create_permissions_table.php
│ │ ├── 2015_01_26_115523_create_permission_role_table.php
│ │ ├── 2015_02_09_132439_create_permission_user_table.php
│ │ ├── 2015_11_20_062500_create_comments_table.php
│ │ ├── 2015_11_20_062513_create_articles_table.php
│ │ ├── 2015_11_20_062601_create_tags_table.php
│ │ ├── 2015_11_20_062613_create_attachments_table.php
│ │ ├── 2015_11_20_062846_create_article_tag_table.php
│ │ ├── 2015_12_09_165930_create_lessons_table.php
│ │ └── 2015_12_10_151357_create_votes_table.php
│ └── seeds/
│ ├── .gitkeep
│ └── DatabaseSeeder.php
├── gulpfile.js
├── lessons/
│ ├── 01-welcome.md
│ ├── 02-hello-laravel.md
│ ├── 02-install-homestead-osx.md
│ ├── 02-install-homestead-windows.md
│ ├── 02-install-on-windows.md
│ ├── 03-configuration.md
│ ├── 04-routing-basics.md
│ ├── 05-pass-data-to-view.md
│ ├── 06-blade-101.md
│ ├── 07-blade-201.md
│ ├── 08-raw-queries.md
│ ├── 09-query-builder.md
│ ├── 10-eloquent.md
│ ├── 11-migration.md
│ ├── 12-controller.md
│ ├── 13-restful-resource-controller.md
│ ├── 14-named-routes.md
│ ├── 15-nested-resources.md
│ ├── 16-authentication.md
│ ├── 17-authentication-201.md
│ ├── 18-eloquent-relationships.md
│ ├── 19-seeder.md
│ ├── 20-1-pagination.md
│ ├── 20-eager-loading.md
│ ├── 21-mail.md
│ ├── 22-events.md
│ ├── 23-validation.md
│ ├── 24-exception-handling.md
│ ├── 25-composer.md
│ ├── 26-document-model.md
│ ├── 27-document-controller.md
│ ├── 28-cache.md
│ ├── 29-elixir.md
│ ├── 30-final-touch.md
│ ├── 31-forum-features.md
│ ├── 32-login.md
│ ├── 32n33-auth-refactoring.md
│ ├── 33-social-login.md
│ ├── 34-role.md
│ ├── 35-locale.md
│ ├── 36-models.md
│ ├── 37-articles.md
│ ├── 38-tags.md
│ ├── 39-attachments.md
│ ├── 40-comments.md
│ ├── 41-ui-makeup.md
│ ├── 42-be-makeup.md
│ ├── 43-change-note.md
│ ├── 44-api-basic.md
│ ├── 45-api-big-picture.md
│ ├── 46-jwt.md
│ ├── 47-dry-refactoring.md
│ ├── 48-all-is-bad.md
│ ├── 49-rate-limit.md
│ ├── 50-id-obfuscation.md
│ ├── 51-cors.md
│ ├── 52-caching.md
│ ├── 53-partial-response.md
│ ├── 54-api-docs.md
│ ├── 999-code-release.md
│ ├── INDEX.md
│ └── images/
│ ├── 02-hello-laravel-img-01.pptx
│ ├── 02-hello-laravel-img-03.pptx
│ ├── 32n33-auth-refactoring-img-02.pptx
│ └── 46-jwt-img-01.pptx
├── package.json
├── phpunit.xml
├── public/
│ ├── .htaccess
│ ├── attachments/
│ │ └── .gitignore
│ ├── build/
│ │ ├── css/
│ │ │ └── app-4cd4d601dd.css
│ │ ├── fonts/
│ │ │ └── FontAwesome.otf
│ │ ├── js/
│ │ │ ├── app-038b8ad709.js
│ │ │ └── app-6c3ef62a70.js
│ │ └── rev-manifest.json
│ ├── index.php
│ └── robots.txt
├── readme.md
├── resources/
│ ├── assets/
│ │ ├── js/
│ │ │ └── app.js
│ │ └── sass/
│ │ ├── _auth.scss
│ │ ├── _commons.scss
│ │ ├── _forum.scss
│ │ ├── _landing.scss
│ │ ├── _lessons.scss
│ │ ├── _mediaquery.scss
│ │ └── app.scss
│ ├── lang/
│ │ ├── en/
│ │ │ ├── auth.php
│ │ │ ├── common.php
│ │ │ ├── errors.php
│ │ │ ├── forum.php
│ │ │ ├── lessons.php
│ │ │ ├── pagination.php
│ │ │ ├── passwords.php
│ │ │ └── validation.php
│ │ └── ko/
│ │ ├── auth.php
│ │ ├── common.php
│ │ ├── errors.php
│ │ ├── forum.php
│ │ ├── lessons.php
│ │ ├── pagination.php
│ │ ├── passwords.php
│ │ └── validation.php
│ └── views/
│ ├── articles/
│ │ ├── create.blade.php
│ │ ├── edit.blade.php
│ │ ├── index.blade.php
│ │ ├── partial/
│ │ │ ├── article.blade.php
│ │ │ ├── form.blade.php
│ │ │ └── search.blade.php
│ │ └── show.blade.php
│ ├── attachments/
│ │ └── partial/
│ │ └── list.blade.php
│ ├── comments/
│ │ ├── index.blade.php
│ │ └── partial/
│ │ ├── best.blade.php
│ │ ├── comment.blade.php
│ │ ├── control.blade.php
│ │ ├── create.blade.php
│ │ ├── edit.blade.php
│ │ └── login.blade.php
│ ├── emails/
│ │ ├── new-comment.blade.php
│ │ └── password.blade.php
│ ├── errors/
│ │ ├── 503.blade.php
│ │ └── notice.blade.php
│ ├── home.blade.php
│ ├── layouts/
│ │ ├── master.blade.php
│ │ └── partial/
│ │ ├── flash_message.blade.php
│ │ ├── flash_modal.blade.php
│ │ ├── footer.blade.php
│ │ ├── markdown.blade.php
│ │ ├── navigation.blade.php
│ │ └── tracker.blade.php
│ ├── lessons/
│ │ ├── partial/
│ │ │ └── pager.blade.php
│ │ └── show.blade.php
│ ├── passwords/
│ │ ├── remind.blade.php
│ │ └── reset.blade.php
│ ├── sessions/
│ │ └── create.blade.php
│ ├── tags/
│ │ └── partial/
│ │ ├── index.blade.php
│ │ └── list.blade.php
│ ├── users/
│ │ ├── create.blade.php
│ │ └── partial/
│ │ └── avatar.blade.php
│ └── vendor/
│ ├── .gitkeep
│ └── flash/
│ ├── message.blade.php
│ └── modal.blade.php
├── server.php
├── storage/
│ ├── app/
│ │ └── .gitignore
│ ├── backup/
│ │ └── .gitignore
│ ├── framework/
│ │ ├── .gitignore
│ │ ├── cache/
│ │ │ └── .gitignore
│ │ ├── sessions/
│ │ │ └── .gitignore
│ │ └── views/
│ │ └── .gitignore
│ └── logs/
│ └── .gitignore
└── tests/
├── TestCase.php
└── integration/
└── Http/
└── Controllers/
├── Api/
│ ├── ApiTest.php
│ ├── PasswordsController.php
│ ├── SessionsController.php
│ ├── UsersController.php
│ └── V1/
│ └── ArticlesController.php
├── AuthTest.php
├── SessionsController.php
├── UsersController.php
└── WelcomeController.php
================================================
FILE CONTENTS
================================================
================================================
FILE: .bowerrc
================================================
{"directory":"resources/assets/vendor","analytics":false}
================================================
FILE: .editorconfig
================================================
# editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.php]
indent_size = 4
[**.blade.php]
indent_size = 2
================================================
FILE: .gitattributes
================================================
* text=auto
*.css linguist-vendored
*.less linguist-vendored
================================================
FILE: .gitignore
================================================
.env
.idea
.DS_Store
.phpstorm*
_ide_helper*
dredd.*
git_*
Homestead.*
node_modules
npm-debug.log
/public/css
/public/js
/resources/assets/css
/resources/assets/vendor
/tests/database.sqlite
/vendor
================================================
FILE: .travis.yml
================================================
language: php
php:
- 5.6
- 7.0
sudo: false
install:
- composer self-update
- travis_retry composer install --no-interaction --no-scripts --prefer-source --dev
before_script:
- cp .env.example .env
- touch tests/database.sqlite
- php artisan key:generate
- php artisan clear-compiled
- php artisan optimize
- php artisan cache:clear
- php artisan migrate --env="testing" --database="sqlite" --force
- php artisan db:seed --env="testing" --database="sqlite" --force
script: vendor/bin/phpunit
matrix:
fast_finish: true
================================================
FILE: CONTRIBUTING.md
================================================
# Contribution Guide
이 강좌에 여러가지 방법으로 기여할 수 있습니다.
- 강좌 오류 수정
- 코드 오류 수정
- ...
기여자 분들은 기여의 형태에 따라 [Contributors / Sponsors](https://github.com/appkr/l5essential#contributors--sponsors) 또는 [Contributor List](https://github.com/appkr/l5essential/graphs/contributors) 에 등록됩니다. 이 강좌는 독자 및 기여자 여러분들과 같이 만들어 가는 것입니다. 여러 분들의 기여 활동은 이 강좌를 접하는 많은 분들에게 도움이 될 것입니다.
## Pull Request
- 코드는 [PSR-2 코딩 컨벤션](http://www.php-fig.org/psr/psr-2/) 을 지켜 주세요.
- 수정 내용을 설명해 주세요.
- Fork 한후 Topic Branch 를 만들어서 PR 을 보내 주세요.
- PR 전에 최신 코드인 지를 확인해 주세요. (`$ git pull && git merge master`)
- 단순 오류 수정이 아니라, 동작을 변경하는 코드라면 Test 를 포함해 주세요.
## Test
이 강좌의 코드는 Integration Test 를 포함하고 있습니다. 코드를 수정했다면 PR 전에 테스트를 실행해 주세요. PR 을 하시면 Travis CI 가 테스트를 한번 더 수행합니다.
```sh
$ phpunit
```
감사합니다.
appkr.
================================================
FILE: Envoy.blade.php
================================================
#--------------------------------------------------------------------------
# List of tasks, that you can run...
# e.g. envoy run hello
#--------------------------------------------------------------------------
#
# hello Check ssh connection
# release Publish new release
# list Show list of releases
# checkout Checkout to the given release (must provide --release=/path/to/release)
# prune Purge old releases (must provide --keep=n, where n is a number)
#
@servers(['web' => 'aws-seoul-deploy'])
@setup
$path = [
'base' => '/home/deployer/www',
'docroot' => '/home/deployer/www/l5.appkr.kr',
'shared' => '/home/deployer/www/shared',
'release' => '/home/deployer/www/releases',
];
$required_dirs = [
$path['base'],
$path['shared'],
$path['release'],
];
$shared_item = [
'/home/deployer/www/shared/.env' => '.env',
'/home/deployer/www/shared/storage' => 'storage',
'/home/deployer/www/shared/cache' => 'bootstrap/cache',
'/home/deployer/www/shared/attachments' => 'public/attachments',
];
$distribution = [
'name' => 'release_' . date('YmdHis'),
];
$git = [
'repo' => 'git@github.com:appkr/l5essential.git',
];
@endsetup
@task('hello', ['on' => ['web']])
HOSTNAME=$(hostname);
echo "Hello Envoy! Responding from $HOSTNAME";
@endtask
@task('release', ['on' => ['web']])
{{--Create directories if not exists--}}
@foreach ($required_dirs as $dir)
if [ ! -d {{ $dir }} ]; then
mkdir {{ $dir }};
chgrp -h -R www-data {{ $dir }};
fi;
@endforeach
{{--Download book keeping officer--}}
if [ ! -f {{ $path['base'] }}/officer.php ]; then
wget https://raw.githubusercontent.com/appkr/envoy/master/scripts/officer.php -O {{ $path['base'] }}/officer.php;
fi;
{{--Fetch code from git--}}
cd {{ $path['release'] }};
git clone -b master {{ $git['repo'] }} {{ $distribution['name'] }};
{{--Symlink shared directory to current release.--}}
{{--e.g. storage, .env, user uploaded file storage, ...--}}
cd {{ $path['release'] }}/{{ $distribution['name'] }};
@foreach($shared_item as $global => $local)
[ -f {{ $local }} ] && rm {{ $local }};
[ -d {{ $local }} ] && rm -rf {{ $local }};
ln -nfs {{ $global }} {{ $local }};
chgrp -h -R www-data {{ $local }};
@endforeach
{{--Run composer install--}}
composer install --prefer-dist --no-scripts;
php artisan clear-compiled;
php artisan optimize;
php artisan cache:clear;
php artisan my:update-lesson;
{{--Symlink current release to service directory.--}}
ln -nfs {{ $path['release'] }}/{{ $distribution['name'] }} {{ $path['docroot'] }};
chgrp -h -R www-data {{ $path['docroot'] }};
{{--Set permission and change owner. Do one final more for safety.--}}
chgrp -h -R www-data {{ $path['release'] }}/{{ $distribution['name'] }};
{{--Book keeping--}}
php {{ $path['base'] }}/officer.php deploy {{ $path['release'] }}/{{ $distribution['name'] }};
{{--Restart web server.--}}
sudo service nginx restart;
sudo service php5-fpm restart;
@endtask
@task('prune', ['on' => 'web'])
if [ ! -f {{ $path['base'] }}/officer.php ]; then
echo '"officer.php" script not found.';
echo '\$ envoy run hire_officer';
exit 1;
fi;
@if (isset($keep) and $keep > 0)
php {{ $path['base'] }}/officer.php prune {{ $keep }};
@else
echo 'Must provide --keep=n, where n is a number.';
@endif
@endtask
@task('hire_officer', ['on' => 'web'])
{{--Download "officer.php" to the server--}}
wget https://raw.githubusercontent.com/appkr/envoy/master/scripts/officer.php -O {{ $path['base'] }}/officer.php;
echo '"officer.php" is ready!';
@endtask
@task('list', ['on' => 'web'])
{{--Show the list of release--}}
if [ ! -f {{ $path['base'] }}/officer.php ]; then
echo '"officer.php" script not found.';
echo '\$ envoy run hire_officer';
exit 1;
fi;
php {{ $path['base'] }}/officer.php list;
@endtask
@task('checkout', ['on' => 'web'])
{{--Checkout to the given release path--}}
if [ ! -f {{ $path['base'] }}/officer.php ]; then
echo '"officer.php" script not found.';
echo '\$ envoy run hire_officer';
exit 1;
fi;
@if (isset($release))
cd {{ $release }};
{{--Symlink shared directory to the given release.--}}
@foreach($shared_item as $global => $local)
[ -f {{ $local }} ] && rm {{ $local }};
[ -d {{ $local }} ] && rm -rf {{ $local }};
ln -nfs {{ $global }} {{ $local }};
chgrp -h -R www-data {{ $local }};
@endforeach
{{--Symlink the given release to service directory.--}}
ln -nfs {{ $release }} {{ $path['docroot'] }};
chgrp -h -R www-data {{ $path['docroot'] }};
{{--Book keeping--}}
php {{ $path['base'] }}/officer.php checkout {{ $release }};
{{--Restart web server.--}}
sudo service nginx restart;
sudo service php5-fpm restart;
@else
echo 'Must provide --release=/full/path/to/release.';
@endif
@endtask
================================================
FILE: LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2015 Appkr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Vagrantfile
================================================
require 'json'
require 'yaml'
VAGRANTFILE_API_VERSION ||= "2"
confDir = $confDir ||= File.expand_path("vendor/laravel/homestead", File.dirname(__FILE__))
homesteadYamlPath = "Homestead.yaml"
homesteadJsonPath = "Homestead.json"
afterScriptPath = "after.sh"
aliasesPath = "aliases"
require File.expand_path(confDir + '/scripts/homestead.rb')
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
if File.exists? aliasesPath then
config.vm.provision "file", source: aliasesPath, destination: "~/.bash_aliases"
end
if File.exists? homesteadYamlPath then
Homestead.configure(config, YAML::load(File.read(homesteadYamlPath)))
elsif File.exists? homesteadJsonPath then
Homestead.configure(config, JSON.parse(File.read(homesteadJsonPath)))
end
if File.exists? afterScriptPath then
config.vm.provision "shell", path: afterScriptPath
end
end
================================================
FILE: apiary.apib
================================================
FORMAT: 1A
HOST: http://api.appkr.kr
# Welcome to the myProject Api
myProject Api 에 오신 것을 환영합니다.
myProject 에서는 포럼 Forum Api 를 제공합니다. 포럼을 사용하는 사용자를 식별하고 권한을 제어하기 위해 사용자 등록 및 로그인 Authentication 기능도 제공합니다.
## Base URL
모든 Api 요청은 아래 Base URL 을 이용합니다.
```http
http://api.appkr.kr
```
## Current Version
현재 버전은 v1 입니다. [Authentication](#authentication) 을 제외하고 모든 Api 요청에 `/v1` 이 포함되어야 합니다.
```http
GET /v1 HTTP/1.1
Host: api.appkr.kr
```
## Request 101
REST 원칙을 따릅니다.
### Reqeust Headers
#### Accept (Content Negotiation)
v1 에서는 JSON 응답만 지원합니다.
```http
GET /v1 HTTP/1.1
Host: api.appkr.kr
Accept: application/json
```
#### Accept-Language (Language Negoation)
v1 에서는 ko_KR 만 지원합니다.
```http
GET /v1 HTTP/1.1
Host: api.appkr.kr
Accept-Language: ko-KR
```
### Request Payload
`multipart/form-data`, `application/x-www-form-urlencoded`, `application/json` 을 모두 지원합니다. `application/json` 사용을 권장합니다.
```http
POST /v1/articles HTTP/1.1
Host: api.appkr.kr
Content-type: application/json
{"field": "value"}
```
## Response 101
### Collection Response
Collection 에 해당하는 리소스를 요청하면 Pagination 이 포함된 응답을 받습니다. `data` 키 아래에 포함된 내용이 Collection 입니다.
```http
GET /v1/articles HTTP/1.1
Host: api.appkr.kr
Content-type: application/json
```
```json
HTTP/1.1 200 OK
{
"data": [
{"key": "value"},
{"key": "value"},
{"key": "value"},
{"key": "value"},
{"key": "value"}
],
"meta": {
"pagination": {
"total": 20,
"count": 5,
"per_page": 5,
"current_page": 1,
"total_pages": 4,
"links": {
"next": "/v1/articles?page=2"
}
}
}
}
```
### Instance Response
Instance 에 해당하는 리소스를 요청하면 요청한 Instance 하나에 대한 응답을 받습니다. 이 응답은 JSON 키가 없습니다.
```http
GET /v1/articles/95280986 HTTP/1.1
Host: api.appkr.kr
Content-type: application/json
```
```json
HTTP/1.1 200 OK
{
"id": 95280986,
"title": "example title"
}
```
### Successful Response
리소스 생성, 수정, 삭제 등의 요청을 성공하면 성공 응답을 받습니다.
```http
PUT /v1/articles/95280986 HTTP/1.1
Host: api.appkr.kr
Content-type: application/json
Authorization: Bearer header.payload.signagure
{"title": "modified title"}
```
```javascript
HTTP/1.1 200 OK
{
"success": {
"code": 200,
"message": "Updated"
}
}
```
성공 응답 목록은 아래와 같고, HTTP 응답 코드와 동일한 코드를 JSON 본문에도 포함합니다.
Response Code|Description
---|---
200|성공
201|새로운 리소스가 성공적으로 생성되었습니다.
204|요청한 리소스가 삭제되었습니다.
### Error Response
에러가 발생했을 때는 아래와 같은 응답을 받습니다.
```http
PUT /v1/articles/95280986 HTTP/1.1
Host: api.appkr.kr
Content-type: application/json
Authorization: Bearer header.payload.signagure
{"title": "modified title"}
```
```javascript
HTTP/1.1 403 Forbidden
{
"error": {
"code": 403,
"message": "Forbidden"
}
}
```
오류 응답 목록은 아래와 같고, HTTP 응답 코드와 동일한 코드를 JSON 본문에도 포함합니다.
Response Code|Description
---|---
400|요청에 오류가 있습니다.
401|인증 오류입니다. Authorization 헤더를 확인해 주세요.
403|권한이 없습니다. Authorization 헤더로 넘긴 토큰에 해당하는 사용자가 요청하신 Action 을 수행할 권한이 없습니다.
404|요청한 리소스가 없습니다.
405|잘못된 HTTP 메소드를 사용했습니다.
422|유효성 검사 오류입니다. 요청 본문에 데이터가 형식에 맞는지 확인하세요.
429|Rate Limit 를 초과했습니다. 잠시 후 다시 시도하세요.
500|서버에 오류가 있습니다. [관리자](mailto:junwonkim@me.com) 에게 신고해 주세요.
503|서버에 트래픽이 폭주했거나, 서버 유지보수 작업 중입니다.
## Rate Limit
[Authentication](#authentication) 요청은 1 분에 10 회 까지, 리소스 요청은 1분에 60 회까지 허용합니다.
```http
GET /v1/articles/95280986 HTTP/1.1
Host: api.appkr.kr
Content-type: application/json
```
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
```
## Caching
GET 동사를 이용한 요청은 `Etag` 헤더를 응답합니다.
> 대부분의 HTTP 클라이언트 라이브러리가 자동으로 모든 처리를 해 줍니다.
> 다만, 여러분의 클라이언트가 `Etag` 기능을 지원하지 않는데, 캐싱의 이득을 보려면, 응답 받은 Etag 헤더를 파싱하여 요청 URL 과 응답 본문을 저장하고, 다음 요청시 `If-None-Match: Etag` 헤더를 전송해야 합니다.
아래 그림은 `Etag` 를 이용한 클라이언트-서버간 캐싱 동작 시퀀스 다이어그램입니다.

### First Request & Response
처음으로 `Etag` 값을 받습니다. 클라이언트 저장소에 받은 `Etag` 값을 키로 요청 URL 과 응답 본문을 저장해 놓습니다. *(대부분의 클라이언트 라이브러리가 자동으로 처리해 줍니다.)*
```http
GET /v1/articles/95280986 HTTP/1.1
Host: api.appkr.kr
```
```http
HTTP/1.1 200 OK
Etag: CtXS0QQlWJKY2dXe
```
### Second-on Request & Response - **No change in server resources**
요청 URL 에 해당하는 `Etag` 값을 `If-None-Match` 헤더에 달아서 보냅니다.
응답을 보니 리소스의 내용이 변경되지 않았으므로, 클라이언트 저장소에 저장된 지난 번 응답 본문을 그대로 사용합니다. *(대부분의 클라이언트 라이브러리가 자동으로 처리해 줍니다.)*
```http
GET /v1/articles/95280986 HTTP/1.1
Host: api.appkr.kr
Content-type: application/json
If-None-Match: CtXS0QQlWJKY2dXe
```
```http
HTTP/1.1 304 Not Modified
```
### Second-on Request & Response - **Server resource changed**
서버의 리소스가 변경되었습니다. 새로 받은 `Etag` 값과 응답 본문을 클라이언트 저장소에 저장합니다. *(대부분의 클라이언트 라이브러리가 자동으로 처리해 줍니다.)*
```http
GET /v1/articles/95280986 HTTP/1.1
Host: api.appkr.kr
Content-type: application/json
If-Non-Match: CtXS0QQlWJKY2dXe
```
```http
HTTP/1.1 200 OK
Etag: F8tG872adeIC3zlf
```
**`WHY`** 모바일 환경에서 응답속도 개선 및 사용자 요금 절감을 위해 꼭 필요합니다.
## Sub Resource Inclusion
### Signature
`include` 쿼리스트링을 이용하면, 현재 요청하는 리소스의 자식 리소스의 Collection 또는 Instance 를 응답에 포함할 수 있습니다.
문법은 아래와 같습니다.
```http
GET /v1/parent?include=child_resource:limit(limit|offset):sort(sort|order) HTTP/1.1
# 여러 개의 자식 리소스를 포함할 때는 array 필드나 콤마 (,) 를 이용할 수 있습니다.
GET /v1/parent?include[]=child_resource_1&include[]=child_resource_2 HTTP/1.1
# --or--
GET /v1/parent?include[]=child_resource_1,child_resource_2 HTTP/1.1
```
Keyword|Description
---|---
`child_resource`|자식 리소스의 이름입니다. (Article 부모 리소스에 대해서는 `comments`, `author`, `tags`, `attachments` 자식 리소스가 가용합니다.)
`:`|파라미터를 구분하는 구분자입니다.
`limit`|예약어 입니다. `comments`, `author`, `tags`, `attachments` 등 Collection 형태의 리소스에만 적용 가능합니다.
`limit(offset\\limit)`|`limit` 반환 받을 Collection 개수 입니다. `offset` 건너뛸 Collection 개수입니다. **(기본값 `limit(3\\0)`)**
`sort`|예약어 입니다. Collection 형태의 리소스에만 적용 가능합니다.
`sort(sort\\order)`|`sort` 정렬 기준 필드로 현재는 `created`, `view_count` 두 개만 지원합니다. `order` 정렬 방향으로 `asc`, `desc` 를 쓸 수 있습니다. **(기본값 `sort(created\\desc)`)**
**`알림`** 위 표에서 `\\` 는 Apiary 의 마크다운 컴파일 에러를 방지하기 위해 `|` 대신 넣은 문자입니다.
### Example
아래는 Article 리소스의 자식 리소스인 comments 와 authors 를 포함하는 예제 입니다.
```http
GET /v1/articles/95280986?include=comments:limit(1|0):sort('created'|'asc') HTTP/1.1
Host: api.appkr.kr
Content-type: application/json
```
```javascript
HTTP/1.1 200 OK
{
"id": 95280986,
"title": "example title",
"content_raw": "example content",
"content_html": "
",
"created": "2015-12-19T10:37:42+0000",
"vote": {
"up": 1,
"down": 1
},
"link": {
"rel": "self",
"href": "/v1/comments/95280986"
},
"author": {
"name": "John Doe",
"email": "john@example.com",
"avatar": "http://www.gravatar.com/d4c74594d841139328695756648b6bd6"
}
}
]
},
"author": {
"name": "John Doe",
"email": "john@example.com",
"avatar": "http://www.gravatar.com/d4c74594d841139328695756648b6bd6"
},
"tags": [
"laravel",
"eloquent"
],
"attachments": 1
}
```
## Partial Response
### Against Parent Resource
`fields` 쿼리스트링을 이용하면, 응답 필드를 골라서 받을 수 있습니다. 필드는 콤마 (`,`) 로 구분하고, 필드간에 공백이 없도록 주의하십시오.
```http
GET /v1/articles/95280986?fields=id,title,content_raw,author HTTP/1.1
Host: api.appkr.kr
Content-type: application/json
```
```javascript
HTTP/1.1 200 OK
{
"id": 95280986,
"title": "example title",
"content_raw": "example content",
"author": {
"name": "John Doe",
"email": "john@example.com",
"avatar": "http://www.gravatar.com/d4c74594d841139328695756648b6bd6"
}
}
```
### Against Sub Resource
역시 `fields` 쿼리스트링을 이용해서 자식 리소스의 응답 필드를 골라 받을 수 있습니다. 부모 리소스에서와 달리, 필드 구분에 파이프 문자 (`|`) 를 이용합니다. 필드 사이에 공백이 없도록 주의하십시오.
```http
GET /v1/articles/95280986?fields=id,title,content_raw,author&include=comments:fields(id|created|vote) HTTP/1.1
Host: api.appkr.kr
Content-type: application/json
```
```javascript
HTTP/1.1 200 OK
{
"id": 95280986,
"title": "example title",
"content_raw": "example content",
"author": {
"name": "John Doe",
"email": "john@example.com",
"avatar": "http://www.gravatar.com/d4c74594d841139328695756648b6bd6"
},
"comments": {
"data": [
{
"id": 95280986,
"created": "2015-12-19T10:37:42+0000",
"vote": {
"up": 1,
"down": 1
}
}
]
},
}
```
# group Welcome to myProject Api v1
myProject Api v1 에 오신 것을 환영합니다.
## Welcoming the Api v1 [/v1]
### Hello myProject Api v1 [GET]
- request
- headers
Accept: application/json
- response 200 (application/json)
{
"name": "myProject Api",
"message": "Welcome to myProject Api. This is a base endpoint of version 1.",
"version": "v1",
"links": [
{
"rel": "self",
"href": "/v1"
},
{
"rel": "api.users.store",
"href": "/auth/register"
},
{
"rel": "api.sessions.store",
"href": "/auth/login"
},
{
"rel": "api.v1.docs",
"href": "/v1/docs"
}
]
}
# group Authentication
사용자 등록 및 인증을 위한 Api 입니다. myProject Api 는 사용자 인증을 위해 JWT Json Web Token 을 이용합니다.
## `token` 의 발급 및 사용
사용자 등록 및 로그인을 통해 생성된 `token` 은 myProject 서버에 새로운 리소스를 생성, 기존 리소스 수정 또는 삭제를 위한 Api 요청을 할 때 꼭 필요합니다. 전술한 Api 요청을 할 때 `token` 을 HTTP Authorization Header 에 붙여서 사용합니다. (e.g. `Authorization: Bearer token`) `token` 이 없으면 401 Unauthorized 응답을 받습니다. 한번 발급된 `token` 은 120 분 동안 유효하며, 유효한 기간 동안은 로그인 없이 Api 요청을 할 수 있습니다. 그래서, Api 클라이언트는 사용자 등록 및 로그인에서 받은 토큰을 로컬 저장소에 저장하고 있어야 합니다.
## `token` 의 만료 및 갱신
`token` 이 만료되면 서버는 401 Unauthorized 응답 코드와 함께 `token_expired` 메시지를 응답합니다. `token` 을 사용하지 않은지 총 2 주일이 지나지 않았다면, 기존 `token` 을 이용하여 새로운 토큰을 받을 수 있습니다. 2 주가 지나 더 이상 갱신이 불가한 상태가 되면, 서버로 부터 401 응답을 받게 되며 이때는 사용자에게 로그인 UI 를 표출하여 로그인을 하도록 유도하여 `token` 을 발급 받아야 합니다.
## User Registration [/auth/register]
새로운 사용자를 등록합니다.
### User Registration [POST]
- request OK
- headers
Accept: application/json
Content-type: application/json
- body
{
"name": "New User",
"email": "new_user@example.org",
"password": "password",
"password_confirmation": "password"
}
- response 201 (application/json)
- Attributes
- success (object)
- code: `201` (number) - Http Status Equivalent Message Code
- message: `Created` (string) - Message
- meta (object)
- token: `header.payload.signature` (string) - Json Web Token
- request Invalid Data
- headers
Accept: application/json
Content-type: application/json
- body
{"email": "invalid-email-address"}
- response 422 (application/json)
- Attributes
- error (object)
- code: `422` (number) - Http Status Equivalent Message Code
- message (array)
- `name 은(는) 필수 입력 항목 입니다.`
- `email 은(는) 유효한 이메일 주소가 아닙니다.`
- `password 은(는) 필수 입력 항목 입니다.`
- request Duplicate User
- headers
Accept: application/json
Content-type: application/json
- body
{
"name": "John Doe",
"email": "john@example.com",
"password": "password",
"password_confirmation": "password"
}
- response 422 (application/json)
- Attributes
- error (object)
- code: `422` (number) - Http Status Equivalent Message Code
- message (array)
- `email 은(는) 이미 사용 중입니다.`
## User Login [/auth/login]
로그인을 하고 `token` 을 얻습니다.
### User Login [POST]
- request OK
- headers
Accept: application/json
Content-type: application/json
- body
{
"email": "john@example.com",
"password": "password"
}
- response 201 (application/json)
- Attributes
- success (object)
- code: `201` (number) - Http Status Equivalent Message Code
- message: `Created` (string) - Message
- meta (object)
- token: `header.payload.signature` (string) - Json Web Token
- request Invalid Credential
- headers
Accept: application/json
Content-type: application/json
- body
{
"email": "john@example.com",
"password": "wrong-password"
}
- response 401 (application/json)
- Attributes
- error (object)
- code: `401` (number) - Http Status Equivalent Message Code
- message: `invalid_credentials` (string) - Message
- request Invalid Data
- headers
Accept: application/json
Content-type: application/json
- body
{
"email": "invalid-email-address",
"password": "password"
}
email: invalid-email-address
- response 422 (application/json)
- Attributes
- error (object)
- code: `422` (number) - Http Status Equivalent Message Code
- message (array)
- `email 은(는) 유효한 이메일 주소가 아닙니다.` (string)
## Token Refresh [/auth/refresh]
만료된 `token` 을 갱신합니다.
### Token Refresh [POST]
- request OK
- headers
Accept: application/json
Authorization: Bearer header.payload.signagure
- response 201 (application/json)
- Attributes
- success (object)
- code: `201` (number) - Http Status Equivalent Message Code
- message: `Created` (string) - Message
- meta (object)
- token: `header.payload.signature` (string) - Json Web Token
# group Forum
Forum Api 는 HTTP 요청을 통해 여러분의 Api 클라이언트가 서버에 저장된 게시글 `Article` 과 댓글 `Comment` 리소스를 이용할 수 있도록 해 줍니다.
**`참고`** 댓글에 대한 생성, 수정, 삭제 Api 는 현재 구현되어 있지 않습니다.
## Articles Collection [/v1/articles]
### List All Articles [GET]
`Article` Collection 을 요청합니다. 사용할 수 있는 쿼리스트링은 다음과 같습니다.
#### Available Querystrings
- `filter` - 필터 (선택, `no_comment`|`not_solved`)
```http
GET /v1/articles?filter=no_comment
```
- `limit` - 응답 받을 게시물 수 (선택, `1`~`10`, 기본값 `5`)
```http
GET /v1/articles?limit=3
```
- `sort` - 정렬에 사용할 필드 (선택, `created`|`view_count`, 기본값 `created`)
```http
GET /v1/articles?sort=view_count
```
- `order` - 정렬 방향 (선택, `asc`|`desc`, 기본값 `desc`)
```http
GET /v1/articles?sort=view_count&order=asc
```
- `q` - 풀 텍스트 서치 키워드 (선택)
```http
GET /v1/articles?q=hello%20api
```
- request OK
- headers
Accept: application/json
- response 200 (application/json)
- headers
Etag: aK08i2L8jQRdjBcd
- attributes
- data (array[Article])
- meta (Pagination)
- request Not Modified
- headers
Accept: application/json
If-None-Match: etag
- response 304 (application/json)
### Not-allowed Querystrings [GET /v1/articles?filter=not_existing_filter]
- request Not-existing Querystrings
- headers
Accept: application/json
- response 422 (application/json)
- Attributes
- error (object)
- code: `422` (number) - Http Status Equivalent Message Code
- message (object)
- filter (array)
- `filter 은(는) 유효하지 않습니다.` (string)
### Create New Article [POST]
새로운 `Article` 리소스를 생성합니다.
- request OK
- headers
Accept: application/json
Content-type: application/json
Authorization: Bearer header.payload.signagure
- body
{
"title": "some title",
"content": "some content",
"tags[]": 1,
"tags[]": 2
}
- response 201 (application/json)
- attributes
- success (object)
- code: `201` (number) - Http Status Equivalent Message Code
- message: `Created` (string) - Message
- request Token Not Provided
- headers
Accept: application/json
Content-type: application/json
- response 400 (application/json)
- attributes
- error (object)
- code: `400` (number) - Http Status Equivalent Message Code
- message: `token_not_provided` (string) - Message
- request Token Invalid
- headers
Accept: application/json
Authorization: Bearer invalid.token.signature
- response 400 (application/json)
- attributes
- error (object)
- code: `400` (number) - Http Status Equivalent Message Code
- message: `token_invalid` (string) - Message
- request Token Expired
- headers
Authorization: Bearer expired.token.signagure
Content-type: application/json
- response 401 (application/json)
- attributes
- error (object)
- code: `400` (number) - Http Status Equivalent Message Code
- message: `token_expired` (string) - Message
## Articles Instance [/v1/articles/{id}]
- parameters
- id: `95280986` (required, number)
### Fetch Article Instance [GET]
`id` 로 지정된 `Article` 리소스의 상세정보를 요청합니다.
`Article Collection` 과 동일한 [Available Querystrings](#available_querystrings) 을 사용할 수 있습니다.
- request OK
- headers
Accept: application/json
- response 200 (application/json)
- headers
Etag: 31Ba8Uuo29Za4s2v
- body
{
"id": 95280986,
"title": "example title",
"content_raw": "example content",
"content_html": "
example content
",
"created": "2015-12-19T10:37:42+0000",
"view_count": 1,
"link": {
"rel": "self",
"href": "/v1/articles/95280986"
},
"comments": 1,
"author": {
"name": "John Doe",
"email": "john@example.com",
"avatar": "http://www.gravatar.com/d4c74594d841139328695756648b6bd6"
},
"tags": [
"laravel",
"eloquent"
],
"attachments": 1
}
- request Not Modified
- headers
Accept: application/json
If-None-Match: 31Ba8Uuo29Za4s2v
- response 304 (application/json)
### Fetch Not-existing Article Instance [GET /v1/articles/{id}]
없는 `id` 이면 404 Not Found 응답을 받습니다.
- parameters
- id: `00000000` (required, number)
- request Not-existing Resource
- headers
Accept: application/json
- response 404 (application/json)
- Attributes
- error (object)
- code: `404` (number) - Http Status Equivalent Message Code
- message: `No query results for model [App\\Article].` (string) - Message
### Update Article Instance [PUT]
`id` 로 지정된 `Article` 의 내용을 수정합니다. 수정은 리소스의 소유자 또는 관리자에게만 허용되며, 이 조건이 충족되지 않을 경우에는 403 Forbidden 응답을 받습니다.
- request OK
- headers
Accept: application/json
Content-type: application/json
Authorization: Bearer header.payload.signagure
- body
{
"title": "updated title",
"content": "updated content",
"tags[]": "3"
}
- response 200 (application/json)
- attributes
- success (object)
- code: `200` (number) - Http Status Equivalent Message Code
- message: `Updated` (string) - Message
- request Forbidden
- headers
Accept: application/json
Content-type: application/json
Authorization: Bearer not.owner.signagure
- response 403 (application/json)
- attributes
- error (object)
- code: `403` (number) - Http Status Equivalent Message Code
- message: `Forbidden` (string) - Message
### Delete Article Instance [DELETE]
`id` 로 지정된 `Article` 을 삭제합니다. 삭제는 리소스의 소유자 또는 관리자에게만 허용되며, 이 조건이 충족되지 않을 경우에는 403 Forbidden 응답을 받습니다.
- request OK
- headers
Accept: application/json
Authorization: Bearer header.payload.signagure
- response 204 (application/json)
- request Forbidden
- headers
Accept: application/json
Authorization: Bearer not.owner.signagure
- response 403 (application/json)
- attributes
- error (object)
- code: `403` (number) - Http Status Equivalent Message Code
- message: `Forbidden` (string) - Message
# data structures
## Article (object)
- id: `95280986` (number) - 고유 키
- title: `example title` (string) - 제목
- content_raw: `example content` (string) - 본문 (Markdown 형식)
- content_html: `
example content
` (string) - 본문 (HTML 컴파일)
- created: `2015-12-19T10:37:42+0000` (string) - 생성일
- view_count: 1 (number) - 조회 수
- link (object)
- rel: `self`
- href: `/v1/articles/95280986` (string) - 상세 내용 요청을 위한 Api Endpoint
- comments: `1` (number) - 댓글 개수
- author (object)
- name: `John Doe` (string) - 작성자 이름
- email: `john@example.com` (string) - 작성자 이메일
- avatar: `http://www.gravatar.com/d4c74594d841139328695756648b6bd6` (string) - 작성자 아바타 URL
- tags (array)
- laravel
- eloquent
- attachments: `1` (number) - 첨부파일 개수
## User (object)
- id: `95280986` (number) - 고유 키
- name: `John Doe` (string) - 사용자 이름
- email: `john@example.com` (string) - 사용자 이메일
- avatar: `http://www.gravatar.com/d4c74594d841139328695756648b6bd6` (string) - 사용자 아바타 URL
- signup: `2016-01-12T06:18:10+0000` - 가입일
- link (object)
- rel: `self`
- href: `/users/95280986` (string) - 상세 내용 요청을 위한 Api Endpoint
- articles: `1` (number) - 포럼 게시글 개수
- comments: `1` (number) - 작성한 댓글 개수
## Comment (object)
- id: `95280986` (number) - 고유 키
- content_raw: `example content` (string) - 본문 (Markdown 형식)
- content_html: `
example content
` (string) - 본문 (HTML 컴파일)
- created: `2015-12-19T10:37:42+0000` (string) - 생성일
- vote (object)
- up: `1` (number) - 좋아요 투표 수
- down: `1` (number) - 싫어요 투표 수
- link (object)
- rel: `self`
- href: `/v1/comments/95280986` (string) - 상세 내용 요청을 위한 Api Endpoint
- author (object)
- name: `John Doe` (string) - 작성자 이름
- email: `john@example.com` (string) - 작성자 이메일
- avatar: `http://www.gravatar.com/d4c74594d841139328695756648b6bd6` (string) - 작성자 아바타 URL
## Tag (object)
- id: `95280986` (number) - 고유 키
- slug: `laravel` (string) - Slug
- created: `2015-12-19T10:37:42+0000` (string) - 생성일
- link (object)
- rel: `self`
- href: `/v1/tags/laravel/articles` (string) - 상세 내용 요청을 위한 Api Endpoint
- articles: 1 (number) - 태그에 해당하는 게시글 개수
## Attachment (object)
- id: `95280986` (number) - 고유 키
- name: `kEvzc4qBPwEze1mi.jpg`
- created: `2015-12-19T10:37:42+0000` (string) - 생성일
- link (object)
- rel: `self`
- href: `http://myproject.dev:8000/attachments/kEvzc4qBPwEze1mi.jpg` (string) - 첨부파일 다운로드 URL
## Pagination
- pagination (object)
- total: `20` (number) - 전체 게시글 개수
- count: `5` (number) - 현재 응답의 게시글 개수
- per_page: `5` (number) - 요청당 응답할 게시글 개수
- current_page: `2` (number) - 현재 페이지 번호
- total_pages: `4` (number) - 총 페이지 수
- links (object)
- previous: `/v1/articles?page=1` (string) - 이전 페이지 URL
- next: `/v1/articles?page=3` (string) - 다음 페이지 URL
================================================
FILE: app/Article.php
================================================
belongsTo(User::class, 'author_id');
}
public function tags()
{
return $this->belongsToMany(Tag::class)->withTimestamps();
}
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
public function solution()
{
return $this->hasOne(Comment::class, 'id', 'solution_id');
}
public function attachments()
{
return $this->hasMany(Attachment::class);
}
/* Query Scope */
public function scopeNoComment($query)
{
return $query->has('comments', '<', 1);
}
public function scopeNotSolved($query)
{
return $query->whereNull('solution_id');
}
/* Helpers */
public function isNotice()
{
return $this->pin ? true : false;
}
public function etag($cacheKey = null)
{
$etag = $this->getTable() . $this->getKey();
if ($this->usesTimestamps()) {
$etag .= $this->updated_at->timestamp;
}
return md5($etag.$cacheKey);
}
}
================================================
FILE: app/Attachment.php
================================================
belongsTo(Article::class);
}
}
================================================
FILE: app/AuthorTrait.php
================================================
author->id == auth()->user()->id;
}
}
================================================
FILE: app/Comment.php
================================================
sum('up');
}
public function getDownCountAttribute()
{
return (int) static::votes()->sum('down');
}
/* Relationships */
public function author()
{
return $this->belongsTo(User::class, 'author_id');
}
public function commentable()
{
return $this->morphTo();
}
public function replies()
{
return $this->hasMany(Comment::class, 'parent_id')->latest();
}
public function parent()
{
return $this->belongsTo(Comment::class, 'parent_id', 'id');
}
public function votes()
{
return $this->hasMany(Vote::class);
}
}
================================================
FILE: app/Console/Commands/BackupDb.php
================================================
%s -u%s -p%s',
$this->option('db'),
storage_path("backup/{$this->option('db')}.sql"),
$this->argument('user'),
$this->argument('pass')
);
system($command);
$now = \Carbon\Carbon::now()->toDateTimeString();
$result = "{$this->getName()} command done at {$now}";
\Log::info($result);
return $this->info($result);
}
}
================================================
FILE: app/Console/Commands/ClearLog.php
================================================
' . $path);
$now = \Carbon\Carbon::now()->toDateTimeString();
$result = "{$this->getName()} command done at {$now}";
\Log::info($result);
return $this->info($result);
}
}
================================================
FILE: app/Console/Commands/Inspire.php
================================================
comment(PHP_EOL.Inspiring::quote().PHP_EOL);
}
}
================================================
FILE: app/Console/Commands/PruneRelease.php
================================================
argument('path');
$dirs = collect(File::directories($path))->sortByDesc(function($dir) {
return File::lastModified($dir);
});
$dirs->values()->splice($this->option($keep))->map(function($dir) {
File::deleteDirectory($dir);
$this->info(sprintf('%s removed.', $dir));
});
$now = \Carbon\Carbon::now()->toDateTimeString();
$result = sprintf(
'%s command done at %s. %d %s removed',
$this->getName(),
$now,
$dirs->count(),
str_plural('release', $dirs->count())
);
\Log::info($result . ': ' . $dirs->toJson());
return $this->warn($result);
}
}
================================================
FILE: app/Console/Commands/UpdateLessonsTable.php
================================================
name);
$lesson->content = \File::get($path);
$lesson->save();
$lesson->touch();
$this->info(sprintf('Success updating %d: %s', $lesson->id, $lesson->name));
}
return $this->warn('Finished.');
}
}
================================================
FILE: app/Console/Kernel.php
================================================
command('inspire')->hourly();
$schedule->command('my:clear-log')->monthly();
$schedule->command(sprintf(
'my:backup-db %s %s',
env('DB_USERNAME'),
env('DB_PASSWORD')
))->dailyAt('03:00');
}
}
================================================
FILE: app/Events/ArticleConsumed.php
================================================
article = $article;
}
}
================================================
FILE: app/Events/Event.php
================================================
cacheTags = $cacheTags;
}
}
================================================
FILE: app/Exceptions/Handler.php
================================================
shouldReport($e) and app()->environment('production')) {
app(\App\Reporters\ErrorReport::class, [$e])->send();
}
return parent::report($e);
}
/**
* Render an exception into an HTTP response.
*
* @param \Illuminate\Http\Request $request
* @param \Exception $e
* @return \Illuminate\Http\Response
*/
public function render($request, Exception $e)
{
if (app()->environment('production')) {
$title = 'Error';
$description = 'Unknown error occurred :(';
$statusCode = 400;
if ($e instanceof ModelNotFoundException or $e instanceof NotFoundHttpException) {
$title = trans('errors.not_found');
$description = trans('errors.not_found_description');
$statusCode = 404;
}
return response(view('errors.notice', [
'title' => $title,
'description' => $description
]), $e->getCode() ?: $statusCode);
}
if (is_api_request()) {
$statusCode = method_exists($e, 'getStatusCode')
? $e->getStatusCode()
: $e->getCode();
if ($e instanceof TokenExpiredException) {
$message = 'token_expired';
} elseif ($e instanceof TokenInvalidException) {
$message = 'token_invalid';
} elseif ($e instanceof JWTException) {
$message = $e->getMessage() ?: 'could_not_create_token';
} elseif ($e instanceof NotFoundHttpException or $e instanceof ModelNotFoundException) {
$statusCode = 404;
$message = $e->getMessage() ?: 'not_found';
} elseif ($e instanceof MethodNotAllowedHttpException) {
$message = $e->getMessage() ?: 'not_allowed';
} elseif ($e instanceof HttpResponseException){
return $e->getResponse();
} elseif ($e instanceof Exception){
$message = $e->getMessage() ?: 'Whoops~ Tell me what you did :(';
}
return json()->setStatusCode($statusCode ?: 400)->error($message);
}
return parent::render($request, $e);
}
}
================================================
FILE: app/Http/Controllers/Api/PasswordsController.php
================================================
middleware = [];
$this->middleware('throttle.api:10,1');
parent::__construct();
}
/**
* Make an error response.
*
* @param $message
* @param int $statusCode
* @return \Illuminate\Http\JsonResponse
*/
protected function respondError($message, $statusCode = 400)
{
return json()->setStatusCode($statusCode)->error('not_found');
}
/**
* Make a success response.
*
* @param $message
* @return \Illuminate\Http\RedirectResponse
*/
protected function respondSuccess($message)
{
return json()->success();
}
}
================================================
FILE: app/Http/Controllers/Api/SessionsController.php
================================================
middleware = [];
$this->middleware('throttle.api:10,1');
$this->middleware('jwt.refresh', ['only' => 'refresh']);
parent::__construct();
}
/**
* Blank method for token refresh.
*
* @return bool
*/
public function refresh()
{
return true;
}
/**
* Make validation error response.
*
* @param \Illuminate\Contracts\Validation\Validator $validator
* @return \Illuminate\Http\JsonResponse
*/
protected function respondValidationError(Validator $validator)
{
return json()->unprocessableError($validator->errors()->all());
}
/**
* Make login failed response.
*
* @return \Illuminate\Http\JsonResponse
*/
protected function respondLoginFailed()
{
return json()->unauthorizedError('invalid_credentials');
}
/**
* Make a success response.
*
* @param string $return
* @param string $token
* @return \Illuminate\Http\JsonResponse
*/
protected function respondCreated($return = '', $token = '')
{
return json()->setMeta(['token' => $token])->created();
}
}
================================================
FILE: app/Http/Controllers/Api/UsersController.php
================================================
middleware = [];
$this->middleware('throttle.api:10,1');
parent::__construct();
}
/**
* Display the specified resource.
*
* @param int $id
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
return json()->withItem(
\App\User::with('articles', 'comments')->findOrFail($id),
new \App\Transformers\UserTransformer
);
}
/**
* Make validation error response.
*
* @param \Illuminate\Contracts\Validation\Validator $validator
* @return \Illuminate\Http\JsonResponse
*/
protected function respondValidationError(Validator $validator)
{
return json()->unprocessableError($validator->errors()->all());
}
/**
* Make a success response.
*
* @param \App\User $user
* @return \Illuminate\Http\JsonResponse
*/
protected function respondCreated(User $user)
{
return json()->setMeta(['token' => \JWTAuth::fromUser($user)])->created();
}
}
================================================
FILE: app/Http/Controllers/Api/V1/ArticlesController.php
================================================
middleware('jwt.auth', ['except' => ['index', 'show']]);
$this->middleware('throttle.api:60,1');
$this->middleware('obfuscate:article');
parent::__construct();
}
/**
* Respond Article collection in JSON.
*
* @param \Illuminate\Pagination\LengthAwarePaginator $articles
* @param string|null $cacheKey
* @return \Illuminate\Http\JsonResponse
*/
protected function respondCollection(LengthAwarePaginator $articles, $cacheKey = null)
{
$reqEtag = request()->getETags();
$genEtag = $this->etags($articles, $cacheKey);
if (config('project.cache') === true and isset($reqEtag[0]) and $reqEtag[0] === $genEtag) {
return $this->respondNotModified();
}
return json()->setHeaders(['Etag' => $genEtag])->withPagination(
$articles,
new ArticleTransformer
);
}
/**
* Respond 201 in JSON.
*
* @param \App\Article $article
* @return \Illuminate\Http\JsonResponse
*/
protected function respondCreated(Article $article)
{
return json()->created();
}
/**
* Respond single Article item in JSON.
*
* @param \App\Article $article
* @param \Illuminate\Database\Eloquent\Collection|null $commentsCollection
* @param string|null $cacheKey
* @return \Illuminate\Http\JsonResponse
*/
protected function respondItem(Article $article, Collection $commentsCollection = null, $cacheKey = null)
{
$reqEtag = request()->getETags();
$genEtag = $article->etag($cacheKey);
if (config('project.cache') === true and isset($reqEtag[0]) and $reqEtag[0] === $genEtag) {
return $this->respondNotModified();
}
return json()->setHeaders(['Etag' => $genEtag])->withItem($article, new ArticleTransformer);
}
/**
* Respond Updated in JSON.
*
* @param \App\Article $article
* @return \Illuminate\Http\JsonResponse
*/
protected function respondUpdated(Article $article)
{
return json()->success('Updated');
}
/**
* Respond 204 Deleted.
*
* @param \App\Article $article
* @return \Illuminate\Http\JsonResponse
*/
protected function respondDeleted(Article $article)
{
return json()->noContent();
}
/**
* Respond Not Modified;
*
* @return \Illuminate\Contracts\Http\Response
*/
protected function respondNotModified()
{
return json()->notModified();
}
}
================================================
FILE: app/Http/Controllers/Api/V1/CommentsController.php
================================================
middleware('jwt.auth');
parent::__construct();
}
/**
* Display a listing of the resource.
*/
public function index()
{
return json()->withPagination(
\App\Comment::paginate(5),
new CommentTransformer
);
}
/**
* Display the specified resource.
*
* @param int $id
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$article = \App\Comment::findOrFail($id);
return json()->withItem(
$article,
new CommentTransformer
);
}
}
================================================
FILE: app/Http/Controllers/Api/V1/WelcomeController.php
================================================
'myProject Api',
'message' => 'Welcome to myProject Api. This is a base endpoint of version 1.',
'version' => 'v1',
'links' => [
[
'rel' => 'self',
'href' => route(\Route::currentRouteName())
],
[
'rel' => 'api.users.store',
'href' => route('api.users.store')
],
[
'rel' => 'api.sessions.store',
'href' => route('api.sessions.store')
],
[
'rel' => 'api.v1.docs',
'href' => 'http://docs.forumv1.apiary.io/'
],
],
]);
}
}
================================================
FILE: app/Http/Controllers/Api/WelcomeController.php
================================================
'myProject Api',
'message' => 'Welcome to myProject Api. This is a base endpoint.',
'version' => 'n/a',
'links' => [
[
'rel' => 'self',
'href' => route(\Route::currentRouteName())
],
[
'rel' => 'api.users.store',
'href' => route('api.users.store')
],
[
'rel' => 'api.sessions.store',
'href' => route('api.sessions.store')
],
[
'rel' => 'api.v1.index',
'href' => route('api.v1.index')
],
[
'rel' => 'api.v1.docs',
'href' => 'http://docs.forumv1.apiary.io/'
],
],
]);
}
}
================================================
FILE: app/Http/Controllers/ArticlesController.php
================================================
middleware('author:article', ['only' => ['update', 'destroy', 'pickBest']]);
if (! is_api_request()) {
$this->middleware('auth', ['except' => ['index', 'show']]);
$allTags = \Cache::remember('tags', 30, function() {
return Tag::with('articles')->get();
});
view()->share('allTags', $allTags);
}
parent::__construct();
}
public function cacheKeys()
{
return 'articles';
}
/**
* Display a listing of the resource.
*
* @param \App\Http\Requests\FilterArticlesRequest $request
* @param string|null $slug
* @return \Illuminate\Http\Response
*/
public function index(FilterArticlesRequest $request, $slug = null)
{
$query = $slug
? Tag::whereSlug($slug)->firstOrFail()->articles()
: new Article;
$cacheKey = cache_key('articles.index');
$query = $this->filter($query->orderBy('pin', 'desc'));
$args = $request->input(config('project.params.limit'), 5);
$articles = $this->cache($cacheKey, 5, $query, 'paginate', $args);
return $this->respondCollection($articles, $cacheKey);
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
$article = new Article;
return view('articles.create', compact('article'));
}
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\ArticlesRequest $request
* @return \Illuminate\Http\Response
*/
public function store(ArticlesRequest $request)
{
$payload = array_merge($request->except('_token'), [
'notification' => $request->has('notification'),
]);
$article = $request->user()->articles()->create($payload);
$article->tags()->sync($request->input('tags'));
if ($request->has('attachments')) {
$attachments = \App\Attachment::whereIn('id', $request->input('attachments'))->get();
$attachments->each(function ($attachment) use ($article) {
$attachment->article()->associate($article);
$attachment->save();
});
}
event(new ModelChanged(['articles', 'tags']));
return $this->respondCreated($article);
}
/**
* Display the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function show($id)
{
$cacheKey = cache_key("articles.show.{$id}");
$secondKey = cache_key("articles.show.{$id}.comments");
$query = Article::with('comments', 'tags', 'attachments', 'solution')->findOrFail($id);
$article = $this->cache($cacheKey, 5, $query, 'findOrFail', $id);
$secondQuery = $article->comments()->with('replies')->withTrashed()->whereNull('parent_id')->latest();
$commentsCollection = $this->cache($secondKey, 5, $secondQuery, 'get');
if (! is_api_request()) {
event(new ArticleConsumed($article));
}
return $this->respondItem($article, $commentsCollection, $cacheKey.$secondKey);
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit($id)
{
$article = Article::findOrFail($id);
return view('articles.edit', compact('article'));
}
/**
* Update the specified resource in storage.
*
* @param \App\Http\Requests\ArticlesRequest $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(ArticlesRequest $request, $id)
{
$payload = array_merge($request->except('_token'), [
'notification' => $request->has('notification'),
]);
$article = Article::findOrFail($id);
$article->update($payload);
if ($request->has('tags')) {
$article->tags()->sync($request->input('tags'));
}
event(new ModelChanged(['articles', 'tags']));
return $this->respondUpdated($article);
}
public function pickBest(Request $request, $id)
{
$this->validate($request, [
'solution_id' => 'required|numeric|exists:comments,id',
]);
Article::findOrFail($id)->update([
'solution_id' => $request->input('solution_id'),
]);
return json()->noContent();
}
/**
* Remove the specified resource from storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
* @throws \Exception
*/
public function destroy(Request $request, $id)
{
$article = Article::with('attachments', 'comments')->findOrFail($id);
foreach ($article->attachments as $attachment) {
\File::delete(attachment_path($attachment->name));
}
$article->attachments()->delete();
$article->comments->each(function ($comment) use ($request) {
app(\App\Http\Controllers\CommentsController::class)->destroy($request, $comment->id);
});
$article->delete();
event(new ModelChanged('articles'));
if ($request->ajax()) {
return response()->json('', 204);
}
return $this->respondDeleted($article);
}
/**
* Respond Article Collection.
*
* @param \Illuminate\Pagination\LengthAwarePaginator $articles
* @param string|null $cacheKey
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
protected function respondCollection(LengthAwarePaginator $articles, $cacheKey = null)
{
return view('articles.index', compact('articles'));
}
/**
* Respond Created.
*
* @param \App\Article $article
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
protected function respondCreated(Article $article)
{
flash()->success(trans('common.created'));
return redirect(route('articles.index'));
}
/**
* Respond single Article item with a corresponding Comment collection.
*
* @param \App\Article $article
* @param \Illuminate\Database\Eloquent\Collection|null $commentsCollection
* @param string|null $cacheKey
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
protected function respondItem(Article $article, Collection $commentsCollection = null, $cacheKey = null)
{
return view('articles.show', [
'article' => $article,
'comments' => $commentsCollection,
'commentableType' => Article::class,
'commentableId' => $article->id,
]);
}
/**
* Respond Updated.
*
* @param \App\Article $article
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
protected function respondUpdated(Article $article)
{
flash()->success(trans('common.updated'));
return redirect(route('articles.show', $article->id));
}
/**
* Respond Deleted.
*
* @param \App\Article $article
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
protected function respondDeleted(Article $article)
{
flash()->success(trans('common.deleted'));
return redirect(route('articles.index'));
}
/**
* Respond Not Modified.
*
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Symfony\Component\HttpFoundation\Response
*/
protected function respondNotModified()
{
return response('', 304);
}
}
================================================
FILE: app/Http/Controllers/AttachmentsController.php
================================================
hasFile('file')) {
return response()->json('File not passed !', 422);
}
// Save file
$file = $request->file('file');
$name = time() . '_' . str_replace(' ', '_', $file->getClientOriginalName());
$file->move(attachment_path(), $name);
$articleId = $request->input('articleId');
// Persist Attachment model
$attachment = $articleId
? \App\Article::findOrFail($articleId)->attachments()->create(['name' => $name])
: \App\Attachment::create(['name' => $name]);
event(new ModelChanged('attachments'));
return response()->json([
'id' => $attachment->id,
'name' => $name,
'type' => $file->getClientMimeType(),
'url' => sprintf("/attachments/%s", $name),
]);
}
/**
* Remove the specified resource from storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy(Request $request, $id)
{
$attachment = \App\Attachment::findOrFail($id);
$path = attachment_path($attachment->name);
if (\File::exists($path)) {
\File::delete($path);
}
$attachment->delete();
event(new ModelChanged('attachments'));
if ($request->ajax()) {
return response()->json('', 204);
}
flash()->success(trans('common.deleted'));
return back();
}
}
================================================
FILE: app/Http/Controllers/Cacheable.php
================================================
middleware('auth');
$this->middleware('author:comment', ['except' => ['store', 'vote']]);
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
$this->validate($request, [
'commentable_type' => 'required|in:App\Article,App\Lesson',
'commentable_id' => 'required|numeric',
'parent_id' => 'numeric|exists:comments,id',
'content' => 'required',
]);
$parentModel = "\\" . $request->input('commentable_type');
$comment = $parentModel::find($request->input('commentable_id'))
->comments()->create([
'author_id' => \Auth::user()->id,
'parent_id' => $request->input('parent_id', null),
'content' => $request->input('content')
]);
event('comments.created', [$comment]);
event(new ModelChanged('articles', 'comments'));
flash()->success(trans('forum.comment_add'));
return back();
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(Request $request, $id)
{
$this->validate($request, ['content' => 'required']);
$comment = Comment::findOrFail($id);
$comment->update($request->only('content'));
event('comments.updated', [$comment]);
event(new ModelChanged('articles', 'comments'));
flash()->success(trans('forum.comment_edit'));
return back();
}
/**
* Vote up or down for the given comment.
*
* @param \Illuminate\Http\Request $request
* @param $id
* @return \Illuminate\Http\JsonResponse
*/
public function vote(Request $request, $id)
{
$this->validate($request, [
'vote' => 'required|in:up,down',
]);
if(Vote::whereCommentId($id)->whereUserId($request->user()->id)->exists()) {
return response()->json(['errors' => 'Already voted!'], 409);
}
$comment = Comment::findOrFail($id);
$up = $request->input('vote') == 'up' ? true : false;
$comment->votes()->create([
'user_id' => $request->user()->id,
'up' => $up ? 1 : null,
'down' => $up ? null : 1,
'voted_at' => \Carbon\Carbon::now()->toDateTimeString(),
]);
return response()->json([
'voted' => $request->input('vote'),
'value' => $comment->votes()->sum($request->input('vote'))
]);
}
/**
* Remove the specified resource from storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy(Request $request, $id)
{
$comment = Comment::with('replies')->find($id);
// Do not recursively destroy children comments.
// Because 1. Soft delete feature was adopted,
// and 2. it's not just pleasant for authors of children comments to being deleted by the parent author.
if ($comment->replies->count() > 0) {
$comment->delete();
} else {
if ($comment->votes->count()) {
$this->deleteVote($comment->votes);
}
$comment->forceDelete();
}
// $this->recursiveDestroy($comment);
event(new ModelChanged('articles', 'comments'));
if ($request->ajax()) {
return response()->json('', 204);
}
flash()->success(trans('common.deleted'));
return back();
}
/**
* Delete given votes collection.
*
* @param $votes
*/
protected function deleteVote($votes)
{
foreach($votes as $vote) {
$vote->delete();
}
}
/**
* Delete comment recursively
*
* @param \App\Comment $comment
* @return bool|null
*/
public function recursiveDestroy(Comment $comment)
{
if ($comment->replies->count()) {
$comment->replies->each(function($reply) {
if ($reply->replies->count()) {
$this->recursiveDestroy($reply);
} else {
$reply->delete();
}
});
}
return $comment->delete();
}
}
================================================
FILE: app/Http/Controllers/Controller.php
================================================
setSharedVariables();
}
$this->cache = app('cache');
if ((new \ReflectionClass($this))->implementsInterface(\App\Http\Controllers\Cacheable::class) and taggable()) {
$this->cache = app('cache')->tags($this->cacheKeys());
}
}
/**
* Share common view variables
*/
protected function setSharedVariables() {
view()->share('currentLocale', app()->getLocale());
view()->share('currentUser', auth()->user());
view()->share('currentRouteName', \Route::currentRouteName());
view()->share('currentUrl', current_url());
view()->share('isLandingPage', in_array(\Route::currentRouteName(), ['home', 'index']));
}
/**
* Do the filter, search, and sorting job
*
* @param $query
* @return mixed
*/
protected function filter($query)
{
$params = config('project.params');
if ($filter = request()->input($params['filter'])) {
$query = $query->{camel_case($filter)}();
}
if ($keyword = request()->input($params['search'])) {
$raw = 'MATCH(title,content) AGAINST(? IN BOOLEAN MODE)';
$query = $query->whereRaw($raw, [$keyword]);
}
$sort = request()->input($params['sort'], 'created_at');
$direction = request()->input($params['order'], 'desc');
if ($sort == 'created') {
// We transformed field name of 'created_at' to 'created'.
// Applicable only to api request. But this code laid here
// to suppress QueryException of not existing column in web request.
$sort = 'created_at';
}
return $query->orderBy($sort, $direction);
}
/**
* Create etag against collection of resources.
*
* @param \Illuminate\Database\Eloquent\Collection|\Illuminate\Contracts\Pagination\Paginator| $collection
* @param string|null $cacheKey
* @return string
*/
protected function etags($collection, $cacheKey = null)
{
$etag = '';
foreach($collection as $instance) {
$etag .= $instance->etag();
}
return md5($etag.$cacheKey);
}
/**
* Execute caching against database query.
*
* @see config/project.php's cache section.
*
* @param string $key
* @param int $minutes
* @param \App\Model|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder
* |\Illuminate\Database\Eloquent\Relations\Relation $query
* @param string $method
* @param mixed ...$args
* @return mixed
*/
protected function cache($key, $minutes, $query, $method, ...$args)
{
$args = (! empty($args)) ? implode(',', $args) : null;
if (config('project.cache') === false) {
return $query->{$method}($args);
}
return $this->cache->remember($key, $minutes, function() use($query, $method, $args){
return $query->{$method}($args);
});
}
}
================================================
FILE: app/Http/Controllers/LessonsController.php
================================================
repo = $repo;
parent::__construct();
}
/**
* Show document page in response to the given $file.
*
* @param string $file
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function show($file = '01-welcome.md')
{
$lesson = $this->repo->find($file);
$commentsCollection = $lesson->comments()->with('replies')
->withTrashed()->whereNull('parent_id')->latest()->get();
return view('lessons.show', [
'index' => $this->repo->index(),
'lesson' => $lesson,
'prev' => $this->repo->prev($file),
'next' => $this->repo->next($file),
'comments' => $commentsCollection,
'commentableType' => $this->repo->model(),
'commentableId' => $lesson->id,
]);
}
/**
* Make image response.
*
* @param $file
* @return \Illuminate\Http\Response
*/
public function image($file)
{
$image = $this->repo->image($file);
$reqEtag = Request::getEtags();
$genEtag = $this->repo->etag($file);
if (isset($reqEtag[0])) {
if ($reqEtag[0] === $genEtag) {
return response('', 304);
}
}
return response($image->encode('png'), 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'public, max-age=0',
'Etag' => $genEtag,
]);
}
/**
* Leave off unnecessary string from the given path.
*
* @param $path
* @return mixed
*/
protected function sanitizePath($path)
{
return starts_with($path, ['/lessons/', 'lessons/'])
? pathinfo($path, PATHINFO_BASENAME)
: $path;
}
}
================================================
FILE: app/Http/Controllers/PasswordsController.php
================================================
middleware('guest');
parent::__construct();
}
/**
* Display the form to request a password reset link.
*
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function getRemind()
{
return view('passwords.remind');
}
/**
* Send a reset link to the given user.
*
* @param \Illuminate\Http\Request $request
* @return $this|\Illuminate\Http\RedirectResponse
*/
public function postRemind(Request $request)
{
$this->validate($request, ['email' => 'required|email']);
if (User::whereEmail($request->input('email'))->noPassword()->first()) {
// Notify the user if he/she is a social login user.
$message = sprintf("%s %s", trans('auth.social_olny'), trans('auth.no_password'));
return $this->respondError($message, 400);
}
$response = Password::sendResetLink($request->only('email'), function ($m) {
$m->subject(trans('auth.email_password_reset_title'));
});
switch ($response) {
case Password::RESET_LINK_SENT:
return $this->respondSuccess(trans($response));
case Password::INVALID_USER:
return $this->respondError(trans($response), 404);
}
}
/**
* Display the password reset view for the given token.
*
* @param null $token
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function getReset($token = null)
{
if (is_null($token) or strlen($token) != 64) {
throw new \Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
}
return view('passwords.reset', compact('token'));
}
/**
* Reset the given user's password.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function postReset(Request $request)
{
$this->validate($request, [
'token' => 'required',
'email' => 'required|email',
'password' => 'required|confirmed',
]);
$credentials = $request->only(
'email',
'password',
'password_confirmation',
'token'
);
$response = Password::reset($credentials, function ($user, $password) {
$user->password = bcrypt($password);
$user->save();
\Auth::login($user);
});
switch ($response) {
case Password::PASSWORD_RESET:
flash(sprintf(
"%s %s",
trans($response),
trans('auth.welcome', ['name' => \Auth::user()->name])
));
return redirect(route('home'));
default:
flash()->error(trans($response));
return back()
->withInput($request->only('email'));
}
}
/**
* Make an error response.
*
* @param $message
* @param int $statusCode
* @return \Illuminate\Http\RedirectResponse
*/
protected function respondError($message, $statusCode = 400)
{
flash()->error($message);
return back()->withInput();
}
/**
* Make a success response.
*
* @param $message
* @return \Illuminate\Http\RedirectResponse
*/
protected function respondSuccess($message)
{
flash($message);
return back();
}
}
================================================
FILE: app/Http/Controllers/SessionsController.php
================================================
middleware('guest', ['except' => 'destroy']);
parent::__construct();
}
/**
* Show the application login form.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
return view('sessions.create');
}
/**
* Handle login request to the application.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function store(Request $request)
{
$validator = \Validator::make($request->all(), [
'email' => 'required|email',
'password' => 'required|min:6',
]);
if ($validator->fails()) {
return $this->respondValidationError($validator);
}
$token = is_api_request()
? \JWTAuth::attempt($request->only('email', 'password'))
: Auth::attempt($request->only('email', 'password'), $request->has('remember'));
if (! $token) {
return $this->respondLoginFailed();
}
event('users.login', [Auth::user()]);
return $this->respondCreated($request->input('return'), $token);
}
/**
* Log the user out of the application.
*
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function destroy()
{
Auth::logout();
flash(trans('auth.goodbye'));
return redirect(route('index'));
}
/**
* Make validation error response.
*
* @param \Illuminate\Contracts\Validation\Validator $validator
* @return \Illuminate\Http\RedirectResponse
*/
protected function respondValidationError(Validator $validator)
{
return back()->withInput()->withErrors($validator);
}
/**
* Make login failed response.
*
* @return \Illuminate\Http\RedirectResponse
*/
protected function respondLoginFailed()
{
flash()->error(trans('auth.failed'));
return back()->withInput();
}
/**
* Make a success response.
*
* @param string $return
* @param string $token
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
protected function respondCreated($return = '', $token = '')
{
flash(trans('auth.welcome', ['name' => Auth::user()->name]));
return ($return)
? redirect(urldecode($return))
: redirect()->intended();
}
}
================================================
FILE: app/Http/Controllers/SocialController.php
================================================
middleware('guest', ['only' => 'execute']);
$this->socialite = $socialite;
parent::__construct();
}
/**
* Handle social login process.
*
* @param \Illuminate\Http\Request $request
* @param string $provider
* @return \App\Http\Controllers\Response
*/
public function execute(Request $request, $provider)
{
if (! $request->has('code')) {
return $this->redirectToProvider($provider);
}
return $this->handleProviderCallback($provider);
}
/**
* Redirect the user to the Social Login Provider's authentication page.
*
* @param string $provider
* @return \Symfony\Component\HttpFoundation\RedirectResponse
*/
protected function redirectToProvider($provider)
{
return $this->socialite->driver($provider)->redirect();
}
/**
* Obtain the user information from the Social Login Provider.
*
* @param string $provider
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
protected function handleProviderCallback($provider)
{
$user = $this->socialite->driver($provider)->user();
$user = (User::whereEmail($user->getEmail())->first())
?: User::create([
'name' => $user->getName() ?: 'unknown',
'email' => $user->getEmail(),
]);
\Auth::login($user, true);
event('users.login', [$user]);
flash(trans('auth.welcome', ['name' => $user->name]));
return redirect(route('home'));
}
}
================================================
FILE: app/Http/Controllers/UsersController.php
================================================
middleware('guest');
parent::__construct();
}
/**
* Show the application registration form.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
return view('users.create');
}
/**
* Handle a registration request for the application.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
if ($user = User::whereEmail($request->input('email'))->noPassword()->first()) {
// Filter through the User model to find whether there is a social account
// that has the same email address with the current request
return $this->syncAccountInfo($request, $user);
}
return $this->createAccount($request);
}
/**
* A user logged into the application with social account first,
* and then, when s/he tries to register an application's native account,
* update his/her name and password as given
*
* @param \Illuminate\Http\Request $request
* @param \App\User $user
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
protected function syncAccountInfo(Request $request, User $user)
{
$validator = \Validator::make($request->except('_token'), [
'name' => 'required|max:255',
'email' => 'required|email|max:255',
'password' => 'required|confirmed|min:6',
]);
if ($validator->fails()) {
return $this->respondValidationError($validator);
}
$user->update([
'name' => $request->input('name'),
'password' => bcrypt($request->input('password')),
]);
$this->addMemberRole($user);
return $this->respondCreated($user);
}
/**
* A user tries to register a native account.
* S/he haven't logged in to the application with a social account before.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
protected function createAccount(Request $request)
{
$validator = \Validator::make($request->except('_token'), [
'name' => 'required|max:255',
'email' => 'required|email|max:255|unique:users',
'password' => 'required|confirmed|min:6',
]);
if ($validator->fails()) {
return $this->respondValidationError($validator);
}
$user = User::create([
'name' => $request->input('name'),
'email' => $request->input('email'),
'password' => bcrypt($request->input('password')),
]);
$this->addMemberRole($user);
return $this->respondCreated($user);
}
/**
* Attach Role to the user
*
* @param \App\User $user
* @return array
*/
protected function addMemberRole(User $user)
{
// 1 is admin, 2 is member
return $user->roles()->sync([2]);
}
/**
* Make validation error response.
*
* @param $validator
* @return \Illuminate\Http\RedirectResponse
*/
protected function respondValidationError(Validator $validator)
{
return back()->withInput()->withErrors($validator);
}
/**
* Make a success response.
*
* @param \App\User $user
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
protected function respondCreated(User $user)
{
\Auth::login($user);
flash(trans('auth.welcome', ['name' => $user->name]));
return redirect(route('home'));
}
}
================================================
FILE: app/Http/Controllers/WelcomeController.php
================================================
middleware('auth', ['only' => ['home']]);
parent::__construct();
}
/**
* Get the index page
*
* @return \Illuminate\Contracts\View\Factory
*/
public function index()
{
return view('home');
}
/**
* Get the home page
*
* @return \Illuminate\Contracts\View\Factory
*/
public function home()
{
return view('home');
}
/**
* Set locale preference of a user
*
* @return \Illuminate\Http\RedirectResponse
*/
public function locale()
{
$cookie = cookie()->forever('locale__myProject', request('locale'));
cookie()->queue($cookie);
return ($return = request('return'))
? redirect(urldecode($return))->withCookie($cookie)
: redirect(\Auth::check() ? route('home') : route('index'))->withCookie($cookie);
}
}
================================================
FILE: app/Http/Kernel.php
================================================
[
// \App\Http\Middleware\EncryptCookies::class,
// \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
// \Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\View\Middleware\ShareErrorsFromSession::class,
// \App\Http\Middleware\VerifyCsrfToken::class,
// ],
//
// 'api' => [
// 'throttle:60,1',
// ],
];
/**
* The application's route middleware.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'throttle.api'=> \App\Http\Middleware\ThrottleApiRequests::class,
'role' => \Bican\Roles\Middleware\VerifyRole::class,
'author' => \App\Http\Middleware\AuthorOnly::class,
'jwt.auth' => \App\Http\Middleware\GetUserFromToken::class,
'jwt.refresh' => \App\Http\Middleware\RefreshToken::class,
'obfuscate' => \App\Http\Middleware\ObfuscateId::class,
];
}
================================================
FILE: app/Http/Middleware/Authenticate.php
================================================
auth = $auth;
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if ($this->auth->guest()) {
if ($request->ajax()) {
return response('Unauthorized.', 401);
} else {
return redirect()->guest(
route('sessions.create', ['return' => $request->fullUrl()])
);
}
}
return $next($request);
}
}
================================================
FILE: app/Http/Middleware/AuthorOnly.php
================================================
user();
$model = '\\App\\' . ucfirst($param);
$modelId = $request->route($param ? str_plural($param) : 'id');
if (! $model::whereId($modelId)->whereAuthorId($user->id)->exists() and ! $user->isAdmin()) {
if (is_api_request()) {
return json()->forbiddenError();
}
flash()->error(trans('errors.forbidden') . ' : ' . trans('errors.forbidden_description'));
return back();
}
return $next($request);
}
}
================================================
FILE: app/Http/Middleware/EncryptCookies.php
================================================
auth->setRequest($request)->getToken()) {
throw new JWTException('token_not_provided', 400);
}
if (! $user = $this->auth->authenticate($token)) {
throw new JWTException('user_not_found', 404);
}
$this->events->fire('tymon.jwt.valid', $user);
return $next($request);
}
}
================================================
FILE: app/Http/Middleware/ObfuscateId.php
================================================
route()->getParameter($routeParamName)) {
$request->route()->setParameter($routeParamName, optimus()->decode($routeParamValue));
}
return $next($request);
}
}
================================================
FILE: app/Http/Middleware/RedirectIfAuthenticated.php
================================================
auth = $auth;
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if ($this->auth->check()) {
return redirect('/home');
}
return $next($request);
}
}
================================================
FILE: app/Http/Middleware/RefreshToken.php
================================================
auth->setRequest($request)->parseToken()->refresh();
return response()->json([
'code' => 201,
'message' => 'success',
'token' => $newToken,
], 201, [
'Authorization' => "Bearer {$newToken}",
]);
}
}
================================================
FILE: app/Http/Middleware/ThrottleApiRequests.php
================================================
resolveRequestSignature($request);
if ($this->limiter->tooManyAttempts($key, $maxAttempts, $decayMinutes)) {
return json()->setHeaders([
'Retry-After' => $this->limiter->availableIn($key),
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => 0,
])->tooManyRequestsError();
}
$this->limiter->hit($key, $decayMinutes);
$response = $next($request);
$response->headers->add([
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => $maxAttempts - $this->limiter->attempts($key) + 1,
]);
return $response;
}
}
================================================
FILE: app/Http/Middleware/VerifyCsrfToken.php
================================================
isDelete()) {
$rules = [];
} elseif ($this->isUpdate()) {
$rules = ['tags' => ['array']];
} else {
$rules = [
'title' => 'required',
'content' => 'required',
'tags' => 'required|array'
];
}
return $rules;
}
}
================================================
FILE: app/Http/Requests/FilterArticlesRequest.php
================================================
"in:{$filters}", // Query scope filter
$params['limit'] => 'size:1,10', // PerPage
$params['sort'] => 'in:created_at,view_count,created', // Sort: Age(created_at), View(view_count)
$params['order'] => 'in:asc,desc', // Direction: Ascending or Descending
$params['search'] => 'alpha_dash', // Search query
$params['page'] => '', // Page number
];
}
}
================================================
FILE: app/Http/Requests/Request.php
================================================
input('_method')), $needle)
or in_array(strtolower($this->header('x-http-method-override')), $needle)
or in_array(strtolower($this->method()), $needle);
}
/**
* Determine if the request is delete
*
* @return bool
*/
protected function isDelete()
{
$needle = ['delete'];
return in_array(strtolower($this->input('_method')), $needle)
or in_array(strtolower($this->header('x-http-method-override')), $needle)
or in_array(strtolower($this->method()), $needle);
}
/**
* {@inheritDoc}
*/
public function response(array $errors)
{
if (is_api_request()) {
return json()->unprocessableError($errors);
}
if ($this->ajax() || $this->wantsJson()) {
return new JsonResponse($errors, 422);
}
return $this->redirector->to($this->getRedirectUrl())
->withInput($this->except($this->dontFlash))
->withErrors($errors, $this->errorBag);
}
/**
* {@inheritDoc}
*/
public function forbiddenResponse()
{
if (is_api_request()) {
return json()->forbiddenError();
}
return response('Forbidden', 403);
}
}
================================================
FILE: app/Http/routes.php
================================================
config('project.api_domain'), 'as' => 'api.', 'namespace' => 'Api', 'middleware' => 'cors'], function() {
/* Landing page */
Route::get('/', [
'as' => 'index',
'uses' => 'WelcomeController@index'
]);
/* User Registration */
Route::post('auth/register', [
'as' => 'users.store',
'uses' => 'UsersController@store'
]);
/* Session.
* In API, logout path is not required. Because,
* when token is expired, any API request will not be validated.
*/
Route::post('auth/login', [
'as' => 'sessions.store',
'uses' => 'SessionsController@store'
]);
Route::post('auth/refresh', [
'as' => 'sessions.refresh',
'uses' => 'SessionsController@refresh'
]);
/* Social Login
* In API, social login is not provided.
* Each client has to integrate an Oauth library, and
* call 'POST auth/register' route in a onOauthLoginSuccessCallback.
*/
/* Password Reminder.
* Password reset is possible only through the web page.
* For api client, this endpoint will accept user's email address
* and send the user email which contains password reset token.
*/
Route::post('auth/remind', [
'as' => 'remind.store',
'uses' => 'PasswordsController@postRemind',
]);
/* User */
Route::resource('users', 'UsersController', ['only' => ['show']]);
/* api.v1 */
Route::group(['prefix' => 'v1', 'namespace' => 'V1'], function() {
/* Landing page */
Route::get('/', [
'as' => 'v1.index',
'uses' => 'WelcomeController@index'
]);
/* Forum */
Route::get('tags/{slug}/articles', [
'as' => 'v1.tags.articles.index',
'uses' => 'ArticlesController@index'
]);
Route::resource('articles', 'ArticlesController', ['except' => ['create', 'edit']]);
Route::resource('comments', 'CommentsController', ['except' => ['create', 'edit']]);
});
});
Route::group(['domain' => config('project.app_domain')], function() {
/* Landing page */
Route::get('/', [
'as' => 'index',
'uses' => 'WelcomeController@index',
]);
Route::get('home', [
'as' => 'home',
'uses' => 'WelcomeController@home',
]);
Route::get('locale', [
'as' => 'locale',
'uses' => 'WelcomeController@locale',
]);
/* Mailing list */
//Route::post('mail-list/subscribe', [
// 'as' => 'mail-list.subscribe',
// 'uses' => 'MailListController@subscribe',
//]);
//
//Route::delete('mail-list/unsubscribe', [
// 'as' => 'mail-list.unsubscribe',
// 'uses' => 'MailListController@unsubscribe',
//]);
/* Documents */
Route::get('lessons/{image}', [
'as' => 'lessons.image',
'uses' => 'LessonsController@image',
]);
Route::get('lessons/{file?}', [
'as' => 'lessons.show',
'uses' => 'LessonsController@show',
]);
/* Forum */
Route::get('tags/{slug}/articles', [
'as' => 'tags.articles.index',
'uses' => 'ArticlesController@index'
]);
Route::put('articles/{articles}/pick', [
'as' => 'articles.pick-best-comment',
'uses' => 'ArticlesController@pickBest'
]);
Route::resource('articles', 'ArticlesController');
/* Attachments */
Route::resource('files', 'AttachmentsController', ['only' => ['store', 'destroy']]);
/* Comments */
Route::post('comments/{id}/vote', 'CommentsController@vote');
Route::resource('comments', 'CommentsController', ['only' => ['store', 'update', 'destroy']]);
/* User Registration */
Route::get('auth/register', [
'as' => 'users.create',
'uses' => 'UsersController@create'
]);
Route::post('auth/register', [
'as' => 'users.store',
'uses' => 'UsersController@store'
]);
/* Social Login */
Route::get('social/{provider}', [
'as' => 'social.login',
'uses' => 'SocialController@execute',
]);
/* Session */
Route::get('auth/login', [
'as' => 'sessions.create',
'uses' => 'SessionsController@create'
]);
Route::post('auth/login', [
'as' => 'sessions.store',
'uses' => 'SessionsController@store'
]);
Route::get('auth/logout', [
'as' => 'sessions.destroy',
'uses' => 'SessionsController@destroy'
]);
/* Password Reminder */
Route::get('auth/remind', [
'as' => 'remind.create',
'uses' => 'PasswordsController@getRemind',
]);
Route::post('auth/remind', [
'as' => 'remind.store',
'uses' => 'PasswordsController@postRemind',
]);
Route::get('auth/reset/{token}', [
'as' => 'reset.create',
'uses' => 'PasswordsController@getReset',
]);
Route::post('auth/reset', [
'as' => 'reset.store',
'uses' => 'PasswordsController@postReset',
]);
});
/* From Laravel 5.2 all built-in events are fired in the form of Object */
//DB::listen(function($event){
// var_dump($event->sql/*, $event->bindings, $event->time*/);
//});
================================================
FILE: app/Jobs/Job.php
================================================
belongsTo(User::class, 'author_id');
}
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
}
================================================
FILE: app/Listeners/.gitkeep
================================================
================================================
FILE: app/Listeners/CacheHandler.php
================================================
cacheTags)->flush();
}
}
================================================
FILE: app/Listeners/CommentsHandler.php
================================================
commentable->notification) {
// get the Article author's email and append to the recipients array.
$this->to[] = $comment->commentable->author->email;
}
// get email address lists from the comments and append to the recipients array.
$this->findEmail($comment);
// Remove duplicate email address.
$to = array_unique($this->to);
$subject = 'New comment';
return \Mail::send('emails.new-comment', compact('comment'), function($m) use($to, $subject) {
return $m->to($to)->subject($subject);
});
}
/**
* Recursively find email address from the comments and push them to recipients list.
*
* @param \App\Comment $comment
*/
protected function findEmail(Comment $comment)
{
if ($comment->parent) {
$this->to[] = $comment->parent->author->email;
return $this->findEmail($comment->parent);
}
return;
}
}
================================================
FILE: app/Listeners/UserEventsHandler.php
================================================
listen(
'users.login',
__CLASS__ . '@onUserLogin'
);
}
/**
* User login event handler
*
* @param \App\User $user
*/
public function onUserLogin(User $user)
{
// Update last_login field
$user->last_login = \Carbon\Carbon::now()->toDateTimeString();
$user->save();
}
}
================================================
FILE: app/Listeners/ViewCountHandler.php
================================================
article->view_count ++;
$event->article->save();
}
}
================================================
FILE: app/Model.php
================================================
cookie('locale__myProject')) {
app()->setLocale(\Crypt::decrypt($locale));
}
\Carbon\Carbon::setLocale(app()->getLocale());
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->singleton(\Jenssegers\Optimus\Optimus::class, function () {
return new \Jenssegers\Optimus\Optimus(132961291, 1484265379, 37817169);
});
if ($this->app->environment('local')) {
$this->app->register(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class);
}
}
}
================================================
FILE: app/Providers/AuthServiceProvider.php
================================================
'App\Policies\ModelPolicy',
];
/**
* Register any application authentication / authorization services.
*
* @param \Illuminate\Contracts\Auth\Access\Gate $gate
* @return void
*/
public function boot(GateContract $gate)
{
$this->registerPolicies($gate);
//
}
}
================================================
FILE: app/Providers/EventServiceProvider.php
================================================
[
\App\Listeners\CacheHandler::class
],
\App\Events\ArticleConsumed::class => [
\App\Listeners\ViewCountHandler::class
]
];
/**
* Register any other events for your application.
*
* @param \Illuminate\Contracts\Events\Dispatcher $events
* @return void
*/
public function boot(DispatcherContract $events)
{
parent::boot($events);
$events->listen('comments.*', \App\Listeners\CommentsHandler::class);
$events->subscribe(\App\Listeners\UserEventsHandler::class);
}
}
================================================
FILE: app/Providers/RouteServiceProvider.php
================================================
pattern('id', '[0-9]+');
// Should handle 32n33-sss-img-dd.png exception
$router->pattern('image', 'images/(?P[0-9n]{2,5}-[\pL-\pN\._-]+)-(?Pimg-[0-9]{2}.png)');
parent::boot($router);
}
/**
* Define the routes for the application.
*
* @param \Illuminate\Routing\Router $router
* @return void
*/
public function map(Router $router)
{
$router->group(['namespace' => $this->namespace], function ($router) {
require app_path('Http/routes.php');
});
}
}
================================================
FILE: app/Reporters/ErrorReport.php
================================================
primitive = $e;
$webhook = $webhook ?: env('SLACK_WEBHOOK');
$this->client = $this->createClient($webhook, $settings);
}
/**
* Send slack report.
*
* @return mixed
*/
public function send()
{
return $this->client->createMessage()->attach($this->buildPayload())->send();
}
/**
* Build Slack Attachment array based on given Exception object.
*
* @see https://api.slack.com/docs/attachments
*
* @return array
*/
protected function buildPayload()
{
return new Attachment([
'fallback' => 'Error Report',
'text' => $this->primitive->getMessage() ?: "Something broken :(",
'color' => 'danger',
'fields' => [
new AttachmentField([
'title' => 'localtime',
'value' => Carbon::now('Asia/Seoul')->toDateTimeString(),
]),
new AttachmentField([
'title' => 'username',
'value' => (auth()->check() ? auth()->user()->email : 'Unknown')
. sprintf(' (%s)', Request::ip()),
]),
new AttachmentField([
'title' => 'route',
'value' => Route::currentRouteName()
. sprintf(
' (%s %s)',
Request::method(),
Request::fullUrl()
),
]),
new AttachmentField([
'title' => 'description',
'value' => sprintf(
'%s in %s line %d',
get_class($this->primitive),
pathinfo($this->primitive->getFile(), PATHINFO_BASENAME),
$this->primitive->getLine()
),
]),
new AttachmentField([
'title' => 'trace',
'value' => $this->primitive->getTraceAsString(),
]),
],
]);
}
/**
* Factory - Create HTTP API Client for Slack
*
* @param array $overrides
* @return \Maknz\Slack\Client
*/
protected function createClient($webhook, $overrides = [])
{
$settings = array_merge([
'channel' => '#l5essential',
'username' => 'aws-demo',
'link_names' => true,
'unfurl_links' => true,
'markdown_in_attachments' => ['title', 'text', 'fields'],
], $overrides);
return new Client($webhook, $settings);
}
}
================================================
FILE: app/Reporters/MonologSlackReport.php
================================================
createLogger();
}
/**
* Send slack report.
*
* @param \Exception $e
* @return mixed
*/
public function send(\Exception $e)
{
return $this->logger->error(
$e->getMessage() ?: "Something broken :(",
$this->buildPayload($e)
);
}
/**
* Build payload array based on given Exception object.
*
* @param \Exception $e
* @return array
*/
protected function buildPayload(\Exception $e)
{
return [
'username' => auth()->check() ? auth()->user()->email : 'Unknown',
'route' => \Route::currentRouteName(),
'localtime' => \Carbon\Carbon::now('Asia/Seoul')->toDateTimeString(),
'exception' => [
'class' => get_class($e),
'file' => $e->getFile(),
'line' => $e->getLine(),
'message' => $e->getMessage(),
'code' => $e->getCode(),
'trace' => $e->getTraceAsString(),
'ip' => \Request::ip(),
'method' => \Request::method(),
'url' => \Request::fullUrl(),
'content' => \Request::instance()->getContent()
?: json_encode(\Request::all()),
'headers' => \Request::header(),
],
];
}
/**
* Factory - Create Monolog instance which has a Slack handler.
*/
protected function createLogger()
{
$logger = \Log::getMonolog();
$logger->pushHandler(
(new \Monolog\Handler\SlackHandler(env('SLACK_TOKEN'), '#l5essential', 'aws-demo'))
->setLevel(\Monolog\Logger::ERROR)
);
return $this->logger = $logger;
}
}
================================================
FILE: app/Repositories/LessonRepository.php
================================================
find('INDEX.md');
}
/**
* Specify an Eloquent Model's class name.
*
* @return string
*/
public function model()
{
return \App\Lesson::class;
}
}
================================================
FILE: app/Repositories/MarkdownRepository.php
================================================
initialize();
}
/**
* Specify an Eloquent Model's class name.
*
* @return string
*/
public abstract function model();
/**
* Factory - new up the model and set the required properties.
*
* @return \Illuminate\Database\Eloquent\Model
* @throws \Exception
*/
protected function initialize()
{
$model = app()->make($this->model());
if (! $model instanceof Model) {
throw new Exception(
'model() method must return a string name of an Eloquent Model. Or the provided model cannot be instantiable.'
);
}
if (! property_exists($this->model(), 'path')) {
throw new Exception(
"{$this->model()} should have a property named 'path'"
);
}
$path = base_path($model::$path);
if (! File::isDirectory($path)) {
throw new Exception(
"Something went wrong with the path property of {$this->model()} model."
);
}
$this->model = $model;
$this->path = $path;
if (! $this->toc) {
// Todo Expensive job. Should apply cache..
$this->toc = Cache::remember('lessons.index', 120, function() use($model) {
$all = glob(base_path($model::$path . DIRECTORY_SEPARATOR . '*.md'));
$excepts = [];
if (property_exists($this->model(), 'excepts')) {
foreach ($model::$excepts as $except) {
$excepts[] = base_path($model::$path . DIRECTORY_SEPARATOR . $except);
}
}
$files = array_diff($all, $excepts);
return array_map(function($file) {
return pathinfo($file, PATHINFO_BASENAME);
}, $files);
});
}
return;
}
/**
* Get the collection of model.
*
* @param array $columns
* @return \Illuminate\Database\Eloquent\Collection
*/
public function all($columns = ['*'])
{
return $this->model->get($columns);
}
/**
* Get the table of contents.
*
* @return array
*/
public function toc()
{
return $this->toc;
}
/**
* Get the currently selected markdown's filename.
*
* @return string
*/
public function current()
{
return $this->current;
}
/**
* Calculate previous entry.
*
* @param $current
* @return bool
*/
public function prev($current) {
$prev = array_search($current, $this->toc) - 1;
return array_key_exists($prev, $this->toc)
? $this->toc[$prev]
: false;
}
/**
* Calculate next entry.
*
* @param $current
* @return mixed
*/
public function next($current) {
$next = array_search($current, $this->toc) + 1;
return array_key_exists($next, $this->toc)
? $this->toc[$next]
: false;
}
/**
* Get the model instance.
*
* @param mixed $id filename
* @param array $columns
* @return \Illuminate\Database\Eloquent\Model
*/
public function find($id, $columns = ['*'])
{
$this->current = $id;
return $this->model->whereName($id)->first()
?: $this->model->create([
'author_id' => 1, // Bad!! Avoid hard code, b.c, admin may change.
'name' => $id,
'content' => File::get($this->getPath($id)),
]);
}
/**
* Calculate and respond image path.
*
* @param string $file
* @return \Intervention\Image\Image
*/
public function image($file)
{
return Image::make($this->getPath($file));
}
/**
* Create etag value
*
* @param string $file
* @return string
*/
public function etag($file)
{
return md5($file . '/' . File::lastModified($this->getPath($file)));
}
/**
* Calculate full path to the given file.
*
* @param string $file
* @return string
*/
protected function getPath($file)
{
$path = $this->path . DIRECTORY_SEPARATOR . $file;
if (!File::exists($path)) {
abort(404, 'File not exist');
}
return $path;
}
}
================================================
FILE: app/Repositories/RepositoryInterface.php
================================================
\d+)/i';
const PATTERN_REMOVE = '/[\w\W\d\D]+/';
const PATTERN_FRONT_FORMATTER = '/---[\w\W\d\D]+extends[\w\W\d\D]+---/';
/**
* Add link to another articles
*
* @param $text
* @return mixed|string
*/
public function text($text) {
if (preg_match(self::PATTERN_ARTICLE, $text, $matches) > 0) {
$text = preg_replace_callback(self::PATTERN_ARTICLE, function ($matches) {
return sprintf(
"%s",
route('articles.show', $matches['id']),
$matches[0]
);
}, $text);
}
if (preg_match(self::PATTERN_REMOVE, $text, $matches) > 0) {
$text = preg_replace_callback(self::PATTERN_REMOVE, function ($matches) {
return '';
}, $text);
}
if (preg_match(self::PATTERN_FRONT_FORMATTER, $text, $matches) > 0) {
$text = preg_replace_callback(self::PATTERN_FRONT_FORMATTER, function ($matches) {
return '';
}, $text);
}
return parent::text($text);
}
}
================================================
FILE: app/Tag.php
================================================
belongsToMany(Article::class)->withTimestamps();
}
}
================================================
FILE: app/Transformers/ArticleTransformer.php
================================================
?include=comments:limit(5|1):order(created_at|desc)
* item case -> ?include=author
*
* @var array
*/
protected $availableIncludes = ['comments', 'author', 'tags', 'attachments'];
/**
* Transform single resource.
*
* @param \App\Article $article
* @return array
*/
public function transform(Article $article)
{
$id = optimus((int) $article->id);
$payload = [
'id' => $id,
'title' => $article->title,
'content_raw' => strip_tags($article->content),
'content_html' => markdown($article->content),
'created' => $article->created_at->toIso8601String(),
'view_count' => (int) $article->view_count,
'link' => [
'rel' => 'self',
'href' => route('api.v1.articles.show', $id),
],
'comments' => (int) $article->comments->count(),
'author' => [
'name' => $article->author->name,
'email' => $article->author->email,
'avatar' => 'http:' . gravatar_profile_url($article->author->email),
],
'tags' => $article->tags->pluck('slug'),
'attachments' => (int) $article->attachments->count(),
];
if ($fields = $this->getPartialFields()) {
$payload = array_only($payload, $fields);
}
return $payload;
}
/**
* Include comments.
*
* @param \App\Article $article
* @param \League\Fractal\ParamBag|null $params
* @return \League\Fractal\Resource\Collection
* @throws \Exception
*/
public function includeComments(Article $article, ParamBag $params = null)
{
$transformer = new \App\Transformers\CommentTransformer($params);
$parsed = $this->getParsedParams();
$comments = $article->comments()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get();
return $this->collection($comments, $transformer);
}
/**
* Include author.
*
* @param \App\Article $article
* @param \League\Fractal\ParamBag|null $params
* @return \League\Fractal\Resource\Item
*/
public function includeAuthor(Article $article, ParamBag $params = null)
{
return $this->item($article->author, new \App\Transformers\UserTransformer($params));
}
/**
* Include tags.
*
* @param \App\Article $article
* @param \League\Fractal\ParamBag|null $params
* @return \League\Fractal\Resource\Collection
* @throws \Exception
*/
public function includeTags(Article $article, ParamBag $params = null)
{
$transformer = new \App\Transformers\TagTransformer($params);
$parsed = $this->getParsedParams();
$tags = $article->tags()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get();
return $this->collection($tags, $transformer);
}
/**
* Include attachments.
*
* @param \App\Article $article
* @param \League\Fractal\ParamBag|null $params
* @return \League\Fractal\Resource\Collection
* @throws \Exception
*/
public function includeAttachments(Article $article, ParamBag $params = null)
{
$transformer = new \App\Transformers\AttachmentTransformer($params);
$parsed = $this->getParsedParams();
$attachments = $article->attachments()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get();
return $this->collection($attachments, $transformer);
}
}
================================================
FILE: app/Transformers/AttachmentTransformer.php
================================================
optimus((int) $attachment->id),
'name' => $attachment->name,
'created' => $attachment->created_at->toIso8601String(),
'link' => [
'rel' => 'self',
'href' => url(sprintf('http://%s:8000/attachments/%s', config('project.app_domain'), $attachment->name)),
],
];
if ($fields = $this->getPartialFields()) {
$payload = array_only($payload, $fields);
}
return $payload;
}
}
================================================
FILE: app/Transformers/CommentTransformer.php
================================================
?include=comments:limit(5|1):order(created_at|desc)
* item case -> ?include=author
*
* @var array
*/
protected $availableIncludes = ['author'];
/**
* Transform single resource.
*
* @param \App\Comment $comment
* @return array
*/
public function transform(Comment $comment)
{
$id = optimus((int) $comment->id);
$payload = [
'id' => $id,
'content_raw' => strip_tags($comment->content),
'content_html' => markdown($comment->content),
'created' => $comment->created_at->toIso8601String(),
'vote' => ['up' => (int) $comment->up_count, 'down' => (int) $comment->down_count],
'link' => [
'rel' => 'self',
'href' => route('api.v1.comments.show', $id),
],
'author' => [
'name' => $comment->author->name,
'email' => $comment->author->email,
'avatar' => 'http:' . gravatar_profile_url($comment->author->email),
],
];
if ($fields = $this->getPartialFields()) {
$payload = array_only($payload, $fields);
}
return $payload;
}
/**
* Include author.
*
* @param \App\Comment $comment
* @param \League\Fractal\ParamBag|null $params
* @return \League\Fractal\Resource\Item
*/
public function includeAuthor(Comment $comment, ParamBag $params = null)
{
return $this->item($comment->author, new \App\Transformers\UserTransformer($params));
}
}
================================================
FILE: app/Transformers/TagTransformer.php
================================================
optimus((int) $tag->id),
'slug' => $tag->slug,
'created' => $tag->created_at->toIso8601String(),
'link' => [
'rel' => 'self',
'href' => route('api.v1.tags.articles.index', $tag->slug),
],
'articles' => (int) $tag->articles->count(),
];
if ($fields = $this->getPartialFields()) {
$payload = array_only($payload, $fields);
}
return $payload;
}
/**
* Include articles.
*
* @param \App\Tag $tag
* @param \League\Fractal\ParamBag|null $params
* @return \League\Fractal\Resource\Collection
* @throws \Exception
*/
public function includeArticles(Tag $tag, ParamBag $params = null)
{
$transformer = new \App\Transformers\ArticleTransformer($params);
$parsed = $this->getParsedParams();
$articles = $tag->articles()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get();
return $this->collection($articles, $transformer);
}
}
================================================
FILE: app/Transformers/UserTransformer.php
================================================
?include=comments:limit(5|1):order(created_at|desc)
* item case -> ?include=author
*
* @var array
*/
protected $availableIncludes = ['articles', 'comments'];
/**
* Transform single resource.
*
* @param \App\User $user
* @return array
*/
public function transform(User $user)
{
$id = optimus((int) $user->id);
$payload = [
'id' => $id,
'name' => $user->name,
'email' => $user->email,
'avatar' => 'http:' . gravatar_profile_url($user->email),
'signup' => $user->created_at->toIso8601String(),
'link' => [
'rel' => 'self',
'href' => route('api.users.show', $id),
],
'articles' => (int) $user->articles->count(),
'comments' => (int) $user->comments->count(),
];
if ($fields = $this->getPartialFields()) {
$payload = array_only($payload, $fields);
}
return $payload;
}
/**
* Include articles.
*
* @param \App\User $user
* @param \League\Fractal\ParamBag|null $params
* @return \League\Fractal\Resource\Collection
* @throws \Exception
*/
public function includeArticles(User $user, ParamBag $params = null)
{
$transformer = new \App\Transformers\ArticleTransformer($params);
$parsed = $this->getParsedParams();
$articles = $user->articles()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get();
return $this->collection($articles, $transformer);
}
/**
* Include comments.
*
* @param \App\User $user
* @param \League\Fractal\ParamBag|null $params
* @return \League\Fractal\Resource\Collection
* @throws \Exception
*/
public function includeComments(User $user, ParamBag $params = null)
{
$transformer = new \App\Transformers\CommentTransformer($params);
$parsed = $this->getParsedParams();
$comments = $user->comments()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get();
return $this->collection($comments, $transformer);
}
}
================================================
FILE: app/User.php
================================================
hasMany(Article::class, 'author_id');
}
public function comments()
{
return $this->hasMany(Comment::class, 'author_id');
}
public function votes()
{
return $this->hasMany(Vote::class);
}
/* Query Scopes */
public function scopeNoPassword($query)
{
return $query->whereNull('password');
}
/* Helpers */
public function isAdmin()
{
return $this->roles()->whereSlug('admin')->exists();
}
}
================================================
FILE: app/Vote.php
================================================
belongsTo(Comment::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
================================================
FILE: app/helpers.php
================================================
text($text);
}
}
if (! function_exists('icon')) {
/**
* Generate FontAwesome icon tag
*
* @param string $class font-awesome class name
* @param string $addition additional class
* @param string $inline inline style
* @return string
*/
function icon($class, $addition = 'icon', $inline = null)
{
$icon = config('icons.' . $class);
$inline = $inline ? 'style="' . $inline . '"' : null;
return sprintf('', $icon, $addition, $inline);
}
}
if (! function_exists('attachment_path')) {
/**
* @param string $path
*
* @return string
*/
function attachment_path($path = '')
{
return public_path($path ? 'attachments' . DIRECTORY_SEPARATOR . $path : 'attachments');
}
}
if (! function_exists('gravatar_profile_url')) {
/**
* Get gravatar profile page url
*
* @param string $email
* @return string
*/
function gravatar_profile_url($email)
{
return sprintf("//www.gravatar.com/%s", md5($email));
}
}
if (! function_exists('gravatar_url')) {
/**
* Get gravatar image url
*
* @param string $email
* @param integer $size
* @return string
*/
function gravatar_url($email, $size = 48)
{
return sprintf("//www.gravatar.com/avatar/%s?s=%s", md5($email), $size);
}
}
if (! function_exists('taggable')) {
/**
* Determine if the current cache driver has cacheTags() method
*
* @return bool
*/
function taggable()
{
return ! in_array(config('cache.default'), ['file', 'database']);
}
}
if (! function_exists('link_for_sort')) {
/**
* Build HTML anchor tag for sorting
*
* @param string $column
* @param string $text
* @param array $params
* @return string
*/
function link_for_sort($column, $text, $params = [])
{
$config = config('project.params');
$direction = request()->input($config['order']);
$reverse = ($direction == 'asc') ? 'desc' : 'asc';
if (request()->input($config['sort']) == $column) {
// Update passed $text var, only if it is active sort
$text = sprintf(
"%s %s",
$direction == 'asc' ? icon('asc') : icon('desc'),
$text
);
}
$queryString = http_build_query(array_merge(
request()->except([$config['page'], $config['sort'], $config['order']]),
[$config['sort'] => $column, $config['order'] => $reverse],
$params
));
return sprintf(
'%s',
urldecode(request()->url()),
$queryString,
$text
);
}
}
if (! function_exists('current_url')) {
/**
* Build current url string, without return param.
*
* @return string
*/
function current_url()
{
if (! request()->has('return')) {
return request()->fullUrl();
}
return sprintf(
'%s?%s',
request()->url(),
http_build_query(request()->except('return'))
);
}
}
if (! function_exists('is_api_request')) {
/**
* Determine if the current request is for HTTP api.
*
* @return bool
*/
function is_api_request()
{
return starts_with(request()->getHttpHost(), config('project.api_domain'));
}
}
if (! function_exists('optimus')) {
/**
* Create Optimus instance.
*
* @param int|null $id
* @return int|\Jenssegers\Optimus\Optimus
*/
function optimus($id = null)
{
$factory = app(\Jenssegers\Optimus\Optimus::class);
if (func_num_args() === 0) {
return $factory;
}
return $factory->encode($id);
}
}
if (! function_exists('cache_key')) {
/**
* Generate key for caching.
*
* Note. Even though the request endpoints are the same,
* the response body should be different because of the query string.
*
* @param $base
* @return string
*/
function cache_key($base)
{
$key = ($uri = request()->fullUrl())
? $base . '.' . urlencode($uri)
: $base;
return md5($key);
}
}
================================================
FILE: artisan
================================================
#!/usr/bin/env php
make(Illuminate\Contracts\Console\Kernel::class);
$status = $kernel->handle(
$input = new Symfony\Component\Console\Input\ArgvInput,
new Symfony\Component\Console\Output\ConsoleOutput
);
/*
|--------------------------------------------------------------------------
| Shutdown The Application
|--------------------------------------------------------------------------
|
| Once Artisan has finished running. We will fire off the shutdown events
| so that any final work may be done by the application before we shut
| down the process. This is the last thing to happen to the request.
|
*/
$kernel->terminate($input, $status);
exit($status);
================================================
FILE: bootstrap/app.php
================================================
singleton(
Illuminate\Contracts\Http\Kernel::class,
App\Http\Kernel::class
);
$app->singleton(
Illuminate\Contracts\Console\Kernel::class,
App\Console\Kernel::class
);
$app->singleton(
Illuminate\Contracts\Debug\ExceptionHandler::class,
App\Exceptions\Handler::class
);
/*
|--------------------------------------------------------------------------
| Return The Application
|--------------------------------------------------------------------------
|
| This script returns the application instance. The instance is given to
| the calling script so we can separate the building of the instances
| from the actual running of the application and sending responses.
|
*/
return $app;
================================================
FILE: bootstrap/autoload.php
================================================
=5.5.9",
"laravel/framework": "5.2.*",
"erusev/parsedown-extra": " 0.7.*",
"intervention/image": "2.3.*",
"laracasts/flash": "1.3.*",
"laravel/socialite": "2.0.*",
"bican/roles": " 2.1.*",
"maknz/slack": "1.*",
"tymon/jwt-auth": "0.5.*",
"appkr/api": "0.1.*",
"jenssegers/optimus": "^0.2.0",
"asm89/stack-cors": "dev-master as 0.2.2",
"barryvdh/laravel-cors": "^0.7.3"
},
"require-dev": {
"symfony/dom-crawler": "~3.0",
"symfony/css-selector": "~3.0",
"fzaninotto/faker": "~1.4",
"mockery/mockery": "0.9.*",
"phpunit/phpunit": "~4.0",
"phpspec/phpspec": "~2.1",
"barryvdh/laravel-ide-helper": "^2.1"
},
"autoload": {
"classmap": [
"database"
],
"files": [
"app/helpers.php"
],
"psr-4": {
"App\\": "app/"
}
},
"autoload-dev": {
"classmap": [
"tests/TestCase.php"
],
"psr-4": {
"Test\\": "tests/integration/"
}
},
"scripts": {
"post-install-cmd": [
"php artisan clear-compiled",
"php artisan optimize",
"php artisan cache:clear"
],
"pre-update-cmd": [
"php artisan clear-compiled"
],
"post-update-cmd": [
"php artisan optimize",
"php artisan cache:clear"
],
"post-root-package-install": [
"php -r \"copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"php artisan key:generate"
]
},
"config": {
"preferred-install": "dist"
}
}
================================================
FILE: config/api.php
================================================
env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| API Endpoint pattern
|--------------------------------------------------------------------------
|
| Path 'pattern' used for is_api_request() Helper.
| Provide 'domain', if the api routes are distinguished by domain name.
|
*/
'endpoint' => [
'pattern' => 'v1/*',
'domain' => env('API_DOMAIN', 'api.myproject.dev'),
],
/*
|--------------------------------------------------------------------------
| Include by query string.
|--------------------------------------------------------------------------
|
| If you defined 'availableInclude' property and includeXxx methods
| in a transformer, you can include sub resources using query string.
| e.g. /authors?include=books:limit(3|0):order(id|desc) means
| including 3 records of 'authors', which is reverse ordered by 'id' field,
| without any skipping(0).
|
| An API client can pass list of includes using array or csv string format.
| e.g. /authors?include[]=books:limit(2|0)&include[]=comments:order(id|asc)
| /authors?include=books:limit(2|0),comments:order(id|asc)
|
| For sub-resource inclusion, client can use dot(.) notation.
| e.g. /books?include=author,publisher.somethingelse
|
*/
'include' => [
'key' => 'include',
'params' => [ // available modifier params and their default value
'limit' => [3, 0], // [limit, offset]
'sort' => ['created_at', 'desc'], // [sortKey, sortDirection]
],
],
/*
|--------------------------------------------------------------------------
| Partial response
|--------------------------------------------------------------------------
|
| API clients are allowed to select the response format using query string.
| This will help saving network bandwidth..
| e.g. /author?fields=id,title,link&include=books:fields(id|title|published_at)
|
*/
'partial' => [
'key' => 'fields',
],
/*
|--------------------------------------------------------------------------
| Transformer directory and namespace.
|--------------------------------------------------------------------------
|
| Below config will be applied when we run 'make:transformer' artisan cmd.
| The generated class will be saved at 'dir', and namespaced as you set.
| Note that the 'dir' should be relative to the project root.
|
*/
'transformer' => [
'dir' => 'app/Transformers',
'namespace' => 'App\\Transformers',
],
/*
|--------------------------------------------------------------------------
| Fractal Serializer
|--------------------------------------------------------------------------
|
| Refer to
| http://fractal.thephpleague.com/serializers/
|
*/
'serializer' => \League\Fractal\Serializer\ArraySerializer::class,
/*
|--------------------------------------------------------------------------
| Default Response Headers
|--------------------------------------------------------------------------
|
| Default response headers that every resource/simple response should includes
|
*/
'defaultHeaders' => ['X-Powered-By' => 'appkr/api'],
/*
|--------------------------------------------------------------------------
| Suppress HTTP status code
|--------------------------------------------------------------------------
|
| If set to true, the status code will be fixed to 200.
|
*/
'suppress_response_code' => false,
/*
|--------------------------------------------------------------------------
| Success Response Format
|--------------------------------------------------------------------------
|
| The format will be used at the ApiResponse to respond with success message.
| respondNoContent(), respondSuccess(), respondCreated() consumes this format
|
*/
'successFormat' => [
'success' => [
'code' => ':code',
'message' => ':message',
]
],
/*
|--------------------------------------------------------------------------
| Error Response Format
|--------------------------------------------------------------------------
|
| The format will be used at the ApiResponse to respond with error message.
| respondWithError(), respondForbidden()... consumes this format
|
*/
'errorFormat' => [
'error' => [
'code' => ':code',
'message' => ':message',
]
]
];
================================================
FILE: config/app.php
================================================
env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| your application so that it is used when running Artisan tasks.
|
*/
'url' => env('APP_URL', 'http://localhost:8000'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. We have gone
| ahead and set this to a sensible default for you out of the box.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by the translation service provider. You are free to set this value
| to any of the locales which will be supported by the application.
|
*/
'locale' => 'ko',
/*
|--------------------------------------------------------------------------
| Application Fallback Locale
|--------------------------------------------------------------------------
|
| The fallback locale determines the locale to use when the current one
| is not available. You may change the value to correspond to any of
| the language folders that are provided through your application.
|
*/
'fallback_locale' => 'en',
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is used by the Illuminate encrypter service and should be set
| to a random, 32 character string, otherwise these encrypted strings
| will not be safe. Please do this before deploying an application!
|
*/
'key' => env('APP_KEY', 'SomeRandomString'),
'cipher' => 'AES-256-CBC',
/*
|--------------------------------------------------------------------------
| Logging Configuration
|--------------------------------------------------------------------------
|
| Here you may configure the log settings for your application. Out of
| the box, Laravel uses the Monolog PHP logging library. This gives
| you a variety of powerful log handlers / formatters to utilize.
|
| Available Settings: "single", "daily", "syslog", "errorlog"
|
*/
'log' => env('APP_LOG', 'single'),
/*
|--------------------------------------------------------------------------
| Autoloaded Service Providers
|--------------------------------------------------------------------------
|
| The service providers listed here will be automatically loaded on the
| request to your application. Feel free to add your own services to
| this array to grant expanded functionality to your applications.
|
*/
'providers' => [
/*
* Laravel Framework Service Providers...
*/
// Illuminate\Foundation\Providers\ArtisanServiceProvider::class, // removed while migrating to 5.2
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
// Illuminate\Routing\ControllerServiceProvider::class, // removed while migrating to 5.2
Illuminate\Cookie\CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Translation\TranslationServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
/*
* 3rd Party Service Providers
*/
Intervention\Image\ImageServiceProvider::class,
Laracasts\Flash\FlashServiceProvider::class,
Laravel\Socialite\SocialiteServiceProvider::class,
Bican\Roles\RolesServiceProvider::class,
Maknz\Slack\SlackServiceProvider::class,
Tymon\JWTAuth\Providers\JWTAuthServiceProvider::class,
Appkr\Api\ApiServiceProvider::class,
Barryvdh\Cors\ServiceProvider::class,
],
/*
|--------------------------------------------------------------------------
| Class Aliases
|--------------------------------------------------------------------------
|
| This array of class aliases will be registered when this application
| is started. However, feel free to register as many as you wish as
| the aliases are "lazy" loaded so they don't hinder performance.
|
*/
'aliases' => [
'App' => Illuminate\Support\Facades\App::class,
'Artisan' => Illuminate\Support\Facades\Artisan::class,
'Auth' => Illuminate\Support\Facades\Auth::class,
'Blade' => Illuminate\Support\Facades\Blade::class,
'Bus' => Illuminate\Support\Facades\Bus::class,
'Cache' => Illuminate\Support\Facades\Cache::class,
'Config' => Illuminate\Support\Facades\Config::class,
'Cookie' => Illuminate\Support\Facades\Cookie::class,
'Crypt' => Illuminate\Support\Facades\Crypt::class,
'DB' => Illuminate\Support\Facades\DB::class,
'Eloquent' => Illuminate\Database\Eloquent\Model::class,
'Event' => Illuminate\Support\Facades\Event::class,
'File' => Illuminate\Support\Facades\File::class,
'Gate' => Illuminate\Support\Facades\Gate::class,
'Hash' => Illuminate\Support\Facades\Hash::class,
'Input' => Illuminate\Support\Facades\Input::class,
'Inspiring' => Illuminate\Foundation\Inspiring::class,
'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class,
'Password' => Illuminate\Support\Facades\Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class,
'Redis' => Illuminate\Support\Facades\Redis::class,
'Request' => Illuminate\Support\Facades\Request::class,
'Response' => Illuminate\Support\Facades\Response::class,
'Route' => Illuminate\Support\Facades\Route::class,
'Schema' => Illuminate\Support\Facades\Schema::class,
'Session' => Illuminate\Support\Facades\Session::class,
'Storage' => Illuminate\Support\Facades\Storage::class,
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
/*
* 3rd Party Facade
*/
'Image' => Intervention\Image\Facades\Image::class,
'Flash' => Laracasts\Flash\Flash::class,
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
'Slack' => Maknz\Slack\Facades\Slack::class,
'JWTAuth' => Tymon\JWTAuth\Facades\JWTAuth::class,
'JWTFactory' => Tymon\JWTAuth\Facades\JWTFactory::class,
],
];
================================================
FILE: config/auth.php
================================================
[
'guard' => 'web',
'passwords' => 'users',
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| here which uses session storage and the Eloquent user provider.
|
| All authentication drivers have a user provider. This defines how the
| users are actually retrieved out of your database or other storage
| mechanisms used by this application to persist your user's data.
|
| Supported: "session", "token"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'token',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication drivers have a user provider. This defines how the
| users are actually retrieved out of your database or other storage
| mechanisms used by this application to persist your user's data.
|
| If you have multiple user tables or models you may configure multiple
| sources which represent each model / table. These sources may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\User::class,
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| Here you may set the options for resetting passwords including the view
| that is your password reset e-mail. You may also set the name of the
| table that maintains all of the reset tokens for your application.
|
| You may specify multiple password reset configurations if you have more
| than one user table or model in the application and you want to have
| separate password reset settings based on the specific user types.
|
| The expire time is the number of minutes that the reset token should be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'email' => 'emails.password',
'table' => 'password_resets',
'expire' => 60,
],
],
];
================================================
FILE: config/broadcasting.php
================================================
env('BROADCAST_DRIVER', 'pusher'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over websockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_KEY'),
'secret' => env('PUSHER_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
//
],
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
'log' => [
'driver' => 'log',
],
],
];
================================================
FILE: config/cache.php
================================================
env('CACHE_DRIVER', 'file'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
*/
'stores' => [
'apc' => [
'driver' => 'apc',
],
'array' => [
'driver' => 'array',
],
'database' => [
'driver' => 'database',
'table' => 'cache',
'connection' => null,
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache'),
],
'memcached' => [
'driver' => 'memcached',
'servers' => [
[
'host' => '127.0.0.1', 'port' => 11211, 'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing a RAM based store such as APC or Memcached, there might
| be other applications utilizing the same cache. So, we'll specify a
| value to get prefixed to all our keys so we can avoid collisions.
|
*/
'prefix' => 'laravel',
];
================================================
FILE: config/compile.php
================================================
[
//
],
/*
|--------------------------------------------------------------------------
| Compiled File Providers
|--------------------------------------------------------------------------
|
| Here you may list service providers which define a "compiles" function
| that returns additional files that should be compiled, providing an
| easy way to get common files from any packages you are utilizing.
|
*/
'providers' => [
//
],
];
================================================
FILE: config/cors.php
================================================
false,
'allowedOrigins' => ['*'],
'allowedHeaders' => ['*'],
'allowedMethods' => ['GET', 'POST', 'PUT', 'DELETE'],
'exposedHeaders' => [],
'maxAge' => 0,
'hosts' => [],
];
================================================
FILE: config/database.php
================================================
PDO::FETCH_CLASS,
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for all database work. Of course
| you may use many connections at once using the Database library.
|
*/
'default' => env('DB_CONNECTION', 'mysql'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Here are each of the database connections setup for your application.
| Of course, examples of configuring each database platform that is
| supported by Laravel is shown below to make development simple.
|
|
| All database work in Laravel is done through the PHP PDO facilities
| so make sure you have the driver for your particular database of
| choice installed on your machine before you begin development.
|
*/
'connections' => [
'testing' => [
'driver' => 'sqlite',
'database' => ':memory:',
],
'sqlite' => [
'driver' => 'sqlite',
'database' => base_path('tests/database.sqlite'),
'prefix' => '',
],
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST', 'localhost'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
'strict' => false,
],
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', 'localhost'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'schema' => 'public',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'host' => env('DB_HOST', 'localhost'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run in the database.
|
*/
'migrations' => 'migrations',
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer set of commands than a typical key-value systems
| such as APC or Memcached. Laravel makes it easy to dig right in.
|
*/
'redis' => [
'cluster' => false,
'default' => [
'host' => '127.0.0.1',
'port' => 6379,
'database' => 0,
],
],
];
================================================
FILE: config/filesystems.php
================================================
'local',
/*
|--------------------------------------------------------------------------
| Default Cloud Filesystem Disk
|--------------------------------------------------------------------------
|
| Many applications store files both locally and in the cloud. For this
| reason, you may specify a default "cloud" driver here. This driver
| will be bound as the Cloud disk implementation in the container.
|
*/
'cloud' => 's3',
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Here you may configure as many filesystem "disks" as you wish, and you
| may even configure multiple disks of the same driver. Defaults have
| been setup for each driver as an example of the required options.
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
],
'ftp' => [
'driver' => 'ftp',
'host' => 'ftp.example.com',
'username' => 'your-username',
'password' => 'your-password',
// Optional FTP Settings...
// 'port' => 21,
// 'root' => '',
// 'passive' => true,
// 'ssl' => true,
// 'timeout' => 30,
],
's3' => [
'driver' => 's3',
'key' => 'your-key',
'secret' => 'your-secret',
'region' => 'your-region',
'bucket' => 'your-bucket',
],
'rackspace' => [
'driver' => 'rackspace',
'username' => 'your-username',
'key' => 'your-key',
'container' => 'your-container',
'endpoint' => 'https://identity.api.rackspacecloud.com/v2.0/',
'region' => 'IAD',
'url_type' => 'publicURL',
],
],
];
================================================
FILE: config/icons.php
================================================
'fa fa-sign-in',
'github' => 'fa fa-github',
'book' => 'fa fa-book',
'locale' => 'fa fa-language',
'certificate' => 'fa fa-certificate',
'forum' => 'fa fa-weixin',
'logout' => 'fa fa-sign-out',
'user' => 'fa fa-user',
'plane' => 'fa fa-paper-plane',
'check' => 'fa fa-check',
'clock' => 'fa fa-clock-o',
'tags' => 'fa fa-tags',
'comments' => 'fa fa-comments',
'reset' => 'fa fa-refresh',
'pencil' => 'fa fa-pencil',
'delete' => 'fa fa-trash-o',
'clip' => 'fa fa-paperclip',
'download' => 'fa fa-cloud-download',
'dropdown' => 'fa fa-chevron-down',
'update' => 'fa fa-paragraph',
'reply' => 'fa fa-reply',
'preview' => 'fa fa-code',
'up' => 'fa fa-chevron-up',
'down' => 'fa fa-chevron-down',
'filter' => 'fa fa-filter',
'view_count' => 'fa fa-eye',
'sort' => 'fa fa-sort',
'desc' => 'fa fa-sort-alpha-desc',
'asc' => 'fa fa-sort-alpha-asc',
'new' => 'fa fa-plus-circle',
'notice' => 'fa fa-bullhorn',
'pin' => 'fa fa-thumb-tack',
];
================================================
FILE: config/jwt.php
================================================
env('JWT_SECRET', 'SomeRandomString'),
/*
|--------------------------------------------------------------------------
| JWT time to live
|--------------------------------------------------------------------------
|
| Specify the length of time (in minutes) that the token will be valid for.
| Defaults to 1 hour
|
*/
'ttl' => 120,
/*
|--------------------------------------------------------------------------
| Refresh time to live
|--------------------------------------------------------------------------
|
| Specify the length of time (in minutes) that the token can be refreshed
| within. I.E. The user can refresh their token within a 2 week window of
| the original token being created until they must re-authenticate.
| Defaults to 2 weeks
|
*/
'refresh_ttl' => 20160,
/*
|--------------------------------------------------------------------------
| JWT hashing algorithm
|--------------------------------------------------------------------------
|
| Specify the hashing algorithm that will be used to sign the token.
|
| See here: https://github.com/namshi/jose/tree/2.2.0/src/Namshi/JOSE/Signer
| for possible values
|
*/
'algo' => 'HS256',
/*
|--------------------------------------------------------------------------
| User Model namespace
|--------------------------------------------------------------------------
|
| Specify the full namespace to your User model.
| e.g. 'Acme\Entities\User'
|
*/
'user' => App\User::class,
/*
|--------------------------------------------------------------------------
| User identifier
|--------------------------------------------------------------------------
|
| Specify a unique property of the user that will be added as the 'sub'
| claim of the token payload.
|
*/
'identifier' => 'id',
/*
|--------------------------------------------------------------------------
| Required Claims
|--------------------------------------------------------------------------
|
| Specify the required claims that must exist in any token.
| A TokenInvalidException will be thrown if any of these claims are not
| present in the payload.
|
*/
'required_claims' => ['iss', 'iat', 'exp', 'nbf', 'sub', 'jti'],
/*
|--------------------------------------------------------------------------
| Blacklist Enabled
|--------------------------------------------------------------------------
|
| In order to invalidate tokens, you must have the the blacklist enabled.
| If you do not want or need this functionality, then set this to false.
|
*/
'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Providers
|--------------------------------------------------------------------------
|
| Specify the various providers used throughout the package.
|
*/
'providers' => [
/*
|--------------------------------------------------------------------------
| User Provider
|--------------------------------------------------------------------------
|
| Specify the provider that is used to find the user based
| on the subject claim
|
*/
'user' => Tymon\JWTAuth\Providers\User\EloquentUserAdapter::class,
/*
|--------------------------------------------------------------------------
| JWT Provider
|--------------------------------------------------------------------------
|
| Specify the provider that is used to create and decode the tokens.
|
*/
'jwt' => Tymon\JWTAuth\Providers\JWT\NamshiAdapter::class,
/*
|--------------------------------------------------------------------------
| Authentication Provider
|--------------------------------------------------------------------------
|
| Specify the provider that is used to authenticate users.
|
*/
'auth' => function ($app) {
return new Tymon\JWTAuth\Providers\Auth\IlluminateAuthAdapter($app['auth']);
},
/*
|--------------------------------------------------------------------------
| Storage Provider
|--------------------------------------------------------------------------
|
| Specify the provider that is used to store tokens in the blacklist
|
*/
'storage' => function ($app) {
return new Tymon\JWTAuth\Providers\Storage\IlluminateCacheAdapter($app['cache']);
}
]
];
================================================
FILE: config/mail.php
================================================
env('MAIL_DRIVER', 'smtp'),
/*
|--------------------------------------------------------------------------
| SMTP Host Address
|--------------------------------------------------------------------------
|
| Here you may provide the host address of the SMTP server used by your
| applications. A default option is provided that is compatible with
| the Mailgun mail service which will provide reliable deliveries.
|
*/
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
/*
|--------------------------------------------------------------------------
| SMTP Host Port
|--------------------------------------------------------------------------
|
| This is the SMTP port used by your application to deliver e-mails to
| users of the application. Like the host we have set this value to
| stay compatible with the Mailgun e-mail application by default.
|
*/
'port' => env('MAIL_PORT', 587),
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all e-mails sent by your application to be sent from
| the same address. Here, you may specify a name and address that is
| used globally for all e-mails that are sent by your application.
|
*/
'from' => ['address' => 'john@example.com', 'name' => 'John Doe'],
/*
|--------------------------------------------------------------------------
| E-Mail Encryption Protocol
|--------------------------------------------------------------------------
|
| Here you may specify the encryption protocol that should be used when
| the application send e-mail messages. A sensible default using the
| transport layer security protocol should provide great security.
|
*/
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
/*
|--------------------------------------------------------------------------
| SMTP Server Username
|--------------------------------------------------------------------------
|
| If your SMTP server requires a username for authentication, you should
| set it here. This will get used to authenticate with your server on
| connection. You may also set the "password" value below this one.
|
*/
'username' => env('MAIL_USERNAME'),
/*
|--------------------------------------------------------------------------
| SMTP Server Password
|--------------------------------------------------------------------------
|
| Here you may set the password required by your SMTP server to send out
| messages from your application. This will be given to the server on
| connection so that the application will be able to send messages.
|
*/
'password' => env('MAIL_PASSWORD'),
/*
|--------------------------------------------------------------------------
| Sendmail System Path
|--------------------------------------------------------------------------
|
| When using the "sendmail" driver to send e-mails, we will need to know
| the path to where Sendmail lives on this server. A default path has
| been provided here, which will work well on most of your systems.
|
*/
'sendmail' => '/usr/sbin/sendmail -bs',
/*
|--------------------------------------------------------------------------
| Mail "Pretend"
|--------------------------------------------------------------------------
|
| When this option is enabled, e-mail will not actually be sent over the
| web and will instead be written to your application's logs files so
| you may inspect the message. This is great for local development.
|
*/
'pretend' => env('MAIL_PRETEND', false),
];
================================================
FILE: config/project.php
================================================
! env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Project identification
|--------------------------------------------------------------------------
|
*/
'name' => 'l5essential',
'description' => 'Laravel 5 입문 및 실전 강좌',
'app_domain' => env('APP_DOMAIN', 'myproject.dev'),
'api_domain' => env('API_DOMAIN', 'api.myproject.dev'),
/*
|--------------------------------------------------------------------------
| People & contacts
|--------------------------------------------------------------------------
|
*/
'contacts' => [
'author' => [
'name' => 'appkr',
'email' => 'juwonkim@me.com',
'url' => '',
'organization' => 'Appkr Studio',
],
'admin' => [
// ...
],
],
/*
|--------------------------------------------------------------------------
| SEO keys
|--------------------------------------------------------------------------
|
| @see https://www.google.com/webmasters/tools/home
| Note. Is not required when Google Analytics is activated.
| @see http://webmastertool.naver.com/board/main.naver
|
*/
'seo' => [
'google_site_key' => 'ToXKBimREnz49pDNot4b-N9ZJgYcKXPPsHsjhg4Zzuc',
'naver_site_key' => '7cebcc8e5493169f5401870d9ce57f48d18491cd',
],
/*
|--------------------------------------------------------------------------
| Available/allowed fields for query string
|--------------------------------------------------------------------------
|
*/
'params' => [
'page' => 'page',
'filter' => 'filter',
'limit' => 'limit',
'sort' => 'sort',
'order' => 'order',
'search' => 'q',
'select' => 'fields',
],
/*
|--------------------------------------------------------------------------
| Available/allowed value for 'filter' query string
|--------------------------------------------------------------------------
|
| 'model_in_lower_case' => ['filter_key' => 'description'],
| filter_key will be transformed to camelCase when calling the scope query.
| e.g. no_comment -> noComment
|
*/
'filters' => [
'article' => [
'no_comment' => 'No Comment',
'not_solved' => 'Not Solved'
]
],
/*
|--------------------------------------------------------------------------
| Available/allowed tags for/associated with Article
|--------------------------------------------------------------------------
|
| Used in database/seeds/DatabaseSeeder.php
|
*/
'tags' => [
'General',
'Laravel',
'Lumen',
'Eloquent',
'Servers',
'Tips',
'Lesson Feedback'
],
];
================================================
FILE: config/queue.php
================================================
env('QUEUE_DRIVER', 'sync'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection information for each server that
| is used by your application. A default configuration has been added
| for each back-end shipped with Laravel. You are free to add more.
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'expire' => 60,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => 'localhost',
'queue' => 'default',
'ttr' => 60,
],
'sqs' => [
'driver' => 'sqs',
'key' => 'your-public-key',
'secret' => 'your-secret-key',
'queue' => 'your-queue-url',
'region' => 'us-east-1',
],
'iron' => [
'driver' => 'iron',
'host' => 'mq-aws-us-east-1.iron.io',
'token' => 'your-token',
'project' => 'your-project-id',
'queue' => 'your-queue-name',
'encrypt' => true,
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'default',
'expire' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control which database and table are used to store the jobs that
| have failed. You may change them to any database / table you wish.
|
*/
'failed' => [
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'failed_jobs',
],
];
================================================
FILE: config/roles.php
================================================
null,
/*
|--------------------------------------------------------------------------
| Slug Separator
|--------------------------------------------------------------------------
|
| Here you can change the slug separator. This is very important in matter
| of magic method __call() and also a `Slugable` trait. The default value
| is a dot.
|
*/
'separator' => '.',
/*
|--------------------------------------------------------------------------
| Models
|--------------------------------------------------------------------------
|
| If you want, you can replace default models from this package by models
| you created. Have a look at `Bican\Roles\Models\Role` model and
| `Bican\Roles\Models\Permission` model.
|
*/
'models' => [
'role' => Bican\Roles\Models\Role::class,
'permission' => Bican\Roles\Models\Permission::class,
],
/*
|--------------------------------------------------------------------------
| Roles, Permissions and Allowed "Pretend"
|--------------------------------------------------------------------------
|
| You can pretend or simulate package behavior no matter what is in your
| database. It is really useful when you are testing you application.
| Set up what will methods is(), can() and allowed() return.
|
*/
'pretend' => [
'enabled' => false,
'options' => [
'is' => true,
'can' => true,
'allowed' => true,
],
],
];
================================================
FILE: config/services.php
================================================
[
'domain' => env('MAILGUN_DOMAIN'),
'secret' => env('MAILGUN_SECRET'),
],
'mandrill' => [
'secret' => env('MANDRILL_SECRET'),
],
'ses' => [
'key' => env('SES_KEY'),
'secret' => env('SES_SECRET'),
'region' => 'us-east-1',
],
'stripe' => [
'model' => App\User::class,
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
],
'github' => [
'client_id' => env('GITHUB_ID'),
'client_secret' => env('GITHUB_SECRET'),
'redirect' => env('GITHUB_CALLBACK'),
],
];
================================================
FILE: config/session.php
================================================
env('SESSION_DRIVER', 'file'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to immediately expire on the browser closing, set that option.
|
*/
'lifetime' => 120,
'expire_on_close' => false,
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it is stored. All encryption will be run
| automatically by Laravel and you can use the Session like normal.
|
*/
'encrypt' => false,
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When using the native session driver, we need a location where session
| files may be stored. A default has been set for you but a different
| location may be specified. This is only needed for file sessions.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => null,
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table we
| should use to manage the sessions. Of course, a sensible default is
| provided for you; however, you are free to change this as needed.
|
*/
'table' => 'sessions',
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the cookie used to identify a session
| instance by ID. The name specified here will get used every time a
| new session cookie is created by the framework for every driver.
|
*/
'cookie' => 'l5essential',
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application but you are free to change this when necessary.
|
*/
'path' => '/',
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| Here you may change the domain of the cookie used to identify a session
| in your application. This will determine which domains the cookie is
| available to in your application. A sensible default has been set.
|
*/
'domain' => null,
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you if it can not be done securely.
|
*/
'secure' => false,
];
================================================
FILE: config/slack.php
================================================
'',
/*
|-------------------------------------------------------------
| Default channel
|-------------------------------------------------------------
|
| The default channel we should post to. The channel can either be a
| channel like #general, a private #group, or a @username. Set to
| null to use the default set on the Slack webhook
|
*/
'channel' => '#general',
/*
|-------------------------------------------------------------
| Default username
|-------------------------------------------------------------
|
| The default username we should post as. Set to null to use
| the default set on the Slack webhook
|
*/
'username' => 'Robot',
/*
|-------------------------------------------------------------
| Default icon
|-------------------------------------------------------------
|
| The default icon to use. This can either be a URL to an image or Slack
| emoji like :ghost: or :heart_eyes:. Set to null to use the default
| set on the Slack webhook
|
*/
'icon' => null,
/*
|-------------------------------------------------------------
| Link names
|-------------------------------------------------------------
|
| Whether names like @regan should be converted into links
| by Slack
|
*/
'link_names' => false,
/*
|-------------------------------------------------------------
| Unfurl links
|-------------------------------------------------------------
|
| Whether Slack should unfurl links to text-based content
|
*/
'unfurl_links' => false,
/*
|-------------------------------------------------------------
| Unfurl media
|-------------------------------------------------------------
|
| Whether Slack should unfurl links to media content such
| as images and YouTube videos
|
*/
'unfurl_media' => true,
/*
|-------------------------------------------------------------
| Markdown in message text
|-------------------------------------------------------------
|
| Whether message text should be interpreted in Slack's Markdown-like
| language. For formatting options, see Slack's help article: http://goo.gl/r4fsdO
|
*/
'allow_markdown' => true,
/*
|-------------------------------------------------------------
| Markdown in attachments
|-------------------------------------------------------------
|
| Which attachment fields should be interpreted in Slack's Markdown-like
| language. By default, Slack assumes that no fields in an attachment
| should be formatted as Markdown.
|
*/
'markdown_in_attachments' => [],
// Allow Markdown in just the text and title fields
// 'markdown_in_attachments' => ['text', 'title']
// Allow Markdown in all fields
// 'markdown_in_attachments' => ['pretext', 'text', 'title', 'fields', 'fallback']
];
================================================
FILE: config/view.php
================================================
[
realpath(base_path('resources/views')),
],
/*
|--------------------------------------------------------------------------
| Compiled View Path
|--------------------------------------------------------------------------
|
| This option determines where all the compiled Blade templates will be
| stored for your application. Typically, this is within the storage
| directory. However, as usual, you are free to change this value.
|
*/
'compiled' => realpath(storage_path('framework/views')),
];
================================================
FILE: database/.gitignore
================================================
*.sqlite
================================================
FILE: database/factories/ModelFactory.php
================================================
define(App\User::class, function(Faker\Generator $faker) {
return [
'name' => $faker->name,
'email' => $faker->safeEmail,
'password' => bcrypt('password'),
'remember_token' => str_random(60),
];
});
$factory->define(App\Article::class, function(Faker\Generator $faker) {
$date = $faker->dateTimeThisMonth;
return [
'title' => $faker->sentence(),
'content' => $faker->paragraph(),
'created_at' => $date,
'updated_at' => $date,
];
});
$factory->define(App\Comment::class, function(Faker\Generator $faker) {
return [
'content' => $faker->paragraph,
];
});
$factory->define(App\Tag::class, function(Faker\Generator $faker) {
$name = ucfirst($faker->optional(0.9, 'Laravel')->word);
return [
'name' => $name,
'slug' => str_slug($name),
];
});
$factory->define(App\Attachment::class, function(Faker\Generator $faker) {
return [
'name' => sprintf("%s.%s", str_random(), $faker->randomElement(['png', 'jpg'])),
];
});
================================================
FILE: database/migrations/.gitkeep
================================================
================================================
FILE: database/migrations/2014_10_12_000000_create_users_table.php
================================================
increments('id');
$table->string('name');
$table->string('email')->unique();
$table->string('password', 60)->nullable();
$table->rememberToken();
$table->timestamp('last_login')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('users');
}
}
================================================
FILE: database/migrations/2014_10_12_100000_create_password_resets_table.php
================================================
string('email')->index();
$table->string('token')->index();
$table->timestamp('created_at');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('password_resets');
}
}
================================================
FILE: database/migrations/2015_01_15_105324_create_roles_table.php
================================================
increments('id')->unsigned();
$table->string('name');
$table->string('slug')->unique();
$table->string('description')->nullable();
$table->integer('level')->default(1);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('roles');
}
}
================================================
FILE: database/migrations/2015_01_15_114412_create_role_user_table.php
================================================
increments('id')->unsigned();
$table->integer('role_id')->unsigned()->index();
$table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade');
$table->integer('user_id')->unsigned()->index();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('role_user');
}
}
================================================
FILE: database/migrations/2015_01_26_115212_create_permissions_table.php
================================================
increments('id')->unsigned();
$table->string('name');
$table->string('slug')->unique();
$table->string('description')->nullable();
$table->string('model')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('permissions');
}
}
================================================
FILE: database/migrations/2015_01_26_115523_create_permission_role_table.php
================================================
increments('id')->unsigned();
$table->integer('permission_id')->unsigned()->index();
$table->foreign('permission_id')->references('id')->on('permissions')->onDelete('cascade');
$table->integer('role_id')->unsigned()->index();
$table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('permission_role');
}
}
================================================
FILE: database/migrations/2015_02_09_132439_create_permission_user_table.php
================================================
increments('id')->unsigned();
$table->integer('permission_id')->unsigned()->index();
$table->foreign('permission_id')->references('id')->on('permissions')->onDelete('cascade');
$table->integer('user_id')->unsigned()->index();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('permission_user');
}
}
================================================
FILE: database/migrations/2015_11_20_062500_create_comments_table.php
================================================
increments('id');
$table->integer('author_id')->unsigned()->index();
$table->string('commentable_type');
$table->integer('commentable_id')->unsigned();
$table->integer('parent_id')->unsigned()->nullable();
$table->string('title')->nullable();
$table->text('content');
$table->timestamps();
$table->softDeletes();
$table->foreign('author_id')->references('id')->on('users')->onDelete('cascade');
$table->foreign('parent_id')->references('id')->on('comments');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('comments');
}
}
================================================
FILE: database/migrations/2015_11_20_062513_create_articles_table.php
================================================
increments('id');
$table->integer('author_id')->unsigned()->index();
$table->string('title');
$table->text('content');
$table->integer('solution_id')->unsigned()->nullable();
$table->boolean('notification')->default(1);
$table->tinyInteger('view_count')->default(0);
$table->boolean('pin')->default(0);
$table->timestamps();
$table->softDeletes();
$table->foreign('author_id')->references('id')->on('users')->onDelete('cascade');
$table->foreign('solution_id')->references('id')->on('comments');
});
if (config('database.default') != 'sqlite') {
DB::statement('ALTER TABLE articles ADD FULLTEXT search(title, content)');
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('articles');
}
}
================================================
FILE: database/migrations/2015_11_20_062601_create_tags_table.php
================================================
increments('id');
$table->string('name');
$table->string('slug')->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('tags');
}
}
================================================
FILE: database/migrations/2015_11_20_062613_create_attachments_table.php
================================================
increments('id');
$table->integer('article_id')->unsigned()->index();
$table->string('name');
$table->timestamps();
// $table->foreign('article_id')->references('id')->on('articles')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('attachments');
}
}
================================================
FILE: database/migrations/2015_11_20_062846_create_article_tag_table.php
================================================
increments('id');
$table->integer('article_id')->unsigned();
$table->integer('tag_id')->unsigned();
$table->timestamps();
$table->foreign('article_id')->references('id')->on('articles')->onDelete('cascade');
$table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('article_tag');
}
}
================================================
FILE: database/migrations/2015_12_09_165930_create_lessons_table.php
================================================
increments('id');
$table->integer('author_id')->unsigned();
$table->string('name');
$table->text('content');
$table->timestamps();
$table->foreign('author_id')->references('id')->on('users');
});
if (config('database.default') != 'sqlite') {
DB::statement('ALTER TABLE lessons ADD FULLTEXT docs(name, content)');
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('lessons');
}
}
================================================
FILE: database/migrations/2015_12_10_151357_create_votes_table.php
================================================
increments('id');
$table->integer('user_id')->unsigned();
$table->integer('comment_id')->unsigned();
$table->tinyInteger('up')->nullable();
$table->tinyInteger('down')->nullable();
$table->timestamp('voted_at');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->foreign('comment_id')->references('id')->on('comments')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('votes');
}
}
================================================
FILE: database/seeds/.gitkeep
================================================
================================================
FILE: database/seeds/DatabaseSeeder.php
================================================
create([
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => bcrypt('password')
]);
factory(App\User::class, 9)->create();
$this->command->info('users table seeded');
/**
* Seeding roles table
*/
Bican\Roles\Models\Role::truncate();
DB::table('role_user')->truncate();
$adminRole = Bican\Roles\Models\Role::create([
'name' => 'Admin',
'slug' => 'admin'
]);
$memberRole = Bican\Roles\Models\Role::create([
'name' => 'Member',
'slug' => 'member'
]);
App\User::where('email', '!=', 'john@example.com')->get()->map(function($user) use($memberRole) {
$user->attachRole($memberRole);
});
App\User::whereEmail('john@example.com')->get()->map(function($user) use($adminRole){
$user->attachRole($adminRole);
});
$this->command->info('roles table seeded');
/*
* Seeding articles table
*/
App\Article::truncate();
$users = App\User::all();
$users->each(function($user) use($faker) {
$user->articles()->save(
factory(App\Article::class)->make()
);
$user->articles()->save(
factory(App\Article::class)->make()
);
});
$this->command->info('articles table seeded');
/**
* Seeding comments table
*/
App\Comment::truncate();
$articles = App\Article::all();
$articles->each(function($article) use($faker, $users) {
$article->comments()->save(
factory(App\Comment::class)->make([
'author_id' => $faker->randomElement($users->lists('id')->toArray())
])
);
});
$this->command->info('comments table seeded');
/*
* Seeding tags table
*/
App\Tag::truncate();
DB::table('article_tag')->truncate();
$rawTags = config('project.tags');
foreach($rawTags as $tag) {
App\Tag::create([
'name' => $tag,
'slug' => str_slug($tag)
]);
}
$tags = App\Tag::all();
foreach($articles as $article) {
$article->tags()->attach(
$faker->randomElements(
$tags->lists('id')->toArray(),
$faker->randomElement([1,2,3])
)
);
}
$this->command->info('tags table seeded');
/*
* Seeding attachments table
*/
App\Attachment::truncate();
if (! File::isDirectory(attachment_path())) {
File::deleteDirectory(attachment_path(), true);
}
$articles->each(function($article) use($faker) {
$article->attachments()->save(
factory(App\Attachment::class)->make()
);
});
$files = App\Attachment::lists('name');
if (! File::isDirectory(attachment_path())) {
File::makeDirectory(attachment_path(), 777, true);
}
foreach($files as $file) {
File::put(attachment_path($file), '');
}
$this->command->info('attachments table seeded');
/**
* Close seeding
*/
// Not required for Laravel 5.2
// @see https://laravel.com/docs/5.2/upgrade#upgrade-5.2.0
// Model::reguard();
if (config('database.default') != 'sqlite') {
DB::statement('SET FOREIGN_KEY_CHECKS=1');
}
}
}
================================================
FILE: gulpfile.js
================================================
var elixir = require('laravel-elixir');
elixir(function (mix) {
mix.sass('app.scss', 'resources/assets/css')
.styles([
'app.css',
'../vendor/dropzone/dist/dropzone.css',
'../vendor/earthsong.css',
], 'public/css/app.css');
mix.scripts([
'../vendor/jquery/dist/jquery.js',
'../vendor/bootstrap-sass/assets/javascripts/bootstrap.js',
'../vendor/fastclick/lib/fastclick.js',
'../vendor/select2/dist/js/select2.js',
'../vendor/dropzone/dist/dropzone.js',
'../vendor/tabby/jquery.textarea.js',
'../vendor/autosize/dist/autosize.js',
'../vendor/highlightjs/highlight.pack.js',
'../vendor/marked/lib/marked.js',
'app.js'
], 'public/js/app.js');
mix.version([
'css/app.css',
'js/app.js'
]);
/* font files are static, so doesn't need to be copied every gulp run.
//mix.copy("resources/assets/vendor/font-awesome/fonts", "public/build/fonts");
/* To activate Browser Sync, uncomment and run $ gulp watch */
//mix.browserSync();
});
================================================
FILE: lessons/01-welcome.md
================================================
---
extends: _layouts.master
section: content
current_index: 0
---
# 1강 - 처음 만나는 라라벨
라라벨은 php 언어로 짜여진 MVC 아키텍처를 지원하는 웹 프레임웍이다. 루비 언어에 레일즈, 파이썬 언어에 장고와 대칭되는 존재라고 보면 된다. [SitePoint의 2015년 설문조사](http://www.sitepoint.com/best-php-framework-2015-sitepoint-survey-results/)에 따르면, 라라벨은 **(국외에서)** 현재 가장 인기 있는 php 프레임웍으로 알려져 있다. 국산 CMS 중 사용자/사이트 수 측면에서 1위 CMS인 [XE](https://www.xpressengine.com/) 에서도 차기 버전인 XE3 는 라라벨로 전환한다고 발표한 바 있다.
## 인기의 비결은?
- 단순하고(== 쉽고) 우아한 문법
- 복잡한 것들은 프레임웍 안에서 처리
- 강력한 확장 기능들
- PSR(Php Standards Recommendations) 적용
- 모던 개발 방법론 적용
- RAD RAD RAD (Rapid Application Development)
**`참고`** 라라벨은 다른 php 프레임웍 대비 "무겁다", "느리다"고 알려져 있다. 라라벨의 철학은 "개발 생산성"이라는 것을 기억하자. 우리가 사는 2015년은, 개발자 인건비에 비하면 큰 메모리에 SSD로 서버 성능을 높이는 비용은 너무나도 싸다. 가령, DigitalOcean의 경우 최고 사양인 8GB메모리, 4Core, 80GB HDD의 IaaS 사용 비용이 월 $80이다. 사용자가 수 억에 달해 스케일아웃으로도 한계에 달해 정말 성능 최적화를 해야할 시점이 된다면 정말 행복한 고민일 것이다. 그때는 훌륭한 개발자들을 뽑아서 하면 된다. 페이스북을 보라~ HHVM 이란 php 엔진도 지들이 직접 만들어 쓰지 않는가? 웬만한 규모에서는 시스템엔지니어 고용 대신 AWS 쓰고, 성능을 최적화하는 마이크로 레벨의 개발보다는 개발 시간을 단축할 수 있는 도구를 사용하는 것이 훨씬 더 현명한 선택일 것이다.
## 내장 기능 (Free)
이 강좌를 통해 소개되는 기본적인 기능외에도 라라벨 5 에는 많은 기능이 포함되어 있다.
- 웹 서비스를 위해 필요한 Cache, Queue, Mail, ...
- [Service Container](http://laravel.com/docs/container)를 이용한 의존성 자동 주입
- [Cron 자동화](http://laravel.com/docs/scheduling)
- [Elixir](http://laravel.com/docs/elixir)를 이용한 CSS/Sass/Less, JS/Coffee 등 Frontend 워크플로우 자동화
- ...
## 확장 기능 (Free)
- [Homestead](https://github.com/laravel/homestead) 로 개발 환경을 표준화할 수 있다.
- [Socialite](https://github.com/laravel/socialite)로 소셜 인증을 쉽게 할 수 있다. [Socialite Provider의 목록](http://socialiteproviders.github.io/)을 확인해 보자.
- [Cashier](https://github.com/laravel/cashier)로 결제 기능을 쉽게 달 수 있다.
- [Envoy](https://github.com/laravel/envoy) 로 SSH 원격 작업을 자동화할 수 있다.
- [Laravel Collective](http://laravelcollective.com/) 라라벨 구버전에 있었으나 빠진 기능들의 모음이다.
## 마이크로 프레임웍 - 루멘 (Free)
라라벨을 쓰는 것이 너무 오버라고 생각되는, 아주 간단한 서비스를 개발하려 할 땐,
- [Lumen](http://lumen.laravel.com/)
## 확장 서비스 ($)
- [Forge](https://forge.laravel.com/)를 이용하여 서버 프로비저닝/서버 관리/코드 배포 등을 자동화할 수 있다. ($10/월, 서버 댓수 제한 없음)
- [Envoyer](https://envoyer.io/)를 이용하여 무중단 코드 배포를 할 수 있다. ($10/월, 10 프로젝트)
## 커뮤니티
- [라라벨 뉴스](https://laravel-news.com/) - 프레임웍 코어 멤버 중의 한명인 Eric Barnes 가 운영하는 뉴스 블로그. 라라벨 개발자라면, 또는 되려면 뉴스레터에 꼭 가입하라.
- [라라 캐스트](https://laracasts.com/) - 역시 코어 멤버 중의 한명인 Jeffrey Way가 운영하는 동영상 강의 서비스. 매주 2~3개의 강의가 올라오며, 기존에 작성된 거의 400편에 가까운 동영상 강의를 볼 수 있다 ($10/월). 라라벨 사용자가 몰려 있는 서비스로서, 포럼도 같이 운영 중이며 포럼 활동도 아주 활발하다.
- [LARAVEL.IO](http://laravel.io/forum) - 라라 캐스트 전에 가장 활발한 활동을 하던 포럼이다.
- [Codecourse](https://www.youtube.com/user/phpacademy) - phpacademy 란 채널이 최근이 이름이 바뀌었다. 무료 동영상 강의를 제공하고 있다.
- 그 외에도 구글링 해보면 라라벨 관련 *영어* 자료는 넘쳐 난다.
## 한국어 리소스
- [한국어 매뉴얼 (번역 by XE팀)](http://xpressengine.github.io/laravel-korean-docs/)
- [라라벨 영상 강좌 (by XE팀)](https://www.xpressengine.com/learn/23061328)
- [한국어 책 (by 정광섭님)](https://www.lesstif.com/pages/viewpage.action?pageId=28606603)
---
이제 라라벨 5 로 여행을 떠나 보자.
- [목록으로 돌아가기](../readme.md)
- [2강 - 라라벨 5 설치하기](02-hello-laravel.md)
================================================
FILE: lessons/02-hello-laravel.md
================================================
---
extends: _layouts.master
section: content
current_index: 1
---
# 2강 - 라라벨 5 설치하기 (on Mac)
## 윈도우즈 사용자라면
[여기를 참고](02-install-on-windows.md)하자. 장기적으로 보고 Mac 이나 Linux 로 전환할 것을 권장한다. 윈도우즈는 코맨드 프롬프트(콘솔)에서 명령어 사용이 불편해서 생산성이 떨어진다.
## 터미널에 익숙해 지자.
기본 내장 터미널 (=='콘솔') 소프트웨어를 사용하지 말고, [iTerm2](https://www.iterm2.com/)(Free) 와 [Oh My Zshell](https://github.com/robbyrussell/oh-my-zsh)(Free) 을 쓸 것을 권장한다. iTerm2 는 Mac 내장 콘솔의 대체 앱이며, Oh My Zshell 은 콘솔의 기능을 더 편리하게 해 주는 플러그인이라 생각하면 된다.
```bash
$ sh -c "$(curl -fsSL https://raw.github.com/robbyrussell/oh-my-zsh/master/tools/install.sh)"
```
Mac 용 패키지 매니저인 [Homebrew](http://brew.sh/)(Free) 도 반드시 설치해 놓자! `Homebrew` 는 Mac 용 `apt-get` 이라 이해하면 된다.
```bash
$ ruby --version # ruby 2.x
$ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
```
## 개발 환경 셋업
### [이 코스에서 사용] 로컬 개발 환경
Mac 사용자라면 2가지 옵션이 있다. 로컬 Mac 을 개발 머신으로 쓰거나, 다음 절에 설명하는 Homestead 를 쓰는 방법이다.
이 코스에서는 **로컬 Mac을 개발환경으로 쓰는 것을 가정하고 설명**한다. 로컬 Mac에 php, mySql 이 설치되어 있지 않다면 Homebrew 를 이용해 설치하자.
```bash
$ brew search php56 # Homebrew 설정에 따라 php56 또는 homebrew/php/php56이 출력될 것이다.
$ brew install homebrew/php/php56
$ brew search mysql5 # 역시 Homebrew 설정에 결과는 다를 수 있다. 가장 높은 버전을 설치하자.
$ brew install homebrew/versions/mysql55
$ /usr/local/bin/mysql.server start
```
터미널을 반드시 한번 껐다가 켜자. 설치되는 동안 사용자의 콘솔 프로파일에 경로를 업데이트했을텐데, 그 경로를 다시 로드하기 위해서다.
```bash
$ php --version # 5.6.xx
$ mysql --version # 5.5.xx
```
**`참고`** Mac 부트시 자동으로 mySql 서버를 기동시키려면, mySql 설치 코맨드가 끝난 후 출력된 내용을 유심히 살펴보기 바란다. `mv /usr/local/opt/mysql/homebrew.mxcl.mysql.plist ~/Library/LaunchAgents/homebrew.mxcl.mysql.plist`와 같은 명령이 있을텐데, 환경마다 다를 수 있을니 필자가 쓴 코맨드가 아니라, 실제 콘솔에 있는 코맨드를 복사해서 콘솔에 붙여 넣고 실행한다.
### **[OPTIONAL]** 공짜로 쓰는 개발 서버 "Homestead"
개발팀 구성원들이 동일한 개발 환경을 가지기 위해서, 또는 Production 과 유사한 환경에서 개발하기 위해서 Homestead 사용을 권장한다. Homestead 는 위에서 언급한 필요 확장 모듈이 기본 설치되어 있다.
설정법은 꽤나 까다로우니 [Homestead 설치 (on Mac)](02-install-homestead-osx.md) 를 참고하자.
## 라라벨이 동작하기 위한 PHP 버전 및 필요 모듈 조건 확인
라라벨을 설치하려는 개발환경 또는 서버가 아래 필요사항을 충족하는 지 확인한다.
- php 5.5.9 이상
- php Extensions
- OpenSSL
- PDO
- Mbstring
- Tokenizer
```bash
$ php --version # PHP 5.6.xx
$ php -m | grep 'openssl\|pdo\|mbstring\|tokenizer'
```
Homebrew 를 통해 설치했다면 기본적으로 모든 모듈이 설치되어 있을 것이다. 하나라도 빠진게 있다면 구글링해서 설치하자~
## 이제 라라벨을 설치해 보자.
라라벨 인스톨러를 사용할 것을 권장한다. 왜냐하면 Composer 를 이용해 설치하는 것 보다 훨~씬 빠르니까...
먼저, Composer 가 필요하다. 왜냐하면, 라라벨 인스톨러가 Composer 를 통해 배포되기 때문이다.
```bash
$ curl -sS https://getcomposer.org/installer | php
$ mv composer.phar /usr/local/bin/composer
$ composer --version # Composer version 1.xx
```
이제 Composer 를 이용해서 라라벨 인스톨러를 설치한다.
```bash
$ composer global require "laravel/installer=~1.1"
```
끝이 아니다. `laravel`과 `homestead` 코맨드를 어디서든 접근할 수 있게 경로 설정하자.
```bash
# 터미널에 익숙치 않은 분이 많아 자세히 쓴다.
# 자신이 사용하는 콘솔에 따라 프로파일 이름이 다를 수 있다. ~/.profile, ~/.bash_profile, ~/.zshrc, ...
# On My Zshell을 설치했다면 ~/.zshrc 이다.
$ nano ~/.zshrc
# 아래와 같이 써진 줄을 찾아 맨 끝에 :$HOME/.composer/vendor/bin 을 추가하자.
export PATH="$PATH:$HOME/.composer/vendor/bin"
# 쇠뿔도 단김에 앞으로 자주 쓰게 될 artisan 코맨드의 별명을 등록해 놓자.
# 앞으로 자주 쓰게될 php artisan 대신 artisan 만 치면 된다.
# 열린 파일 맨 끝에 써 넣는다.
alias artisan="php artisan"
# ctrl + x -> Y -> enter 순으로 눌러 수정 내용을 저장한다.
# 수정된 내용이 반영될 수 있도록 콘솔을 다시 시작하거나, 아래 코맨드를 실행한다.
$ source ~/.zshrc
# laravel 코맨드의 경로가 잘 설정되었는지 확인해 보자.
$ laravel --version # Laravel Installer version 1.x
```
휴~, 이제 설치를 위한 준비가 완료되었다. 라라벨 인스톨러로 라라벨 5를 설치하자.
```bash
$ laravel new myProject
$ cd myProject
$ php artisan --version # Laravel Framework version 5.x
$ chmod -R 777 storage bootstrap/cache
```
서버를 부트업하고, 라라벨을 시작해 보자!
```bash
# 로컬 서버를 부트한다.
$ php artisan serve # 종료하려면 ctrl+c
# Laravel development server started on http://localhost:8000/
```
브라우저에서 `http://localhost:8000` 페이지를 방문해서 'Laravel 5' 란 글씨가 써진 화면이 보인다면, 성공적으로 설치한 것이다.

**`참고`** `artisan` 은 라라벨의 코맨드 라인 툴이다. `$ php artisan` 을 실행한 후, 설명을 쭈욱~ 한번 살펴보자. 개발 중에 코드에디터와 콘솔을 오가면서, `artisan` 코맨드를 많이 사용하게 될 것이다.
## 라라벨의 폴더 구조를 살펴 보자.
처음 설치하면 딱 아래와 같은 형태이다. 지금은 눈에 안들어 오니, 그냥 휘~익 훑어 보자. 코스를 진행하다 보면 저절로 익히게 된다.
```
.
├── .env # 글로벌 설정 중 민감한 값, dev/production 등 앱 실행환경에 따라 변경되어야 하는 값을 써 놓는 곳
├── app
│ ├── Console
│ │ ├── Commands # 콘솔 코맨드 하우징
│ │ └── Kernel.php # 콘솔 코맨드, 크론 스케쥴 등록
│ ├── Events # 이벤트 클래스 하우징
│ ├── Exceptions # Exception 하우징
│ │ └── Handler.php # 글로벌 Exception 처리 코드
│ ├── Listeners # 이벤트 핸들러
│ ├── Jobs
│ ├── Policies
│ ├── Http # Http 요청 처리 클래스들의 하우징
│ │ ├── Controllers # Http Controller
│ │ ├── Kernel.php # Http 및 Route 미들웨어 등록
│ │ ├── Middleware # Http 미들웨어 하우징
│ │ ├── Requests # Http 폼 요청 미들웨어 하우징
│ │ └── routes.php # Http 요청 Url을 Controller에 맵핑시키는 규칙을 써 놓은 테이블
│ └── Providers # 서비스 공급자 하우징 (config/app.php에서 바인딩 됨)
│ ├── AppServiceProvider.php
│ ├── AuthServiceProvider.php
│ ├── EventServiceProvider.php # 이벤트 리스너, 구독 바인딩
│ └── RouteServiceProvider.php # 라우팅 바인딩 (글로벌 라우팅 파라미터 패턴 등이 등록되어 있음)
├── composer.json # 이 프로젝트의 Composer 레지스트리, Autoload 규칙 등이 담겨 있다. (c.f. Node의 package.json)
├── config # database, queue, mail 등 글로벌 설정 하우징
├── database
│ ├── migrations # 데이터베이스 스키마
│ └── seeds # 생성된 테이블에 Dummy 데이터를 삽입하는 클래스들 (개발 목적)
├── gulpfile.js # Elixir (프론트엔드 빌드 자동화) 스크립트
├── public # 웹 서버에 의해 지정된 Document Root
├── resources
│ ├── assets # JavaScript, CSS 하우징
│ ├── lang # 다국어 지원을 위한 언어 레지스트리 하우징
│ └── views # 뷰 파일 하우징
├── storage # Laravel5 파일 저장소
└── vendor # composer.json의 저장소
```
## 라라벨의 프로젝트 구조
아래 그림을 보자. 방금 설치한 상태가 그림 왼쪽에 빨간색으로 표시된 Laravel (`laravel/laravel`) 과 Framework (`laravel/framework`) 가 설치된 상태이다. 별도로 분리해 놓은 이유는 Framework 요소가 Laravel 이 아닌, 가령 [Lumen](http://lumen.laravel.com/) 처럼 다른 프로젝트에서도 사용할 수 있도록 하기 위해서이다.
라라벨이 제공하는 문법과 API 들을 이용해서 User Code (`appkr/l5essential`) 라고 표시된, 우리만의 서비스를 만들게 된다. 이 과정에서 라라벨에서 제공하는 기본 기능외에 외부의 패키지들, User-pulled 3rd Party Packages 라 표시된 부분들도 가져와서 사용하게 된다.

## 라라벨의 동작 시퀀스
역시 마찬가지다. 지금은 몰라도 된다. 나중에 한번 돌아와서 다시 보게 된다면, 아~ 하고 이해될 것이다.

---
- [목록으로 돌아가기](../readme.md)
- [1강 - 처음 만나는 라라벨](01-welcome.md)
- [3강 - 글로벌 설정 살펴보기](03-configuration.md)
================================================
FILE: lessons/02-install-homestead-osx.md
================================================
---
extends: _layouts.master
section: content
current_index: 57
---
# Homestead 설치 (on Mac)
## 사전 요구 사항
[VirtualBox](https://www.virtualbox.org/wiki/Downloads) 와 [Vagrant](http://www.vagrantup.com/downloads.html) 설치가 필요하다. 인스톨러 화면에서 "Next" 만 계속 눌러서 쉽게 설치할 수 있다.
## Vagrant Box 설치
라라벨 커뮤니티에서 미리 준비해서 [Vagrant Box Registry](https://atlas.hashicorp.com/boxes/search) 에 배포해 놓은 `laravel/homestead` Vagrant Box (==Virtual Machine Image) 를 다운로드 하는 과정이다. 이 강좌를 쓰는 시점에 `laravel/homestead` Vagrant Box 의 최신 버전은 PHP7 이 기본 포함되어 있는 0.4.0 이다. 여기서는 PHP5 버전을 쓸 것이므로, `--box-version 0.3.3` 옵션을 추가해 주었다.
```bash
$ vagrant box add laravel/homestead --box-version 0.3.3
# 설치에 실패했다면, 기존 다운로드 찌거기를 지우고 다시 다운로드 받기 위해 --clean 옵션 스위치를 붙여야 한다.
# 기존에 설치한 laravel/homestead Box 을 덮어쓰고 강제로 다시 설치하려면 --force 옵션 스위치를 붙인다.
```
설치 과정에 아래 처럼 Virtual Machine Provider 를 선택하는 화면이 나온다. `2) vmware` 는 [Vagrant 용 Plugin](http://www.vagrantup.com/vmware) 을 별도로 사서 설치해야 한다는 점을 알고 있자. 이 강좌를 쓰는 현재 Vmware Vagrant Plugin 의 가격은 $79 이다. 무료로 쓸 수 있는 `1) virtualbox` 를 선택했다. OS 에 따라 수 GB 용량을 다운로드 받아야 하므로 꽤 오랜 시간이 걸린다.
```bash
This box can work with multiple providers! The providers that it
can work with are listed below. Please review the list and choose
the provider you will be working with.
1) virtualbox
2) vmware_desktop
Enter your choice: 1
```
## Homestead CLI 설치
[Homestead CLI](https://github.com/laravel/homestead) 는 `vagrant` CLI 의 Wrapper 이다. `vagrant` 대신 `homestead` 코맨드를 이용할 수 있다.
```bash
$ composer global require "laravel/homestead:2.*"
```
`homestead` CLI 는 '~/.composer' 디렉토리에 설치된다. 경로 지정없이 `homestead` 명령을 사용하려면, 아래 처럼 경로를 추가해 주어야 한다.
```bash
# 사용하는 Shell 에 따라 Profile 파일 이름은 다를 수 있다.
# 필자는 Zshell 을 쓰므로, .zshrc 이다. e.g. .profile, .bashrc
$ nano ~/.zshrc
# composer global 로 설치한 패키지들의 실행파일을 경로에 넣어 준다.
# 이 과정이 없다면 $ ~/.composer/vendor/bin/homestead 와 같이 전체 경로를 써주어야 한다.
export PATH="$PATH:$HOME/.composer/vendor/bin"
# 수정했다면 ctrl + X, "Y" 를 눌러 변경 내용을 저장하고, 엔터를 한번 더 눌러 기존 파일을 덮어 쓴다.
# 그리고, 수정 내용을 현재 콘솔에 적용해 준다. 콘솔을 껐다가 다시 실행해도 된다.
$ source ~/.zshrc
```
최신 버전이 `3.*` 이지만, 필자는 굳이 구 버전인 `2.*` 를 설치했다. 새 버전은 `laravel/homestead` Vagrant Box v0.4.0 과 궁합이 맞도록 구현 되어 있을 뿐만아니라, 아래에 나온 편리한 코맨드들도 모두 제거되었기 때문이다.
```bash
$ homestead
Laravel Homestead version 2.1.8
Usage:
command [options] [arguments]
Options:
# ...
Available commands:
destroy Destroy the Homestead machine
edit Edit a Homestead file
halt Halt the Homestead machine
help Displays help for a command
init Create a stub Homestead.yaml file
list Lists commands
make Install Homestead into the current project
provision Re-provisions the Homestead machine
resume Resume the suspended Homestead machine
run Run commands through the Homestead machine via SSH
ssh Login to the Homestead machine via SSH
ssh-config Outputs OpenSSH valid configuration to connect to Homestead
status Get the status of the Homestead machine
suspend Suspend the Homestead machine
up Start the Homestead machine
update Update the Homestead machine image
```
`make` 란 명령은 꽤 최근에 추가된 것인데, 각 프로젝트마다 별도의 Homestead 설정을 만들 수 있는 명령이다. Docker 의 Container 개념과 유사하다고 볼 수 있다. 복수의 프로젝트를 개발 중이고, 프로젝트간에 서버 환경이 굉장히 많이 다르다면, 사용하면 좋을 것 같다.
## Homestead 설정
`laravel/homestead` Vagrant VM 을 올바르게 셋팅하기 위한 설정 파일은 '~/.homestead' 디렉토리에 위치해야 한다. 아래 명령으로 이 설정 파일을 초기화하자.
```bash
$ homestead init
```
Homestead 설정을 우리 프로젝트에 맞게 수정하자.
```bash
$ homestead edit
# 로컬에 Sublime Text 가 설치되어 있지 않다면..
# ~/.homestead/Homestead.yaml 을 편집기로 직접 열면 된다.
```
```bash
# ~/.homestead/Homestead.yaml
ip: "192.168.10.10" # homestead VM 이 사용할 ip 주소
memory: 2048
cpus: 1
provider: virtualbox # Virtual Machine Provider
# SSH 로그인에 사용할 public key. 이 키 값은 homestead VM 의
# /home/vagrant/.ssh/authorized_keys 에 자동으로 추가된다.
authorize: ~/.ssh/id_rsa.pub
keys:
- ~/.ssh/id_rsa # SSH 로그인에 사용할 private key
# 로컬과 VM 간에 공유할 폴더를 설정한다.
# 이 강좌용 라라벨은 ~/workspace/myProject 에 위치한다고 가정한다.
folders:
- map: ~/workspace # 로컬 디렉토리 경로
to: /home/vagrant/Code # VM 의 디렉토리 경로
# 도메인 이름 (hostname) 과 VM 에 설치된 웹 서버의 Document Root 를 설정한다.
sites:
- map: myproject.dev # 도메인 이름
to: /home/vagrant/Code/myProject/public # 웹 서버의 Document Root
# 사이트를 추가하려면.. 아래 처럼 추가 사이트를 정의한 다음
# VM 콘솔에서 $ homestead provision 명령을 실행한다.
- map: example.dev
to: /home/vagrant/Code/example/public
# 서버 프로비저닝 과정에서 아래에 정의한 DB 를 만들어 준다.
databases:
- myProject
```
## Host 파일 설정
Homestead 설정에서 myproject.dev 란 도메인을 이용했다. 이런 도메인은 존재하지 않는다. 운영체제의 Host 파일을 수정할 것이다. 운영체제에 포함된 'hosts' 파일은 DNS 로 myproject.dev 에 대한 ip 주소 Resolution 요청이 나가기 전에 요청을 낚아 채서, 'hosts' 파일 안에서 찾는다. 사용자가 요청한 도메인에 해당하는 레코드가 있으면 지정된 ip 주소로 이동할 것이다.
```bash
$ sudo nano /etc/hosts
```
```bash
# /etc/hosts
192.168.10.10 myproject.dev
```
## Homestead 실행
실행해 보자. 처음 실행할 때는 시간이 좀 걸리는데, 이유는 앞에서 Homestead.yaml 에 설정한, ip 주소, public key 복사, 공유 폴더 설정 등을 하기 때문이다.
```bash
$ homestead up
# homestead VM 을 중지시킬 때는 $ homestead halt
# 완전히 끌 때는 $ homestead suspend
```
VM 에 로그인하고 Homestead.yaml 설정이 잘 먹었나 확인해 보자.
```bash
$ homestead ssh
# Welcome to Ubuntu 14.04.3 LTS (GNU/Linux 3.19.0-25-generic x86_64)
vagrant@homestead:~$ ls ~/Code
# ... myProject ...
vagrant@homestead:~$ ifconfig | grep 192.168.10.10
# inet addr:192.168.10.10 Bcast:192.168.10.255 Mask:255.255.255.0
vagrant@homestead:~$ cat ~/.ssh/authorized_keys
# ssh-rsa AAAAB3NzaC1...D3XDfv juwonkim@me.com
```
**`참고`** ssh 를 이용해 직접 접속하려면 `$ ssh vagrant@myproject.dev -D 2222`.
## 데이터베이스 접속
Host `127.0.0.1`, Port `33060`, Username `homestead`, Password `secret` 로 접속한다. PostgresSQL 의 경우 Port `54320` 으로 접속한다.

## 웹 서버 접속
Homestead 에는 Nginx 가 기본으로 탑재되어 있고, Homestead.yaml 의 sites 섹션에서 설정한대로 이미 서비스가 돌고 있는 상태이다.
브라우저에서 'http://myproject.dev' 로 접속해 보자. 테스트용으로 쓸 수 있는 self-signed 인증서가 설치되어 있기 때문에 'https://myproject.dev' 도 사용할 수 있다.
---
- [목록으로 돌아가기](../readme.md)
================================================
FILE: lessons/02-install-homestead-windows.md
================================================
---
extends: _layouts.master
section: content
current_index: 58
---
# Homestead 설치 (on Windows)
## 사전 요구 사항
[VirtualBox](https://www.virtualbox.org/wiki/Downloads) 와 [Vagrant](http://www.vagrantup.com/downloads.html) 설치가 필요하다. 인스톨러 화면에서 "Next" 만 계속 눌러서 쉽게 설치할 수 있다.
## Vagrant Box 설치
라라벨 커뮤니티에서 미리 준비해서 [Vagrant Box Registry](https://atlas.hashicorp.com/boxes/search) 에 배포해 놓은 `laravel/homestead` Vagrant Box (==Virtual Machine Image) 를 다운로드 하는 과정이다. 이 강좌를 쓰는 시점에 `laravel/homestead` Vagrant Box 의 최신 버전은 PHP7 이 기본 포함되어 있는 0.4.0 이다.
```bash
$ vagrant box add laravel/homestead
# 설치에 실패했다면, 기존 다운로드 찌거기를 지우고 다시 다운로드 받기 위해 --clean 옵션 스위치를 붙여야 한다.
# 기존에 설치한 laravel/homestead Box 을 덮어쓰고 강제로 다시 설치하려면 --force 옵션 스위치를 붙인다.
```
설치 과정에 아래 처럼 Virtual Machine Provider 를 선택하는 화면이 나온다. `2) vmware` 는 [Vagrant 용 Plugin](http://www.vagrantup.com/vmware) 을 별도로 사서 설치해야 한다는 점을 알고 있자. 이 강좌를 쓰는 현재 Vmware Vagrant Plugin 의 가격은 $79 이다. 무료로 쓸 수 있는 `1) virtualbox` 를 선택했다. OS 에 따라 수 GB 용량을 다운로드 받아야 하므로 꽤 오랜 시간이 걸린다.
```bash
This box can work with multiple providers! The providers that it
can work with are listed below. Please review the list and choose
the provider you will be working with.
1) virtualbox
2) vmware_desktop
Enter your choice: 1
```
## Homestead 프로젝트 설치
`git` 명령을 쓸 수 없는 경우, [https://github.com/laravel/homestead](https://github.com/laravel/homestead) 를 방문해서 zip 파일을 다운로드 한 후, 적절한 위치에 압축을 해제한다.
```bash
# Git Bash
$ cd ~ && git clone https://github.com/laravel/homestead.git Homestead
```
## Homestead 설정
`laravel/homestead` Vagrant VM 을 올바르게 셋팅하기 위한 설정 파일은 '~/.homestead' 디렉토리에 위치해야 한다. 아래 명령으로 이 설정 파일을 초기화하자.
```bash
# Git Bash
$ cd ~/Homestead && bash init.sh
# Windows Command Prompt
\> cd %HOMEPATH%\Homestead
\> init
```
Homestead 설정을 우리 프로젝트에 맞게 수정하자. 코드 에디터 또는 텍스트 편집기로 %HOMEPATH%\.homestead\Homestead.yaml 파일을 연다.
```bash
# /c/Users/{username}/.homestead/Homestead.yaml
# Or %HOMEPATH%\.homestead\Homestead.yaml
ip: "192.168.10.10" # homestead VM 이 사용할 ip 주소
memory: 2048
cpus: 1
provider: virtualbox # Virtual Machine Provider
# SSH 로그인에 사용할 public key. 이 키 값은 homestead VM 의
# /home/vagrant/.ssh/authorized_keys 에 자동으로 추가된다.
authorize: ~/.ssh/id_rsa.pub
keys:
- ~/.ssh/id_rsa # SSH 로그인에 사용할 private key
# 로컬과 VM 간에 공유할 폴더를 설정한다.
# 이 강좌용 라라벨은 ~/myProject 에 위치한다고 가정한다.
folders:
- map: ~/myProject # 로컬 디렉토리 경로
to: /home/vagrant/myProject # VM 의 디렉토리 경로
# 도메인 이름 (hostname) 과 VM 에 설치된 웹 서버의 Document Root 를 설정한다.
sites:
- map: myproject.dev # 도메인 이름
to: /home/vagrant/myProject/public # 웹 서버의 Document Root
# 사이트를 추가하려면.. 아래 처럼 추가 사이트를 정의한 다음
# VM 콘솔에서 $ vagrant provision 명령을 실행한다.
- map: example.dev
to: /home/vagrant/example/public
```
## Host 파일 설정
Homestead 설정에서 myproject.dev 란 도메인을 이용했다. 이런 도메인은 존재하지 않는다. 운영체제의 Host 파일을 수정할 것이다. 운영체제에 포함된 'hosts' 파일은 DNS 로 myproject.dev 에 대한 ip 주소 Resolution 요청이 나가기 전에 요청을 낚아 채서, 'hosts' 파일 안에서 찾는다. 사용자가 요청한 도메인에 해당하는 레코드가 있으면 지정된 ip 주소로 이동할 것이다.
텍스트 편집기나 코드 에디터로 `%WINDIR%\System32\drivers\etc\hosts` 파일을 열어 아래 레코드를 추가한다.
```bash
# %WINDIR%\System32\drivers\etc\hosts
192.168.10.10 myproject.dev
```

## SSH Key 생성
Homestead 설정에서 `authorize: ~/.ssh/id_rsa.pub` 와 `~/.ssh/id_rsa` 를 설정했는데, 해당 파일은 존재하지 않는다. Git Bash 를 쓸 수 없다면, [PuTTYgen](http://www.chiark.greenend.org.uk/~sgtatham/putty/download.html) 을 설치하고 private, public key pair 를 만들자.
```bash
# Git Bash
$ mkdir ~/.ssh
$ ssh-keygen -t rsa
# Enter file in which to save the key (/c/Users/suchc/.ssh/id_rsa):
# Enter passphrase (empty for no passphrase):
# Enter same passphrase again:
```
## Homestead 실행
실행해 보자. 처음 실행할 때는 시간이 좀 걸리는데, 이유는 앞에서 Homestead.yaml 에 설정한, ip 주소, public key 복사, 공유 폴더 설정 등을 하기 때문이다. 처음 실행할 때는 방화벽 관련 보안 경고가 뜰 수 있는 데 "허용" 해 주자.
```bash
# Git Bash
$ cd ~/Homestead && vagrant up
# homestead VM 을 중지시킬 때는 $ vagrant halt
# 완전히 끌 때는 $ vagrant suspend
# Windows Command Prompt
\> cd %HOMEPATH%\Homestead
\> vagrant up
```
VM 에 로그인하고 Homestead.yaml 설정이 잘 먹었나 확인해 보자. 코맨드 프롬프트에서는 SSH Client 가 없어서 안되는 작업이니, 반드시 Git Bash 를 이용해야 한다.
```bash
# Git Bash only
$ vagrant ssh
# Welcome to Ubuntu 14.04.3 LTS (GNU/Linux 3.19.0-25-generic x86_64)
vagrant@homestead:~$ ls ~/myProject
# app ... bootstrap ...
vagrant@homestead:~$ ifconfig | grep 192.168.10.10
# inet addr:192.168.10.10 Bcast:192.168.10.255 Mask:255.255.255.0
vagrant@homestead:~$ cat ~/.ssh/authorized_keys
# ssh-rsa AAAAB3NzaC1...TpJ5HH suchc@homepc
```
**`참고`** ssh 를 이용해 직접 접속하려면 `$ ssh vagrant@myproject.dev -D 2222`.
## 데이터베이스 접속
Host `127.0.0.1`, Port `33060`, Username `homestead`, Password `secret` 로 접속한다. PostgresSQL 의 경우 Port `54320` 으로 접속한다. 필자는 [SQLyog](https://code.google.com/p/sqlyog/wiki/Downloads) 클라이언트를 이용하였다.

## 웹 서버 접속
Homestead 에는 Nginx 가 기본으로 탑재되어 있고, Homestead.yaml 의 sites 섹션에서 설정한대로 이미 서비스가 돌고 있는 상태이다.
브라우저에서 'http://myproject.dev' 로 접속해 보자. 테스트용으로 쓸 수 있는 self-signed 인증서가 설치되어 있기 때문에 'https:://myproject.dev' 도 사용할 수 있다.

---
- [목록으로 돌아가기](../readme.md)
================================================
FILE: lessons/02-install-on-windows.md
================================================
---
extends: _layouts.master
section: content
current_index: 2
---
# 2강 - 라라벨 5 설치하기 (on Windows)
Windows 사용자라면 Mac 으로 전환할 것을 권장한다. 필자는 10년도 훨씬 전에 Windows Server 2000 트랙에서 [MCSE](https://www.microsoft.com/en-us/learning/mcse-certification.aspx) 였었다. 무려 6 주에 걸쳐 매주 한 과목씩 총 6 과목을 시험을 봐야 했었다. 예전엔 Windows 밖엔 쓸 줄 몰랐단 얘기다.
어쨌든 시간이 지나면서 여러 OS 를 경험하고 난 후 필자가 느낀 바는 Windows 는 개인용 PC 운영체제로 나쁘지 않다는 점이다. 서버 운영체제로 쓸 때도 나쁘지 않다. 헌데 **개발자 용으로 쓸 때 Windows 는 Mac 대비 생산성 측면에서 아주아주아주 별로다**. 특히, 콘솔. 아래 그림은 캘리포니아의 어느 개발자 컨퍼런스 모습인데.. 아마 Windows 가 대세인 한국에서 다양한 개발 생산성 도구들을 개발했다면 아래 그림은 완전 역전되었을 지도 모르겠다.

## 개발 환경 셋업
시작하기 전에.. 사용자 계정이 한글이거나, 영문이더라도 사용자 계정에 공백이 있다면 반드시 새로운 사용자 계정을 만들고 아래 과정을 수행하시기 바란다.
> 홍길동 (x), user name (x), username (o)
### [이 코스에서 사용] 로컬 개발 환경
Windows 사용자에게도 2가지 옵션이 있다. 로컬 PC를 개발 머신으로 쓰거나, 다음 절에 설명하는 Homestead 를 쓰는 방법이다.
이 코스에서는 **로컬 PC를 개발환경으로 쓰는 것을 가정하고 설명**한다. 로컬 PC에 PHP, Mysql 이 설치되어 있지 않다면 [Bitnami Wamp](https://bitnami.com/stack/wamp) 를 이용해 설치하자.
Bitnami 를 이용해 설치한 PHP 실행기가 OS 환경변수에 등록되어 있지 않으므로 등록해 주어야 한다. 제어판 -> 시스템 -> 고급 -> 시스템 변수 또는 환경 변수에서 Path 부분을 찾은 다음, PHP 실행기의 경로를 등록한다. 필자의 경우 `C:\Bitnami\wampstack-5.5.30-0\php` 을 등록하였다. 열려 있던 코맨드 프롬프트 창이 있다면, 재 실행 해 주어야 방금 변경한 환경설정의 적용된다는 것을 꼭 기억하자.


그리고, 코맨드 프롬프트 대체 프로그램인 [Git Bash](https://git-for-windows.github.io/) 를 설치하자.
## **[OPTIONAL]** 공짜로 쓰는 개발 서버 "Homestead"
개발팀 구성원들이 동일한 개발 환경을 가지기 위해서, 또는 Production 과 유사한 환경에서 개발하기 위해서 Homestead 사용을 권장한다. Homestead 는 위에서 언급한 필요 확장 모듈이 기본 설치되어 있다.
설정법은 꽤나 까다로우니 [Homestead 설치 (on Windows)](02-install-homestead-windows.md) 를 참고하자.
## 라라벨이 동작하기 위한 PHP 버전 및 필요 모듈 조건 확인
라라벨을 설치하려는 개발환경 또는 서버가 아래 필요사항을 충족하는 지 확인한다.
- php 5.5.9 이상
- php Extensions
- OpenSSL
- PDO
- Mbstring
- Tokenizer
```bash
# Git Bash
$ php --version # PHP 5.5.30
$ php -m # Git Bash 에서는 파이프 (`|`) 연산자가 먹지 않아 `grep` 명령을 쓸 수 없다.
# Windows Command Prompt
\> php --version # PHP 5.5.30
\> php -m | findstr openssl
\> php -m | findstr pdo
\> php -m | findstr mbstring
\> php -m | findstr tokenizer
```
Bitnami Wamp 를 설치했다면 모두 설치되어 있을 것이다. 이미 PHP, Mysql 이 설치되어 있었던 경우에, 필요한 모듈 중 하나라도 빠진게 있다면 구글링해서 설치하자~

## 이제 라라벨을 설치해 보자.
라라벨 인스톨러를 사용할 것을 권장한다. 왜냐하면 Composer 를 이용해 설치하는 것 보다 훨~씬 빠르니까...
먼저, Composer 가 필요하다. 왜냐하면, 라라벨 인스톨러가 Composer 를 통해 배포되기 때문이다. [윈도우즈용 Composer 인스톨러](https://getcomposer.org/Composer-Setup.exe)를 다운로드 받아 설치하자. 설치 중에 PHP 경로를 물어본다면, 앞 절에서 설정한 경로로 찾아 들어가서 PHP 실행기를 선택해 준다.
```bash
\> composer --version # Composer version 1.xx
```

이제 Composer 를 이용해서 라라벨 인스톨러를 설치한다.
```bash
\> composer global require "laravel/installer"
```
`laravel` 코맨드를 어디서든 접근할 수 있게 Path 환경변수를 설정하자. 필자의 경우 `C:\Users\{username}\AppData\{Local|Roaming}\Composer\vendor\bin` 을 등록하였다. 환경 변수가 변경되었으면, 열려 있던 콘솔 창을 재 실행해 주어야 한다.
```bash
\> laravel --version # Laravel Installer version 1.2.1
```
휴~, 이제 설치를 위한 준비가 완료되었다. 라라벨 인스톨러로 라라벨 5를 설치하자.
```bash
\> laravel new myProject
\> cd myProject
\> php artisan --version # Laravel Framework version 5.x
```

서버를 부트업하고, 라라벨을 시작해 보자!
```bash
# 로컬 서버를 부트한다.
\> php artisan serve # 종료하기 ctrl+c
# Laravel development server started on http://localhost:8000/
```
브라우저에서 `http://localhost:8000` 페이지를 방문해서 'Laravel 5' 란 글씨가 써진 화면이 보인다면, 성공적으로 설치한 것이다.

**`참고`** `artisan` 은 라라벨의 코맨드 라인 툴이다. `\> php artisan` 을 실행한 후, 설명을 쭈욱~ 한번 살펴보자. 개발 중에 코드 에디터와 콘솔을 오가면서, `artisan` 코맨드를 많이 사용하게 될 것이다.
프로젝트의 디렉토리 구조와 라라벨의 동작 시퀀스 다이어그램은 Mac 용 설치 문서 "[2강 - 라라벨 5 설치하기](02-hello-laravel.md)"를 참조하자.
---
- [목록으로 돌아가기](../readme.md)
- [1강 - 처음 만나는 라라벨](01-welcome.md)
- [3강 - 글로벌 설정 살펴보기](03-configuration.md)
================================================
FILE: lessons/03-configuration.md
================================================
---
extends: _layouts.master
section: content
current_index: 3
---
# 3강 - 글로벌 설정 살펴보기
## Code Editor 와 DB Client
- [phpStorm](https://confluence.jetbrains.com/display/PhpStorm/PhpStorm+Early+Access+Program)(1달 Free)을 권장한다.
- [Sequel Pro](http://www.sequelpro.com/download)(Free)를 권장한다.
설치하자.
## .env
.env에 써진 값들을 config/\*\*.php 에서 `env(string $key)`로 읽을 수 있다. 왜 config/\*\*.php, 가령 database.php 에 직접 하드코드로 쓰지 않을까? 이유는...
- local, staging, production 등 어플리케이션 실행 환경에 따라 설정 값이 바뀌어야 할 때 유연하게 대응하기 위해서다.
- 패스워드 등 민감한 정보를 버전 컨트롤에서 제외하기 위해서다. (.gitignore 파일을 확인해 보자.)
```
APP_ENV=local # 실행환경
APP_DEBUG=true # 디버그 스위치
APP_KEY= # 32bit Application Key
```
.env 파일이 없다면, 생성하자.
```bash
$ cp .env.example .env
```
## Application Key
.env에 설정된 `APP_KEY` 값은 라라벨 프레임웍 전반에 걸쳐 Cipher 알고리즘에서 Seed 값으로 사용된다. 설정되어 있지 않다면 꼭 설정하자.
```bash
$ php artisan key:generate
```
## DB 에 연결하자.
먼저 프로젝트에 사용할 데이터베이스를 설정하자. 라라벨에서 .env 파일 수정만으로 DB 설정이 가능하다.
```
DB_HOST=localhost
DB_DATABASE=myProject
DB_USERNAME=homestead
DB_PASSWORD=secret
```
실제 DB 접속은 8강 부터 할 것이다. config 디렉토리 아래에 있는 다른 파일들도 살펴 보자.
**`참고`** Homestead 에 설치된 mySQL에 접속하려면, port를 33060으로 설정해야 한다.
---
- [목록으로 돌아가기](../readme.md)
- [2강 - 라라벨 5 설치하기](02-hello-laravel.md)
- [4강 - Routing 기본기](04-routing-basics.md)
================================================
FILE: lessons/04-routing-basics.md
================================================
---
extends: _layouts.master
section: content
current_index: 4
---
# 4강 - Routing 기본기
## 웰컴 뷰를 가지고 놀자
resources/views/welcome.blade.php 를 연다. 이 파일이 바로 2강에서 'Laravel 5'란 큰 글씨로 우리를 반겨 주었던 바로 그 뷰이다. 모든 내용을 지우고, "Hello World"를 쓴 후, 로컬서버를 부트업하고 브라우저에서 http://localhost:8000으로 접근해 보자.
```html
Hello World
```
```bash
$ php artisan serve
$ open http://localhost:8000 # 크롬브라우저에 주소를 직접 입력하라는 의미
```
**`참고`** 앞으로의 실습을 위해 iTerm2의 화면을 그림과 같이 분할하고, #1창에 `$ php artisan serve`, #2창에 `$ php artisan tinker`, #창은 빈 콘솔 로 띄워 놓을 것을 권장한다. 크롬브라우저에는 http://localhost:8000 을 띄워 놓자. 듀얼 모니터라면 한대에 브라우저, 한대에 코드에디터와 콘솔을 띄워 놓으면 좋다.
**`중요`** 실습 중에 .env 파일 또는 config/\*\*.php 파일 수정으로 환경 변수가 바뀌면 반드시 로컬 서버를 재실행 해 주어야 한다.

## Routing
여기에서 의문점? 분명 홈페이지(/)로 접근했고, welcome.blade.php 와 관련된 어떤 힌트도 제공하지 않았는데 어떻게 이 뷰가 로드되었을까? 요청 Url에 따라 적절한 패스로 연결시켜주는 것이 바로 Routing이 하는 역할이다.
app/Http/routes.php 를 열어보자.
```php
Route::get('/', function () {
return view('welcome');
});
```
'/' 요청이 오면, function 으로 싸진 Closure가 동작한다는 의미이다. Closure 안을 보면, view()라는 function에 'welcome'이란 인자를 넘겨서 반환된 값을 다시 반환한다. 'welcome'이란 인자는 resources/views/welcome.blade.php 란것을 알 수 있다. 즉, Closure에서 반환된 값이 Http 응답으로 전달된다.
`view(string $view)`가 아니라 스트링을 반환하면 어떻게 될까? 브라우저에 스트링이 출력된다.
```php
Route::get('/', function () {
return 'Hello World';
});
```
가령, 라라벨에 기본 내장 되어 있는 resources/views/errors/503.blade.php과 같이 하위 뷰를 응답하려면 어떻게 해야할까? 하위 디렉토리는 '.' 또는 '/'로 구분한다.
```php
Route::get('/', function () {
return view('errors.503');
});
```
**`참고`** `view()`는 Helper Function 이다. `return View::make('welcome')`와 같이 라라벨이 제공하는 Facade('파사드' 또는 '빠사드'라 읽는다.)를 이용할 수도 있다. 필자는 `view()->`까지 입력했을 때 코드힌트가 나와서 Helper Function을 더 선호한다. 말 나온 김에, Facade는 Static Access 형태를 빌려 쓰고 있지만, 실제로 백그라운드에서는 Service Container에 의해서 새로운 instance를 생성하여 메소드에 접근하므로, Anti Pattern이 아니다.
**`참고`** 방금 살펴본 resources/views/errors/503.blade.php 뷰는 라라벨 어플리케이션이 유지보수 모드에 들어갔을 때 사용자에게 보여주는 뷰이다. `$ php artisan down` 명령으로 유지보수 상태로 전환하고, `$ php artisan up` 으로 서비스 상태로 복귀할 수 있다. 유지보수 모드는 웹 서버를 중지 시킨 것은 아니다.
---
- [목록으로 돌아가기](../readme.md)
- [3강 - 글로벌 설정 살펴보기](03-configuration.md)
- [5강 - 뷰에 데이터 바인딩하기](05-pass-data-to-view.md)
================================================
FILE: lessons/05-pass-data-to-view.md
================================================
---
extends: _layouts.master
section: content
current_index: 5
---
# 5강 - 뷰에 데이터 바인딩하기
## 블레이드 템플릿 맛보기
resources/views/index.blade.php 를 만들고 아래와 같이 데이터를 바인딩 시켜 보자. 여기서 `{{ }}`은 라라벨의 템플릿 엔진인 블레이드에서 사용하는 String Interpolation 문법이다. 즉, 뷰 안에서 `= ?>`과 같은 역할을 해 주는 것이다.
```html
{{ $greeting }} {{ $name or '' }}. Welcome Back~
```
**`참고`** HTML 스트링등 특수문자가 포함된 데이터를 뷰에 바인딩 시킬 때는 {{ }}대신 {!! !!}을 사용한다. `{{ $name or '' }}`을 php 문법으로 컴파일 하면, 대략 `echo $name ? $name : '';`와 같다.
## with() 메쏘드로 뷰에 데이터 바인딩하는 방법
```php
Route::get('/', function () {
$greeting = 'Hello';
return view('index')->with('greeting', $greeting);
});
```
## with() 메쏘드로 한 개 이상의 데이터를 넘기는 방법
```php
Route::get('/', function () {
return view('index')->with([
'greeting' => 'Good morning ^^/',
'name' => 'Appkr'
]);
});
```
## view() 의 2번째 인자로 데이터를 넘기는 방법
```php
Route::get('/', function () {
return view('index', [
'greeting' => 'Ola~',
'name' => 'Laravelians'
]);
});
```
**`참고`** 실전에서는 `compact(mixed $varname)` php 내장 함수와 조합하여, `$greeting='World'; return view('index', compact('greeting'));`와 같은 식으로 많이 이용한다.
## view 인스턴스의 Property를 이용하는 방법
```php
Route::get('/', function () {
$view = view('index');
$view->greeting = "Hey~ What's up";
$view->name = 'everyone';
return $view;
});
```
---
- [목록으로 돌아가기](../readme.md)
- [4강 - Routing 기본기](04-routing-basics.md)
- [6강 - 블레이드 101](06-blade-101.md)
================================================
FILE: lessons/06-blade-101.md
================================================
---
extends: _layouts.master
section: content
current_index: 6
---
# 6강 - 블레이드 101
블레이드는 라라벨의 템플릿 엔진이다. 뷰 안에 포함된 블레이드 문법들은 블레이드 엔진에 의해 php 코드로 컴파일 된다.
## `{{ }}` - String interpolation
php echo 코맨드와 같은 역할을 해 준다.
```html
{{ $greeting }}
```
## `{{-- --}}` - Comment
HTML 주석으로 컴파일 된다. 그런데 브라우저에서 소스보기로 보면 엄연히 다르다.
```html
{{-- count(range(1, 10)) --}}
```
## `@foreach`
resources/views/index.blade.php 에서 `@foreach` 블레이드 문법을 사용해 보자.
```html
@foreach($items as $item)
{{ $item }}
@endforeach
```
당연히 `$items` 변수를 뷰로 넘겨줘야 한다. 어디서? 5강에서 배운 내용이다. app/Http/routes.php 에서...
```php
Route::get('/', function () {
$items = [
'Apple',
'Banana'
];
return view('index', compact('items'));
});
```
**`참고`** `@for`도 사용할 수 있다.
## `@if`
resources/views/index.blade.php 에서 `@if` 블레이드 문법을 사용해 보자.
```html
@if($itemCount = count($items))
There are {{ $itemCount }} items !
@else
There is no item !
@endif
```
**`참고`** `@elseif` 당연히 된다. `@unless (== if(!))`도 사용할 수 있다.
## `@forelse`
resources/views/index.blade.php 에서 `@forelse` 블레이드 문법을 사용해 보자. `@forelse`는 `@if`와 `@foreach`의 결합이다. 뷰로 넘어온 변수에 값이 있고 ArrayAccess를 할 수 있으면 , `@forelse`를 타고 그렇지 않으면 `@empty`를 탄다. 자주 이용하게 되니 잘 기억해 두자.
```
@forelse($items as $item)
The item is {{ $item }}
@empty
There is no item !
@endforelse
```
`@forelse` 위에서 ``로 변수를 오버라이드하고 다시 실험해 보자.
---
- [목록으로 돌아가기](../readme.md)
- [5강 - 뷰에 데이터 바인딩하기](05-pass-data-to-view.md)
- [7강 - 블레이드 201](07-blade-201.md)
================================================
FILE: lessons/07-blade-201.md
================================================
---
extends: _layouts.master
section: content
current_index: 7
---
# 7강 - 블레이드 201
## @yield, @extends, @section - 마스터 레이아웃 사용하기
페이지마다 반복되는 헤더랑 풋터는 어디에 넣지? 이때 필요한 것이 마스터 레이아웃이다. resources/views/master.blade.php 파일을 만들고, 아래와 같이 HTML 뼈대를 만들어 보자.
```html
Laravel 5 Essential
@yield('content')
```
**`참고`** phpStorm은 [Emmet](http://docs.emmet.io/)을 지원한다. 위 파일에서 `!` 만 입력한 상태에서 Tab을 누르면 HTML 기본 뼈대가 채워진다. ul>li*3>aTab 을 한번 해 보면 Emmet 문법을 왜 익혀야 하는지 금방 알 것이다.
resources/views/index.blade.php는 다음과 같이 수정하고, 브라우저에서 확인해 보자. HTML 소스도 반드시 확인해 보자.
```html
@extends('master')
@section('content')
Your content here !!!
@stop
```
여기서 `@extends`라는 키워드는 index.blade.php가 master.blade.php를 상속한다는 뜻이다. 또 `@yield('content')` 와 `@section('content')`를 주목하자. content라고 이름 지어진 섹션이 마스터 레이아웃에서 yield('양도하다', '넘겨주다' 라는 뜻) 된다는 의미다. 섹션은 원하는 숫자만큼 여러개 만들 수 있다. 뷰에서 만든 섹션의 이름으로 마스터 레이아웃에서 yield해 주기만 하면 된다. 가령, 이런 것이 가능해 진다.
```html
@extends('master')
@section('style')
@stop
@section('content')
Your content here !!!
@stop
@section('script')
@stop
```
## @include - 하위 뷰 포함하기
resources/views/footer.blade.php를 만들자.
```html
```
resources/views/master.blade.php 또는 resources/views/index.blade.php 어디서든 방금 만든 footer 뷰를 불러와 삽입할 수 있다.
```html
@include('footer')
```
---
- [목록으로 돌아가기](../readme.md)
- [6강 - 블레이드 101](06-blade-101.md)
- [8강 - 날 쿼리 :(](08-raw-queries.md)
================================================
FILE: lessons/08-raw-queries.md
================================================
---
extends: _layouts.master
section: content
current_index: 8
---
# 8강 - 날 쿼리 :(
## 사용할 테이블을 만들자
[3강 - 글로벌 설정 살펴보기](03-configuration.md)에서 .env 파일에 설정한 내용으로 posts 테이블을 만들어 보자.
> **주의**
>
> 홈스테드 환경 사용자는 데이터베이스 관련 모든 명령을, 호스트 컴퓨터의 쉘이 아니라 홈스테드 쉘에서 수행해야 한다. 뒤에 나올 마이그레이션 등도 마찬가지로 홈스테스 쉘에서 수행해야 한다.
```bash
# 아래 명령들은 이런 내용으로 만든다는 내용을 보여주기 위한 것이며 실제로는 GUI 툴로 해도 무방하다.
$ mysql -uroot
mysql > CREATE DATABASE myProject;
mysql > CREATE USER 'homestead' IDENTIFIED BY 'secret';
mysql > GRANT ALTER, CREATE, INSERT, SELECT, DELETE, REFERENCES, UPDATE, DROP, EXECUTE, LOCK TABLES, INDEX ON myProject.* TO 'homestead';
mysql > FLUSH PRIVILEGES;
mysql > exit (enter)
$ mysql -uhomestead -p
mysql > CREATE TABLE posts(
-> id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
-> title VARCHAR(255),
-> body TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
mysql > INSERT INTO posts(title, body) VALUES('My Title', 'My Body');
mysql > exit (enter)
```
mySql에 root로 로그인하여 myProject DB를 만들고, homestead 사용자에 대해 myProject DB에 대한 접근 권한 부여를 반드시 해야 한다. 아래 그림은 Sequel Pro 에서 권한 부여Cmd + U하는 과정이다.

## 라라벨을 이용해서 DB 쿼리를 해 보자.
**`참고`** 실제로 이렇게 사용하는 경우는 거의 없으니 참고만 하자.
쿼리를 배우기 위해 라라벨에서 제공하는 REPL 을 이용하자. tinker('어설프게 손보고 고치다' 라는 뜻)라고 불리는 artisan 코맨드인데, 라라벨의 모든 환경이 제공되기 때문에 여러가지 실험적인 시도들을 해보기 편리하다.
```bash
$ php artisan tinker
Psy Shell v0.5.2 (PHP 5.6.7 — cli) by Justin Hileman
>>> (cursor)
```
posts 테이블을 가져와 보자.
```bash
>>> DB::select('select * from posts');
=> [
{#676
+"id": 1,
+"title": "My Title",
+"body": "My Body",
},
]
```
레코드를 더 추가하자. 라라벨은 PDO를 이용하기 때문에 ? 처럼 같이 데이터를 바인딩해 줘야 한다.
```bash
>>> DB::insert('insert into posts(title, body) values(?, ?)', ['Second Title', 'Second Body']);
=> true
```
Collection이 아니라 하나의 Instance만 얻으려면 어떻게 해야 할까?
```bash
>>> $post = DB::selectOne('select * from posts where id = ?', [1]);
=> {#689
+"id": 1,
+"title": "My Title",
+"body": "My Body",
}
>>> $post->title;
=> "My Title"
```
업데이트도 해 보자.
```bash
>>> DB::update('update posts set title="Modified Title" where id = ?', [2]);
=> 1
```
---
- [목록으로 돌아가기](../readme.md)
- [7강 - 블레이드 201](07-blade-201.md)
- [9강 - 쿼리 빌더](09-query-builder.md)
================================================
FILE: lessons/09-query-builder.md
================================================
---
extends: _layouts.master
section: content
current_index: 9
---
# 9강 - 쿼리 빌더
SQL 문을 php 코드로 쓴 거라고 보면 된다. 지금은 그냥 SQL로 쓰면 될 것, 더 길고 복잡한 코드로 쓰지?라고 반문할 수 있지만.. 차차 그 편리성을 알게 되니 무작정 따라해 보자.
## 쿼리 빌더를 이용해 보자.
`DB` Facade와 `table()`, `get()` 메소드를 이용하여 Collection(레코드셋)을 가져 와 보자.
```bash
$ php artisan tinker
>>> DB::table('posts')->get(); # SELECT * FROM posts
=> [
{#679
+"id": 1,
+"title": "My Title",
+"body": "My Body",
},
{#680
+"id": 2,
+"title": "Modified Title",
+"body": "Second Body",
},
]
```
`first()`, `find()` 메소드로 Instance(레코드)를 가져오자.
```bash
>>> DB::table('posts')->find(2);
=> {#688
+"id": 2,
+"title": "Modified Title",
+"body": "Second Body",
}
>>> DB::table('posts')->first();
=> {#671
+"id": 1,
+"title": "My Title",
+"body": "My Body",
}
```
`where()` 예상대로 where 절을 사용하는 거다.
```bash
# 모두 동일한 쿼리이다. where()에서 = 연산자는 생략 가능하다.
>>> DB::table('posts')->where('id', '=', 1)->get();
>>> DB::table('posts')->where('id', 1)->get();
>>> DB::table('posts')->whereId(1)->get();
=> [
{#692
+"id": 1,
+"title": "My Title",
+"body": "My Body",
},
]
```
**`참고`** `whereId()`는 Dynamic Method이다.
**`참고`** `where()`에 Closure를 쓸 수도 있다 `where(function($query) {$query->where('field', 'operator', 'value);})`
`select()`를 이용하여 필요한 필드만 가져오자.
```bash
>>> DB::table('posts')->select('title')->get();
=> [
{#686
+"title": "My Title",
},
{#678
+"title": "Modified Title",
},
]
```
자주 쓰는 메소드들이다. 공식 문서를 보면 알겠지만, `count()`, `distinct()`, `select(DB::raw('count(*) as cnt'))`, `join()`, `union()`, `whereNull()`, `having()`, `groupBy()`, ... 표현하지 못하는 SQL 문장은 없다고 보면 된다.
- `insert(array $value)`
- `update(array $values)`
- `delete(int $id)`
- `lists(string $column)`
- `orWhere(string $column, string $operator, mixed $value)`
- `limit(int $value)` // == `take(int $value)`
- `orderBy(string $column, string $direction)`
- `latest()` // == `orderBy('created_at', 'desc')`
---
- [목록으로 돌아가기](../readme.md)
- [8강 - 날 쿼리 :(](08-raw-queries.md)
- [10강 - 엘로퀀트 ORM](10-eloquent.md)
================================================
FILE: lessons/10-eloquent.md
================================================
---
extends: _layouts.master
section: content
current_index: 10
---
# 10강 - 엘로퀀트 ORM
엘로퀀트는 라라벨의 ORM (Object Relational Mapper, Active Record Pattern 의 구현체)이다. 데이터베이스는 테이블간 관계를 가지고 있다. 데이터베이스를 추상화한 모델 클래스 간에 관계를 맺어 주는 구현체를 일반적으로 ORM이라 칭한다. 뿐만 아니라 라라벨에서는 config/database.php에 의해 설정된 DB Driver와 ORM 사용으로 인해 데이터베이스와 어플리케이션 간에 디커플링 효과도 얻게 된다. 어플리케이션 코드 수정 한 줄 없이 mySQL을 SQLite로 바꿀 수 있다는 의미이다.
## 모델
라라벨이 MVC 프레임웍인데 우리가 이제까지 본 것은 V(뷰) 뿐이었다. 드디어 M에 해당하는 모델을 볼 차례이다. authors 라는 테이블을 생성하자.
```bash
$ mysql -uhomestead -p
mysql > CREATE TABLE authors(
-> id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
-> email VARCHAR(255) NOT NULL,
-> password VARCHAR(60) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
mysql > INSERT INTO authors(email, password) VALUES('john@example.com', 'password');
```
**`참고`** 이번까지는 수작업으로 테이블을 생성했다. 다음 번 부터는 migration을 통해 테이블을 생성하고, seed를 통해 데이터를 생성할 것이다.
모델을 생성하자. artisan CLI에서 제공하는 Generator 를 이용하자. 코맨드를 수행한 후 app/Post.php, app/Author.php 파일이 생성되었는 지 확인하자.
```
$ php artisan make:model Post
$ php artisan make:model Author
```
**`참고`** 테이블 이름을 users 와 같이 복수로, 모델 이름은 User 처럼 단수로 하는 것이 규칙이다. DB 테이블은 Collection을 담고 있고, 모델은 DB 테이블에 담긴 하나의 레코드를 클래스로 표현한 것이기 때문이다. 규칙을 지킬 수 없는 경우에는, 가령 모델명을 Author, 테이블을 users 라 했을 경우, 라라벨이 둘 간의 관계를 알 수 있도록 Author 모델에 `protected $table = 'users';` 코드를 추가해 주어야 한다.
## 처음 만나는 엘로퀀트 쿼리
전체 Collection을 가져오자. 이번엔 `DB` Facade를 이용하지 않고, `App\Author` 모델을 이용한 것을 눈여겨 보자.
```bash
$ php artisan tinker
>>> App\Author::get(); # == DB::table('authors')->get();
=> Illuminate\Database\Eloquent\Collection {#677
all: [
App\Author {#678
id: 1,
email: "john@example.com",
password: "password",
},
],
}
```
**`참고`** 쿼리 빌더 에서 사용할 수 있는 대부분의 메소드는 Eloquent Model에서도 사용할 수 있다. 예를 들면, `App\Author::orderBy('id', 'desc')->limit(1)->lists('email');`와 같이. 사실, 이번 강좌에서 보는 것은 엘로퀀트를 상속한 모델을 이용해서 쿼리 빌더를 쓸 수 있다는 정도이다. 엘로퀀트의 전부가 아니란 얘기다. 엘로퀀트 ORM의 꽃은 모델간의 관계 맺기 이며, 18강에서 다루고 있다.
새로운 Instance를 생성하고 데이터베이스에 저장해 보자.
```bash
$ php artisan tinker
>>> $author = new App\Author;
>>> $author->email = 'foo@bar.com';
>>> $author->password = 'password';
>>> $author->save(); # 메모리에만 존재하던 인스턴스를 데이터베이스에 저장한다.
# Illuminate\Database\QueryException with message '... updated_at in ...'
```
## QueryException
`save()` 메소드 호출에서 예외가 발생했을 것이다. 엘로퀀트는 모든 모델이 `updated_at`과 `created_at` 필드가 있다고 가정하고, 새로운 Instance가 생성될 때 현재의 timestamp값을 입력하려한다. 그런데, 위 테이블들은 수작업으로 만든 테이블이라 앞서 말한 필드들이 존재하지 않는다. 방법은 필드를 추가하는 방법과, timestamp 입력을 모델에서 끄는 방법이 있는데, 실습을 위해 일단 끄자.
```php
// Post 모델도 적용해 주자.
class Author extends Model
{
public $timestamps = false;
}
```
**`중요`** **_모델에 변경이 생기면 실행중이던 tinker 를 다시 실행해 주어야 한다._** tinker 가 로드될 시점에 라라벨 구동을 위한 모든 환경이 로드되므로, 이후 변경을 반영하기 위함이다. ctrl + C 또는 `>>> exit` 명령으로 종료할 수 있다. tinker를 재실행 한 후 up 화살표로 이전 코맨드 이력을 탐색할 수 있다.
```bash
>>> $author = new App\Author;
>>> $author->email = 'foo@bar.com';
>>> $author->password = 'password';
>>> $author->save();
=> true
```
## 다른 메소드를 이용한 모델 생성
이번에는 `save()` 대신 `create()` 메소드를 이용할 것이다.
```bash
$ php artisan tinker
>>> App\Author::create([
... 'email' => 'bar@baz.com',
... 'password' => bcrypt('password')
... ]);
# Illuminate\Database\Eloquent\MassAssignmentException with message 'email'
```
**`참고`** `bcrypt(string $value)` 은 암호화된 60byte스트링를 만들어 준다. Facade로 쓰면 `Hash::make(string $value)` 와 같다.
## MassAssignmentException
timestamps를 무력화 시킨 후에도, `create()` 메소드를 이용할 때는 에러가 발생했다. `create()` 메소드로 모델 인스턴스를 생성할 때는 해당 모델에 `$fillable` 속성을 지정해 주어야 한다. 폼을 통해 사용자가 넘긴 값을 그대로 DB에 넣을 경우를 대비해, 악의적인 필드가 입력되는 것을 방지하기 위한 조치이다. Post와 Author 모델을 열고 `$fillable` 속성을 지정하자.
```php
class Author extends Model
{
protected $fillable = ['email', 'password'];
}
```
```php
class Post extends Model
{
protected $fillable = ['title', 'body'];
}
```
tinker 를 재시작하고 up 키를 눌러, `create()` 메소드를 다시 실행해 보자.
```bash
$ php artisan tinker
>>> App\Author::create([
... 'email' => 'bar@baz.com',
... 'password' => bcrypt('password')
... ]);
=> App\Author {#680 # bcrypt() Helper에 의해 암호화된 60 byte 패스워드를 확인하자.
email: "bar@baz.com",
password: "$2y$10$tL/9voTNRtH7dfE9yULVaOybUWTcNkLRws9gTawcU85L3PEwRotUS",
id: 3,
}
```
---
- [목록으로 돌아가기](../readme.md)
- [9강 - 쿼리 빌더](09-query-builder.md)
- [11강 - DB 마이그레이션](11-migration.md)
================================================
FILE: lessons/11-migration.md
================================================
---
extends: _layouts.master
section: content
current_index: 11
---
# 11강 - DB 마이그레이션
마이그레이션은 데이터베이스를 위한 버전 컨트롤이라 생각하면 된다. 처음 테이블을 생성하고, 가령 이후에 새로운 필드를 추가한다든지, 필드의 이름을 바꾼다든지 등의 이력을 모두 마이그레이션 코드로 남겨 두고, 테이블을 생성했다가 롤백하는 등 자유롭게 이용할 수 있다.
마이그레이션 코드 작성은 정말 지루한 일이다. 토이(toy) 프로젝트를 하는데 마이그레이션을 굳이 작성할 필요는 없다. 하지만, 대형 서비스라면 테이블 스키마를 변경해야 할 수도 있는 새로운 요구사항이 생길 수 있다. 이때 기초공사를 잘못해 두었다면, 개발자에게 엄청난 위기 상황이 닥칠 수도 있는데, 마이그레이션이 위기에서 개발자를 구해주는 데 도움을 줄 것이다. 정말로~ 그리고, 팀으로 여러 명이 테이블 스키마를 변경해 가면서 개발할 때는, `mysqldump` 해서 주고 받는 수고를 피하기 위해 꼭 필요하다.
라라벨에서는 데이터베이스 스키마를 코드로 생성하기 위한 `Blueprint` 클래스를 제공하고 있다.
## Migration 을 만들자.
먼저 기존에 만든 posts, authors 테이블들을 삭제하자.
```bash
$ mysql -u homestead -p
mysql> SET FOREIGN_KEY_CHECKS = 0;
mysql> DROP TABLE posts;
mysql> DROP TABLE authors;
mysql> SET FOREIGN_KEY_CHECKS = 1;
```
artisan CLI 를 이용해 마이그레이션을 만들자.
```bash
$ php artisan make:migration create_posts_table
$ php artisan make:migration create_authors_table
```
database/migrations 디렉토리에 timestamp_create_xxx_table 이란 2개의 마이그레이션이 생성된 것을 확인하자. database/migrations/timestamp_create_posts_table 을 열어보면 `up()`, `down()` 2개의 메소드가 생성된 것을 확인할 수 있다. `up()` 은 마이그레이션을 실행할 때 동작하는 메소드이고 (`$php artisan migrate`), `down()`은 직전 마이그레이션을 롤백 하기 위한 메소드이다 (`$ php artisan migrate:rollback`).
```php
// CreateAuthorsTable 도 작성하자.
class CreatePostsTable extends Migration
{
public function up()
{
Schema::create('posts', function($table) {
$table->increments('id'); // id INT AUTO_INCREMENT PRIMARY KEY
$table->string('title', 100); // title VARCHAR(100)
$table->text('body'); // body TEXT
$table->timestamps(); // created_at TIMESTAMP, updated_at TIMESTAMP
});
}
public function down()
{
Schema::dropIfExists('posts'); // DROP TABLE posts
}
}
```
`up()` 에서는 `Schema` Facade 의 `create()` 메소드를 이용하는데, 첫번째 인자는 테이블 이름, 두번째 인자는 콜백이다. 콜백은 `$table`이란 Blueprint 인스턴스를 주입하며, `string()`, `text()`, `integer()`, `timestamp()` 등 데이터베이스의 데이터 타입에 해당하는 다양한 메소드를 제공한다.
마이그레이션을 실행해 보자.
```bash
$ php artisan migrate
# Migration table created successfully.
# Migrated: 2014_10_12_000000_create_users_table
# Migrated: 2014_10_12_100000_create_password_resets_table
# Migrated: 2015_11_10_080603_create_posts_table
# Migrated: 2015_11_10_080609_create_authors_table
```
테이블이 정상적으로 생성되었는 지 확인하자.
```bash
$ mysql -u homestead -p
mysql> use myProject;
mysql> describe posts;
mysql> describe authors;
```
**`참고`** 마이그레이션을 처음 실행하면, migrations, users, password_resets 등과 같이 라라벨이 기본 내장된 마이그레이션도 같이 실행되어 해당 테이블들이 같이 생성된다.
## 롤백해 보자.
```bash
$ php artisan migrate:rollback
# 롤백되는 것이 잘 확인되었으면 다음 실습을 위해 다시 마이그레이션하자.
$ php artisan migrate
```
## 필드를 추가
authors 테이블에 name 필드를 추가하는 것을 깜빡했다고 가정하자. 물론, 전체 롤백을 하고, 최초 테이블 생성 마이그레이션에 name 필드를 추가한 뒤 마이그레이션을 다시 실행할 수 도 있다. 그런데, 만약 테이블에 데이터가 있다면... 난감해 진다. 필드를 추가하는 마이그레이션을 작성해 보자.
```bash
$ php artisan make:migration add_name_to_authors_table
```
```php
use Illuminate\Database\Schema\Blueprint;
class AddNameToAuthorsTable extends Migration
{
public function up()
{
Schema::table('authors', function(Blueprint $table) {
$table->string('name')->after('email')->nullable(); // nullable()은 NULL 을 허용한다는 얘기
});
}
public function down()
{
Schema::table('authors', function(Blueprint $table) {
$table->dropColumn('name');
});
}
}
```
테이블을 새로 생성할 때 쓰던 `create()`가 아니라, 이미 만들어진 테이블에 스키마를 변경하는 것이라 `table()` 메소드를 쓴 것에 주목하자. `after()`는 mySql에서만 쓸 수 있는 메소드로 인자로 넘겨 받은 필드 다음에 새로운 필드를 추가해 준다.
마이그레이션을 실행하고, 필드가 추가되었는 지 확인해 보자.
```bash
$ php artisan migrate
```
**`팁`** 이번 마이그레이션에서는 Closure Function 인자에 Blueprint 라고 TypeHint 를 썼다. TypeHint 를 쓰면 phpStorm 에서 `->` 로 코드 힌트를 볼 수 있어서 편리하다.
**`참고`** `Blueprint` 의 풀 네임스페이스는 `\Illuminate\Database\Schema\Blueprint` 이다. 코드 내에 두 군데 이상 같은 클래스가 쓰인다면 `use` 키워드로 import 해 주어야 한다. phpStorm 은 Preference 셋팅에 따라 Blueprint 타이핑이 끝나고 나면 자동으로 import 를 해 주어 편리한데, 혹 자동 import 가 안되었다면 `Blueprint` 위에 커서를 놓고 option + Enter 을 눌러 컨텍스트 메뉴를 띄운 후 import 할 수도 있다.
## Reset & Refresh
`migrate:rollback` 이 직전 마이그레이션만 롤백하는 반면 `migrate:reset` 는 모든 마이그레이션을 롤백하고 데이터베이스를 초기화 시킨다. `migrate:refresh` 는 리셋을 실행해서 데이터베이스를 청소한 후, 마이그레이션을 처음부터 다시 실행하는 코맨드이다.
---
- [목록으로 돌아가기](../readme.md)
- [10강 - 엘로퀀트 ORM](10-eloquent.md)
- [12강 - 컨트롤러](12-controller.md)
================================================
FILE: lessons/12-controller.md
================================================
---
extends: _layouts.master
section: content
current_index: 12
---
# 12강 - 컨트롤러
MVC에서 V(뷰)와 M(모델)을 살펴보았다. 이제 마지막 콤포넌트인 C(컨트롤러)를 살펴볼 차례이다.
## Revisit Route
이제까지 모든 HTTP 요청에 대한 처리를 app/Http/routes.php의 Route Closure에서 처리 했다. Router의 역할은 HTTP 요청을 적절한 처리 로직으로 연결시켜주는 것, 즉 컨트롤러에 연결시켜주는 것이다. app/Http/routes.php에 컨트롤러를 이용한 Route를 만들어 보자.
```php
Route::get('/', 'IndexController@index');
```
HTTP GET / 요청이 들어오면, `IndexController`의 `index()` 메소드로 연결시키라는 뜻이다.
## Controller를 만들자.
서버를 부트업하고, '/' 경로로 접근해 보자. `ReflectionException - Class App\Http\Controllers\IndexController does not exist` 란 메시지가 출력되었을 것이다. IndexController가 없기 때문이다. artisan CLI를 이용해서 만들자.
```bash
$ php artisan make:controller IndexController
```
app/Http/Controllers/IndexController 가 생성된 것을 확인하자. `index()` 메소드를 만들자.
```php
class IndexController extends Controller
{
public function index() {
return view('index');
}
}
```
서버를 부트업하고, / 경로로 접근하여, 정상적으로 뷰가 표시된 것을 확인하자. 4강에서 Route에 Closure에 넣은 내용과 `index()` 메소드의 내용이 동일하다는 것을 인지했을 것이다. Route에 모든 비즈니스 로직을 넣을 수 없을 뿐더러, 코드를 효율적으로 구조화시키기 위해서 컨트롤러를 사용하여 구조화한 것이라 생각하면 된다.
---
- [목록으로 돌아가기](../readme.md)
- [11강 - DB 마이그레이션](11-migration.md)
- [13강 - RESTful 리소스 컨트롤러](13-restful-resource-controller.md)
================================================
FILE: lessons/13-restful-resource-controller.md
================================================
---
extends: _layouts.master
section: content
current_index: 13
---
# 13강 - RESTful 리소스 컨트롤러
REST는 이 코스 범위를 넘어가는 내용이니, 시간 날 때 구글링을 통해서 공부하자. 모든 HTTP 요청 Url 엔드포인트에 대해서 `IndexController@method` 와 같이 연결하면 Route 정의만으로도 수백, 수천 줄이 될 수 있다. REST 원칙에 따라 리소스 이름으로된 Url 엔드포인트를 정의하고, `@method` 없이 컨트롤러에 연결시키는 것이 리소스 컨트롤러라고 이해하고 넘어가자.
## RESTful Resource Route
아래는 Post 모델에 대한 Url 엔드포인트를 'posts'라 했을 때, REST 원칙에 따라 라라벨이 자동으로 생성해 주는 Url 엔드포인트와 PostsController의 메소드간의 연결을 표로 표현한 것이다.
Verb|Endpoint|Method Override|Controller Method|Description
---|---|---|---|---
GET|/posts/| |`index()`|Post 모델 Collection 보기
GET|/posts/{id}| |`show()`|id를 가지는 Post Instance 보기
GET|/posts/create| |`create()`|새로운 Post Instance 생성을 위한 폼
POST|/posts| |`store()`|새로운 Post Instance 생성
GET|/posts/{id}/edit| |`edit()`|id를 가진 Post Instance 업데이트 폼
POST|/posts/{id}|`_method=PUT` `(x-http-method-override: PUT)`|`update()`|id를 가진 Post Instance 업데이트
POST|/posts/{id}|`_method=DELETE` `(x-http-method-override: DELETE)`|`delete()`|id를 가진 Post Instance 삭제
app/Http/routes.php에 posts 경로에 대한 resource 라우트를 정의하자. 그간 배웠던 `get()` 메소드가 아닌, `resource()`란 메소드를 쓰는 것에 유의하자.
```php
Route::resource('posts', 'PostsController');
```
artisan CLI 로 Route 목록을 확인해 보자.
```bash
$ php artisan route:list
# ReflectionException - Class App\Http\Controllers\PostsController does not exist
```
## RESTful Resource Controller 만들기
artisan CLI 로 PostsController를 만들자. 이번엔 --plain 옵션이 빠진다.
```bash
$ php artisan make:controller PostsController --resource
# Route 목록을 다시 확인해 보자.
$ php artisan route:list
```

## 테스트
app/Http/Controller/PostsController.php 가 만들어졌는지 확인하자. PostsController의 각 메소드에 Dummy 반환값을 넣고 RESTful 라우트와 컨트롤러가 잘 동작하는지 확인해 보자.
```php
class PostsController extends Controller
{
public function index()
{
return '[' . __METHOD__ . '] ' . 'respond the index page';
}
public function create()
{
return '[' . __METHOD__ . '] ' . 'respond a create form';
}
public function store(Request $request)
{
return '[' . __METHOD__ . '] ' . 'validate the form data from the create form and create a new instance';
}
public function show($id)
{
return '[' . __METHOD__ . '] ' . 'respond an instance having id of ' . $id;
}
public function edit($id)
{
return '[' . __METHOD__ . '] ' . 'respond an edit form for id of ' . $id;
}
public function update(Request $request, $id)
{
return '[' . __METHOD__ . '] ' . 'validate the form data from the edit form and update the resource having id of ' . $id;
}
public function destroy($id)
{
return '[' . __METHOD__ . '] ' . 'delete resource ' . $id;
}
}
```
테스트를 위해 [PostMan 크롬 확장 프로그램](https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop)을 사용할 것을 권장한다. 이 문서의 표 대로 하나씩 대입해 보자. PostMan에서 GET을 선택하고 http://localhost:8000/posts, http://localhost:8000/posts/1, http://localhost:8000/posts/1/edit.

그럼, HTTP 요청 메소드를 POST로 바꾸고, http://localhost:8000/posts를 해보자.
## TokenMismatchException
라라벨은 CSRF(Cross Site Request Forgery) 공격을 방지하기 위해 기존 데이터를 변경하는 행위, 즉, 신규 생성, 업데이트, 삭제 등의 행위에 대해서는 CSRF 토큰을 폼요청에서 제공해야 한다. 가령 `PostsController@create` 메소드에서 응답한 모델 생성 폼에서 숨은 필드로 `_token` 값을 제공해야 한다. 폼 요청을 받은 `PostsController@store` 메소드는 토큰의 유효성을 확인하고, 같은 세션일 경우, 즉, `create()`를 요청한 클라이언트와 `store()`를 요청한 클라이언트가 동일할 경우에만 `store()` 액션을 수행한다. 지금 우리가 PostMan을 통해서 테스트하는 행위 자체가 CSRF 공격이라 볼 수 있다.
우선 이번 테스트를 위해 CSRF 보호기능을 잠시 끄도록 하자. app/Http/Middleware/VerifyCsrfToken.php를 아래 처럼 수정한다.
```php
class VerifyCsrfToken extends BaseVerifier
{
protected $except = [
'posts',
'posts/*'
];
}
```
POST http://localhost:8000/posts가 정상 동작하는 것을 확인한 후, 이번에는 POST http://localhost:8000/posts/1 으로 요청해 보자. 또 에러가 날 것이다.
## MethodNotAllowedHttpException
브라우저들은 PUT, DELETE 등의 HTTP 동사(==메소드)를 지원하지 않는다. 즉, 브라우저에서는 PUT, DELETE등의 요청을 할 수 없다는 얘기다. 그럼에도 불구하고, REST 원칙을 지키기 위해서 라라벨 뿐 아니라 대부분의 웹 프레임웍들이 메소드 오버라이딩을 사용한다. POST로 폼 전송을 하되 숨은 필드로 `_method=PUT` 등과 같이 "내 비록 POST로 요청하지만 이건 PUT 요청이오~" 라고 서버 프레임웍에게 힌트를 주는 방법이다.
http://localhost:8000/posts/1 로 PUT, DELETE 요청을 하기 위해서는 폼데이터로 `_method=PUT`, `_method=DELETE` 를 추가해 주어야 한다. PostMan에 Body라 써진 탭을 열고, form-data 에 Key:"_method", Value:"PUT"을 각각 입력한 후 다시 테스트해 보자.
**`참고`** 숨은 필드를 이용하지 않고 `x-http-method-override: DELETE` 와 같이 HTTP Header를 이용해서 메소드 오버라이딩을 할 수도 있다.
모든 테스트가 끝났으면, `VerifyCsrfToken` 클래스에서 예외 처리 했던 것을 원복 시키자.
```php
class VerifyCsrfToken extends BaseVerifier
{
protected $except = [];
}
```
---
- [목록으로 돌아가기](../readme.md)
- [12강 - 컨트롤러](12-controller.md)
- [14강 - 이름 있는 Route](14-named-routes.md)
================================================
FILE: lessons/14-named-routes.md
================================================
---
extends: _layouts.master
section: content
current_index: 14
---
# 14강 - 이름 있는 Route
Named Routes는 여러모로 유용하다. 컨트롤러에서 `redirect(string $to)` Helper Function 으로 이동할 Url을 만들거나, 뷰 안에서 다른 Url로 이동하는 링크를 만들 때, 하드코드로 Url을 써 놓는 것 보다 여러 모로 관리상 편리하다. 가령, posts 라는 Url 엔트포인트를 어느날 갑자기 articles 로 모두 바꾸어야 한다고 생각해 보라. 모든 컨트롤러와 뷰를 찾아 다니면서 Url을 변경하는 것이 얼마나 귀찮을까?
## Route 에 이름을 주자
app/Http/routes.php 에서 공부해 보자. Route 메소드의 두번째 인자로 배열을 사용하고, 배열의 키로 'as'를 사용하여 라우트의 이름을 지정한다. 컨트롤러로의 연결은 'uses' 키를 사용한다.
```php
Route::get('posts', [
'as' => 'posts.index',
'uses' => 'PostsController@index'
]);
```
`$ php artisan route:list`로 Name 컬럼을 확인해 보자. 이제 컨트롤러나 뷰에서 'posts.index'란 이름을 사용할 수 있다. 가령 `return redirect(route('posts.index'))` 또는 `목록으로 돌아가기`와 같은 식으로 말이다.
```bash
$ php artisan tinker
>>> route('posts.index');
=> "http://localhost/posts"
```
어 틀린데... 포트번호 8000이 빠졌잖아. 클라이언트가 브라우저일 경우 host와 port를 자동으로 인지하지만, 코맨드 라인에서는 HTTP 환경 변수가 없기 때문이다. `config/app.php`을 수정하자.
```php
'url' => 'http://localhost:8000',
```
Closure로 쓸 때도 이름을 부여할 수 있다.
```php
Route::get('posts', [
'as' => 'posts.index',
function() {
return view('posts.index');
}
]);
```
## RESTful Resource Route의 이름
```php
Route::resource('posts', 'PostsController');
```
`$ php artisan route:list` 로 확인해 보자. Route 이름이 자동으로 부여 된다. 이제 팅커링 해 보자.
```bash
$ php artisan tinker
>>> route('posts.index');
=> "http://localhost:8000/posts"
>>> route('posts.store');
=> "http://localhost:8000/posts"
>>> route('posts.create');
=> "http://localhost:8000/posts/create"
>>> route('posts.destroy', 1);
=> "http://localhost:8000/posts/1"
>>> route('posts.show', 1);
=> "http://localhost:8000/posts/1"
>>> route('posts.update', 1);
=> "http://localhost:8000/posts/1"
>>> route('posts.edit', 1);
=> "http://localhost:8000/posts/1/edit"
```
**`참고`** 중복된 Route의 경우, 항상 위에 정의된 것이 아래에 정의된 것을 오버라이드 한다. 가령 posts/count 라는 Route가 있다면 RESTful Resource 정의보다 먼저(== 위에) 정의하는게 안전하다.
---
- [목록으로 돌아가기](../readme.md)
- [13강 - RESTful 리소스 컨트롤러](13-restful-resource-controller.md)
- [15강 - 중첩된 리소스](15-nested-resources.md)
================================================
FILE: lessons/15-nested-resources.md
================================================
---
extends: _layouts.master
section: content
current_index: 15
---
# 15강 - 중첩된 리소스
특정 리소스에 딸린 하위 리소스를 보여줘야 하는 경우가 있다. 가령, Post id 1번에 딸린 Comment 목록을 보여주거나, Comment를 생성/수정/삭제하는 경우 등이다.
## 중첩 리소스 Route를 만들자.
app/Http/routes.php에서 아래와 같이 작성해 보자.
```php
Route::resource('posts.comments', 'PostCommentController');
```
artisan CLI 로 PostCommentController 를 만들자.
```bash
$ php artisan make:controller PostCommentController --resource
```
`$ php artisan route:list`로 확인해 보자. `posts/{posts}/comments/{comments}` 형태의 라우트를 얻을 수 있다.

## Route Parameter 접근
Controller에서 `$postId`와 `$commentId`에 접근할 수 있다.
```php
class PostCommentController extends Controller
{
public function index($postId)
{
// GET http://localhost:8000/posts/1/comments
return '[' . __METHOD__ . "] \$postId = {$postId}";
// [App\Http\Controllers\PostCommentController::index] $postId = 1
}
...
public function show($postId, $commentId)
{
// GET http://localhost:8000/posts/1/comments/20
return $postId . '-' . $commentId;
// [App\Http\Controllers\PostCommentController::show] $postId = 1, $commentId = 20
}
}
```
---
- [목록으로 돌아가기](../readme.md)
- [14강 - 이름 있는 Route](14-named-routes.md)
- [16강 - 사용자 인증 기본기](16-authentication.md)
================================================
FILE: lessons/16-authentication.md
================================================
---
extends: _layouts.master
section: content
current_index: 16
---
# 16강 - 사용자 인증 기본기
좀 과장해서, 어떤 프로젝트든 로그인이 거의 업무량의 절반이라고들 한다. 그만큼 User 모델과 연결된 기능들이 많다는 의미로 이해하면 되겠다. 서비스에 들어온 사용자를 식별하는 방법을 인증(Authentication)이라 한다. 바꾸어 말하면, 사용자가 제시한 신분증이 DB에 저장된 User 정보와 동일한지 확인하고, 맞으면 통과시켜 주면서, 세션이라는 명찰을 하나 나눠 주는 행위라 이해할 수 있다.
이번 강좌에서는 라라벨이 제공하는 메소드들을 이용해서 사용자 인증을 직접 구현해 보고, 다음 강좌에서 라라벨에 포함되어 배포(Shipping)되는 네이티브 인증 구현을 살펴보자.
# User 모델
라라벨에는 User 모델과 마이그레이션이 이미 포함되어 있다. app/User.php를 살펴보자.
```php
class User extends Model implements ...
{
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password', 'remember_token'];
}
```
`$fillable` 속성을 통해 name, email, password 필드는 MassAssign 이 가능하다는 것을 알 수 있다. `$hidden` 속성은 외부에 노출되지 않는 필드들을 정의한 것이다. 그럼, 사용자를 하나 만들어 보자.
```bash
$ php artisan tinker
>>> $user = new App\User;
>>> $user->name = 'John Doe';
>>> $user->email = 'john@example.com';
>>> $user->password = bcrypt('password');
>>> $user->save();
```
**`참고`** 마이그레이션을 실행한 적이 없다면, `$ php artisan migrate` 을 먼저 하자.
잘 생성되었나 확인해 보자.
```bash
# $hidden 속성에 의해 password와 remember_token은 노출되지 않는다.
$ php artisan tinker
>>> App\User::first();
=> App\User {#684
id: 1,
name: "John Doe",
email: "john@example.com",
created_at: "2015-11-10 09:20:09",
updated_at: "2015-11-10 09:20:09",
}
```
## 사용자를 인증해 보자.
기본을 이해하기 위해 이번에도 app/Http/routes.php를 이용하자.
```php
Route::get('auth', function () {
$credentials = [
'email' => 'john@example.com',
'password' => 'password'
];
if (! Auth::attempt($credentials)) {
return 'Incorrect username and password combination';
}
return redirect('protected');
});
Route::get('auth/logout', function () {
Auth::logout();
return 'See you again~';
});
Route::get('protected', function () {
if (! Auth::check()) {
return 'Illegal access !!! Get out of here~';
}
return 'Welcome back, ' . Auth::user()->name;
});
```
서버를 부트업하고 브라우저에서 'auth' Route로 접근해 보자. 인증이 성공되고 바로 'protected' Route로 넘어가는 것을 확인할 수 있다.
이해를 돕기 위해, 'auth' Route의 Closure에다 인증에 필요한 정보를 하드코드를(`$credentials`) 박았다. 실전에서는 `Request::input('email')`과 같은 식으로 받아야 한다. **`Auth::attempt()` 메소드에 `$credentials`를 넘기면, 단순히 true/false만 리턴하는 것이 아니라, 백그라운드에서는 서버에 로그인한 사용자의 세션도 생성한다**는 것을 기억하자.
정상적으로 로그인되고 Redirect되어 'protected' Route로 들어 왔다면 사용자의 세션 정보가 서버에 생성되었을 것이다. 사용자가 로그인되어 있는 지 확인하는 메소드가 `Auth::check()` 이다. 실험을 위해 브라우저에서 'auth/logout' Route로 접근하여 (`Auth::logout()`) 사용자를 로그아웃 시킨 후, 'protected' Route로 다시 접근해 보자.
## 'auth' 미들웨어
여기서 잠깐! 만약 로그아웃한 후 'protected' Route를 방문했는데 if 블럭이 없었다면 어떻게 될까? `ErrorException-Trying to get property of non-object` 가 발생했을 것이다. 로그인 되지 않았기 때문에 `Auth::user()` 는 null 이고, `null->name`은 성립되지 않기 때문에 예외가 발생한 것이다. if 블럭을 사용하지 않고 Null Pointer를 예방하는 방법이 바로 'auth' 미들웨어 이다.
'auth' 미들웨어를 사용하도록 app/Http/routes.php 를 수정해 보자.
```php
Route::get('protected', [
'middleware' => 'auth',
function () {
return 'Welcome back, ' . Auth::user()->name;
}
]);
```
로그아웃한 후 'protected' Route로 다시 접근해 보자.
## NotFoundHttpException
'auth' 미들웨어는 app/Http/Middleware/Authenticate.php 에 위치하고 있다. 코드를 들여다 보면, 로그인이 안되어 있으면 'auth/login' 으로 Redirect 하도록 되어 있다. 해당 Route가 없어서 그런 것이니, 만들자. app/Http/routes.php 를 다시 수정하고, 'protected' Route를 방문해 보자.
```php
Route::get('auth/login', function() {
return "You've reached to the login page~";
});
```
**`참고`** 방금 본 'auth' 미들웨어는 Route by Route로 사용자가 선택해서 적용할 수 있는 'Route 미들웨어'라 한다. 반면, 앞 강좌에서 보았던 'csrf' 미들웨어는 'HTTP 미들웨어'라 칭한다. HTTP 미들웨어는 모든 HTTP 요청이 무조건 거쳐야 하는 글로벌 미들웨어라고 생각하면 된다.
---
- [목록으로 돌아가기](../readme.md)
- [15강 - 중첩된 리소스](15-nested-resources.md)
- [17강 - 라라벨에 내장된 사용자 인증](17-authentication-201.md)
================================================
FILE: lessons/17-authentication-201.md
================================================
---
extends: _layouts.master
section: content
current_index: 17
---
# 17강 - 라라벨에 내장된 사용자 인증
16강에 이어 이번 강좌에서는 라라벨에 딸려서 배포(Shipping)되는 인증 기능을 살펴 보자. 내장된 사용자 인증 기능은 특정 시간내에 로그인 실패가 많으면 로그인을 제한하는 쓰로틀링 기능이 포함되어 있고, 좀 더 나이스하게 코드를 구조화해 놓았다. 핵심은 16강과 동일하다.
## Controllers
app/Http/Controllers/Auth 디렉토리를 보면 2개의 파일이 보인다.
- AuthController.php - 새 사용자 등록과 로그인/로그아웃 로직을 포함하고 있다.
- PasswordController.php - 패스워드 리셋 로직을 포함하고 있다. (이 강좌에서는 다루지 않는다.)
## Routes
app/Http/routes.php 를 열어 아래 내용을 추가 하자.
```php
Route::get('/', function() {
return 'See you soon~';
});
Route::get('home', [
'middleware' => 'auth',
function() {
return 'Welcome back, ' . Auth::user()->name;
}
]);
// Authentication routes...
Route::get('auth/login', 'Auth\AuthController@getLogin');
Route::post('auth/login', 'Auth\AuthController@postLogin');
Route::get('auth/logout', 'Auth\AuthController@getLogout');
// Registration routes...
Route::get('auth/register', 'Auth\AuthController@getRegister');
Route::post('auth/register', 'Auth\AuthController@postRegister');
```
**`참고`** `AuthController`를 눈 씻고 찾아 봐도, `getLogin()` 메소드를 찾을 수 없다. 이들 메소드는 `Illuminate\Foundation\Auth\AuthenticateUsers` 트레이트 에서 찾을 수 있다.
## Views
뷰는 라라벨에 기본 포함되어 있지 않다. [공식 문서](http://laravel.com/docs/authentication#included-views)를 참조해서 뷰를 만들자.
### 로그인 폼 - resources/views/auth/login.blade.php
```html
@extends('master')
@section('content')
@stop
```
### 사용자 등록 폼 - resources/views/auth/register.blade.php
```html
@extends('master')
@section('content')
@stop
```
## 실험해 보자.
서버를 부트업하고, 'auth/register' 와 'auth/login' Route를 방문해 보자. 사용자를 등록하고, 로그인/로그아웃('auth/logout') 해보자.
**`참고`** 라라벨에서 기본 제공하는 인증에서 패스워드는 최소 6자리 이상이어야 한다. `App\Http\Controllers\Auth\AuthController@validator` 메소드를 보면 `'password' => 'required|confirmed|min:6'` 라고 유효성 검사 규칙이 지정되어 있는 것을 확인할 수 있다. 유효성 검사, 뷰에 쓰인 `old()` 함수에 대해서는 뒤에서 다시 살펴보도록 하자.
**`참고`** 다음으로 넘어가기 전에 로그인 뷰에서 소스보기를 해 보자. `` 란 라인을 볼 수 있다. 바로 `csrf_field()` Helper Function이 CSRF 공격을 막기 위해 만든 토큰을 담은 숨은 입력 폼이다. 13강에서 공부한 내용이다. `{!! !!}`은 '<', '>' 같은 특수문자의 '<', '>' Escaping 되는 것을 막기 위한 블레이드의 Interpolation 문법이다.
---
- [목록으로 돌아가기](../readme.md)
- [16강 - 사용자 인증 기본기](16-authentication.md)
- [18강 - 모델간 관계 맺기](18-eloquent-relationships.md)
================================================
FILE: lessons/18-eloquent-relationships.md
================================================
---
extends: _layouts.master
section: content
current_index: 18
---
# 18강 - 모델간 관계 맺기
쿼리빌더 없이 여러 개의 테이블에서 Join Query하는 것은 정말 번거로운 일이다. 엘로퀀트 ORM을 이용해서 모델간에 관계를 연결하고, 손쉽게 관계된 모델의 속성값들에 접근해 보자.
## 테이블 연결
이전 강좌를 통해 만든 users와 posts 테이블 간의 관계를 생각해 보자. User는 여러 개의 Post를 만들 수 있다. 하나의 Post는 한명의 User에 속한다. 즉, one to many 관계가 형성된다. 테이블을 수정하기 위해 새로운 마이그레이션을 작성하자.
```bash
$ php artisan make:migration add_user_id_to_posts_table
```
```php
class AddUserIdToPostsTable extends Migration
{
public function up()
{
Schema::table('posts', function(Blueprint $table) {
$table->integer('user_id')->unsigned()->after('id');
$table->foreign('user_id')->references('id')->on('users')
->onUpdate('cascade')->onDelete('cascade');
});
}
public function down()
{
Schema::table('posts', function(Blueprint $table) {
$table->dropForeign('posts_user_id_foreign');
$table->dropColumn('user_id');
});
}
}
```
`up()` 메소드를 살펴보자. posts 테이블에 user_id 필드를 생성한다. user_id가 음수가 될 수 없으므로 `unsigned()`를 체인했다. 테이블을 생성할 때 외래 키 연결을 하지 않았으므로, 마이그레이션에서 `foreign()` 메소드를 이용하여 `posts_user_id_foreign` 란 이름을 가진 관계를 생성하였다. `references()`, `on()`, `onUpdate()`, `onDelete()` 메소드 들을 사용한 것을 주의 깊게 살펴 보자.
`down()` 메소드는 `dropForeign()`을 `dropColumn()` 보다 먼저 호출했다. 이유는 외래키 연결이 있는 관계에서 컬럼을 삭제할 수 없기 때문이다.
마이그레이션하자.
```bash
$ php artisan migrate
```
## 모델간 관계를 연결하자.
User has many Post. Post belongs to a User. 이 관계를 기억하고, User 모델을 열어 보자.
```php
class User extends Model implements ...
{
public function posts() {
return $this->hasMany('App\Post');
}
}
```
`$this (== App\User)`는 여러 개의 `App\Post`를 가질 수 있다라는 식으로 읽으면 되겠다. `posts()`라는 복수 형태로 쓴 것을 유의하자. 실제로 `posts()` 메소드의 최종 결과값이 Collection이기 때문이다. 가령, 메소드 이름을 전혀 생뚱한 `abc()`로 해도 되지만, 사람이 읽어 이해할 수 있는 이름으로 짓는게 좋은 습관이다. 팅커링해 보자.
```bash
$ php artisan tinker
>>> App\User::find(1)->posts()->get();
=> Illuminate\Database\Eloquent\Collection {#686
all: [],
}
```
[] 이 반환되었다. id 1번을 갖는 User 가 생성한 Post가 없기 때문이다. 만들어 보자.
**`팁`** 앞 강의에서 Post 모델에 수정했던 `public $timestamps = false;` 코드는 삭제하자.
```bash
$ php artisan tinker
>>> App\User::find(1)->posts()->create([
... 'title' => 'My First Article',
... 'body' => 'My post body...'
... ]);
=> App\Post {#697
title: "My First Article",
body: "My post body...",
user_id: 1,
updated_at: "2015-11-10 12:33:35",
created_at: "2015-11-10 12:33:35",
id: 1,
}
```
`find()`로 가져온 User 인스턴스에 `posts()->create()` 메소드를 체인한 것을 주의 깊게 보자. 생성된 Post 모델에 user_id가 자동으로 1이 입력된 것을 확인하자. 좀 더 팅커링해 보자.
```bash
$ php artisan tinker
>>> $posts = App\User::find(1)->posts()->get(); # == App\User::find(1)->posts
=> Illuminate\Database\Eloquent\Collection {#687
all: [
App\Post {#674
id: 1,
user_id: 1,
title: "My First Article",
body: "My post body...",
created_at: "2015-11-10 12:33:35",
updated_at: "2015-11-10 12:33:35",
},
],
}
>>> $posts[0]->title;
=> "My First Article"
>>> $post = App\Post::find(1);
=> App\Post {#689
id: 1,
user_id: 1,
title: "My First Article",
body: "My post body...",
created_at: "2015-11-10 12:33:35",
updated_at: "2015-11-10 12:33:35",
}
>>> $post->user()->get();
# BadMethodCallException with message 'Call to undefined method Illuminate\Database\Query\Builder::user()'
```
`App\User::find(1)->posts` 형태로도 1번 id User의 Post Collection을 가져올 수 있다는 것을 기억하자. 실전에서는 `Auth::user()->posts` 와 같이 현재 로그인한 사용자의 모든 Post 를 가져오는 식으로 많이 사용하게 될 것이다.
## 반대 방향 관계 (Reverse Relationship)
$user->posts 가 가능하다면, $post->user 도 가능해야 한다. 그런데 위 팅커링에서는 `BadMethodCallException`이 발생했다. 이유는 User 모델에 Post와 관계를 설정해 주었지만, Post 모델에는 User와의 관계가 설정되어 있지 않기 때문이다. Post 모델을 열자.
```php
class Post extends Model
{
public function user() {
return $this->belongsTo('App\User');
}
}
```
`$this (== App\Post)`는 `App\User`에 속한다 라고 읽어 보자. 이제 역방향 관계 설정이 잘 되었는 지 확인해 보자.
```bash
$ php artisan tinker
>>> App\Post::find(1)->user()->first();
=> App\User {#691
id: 1,
name: "John Doe",
email: "john@example.com",
created_at: "2015-11-10 09:20:09",
updated_at: "2015-11-10 09:36:52",
}
```
**`참고`** 위 예에서 posts 테이블에 컬럼을 `user_id`로 하지 않았다면 어떻게 해야 하나? 이때는 관계를 설정할 때, `return $this->hasMany('App\Post', 'custom_field_name');`, `return $this->belongsTo('App\User', 'custom_foreign_key');` 처럼, 엘로퀀트에게 컬럼 이름을 알려 주어야 한다.
---
- [목록으로 돌아가기](../readme.md)
- [17강 - 라라벨에 내장된 사용자 인증](17-authentication-201.md)
- [19강 - 데이터 심기](19-seeder.md)
================================================
FILE: lessons/19-seeder.md
================================================
---
extends: _layouts.master
section: content
current_index: 19
---
# 19강 - 데이터 심기
앞에서 마이그레이션으로 테이블을 만들어 보았다. 만들어진 테이블에 테스트 데이터 또는 기본 데이터를 심는 과정을 씨딩(Seeding)이라 한다. 라라벨 5부터 Seeding을 효율적으로 하기 위한 Factory 기능이 제공된다. Factory는 데이터베이스 Seeding 뿐만 아니라, 테스트 클래스에서 필요한 데이터를 Stubbing하는데도 유용하게 사용할 수 있다.
## Model Factory
database/factories/ModelFactory.php 를 살펴보자. `App\User` 모델에 대한 Factory는 기본 제공되는 것을 알 수 있다. `App\Post` 모델에 대한 Factory를 정의해 보자.
```php
$factory->define(App\Post::class, function (Faker\Generator $faker) {
return [
'title' => $faker->sentence,
'body' => $faker->paragraph,
'user_id' => App\User::all()->random()->id
];
});
```
`$faker`는 다양한 형태의 Dummy 데이터를 생성해 주는 클래스이다. 'user_id'를 생성할 때, `App\User::all()->random()->id`를 쓴 것을 주목해 보자. 아무리 테스트를 위한 데이터 심기라 하지만 실제 존재하는 User를 Post와 연결해야 하기 때문이다.
**`참고`** php 5.5 이상에서는 `'App\Post'` 대신 `App\Post::class`와 같은 문법으로 사용할 수 있다. 문자열이 아니기 때문에 phpStorm 같은 IDE에서 해당 클래스로 이동할 때 편리하다(Cmd + Click).
팅커링 해 보자.
```bash
$ php artisan tinker
>>> factory('App\User')->make();
=> App\User {#707
name: "Louvenia McDermott Sr.",
email: "filiberto.moore@gmail.com",
}
>>> factory('App\User', 2)->make(); # Instance 2개 생성
=> Illuminate\Database\Eloquent\Collection {#700
all: [
App\User {#703
name: "Jany Ullrich",
email: "skunde@friesen.org",
},
App\User {#712
name: "Ms. Shanelle Heller III",
email: "walker84@lebsack.com",
},
],
}
```
**`참고`** `factory()` Helper 에서 호출한 `make()` 메소드는 메모리에 모델 인스턴스만 생성하는 반면, 곧 보게 될 `create()` 메소드는 모델 인스턴스를 생성하고 DB에 저장하는 일까지 한다.
## Seeder 클래스
tinker해 보았던 것을 이제 Seeder 클래스로 만들자.
```bash
$ php artisan make:seed UsersTableSeeder
$ php artisan make:seed PostsTableSeeder
```
`factory()` Helper Function을 이용해서 Seeder 클래스를 채우자. `truncate()` 메소드는 모델과 연결된 테이블 데이터를 깨끗이 지워주는 역할을 해 준다.
```php
// database/seeds/UsersTableSeeder
class UsersTableSeeder extends Seeder
{
public function run()
{
App\User::truncate();
factory('App\User', 10)->create();
}
}
// database/seeds/PostsTableSeeder
class PostsTableSeeder extends Seeder
{
public function run()
{
App\Post::truncate();
factory('App\Post', 20)->create();
}
}
```
Seeder 클래스가 완성되었으면 마스터 Seeder 클래스인, database/seeds/DatabaseSeeder.php 에 등록하자. `Model::ungard()`는 모든 모델에 대해 MassAssignment를 허용한다는 의미이다.
```php
class DatabaseSeeder extends Seeder
{
public function run()
{
Model::unguard();
$this->call(UsersTableSeeder::class);
$this->command->info('users table seeded');
$this->call(PostsTableSeeder::class);
$this->command->info('posts table seeded');
Model::reguard();
}
}
```
## Seeding 하자.
이제 모든 준비가 완료되었으니, Seeding을 하자.
```bash
$ php artisan db:seed
```
## QueryException
```bash
[Illuminate\Database\QueryException]
SQLSTATE[42000]: Syntax error or access violation: 1701 Cannot truncate a table referenced in a foreign key constraint ...
```
왜 발생했을까? `truncate()` 메소드 호출에서 `posts.user_id` 와 `users.id` 간의 Foreign Key 제약 때문에 테이블의 레코드 삭제가 불가해서 발생한 것이다. database/seeds/DatabaseSeeder.php에 아래 2 라인을 추가하고 Seeding을 다시 실행해 보자.
```php
class DatabaseSeeder extends Seeder
{
public function run()
{
DB::statement('SET FOREIGN_KEY_CHECKS=0');
...
DB::statement('SET FOREIGN_KEY_CHECKS=1');
}
}
```
이제 될 것이다.
```bash
$ php artisan db:seed
```
Seeding이 잘 되었는 지, DB 테이블을 확인해 보자.
---
- [목록으로 돌아가기](../readme.md)
- [18강 - 모델간 관계 맺기](18-eloquent-relationships.md)
- [20강 - Eager 로딩](20-eager-loading.md)
================================================
FILE: lessons/20-1-pagination.md
================================================
---
extends: _layouts.master
section: content
current_index: 21
---
# 페이징
모델에 데이터가 많아지면 한번에 모든 레코드를 표시할 수가 없게 된다. 이때 필요한 것이 페이징이다.
## 페이징된 콜렉션 만들기
app/Http/routes.php 에 아래와 같이 Route를 써 보자. 모델에 대한 쿼리 끝에 `get()` 이나 `find()` 대신, `paginate()` 메소드를 체인하면 페이징을 위한 준비 완료! 인자는 한번에 반환할 Collection 갯수를 넣는다. 이 예제에서는 10개로 했다.
```php
Route::get('posts', function() {
$posts = App\Post::with('user')->paginate(10);
return view('posts.index', compact('posts'));
});
```
## 페이징된 결과 보기
라라벨 페이징은 [twitter bootstrap](http://getbootstrap.com/)과 완벽하게 호환된다. 확인을 위해 resources/views/master.blade.php 에 bootstrap 사용을 선언하자.
```html
...
```
20강에서 사용하던 resources/views/posts/index.blade.php 를 그대로 사용하자. `@stop` 바로 전에 아래 코드를 추가하자. `paginate()` 메소드를 체임함으로써, `$posts` 객체는 페이징이 가능한 Paginator 인스턴스로 변경되었고 `render()` 메소드를 쓸 수 있게 된 것이다. 렌더링된 뷰에서 소스보기를 해서 어떤 코드가 추가되었는 지 확인해 보자.
```html
...
@if($posts)
{!! $posts->render() !!}
@endif
@stop
```

---
- [목록으로 돌아가기](../readme.md)
- [20강 - Eager 로딩](20-eager-loading.md)
- [21강 - 메일 보내기](21-mail.md)
================================================
FILE: lessons/20-eager-loading.md
================================================
---
extends: _layouts.master
section: content
current_index: 20
---
# 20강 - Eager 로딩
Eager 로딩은 N+1 쿼리 문제를 해결해 주는 방법이다. 겁먹을 필요 없다, 아주 간단하니까.
## N+1 쿼리 문제를 만들어 보자.
"18강 모델 관계 맺기" Post와 User 모델을 그대로 사용하자. app/Http/routes.php 를 작성하자.
```php
Route::get('posts', function() {
$posts = App\Post::get();
return view('posts.index', compact('posts'));
});
```
resources/views/posts/index.blade.php를 만들자. "19강 데이터 심기" 에서 만든 Post 모델의 'title'과, Post 작성자의 'name'을 뷰에 뿌릴 것이다. `@forelse` 루프 안에서 `{{ $post->user->name }}` 와 같은 식으로 User 모델의 'name' 속성에 접근한 것을 확인하자.
```html
@extends('master')
@section('content')
List of Posts
@forelse($posts as $post)
{{ $post->title }}
by {{ $post->user->name }}
@empty
There is no article!
@endforelse
@stop
```
서버를 부트업하고 'posts' Route를 방문해 보자. 잘 출력되었는데 이게 무슨 문제란 것인가? 자 여기서 실제 백그라운드에서 발생하는 쿼리를 살펴 보자. app/Http/routes.php 에 아래 코드를 넣고, 브라우저에서 페이지를 새로고침 해 보자.
```php
DB::listen(function($sql, $bindings, $time){
var_dump($sql);
});
```
**`알림`** 라라벨 5.2를 사용한다면 위 코드에서 에러가 날 것이다. [여기를 참고](https://github.com/appkr/l5essential/issues/12)해서 알맞게 코드를 수정하자.
다음과 같은 쿼리를 볼 수 있다. 즉, `{{ $post->user->name }}` 에서 매번 쿼리를 하는 것이다.
- `select * from posts` x 1건
- `select * from users where users.id = ? limit 1` x N건

## Eager 로딩으로 N+1개의 쿼리를 2개로 만들어 보자.
app/Http/routes.php 에서 엘로퀀트 쿼리를 아래와 같이 수정하고 브로우저에서 페이지를 새로고침해 보자.
```php
Route::get('posts', function() {
$posts = App\Post::with('user')->get();
return view('posts.index', compact('posts'));
});
```
쿼리가 2개로 줄어든 것이 보이는가?
- `select * from posts` x 1건
- `select * from users where users.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` x 1건
`with(string|array $relations)` 메소드는 항상 엘로퀀트 모델 바로 뒤, 다른 메소드를 체인하기 전에 써야 한다. 메소드의 인자는 테이블 이름이 아니라, 모델 클래스에서 정의한 관계를 나타내는 메소드 이름임을 잘 기억하자.
## Lazy Eager 로딩
가끔 엘로퀀트를 이용한 쿼리를 먼저 만들어 놓고, 나중에 관계를 로드해야 하는 경우가 발생할 수 있다. 예를 들면, 쿼리 하나를 여러 번 재사용할 경우, 앞에서는 Eager 로딩이 필요없었지만, 나중에 필요하게 되는 경우 등이 해당된다. 이때는 `load(string|array $relations)` 메소드를 이용할 수 있다.
```php
Route::get('posts', function() {
$posts = App\Post::get();
$posts->load('user');
return view('posts.index', compact('posts'));
});
```
---
- [목록으로 돌아가기](../readme.md)
- [19강 - 데이터 심기](19-seeder.md)
- [추가 - 페이징](20-1-pagination.md)
================================================
FILE: lessons/21-mail.md
================================================
---
extends: _layouts.master
section: content
current_index: 22
---
# 21강 - 메일 보내기
## mailgun 서비스 가입하자
실습을 위해 라라벨에 기본 값으로 설정되어 있는 [Mailgun 서비스](http://www.mailgun.com/)에 가입하자. 월 1만통까지는 공짜라고 한다. 가입 후 반드시, 가입할 때 사용한 메일 계정으로 들어가서 활성화 링크를 눌러줘야 정상적으로 메일 발송이 가능하다.
## 메일 설정
config/mail.php 를 열어 보내는 사람 정보를 채우자.
```php
'from' => ['address' => 'john@example.com', 'name' => 'John Doe'],
```
.env 를 열어 메일을 설정해 보자. MIAL_ 로 시작하는 나머지 설정은 모두 지우자.
```bash
MAIL_DRIVER=smtp
MAIL_USERNAME=your_mailgun_login_email
MAIL_PASSWORD=your_mailgun_login_password
```
## 메일을 보내 보자.
app/Http/routes.php 에 메일을 보내는 Route를 작성하자. 노파심에 다시 얘기하면, 학습을 위해 편의상 routes.php 에 작성하지만, 실전에서는 컨트롤러 (또는 서비스 로직)에 들어가야 하는 내용이다.
```php
Route::get('mail', function() {
$to = 'YOUR@EMAIL.ADDRESS';
$subject = 'Studying sending email in Laravel';
$data = [
'title' => 'Hi there',
'body' => 'This is the body of an email message',
'user' => App\User::find(1)
];
return Mail::send('emails.welcome', $data, function($message) use($to, $subject) {
$message->to($to)->subject($subject);
});
});
```
`send()` 메소는 3개의 인자를 받는다. 첫번째는 사용할 뷰, 두번 째는 뷰에서 바인딩 시킬 데이터, 세번째는 콜백이다.
**`참고`** `send()` 대신 `queue()` 메소드를 사용하는 것이 편리하다. Queue 설정이 되어 있지 않으면, `send()`로 자동 폴백된다.
resources/views/emails/welcome.blade.php 를 만들자.
```html
{{ $title }} {{ $user->name }}
{{ $body }}
```
블레이드 문법을 통해서 `send()` 메소드를 통해 넘겨 받은 데이터들을 바인딩하는 것을 볼 수 있다. `config(string $key) (== Config::get(string $key))` 함수를 통해 config/**.php 에 위치한 설정 값을 읽을 수 있다.
브라우저를 열고 'mail' Route를 방문한 후, `$to`로 지정한 메일 계정으로 가서 이메일이 잘 왔나 확인해 보자.

## 테스트 방법
메일이 잘 가는 지를 테스트하기 위해 매번 실제로 메일을 보내는 것은 여러가지로 좋지 않다. 라라벨에서는 메일 테스트를 위해 'log' 드라이버를 제공하며, 메일 발송 결과를 storage/logs/laravel.log 에서 확인할 수 있다. .env에서 수정할 수 있다.
```bash
MAIL_DRIVER=log
```
로그파일은 tail 명령을 이용하면 관찰하기 편리하다.
```bash
$ tail -f ./storage/logs/laravel.log
```
**`참고`** 실전에서는 `Mail::queue()` 메소드를 한번 Wrapping 한 도메인 레이어용 메일러를 별도로 만들고, 컨트롤러의 생성자에 의존성 주입을 해서 사용한다. 또는 컨트롤러에서 이런 데이터로 메일을 보내줘라고 이벤트를 생성하고, 이벤트를 구독하는 클래스에서 별도의 프로세스로 메일을 보내는 작업을 하게 된다.
---
- [목록으로 돌아가기](../readme.md)
- [추가 - 페이징](20-1-pagination.md)
- [22강 - 이벤트](22-events.md)
================================================
FILE: lessons/22-events.md
================================================
---
extends: _layouts.master
section: content
current_index: 23
---
# 22강 - 이벤트
라라벨 이벤트 시스템은 Observer 또는 PubSub 패턴을 구현할 수 있게 해 준다. 필자가 알고 있는 이벤트 방식 구조체의 잇점 몇가지는 아래와 같다.
- 사용자에게 빠른 응답을 제공할 수 있다. (Non blocking I/O)
- 리스너를 여러개 구현하면, 이벤트 하나로 여러가지 작업을 동시에 수행할 수 있다.
## 시나리오
어떤 목적인지 모르겠지만, 사용자가 로그인 하면 users 테이블에 last_login 필드에 로그인 시간을 업데이트한다고 가정하자.
**`참고`** 이벤트는 주로 IO 가 수반되는 경우에 많이 사용된다. smtp 로 이메일을 보낸다든가, HTTP 클라이언트로 외부 서비스로 부터 데이터를 가져온다든가, 파일 시스템으로부터 큰 파일을 읽을 때 등이 대표적인 예이다.
## 마이그레이션
users 테이블에 last_login 필드를 추가하자.
```bash
$ php artisan make:migration add_last_login_to_users_table
```
마이그레이션 코드를 작성하자.
```php
class AddLastLoginToUsersTable extends Migration
{
public function up()
{
Schema::table('users', function(Blueprint $table) {
$table->timestamp('last_login')->nullable();
});
}
public function down()
{
Schema::table('users', function(Blueprint $table) {
$table->dropColumn('last_login');
});
}
}
```
마이그레이션을 실행하자.
```bash
$ php artisan migrate
```
User 모델을 수정하자. `$dates` 속성에 'last_login' 필드를 추가함으로써, 'updated_at', 'created_at' 처럼 `Carbon\Carbon` 이 제공하는 다양한 메소드에 접근할 수 있다.
```php
class User extends Model implements ...
{
protected $dates = ['last_login'];
}
```
## 구조체를 만들어 보자.
이번에도 app/Http/routes.php 를 사용할 것이다. 제 "16강 사용자 인증 기본기"에서 사용했던 내용을 좀 훔쳐오자.
```php
Route::get('auth', function () {
$credentials = [
'email' => 'john@example.com',
'password' => 'password'
];
if (! Auth::attempt($credentials)) {
return 'Incorrect username and password combination';
}
Event::fire('user.login', [Auth::user()]);
var_dump('Event fired and continue to next line...');
return;
});
Event::listen('user.login', function($user) {
var_dump('"user.log" event catched and passed data is:');
var_dump($user->toArray());
});
```
tinker 로 로그인에 사용할 사용자를 만들자.
```bash
$ php artisan tinker
>>> App\User::create([
... 'email' => 'john@example.com',
... 'name' => 'John Doe',
... 'password' => bcrypt('password')
... ]);
=> App\User {#684
email: "john@example.com",
name: "John Doe",
updated_at: "2015-11-10 14:17:48",
created_at: "2015-11-10 14:17:48",
id: 11,
}
```
서버를 부트업하고 브라우저에서 'auth' Route 로 접근해 보자. `Event::fire(string|object $event, mixed $payload)` 에서 이벤트를 던지고, `Event::listen(string|array $events, mixed $listener)`이 이벤트를 받아서 처리하는 식의 구조체이다. `Event::fire()` 대신 `event()` Helper Function을 이용할 수도 있다.

## 이벤트를 처리하자.
구조체가 만들어 졌으니, 시나리오 대로 로그인 시각을 저장하자.
```php
Event::listen('user.login', function($user) {
$user->last_login = (new DateTime)->format('Y-m-d H:i:s');
return $user->save();
});
```
artisan CLI 로도 확인해 보자. 'last_login' 필드가 `Carbon\Carbon` 인스턴스로 잘 동작하는 것을 확인할 수 있다.
```bash
$ php artisan tinker
>>> $user = App\User::find(11);
=> App\User {#672
id: 11,
name: "John Doe",
email: "john@example.com",
created_at: "2015-11-10 14:17:48",
updated_at: "2015-11-10 14:24:36",
last_login: "2015-11-10 14:24:36",
}
>>> $user->last_login;
=> Carbon\Carbon {#679
+"date": "2015-11-10 14:24:36.000000",
+"timezone_type": 3,
+"timezone": "UTC",
}
```
## 라라벨 공식 문서에서의 이벤트
처음 접하는 분들이 보기에 라라벨 공식 문서의 이벤트 설명은 정말 어렵다. 하지만, 기본적으로 위 예제와 같은 동작을 좀 더 관리하기 편리하도록 쪼개 놓은 것이라고 볼 수 있다.
공식 문서의 `event(new PodcastWasPurchased($podcast))` 에서 이벤트 이름과 이벤트 데이터를 객체로 넘긴 것이며, app/Proviers/EventServiceProvider.php 에서 이벤트 이름과 이벤트 핸들러를 연결시켜 준 것이다. 연결된 이벤트 핸들러는 결국은 우리 예제의 `Event::listen()` 에 두번 째 인자로 전달한 Closure를 별도 클래스로 떼 놓은 것이다.
---
- [목록으로 돌아가기](../readme.md)
- [21강 - 메일 보내기](21-mail.md)
- [23강 - 입력 값 유효성 검사](23-validation.md)
================================================
FILE: lessons/23-validation.md
================================================
---
extends: _layouts.master
section: content
current_index: 24
---
# 23강 - 입력 값 유효성 검사
항상 듣는 말이다. **"사용자가 입력한 값은 절대 신뢰하지 마라."** 유효성 검사는 사용자의 악의적인 해킹 시도 또는 잘못된 데이터 입력으로 부터 서비스를 보호하는 기본 중에 기본이므로 굉장히 중요하다. 프론트엔드에서 자바스크립트로 한번 걸렀다고 해도, HTTP 클라이언트를 통해서 직접 요청할 수 있으므로 백엔드에서도 반드시 유효성 검사를 수행해야 한다.
## 라라벨이 지원하는 유효성 검사 규칙
이 예제에서 사용하는 `required`, `min` 뿐 아니라, `string`, `confirmed`, `exists`, `required_if`, 등등등 굉장히 많다. [공식 문서](http://laravel.com/docs/validation#available-validation-rules)를 참고하자.
## 유효성 검사 레이어를 만들어 보자.
app/Http/routes.php 를 이용하자. 여러 번 말하지만 학습 목적이며, 실제로는 컨트롤러에 작성되어야 한다.
```php
Route::post('posts', function(\Illuminate\Http\Request $request) {
$rule = [
'title' => ['required'], // == 'title' => 'required'
'body' => ['required', 'min:10'] // == 'body' => 'required|min:10'
];
$validator = Validator::make($request->all(), $rule);
if ($validator->fails()) {
return redirect('posts/create')->withErrors($validator)->withInput();
}
return 'Valid & proceed to next job ~';
});
Route::get('posts/create', function() {
return view('posts.create');
});
```
`Validator::make(array $formData, array $rule)` 메소드로 `Validator` 인스턴스를 만든다. 첫번 째 인자는 폼 데이터인데, 예제에서는 `$request` 인스턴스를 주입하고 `$request->all()` 로 접근했다. 두번 째 인자는 유효성 규칙이다. `Validator` 인스턴스에 대해 `fails()` 메소드로 if 테스트를 수행하고, 유효성 검사를 통과하지 못했을 경우, 'posts/create' Route로 Redirect 시켰다. 이때, `withErrors($validator)` 로 Sesson 에 에러 데이터를 굽는다. 또, `withInput()`으로 Session에 방금 넘겨 받은 폼 데이터를 'posts/create' Route로 돌아갔을 때도 쓸 수 있도록 한다.
**`참고`** `$request` 인스턴스 주입 대신, `Request::input(string $key)/Input::get(string $key)`, `Request::all()/Input::all()` 과 같이 라라벨 Facade를 이용할 수도 있다.
## 폼을 만들자.
resources/views/posts/create.blade.php 를 만들어 보자.
```html
@extends('master')
@section('content')
New Post
@stop
```
이 폼에서는 `{!! $errors->first('title', ':message') !!}` 와 같이 블레이드 문법으로 POST 'posts' Route에서 넘겨 받은 `$errors` 인스턴스에 접근하고 있다. `$errors->all()`로 전체 에러 메시지백을 받은 후, 루프를 돌면서 에러를 뿌리는 방법도 있다. `$errors` 변수는 모든 뷰에서 항상 존재하기 때문에 `if (isset($errors))` 등과 같은 방어 조치를 할 필요가 없다는 것을 기억해 두자. `$errors`는 `withErrors()`에 의해 세션에 구워진 값이다.
`{{ old(string $key) }}` Helper Function 을 사용한 것도 주의 깊게 보자. `old()`는 이전 입력 값이 Session에 없으면 '' 를 반환하고, 값이 있으면 반환한다. `withInput()`에 의해 세션에 구워진 값이다.
## 테스트 해 보자.
브라우저에서 'posts/create' Route를 열자. New Post 입력 폼이 열렸을 것이다. Title 입력박스에만 'First Article'이라고만 입력하고, '폼 제출' 버튼을 눌러 보자. 'post/create' `$validator->fails()` 블럭에서 'posts/create' Route로 Redirect되면서, Body 텍스트 영역 옆에 에러 메시지가 출력되었을 것이다. Title에 방금 입력했던 값이 폼에 입력되어 있는 것도 확인하자.
**`참고`** `create.blade.php` 에서 `{{ var_dump(Session::all()) }}`을 넣고, 폼 전송을 다시 해 보자.
## 다양한 폼 유효성 검사 방법
[공식문서](http://laravel.com/docs/validation)를 보면 크게 3가지 유효성 검사 방법을 설명하고 있다.
1. `Validator` 인스턴스를 직접 만드는 방법
2. 컨트롤러에서 기본으로 사용할 수 잇는 `validate()` 메소드를 이용하는 방법
3. `FormRequest`를 이용하는 방법
1 번은 우리 예제에서 이미 다루었다.
2 번은 모든 컨트롤러가 `App\Http\Controllers\Controller` 라는 베이스 컨트롤러를 상속하고 있고, 베이스 컨트롤러가 포함하고 있는 `ValidatesRequests` 트레이트에 정의된 `validate()` 메소드를 이용하는 방법이다. 사용법은 1번과 거의 유사하다. 메소드의 첫 번째 인자로, 1번 방법에서는 `Request::all()` 배열을 넘긴 반면, 이 방법에서는 `Illuminate\Http\Request` 인스턴스 자체를 넘기면 된다. 많이 하는 질문 중에 하나가 유효성 검사 규칙을 어디에 두어야 하냐는 것인데, 정답은 없다. 컨트롤러에 두어도 되는데, 보통 모델 클래스에 Static 속성으로 정의하는 것을 많이 보았다. 아래 Post 모델과 컨트롤러의 예제를 보자.
```
// app/Http/routes.php
Route::resource('posts', 'PostsController');
// app/Post.php
class Post extends Model
{
public static $rules = [
'title' => ['required'],
'body' => ['required', 'min:10']
];
}
// app/Http/Controllers/PostsController.php
class PostsController extends Controller
{
public function create()
{
return view('posts.create');
}
public function store(Request $request)
{
$this->validate($request, \App\Post::$rules);
return '[' . __METHOD__ . '] ' . 'validate the form data from the create form and create a new instance';
}
}
```
3 번 Form Request를 이용하는 방법이 가장 깔끔하긴 하다. [공식 문서](http://laravel.com/docs/validation#form-request-validation)를 참고하자.
---
- [목록으로 돌아가기](../readme.md)
- [22강 - 이벤트](22-events.md)
- [24강 - 예외 처리](24-exception-handling.md)
================================================
FILE: lessons/24-exception-handling.md
================================================
---
extends: _layouts.master
section: content
current_index: 25
---
# 24강 - 예외 처리
## 예외 던지기
라라벨에서 예외(Exception)는 컨트롤러 또는 서비스 로직을 수행하는 도중 어디서든 던질 수 있다. app/Http/routes.php를 이용하여, 예외를 던지는 방법은 실습해 보자.
```php
Route::get('/', function() {
throw new Exception('Some bad thing happened');
});
```
'/' Route로 접근해서 Whoops 페이지를 확인해 보자. `throw new My\Name\Space\CustomException('message');` 처럼 자신만의 Exception 클래스를 만들어서 던질 수도 있다.
**`참고`** Production 환경에서는 보안을 위해 Stack Trace가 모두 찍히는 DEBUG 옵션을 꺼야 한다. .env 파일에서 `APP_ENV=production`, `APP_DEBUG=false`로 바꾼 후 '/' Route를 다시 방문해 보자. 웹 서버를 재시작해야 변경 내용을 확인할 수 있다.
또 다른 방법은 `abort(404)` 처럼, `abort(int $code, string $message)` Helper Function을 이용하는 방법이다. `abort()`는 `HttpException` 을 던진다.
## 예외를 캐치하자.
라라벨에서는 `try{} catch($e){}` 로 싸지 않아도, 글로벌 하게 예외를 잡아준다. 바로 app/Exceptions/Handler.php 가 주인공이다. 클래스를 살펴보자.
```php
class Handler extends ExceptionHandler
{
protected $dontReport = [];
public function report(Exception $e) {}
public function render($request, Exception $e) {}
}
```
`$dontReport` 속성에는 `report()` 메소드를 타지 않을 예외 클래스들의 리스트를 정의한다.
`report()` 메소드는 기본적으로 로그 (storage/logs/laravel.log)에 예외의 내용을 쓴다. 여기서 관리자 이메일, 슬랙, BugSnag와 같은 외부 서비스에 예외 내용 등을 리포트하는 로직을 더 구현해 넣을 수 있다.
`render()` 메소드에서는 예외를 Http 응답으로 렌더링하는 로직들을 포함한다.
## 예외를 처리하자.
app/Exceptions/Handler.php의 `render()` 메소드에 아래 내용을 추가해 보자. `ModelNotFoundException` 이 발생하면 지정된 뷰를 내용으로 하는 HTTP 404 응답을 반환하라는 의미이다.
```php
class Handler extends ExceptionHandler
{
public function render($request, Exception $e)
{
if ($e instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) {
return response(view('errors.notice', [
'title' => 'Page Not Found',
'description' => 'Sorry, the page or resource you are trying to view does not exist.'
]), 404);
}
return parent::render($request, $e);
}
}
```
`Handler@render()` 메소드에서 뷰로 리턴할 resources/views/errors/notice.blade.php 뷰를 만들자.
```html
{{ $title }}
{{ $title }}
{{ $description }}
```
routes.php 에서 `ModelNotFoundException`을 발생시키자. 해당 예외는 엘로퀀트 모델에 존재하지 않는 인스턴스를 쿼리했을 때 발생한다. 브라우저에서 '/' Route를 방문하여 렌더링 된 결과를 확인하자.
```php
Route::get('/', function() {
return App\Post::findOrFail(100);
});
```

---
- [목록으로 돌아가기](../readme.md)
- [23강 - 입력 값 유효성 검사](23-validation.md)
- [25강 - 컴포저](25-composer.md)
================================================
FILE: lessons/25-composer.md
================================================
---
extends: _layouts.master
section: content
current_index: 26
---
# 25강 - 컴포저
2강에서 라라벨 5 처음 설치할 때 Composer를 설치했을 것이다. 그땐 무엇인지 모르고 마냥 썼을 수도 있지만, 이제 그 정체를 조금만 핥아 보자. Composer는 php의 패키지 매니저이다. 패키지 레지스트리는 [패키지스트](https://packagist.org/)라 불린다. Java에 Maven, Python에 PyPi, Ruby에 Gem, Node에 Npm이 있다면, php엔 Composer가 있다. 라라벨도 버전 4로 넘어가면서 Composer를 본격적을 도입하고, 코어 프레임웍과 외부 패키지로 분리했다.
작은 서비스를 개발할 때는 패키지를 관리하는 일이 필요없지만, 서비스가 커질 수록 패키지 관리의 필요성은 급증한다. 급기야 **패키지를 관리하지 않아서, 개발자들이 패키지에 의해서 관리 당해지는 웃지 못할 사태**가 벌어질 수 있다. 패키지 매니저를 쓸 때 개인적으로 좋은 점을 정리해 봤다.
- 기억력을 보조한다 (레지스트리 역할).
- 이미 만들어진 바퀴를 쉽게 가져다 쓰고, 모양을 약간 바꾸어 쓸 수도 있다 (Code Reuse).
- 외부 패키지들은 버전 관리에서 빠지기 때문에 코드 풋프린트가 줄어들고 Git에 푸시 할 때 빠르다.
- 외부 패키지들의 업데이트를 자동화할 수 있다. 즉, 일일이 확인하고, 다운로드하고, 설치해 줄 필요 없다.
## 시나리오
어느 날 고객사에서 기존에 개발한 블로그 서비스에 Markdown 기능을 추가해 달라는 요청을 했다고 가정해 보자. 구글링을 통해 여러 패키지들을 검토해 본 후 여러 CMS들이 쓰고 있다는 말에 낚여서 `erusev/parsedown-extra` 을 쓰기로 했다고 가정하자.
**`참고`** Markdown 은 과거의 위키 문법 처럼 빠른 글쓰기는 지원하는 초경량 마크업 도구이다. 지금 보는 이 문서들이 모두 Markdown 으로 작성되었고, Github가 컴파일해 주는 것이다.
## 패키지를 설치해 보자.
```bash
$ composer require "erusev/parsedown-extra: 0.7.*"
# 버전 없이 쓸 때는 따옴표를 안쳐도 된다.
# 버전 앞에 ':' 대신 '='를 쓸 수도 있다.
```
꽤 오랜 시간이 걸릴 것이다 :(. 프로젝트 디렉토리에 위치한 composer.json을 열어서 `require` 섹션에 엔트리가 정상적으로 업데이트된 것을 확인하자.
```json
{
"...": "...",
"require": {
"php": ">=5.5.9",
"laravel/framework": "5.1.*",
"erusev/parsedown-extra": " 0.7.*"
},
"...": "...",
}
```
**`참고`** 프로젝트 디렉토리에 위치한 composer.json 파일을 열고, `require` 섹션에 필요한 패키지를 직접 추가해 준 후, `$ composer update` 코맨드를 실행하는 방법으로도 설치할 수 있다.
**`참고`** 라라벨 전용으로 만들어진 패키지의 경우에는 설치 후, Service Provider, Facade, 설정 파일 발행 등 해당 패키지 사용을 더 편리하게 사용하기 위한 추가적인 작업이 필요할 수 있다. 해당 패키지의 Github 또는 Packagist 페이지의 설명을 참조하자.
**`참고`** 0.7.*은 0.7로 시작하는 버전 중 가장 최신 버전이란 의미이다. 모든 필드와 규칙을 설명할 수 없으므로, [공식 문서](https://getcomposer.org/doc/) 또는 XE팀에서 번역한 [한글본](https://xpressengine.github.io/Composer-korean-docs/)을 참조하자.
## 패키지를 이용해 보자.
app/Http/routes.php 를 이용하자.
```php
Route::get('/', function() {
$text =<<text($text);
});
```
서버를 부트업하고 '/' Route를 방문해 보자. Html로 잘 포맷팅 된 문서를 보고 있으면 성공한 것이다.

입력 문자열로 긴 스트림을 쓰기 위해서 [Heredoc](http://php.net/manual/kr/language.types.string.php#language.types.string.syntax.heredoc) 문법을 사용하였다. 실전에서는 POST 로 넘겨 받은 폼 데이터의, 가령 'body' 필드 값일 것이며, `Input::get('body')/Request::input('body')`로 값을 얻어 올 수 있을 것이다.
설치한 `erusev/parsedown`의 [Github 페이지](https://github.com/erusev/parsedown)를 참고하여 사용법을 익혔다. 이 패키지는 `namespace`를 쓰지 않아 Root 네임스페이스에 존재한다. 인스턴스를 생성하고, `text()` 메소드에 컴파일할 문자열들을 넘겨 주었다. 실전에서는 컴파일 된 결과를 뷰의 데이터로 바인딩하거나, `markdown()`과 같은 커스텀 Helper Function을 만들어 뷰에서 직접사용하면 된다.
여기서 주목할 만한 것은, `app()` 이란 Helper Function의 등장이다. 이는 `new ParsedownExtra()`와 같은 역할을 한다고 보면 된다. `app()` 을 쓰는 것이 `new` 키워드를 쓰는 것 보다 더 좋은 점은 `ParsedownExtra` 클래스의 생성자에 주입되는 다른 인스턴스가 있다면(의존성 주입), `app()`의 경우 자동으로 주입을 해 준다. 다시 설명하면, `new` 키워드를 썼을 때에 꼭 해야 하는, 아래 예와 같이 복잡한 의존성 주입이 필요없다는 얘기다. 이는 라라벨의 Service Container 의 기능인데, 이 코스의 범위를 벗어난다 생각되므로, [공식 문서](http://laravel.com/docs/container)를 참조하기 바란다.
```php
$sb = new SkateBoard(new Roller(new Wheel, new Joint), new Plate);
$sb->run();
```
## 어디에 어떤 패키지가 있는 지 어떻게 알아요?
유용성, 인기도, 완성도 측면에서 검증되어 큐레이션된 패키지 들이 있는 곳을 소개한다. 필요한 기능이 있다면 직접 만들려 하지 말고, 이 목록을 탐색하고 구글링해 보자.
- [ziadoz/awesome-php](https://github.com/ziadoz/awesome-php)
- [chiraggude/awesome-laravel](https://github.com/chiraggude/awesome-laravel)
- [TimothyDJones/awesome-laravel](https://github.com/TimothyDJones/awesome-laravel)
> **`참고`** 컴포저를 사용하다 보면, 아래와 같은 메시지를 만나는 경우가 있다. Github 에 정해진 시간당 요청할 수 있는 한도를 초과했다는 의미인데, 메시지를 자세히 보면 답이 있다. 제시한 URL 로 이동하여 토큰을 만들고, 복사하여 'Token(hidden):' 에 붙여 넣으면 끗!
> Could not fetch https://api.github.com/repos/sebastianbergmann/global-state/zipball/bc37d50fea7d017d3d340f230811c9f1d7280af4, please create a GitHub OAuth token to go over the API rate limit.
> Head to https://github.com/settings/tokens/new?scopes=repo&description=xxx to retrieve a token. It will be stored in "/home/vagrant/.composer/auth.json" for future use by Composer.
> Token (hidden):
---
- [목록으로 돌아가기](../readme.md)
- [24강 - 예외 처리](24-exception-handling.md)
================================================
FILE: lessons/26-document-model.md
================================================
---
extends: _layouts.master
section: content
current_index: 27
---
# 실전 프로젝트 1 - Markdown Viewer
라라벨 공식 문서와 유사하게 왼쪽 사이드바에 문서 목록, 오른쪽 본문 영역에 HTML 로 컴파일된 문서가 표시되는 헝태로 최종 목표이미지를 잡아 보자. 이 실전 프로젝트를 통해 25강까지 배운 기본기 외에 Filesystem, Custom Helper, Cache, Elixir 등을 더 사용해 보게될 것이다.
## 26강 - Document 모델
이 강좌의 마크다운 문서들은 docs 폴더에 위치하고 있다. 생각해 보면, DB 테이블에서 데이터를 가져오는 것이 아니라, 파일시스템에서 가져와야 한다. 파일시스템에 접근하는 방법을 알아 보자.
### File vs Storage
라라벨에서 `File`과 `Storage` 2개의 Facade가 있다. 공식문서에서 `File`이 빠진 것으로 보아 `Storage`의 사용을 권장하는 것처럼 보인다. 두 Facade의 API(== public 메소드)는 거의 동일하지만, `Storage`는 파일 저장위치를 config/filesystems.php 에 지정해 놓으면 그 디렉토리 밖을 벗어날 수 없다. 우리의 docs 폴더는 프로젝트 루트에 위치하므로, `File` Facade를 이용해야 한다.
**`참고`** `put(string $path, string $contents)` 파일 쓰기, `files(string $directory)` 파일 목록 가져오기, `glob(string $pattern)` 패턴에 맞는 파일 목록 가져오기, `isDirectory(string $directory)` 디렉토리 체크, `makeDirectory(string $path, int $mode)` 디렉토리 만들기 등은 실전에서 자주 사용하게 되니 사용법을 익혀 두자.
### Document 모델을 만들자.
```bash
$ php artisan make:model Document
```
Document 모델은 엘로퀀트를 상속하지 않는다. 잘 생각해 보면, 컨트롤러의 기본 동작인 CRUD(Create, Read, Update, Delete) 중 Read만, 즉, 컨트롤러에서 요청한 이름에 해당하는 파일을 잘 읽어서 반환해 주는 메소드 하나만 필요하다.
```php
getPath($file))) {
abort(404, 'File not exist');
}
return File::get($this->getPath($file));
}
private function getPath($file)
{
return base_path($this->directory . DIRECTORY_SEPARATOR . $file);
}
}
```
`getPath()`란 메소드를 먼저 보자. `base_path()`는 프로젝트 루트 디렉토리의 절대 경로를 반환하는 Helper이다. 추가 경로를 인자를 넣으면 덧붙여서 반환해 준다.
```bash
$ php artisan tinker
>>> base_path();
=> "/Users/Juwon/workspace/myProject"
>>> base_path('docs/file.ext');
=> "/Users/Juwon/workspace/myProject/docs/file.ext"
```
**`참고`** DIRECTORY_SEPARATOR 상수는 윈도우즈 시스템에서는 `\` *nix 에서는 `/`를 반환한다.
`File::exists(string $path)` 로 인자로 넘어온 파일이 존재하지 않으면, `abort()` Helper로 404 NotFoundHttpException 을 던지도록 했다. if 테스트를 통과하면, 인자로 받은 마크다운 파일의 내용을 읽어서 반환한다. 인자로 넘겨 받은 `$file` 값이 없을 경우를 대비해, index.md 를 기본값으로 지정했다.
### 동작 테스트
app/Http/routes.php 에서 동작 테스트를 해 보자. [25강 - 컴포저](25-composer.md)에서 사용한 내용을 조금만 수정해서 사용할 것이다.
```php
Route::get('docs/{file?}', function($file = null) {
$text = (new App\Document)->get($file);
return app(ParsedownExtra::class)->text($text);
});
```
이전에 보지 못했던 `'docs/{file?}'` 엔드포인트가 먼저 눈에 띈다. 여기서 `file`을 'Route 파라미터'라고 한다. 파라미터는 중괄호로 싼다. 올드스쿨식으로 표현하자면 docs?file= 과 같다고 보면 된다. 파라미터로 받은 `$file`을 바로 뒤 콜백에 인자로 넘긴 것이 보일 것이다. 물음표는 `file` 파라미터가 있을 수도 있고 없을 수도 있다는 의미이다. 즉, docs, docs/any-text 를 모두 이 Route에서 처리한다는 의미이다.
25강에 `$text` 변수의 값을 Heredoc 으로 하드코드로 넣어 주었다면, 여기서는 Document 모델의 `get()` 메소드 요청으로 부터 받아왔다. 인스턴스 생성과 메소드 호출을 인라인으로 한 줄에 표현하기 위해 () 문법을 이용하였다.
서버를 부트업하고 테스트해 보자.

### 예외 처리
`Document` 모델에서 `abort(404)`를 던진 것에 대한 예외처리를 하자. [24강 - 예외 처리](24-exception-handling.md)에서 배운 app/Exceptions/Handler.php 에다, `or $e instanceof NotFoundHttpException` 을 추가해 주자.
```php
public function render($request, Exception $e)
{
if ($e instanceof ModelNotFoundException or $e instanceof NotFoundHttpException) {
return response(view('errors.notice', [
'title' => 'Page Not Found',
'description' => 'Sorry, the page or resource you are trying to view does not exist.'
]), 404);
}
return parent::render($request, $e);
}
```

---
- [목록으로 돌아가기](../readme.md)
- [27강 - Document 컨트롤러](27-document-controller.md)
================================================
FILE: lessons/27-document-controller.md
================================================
---
extends: _layouts.master
section: content
current_index: 28
---
# 실전 프로젝트 1 - Markdown Viewer
## 27강 - Document 컨트롤러
이번 강좌에서는 routes.php 에서 콜백으로 정의했던 내용을 컨트롤러로 옮기고, ParsedownExtra도 좀 더 편하게 사용할 수 있도록 커스텀 Helper Function을 만들어 보자.
### Custom Helper
라라벨 내장 Helper Function 처럼, 여기저기서 편하게 마크다운 컴파일러를 불러 쓸 수 있도록 하기 위해, app/helpers.php 를 만들자.
```php
function markdown($text) {
return app(ParsedownExtra::class)->text($text);
}
```
라라벨이 부트업될 때 helpers.php 파일도 포함시키기 위해, composer.json에 files 엔트리를 추가하자.
```json
"autoload": {
"classmap": ["..."],
"files": ["app/helpers.php"],
"psr-4": {"...": "..."},
},
```
autoload 파일을 리프레시하기 위해 아래 코맨드를 수행하자.
```bash
$ composer dump-autoload
# autoload가 잘 되었는지 tinker 로 테스트해 보자
$ php artisan tinker
>>> markdown('**bold**');
=> "
bold
"
```
### Document 컨트롤러를 만들자.
```bash
$ php artisan make:controller DocumentsController
```
잘 생각해 보면, DocumentsController도 마크다운으로 컴파일된 내용을 데이터로 하고 뷰를 리턴하는 `show()` 메소드 하나면 충분하다.
```php
document = $document;
}
public function show($file = null)
{
return view('documents.index', [
'index' => markdown($this->document->get()),
'content' => markdown($this->document->get($file ?: '01-welcome.md'))
]);
}
}
```
생성자(Constructor)에서 `App\Document` 인스턴스를 주입(Dependency Injection)했다. 뷰에 2개의 데이터를 바인딩하는데 `$index`는 왼쪽 사이드 바에 보여줄 목록이며, `$content`는 본문이다.
### Route를 정의하자.
Route 정의가 훨씬 간단해 졌다. [14강 - 이름 있는 Route](14-named-routes.md)에서 배운 것 처럼, Route 이름은 필수이다.
```php
Route::get('docs/{file?}', [
'as' => 'documents.show',
'uses' => 'DocumentsController@show'
]);
```
### 뷰를 만들자.
resources/views/documents/index.blade.php 를 만들자.
```html
@extends('master')
@section('content')
Documents Viewer
{!! $content !!}
@stop
```
서버를 띄우고 뷰에 잘 뿌려지는지 확인해 보자.

기본 기능은 이걸로 완료되었다. 다음 강의에서는 백엔드의 부하 감소를 위해 Cache 기능을 활용하는 것을 배우도록 하자. 좀 더 나이스하게 보이기 위해서 CSS 작업을 할 것인데, 빌드를 위해 Elixir도 사용해 볼 것이다.
---
- [목록으로 돌아가기](../readme.md)
- [26강 - Document 모델](26-document-model.md)
- [28강 - Cache](28-cache.md)
================================================
FILE: lessons/28-cache.md
================================================
---
extends: _layouts.master
section: content
current_index: 29
---
# 실전 프로젝트 1 - Markdown Viewer
## 28강 - Cache
우리가 사용한 마크다운 파일은 자주 변경되지 않는다. 즉, 매번 사용자가 요청할 때 마다 모델에서 요청한 파일을 읽어 들이고, ParsedownExtra 클래스 인스턴스를 만들어서 `text()` 메소드를 호출한다는 것은 서버의 CPU 및 메모리 자원뿐만아니라 사용자의 시간을 낭비하는 일이다. 이럴 때 필요한 것이 서버측 캐시이다. 캐시는 HTTP 요청에 대한 응답을 파일 또는 메모리에 저장해 두었다가 두번째 요청부터는 저장소에서 바로 꺼내어 주는 기능이다. 데이터베이스 드라이버와 마찬가지로 라라벨 캐시도 다양한 드라이버를 지원한다.
### 캐시 설정
.env 와 config/cache.php를 열어서 기본 캐시 드라이버 설정을 확인하자. `file` 드라이버를 사용하고 있고, 캐시 저장소는 storage/framework/cache 인 것을 알 수 있다. 우선 실습을 위해 `file` 드라이버를 그냥 사용하도록 하자.
```bash
CACHE_DRIVER=file
```
```php
'...' => '...',
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache'),
],
```
### 컨트롤러에 캐시 기능 추가
`DocumentsController::show()` 메소드를 수정할 것이다. 캐시는 `key => value` 저장소라는 것을 기억하자.
```php
public function show($file = '01-welcome.md')
{
$index = \Cache::remember('documents.index', 120, function () {
return markdown($this->document->get());
});
$content = \Cache::remember("documents.{$file}", 120, function() use ($file) {
return markdown($this->document->get($file));
});
return view('documents.index', compact('index', 'content'));
}
```
`Cache` Facade 를 이용한다.
`remember()` 메소드의 첫번 째 인자는 키 이름이다. 이 키 이름으로 데이터를 저장할 것이다. 두번째 인자는 캐시를 유지할 시간이다. 키 이름에 해당하는 값이 캐시 저장소에 없으면, 세번째 인자로 받은 콜백을 실행한다. 이 콜백에서 반환되는 값을 캐시 키 값으로 해서, 두번째 인자로 지정된 시간만큼 캐시 저장소에 가지고 있는다. 지정된 시간 내에 들어온 요청에 대해서는 세번째 인자인 콜백을 실행하지 않고, 캐시 저장소에서 키 값에 대응되는 값(value)를 찾은 후 반환하는 식이다.
이 예제에서는 키 이름을 `document.index` 와 `documents.문서이름`으로 지정했고, 첫 캐시가 적재된 이후 2시간 동안은 캐시에서 바로 응답하도록 했다. 가령, 8시 정각에 첫 요청이 들어와 새로운 캐시가 생성되었다면, 10시까지는 캐시에서 응답을 하다가, 10시 1초 이후에 요청이 들어오면 다시 콜백을 수행하고 캐시 저장소에 키와 값을 적재하게 된다. (무슨 얘긴지 이해 되었기를 바라는 마음에서 반복해서 설명했다.)
**`참고`** 앞 강의에서 계속 Closure라는 용어를 썼다. 이번 강의에서는 콜백이란 용어를 썼다. 엄격하게 다르지만, 지금은 같은 용어라고 생각하자.
**`참고`** "어떨 때는 클래스 앞에 `\` 를 쓰고 어떨 때는 안 쓰고 왜 그래요?" "이번에는 일부러 쓴 것이다." `\`는 루트 네임스페이스를 의미한다. 우리 컨트롤러는 `App\Http\Controllers` 네임스페이스 아래에 위치한다. 컴퓨터에서 절대경로와 상대 경로를 얘기할 때랑 마찬가지로, `Cache`라고 그냥 쓰면, `App\Http\Controllers\Cache`를 의미하게 되고(상대 경로), 그런 클래스는 존재하지 않는다. 해서 루트 (`\`) 네이스페이스에 존재하는 `Cache` 라고 명시적으로 쓴 것이다. 클래스를 시작하기 전에 `use` 키워드로 전체 경로를 표시해 주면 `\`를 생략할 수 있다. (이는 네임스페이스가 있는 모든 언어 마찬가지다. Java의 경우 `use` 대신 `import`를 사용한다. `import org.apache.http.client.HttpClient` 처럼.)
### 실험해 볼까?
서버를 띄우고, '/docs' Route를 처음으로 방문해 보자. 그리고, storage/framework/cache 아래에 캐시 파일이 생성된 것을 확인해 보자. 또, 'docs' Route를 다시 방문해 봐도, 우리 예제가 무겁지 않아서 별로 빨라진 것은 못 느낄 것이다. `Document::show()` 메소드에 `dd('reached')` 와 같은 디버그 코드를 넣고 다시 요청해 보자. 클라이언트 측의 요청이 캐시에 의해 바로 응답되고, `Document` 모델에 도달하지 않을 것을 알 수 있을 것이다. `docs/01-welcome.md` 를 수정하고 재요청해봐도 수정된 내용은 표시되지 않을 것이다, 2시간 동안은...
### 그럼, 2시간 내에 내용이 변경되면 어떻게 하지?
일단은 artisan CLI를 이용해 수동으로 지워 보자. 변경이 생기면 이벤트를 던져서 캐시를 초기화하는 것은 뒤에 다시 배운다.
```bash
$ php artisan cache:clear
```
### (OPTIONAL) Memcached
파일 캐시도 훌륭하다. 하지만 실전에서는 memcache나 redis와 같은 인 메모리(in-memory) 캐시를 주로 사용한다. 여기서는 memcached를 사용할 것이다.
.env 를 수정하자.
```bash
CACHE_DRIVER=memcached
```
먼저 이전에 사용했던 file 캐시를 삭제하고 memcached 를 설치하고 실행하자.
```bash
$ rm -rf storage/framework/cache/*
$ brew search memcache
$ brew install homebrew/php/php56-memcached # 자신의 php 버전에 맞는 모듈을 설치하자.
# 설치 종료 후 콘솔에 표시된 memcached 시작 코맨드를 잘 살펴보자.
# 필자의 장치에는 이미 설치되어 있어, 알고 있던 코맨드를 남긴다.
$ memcached -u memcached -d -m 30 -l 127.0.0.1 -p 11211
# 본 강좌랑 무관하므로 사용법은 구글링으로 찾아 보시길..
```
file 캐시에서 했던 방법과 동일한 방법으로 캐시 기능이 잘 동작하는 지 확인해 보자. 설정 값이 변경되었으므로 테스트 전에 `$ php artisan serve` 를 재기동하는 것을 잊지 말고.
**`참고`** 서버 측 뿐만 아니라, 클라이언트(브라우저) 측에서도 캐싱을 한다. 라라벨과 무관하므로 설명하지 않는다.
---
- [목록으로 돌아가기](../readme.md)
- [27강 - Document 컨트롤러](27-document-controller.md)
- [29강 - Elixir, 만병통치약?](29-elixir.md)
================================================
FILE: lessons/29-elixir.md
================================================
---
extends: _layouts.master
section: content
current_index: 30
---
# 실전 프로젝트 1 - Markdown Viewer
## 29강 - Elixir, 만병통치약?
**`필독`** 프론트엔드에 관심이 없다면 이 강좌를 읽을 필요 없다.
Elixir('엘릭서'라 읽음)은 만병통치약을 뜻하는 단어이다. 라라벨의 Elixir란 이름은 어디에서 유래되었는 지 모르겠다. Elixir는 여러 기능을 가지고 있지만, 함축하자면 프론트엔드 리소스, 그러니까 CSS 와 Javascript 같은 리소스의 빌드를 자동화하는 도구라고 생각할 수 있다. 여기서 말하는 빌드란 Minification, CSS Vendor Prefixing, 여러 파일 병합, Sass/Less/Coffee/Babel 스크립트의 컴파일, ... 등을 의미한다. 프론트엔드 빌드 자동화를 수행하는 구현체는 Ruby on Rails 에서 Asset Pipeline 이란 이름으로 세상에 먼저 소개되었고, 이후 Django Pipeline 등 여러 프레임웍에서 따라한 것으로 필자는 알고 있다. 라라벨도 그중 하나!!
라라벨 5 버전이 출시되고 Elixir 가 공개되기 이전에 개발자들은 [Gulp](http://gulpjs.com/)나 [Grunt](http://gruntjs.com/)를 이용하여 빌드 자동화 스크립트를 작성해 왔다. 하지만 라라벨 5의 Elixir는 기존 빌드 스크립트들의 복잡함과 어려움을 극도로 단순화하여, 초보자도 쉽게 사용할 수 있는 API를 제시하고 있다. (그럼에도 불구하고, 여전히 어렵고 복잡하다.)
### 필요한 프론트엔드 리소스 정의
우리 프로젝트에 필요한 프론트엔드 리소스는 무엇인가? 앞선 강좌를 통해 우린 이미 Twitter Bootstrap을 가져다 쓴 바 있다. 이번 강좌에서는 CDN 버전을 그대로 사용하지 않고, 우리 서비스의 로컬 리소스로 추가할 것이다. 그 외 아이콘을 쓰기 위한 FontAwesome, 앞으로 계속 진행될 학습을 위해 이 프로젝트 전용 스타일시트와 자바스크립트 파일을 만들 것이다. 강좌를 진행하면서 이 목록에 더 많은 프론트엔드 리소스들이 추가될 것이다.
- Twitter Bootstrap
- FontAwesome
- resources/assets/sass/app.scss
- resources/assets/js/app.js
이번 강좌를 통해, 위에 나열된 2개의 외부 의존성과 내가 만든 2개의 리소스에 대한 빌드 자동화 방법을 배워볼 것이다.
### 필요한 글로벌 툴들 설치
먼저 [nodejs](https://nodejs.org)가 필요하다. 공식 사이트를 방문한 후, 인스톨러를 다운로드 받아 클릭-클릭으로 설치할 수 있다. 이 글을 쓰는 시점에 4.2.2 LTS 버전과 5.0 두 개의 버전이 존재하는데, 4.2.2 LTS를 받자. 인스톨러를 통해 node 런타임과 npm (node package manager)가 동시에 설치된다.
```bash
$ node --version # v4.2.2
$ npm --version # 2.14.7
```
Elixir는 Gulp 툴의 Wrapper이다. Gulp에 의존한다는 얘기~ 고로, 설치하자. 개발 머신 전체에서 `gulp`란 명령을 쓸 수 있도록 글로벌로(`-g`)로 설치한다.
```bash
$ sudo npm install -g gulp
$ gulp --version # 3.9.0
```
3rd Party(남이 만든 것이란 의미. 내껀 1st Party라 한다) 스타일시트와 자바스크립트 리소스를 관리하기 위해 Bower 툴을 설치하자.
```bash
$ sudo npm install -g bower
$ bower --version # 1.6.5
```
여기까지 작업은 다른 프로젝트에서도 계속 사용하게 될 글로벌 패키지들의 설치로, 최초 딱 한번만 수행하면 된다. 이 다음부터 설명하는 것은 매 프로젝트마다 해 주어야 하는 설치 작업이다.
### Elixir 의존 패키지 설치 및 최초 실행
Elixir가 의존하는 node 패키지들을 설치하자. 설치할 패키지들은 package.json 파일에 정의되어 있으며, 라라벨 프로젝트가 생성될 때 같이 생성되었다. 아래 작업은 라라벨에서 `composer install` 했던 것과 동일한 작업이라 생각하면 된다.
```bash
$ npm install
```
node_modules 폴더 아래에 뭔가 잔뜩 설치되었을 것이다. Elixir가 잘 동작하는지 확인해 보자. 아래 코맨드를 수행하기 전에 public/css 디렉토리가 있는지 확인해 보자. 없을 것이다. 코맨드 실행 후 다시 확인해 보자.
```bash
$ gulp
```
### Bower 패키지 설치
php/composer.json, nodejs/package.json 처럼 Bower 용 패키지들을 담을 레지스트리 파일인, bower.json 을 만들자. 또, .bowerrc 파일로 Bower 패키지들의 설치 위치도 변경하자 (디폴트는 프로젝트 루트의 bower_components). 또, .gitignore 파일에 Bower 패키지 저장소가 버전 컨트롤에서 제외되도록 정의해 놓자.
```bash
$ touch bower.json && echo '{"name":"myProject"}' > bower.json # bower.json 파일을 만들고, JSON 오브젝트를 써 놓는다.
$ touch .bowerrc && echo '{"directory":"resources/assets/vendor","analytics":false}' > .bowerrc
$ echo '/resources/assets/vendor' >> .gitignore # >> 은 기존 파일에 내용 append의 의미다.
```
Bower 패키지를 설치하자. `--save-dev` 는 bower.json 파일에 devDependencies 로 써 놓는다는 의미이다. 버전 컨트롤에서 제외시켰는데, bower.json에 의존성을 써 놓았기 때문에, 다른 개발자가 프로젝트의 버전 컨트롤 저장소를 clone/pull 했을 때 `$ bower install` 코맨드로 의존성을 설치할 수 있다. 이 강좌에서는 scss 문법을 이용할 것이라, `bootstrap-sass` 패키지를 설치했다. resources/assets/vendor 디렉토리에 설치한 패키지들이 있는지 확인해 보자.
```bash
$ bower install bootstrap-sass --save-dev
$ bower install font-awesome --save-dev
```
### 휴~ 이제 빌드 스크립트를 쓰자.
`$ gulp` 명령에 반응하는 스크립트는 프로젝트 루트에 위치한 gulpfile.js 이다. 아래는 sass 컴파일하고, 자바스크립트들을 머지한 후, 파일 이름에 버전을 매기고, fonts 파일을 정해진 위치에 배포하는 gulpfile.js 스크립트이다.
```javascript
var elixir = require('laravel-elixir');
elixir(function (mix) {
mix
.sass('app.scss')
.scripts([
'../vendor/jquery/dist/jquery.js',
'../vendor/bootstrap-sass/assets/javascripts/bootstrap.js',
'app.js'
], 'public/js/app.js')
.version([
'css/app.css',
'js/app.js'
])
.copy("resources/assets/vendor/font-awesome/fonts", "public/build/fonts");
});
```
Elixir의 Javascript API 들은 대부분 관례로 정해진 상대 경로를 기준으로 하므로, [문서](http://laravel.com/docs/elixir)를 잘 살펴보고 이용해야 한다. 필자가 아는 관례들은 아래에 정리해 두었다.
API|Base Directory|Description
---|---|---
`less(src, output, options)`|resources/assets/less|less 문법으로 써진 스타일시트를 css로 컴파일
`sass(src, output, options)`|resources/assets/sass|sass, scss 문법으로 써진 스타일시트를 css로 컴파일
`styles(styles, output, baseDir)`|resources/assets/css|css 문법으로 써진 파일을 읽어서 머지하고 public/css로 배포
`coffee(src, output, options)`|resources/assets/coffee|coffee 문법으로 써진 자바스크립트를 js로 컴파일
`babel(scripts, output, baseDir, options)`|resources/assets/babel|ES6 또는 7 문법으로 써진 자바스크립트를 js로 컴파일
`scripts(scripts, output, baseDir)`|resources/assets/js|js 파일을 읽어서 머지하고 public/js로 배포
`copy(src, output)`|프로젝트 루트|`src` 파일/디렉토리를 `output`으로 복사
`version(src, buildPath)`|public|`src`로 넘겨 받은 css, js 파일을 버전을 부여하여 public/build/css, public/build/js 디렉토리로 배포
Bootstrap과 FontAwesome의 스타일시트들은 `styles()` API를 이용하지 않고, app.scss에서 import 하였다. 이 과정과 무관하므로 app.scss에 작성된 내용들은 설명하지 않으므로, 소스코드를 살펴 보기 바란다.
```css
@import "../vendor/bootstrap-sass/assets/stylesheets/bootstrap";
@import "../vendor/font-awesome/scss/font-awesome";
...
```
### Version & Cache Busting
브라우저 입장에서 보면 app.css 파일은 정적(static) 파일이다. 즉, 캐싱 기능이 있는 브라우저에서, 웹 서버에 요청을 해서 304 Not Modified 응답을 받으면, 브라우저는 로컬 캐시 저장소에 가지고 있던 파일을 사용한다. 웹 서버는 바보라 정적 파일의 변경을 눈치채지 못할 수 있다. 그래서 개발측에서 수정한 파일의 이름이 같으면, 캐시 정책이 만료되기 전에는, 웹 서버는 app.css 파일에 대한 브라우저의 요청에 계속 304로만 답하게 된다. 변경된 프론트엔드용 정적 리소스가 서비스에 바로 반영될 수 있도록 하는 테크닉이 Cache Busting의 핵심이다. 과거에는 app.css?t=1447665283 와 같이 랜덤 스트링을 붙여 브라우저가 매번 새로운 파일을 받아가도록 하기도 했었다.
라라벨 Elixir에서는 `version()` API를 이용해서 Cache Busting을 자동화 해 준다. 빌드된 파일들은 public/build 디렉토리에 저장되고, 뷰에서 `elixir()` Helper를 이용해서 사용할 수 있다. `$ gulp` 명령을 실행할 때 마다, public/build 아래의 .js, .css 파일명이 변경되는 것을 확인할 수 있을 것이다. 빌드를 위해 생성된 중간 파일들은 필요 없으므로, 버전 컨트롤에서 제외하자.
```bash
$ echo /public/css >> .gitignore
$ echo /public/js >> .gitignore
```
Cache Busting을 사용할 수 있도록 resources/views/master.blade.php 를 수정해 보자. `{{ elixir('css/app.css') }}`, `{{ elixir('js/app.js') }}`를 주목해 보자. `elixir()` Helper는 public/build/rev.manifest.json 의 내용을 읽어서 가장 최신으로 빌드된 리소스의 파일명을 쓸 수 있도록 하는 기능을 한다.
```html
Laravel 5 Essential
@yield('style')
@yield('content')
@include('footer')
@yield('script')
```
### 췍!
이제 카드를 까보자. resources/assets/sass/app.scss, resources/views/docs/index.md를 더 수정했다. 거듭말하지만, 프론트엔트 코스가 아니므로 자세한 설명은 하지 않으니, 코드를 참조하기 바란다.

**`참고`** 28강에서 우리가 캐시 기능을 추가했던 것을 기억하는가? 스타일시트나 뷰를 변경한 후, `$ php artisan cache:clear`를 해서 기존 캐시를 지워주어야 변경된 내용을 확인할 수 있다. 개발기간 중에는 불편할 수 있으니, 컨트롤러에서 캐시 기능을 주석처리해 놓는 것도 좋다. 다만, 버전 컨트롤에 업로드하기 전에 반드시 주석을 풀어줘야 한다.
**`참고`** Elixir 에는 liveReload 유사 기능을 내장하고 있다. 파일이 변경되면 브라우저에 바로 반영해 주는 기능이다. `browserSync()` 기능을 사용하려면, [공식문서](http://laravel.com/docs/elixir#browser-sync)를 확인해 보자.
### production 빌드
css vendor prefix, css minify, js uglify 까지 하면, production 환경에 적합한 프론트엔드 리소스를 빌드할 수 있다. `--production` 옵션을 이용하자.
```bash
$ gulp --production
```

---
- [목록으로 돌아가기](../readme.md)
- [28강 - Cache](28-cache.md)
- [30강 - Debug & Final Touch](30-final-touch.md)
================================================
FILE: lessons/30-final-touch.md
================================================
---
extends: _layouts.master
section: content
current_index: 31
---
# 실전 프로젝트 1 - Markdown Viewer
## 30강 - Debug & Final Touch
**`필독`** 프론트엔드에 관심이 없더라도, 1번과 2번은 백엔드 내용이므로 보도록 하자.
29강까지 작성한 우리 프로젝트에는 3가지 문제가 있다.
1. 이미지가 정상적으로 표시되지 않는다.
2. 브라우저의 이미지 캐시가 동작하지 않는다.
3. 코드 블럭에 Syntax Highlight가 없다.
### 이미지 표시하기
우리 이미지는 public 디렉토리 밖에, 그러니까 public 과 같은 레벨인 docs 디렉토리에 위치하고 있다. 즉 웹 서버가 접근할 수 없는 위치에 있다는 것이다. 이럴 경우에는 Route 에서 이미지 파일에 대한 요청을 받아서, 컨트롤러에서 DocumentRoot(==public) 바깥 쪽에 위치한 이미지를 찾아 정확한 Content-Type 으로 응답해 주어야 한다.
app/Http/routes.php 에 이미지 요청을 컨트롤러와 연결시키는 Route를 작성하자. 기존 문서 요청과 Route가 거의 동일하여, 이를 분리시키기 위하여 라라벨 Route의 글로벌 패턴이란 기능을 사용하였다. `Route::pattern()`의 첫번째 인자는 Route Parameter 이름, 두번째 인자는 정규표현식이다. 이 예제의 정규표현식은 01-welcome-image-01.png 에서는 매칭이 발생하고, 01-welcome.md 에서는 매칭이 발생하기 않는다. 해서, `Route::get('docs/{image}')`에서는 이미지 파일만 응답하고, `Route::get('docs/{file?}')` 에서는 나머지 docs로 시작하는 모든 경우에 대해 응답하게 된다.
```php
Route::pattern('image', '(?P[0-9]{2}-[\pL-\pN\._-]+)-(?Pimg-[0-9]{2}.png)');
Route::get('docs/{image}', [
'as' => 'documents.image',
'uses' => 'DocumentsController@image'
]);
Route::get('docs/{file?}', [
'as' => 'documents.show',
'uses' => 'DocumentsController@show'
]);
```
**`참고`** Route Parameter에 대한 패턴이, 가령 `{image}`에 대한 패턴이 여러군데 쓰이면 `Route::pattern()`과 같이 글로벌 패턴을 사용한다. 여러 곳에 쓰지 않고 특정 Route 에서만 사용된다면, `Route::get('docs/{image}', '...')->where('image', 'pattern')` 처럼 사용할 수도 있다.
**`참고`** 정규표현식의 작성 및 테스트를 위해서는 [Regex101](https://regex101.com/) 사이트를 이용할 것을 추천한다. 필자가 본 정규표현식 툴 중 최고이다.
이미지 응답을 만들기 위해서 intervention/image 패키지를 이용할 것이다. composer 로 설치하고, [매뉴얼](http://image.intervention.io/getting_started/installation#laravel)에 따라 config/app.php 파일에 서비스프로바이더, Facade도 추가하자.
```bash
$ composer require "intervention/image:2.3.*"
```
```php
//config/app.php
return [
...,
'providers' => [
...,
Intervention\Image\ImageServiceProvider::class,
],
'aliases' => [
...,
'Image' => Intervention\Image\Facades\Image::class,
]
];
```
이제 Image Facade를 이용할 수 있으니, Document 모델과 DocumentsController 를 수정하자. Document 모델에서 중복 제거를 위해 여러군데 refactoring 된 것을 확인할 수 있다.
```php
// app/Document.php
directory = $directory;
}
public function get($file = 'index.md')
{
return File::get($this->getPath($file));
}
public function image($file)
{
return Image::make($this->getPath($file));
}
private function getPath($file)
{
$path = base_path($this->directory . DIRECTORY_SEPARATOR . $file);
if (! File::exists($path)) {
abort(404, 'File not exist');
}
return $path;
}
}
```
`image()` 메소드에서 인자로 넘겨 받은 파일을 찾아 `\Intervention\Image\Image` 인스턴스를 리턴한다.
```php
// app/Http/Controllers/DocumentsController.php
document = $document;
}
public function show($file = '01-welcome.md')
{
...
}
public function image($file)
{
$image = $this->document->image($file);
return response($image->encode('png'), 200, [
'Content-Type' => 'image/png'
]);
}
}
```
`image()` 메소드에서 `Content-Type`을 png 타입으로 지정하고, 모델로 부터 넘겨 받은 이미지 인스턴스를 컨텐츠로 해서 HTTP 응답을 하고 있다.

### 브라우저 캐시 살리기
intervention/image 를 이용해서 만든 이미지 응답은 웹 서버가 접근할 수 있는 DocumentRoot(== public)에 있는 리소스로 만들어진 것이 아니다. 정해진 Route 규칙에 따라 요청하면 서버에서 이미지를 DocumentRoot 밖에서 찾아 반응하는 이미지 응답이기 때문에, 웹 서버가 브라우저 캐싱에 관여할 수 없다. 해서 수동으로 브라우저 캐싱 기능을 살려 주어야 한다.
구현을 위해 웹서버와 브라우저간의 캐싱 메카니즘을 이해해야 한다. 브라우저가 리소스(이 예제에서는 이미지) 요청을 할 때, 캐시에 요청할 URL 과 연결된 캐시가 있는 지 확인하고, 키 값을 얻어온다. 이 키 값은 이전 동일 URL 요청에서 서버가 Etag 헤더 값으로 응답한 것이다. 얻어온 키 값은 If-Non-Match 헤더의 값으로 지정하고 서버에 리소스를 요청한다. 요청을 받은 서버는 If-Non-Match 헤더의 값과 URL 에 해당하는 서버의 Etag 로직에 의해 생성된 값을 비교하여, 같으면 304 Not Modified를, 다르면 200 OK와 함께 요청한 리소스를 HTTP 바디로 해서 응답한다. 아래 그림을 보자.

*`Image Source` ["Etags and browser cache, June 26 2015"](http://thespringthing.blogspot.kr/2015/06/etags-and-browser-cache.html)*
먼저 Etag를 만드는 로직을 Document 모델에 구현하자. `md5()` php 내장 해시 함수를 사용하였다. Etag 값은 고유하기만 하면 어떤 문자열을 사용해도 상관없다.
```php
class Document
{
...
public function etag($file)
{
return md5($file . '/' . File::lastModified($this->getPath($file)));
}
...
}
```
DocumentsController 에서 요청으로 받은 Etag값과 Document 모델에서 생성한 Etag 값과 비교하여 같으면 304, 같지 않으면 200을 응답하는 로직을 만들어보자.
```php
class DocumentsController extends Controller
{
...
public function image($file)
{
$image = $this->document->image($file);
$reqEtag = Request::getEtags();
$genEtag = $this->document->etag($file);
if (isset($reqEtag[0])) {
if ($reqEtag[0] === $genEtag) {
return response('', 304);
}
}
return response($image->encode('png'), 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'public, max-age=0',
'Etag' => $genEtag,
]);
}
}
```

### Syntax Highlight 적용하기
Syntax Highlight를 위한 Bower 콤포넌트를 설치하자!
```bash
$ bower install google-code-prettify --save-dev
```
gulpfile.js에 방금 다운로드 받은 패키지를 추가하자.
```javascript
elixir(function (mix) {
mix
.sass('...')
.scripts([
'...',
'../vendor/google-code-prettify/src/run_prettify.js',
'app.js'
], 'public/js/app.js')
....
});
}
```
해당 패키지에 대한 [사용법은 여기](https://code.google.com/p/google-code-prettify/wiki/GettingStarted)서 익혔다. 마크다운 컴파일러는 코드블럭을 `
` 태그로 컴파일 한다. 따라서, `
` 태그에 방금 설치한 google-code-prettify의 스타일시트가 반응하도록 클래스를 추가해 줄 것이다. 마크다운 문서에 직접 추가할 수 없으니, resources/assets/js/app.js에 jQuery를 이용해 동적으로 추가해 보자.
```javascript
$("pre").addClass("prettyprint");
```
gulp 커맨드로 다시 빌드하고, 캐시도 지운 후 Syntax Highlight가 작 적용되었는지 확인해 보자.
```bash
$ gulp --production
$ php artisan cache:clear
```

---
- [목록으로 돌아가기](../readme.md)
- [29강 - Elixir](29-elixir.md)
================================================
FILE: lessons/31-forum-features.md
================================================
---
extends: _layouts.master
section: content
current_index: 32
---
# 실전 프로젝트 2 - Forum
댓글이 가능한 간단한 포럼을 구현해 본다. 이를 통해 라라벨의 Request & Response 라이프싸이클에 대한 이해를 높인다. 뿐만 아니라, CRUD, Event, File/Image Upload, 인증과 권한부여 등에 대해 배워볼 예정이다.
## 31강 - 포럼 요구사항 기획
[StackOverflow](http://stackoverflow.com/) 또는 [라라캐스트 포럼](https://laracasts.com/discuss)을 머릿 속에 떠올리며 어떤 기능들이 필요할지?, 최종 결과물은 어떤 모습을 하고 있을 지?, 개발해야 할 항목들을 하나씩 정리해 보자. 일단 쭈욱~ 나열하고, 강좌 진행 상황에 따라 개발 항목을 삭제하거나, 추가하도록 하자.
- **사용자 인증 및 역할 기반 접근 제어**
- 네이티브 인증
- 소셜 인증
- admin, member 역할에 따른 접근 제어
- **고품질의 레이아웃/UI**
- 메뉴, 사용자 등록 폼, 로그인 폼
- Gravatar로 사용자 사진을 보여준다.
- 모바일에 대응한다.
- 영어, 한글 2가지 언어를 지원한다.
- **포럼**
- 포럼 생성, 포럼 목록, 포럼 상세 보기, 포럼 수정, 포럼 삭제
- 권한이 있는 사용자만 포럼을 생성할 수 있다.
- 관리자 또는 포럼 소유자만이 포럼을 수정 또는 삭제 할 수 있다.
- 파일 또는 이미지를 첨부할 수 있다.
- 마크다운 문법을 지원한다. 작성 중 프리뷰 기능을 쓸 수 있다.
- 태그 기능을 지원한다.
- 필터 기능을 지원한다.
- 포럼 생성시 지정된 사용자에게 이메일을 발송한다.
- 포럼 생성시 댓글에 대한 알림 기능을 토글할 수 있다.
- "답변됨" 기능을 지원한다.
- Full Text Search 기능을 지원한다.
- **댓글**
- 댓글의 댓글 기능을 지원한다.
- 포럼과 댓글은 one to many 관계를 가진다. (포럼 삭제시 댓글 삭제)
- 권한이 있는 사용자만 댓글을 생성할 수 있다.
- 관리자 또는 댓글 소유자만이 댓글을 수정 또는 삭제할 수 있다.
- 마크다운 문법을 지원한다.
- 포럼 생성자가 알림을 켜 놓은 경우, 댓글 생성시 연결된 포럼 생성자에게 이메일을 발송한다.
- **포럼/댓글 공통**
- 성능 향상을 위해 서버 사이드 캐싱을 이용한다.
---
- [목록으로 돌아가기](../readme.md)
- [32강 - 사용자 로그인](32-login.md)
================================================
FILE: lessons/32-login.md
================================================
---
extends: _layouts.master
section: content
current_index: 33
---
# 실전 프로젝트 2 - Forum
실습을 진행하기 전에 기존에 만들었던 파일 중, 쓰지 않을 파일이나, 쓰지 않을 코드 블럭들을 삭제할 것을 권장한다. 정리하지 않아도 무방하긴 하지만...
## 32강 - 사용자 로그인
기본기 16강, 17강에서 배운 내용을 기반으로 사용자 로그인 기능을 만들어 보자. 여러개로 쪼개기도 뭣 하고... 그리고, 진도를 좀 많이 빼기 위해, 이번 강좌에서 욕심을 좀 냈으니 지치지 말고 따라해 주기 바란다.
### Route 정의
app/Http/routes.php에 사용자 등록, 로그인/아웃, 비밀번호 초기화, 홈페이지, 로그인 후 이동할 페이지 등에 사용할 엔드포인트를 먼저 만들자. 서비스에 사용할 수 있을 만큼의 퀄리티를 내기 위해 이번 강좌부터는 코드량이 좀 많다.
```php
Route::get('/', [
'as' => 'root',
'uses' => 'WelcomeController@index'
]);
Route::get('home', [
'as' => 'home',
'uses' => 'WelcomeController@home'
]);
/* User Registration */
Route::group(['prefix' => 'auth', 'as' => 'user.'], function () {
Route::get('register', [
'as' => 'create',
'uses' => 'Auth\AuthController@getRegister'
]);
Route::post('register', [
'as' => 'store',
'uses' => 'Auth\AuthController@postRegister'
]);
});
/* Session */
Route::group(['prefix' => 'auth', 'as' => 'session.'], function () {
Route::get('login', [
'as' => 'create',
'uses' => 'Auth\AuthController@getLogin'
]);
Route::post('login', [
'as' => 'store',
'uses' => 'Auth\AuthController@postLogin'
]);
Route::get('logout', [
'as' => 'destroy',
'uses' => 'Auth\AuthController@getLogout'
]);
});
/* Password Reminder */
Route::group(['prefix' => 'password'], function () {
Route::get('remind', [
'as' => 'reminder.create',
'uses' => 'Auth\PasswordController@getEmail'
]);
Route::post('remind', [
'as' => 'reminder.store',
'uses' => 'Auth\PasswordController@postEmail'
]);
Route::get('reset/{token}', [
'as' => 'reset.create',
'uses' => 'Auth\PasswordController@getReset'
]);
Route::post('reset', [
'as' => 'reset.store',
'uses' => 'Auth\PasswordController@postReset'
]);
});
```
`Route::group()`은 여러개의 Route에 공통된 Prefix Url, Route Name을 붙이거나, 미들웨어를 동시에 적용할 때 사용할 수 있다. 여기에 사용한 모든 컨트롤러와 메소드는 라라벨 기본으로 내장되어 배포되는 `App\Http\Controllers\Auth\AuthController`, `App\Http\Controllers\Auth\PasswordController`의 것을 그대로 사용한 것이다.
app/Http/Controllers/WelcomeController.php 는 별도로 만들어 주고, Route 와 연결된 메소드를 써 주어야 한다.
```bash
$ php artisan make:controller WelcomeController
```
```php
middleware('auth', ['only' => ['home']]);
}
public function index()
{
return view('index');
}
public function home()
{
return view('home');
}
}
```
16~17강에서 'auth' 미들웨어를 배운것을 떠올려 보자. `Route::get('url', ['middleware' => 'auth', ...]);` 식으로 썼을 것이다. Route 대신 컨트롤러에서 메소드별로 미들웨어를 적용할 수 있는데, 위 예와 같이 생성자 메소드에서 `$this->middleware('middleware-to-use')` 식으로 쓴다. 그리고, 두번째 인자로 `only` 키워드를 사용했는데, 지정된 메소드에서만 이 미들웨어를 적용하겠단 의미이다. 즉, 여기서는 `home()` 메소드에 접근하기전에 'auth' 미들웨어를 거쳐야 하고, 'auth' 미들웨어에 의해 로그인되어 있지 않을 경우, 'auth/login' Route 로 이동하게 된다.
Route가 잘 정의되었는지 확인해 보자. 에러가 안났다는 것은 엔드포인트와 컨트롤러의 메소드가 잘 연결되었다는 의미이다.
```bash
$ php artisan route:list
```

### 마스터 템플릿을 손보자!
먼저, 뷰 디렉토리를 좀 더 구조화 하기 위해, 기존의 master.blade.php 파일은 resources/views/layouts/master.blade.php 로 이동하였다.
좀 더 있어 보이는 레이아웃을 위해 [Bootstrap 사이트](http://getbootstrap.com/getting-started/)에서 괜찮은 템플릿을 좀 훔쳐와서, resources/views/layouts/master.blade.php 에 적용해 보았다. 구조화를 위해 navigation.blade.php, footer.blade.php 로 내용을 좀 나누었으니 코드를 살펴 보자.
내친 김에 플래시 메시지도 사용할 것이다. 플래시 메시지란 컨트롤러에서 세션에 구워 뷰에 전달할 메시지를 의미한다. 뷰에서는 `Session::get('key')` 로 값을 얻을 수 있다. 이 프로젝트에서는 laracasts/flash 패키지를 이용할 것이다.
```bash
$ composer require "laracasts/flash:1.3.*"
```
```php
// config/app.php
'providers' => [
...
Laracasts\Flash\FlashServiceProvider::class,
],
'aliases' => [
...
'Flash' => Laracasts\Flash\Flash::class,
],
```
```html
Laravel 5 Essential
@yield('style')
@include('layouts.partial.navigation')
@include('layouts.partial.flash_message')
@yield('content')
@include('layouts.partial.footer')
@yield('script')
```
`@include` 로 하위 뷰들을 포함하고 있다. ``는 자바스크립트에서 XHR 요청을 할 때 사용하기 위해 포함시켜 놓은 것이다 ([공식 문서 참고](http://laravel.com/docs/routing#csrf-protection)).
```html
```
`@if(! auth()->check())` 로 로그인이 안되어 있으면 로그인과 사용자 등록 링크를, 로그인되어 있으면 메뉴들과 로그아웃 링크를 보여 주도록 뷰를 분기시키고 있다.
```html
@if (session()->has('flash_notification.message'))
{{ session('flash_notification.message') }}
@endif
@if ($errors->has())
Some errors found in the form. Please review and correct them and retry !
@endif
```
첫번 째 블럭은 세션에 flash_notification으로 시작하는 값이 있으면 [Bootstrap CSS로 나이스하게 디자인된 Alert](http://getbootstrap.com/components/#alerts)를 보여 준다. 두번째 블럭은 23강 유효성 검사에서 배운 세션에 구워 놓은 `$errors` 값이 있으면 폼을 다시 한번 체크하라고 Alert를 띄워 준다.
```html
```
다국어 지원할 것을 대비해 footer 영역에 미리 링크를 준비해 놓았다.
resources/views/index.blade.php, resources/views/home.blade.php 뷰 파일들은 각자의 취향에 맞게 적절한 내용을 담아 만들도록 하자.

### 뷰를 만들자.
이제 사용자 등록, 로그인, 비밀번호 초기화 폼을 만들 것인데, `App\Http\Controllers\Auth\AuthController`, `App\Http\Controllers\Auth\PasswordController` 의 메소드들에 미리 정의된 뷰의 이름들을 잘 확인하고 뷰 파일을 만들자. 필자는 기본 내장된 컨트롤러 메소드 이름이 굉장히 헷갈려서 맘에 들지 않아, 아래 테이블로 정리해 보았다.
Route|Route 이름|컨트롤러 메소드|연결된 뷰|뷰의 역할
---|---|---|---|---
/|index|WelcomeController@index|index|인덱스 페이지
home|home|WelcomeController@home|home|로그인한 후 이동할 페이지
auth/register|user.create|AuthController@getRegister|auth.register|사용자 등록 폼
auth/login|session.create|AuthController@getLogin|auth.login|사용자 로그인 폼
auth/remind|reminder.create|PasswordController@getEmail|auth.password|비밀번호 초기화 링크 요청 이메일 발송 폼
| | |emails.password|비밀번호 초기화 링크를 담은 이메일 뷰
auth/reset/{token}|reset.create|PasswordController@getReset|auth.reset|비밀번호 초기화 폼
**`참고`** `getRegister()` 메소드는 `Illuminate\Foundation\Auth\RegistersUsers` trait에서, `getLogin()` 메소드는 `Illuminate\Foundation\Auth\AuthenticatesUsers` trait에서, `getEmail()` 와 `getReset()` 메소드는 `Illuminate\Foundation\Auth\ResetsPasswords` trait에서 각각 찾아야 한다. 이들 trait들을 `App\Http\Controllers\Auth\AuthController`, `App\Http\Controllers\Auth\PasswordController`가 use 키워드로 사용하고 있다.
```html
@extends('layouts.master')
@section('content')
@stop
```
스샷에서 입력값 유지, 유효성 검사, 앞 절에서 설치한 플래시메시지 등 모든 기능이 동작하는 것을 확인할 수 있다.

```html
@extends('layouts.master')
@section('content')
@stop
```
`` 의 값은 `Auth::attempt(array $credentials, bool $remember)` 메소드의 2번째 인자로 전달된다. 2번째 인자 없이 로그인하면 2시간동안 로그인 세션이 유지된다. 체크박스에 체크가 되어 `true` 값이 전달되면, 5년동안 로그인이 유지된다.

```html
@extends('layouts.master')
@section('content')
@stop
```

```html
Click here to reset your password: {{ route('reset.create', $token) }}
```
.env 파일에서 `MAIL_DRIVER=log`로 바꾸어 놓고, 비밀번호 초기화를 위한 이메일이 잘 나가는지 확인해 보았다.

```html
@extends('layouts.master')
@section('content')
@stop
```
비밀번호 초기화 이메일에서 받은 링크를 브라우저에 붙여 넣어 비밀번호를 초기화할 수 있다.

디자인을 위해 resources/assets/sass/app.scss 도 일부 내용이 변경되었다. 상세 설명은 생략하니, 코드를 참고하기 바란다.
---
- [목록으로 돌아가기](../readme.md)
- [31강 - 포럼 요구사항 기획](31-forum-features.md)
- [33강 - 소셜 로그인](33-social-login.md)
================================================
FILE: lessons/32n33-auth-refactoring.md
================================================
---
extends: _layouts.master
section: content
current_index: 41
---
# 실전 프로젝트 2 - Forum
## 32/33 보충 - 인증 리팩토링
잠깐 쉬어가자~ 32강 33강에서 구현했던 라라벨 내장 인증 Route와 컨트롤러의 메소드가 맘에 들지 않아, 인증 레이어를 다시 구성하고, Integration Test 부분도 추가했다.
또, 앞 강의에서 발생한 버그들도 수정했다.
### 인증 재구현
라라벨 내장 인증 기능을 사용함에 있어서 Route 정의에 연결된 'AuthController'의 메소드들이 trait 와 프레임웍 속에 숨어 있어서, 읽기도 쉽지 않을 뿐더러, 필요에 의해서 내부를 수정하면, 다음 프레임웍 업데이트 때, 나의 수정 내용이 엎어쳐지는 불상사가 발생할 수 있다. 라라벨에 내장된 인증 구현에 대한 의존성을 버리기 위해서,
- `App\Http\Controllers\UsersController` (가입)
- `App\Http\Controllers\SessionsController` (로그인/아웃)
- `App\Http\Controllers\SocialController` (소셜 로그인)
- `App\Http\Controllers\PasswordsController` (비밀번호 재설정)
으로 각각 분리하여 작성하였다.
#### 문제점 인식
서비스마다 로그인에 대한 정책이 있기 마련인데, 필자는 우리 서비스(이하 'myProject')의 사용자들이 소셜 로그인도 할 수 있고, 네이티브 로그인도 할 수 있게 하고 싶다. 여기서는 사용자의 Github 이메일 계정과, myProject 에 회원 가입한 이메일 계정이 동일할 경우만을 가정한다.
**`참고`** Github 이메일과 myProject의 사용자 이메일 주소가 서로 다르다면, 두 개의 서로 다른 계정으로 인식될 것이다. 즉, myProject 에 들어와서 사용자가 생성하는 Article, Comment 등의 모델은 그때 그때 로그인한 소셜 또는 네이티브 사용자에게 속할 것이다. 프로그램적으로 해결할 수 있는 문제가 아니므로, 정책적으로 해결하거나, 사용자가 수동으로 계정을 연결할 수 있는 방법과 장치를 제공해야 할 것이다.
기존 소셜 로그인 코드를 잠시 살펴 보자. 아래 코드에서 `User::firstOrCreate()` 메소드에서 Github 로 부터 받은 정보를 이용하여 **'password'가 없는 사용자를 생성**하고 있다는 것을 기억하고 있자.
```php
// 기존 코드
// app/Http/Controllers/Auth/AuthController.php
public function handleProviderCallback()
{
$user = \Socialite::driver('github')->user();
$user = User::firstOrCreate([
'name' => $user->getName(),
'email' => $user->getEmail(),
]);
...
}
```
아래와 같은 경우의 수가 발생할 수 있다.
1. myProject 에 회원 가입을 먼저하고, 소셜 로그인을 시도하는 경우
1. 사용자 이름까지 같을 경우 -> 로그인 됨
2. **사용자 이름이 다르면, 사용자 생성을 시도하게 됨 -> 동일 이메일을 가진 User 가 하나 더 생김**
2. 소셜 로그인을 하여, 빈 'password'를 가진 User 모델이 생성된 상태에서, 네이티브 인증을 시도하는 경우
1. 'password' 가 없으므로 네이티브 로그인 불가능
2. myProject 에 회원 가입을 시도하는 경우, **같은 이메일을 가진 User 가 이미 있으므로 가입 불가능**
다음과 같은 방법으로 해결해 보자.
1. myProject 에 회원 가입을 먼저하고, 소셜 로그인을 시도하는 경우
1. `firstOrCreate()` 부분을 'email'로 먼저 쿼리하고, 없으면 User를 생성하는 로직으로 다시 작성하자.
2. 소셜 로그인을 하여, 빈 'password'를 가진 User 모델이 생성된 상태에서...
1. myProject 에 회원 가입을 시도하는 경우, 기존 소셜 로그인으로 'password' 없이 생성된 계정에 'password'를 업데이트한다.
2. 비밀번호 재설정을 시도하는 경우, 소셜 로그인 사용자라고 안내한다.
우선, 기존 마이그레이션의 'password' 필드에 `nullable()` 속성을 추가하였다. 수정했으면 `$ php artisan migrate:refresh --seed`
```php
// database/migrations/create_users_table.php
public function up()
{
// ...
$table->string('password', 60)->nullable();
}
```
#### Route 정의

잘 보면, `Route::get('social/{provider}', 'SocialController@execute')` 부분에서 'social/github', 'social/facebook' 등으로 소셜 로그인 공급자를 더 붙일 수 있도록 Route 구조를 좀 변경할 것을 확인할 수 있다.
```php
// app/Http/routes.php
/* User Registration */
Route::get('auth/register', [
'as' => 'users.create',
'uses' => 'UsersController@create'
]);
Route::post('auth/register', [
'as' => 'users.store',
'uses' => 'UsersController@store'
]);
/* Social Login */
Route::get('social/{provider}', [
'as' => 'social.login',
'uses' => 'SocialController@execute',
]);
/* Session */
Route::get('auth/login', [
'as' => 'sessions.create',
'uses' => 'SessionsController@create'
]);
Route::post('auth/login', [
'as' => 'sessions.store',
'uses' => 'SessionsController@store'
]);
Route::get('auth/logout', [
'as' => 'sessions.destroy',
'uses' => 'SessionsController@destroy'
]);
/* Password Reminder */
Route::get('auth/remind', [
'as' => 'remind.create',
'uses' => 'PasswordsController@getRemind',
]);
Route::post('auth/remind', [
'as' => 'remind.store',
'uses' => 'PasswordsController@postRemind',
]);
Route::get('auth/reset/{token}', [
'as' => 'reset.create',
'uses' => 'PasswordsController@getReset',
]);
Route::post('auth/reset', [
'as' => 'reset.store',
'uses' => 'PasswordsController@postReset',
]);
```
**`중요`** Route 정의가 변경되었으므로, 회원 가입, (소셜) 로그인, 비밀번호 재설정 관련해서 기존에 뷰 코드에 박아 놓았던, `route()` Helper 를 이용한 링크들을 모두 수정해 주어야 한다.
**`참고`** 기존에 'resources/views/auth' 아래에 위치하던 뷰들도 'users', 'sessions', 'passwords' 아래로 옮기고, 이름도 적절히 변경하였다. 각 컨트롤러에서 `view()`를 반환할 때 바뀐 위치로 적용해 주어야 한다.
#### 'SocialController(소셜 로그인)' 구현
앞 절의 Route 정의에서 `Route::get('social/{provider}', 'SocialController@execute')`로 썼다. 소셜 인증 과정에는 Github 'Authorize application' 페이지로 이동하는 Route 하나, 앞 과정에서 사용자가 승인하면 Github 에서 myProject 로 콜백해 주는 Route 총 두 개가 필요했던 것을 `execute()` 하나로 줄였다. `execute()` 메소드에서 myProject의 Router로 들어오는 HTTP 요청 쿼리스트링의 'code' 필드의 유무에 따라 분기시킨 것이다. 전체적인 과정은 아래 그림을 참조하자.

`$user = (\App\User::whereEmail($user->getEmail())->first()) ?: \App\User::create([...]);` 부분에서 기존의 `firstOrCreate()` 메소드를 다시 썼다. 'email' 로만 쿼리해서 있으면 myProject 에 로그인해 주고, 해당 'email'을 가진 레코드가 없으면, 'email', 'name' 필드를 가진 사용자를 생성시키도록 수정하였다.
```php
// app/Http/Controllers/SocialController.php
class SocialController extends Controller
{
public function execute(Request $request, $provider)
{
if (! $request->has('code')) {
return $this->redirectToProvider($provider);
}
return $this->handleProviderCallback($provider);
}
protected function redirectToProvider($provider) { // ...}
protected function handleProviderCallback($provider)
{
$user = $this->socialite->driver($provider)->user();
$user = (\App\User::whereEmail($user->getEmail())->first())
?: \App\User::create([
'name' => $user->getName(),
'email' => $user->getEmail(),
]);
// ...
}
}
```
Route 가 바뀌었으므로, [Github Developer Applications Console](https://github.com/settings/developers)를 방문하여 Authorization callback URL 을 'http://localhost:8000/social/github' 로 변경하여야 한다.
**`참고`** `handleProviderCallback()` 에서 보통은 Github 에서 받은 사용자 정보로 폼을 포함한 뷰를 한번 더 사용자에게 보여주며, 다른 이메일, 비밀번호, 사용자 이름 등을 더 받아 `App\User` 모델로 저장하는 것이 정석이다. 이렇게 구현하면, 위에서 언급한 복잡한 Sync 과정이 불필요하다.
#### UsersController(가입) 구현
기존 대비 특별히 달라진 부분들만 살펴보도록 하자.
`store()` 메소드에서는, 먼저 `App\User` 모델에 새로 정의한 `noPassword() (== whereNull('password'))` 란 [쿼리스코프](http://laravel.com/docs/eloquent#query-scopes)를 이용하여 회원 가입 요청에서 사용자가 제출한 'email' 값과 일치하고, 'password' 가 `null` 인 사용자를 찾는다. 이는 소셜로 로그인하면서 생성된 계정을 의미한다. 소셜로 생성된 계정이면 `syncAccountInfo()` 메소드로 처리 로직을 위임하고, 소셜 로그인 이력이 없는 회원 가입 요청이면 `createAccount()` 메소드로 위임했다.
`syncAccountInfo()` 메소드에서는, 사용자가 회원 가입 폼에 입력한 값들에 대한 유효성 검사를 수행하고, 통과하면 폼에서 넘겨 받은 'password' 값으로 기존에 `null` 이던 값을 대체하였다. Github 사용자 이름과, myProject 에서 사용하는 이름이 다를 수 있으므로, 'name' 필드도 넘겨 받은 값으로 업데이트하였다. 여기서 주목할 점은 `createAccount()` 메소드와 폼 데이터에 대한 유효성 검사 규칙이 약간 다르다는 것이다. 소셜 로그인으로 이미 사용자는 생성된 상태이므로, 'email' 필드 검사 규칙에서 'unique:users' 규칙이 빠졌다.
`createAccount()` 는 기존과 크게 달라진 점이 없다.
```php
// app/Http/Controllers/UsersController.php
class UsersController extends Controller
{
public function store(Request $request)
{
if ($user = User::whereEmail($request->input('email'))->noPassword()->first()) {
// Filter through the User model to find whether there is a social account
// that has the same email address with the current request
return $this->syncAccountInfo($request, $user);
}
return $this->createAccount($request);
}
protected function syncAccountInfo(Request $request, User $user)
{
$validator = \Validator::make($request->except('_token'), [
'name' => 'required|max:255',
'email' => 'required|email|max:255',
'password' => 'required|confirmed|min:6',
]);
if ($validator->fails()) {
return back()->withInput()->withErrors($validator);
}
$user->update([
'name' => $request->input('name'),
'password' => bcrypt($request->input('password'))
]);
\Auth::login($user);
flash(trans('auth.welcome', ['name' => $user->name]));
return redirect(route('home'));
}
protected function createAccount(Request $request) {// ...}
}
```
#### SessionsController(로그인/아웃) 구현
여기서는 기존 대비 크게 달라진 점이 없이, trait나 프레임웍 속에 숨어 있던 것을 도메인레이어로 옮겨 놓았을 뿐이므로, 설명을 생략한다.
#### PasswordsController(비밀번호 재설정) 구현
myProject 에 가입하지 않고, 소셜 로그인으로만 사용하던 사용자가 갑자기 비밀번호 재설정 시도를 할 경우를 대비한 방어조치만 추가했다. 나머지 부분들은 기존 대비 크게 다르지 않다.
```php
// app/Http/Controllers/PasswordsController.php
public function postRemind(Request $request)
{
// ...
if (User::whereEmail($request->input('email'))->noPassword()->first()) {
flash()->errors(sprintf("%s %s", trans('auth.social_olny'), trans('auth.no_password')));
return back();
}
//...
}
```
#### 인증 관련 통합 테스트 추가
설명은 생략하지만, 'tests/Http/Controllers' 디렉토리 하위에 포함된 코드들을 살펴보고, 테스트를 수행해 볼 것을 권장한다. 필자가 아직은 실력이 미천해서 외부로 HTTP 요청이 발생하는 'SocialController' 와 이메일을 보내야 하는 'PasswordsController' 부분은 테스트 코드를 쓰지 못했다 (PR 또는 가르침 환영합니다 ^^/)
```bash
$ phpunit
```
### 디버그 및 자잘한 개선
'config/services.php' 에 하드코드로 박아 놓았던 Github 소셜 로그인 관련 설정 값들을 '.env'로 옮겼다.
로그인 하지 않은 상태에서 `@if (auth()->user()->isAdmin() ...)` 이 들어간 뷰를 방문하면 null 포인터 에러가 나는 버그가 있었다. `@if ($currentUser and ($currentUser->isAdmin() ...))` 으로 변경하였다. 로그인을 하면 `auth()->user()` 는 로그인한 사용자에 해당하는 `App\User` 인스턴스를 반환하는데, 로그인 되어 있지 않으면 `null`을 반환한다. `null->isAdmin()` 은 당연히 성립할 수 없는 코드이다.
아울러, 뷰 코드에 `Auth::check()` 로 된 부분들도 `App\Http\Controlelrs\Controller::__construct()` 에서 뷰에 공유한 `$currentUser` 변수로 대체하였다. 만들었으면 써야 하기에...
코드에디터(또는 IDE)에서 Cmd + Mouse Click 으로 , Facade 에 연결된 메소드로 이동을 쉽게 하기 위해서 ['barryvdh/laravel-ide-helper'](https://github.com/barryvdh/laravel-ide-helper) 패키지를 Dev Dependency 로 추가하였다. `$ composer require barryvdh/laravel-ide-helper --dev`. 설정법은 스스로 찾아서 적용하기 바라고, 설명하고 싶었던 것은 다른 것이다. 개발 과정 중에만 필요한 패키지를 설치하고, 해당 패키지가 제공하는 ServiceProvider를 'config/app.php' 에 등록할텐데, production 서버에 올릴 때 매번 불필요한 패키지 라인을 주석처리 하는 것은 귀찮은 일이다. 이때 `App\Providers\AppServiceProvider` 를 이용하면 편리하게 local 또는 dev 환경일 때만 로드되도록 할 수 있다. 참고로 아래 코드에서 `$this->app->environment()` 는 `\App::environment()` 또는 `app()->environment()` 로도 쓸 수 있다.
```php
// app/Providers/AppServiceProvider.php
public function register()
{
if ($this->app->environment('local')) {
$this->app->register(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class);
}
}
```
---
- [목록으로 돌아가기](../readme.md)
- [39강 - Attachment 기능 구현](39-attachments.md)
- [40강 - Comment 기능 구현](40-comments.md)
================================================
FILE: lessons/33-social-login.md
================================================
---
extends: _layouts.master
section: content
current_index: 34
---
# 실전 프로젝트 2 - Forum
## 33강 - 소셜 로그인
소셜 로그인을 지원하지 않는 서비스는 구닥다리로 느껴진다. 소셜 로그인은 내 서비스에 들어 온 사용자의 신분확인을 제 3자에게 위탁(아웃소싱)하는 행위라 보면 되며, 기술적인 백그라운드는 Oauth 이다 (동작 시퀀스가 궁금하다면 [조대협님의 블로그](http://bcho.tistory.com/913)를 참조하자).
간단히만 설명하자면... 내 서비스에서 사용자가 소셜로그인 버튼을 누르면, 우리 서비스('myProject'라 하자.)에 대한 여러가지 정보를 쿼리스트링으로 담고 소셜로그인 서비스 제공자의 페이지로 이동하게 된다. 표시된 UI 에서 사용자가 승인을 누르게 되는데, 이는 "내가 myProject 서비스에 이 소셜서비스의 권한을 위임해 줄게. 다는 안되고 UI 아래에 표시된 scope로 지정된 만큼만..." 의 의미이다. 즉 myProject는 소셜로그인 서비스를 통해 사용자의 신원을 확인하게 되는 과정인 것이다.

이 코스에서는 github의 소셜로그인을 이용해 본다.
### Socialite 확장 기능
소셜로그인을 위한 구현체는 라라벨 기본 패키지에 포함되어 있지 않고 별도로 설치해야 한다.
```bash
$ composer require "laravel/socialite:2.0.*"
```
```php
// config/app.php
'providers' => [
// Other service providers...
Laravel\Socialite\SocialiteServiceProvider::class,
],
'aliases' => [
// Other aliases...
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
]
```
### Oauth Credential 설정
[Github Developer Applications Console](https://github.com/settings/developers) 을 방문하여 'Register new application' 버튼을 눌러 아래 그림과 같이 정보를 입력하자. Authorization callback URL 은 'http://localhost:8000/auth/github/callback'으로 하자.


Application 등록이 완료되어 위 그림과 같은 등록정보 페이지를 확인할 수 있는데, 여기서 'Client ID'와 'Client Secret'를 복사하여, config/services.php 에 github 소셜로그인 정보를 셋팅한다. 물론 .env 파일에 넣고, `env()` Helper 로 읽어 오는 것이 정석이다.
```php
// config/services.php
return [
// Other services setting
'github' => [
'client_id' => '복사한 Client ID',
'client_secret' => '복사한 Client Secret',
'redirect' => route('session.github.callback'),
],
];
```
### Route 정의
공식문서대로 구현하자. 대신 Route 이름을 주자.
```php
Route::group(['prefix' => 'auth', 'as' => 'session.'], function () {
// Other route definitions...
/* Social Login */
Route::get('github', [
'as' => 'github.login',
'uses' => 'Auth\AuthController@redirectToProvider'
]);
Route::get('github/callback', [
'as' => 'github.callback',
'uses' => 'Auth\AuthController@handleProviderCallback'
]);
});
```
### 컨트롤러 메소드 정의
app/Http/Controllers/Auth/AuthController.php 에 아래 메소드를 추가한다.
```php
class AuthController extends Controller
{
...
public function redirectToProvider()
{
return \Socialite::driver('github')->redirect();
}
public function handleProviderCallback()
{
$user = \Socialite::driver('github')->user();
dd($user);
}
}
```
`redirectToProvider()` 메소드는 사용자가 소셜로그인 버튼을 눌렀을 때, Github 승인 페이지로 이동시켜주는 Redirect 응답을 만들어 준다. Github 쪽에서 모든 작업이 완료되면, 앞 절에서 우리가 설정한 Authorization callback URL로 Redirect 시키면서 Payload로 여러가지 정보들을 넘겨준다. 요청을 받은 `handleProviderCallbck()` 메소드는 Github 로 부터 넘겨 받은 Payload를 기반으로 `Laravel\Socialite\Two\User` 인스턴스를 만든다.
'http://localhost:8000/auth/github' 주소를 직접 쳐서 여기까지 테스트를 해 보자.

### 소셜 사용자 등록 및 로그인 처리
Github를 이용해서 로그인한 사용자를 우리 서비스의 사용자로도 등록시키자. 또, 세션을 만들어야 하므로, 로그인 처리도 하기 위해 `handleProviderCallback()` 메소드를 업데이트하자. 엘로퀀드 모델에서 `firstOrCreate()` 메소드는 인자로 주어진 정보와 일치하는 레코드가 있으면 반환하고, 없으면 새로 생성하는 역할을 한다.
```php
class AuthController extends Controller
{
...
public function handleProviderCallback()
{
$user = \Socialite::driver('github')->user();
$user = User::firstOrCreate([
'name' => $user->getName(),
'email' => $user->getEmail(),
]);
auth()->login($user, true);
return redirect(route('home'));
}
```
'http://localhost:8000/auth/github' 주소를 직접 쳐서 여기까지 테스트를 해 보자.

**`참고`** Github 사용자의 경우, 현재는 비밀번호가 없기 때문에 네이티브 로그인 기능으로 로그인할 수 없다. users 테이블에 소셜로그인으로 생성된 사용자에 대한 유효성을 표시하는 플래그를 만들고, 로그인 이메일은 맞는데 비밀번호가 없고 소셜 로그인 유효성 플래그가 있는 경우, 비밀번호를 추가로 받아 네이티브로도 로그인할 수 있게 하는 등의 기능 확장은 /각/자/ 해 보자. 또, Github 뿐만 아니라, 여러 소셜 로그인 공급자를 등록할 수 있도록 Social 모델을 만들어서 User 모델과 연결시키는 수준의 기능확장도 /각/자/ 도전해 보자.
### 소셜 로그인 버튼 추가
사용자가 Github 를 이용해 우리 서비스에 로그인할 수 있도록 로그인 페이지에 버튼을 달아 주자.
```html
@section('style')
@stop
@section('content')
```
풀텍스트 검색 기능은 나중에 구현할 것이므로, 폼만 만들어 놓고 넘어가자.
```html
```
`ArticlesController::__construct()` 에서 모든 뷰에 공유한 `$allTags` 변수를 여기서 이용한다.
'active' 클래스를 표시하기 위해 삼항 연산자를 이용하였고, `Route::current()->parameter('id')` 로 현재 요청의 Route 파라미터를 얻어 왔다. 아직 Route 가 정의되지 않았으므로 이 기능은 동작하지 않는데, 곧 고칠 것이다.
`$tagCount = $tag->articles->count()` 로 각 Tag 에 해당하는 Article 의 갯수를 구하고, 태그 이름 옆에 숫자로 표시하였다. `ArticlesController::__construct()` 에서 `$allTags` 변수에서 Eager Loading 을 한 이유가, 여기서 N + 1 쿼리 문제를 피하기 위해서였다.
**`참고`** 태그 리스트를 포함한 왼쪽 영역은 여러 뷰에서 사용하므로, [뷰 컴포저](http://laravel.com/docs/views#view-composers)로 구성하면 더 깔끔하다. 이 코스 수준을 넘어서는 내용으로 각자 공부해서 적용해 보자.
```html
```
`@include('users.partial.avatar', ['user' => $article->author])` 로, `$user` 변수를 사용자 사진을 표시할 하위 뷰에 데이터를 넘겨 준다.
`$commentCount = $article->comments->count()` 로, 각 Article에 연결된 Tag 의 갯수를 구하고, Article 제목 옆에 갯수를 표시하였다.
기본기 강좌에서 'created_at', 'updated_at'은 `Carbon\Carbon` 인스턴스라고 얘기한바 있다. `$article->created_at->diffForHumans()` 메소드를 쓰면 '6 hours ago' 처럼 Article 생성 시각을 표시할 수 있다.
`@include('tags.partial.list', ['tags' => $article->tags])` 로, 각 Article 인스턴스에 연결된 Tag 의 목록을 넘겨 준 것을 기억해 놓자.
```html
@if ($user)
@else
@endif
```
`gravatar_profile_url()`, `gravatar_url()` Helper를 이용하여, 사용자의 프로파일 주소와 아바타를 가져왔다. 이 Helper들은 다음 섹션에서 구현할 것이다. 로그인한 사용자만 포럼을 남길 수 있게 조치했으므로, `$user` 변수가 없을 가능성은 없다. `$user = null` 인 경우를 대비해, 미리 조심해서 나쁠 것은 없다.
```html
@if ($tags->count())
{!! icon('tags') !!}
@endif
```
'articles/partial/article.blade.php' 에서 넘겨 받은 `$tags` 변수를 이용하여, 각 Article 에 연결된 Tag 들의 이름을 뿌려준다. 링크 href 속성에 '#'로 표시된 부분은 나중에 다시 업데이트할 것이다.
#### Gravatar Helper
```php
// app/helpers.php
function gravatar_profile_url($email)
{
return sprintf("//www.gravatar.com/%s", md5($email));
}
function gravatar_url($email, $size = 72)
{
return sprintf("//www.gravatar.com/avatar/%s?s=%s", md5($email), $size);
}
```
```bash
$ php artisan tinker
>>> gravatar_url('juwonkim@me.com');
=> "//www.gravatar.com/avatar/6a7346bbad52884566008892003fc6ac?s=72"
```
's' 쿼리는 가져올 아바타 이미지의 사이즈를 의미한다.
### 상세 보기 구현
목록에서 Article 엔트리의 제목을 누르면 'GET /articles/{id}' 로 넘어가도록 목록 보기 뷰에서 링크를 걸었다. 이 Route 에 해당하는 `ArticlesController::show()` 메소드를 작성하자.
```php
class ArticlesController extends Controller
{
public function show($id)
{
$article = Article::with('comments', 'author', 'tags')->findOrFail($id);
return view('articles.show', compact('article'));
}
}
```
앞에서 작업을 많이 했기에 'articles/show.blade.php' 는 의외로 간단하다. Article 인스턴스의 'content' 속성값을 뿌리기 위해, 이전에 만든 `markdown()` Helper를 이용하였다.
```html
@extends('layouts.master')
@section('content')
@stop
```
'Comment here' 라고 된 부분이 보일 것이다. 여기에 댓글 작성 폼과, 그간 작성된 댓글 목록이 표시될 것이다.

### 포럼 쓰기 구현
#### 쓰기 UI 구현
새 포럼 글을 쓰는 UI는 목록 보기 뷰나 상단의 네비게이션 메뉴에 위치하면 적절할 것 같다.
```html
...
```
#### 쓰기 폼 구현
포럼을 쓰기 위한 폼을 만들자.
```php
// app/Http/Controllers/ArticlesController
public function create()
{
$article = new Article;
return view('articles.create', compact('article'));
}
```
`$article = new Article;` 부분은 create 와 edit 뷰에서 폼을 공용으로 사용하기 위한 편법이다. 뒤에 나오는 공용 폼에서 `` 에서 edit 뷰에서는 Article 인스턴스가 바인딩되지만, create 뷰에는 바인딩되지 않기 때문에 'Undefined variable: article' 에러가 난다. `old()` Helper 에서 세션에 `$title` 값이 있으면 쓰고, 없으면 `$article->title`로 폴백하라는 의미이다.
```html
@extends('layouts.master')
@section('content')
...
@stop
```
`@include('articles.partial.form')` 은 포럼 수정하기에서도 동일하게 사용되는 폼이므로 하위 뷰로 뺐다.
```html
{!! $errors->first('title', ':message') !!}
{!! $errors->first('content', ':message') !!}
@include('articles.partial.tagselector')
```
`@include('articles.partial.tagselector')`, 나중에 포럼을 작성/수정할 때 태그를 선택하는 UI를 추가할 것이다. 여유가 된다면, 'content' 필드에 쓴 내용을 마크다운으로 미리보기하는 기능도 추가해 볼 것이다.

#### 저장 로직 구현
기본기 강좌의 [23강 - 입력 값 유효성 검사](23-validation.md)에서는 다루지 않았는데, 여기서는 [Form Request](http://laravel.com/docs/validation#form-request-validation) 를 사용하여 사용자의 입력값 유효성 검사를 할 것이다.
```bash
$ php artisan make:request ArticlesRequest
```
```php
// app/Http/Requests/ArticlesRequest.php
class ArticlesRequest extends Request
{
public function authorize()
{
return true;
}
public function rules()
{
return [
'title' => 'required',
'content' => 'required',
];
}
}
```
`authorize()` 메소드에서 `false` 를 반환하면, 403 Forbidden 응답이 떨어진다. 우선은 `true`를 반환하자.
`rules()` 메소드에서 각 필드별로 검사할 규칙을 정의한다. 여기서는 'required' 만 이용했다.
```php
// app/Http/Controllers/ArticlesController.php
use App\Http\Requests\ArticlesRequest;
class ArticlesController extends Controller
{
public function store(ArticlesRequest $request)
{
$article = Article::create($request->all());
flash()->success(trans('forum.created'));
return redirect(route('articles.index'));
}
}
```
`store()` 메소드에 원래 있던 `Illuminate\Http\Request` 대신, 방금 만든 `App\Http\Requests\ArticlesRequest` 을 주입했다. 우리가 만든 Form Request는 원래있던 Request 객체를 상속하고 있고, 거기다 유효성 검사 기능을 추가한 것이다. Form Request 는 미들웨어와 유사한 기능을 하고 있는데, 우리가 만든 Form Request를 통과하지 못하면, `store()` 메소드는 전혀 실행되지 않는다.
`flash()` 메소드는 [32강 - 사용자 로그인](32-login.md) 에서 설치한 'laracasts/flash' 패키지에 포함된 Helper로, `success()`, `error()` 등에 메소드를 체인해서 사용할 수 있다.
Tag UI 가 준비되면, 유효성 검사 로직과 저장 로직을 다시 손 볼 것이다.
### 포럼 수정 구현
#### 수정 UI 구현
수정 및 삭제 UI는 주로 목록 보기 또는 상세 보기에서 접근할 수 있다. 여기서는 상세 보기 뷰에서 구현한다.
```html
...
```
#### 수정 폼 구현
쓰기 폼에서 공용 폼을 만들었기에 한결 간편해 졌다.
```php
// app/Http/Controllers/ArticlesController.php
public function edit($id)
{
$article = Article::findOrFail($id);
return view('articles.edit', compact('article'));
}
```
```html
@extends('layouts.master')
@section('content')
...
@stop
```
`{!! method_field('PUT') !!}` 는 [13강 - RESTful 리소스 컨트롤러](13-restful-resource-controller.md) 에서 설명한 메소드 오버라이드를 위한 숨김 필드를 생성해 주는 Helper 로, `` 태그를 생성한다.
#### 수정 로직 구현
포럼 쓰기에서 만든 FormRequest를 그대로 사용하자.
```php
// app/Http/Controllers/ArticlesController.php
public function update(ArticlesRequest $request, $id)
{
$article = Article::findOrFail($id);
$article->update($request->except('_token', '_method'));
flash()->success(trans('forum.updated'));
return redirect(route('articles.index'));
}
```

### 포럼 삭제 구현
#### 삭제 UI 구현
일반적으로 삭제는 Ajax 요청을 이용하는데, 여기서는 전통적인 폼 요청을 통해서 하도록 하자.
```html
...
```
#### 삭제 로직 구현
```php
// app/Http/Controllers/ArticlesController.php
public function destroy($id)
{
Article::findOrFail($id)->delete();
flash()->success(trans('forum.deleted'));
return redirect(route('articles.index'));
}
```
### 접근 제어 (Authorization)
상세 보기에서 관리자이거나 사용자 자신이 작성한 포럼만 수정하거나 삭제할 수 있도록 할 것이다. [34강 - 사용자 역할](34-role.md) 에서 설치한 'bican/roles' 패키지가 밥값을 할 차례이다... 라고 생각하고, 이용하려고 했으나 뭔가 여의치 않다.
해서 User 와 Article 모델에 Helper 메소드를 만들었다. 어쨌든, 뷰에서 접근을 제한하여 버튼 UI 자체가 표시되지 않게 하는 게 우선이고...
```php
// app/User.php
public function isAdmin()
{
return $this->roles()->whereSlug('admin')->exists();
}
```
```php
// app/Article.php
public function isAuthor()
{
return $this->author->id == auth()->user()->id;
}
```
```html
@if (auth()->user()->isAdmin() or $article->isAuthor())
Edit
@endif
```
다음은 CURL 코맨드 등을 이용하여 직접 Route 에 접근하는 것을 막아야 한다. 이를 위해 Route 미들웨어를 만들고, app/Http/Kernel.php 에 Route 미들웨어라고 등록할 것이다.
```bash
$ php artisan make:middleware CanAccessArticle
```
```php
// app/Http/Middleware/CanAccessArticle.php
class CanAccessArticle
{
public function handle($request, Closure $next)
{
$user = $request->user();
$articleId = $request->route('articles');
if (! \App\Article::whereId($articleId)->whereAuthorId($user->id)->exists() and ! $user->isAdmin()) {
flash()->error(trans('errors.forbidden') . ' : ' . trans('errors.forbidden_description'));
return back();
}
return $next($request);
}
}
```
`Illuminate\Http\Request` 인스턴스에서는 `user()` 메소드를 사용할 수 있으며, `auth()->user()` 와 동일한 결과를 얻을 수있다. 로그인한 사용자 id와 Route 파라미터로 넘겨 받은 Article id로 Article 모델을 검색하여 레코드가 있으면, 작성자이므로 통과시켜 주는 식이다.
**`참고`** 미들웨어에서 Route를 통해 사용자로부터 넘겨 받은 파라미터를 이용할 수 있다. [미들웨어 파라미터](http://laravel.com/docs/middleware#middleware-parameters)를 잘 이용하면, Article 모델 뿐 아니라 여러 모델에 적용할 수 있는 미들웨어를 만들수 있을 것이다.
```php
// app/Http/Kernel.php
protected $routeMiddleware = [
// Other Route Middlewares ...
'accessible' => \App\Http\Middleware\CanAccessArticle::class,
];
```
컨트롤러에서 미들웨어를 등록하자. 미들웨어는 순차적으로 실행되므로 반드시 'auth' 미들웨어 다음에 정의되어야 한다. 이유는 사용자 로그인을 먼저 걸러주고 난 이후에, 이 사용자가 해당 Route와 컨트롤러 메소드에 접근할 수 있는 지 체크하는 식이다.
```php
// app/Http/Controllers/ArticlesController.php
public function __construct()
{
...
$this->middleware('accessible', ['except' => ['index', 'show', 'create']]);
}
```
#### 테스트
artisan CLI의 tinker 코맨드로 사용자의 비밀번호를 수정하자.
```bash
$ php artisan tinker
>>> $user = App\User::find(2);
>>> $user->password = bcrypt('password');
>>> $user->save();
```
1번과 2번 사용자로 번갈아 로그인한 후 구현한 내용이 잘 동작하는 지 확인해 보자.
---
- [목록으로 돌아가기](../readme.md)
- [36강 - 마이그레이션과 모델](36-models.md)
- [38강 - Tag 기능 구현](38-tags.md)
================================================
FILE: lessons/38-tags.md
================================================
---
extends: _layouts.master
section: content
current_index: 39
---
# 실전 프로젝트 2 - Forum
## 38강 - Tag 기능 구현
이번 강좌에서는 37강에서 구현하던 포럼의 Article 기능에 Tag 기능을 더할 것이다.
### 태그 선택 UI 구현
`ArticlesController::__construct()` 에서 뷰에 공유한 `$allTags` 변수를 이용하여, `
@stop
```
'articles.show' 뷰에서 'comments.index' 뷰를 `@include` 하였다. 'comments.index' 뷰를 만들고 컨트롤러에서 바인딩시킨 변수들이 잘 넘어 왔는 지 확인해 보자.
```html
toArray()) ?>
```

### 뷰 구조
앞 절에서 'comments.index' 뷰로 뷰 데이터들이 잘 넘어오는 것을 확인했으니, 뷰 구조를 좀 더 확장해서 볼 것이다. 먼저 아래 그림을 확인하자. 빨강은 뷰 이름, 검정은 HTML class 이름이다.

```html
```
최상위 댓글을 작성할 수 있는 뷰인 'comments.partial.create' 뷰를 로그인이 되었을 경우에만 `@include` 하였다.
`@forelse` 로 반복하면서, 'comments.partial.comment' 뷰를 이용해 현재 포럼 상세 보기에 해당하 `App\Article` 모델과 연결된 댓글들의 목록을 보여줄 것이다. 여기서 하위 뷰에 `$parentId` 란 변수를 넘겨 주었는데, 이는 하위 뷰에서 대댓글을 작성할 때 유용하게 사용된다.
```html
@if($currentUser and ($comment->isAuthor() or $currentUser->isAdmin()))
@include('comments.partial.control')
@endif
{!! markdown($comment->content) !!}
@if ($currentUser)
@endif
@if($currentUser and ($comment->isAuthor() or $currentUser->isAdmin()))
@include('comments.partial.edit')
@endif
@if($currentUser)
@include('comments.partial.create', ['parentId' => $comment->id])
@endif
@forelse ($comment->replies as $reply)
@include('comments.partial.comment', ['comment' => $reply])
@empty
@endforelse
```
이 뷰에서는 현재 화면에 뿌리는 댓글을 수정 또는 삭제를 트리거하기 위한 컨트롤 요소를 'comments.partial.control' 뷰에 포함하고 있다.
또, 대댓글을 작성하는 'comments.partial.create' 폼, 현재 댓글을 수정할 수 있는 'comments.partial.edit' 폼을 포함하고 있다. Article 모델과 같이 Comment 모델에도 `isAuthor()` 메소드를 추가했는데, 이렇게 중복이 발생하는 경우에는 trait 로 빼는 것이 좋을 것 같다.
이 뷰에서 가장 눈여겨 볼 것은, 재귀적으로 뷰를 `@include` 하고있다는 것이다. 다시 말하면, `$comment->replies` 값이 있으면 (== 대댓글), 자기 뷰 안에 자기 자신 즉, 'comments.partial.comment' 자식뷰를 계속 Nesting 한다는 것이다.
```html
```
앞서 보았듯이, 위 뷰는 일반 댓글을 작성하기 위한 폼으로도, 'comments.partial.comment' 의 재귀적 호출 즉, 댓글의 댓글을 작성하기 위한 폼으로도 사용된다. 재귀적 호출일 때, 일종의 플래그 역할을 하기 위해서 하위 뷰에 `$parentId` 변수를 넘겨 주었고, 위 댓글 작성 폼이 최상위 댓글이 아니라 댓글의 댓글 작성 폼으로 사용되었을 때 부모 댓글이 있음을 `CommentsController::store()` 에 알리기 위해서 `@if(isset($parentId))` 로 숨김 필드를 선택적으로 생성하였다.
현재 로그인한 사용자, 즉 신규 댓글을 작성할 사용자의 사진을 보여주기 위해 `@include('users.partial.avatar', ['user' => $currentUser])` 를 호출했고, 뷰에 공유된 데이터인 `$currentUser` 를 넘겨 주었다.
댓글 작성 후 폼 전송을 하게 될 텐데, 이 때 서버 측이 어떤 모델의 어떤 id에 연결시킬 지 힌트를 주기 위해 `$commentableType`, `$commentableId` 변수를 숨김 필드에 담았다.
```html
```
댓글 수정 및 삭제 컨트롤 UI를 위해 [Bootstrap Dropdowns](http://getbootstrap.com/components/#dropdowns) 에서 디자인을 훔쳐와서 적용했을 뿐, 구현상 특별한 점은 없다.
```html
```
수정 폼도 우리가 앞에서 흔히 봐 왔던 폼이다. 다만 이미 'commentable_type', 'commentable_id' 가 이미 저장되어 있는 `App\Comment` 모델을 수정하는 것이므로, 기존의 댓글 작성 폼과 달리 숨김 필드가 필요 없다.
여기까지 작업하고 테스트해 보면, 에러가 날 것이다. Route 정의와 컨트롤러가 없어서 이다.
### 컨트롤러 구현
```bash
$ php artisan make:controller CommentsController --resource
```
'index', 'create', '...' 뷰 요청하는 메소드는 필요없으므로 Route 정의에서 제외했다.
```php
// app/Http/routes.php
Route::resource('comments', 'CommentsController', ['only' => ['store', 'update', 'destroy']]);
```
#### 댓글 생성 로직 구현
댓글 기능을 한번만 구현하고, `App\Article`, `App\Something`, ... 여러 모델에서 사용하기 위해서 우리가 선택한 디자인은 Morph Many to Many 였다 (사실은 학습 목적이 더 강하다). 이 절의 컨트롤러 구현에서 여러 모델에서 Comment 기능을 공유해서 쓰기 위해, 어떻게 했는 지 눈여겨 살펴보기 바란다.
```php
// app/Http/Controllers/CommentsController.php
class CommentsController extends Controller
{
public function __construct()
{
$this->middleware('auth');
$this->middleware('author:comment', ['except' => ['store']]);
}
public function store(Request $request)
{
$this->validate($request, [
'commentable_type' => 'required|in:App\Article',
'commentable_id' => 'required|numeric',
'parent_id' => 'numeric|exists:comments,id',
'content' => 'required',
]);
$parentModel = "\\" . $request->input('commentable_type');
$parentModel::find($request->input('commentable_id'))
->comments()->create([
'author_id' => \Auth::user()->id,
'parent_id' => $request->input('parent_id', null),
'content' => $request->input('content')
]);
flash()->success(trans('forum.comment_add'));
return back();
}
}
```
유효성 검사 규칙 중에 'in:App\Article' 부분이 보일 것이다. 연결된 모델이 늘어나면, 'in:App\Article,App\Something' 처럼 콤마(,)를 찍고 계속 넣을 수 있다.
'parent_id' 유효성 검사에서는 'exists:comments,id' 을 쓰고 있는데, 'comments' 테이블에 `whereId($request->input('parent_id'))`로 쿼리했을 때 레코드가 있어야 한다는 의미이다. 그럴 경우는 거의 없겠지만, 대댓글을 작성하는 중에 원본 댓글의 작성자가 삭제하는 경우를 생각해 볼 수 있을 것이다.
`$parentModel = "\\" . $request->input('commentable_type')` 은 문자열 '\App\Article' 을 생성하고 `$parentModel` 변수에 담는다. 코드에서 `$parentModel::find()` 는 결국 `\App\Article::find()` 로 치환된다. 유효성 검사를 한번 거쳤으므로 없는 모델에 쿼리하는 Exception 은 발생하지 않을 것이다.
#### 미들웨어 수정
위 절의 `CommentsController::__construct()` 에서 `ArticlesController::__construct()` 에서 썼던 `CanAccessArticle (별칭 'accessible')` 미들웨어를 썼다. 권한에 맞게 생성, 수정, 삭제 UI를 뷰에서 숨겼다고는 하나, ArticlesController 에서와 마찬가지로 HTTP Client 를 이용하여 직접 요청하는 경우에 대해서도 방어해야 하기에... [미들웨어 파라미터](http://laravel.com/docs/middleware#middleware-parameters) 기능을 이용해서 여러 컨트롤러에서 사용할 수 있도록 기존 미들웨어를 수정해 보자.
네이밍은 항상 힘들다. 'CanAccessArticle' 에서 좀 더 일반적인 'AuthorOnly' 로, 그리고 별칭은 'author' 로 수정했다.
```php
// app/Http/Kernel.php
protected $routeMiddleware = [
// ...
'author' => \App\Http\Middleware\AuthorOnly::class,
];
```
미들웨어 파라미터 사용법이다. `handle()` 메소드의 세번째 인자로 `$param`을 받았다. 이는 소문자로 된 모델 이름이라고 우리 나름의 규칙을 정하자. `$model` 을 받아 오는 부분은 `CommentsController::store()` 에서 한거랑 비슷한 구현이다. `str_plural(string $value)` 은 라라벨 내장 Helper 로, 인자로 넘겨 받은 영문으로 된 `$value` 를 복수 단어로 변환해 준다.
미들웨어 파라미터는 컨트롤러에서 `$this->middleware('미들웨어별칭:파라미터')` 식으로 쓸 수 있다.
```php
// app/Http/Middlewares/AuthorOnly.php
public function handle(Request $request, Closure $next, $param)
{
$user = $request->user();
$model = '\\App\\' . ucfirst($param);
$modelId = $request->route(str_plural($param));
if (! $model::whereId($modelId)->whereAuthorId($user->id)->exists() and ! $user->isAdmin()) {
flash()->error(trans('errors.forbidden') . ' : ' . trans('errors.forbidden_description'));
return back();
}
return $next($request);
}
```
미들웨어를 수정했으니 기존의 `ArticlesController` 도 `$this->middleware('author:article', [...]);` 로 수정해 주자.
#### 댓글 수정 및 삭제 로직 구현
수정 로직은 특별한게 없다. 일단, 아래 코드를 보자.
```php
// app/Http/Controllers/CommentsController.php
class CommentsController extends Controller
{
// Other methods ...
public function update(Request $request, $id)
{
$this->validate($request, ['content' => 'required']);
Comment::findOrFail($id)->update($request->only('content'));
flash()->success(trans('forum.comment_edit'));
return back();
}
public function destroy(Request $request, $id)
{
$comment = Comment::find($id);
$this->recursiveDestroy($comment);
if ($request->ajax()) {
return response()->json('', 204);
}
flash()->success(trans('forum.deleted'));
return back();
}
public function recursiveDestroy(Comment $comment)
{
if ($comment->replies->count()) {
$comment->replies->each(function($reply) {
if ($reply->replies->count()) {
$this->recursiveDestroy($reply);
} else {
$reply->delete();
}
});
}
return $comment->delete();
}
}
```
개인의 취향일 수도 있다. 마이그레이션에서 정의한 `$table->foreign('parent_id')->references('id')->on('comments')->onDelete('cascade');` 에 의해 대댓글은 자동 삭제된다. 그런데 필자의 경우에는, 좀 더 안전하게 하기 위해서, 코드 레벨에서 먼저 한번 삭제한다. 이를 위해서, `recursiveDestroy()` 라는 재귀적 호출을 하는 메소드를 만들었고, `destroy()` 메소드에서 호출하였다.
그런데... 댓글을 직접 삭제하는 경우도 있지만, Comment 의 부모 모델인 Article 을 삭제하는 경우도 있다. 우리 모델은 'Morph Many to Many' 라는 것을 기억하자. 즉, 데이터베이스의 외래키 관계가 형성될 수 없다는 의미이다. 그렇다면 Article 모델이 삭제되면 연결된 Comment 도 삭제하도록 코드 레벨에서 구현해야 한다. 그런데 여기서, 디자인 의사 결정이 필요하다. 'Article 이 삭제되었으니, 연결된 그 자식 모델들도 전부 삭제할거냐? 남겨 놓고, '삭제된 글'이라는 자리표시자(== Placeholder) 를 남기고 댓글은 삭제하지 말고 남겨 놓을 것인가?' 의 결정. 여기서는 전부 삭제하는 것으로 하자.
```php
// app/Http/Controllers/ArticlesController.php
public function destroy($id)
{
$article = Article::with('attachments', 'comments')->findOrFail($id);
foreach($article->attachments as $attachment) {
\File::delete(attachment_path($attachment->name));
}
$article->attachments()->delete();
$article->comments->each(function($comment) { // foreach 로 써도 된다.
app(\App\Http\Controllers\CommentsController::class)->recursiveDestroy($comment);
});
$article->delete();
flash()->success(trans('forum.deleted'));
return redirect(route('articles.index'));
}
```
이 강좌와 관련된 부분은 `$article->comments->each(function($comment) {...}` 부분인데, `app()` Helper 로 다른 컨트롤러에 있는 메소드를 접근하고 있다. 사실상은 Anti-pattern 이며, `recursiveDestroy()` 메소드를 trait 등으로 빼는 것이 좋을 것 같다.
### 화장하기
여기까지 작성하고, 브라우저에서 보면 스크린샷 처럼 나오지 않고, 아주 못~쉥긴 댓글 뷰가 표시될 것이다. 아래와 같은 뷰 로직을 생각해 보자.

1. 최상위 'comments.partial.create' 는 항상 표시된다.
2. 페이지 로드시 'comments.partial.comment' 에 포함된 현재 댓글 수정 폼, 대댓글 작성 폼은 표시되지 않는다.
3. 'Reply' 버튼을 클릭하면 해당 댓글 아래에 대댓글 작성 폼이 토글(표시/숨김) 된다. 해당 댓글에 수정 폼이 표시되어 있다면 숨긴다.
4. 'comments.partial.control' 조각 뷰에서 수정을 선택하면 해당 댓글 수정 폼이 표시된다. 해당 댓글에 대댓글 작성폼이 표시되어 있다면 숨긴다.
5. 'comments.partial.control' 조각 뷰에서 삭제를 선택하면 해당 댓글 삭제 Ajax 이 나가고, 삭제 성공시 플래시 메시지를 표시한다.
위 내용을 구현한 CSS, JS 코드인데, 코드에 대한 설명은 생략한다. 일단 jQuery로 썼는데, 이 코스를 통해서 기회가 된다면 [Vue.js](http://vuejs.org/) 로 다시 쓸 생각이다. 라라벨과 같은 완벽한 Backend 를 갖추고, 프론트엔드에서 또 다시 AngularJS 와 같은 모든 것을 갖춘 프레임웍을 쓴다는 것은 오버라는 생각이든다 (개발해야 할 코드량이 많다는 의미다). 그런 면에서 뷰 모델만 건드리는 Vue.js 와 같은 라이브러리가 합리적인 선택이라 생각된다.
```html
@section('style')
@stop
@section('script')
@stop
```
이번 강좌를 진행하는 동안 'resources/assets/js/app.js' 에 자잘한 Cosmetic Change 들이 있었다. 변경 내용들은 Commit 로그를 참고하도록 하자.
---
- [목록으로 돌아가기](../readme.md)
- [39강 - Attachment 기능 구현](39-attachments.md)
- [32/33 보충 - 인증 리팩토링](32n33-auth-refactoring.md)
- [41강 - UI 개선](41-ui-makeup.md)
================================================
FILE: lessons/41-ui-makeup.md
================================================
---
extends: _layouts.master
section: content
current_index: 43
---
# 실전 프로젝트 2 - Forum
## 41강 - UI 개선
이번 강에서 전반적인 UI 개선 작업을 하고, 다음 강에서는 이벤트, 알림, 캐싱 등 서버 사이드 쪽 개선 작업을 하도록 하자. 먼저 무엇을 개선할지 정의하자.
1. 포럼 본문 또는 댓글을 쓸 때
1. 사용자가 입력하는 내용을 실시간으로 마크다운 컴파일하여 보여준다.
2. textarea 요소에서 코드를 쓸 경우를 대비해, Tab 키 입력을 지원하자. (원래는 Tab 키를 치면 다음 요소로 이동해 버린다.)
3. 사용자가 입력한 내용이 길어지는 것을 대비해, textarea 요소의 크기를 입력 내용의 양에 따라 자동으로 조절하자.
2. 마크다운 문법을 잘 모르는 사용자를 배려하여, 사용법을 페이지에서 보여주자.
3. 파일 첨부 UI를 토글할 수 있도록 하자.
### UI 콤포넌트 설치
위의 기획에 따라, 필자의 경험과 인터넷 검색을 통해 적절한 프론트엔드 라이브러리를 아래와 같이 선택하였다.
- [Fastclick](https://github.com/ftlabs/fastclick) : 모바일 디바이스에서 터치 반응을 개선한다.
- [Tabby](https://github.com/alanhogan/Tabby) : textarea 에서 Tab 키 입력을 지원한다.
- [Autosize](https://github.com/jackmoore/autosize) : 텍스트 길이에 따라 textarea 요소의 높이를 자동으로 조정한다.
- [Marked](https://github.com/chjj/marked) : 클라이언트 사이드에서의 마크다운 컴파일 기능을 지원한다.
- [Highlightjs](https://github.com/components/highlightjs) : 코드에서 Syntax Highlight 기능을 지원한다. 기존 Google Code Prettify 를 대체한다.
- [Earthsong](http://daylerees.github.io/) : Syntax Highlight 에서 Earthsong 테마를 입힌다. Highlightjs 내장 테마를 대체한다.
**`참고`** Earthsong 테마는 [Dayle Rees](http://daylerees.com/) 가 개발했다. 그는 라라벨 코어 멤버로 활동할 뿐 아니라, [PHP PANDA](https://leanpub.com/php-pandas) 등 여러 권의 PHP 서적을 낸 바 있다. 그가 만든 [Color Scheme](https://github.com/daylerees/colour-schemes) 은 모르는 개발자가 없을 정도로 유명하다. Color Scheme에서 제공하는 다양한 에디터 테마를 보려면 [Color Scheme Gallery](http://daylerees.github.io/)를 확인해 보자.
```bash
# highlightjs를 쓸 것이므로 google-code-prettify 는 제거한다.
$ bower uninstall google-code-prettify --save-dev
$ bower install fastclick tabby autosize marked highlightjs --save-dev
# Bower 콤포넌트가 아니므로, github 에서 raw URL를 따서 CURL 로 다운로드하였다.
$ curl https://raw.githubusercontent.com/daylerees/colour-schemes/master/highlightjs/contrast/earthsong-contrast.css -o ./resources/assets/vendor/earthsong.css
```
빌드스크립트를 수정하자. 수정 후 빌드 코맨드는 이젠 척척 알아서.. `$ gulp` (or `$ gulp --production`)
```javascript
// gulpfile.js
elixir(function (mix) {
mix
.styles([
// ...
'../vendor/earthsong.css',
'app.css'
], 'public/css/app.css')
.scripts([
// ...
'../vendor/fastclick/lib/fastclick.js',
'../vendor/tabby/jquery.textarea.js',
'../vendor/autosize/dist/autosize.js',
'../vendor/highlightjs/highlight.pack.js',
'../vendor/marked/lib/marked.js',
'app.js'
], 'public/js/app.js')
// ...
});
```
### 포럼 본문 및 댓글 쓰기 개선
#### Fastclick
쉬운 것 먼저 하자. Fastclick 기능 추가는 `attach()` 메소드 호출 하나로 끝난다.
```javascript
// resources/assets/js/app.js
var csrfToken = $('meta[name="csrf-token"]').attr('content'),
routeName = $('meta[name="route"]').attr('content'),
textAreas = $('textarea');
/* Global Settings */
/* Activate Fastclick */
window.addEventListener('load', function() {
FastClick.attach(document.body);
}, false);
/* Set Ajax request header.
Document can be found at http://laravel.com/docs/routing#csrf-x-csrf-token */
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': csrfToken
}
});
```
작업하는 김에 코드들도 약간 정리했다. `csrfToken` 부분은 앞 절에서 설명했을 것이다.
`textareas` 란 글로벌 변수를 주목하자. 이 스크립트는 모든 페이지 로딩시 같이 로딩된다. 즉, 페이지 로딩시 마다, textarea 요소가 있는 페이지에서는 `textareas` 변수가 셋팅이 된다.
Fastclick 적용 결과는 눈에 딱히 보이지는 않는다. 모바일 브라우저에서 접근해서 확인해야 하는데, 필자도 별로 체감은 되지 않는 것 같다.
#### Highlightjs
역시 쉽다. 'articles.show' 뷰 등에서는 기존에 작성한 포럼 본문이 표시되고, 코드 블럭을 포함하고 있을 수 있다. 'articles.show' 페이지가 로드되자 마자, 코드 블럭이 있으면 Highlightjs 가 작동한다. 주의할 점은 페이지 로드 이후에 자바스크립트에 의해서 동적으로 DOM에 추가된 코드블럭에는 Highlightjs 가 적용되지 않는다는 점이다.
```javascript
// resources/assets/js/app.js
/* Activate syntax highlight.
This will affect code blocks right after the page renders */
hljs.initHighlightingOnLoad();
```

#### Tabby & Autosize
역시 쉽다. 위에서 `textareas` 란 변수를 지정한 것을 기억할 것이다. Tabby 와 Autosize 기능은 `textareas.length` 값이 있을 때만 동작시키는 것으로 했다.
```javascript
// resources/assets/js/app.js
if (textAreas.length) {
/* Activate Tabby on every textarea element */
textAreas.tabby({tabString: ' '});
/* Auto expand textarea size */
autosize(textAreas);
}
```

#### Marked & Hightlightjs
이 부분은 쉽지 않다. 사용자가 textarea 에 입력하는 내용을 Marked 로 컴파일하여 미리보기 요소에 보여줄 것이다.
먼저, textarea 요소가 포함되어 있는 'articles.partial.form' 뷰와 'comments.partial.create', 'comments.partial.edit' 뷰에 미리보기를 표시할 HTML 요소를 추가하자.
```html
{!! $errors->first('content', ':message') !!}
{{ markdown(old('content', 'Preview will be shown here...')) }}
```
```css
/* resources/assets/sass/_forum.scss */
div.preview__forum {
display: none;
@extend .form-control;
margin-top: $baseline;
height: auto;
}
```
'div.preview__forum' 이란 요소를 추가하고, 처음 로드될 때 상태를 `display:none;` 로 지정하였다.
유효성 검사 에러가 발생할 경우를 대비해, `{{ markdown(old('content', '...')) }}` 라고 쓴 것도 놓치지 말자. 포럼/댓글 작성/수정 폼 전송시에는 컴파일되지 않은 Raw 상태로 `*Controller::store()` 메소드에 전달되고, 유효성 검사에서 튕길 경우, `withInput()` 에 의해서 사용자가 작성한 폼 값들을 세션에 구워서 폼을 전송했던 뷰로 되돌려 보낸다. 이 때 서버는 뷰를 응답하기 전에, `markdown()` Helper 를 이용해서 미리 HTML 로 컴파일 된 내용을 'div.preview__forum' 요소에 넣어 놓는 부분이다. 당연히 textarea 요소에는 컴파일되지 않은 Raw 상태를 그대로 뿌리게 된다.
이제 자바스크립트 부분을 보도록 하자.
```javascript
// resources/assets/js/app.js
if (textAreas.length) {
// Other library activation codes ...
textAreas.on("focus", function (e) {
// Show preview pane when a textarea is in focus
$(this).siblings("div.preview__forum").first().show();
});
textAreas.on("keyup", function(e) {
// Register 'keyup' event handler
var self = $(this),
content = self.val(),
previewEl = self.siblings("div.preview__forum").first();
// Compile textarea content
var compiled = marked(content, {
renderer: new marked.Renderer(),
gfm: true,
tables: true,
breaks: true,
pedantic: false,
sanitize: true,
smartLists: true,
smartypants: false
});
// Fill preview container with compiled content
previewEl.html(compiled);
// Add syntax highlight on the preview content
previewEl.find('pre code').each(function(i, block) {
hljs.highlightBlock(block)
});
}).trigger("keyup");
}
```
먼저 textarea 에 커서가 들어가면 (== 'focus' 이벤트), jQuery의 `show()` 메소드를 이용하여 'div.preview__forum' 요소를 `display: block;` 상태로 변경시켰다.
그 다음은 textarea 에 'keyup' 이벤트가 발생했을 때 이다. textarea 에 입력한 내용을 읽어오고, 미리보기를 표시할 요소를 잡아 `content`, `previewEl` 변수가 각각 담았다.
`marked()` 메소드를 이용해서 `content` 를 컴파일하여 `compiled` 변수에 담은 후, `previewEl` 의 내용을 `compiled` 로 채워 넣었다.
앞서 설명했듯이, Highlightjs 가 페이지 로드 이후에 동적으로 DOM 에 추가된 코드블럭에 대해서는 동작을 못하기 때문에, 이 부분을 처리하는 코드도 추가하였다.

### 마크다운 사용법 Modal
Bootstrap 에는 Modal 요소를 포함하고 있다. 이를 활용하자.
먼저, Modal에 본문으로 표시될, 마크다운 사용법을 담고 있는 뷰를 만들고, 적절한 위치에 `@include` 시키자.
```html
```
'a#md-caller' 가 클릭되었을 때, `modal()` 메소드를 호출하는 것으로 처리하였다.

### 파일 첨부 UI 토글
미리보기까지 들어가면서, 폼이 너무 길어지게 되어 줄일 필요성이 대두되었다. 나머지 폼들은 모두 채워야 하지만, 파일 첨부는 선택적으로 해도 되는 요소이므로, 사용자가 필요할 때만 열어서 파일을 올려 놓을 수 있도록 하자.
```html
@section('script')
@stop
```

### 그 외 추가된 장식들
- Back to top 버튼이 추가되었다. 페이지 스크롤이 발생했을 때 버튼이 표시되며, 누르면 페이지의 맨 위로 이동하는 그거다. (resources/views/layouts/partial/footer.blade.php, resources/assets/js/app.js)
- 모바일에서 Forum, Documents 를 열었을 때 좌측에 표시되던 태그, 문서목록을 숨기도록 하였다. 그리고 뷰 하단에 작은 버튼을 두어 누르면, 목록이 열리도록 하였다. (resources/assets/sass/\_mediaqueries.scss, resources/assets/js/app.js)
- 페이지에서 블럭을 잡았을 때, 선택 영역의 색상을 수정했다. (resources/assets/sass/\_commons.scss)

---
- [목록으로 돌아가기](../readme.md)
- [40강 - Comment 기능 구현](40-comments.md)
- [42강 - 서버 사이드 개선](42-be-makeup.md)
================================================
FILE: lessons/42-be-makeup.md
================================================
---
extends: _layouts.master
section: content
current_index: 44
---
# 실전 프로젝트 2 - Forum
## 42강 - 서버 사이드 개선
아직 추가해야 할 기능들이 많지만, 이번 강으로 실전 프로젝트 2편은 마무리하기로 한다. 먼저 무엇을 개선할 지 리스트업하자.
1. 포럼에서 필터, 풀텍스트 검색, 정렬 기능을 제공한다.
2. 응답 성능 향상을 위해, 서버 사이드 캐싱을 활성화한다. `event:generate` 코맨드 이용한다.
3. 포럼에 댓글, 또는 댓글에 대댓글이 달릴 경우, 원본 글 작성자에게 이메일 알림을 발송한다. 이벤트를 수동으로 만들 것이다.
4. 서비스 디렉토리를 만들고, 기존 Markdown 컴파일러를 상속하여, 커스텀 컴파일 규칙을 만든다.
5. 앞 강에서 누락된 베스트 답글을 선택하고 표시하는 기능을 추가할 것이다.
### 필터, 풀 텍스트 검색, 정렬 기능
3가지 기능 모두가 `ArticlesController::index()` 와 관련이 있어 한번에 설명하기로 한다. 주목할 점은 필터를 적용하면 필터가 적용된 포럼 글만 표시된다. 풀 텍스트 검색을 적용하면 검색어에 해당하는 포럼 글만 표시된다. 반면, 정렬은 페이지에 표시할 포럼 콜렉션 자체를 변경하지는 않으며, 필터나 검색에 의해 선택된 포럼 콜렉션의 오름차순, 내림차순 정렬, 즉 Decoration만 하는 역할을 한다는 점을 기억하자.
2가지 필터. 필터 추가는 독자들이 얼마든지 할 수 있을 것이다. URL 쿼리의 필드명은 'f' 로 하자. (e.g. f=nocomment)
- `nocomment`: 댓글이 없는 포럼 글
- `notsolved`: 베스트 답글이 없는 포럼 글
2가지 정렬 기준. URL 쿼리에서 's' 는 정렬할 기준 필드, 'd' 는 정렬방향으로 사용자에게 받는 걸로 하자. (e.g. s=created_at&d=asc)
- `created_at` 필드에 의한 정렬. Age 라 칭하자.
- `view_count` 필드에 의한 정렬. View 라 칭하자.
**`참고`** 간단해서 설명은 생략했지만, 포럼 상세보기 (`ArticlesController::show()`) 가 페이지 노출되었을 때 'view_count' 를 올리는 로직을 본 강좌에서 추가하였다.
#### UI 구현
기존의 'layouts.partial.search' 에 작성했던 검색 폼을 'articles.partial.search' 로 이동하였다. 기존 대비 폼에 action 속성이 추가되었고, 폼 전송을 하면 GET /articles?s=키워드 요청이 발생하며, 이는 `ArticlesController::index()` 에서 처리된다. 검색 키워드를 담고 폼을 통해 전송되는 필드 이름이 'q' 라는 것을 기억하자.
```html
```
필터 목록을 표시할 UI를 추가하였다.
```html
@foreach(['created_at' => 'Age', 'view_count' => 'View'] as $column => $name)
{!! link_for_sort($column, $name) !!}
@endforeach
```
위 뷰에서 정렬 링크를 생성하기 위해 `link_for_sort()` 라는 함수를 추가했는데 본문보다 코드에 설명하는 것이 더 효율적이라 생각되어, 코드 중간중간에 주석을 달았다.
```php
// app/helpers.php
function link_for_sort($column, $text, $params = [])
{
// 현재 요청의 'd' 쿼리 파라미터가 asc 이면, $reverse 에 desc
$direction = Request::input('d');
$reverse = ($direction == 'asc') ? 'desc' : 'asc';
// 정렬을 위한 쿼리 파라미터(s) 의 값이 있으면,
// 오름차순 또는 내림차순 아이콘을 함수의 인자로 넘겨 받은 $text 에 붙인다.
if (Request::input('s') == $column) {
$text = sprintf(
"%s %s",
$direction == 'asc' ? icon('asc') : icon('desc'),
$text
);
}
// 현재 요청의 쿼리 스트링에서 'page', 's', 'd' 등을 제외한 나머지 쿼리 스트링과
// 이 함수의 인자로 넘겨 받은 값들로 생성한 's', 'd' 등의 쿼리 스트링을 합쳐서
// Anchor 태그의 href 속성 값에서 사용할 $queryString 생성한다.
$queryString = http_build_query(array_merge(
Input::except(['page', 's', 'd']),
['s' => $column, 'd' => $reverse],
$params
));
// 현재 요청 URL 을 Request::url() 로 얻어 오고,
// 앞에서 만든 $queryString 문자열을 합쳐서 완전한 HTML 태그를 생성한다.
return sprintf(
'%s',
urldecode(Request::url()),
$queryString,
$text
);
}
```
위 함수에서 선택된 정렬 링크를 한번 더 선택하면, 오름차순, 내림차순 간에 토글된다. 물론, 아이콘도 같이 변경된다.
#### 마이그레이션
[MySql 공식 문서](https://dev.mysql.com/doc/refman/5.6/en/fulltext-boolean.html)를 이용하여 MySql 네이티브 풀텍스트 검색을 구현하기로 한다. 이를 위해 기존 마이그레이션을 약간 수정해야 한다.
```php
// database/migrations/create_articles_table.php
class CreateArticlesTable extends Migration
{
public function up()
{
Schema::create('articles', function (Blueprint $table) { // ... });
DB::statement('ALTER TABLE articles ADD FULLTEXT search(title, content)');
}
// ...
}
```
마이그레이션을 실행한다.
```bash
$ php artisan migrate:refresh --seed
```
#### 컨트롤러 구현
`index()` 메소드 안에 필터, 검색, 정렬 로직을 넣으면 길어져서 `filter()` 란 메소드로 빼내었다. 이번에도 코드에서 주석으로 설명한다.
**`참고`** 라라벨 커뮤니티에서는 비즈니스 로직과 데이터 소스를 디커플링시키고, 코드의 재활용성을 높이기 위해서 [Repository Pattern](https://github.com/domnikl/DesignPatternsPHP/tree/master/More/Repository) 을 많이 사용한다. `filter()` 메소드와 같은 내용들은 Repository 로 옮겨져서 다른 클래서에서도 사용할 수 있도록 하면 좋을 것이다.
```php
// app/Http/Controllers/ArticlesController.php
class ArticlesController extends Controller
{
public function index(FilterArticlesRequest $request, $id = null)
{
$query = $id
? Tag::findOrFail($id)->articles()
: new Article;
$query = $query->with('comments', 'author', 'tags', 'solution', 'attachments');
$articles = $this->filter($request, $query)->paginate(10);
return view('articles.index', compact('articles'));
}
protected function filter($request, $query)
{
if ($filter = $request->input('f')) {
// 'f' 쿼리 스트링 필드가 있으면, 그 값에 따라 쿼리를 분기한다.
switch ($filter) {
case 'nocomment':
$query->noComment();
break;
case 'notsolved':
$query->notSolved();
break;
}
}
if ($keyword = $request->input('q')) {
// 이번에도 'q' 필드가 있으면 풀텍스트 검색 쿼리를 추가한다.
$raw = 'MATCH(title,content) AGAINST(? IN BOOLEAN MODE)';
$query->whereRaw($raw, [$keyword]);
}
// 's' 필드가 있으면 사용하고, 없으면 created_at 을 기본값으로 사용한다.
$sort = $request->input('s', 'created_at');
// 'd' 필드가 있으면 사용하고, 없으면 desc 를 기본값으로 사용한다.
$direction = $request->input('d', 'desc');
return $query->orderBy($sort, $direction);
}
// Other codes ...
}
```
컨트롤러 구현 중에 좀 더 가독성 높은 쿼리를 위해, Article 모델에 아래 2개의 쿼리스코프를 추가하였다.
```php
// app/Article.php
class Article extends Model
{
public function scopeNoComment($query)
{
return $query->has('comments', '<', 1);
}
public function scopeNotSolved($query)
{
return $query->whereNull('solution_id');
}
}
```
`ArticlesController::index()` 에서 `FilterArticlesRequest` 란 Form Request 를 받는다. 이는 브라우저의 주소표시줄을 통해 사용자의 눈에도 보이는 HTTP GET 쿼리 스트링을 통해서, 'f', 's', 'd' 등의 필드 값이 전달되기 때문에, 서비스에서 허용하지 않는 문자열이 들어오는 것을 막기 위한 조치이다.
```php
// app/Http/Requests/FilterArticlesRequest.php
class FilterArticlesRequest extends Request
{
public function rules()
{
return [
'f' => 'in:nocomment,notsolved', // filter
's' => 'in:created_at,view_count', // Sort: Age(created_at), View(view_count)
'd' => 'in:asc,desc', // Direction: Ascending or Descending
'q' => 'alpha_dash', // Search query
];
}
}
```
컨트롤러 코드에서 풀텍스트 검색을 위해 `whereRaw()` 란 날 SQL을 쓸 수 있는 메소드를 이용하였고, 특수한 MySql 쿼리를 이용하였다. 쿼리는 앞서 언급한 [MySql 공식 문서](https://dev.mysql.com/doc/refman/5.6/en/fulltext-boolean.html)를 참고하였다.
**`참고`** 여기서는 MySql 네이티브 풀텍스트 검색을 이용했지만, 데이터베이스 엔진의 의존성을 버리고 빠른 성능을 달성하기 위해서는 [Elastic Search](https://www.elastic.co/downloads/elasticsearch) 등을 검토해 보기 바란다.

### 캐시
#### 동작 원리
아래와 같은 원리로 동작할 것이다. () 안에 포함된 내용은 'file', 'database' 캐시에는 적용되지 않는다.
- `ArticlesController::index()` 에서 Article 모델의 콜렉션을 캐시에 ('xxx' 라는 태그로) 저장한다.
- 신규 Article 모델의 생성, 기존 모델의 수정 또는 삭제시 이벤트를 던져 ('xxx' 태그를 가진) 캐시를 삭제한다.
#### 도우미 패키지 설치
라라벨의 캐시 기능을 좀 더 편리하게 사용하기 위해서 [watson/rememberable](https://github.com/dwightwatson/rememberable) 패키지를 가져와서 사용할 것이다. 라라벨 4 버전에 있다가 5 버전에서 빠진 기능이다.
```bash
$ composer require watson/rememberable
```
이 패키지는 `Watson\Rememberable\Rememberable` 이란 Trait를 제공하는데, Model 에서 use 키워드로 활성화시켜 주면, `remember(\DateTime|int $minutes)` 란 메소드에 접근할 수 있다. 모든 모델에서 앞서 언급한 Trait 를 써주는 것은 피곤하기도 하거니와, 코드 구조화 측면에서 추상 모델 클래스 (abstract Model) 를 하나 만들고, 우리 프로젝트의 모델들은 이 추상 모델을 상속 받도록 하자. 이 추상 모델은 Eloquent 를 상속 받을 것이다.
```php
// app/Mode.php
articles()
: new Article;
$query = taggable()
? $query->with('comments', 'author', 'tags', 'attachments')->remember(5)->cacheTags('articles')
: $query->with('comments', 'author', 'tags', 'solution', 'attachments')->remember(5);
// ...
}
}
```
앞서 언급한 `taggable()` Helper 는 'config/cache.php' 를 읽어 와서, default 드라이버가 'file' 또는 'database' 인지를 체크한다.
```php
// app/helpers.php
function taggable()
{
return !in_array(config('cache.default'), ['file', 'database']);
}
```
#### 캐시 삭제 이벤트
모델에 변경 사항이 있을 때 이벤트를 던져, 지정된 태그의 캐시만 비울 것이다. 앞서 설명했듯이, 캐시 태그 기능은 'file', 'database' 캐시 드라이버에서 지원되지 않으므로, 캐시 전체를 비우는 식으로 구현할 것이다.
모델에 변경이 발생하는 `store()`, `update()`, `destroy()` 메소드에서 각각 이벤트를 던져야 한다. `ModelChanged` 라고 이름 지었고, 이벤트 데이터로 지워야 할 태그의 리스트를 전달하였다.
```php
// app/Http/Controllers/ArticlesController.php
class ArticlesController extends Controller
{
public function store(ArticlesRequest $request)
{
// ...
event(new ModelChanged(['articles', 'tags']));
}
// ...
}
```
[22강 - 이벤트](22-events.md) 에서 배운 내용과는 다른 방법을 이용할 것이다.
이벤트 이름과, 리스너 이름을 먼저 정한다. 이벤트 이름은 앞서 컨트롤러에서 `ModelChanged` 로 정했고, 리스너는 `CacheHandler` 로 정했다.
```php
// app/Prividers/EventServiceProvider.php
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
\App\Events\ModelChanged::class => [
\App\Listeners\CacheHandler::class
],
];
// ...
}
```
artisal CLI 로 이벤트 클래스와 리스너 클래스를 만든다. 'app/Events/ModelChanged.php' 와 'app/Listeners/CacheHandler.php' 파일이 만들어 진 것을 확인하자.
```bash
$ php artisan event:generate
```
이벤트 클래스는 단순한 DTO (== [Data Transfer Object](https://en.wikipedia.org/wiki/Data_transfer_object)) 로 클래스간 데이터를 주고 받기 위한 매개체이다. 해서, DTO의 클래스 변수들은 모두 public 으로 주어야 한다는 것을 주의하자.
```php
// app/Events/ModelChanged.php
cacheTags = $cacheTags;
}
}
```
리스너도 별 것 없다. `EventServiceProvider` 의 `$listen` 속성에서 이벤트에 연결된 리스너의 `handle()` 메소드를 기본적으로 호출하게 되어있고, 이 메소드에는 이벤트 객체를 인자로 넘겨 주게 되어 있다.
`ModelChanged` 객체를 넘겨 받았으므로, 위헤서 정의한 `$cacheTags` 변수에 쉽게 접근할 수 있다. `taggable()` Helper 로 태그 기능이 없으면, 캐시 전체를 삭제하고 Early Return 을 하도록 하자.
```php
// app/Listeners/CacheHandler.php
cacheTags)->flush();
}
}
```
> **`참고`** memcached 사용 관련...
> 필자의 경우, memcache 사용을 위하여 'memcached(system library)', 'php-memcached(php module)' 을 모두 설치해 주어야 했다.
> memcached 를 사용하려면 .env.php 에서 CACHE_DRIVER=memcached 로 수정하고, 아래 설치 및 실행 명령을 참고하자.
```bash
# Linux
$ sudo apt-get install memcached php5-memcached
# Mac OS
$ brew install memcached homebrew/php/php5x-memcached
# Run as daemon mode
$ memcached -u memcached -d -m 30 -l 127.0.0.1 -p 11211
```
**`완전 잡담`** 가령, `Article::with('...')->where('...')->get()` 쿼리를 할 때, `with()` 가 붙는 순간 `Illuminate\Database\Eloquent\Builder` 인스턴스로 변하고, `get()` 으로 최종 결과값을 가져오면 `Illuminate\Database\Eloquent\Collection` 인스턴스가 된다.
### 이메일 알림
별로 어렵지 않은데, 앞 전의 이벤트와 약간 다른 방식으로 이벤트와 핸들러를 등록할 것이다.
```php
// app/Providers/EventServiceProvider.php
class EventServiceProvider extends ServiceProvider
{
// ...
public function boot(DispatcherContract $events)
{
parent::boot($events);
$events->listen('comments.*', \App\Listeners\CommentsHandler::class);
}
}
```
[22강 - 이벤트](22-events.md) 에서 배운 내용과 유사하다. 다만, `listen()` 메소드를 쓴 위치만, 글로벌 routes.php 에서 위 파일로 옮겨졌을 뿐이다. 'comments.*' 는 예상한대로, 'comments.' 로 시작하는 모든 이벤트를 `\App\Listeners\CommentsHandler::handle()` 로 연결시키겠다는 의미이다.
아래는 이벤트 핸들러 구현인데, 이메일을 보내는 일반적인 구현이다. 특이할만한 점은 `$comment->commentable()` 메소드를 이용하여 Comment 에 연결된 Article 객체를 가져와서 `author->email` 을 접근했다는 부분과, Comment 자신의 부모 Comment 의 `author->email` 도 같이 가져와서, 수신자에서 중복을 제거하고 메일을 발송하고 있다는 점이다.
'emails.new-comment' 뷰는 설명을 생략한다.
```php
// app/Listeners/CommentsHandler.php
commentable->author->email;
if ($comment->parent) {
$to[] = $comment->parent->author->email;
}
$to = array_unique($to);
$subject = 'New comment';
return \Mail::send('emails.new-comment', compact('comment'), function($m) use($to, $subject) {
$m->to($to)->subject($subject);
});
}
}
```
### Markdown 컴파일러 확장
[25강 - 컴포저](25-composer.md) 에서 가져온 `ParsedownExtra` 클래스를 이용하여, 사용자가 마크다운으로 작성한 포럼 글을 다시 보여줄 때 잘 사용하고 있었다.
그런데, 갑자기 새로운 요구사항이 생겼다고 가정하자. 'a#포럼글id' 또는 'article#포럼글id' 식으로 마크다운 본문을 쓰면, 자동으로 해당 id로 이동하는 링크를 제공해야 한다고 하자.
먼저, `ParsedownExtra` 상속받아 이 프로젝트만의 `Markdown` 클래스를 만들고, 여기에 해당 로직을 녹여 넣도록 하자. `preg_*()` 함수는 PHP 내장함수로 사용법에 대한 설명은 공식 문서를 참고하기 바란다. 다만, `public function text($text)` 의 마지막 줄 `return parent::text($text);` 에서 넘겨 받은 raw 문자열에서 필요한 컴파일 작업을 먼저 수행하여 얻은 결과물을 부모 클래스로 넘겨 데코레이션한 부분은 눈여겨 볼만하다.
```php
// app/Services/Markdown.php
\d+)/i';
public function text($text) {
if (preg_match(self::PATTERN_ARTICLE, $text, $matches) > 0) {
$text = preg_replace_callback(self::PATTERN_ARTICLE, function ($matches) {
return sprintf(
"%s",
route('articles.show', $matches['id']),
$matches[0]
);
}, $text);
}
return parent::text($text);
}
}
```
### 베스트 답글 선택 구현
#### UI 구성
포럼 상세 보기 본문에 베스트 답글(== 댓글)이 있으면, 표시하도록 하였다.
또, 댓글 뷰를 `@include` 할 때, `$solved`, `$owner` 란 새로운 변수도 넘겨 주도록 하였다.
```html
```
'comments.partial.best' 뷰는 특별한 내용이 없으므로 생략한다.
포럼 글에 대해서 베스트 댓글이 없으면, 포럼 글 작성자가 베스트를 선택할 수 있도록 UI를 제공해야 한다. 'articles.show' 뷰에서 넘겨 받은, `$solved`, `$owner` 변수를 활용하고 있는 것을 확인할 수 있다.
아래에서 `@parent` 란 블레이드 문법에 주목하자. 이는 부모 뷰에 동일한 이름의 `@section` 정의가 있으면, 둘을 합쳐서 `@yield` 할 수 있게 해 준다. 가령, 'comments.partial.commnet' 뷰에 `@section('script')` 가 있고, 부모 뷰인 'comments.index' 에도 `@section('script')` 가 있다면, `@parent` 키워드를 포함하지 않으면, 일반적인 클래스 상속과 동일하게 자식뷰의 섹션이 부모뷰를 오버라이드해 버린다.
```html
@if ($currentUser)
@if (! $solved && $owner)
@endif
@endif
@section('script')
@parent
@stop
```

### 그 외 추가된 장식들
- [22강 - 이벤트](22-events.md) 에서 썼던 users.last_login 필드를 살려서, 사용자가 로그인할 때마다 시각을 업데이트하였다. (database/migrations/create_users_table.php, app/Http/Controllers/SessionsController.php, app/Providers/EventServiceProvider.php, app/Listeners/UserEventsHandler.php)
- 포럼 상세 보기 페이지가 로드될 때마다 articles.view_count 값을 올려 조회수를 표시하는 기능을 추가하였다. (database/migrations/create_articles_table.php, app/Providers/EventServiceProvider.php, app/Listeners/ViewCountHandler.php, resources/views/articles/partial/article.blade.php)
---
- [목록으로 돌아가기](../readme.md)
- [41강 - UI 개선](41-ui-makeup.md)
- [43강 - 변경 사항 알림](43-change-note.md)
================================================
FILE: lessons/43-change-note.md
================================================
---
extends: _layouts.master
section: content
current_index: 45
---
# 실전 프로젝트 2 - Forum
## 43강 - 변경 사항 알림
2~3주 정도 다른 일로 쉬는 동안 강좌는 쓰지 못했지만, 코드 변경을 계속 해 왔다. 다음 실전 강좌로 넘어가기 전에, 모든 변경 내용을 설명하지는 못하겠지만, 큰 변경 내용은 정리해서 공유하고자 한다.
1. Article Refactoring
- 포럼에서 "상단 고정 게시물" 기능을 구현했다.
- `Article`, `Comment` 모델에 [Soft Delete](http://laravel.com/docs/eloquent#soft-deleting) 기능을 추가하였다.
- 댓글에 투표 기능을 추가했다.
2. Lesson Refactoring
- 'documents (문서)' 디렉토리, Route 엔드포인트, 뷰 등등을 'lessons (강좌)' 으로 변경했다. 아울러 모델 이름도 `Document` 에서 `Lesson` 으로 변경했다.
- 포럼 뿐 아니라 강좌에서도 댓글을 쓸 수 있도록 수정하였다. 이 과정에서 `Lesson` 모델 관련 [Repository Pattern](https://github.com/domnikl/DesignPatternsPHP/tree/master/More/Repository) 을 적용하는 등 몇 가지 관련 코드들의 수정이 있었다.
- 강좌에서도 "이전", "다음" 페이지네이션 기능을 추가하였다.
- 'lessons (강좌)' 디렉토리에 담긴 마크다운 파일의 내용이 바뀌었을 경우, `Lesson` 모델과 컨텐츠 동기화를 해 주는 커스텀 Artisan 코맨드를 추가했다.
3. 라이브 데모 사이트 개설
- 이 강좌의 라이브 데모 사이트를 위해, 랜딩 페이지를 만들고, [Amazon Web Service](http://aws.amazon.com/) 에 코드를 배포하였다.
- 이 과정에서 [Envoy SSH Task Runner](http://laravel.com/docs/envoy) 를 사용하였다.
- 라이브 데모 사이트에서 발생하는 Exception 을 [\# slack](https://slack.com/) 메시지로 받기 위한 기능을 추가했다.
**`참고`** 상세한 변경사항은 [Laravel 5 Essential](https://github.com/appkr/l5essential/commit) 의 Commit History 에서 확인하시기 바란다.
### 1. Article Refactoring
#### 상단 고정 게시물 기능 구현
`Article` 모델에 `$pin` 속성이 지정되어 있으면, 포럼 목록을 표시할 때 가장 위에 표시하는 식으로 포럼 "상단 고정 게시물" 기능을 구현했다.
```php
// DATE_create_articles_table.php
class CreateArticlesTable extends Migration
{
public function up()
{
Schema::create('articles', function (Blueprint $table) {
// ...
$table->boolean('pin')->default(0);
}
}
}
```
아래 코드에서 `orderBy('pin', 'desc')`가 추가된 것을 확인하자.
```php
// app/Http/Controllers/ArticlesController.php
class ArticlesController extends Controller
{
protected function filter($request, $query)
{
//...
return $query->orderBy('pin', 'desc')->orderBy($sort, $direction);
}
```
그리고, `$pin` 속성은 관리자만 지정하거나 해제할 수 있도록 하였다.
```html
@if ($currentUser and $currentUser->isAdmin())
@endif
```
#### Soft Delete 적용 및 댓글에 응용
[Soft Delete](http://laravel.com/docs/eloquent#soft-deleting) 란 사용자가 삭제 요청을 하면, DB 에서 레코드를 완전히 삭제하는 것이 아니라, `deleted_at` 이란 필드에 삭제된 날짜를 넣어 놓는 식으로 동작한다. 마이그레이션에서 `deleted_at` 필드를 추가하고, Soft Delete 를 적용할 모델에서 라라벨에 제공하는 Trait 만 추가하면 된다.
```php
// DATE_create_articles_table.php
class CreateArticlesTable extends Migration
{
public function up()
{
Schema::create('articles', function (Blueprint $table) {
// ...
$table->softDeletes();
}
}
}
```
아래에서 `$dates` 는 날짜 형식을 `Carbon\Carbon` 인스턴스로 바꾸어서 보여주기 위한 Accessor 이다. 기본기 [22강 - 이벤트](22-events.md) 강좌에서 `$last_login` 속성을 추가할 때도 사용한 적이 있다.
```php
// app/Article.php
use Illuminate\Database\Eloquent\SoftDeletes;
class Article extends Model
{
use SoftDeletes;
protected $dates = ['deleted_at'];
// ...
}
```
Soft Delete 가 적용되었더라도 엘로퀀트나 쿼리빌더에서는 이전과 동일하게 쿼리하면, 라라벨이 내부적으로 `deleted_at == null` 인 레코드들만 가져오게 되어 있다. 다만 삭제된 레코드까지도 가져오고 싶다면 `withTrashed()` 메소드를 체인하면 된다. 아래는 자식 댓글이 있음에도 불구하고, 작성자가 자신의 댓글을 삭제했을 때, "삭제된 댓글" 이라고 표시하기 위한 구현인데, `withTrashed()` 로 삭제된 댓글까지도 모두 가져오고 있음을 확인할 수 있다.
```php
// app/Http/Controllers/ArticlesController.php
class ArticlesController extends Controller
{
public function show($id)
{
$article = Article::with('comments', 'tags', 'attachments', 'solution')->findOrFail($id);
$commentsCollection = $article->comments()->with('replies')
->withTrashed()->whereNull('parent_id')->latest()->get();
// ...
}
```
아래는 `Comment` 모델의 삭제 구현이다. 자식 댓글이 없으면 `forceDelete()` 메소드로 DB 에서 완전히 삭제해 버리고, 그렇지 않으면 `delete()` 메소드로 Soft Delete 한다.
```php
// app/Http/Controllers/CommentsController.php
class CommentsController extends Controller
{
public function destroy(Request $request, $id)
{
$comment = Comment::with('replies')->find($id);
if ($comment->replies->count() > 0) {
$comment->delete();
} else {
$comment->forceDelete();
}
// ...
}
}
```
이제 뷰를 살펴보자. `$comment->trashed()` 는 Soft Delete 된 댓글일 경우 `true` 를 반환한다.
```html
@forelse($comments as $comment)
@include('comments.partial.comment', [
// ...
'hasChild' => count($comment->replies),
'isTrashed' => $comment->trashed()
])
@empty
@endforelse
```
```html
@if ($isTrashed and ! $hasChild)
@elseif ($isTrashed and $hasChild)
삭제된 댓글입니다.
@else
@endif
```
#### 댓글에 투표 기능 추가
[Disqus](https://disqus.com/), [Stack Overflow](http://stackoverflow.com/) 의 댓글들은 투표시스템 (== 포인팅 시스템) 을 가지고 있고, 그 점수에 따라 댓글의 품질을 평가하고 있다. 여기서도 유사한 기능을 구현했다. 주의할 점은 특정 댓글에 이미 Up 또는 Down 투표를 한 사용자는 같은 댓글에 대해서는 다시 투표를 할 수 없어야 한다는 점이다.
먼저 투표을 받기 위한 마이그레이션과 모델을 만들자. `user_id` 필드는 이미 투표한 사용자인지를 판단하기 위해서 사용한다. `up`, `down` 필드는 특정 `comment_id` 에 대한 투표 값 통계를 내기 위한 목적으로 각각 분리해서 필드를 생성했다.
```php
// DATE_create_votes_table.php
class CreateVotesTable extends Migration
{
public function up()
{
Schema::create('votes', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->unsigned();
$table->integer('comment_id')->unsigned();
$table->tinyInteger('up')->nullable();
$table->tinyInteger('down')->nullable();
$table->timestamp('voted_at');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->foreign('comment_id')->references('id')->on('comments')->onDelete('cascade');
});
}
}
```
아래는 `Vote` 모델이다. `User`, `Comment` 모델에서의 Reverse Relationship 은 설명을 생략한다.
```php
// app/Vote.php
class Vote extends Model
{
public $timestamps = false;
protected $fillable = [
'user_id',
'comment_id',
'up',
'down',
'voted_at',
];
protected $dates = [
'voted_at',
];
public function comment()
{
return $this->belongsTo(Comment::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
```
이제 투표를 하기 위한 UI 를 만들자. 아래 코드에서 `votes->contains('user_id', $currentUser->id); ?>` 부분을 주목해볼만 하다. 페이지가 로드될 때, 현재 로그인한 사용자가 이미 투표를 했는지 확인하는 부분인데, `\Illuminate\Database\Eloquent\Collection::contains(mixed $key, mixed $value = null)` API 를 이용하고 있다. 이미 투표했을 경우. `@if ($voted) {{ 'disabled="disabled"' }} @endif` 에서 버튼을 비활성화시켰다.
투표 값, 'up' 또는 'down' 전송은 Ajax 를 이용하고 있고, 서버에서 성공 응답을 받으면, 투표한 댓글의 투표 버튼을 비활성화 시키고 있다.
```html
@if ($currentUser)
votes->contains('user_id', $currentUser->id); ?>
@endif
@section('script')
@parent
@stop
```
이제 뷰에서 Ajax 로 전송한 투표를 받고 처리할 수 있는 Route 와 컨트롤러 로직을 만들어야 한다.
```php
// app/Http/routes.php
Route::post('comments/{id}/vote', 'CommentsController@vote');
```
아래는 컨트롤러이다. 주석에 설명을 달았다.
```php
// app/Http/Controllers/CommentsController.php
class CommentsController extends Controller
{
public function vote(Request $request, $id)
{
$this->validate($request, [
'vote' => 'required|in:up,down',
]);
if(Vote::whereCommentId($id)->whereUserId($request->user()->id)->exists()) {
// 사용자가 브라우저의 Inspector 등을 이용해서 disabled 된 투표 버튼을 다시 활성화시켜
// 중복 투표를 하는 것을 방지하기 위한 조치이다.
return response()->json(['errors' => 'Already voted!'], 409);
}
$comment = Comment::findOrFail($id);
$up = $request->input('vote') == 'up' ? true : false;
$comment->votes()->create([
'user_id' => $request->user()->id,
'up' => $up ? 1 : null,
'down' => $up ? null : 1,
'voted_at' => \Carbon\Carbon::now()->toDateTimeString(),
]);
return response()->json([
// up, down 어떤 투표인지와 투표 후 총 투표 수를 반환하고,
// 뷰의 자바스크립트에서 총 투표 수를 업데이트한다.
'voted' => $request->input('vote'),
'value' => $comment->votes()->sum($request->input('vote'))
]);
}
}
```
아래 `Comment` 모델에서 `$with` 속성은 모든 쿼리에서 포함할 Eager Loading 관계이다. 즉, `Comment::find(1)` 로만 쿼리해도 `Comment::with('author', 'votes')->find(1)` 과 같은 결과를 보여준다는 의미이다.
```php
// app/Comment.php
class Comment extends Model
{
protected $with = [ 'author', 'votes', ];
protected $appends = [ 'up_count', 'down_count' ];
public function getUpCountAttribute()
{
return (int) static::votes()->sum('up');
}
public function getDownCountAttribute()
{
return (int) static::votes()->sum('down');
}
// ...
}
```
투표를 포함한 댓글 목록을 브라우저 쪽으로 던져 주는 부분은 `ArticlesController::show()` 메소드인데 설명은 생략한다. 대신, 사용자로 부터 얻은 댓글에 대한 통계를 어떻게 얻는지를 설명하기로 한다.
`Comment` 모델에서 `up_count` 와 `down_count` 란 필드를 동적으로 생성하고 모델의 속성으로 Append 하였다. 이 속성들은 `getUpCountAttribute()`, `getDownCountAttribute()` 란 메소드에서 그 값이 채워지는데, `Comment::votes()->sum(up)` 처럼 엘로퀀트 컬렉션에서 제공하는 통계 메소드인 `sum()` 을 이용하고 있다. 앞 전에 `Vote` 모델을 채울 때, `up` 과 `down` 속성에 `null|1` 을 채운 것이 여기서 모두 합해 지는 것이다.
```bash
$ php artisan tinker
>>> $comment = App\Comment::find(22);
=> App\Comment {#835
content: "저는 자식 댓글입니다.",
#...,
author: App\User {#840
name: "Rowena Ferry",
#...,
},
votes: Illuminate\Database\Eloquent\Collection {#838
all: [
App\Vote {#843
up: 1,
#...,
},
App\Vote {#844
up: 1,
#...,
},
],
},
}
>>> $comment->up_count;
=> 2
>>> $comment->down_count;
=> 0
```

### 2. Lesson Refactoring
#### Repository 구현
강좌에서도 댓글을 쓸 수 있도록 하기 위해서, [28강 - Cache](28-cache.md) 에서 살펴본 바와 유사하게, 사용자의 마크다운 파일 요청이 있으면, 파일시스템에 있던 파일을 읽어서 바로 주는 것이 아니라 DB 에 넣었다. 즉, 파일의 내용을 DB 에 넣어서, 모델을 만들었다는 얘기다. 단, 매번 파일시스템 -> DB Insert 식으로 동작하는 것이 아니라, 캐시처럼 DB 를 먼저 탐색해서 없으면 파일시스템에서 읽어서 DB Insert 하는 식으로 구현했다.
먼저 파일로 부터 읽어 들인 마크다운 파일의 내용을 담을 DB 마이그레이션을 만들어야 한다.
```php
// DATE_create_lessons_table.php
class CreateLessonsTable extends Migration
{
public function up()
{
Schema::create('lessons', function (Blueprint $table) {
$table->increments('id');
$table->integer('author_id')->unsigned();
$table->string('name');
$table->text('content');
$table->timestamps();
$table->foreign('author_id')->references('id')->on('users');
});
}
}
```
여러가지 구조를 고민하다가, 강좌 외에도 마크다운 형식의 파일을 서비스할 일이 더 있을 것을 고려해서 Repository 를 만들기로 했다. 아래 코드에서 `find()` 메소드를 보면, 사용자가 요청한 파일 이름으로 먼저 DB 쿼리를 하고, 쿼리 결과가 `null` 이면 파일 시스템에서 읽어서 `Lesson` 모델을 생성하고 있는 것을 확인할 수 있다.
```php
// app/Repositories/MarkdownRepository.php
use Exception;
use File;
use Illuminate\Database\Eloquent\Model;
abstract class MarkdownRepository implements RepositoryInterface
{
protected $model;
protected $path;
public function __construct()
{
$this->initialize();
}
public abstract function model();
protected function initialize()
{
$model = app()->make($this->model());
if (! $model instanceof Model) {
throw new Exception(
'model() method must return a string name of an Eloquent Model.'
);
}
if (! property_exists($this->model(), 'path')) {
throw new Exception(
"{$this->model()} should have a property named 'path'"
);
}
$path = base_path($model::$path);
if (! File::isDirectory($path)) {
throw new Exception(
"Something went wrong with the path property of {$this->model()} model."
);
}
$this->model = $model;
$this->path = $path;
}
public function find($id, $columns = ['*'])
{
return $this->model->whereName($id)->first()
?: $this->model->create([
// Bad!! Avoid hard code, b.c, admin may change.
'author_id' => 1,
'name' => $id,
'content' => File::get($this->getPath($id)),
]);
}
public function image($file) { // ... }
public function etag($file) { // ... }
protected function getPath($file)
{
$path = $this->path . DIRECTORY_SEPARATOR . $file;
if (!File::exists($path)) {
abort(404, 'File not exist');
}
return $path;
}
}
```
이 추상 클래스를 상속하는 클래스는 `public abstract function` 으로 지정한 `model()` 메소드를 반드시 구현해야 한다. 그리고, 해당 Repository 와 연결된 모델에서는 `$path` 속성을 제공해야 한다. 이 클래스를 상속하게 되는 `LessonRepository` 와 `Lesson` 모델을 차례로 볼 것이다.
```php
// app/Repositories/LessonRepository.php
class LessonRepository extends MarkdownRepository
{
public function model()
{
return \App\Lesson::class;
}
}
```
```php
// app/Lesson.php
class Lesson extends Model
{
public static $path = 'lessons';
// ...
}
```
`MarkdownRepository` 추상 클래스에서 `initialize()` 메소드의 구동을 살펴보자.
- 먼저 `app()->make(string $abstract)` Helper 를 이용해서, `Lesson` 모델을 생성한다. `LessonRepository::model()` 메소드에서 `\App\Lesson` 이란 스트링을 반환하게 되어 있으므로, 당연히 전술한 코드는 `app()->make('\App\Lesson')` 와 같이 된다.
- 좀 전에 만든 클래스가 엘로퀀트 모델이 맞는지를 체크하여 아닐 경우 `\Exception` 을 던진다
- [`property_exists(mixed $class, string $property)`](http://php.net/manual/kr/function.property-exists.php) PHP 내장 함수로, `Lesson` 모델에 `$path` 속성이 지정되어 있지 않을 경우, `\Exception` 을 던진다.
- 또, `Lesson` 모델에서 `$path` 속성으로 지정한 디렉토리가 존재하지 않을 경우 `\Exception` 을 던진다.
- 모든 체크 과정이 완료되면, 이 추상 클래스가 동작하기 위해 필요한 `$model`, `$path` 속성이 모두 준비된다.
이제 `LessonsController` 에서 만들어진 `LessonRepository` 를 사용하면 된다.
```php
// app/Controllers/LessonsController
class LessonsController extends Controller
{
protected $repo;
public function __construct(LessonRepository $repo)
{
// LessonRepository 를 Injection 하고 $repo 속성에 할당한다.
$this->repo = $repo;
}
public function show($file = '01-welcome.md')
{
$lesson = $this->repo->find($file);
$commentsCollection = $lesson->comments()->with('replies')
->withTrashed()->whereNull('parent_id')->latest()->get();
return view('lessons.show', [
'index' => $this->repo->index(),
'lesson' => $lesson,
'comments' => $commentsCollection,
'commentableType' => $this->repo->model(),
'commentableId' => $lesson->id,
]);
}
// ...
}
```
강좌에서도 댓글을 사용하기 위해 `$comments` 변수를 뷰로 넘기는 것이 보일 것이다.
#### Pagination
포럼 목록보기에서 `\Illuminate\Database\Eloquent\Collection` 을 이용해서 페이지네이션을 보여주었다면, 강좌에서는 단일 강좌 상세보기를 보여주고 있으므로 "다음" 또는 "이전" 강좌로 이동하는 페이지네이션이 적절할 것으로 생각된다.
`MarkdownRepository` 에 페이지네이션에 필요한 기능을 구현하였다. `$toc` 는 전체 강좌 목록을 가진 배열이다. `$current` 는 현재 선택된 강좌의 배열 값 (== 파일이름) 이다. `$toc` 는 `initialize()` 과정에서 셋팅이되며, `$current` 는 `find()` 메소드에서 셋팅이 된다.
`$toc` 를 셋팅하는 과정에 [`glob()`](http://php.net/manual/kr/function.glob.php), [`array_diff()`](http://php.net/manual/kr/function.array-diff.php), [`array_map()`](http://php.net/manual/kr/function.array-map.php), [`pathinfo()`](http://php.net/manual/kr/function.pathinfo.php) 등의 PHP 내장 함수를 사용하는데, 공식 문서들을 참고하자.
`prev(string $current)`, `next(string $current)` 메소드는 모두 `$current` 를 인자로 받는데, 이는 현재 화면에 표시된 강좌의 마크다운 파일이름이다. 이 메소드들에서는 [`array_search()`](http://php.net/manual/kr/function.array-search.php) PHP 내장 함수를 이용해서, 인자로 넘겨 받은 `$current` 에 해당하는 배열 인덱스를 `$toc` 에서 찾은 후, +1 또는 -1 해서 원하는 인덱스에 해당하는 파일이름을 반환하게 구현되어 있다. [`array_key_exists()`](http://php.net/manual/kr/function.array-key-exists.php) 도 찾아보기 바란다.
```php
// app/Repositories/MarkdownRepository.php
abstract class MarkdownRepository implements RepositoryInterface
{
protected $toc;
protected $current;
public function __construct() {// ...}
protected function initialize()
{
if (! $this->toc) {
$all = glob(base_path($model::$path . DIRECTORY_SEPARATOR . '*.md'));
$except = glob(base_path($model::$path . DIRECTORY_SEPARATOR . '*INDEX.md'));
// $files 는 INDEX.md 를 제외한 나머지 *.md 파일들을 담고 있는 배열이다.
$files = array_diff($all, $except);
$this->toc = array_map(function($file) {
// $files 배열을 순회하면서, $this->toc 에 담는다.
return pathinfo($file, PATHINFO_BASENAME);
}, $files);
}
}
public function find($id, $columns = ['*'])
{
// $id 는 '01-welcome.md' 와 같은 파일 이름을 담고 있다.
$this->current = $id;
// ...
}
public function prev($current) {
// $current 가 '01-welcome.md' 라면,
// array_search 의 결과는 이 파일의 index 인 0 이 된다.
// $prev 에는 -1 이 할당된다.
$prev = array_search($current, $this->toc) - 1;
// $this->toc[-1] 은 존재하지 않으므로 false 가 리턴될 것이다.
return array_key_exists($prev, $this->toc) ? $this->toc[$prev] : false;
}
public function next($current) {
// $current 가 '01-welcome.md' 라면,
// array_search 의 결과는 이 파일의 index 인 0 이 된다.
// $next 에는 2 가 할당된다.
$next = array_search($current, $this->toc) + 1;
// $this->toc[2] 에 해당하는 값은 '02-hello-laravel.md' 이다.
return array_key_exists($next, $this->toc) ? $this->toc[$next] : false;
}
// ...
}
```
이제 쉽다. 컨트롤러에서 뷰에다 "이전", "다음" 에 해당하는 파일이름을 넘겨주기만 하면 되니까..
```php
// app/Http/Controllers/LessonsController.php
class LessonsController extends Controller
{
public function show($file = '01-welcome.md')
{
// ...
return view('lessons.show', [
// ...
'prev' => $this->repo->prev($file),
'next' => $this->repo->next($file),
]);
}
}
```
뷰를 보자.
```html
// resources/views/lessons/show.blade.php
@include('lessons.partial.pager')
```
컨트롤러에서 넘겨 받은 `$prev`, `$next` 값이 `false` 일 경우, 즉 처음 페이지 또는 마지막 페이지일 경우, `class="disabled"` 속성을 지정하고 있다. 반면 값이 있을 경우에는, `route('lessons.show', '파일이름')` 으로 "이전" 또는 "다음" 으로 이동하는 링크를 만드는 것을 볼 수 있다.
```
// resources/views/lessons/partial/pager.blade.php
```
기존 강좌에서 넣어 두었던 마크다운을 이용한 이전 강좌, 다음 강좌 링크와 이번에 구현한 페이지네이션간의 중복을 피하기 위해, 기존 강좌의 마크다운 파일에 <!--@start--> <!--@end--> 와 같은 커스텀 마크다운 컴파일 규칙을 정의하고, 그것을 이해하고 처리하기 위해 'app/Services/Markdown.php' 도 약간 수정했다.

#### Custom Artisan Command
사용자의 요청이 없으면 강좌의 내용은 DB 에 Insert 되지 않는다. 그렇지만, 이미 DB 에 들어간 강좌의 마크다운 파일을, 다음 릴리즈 때 수정해서 올려야 한다면 어떻게 할까? 당연히 기존에 DB 에 들어가 있는 강좌 엔트리를 모두 업데이트해 줘야 한다. 사실 'lessons' 테이블을 truncate 하면 모든게 리셋되고, 새로 파일에서 읽어 DB Insert 를 하겠지만, 이렇게 하면 문제가 생긴다. 무슨 문제이고 하니, 댓글의 'commentable_id' 값이 이미 DB 에 들어간 강좌의 'id' 와 연결되어 있다는 점이다. 강좌의 변경 내용을 효율적으로 반영하기 위해 [커스텀 Artisan 코맨드](http://laravel.com/docs/5.2/artisan) 를 만들자.
```bash
$ php artisan make:console UpdateLessonsTable
```
기본적으로 알고 있어야 할 것은 이 정도다. `$this->argument()` 로 콘솔에서 사용자가 입력한 인자를 얻을 수 있다. `$this->option()` 으로 콘솔에서 사용자가 입력한 옵션 값을 얻을 수 있다. `$this->info()`, `$this->error()`, `$this->warning()`, `$this->line()`, `$this->table()` 등으로 사용자에게 코맨드 실행결과를 알려 줄 수 있다. 사실 가장 중요한 것은 코맨드의 인자와 옵션을 지정하는 `$signature` 속성이다. 더 상세한 내용은 공식문서를 살펴볼 것을 권장한다.
```php
// app/Console/Commands/UpdateLessonsTable.php
class UpdateLessonsTable extends Command
{
protected $signature = 'my:update-lessons';
protected $description = 'Update the content of the lessons table.';
public function handle()
{
$lessons = \App\Lesson::all();
foreach($lessons as $lesson) {
$path = base_path(\App\Lesson::$path . DIRECTORY_SEPARATOR . $lesson->name);
$lesson->content = \File::get($path);
$lesson->save();
$lesson->touch();
$this->info(sprintf('Success updating %d: %s', $lesson->id, $lesson->name));
}
return $this->warn('Finished.');
}
}
```
Artisan 명령을 작성했으면 반드시 'app/Console/Kernel.php' 에 등록해 주어야 한다.
```php
// app/Console/Kernel.php
class Kernel extends ConsoleKernel
{
protected $commands = [
\App\Console\Commands\UpdateLessonsTable::class,
];
// ...
}
```
실행해 보면 아래 그림과 같은 결과를 얻을 수 있다.

**`참고`** 라라벨은 서버의 Crontab 정의를 직접 손대지 않고도, PHP 코드 레벨에서 작업 스케쥴을 정의할 수 있게 도와 준다. 공식 문서의 [Task Scheduling](https://laravel.com/docs/5.2/scheduling) 부분을 살펴볼 것을 권장한다. 아래는 이 서비스에 적용되어 있는 월 단위 라라벨 로그를 삭제하는 스케쥴이다. 매일 3시에 DB 를 백업하는 코맨드도 넣어 두었다.
```php
// app/Console/Commands/ClearLog.php
class ClearLog extends Command
{
protected $signature = 'my:clear-log';
protected $description = 'Clear Laravel log.';
public function handle()
{
$path = storage_path('logs/laravel.log');
system('cat /dev/null > ' . $path);
$now = \Carbon\Carbon::now()->toDateTimeString();
$result = "{$this->getName()} command done at {$now}";
\Log::info($result);
return $this->info($result);
}
}
```
```php
// app/Console/Kernel.php
class Kernel extends ConsoleKernel
{
// ...
protected function schedule(Schedule $schedule)
{
$schedule->command('inspire')->hourly();
$schedule->command('my:clear-log')->monthly();
$schedule->command(
sprintf('my:backup-db %s %s', env('DB_USERNAME'), env('DB_PASSWORD'))
)->dailyAt('03:00');
}
}
```
### 3. 라이브 데모 사이트 개설
#### 라이브 데모 사이트
AWS 에 오픈했다.

#### Envoy SSH Task Runner
[Envoy](http://laravel.com/docs/envoy) 는 라라벨에서 제공하는 Remote Task Runner 이다. 원격 서버에 SSH 로 직접 로그인하지 않고도, 스크립트로 써 놓은 Task 를 **"로컬"** 에서 실행할 수 있는 기능이다. Envoy 는 [Puppet](http://www.puppetlabs.com/), [Chef](https://www.chef.io/) 와 같은 프로비저닝 툴도 아니고, [Capistrano](https://github.com/capistrano/capistrano/wiki), [Deployer](https://github.com/deployphp/deployer) 와 같은 배포 툴과도 결이 다르다. 앞서 나열한 목적이 명확한 고차원적인 애들 보다는 훨신 더 저수준 (== Low Level) 이고, 그만큼 자유도가 높다는 뜻이다. 이야기인 즉, 프로비저닝 용도로도 쓸 수 있고, 배포 용도로도 쓸 수 있고, 일반적인 관리 작업 용도로도 쓸 수 있다는 말이다. 파이썬으로 만들어진 [fabric/fabric](https://github.com/fabric/fabric) 과 가장 유사하다고 할 수 있다.
Envoy 를 사용하기 위해 가장 먼저 해야 하는 일은 Envoy 를 설치하는 일이다.
```bash
$ composer global require "laravel/envoy=~1.0"
```
```bash
# 사용하는 Shell 에 따라 Profile 파일 이름은 다를 수 있다.
# 필자는 Zshell 을 쓰므로, .zshrc 이다. e.g. .profile, .bashrc
$ nano ~/.zshrc
# composer global 로 설치한 패키지들의 실행파일을 경로에 넣어 준다.
# 이 과정이 없다면 $ ~/.composer/vendor/bin/envoy 와 같이 전체 경로를 써주어야 한다.
export PATH="$PATH:$HOME/.composer/vendor/bin"
# 수정했다면 ctrl + X, "Y" 를 눌러 변경 내용을 저장하고, 엔터를 한번 더 눌러 기존 파일을 덮어 쓴다.
# 그리고, 수정 내용을 현재 콘솔에 적용해 준다. 콘솔을 껐다가 다시 실행해도 된다.
$ source ~/.zshrc
```
로컬에 설치된 Ubuntu VM을 원격 서버라 가정하고, 여기에 접속해서 터미널에서 'hello' 를 찍는 간단한 스크립트만 짜 볼 것이다. 독자들께서는 사용하시는 원격 서버의 정보를 직접 입력하여 테스트해 보시기 바란다. 여기서 재밌는 부분은 블레이드와 유사한 문법을 이용할 수 있다는 점이다.
```bash
// Envoy.blade.php
@servers(['homestead' => 'homestead.vm'])
@task('hello', ['on' => 'homestead'])
echo "Hello Envoy!";
@endtask
```
필자는 원격 서버에 사용자 이름 및 키 파일 지정없이 `$ ssh 원격서버주소` 만으로 접속할 수 있도록 아래와 같이 '~/.ssh/config' 를 항상 지정한다. 이 과정이 없다면 `$ ssh -i 키파일위치 사용자이름@원격서버주소` 식으로 로그인해야 한다. '~/.ssh/config' 를 정의하자.
```bash
$ nano ~/.ssh/config
Host homestead.vm
HostName homestead.vm
User vagrant
IdentityFile ~/.ssh/id_rsa
# 수정을 완료한 후 ctrl + X -> Y -> Enter.
```
`$ envoy run hello` 를 실행하면 아래 그림과 같이 원격 서버에 접속해서 명령을 수행하고 결과를 로컬로 다시 돌려준 것을 확인할 수 있을 것이다.

#### \# slack 을 활용한 Error Reporting
[BugSnag](https://bugsnag.com/) 과 같은 에러 관리 도구를 이용하면 좋겠지만 관리 도구는 항상 비용을 수반하게 된다. 소속한 개발팀에서 슬랙, 힙챗, 텔레그램과 같이 수시로 들여다 보는 커뮤니케이션 도구가 있다면, 원격 서버에서 발생하는 에러를 모니터링 하기에는 더 없이 좋은 도구이다. 여기서는 필자가 사용하는 [\# slack](https://slack.com/) 을 이용할 것이다.
PHP 자체는 Fatal Error 가 발생하지 않는 한 실행을 멈추지 않고 다음 코드를 실행하는 식으로 예외에 대해 관대한 편이다. 심지어 변수가 정의되지 않았을 때도 Warning 만 발생시키고 죽지 않는다. 하지만, 라라벨은 모든 Error 를 Exception 으로 던지게 구조화되어 있고, 기본기 강좌 [24강 - 예외 처리](24-exception-handling.md) 에서 살펴본 바와 같이 'app/Exceptions/Handler.php' 에서 이들을 캐치하고 있다. 즉 \# slack 으로 예외를 보고하기 위해서는 'Handler.php' 에서 관련 코드를 추가하면 된다는 의미이다.
```php
// app/Exceptions/Handler.php
class Handler extends ExceptionHandler
{
public function report(Exception $e)
{
if ($this->shouldReport($e) and app()->environment('production')) {
app(\App\Reporters\ErrorReport::class, [$e])->send();
}
//...
}
}
```
그 다음은 \# slack 메시지를 보내는 방법을 찾아야 하는데, [maknz/slack](https://github.com/maknz/slack) 와 같은 외부 라이브러리를 이용하는 방법과, 라라벨에 기본 내장된 [monolog/monolog](https://github.com/Seldaek/monolog) 를 이용하는 방법이 있다. 여기서는 maknz/slack 을 이용했다. monolog 를 이용한 구현도 'app/Reporters/MonologSlackReport.php' 에 있다.
```bash
$ composer require "maknz/slack:1.*"
# ServiceProvider, Facade, config 등은 패키지의 문서를 참고하자.
```
위 'Handler.php' 코드에서 본 바와 같이 `ErrorReport` 란 클래스를 만들고, 거기에 `send()` 라는 API 를 정의한 것을 알 수 있다.
```php
// app/Reporters/ErrorReport.php
primitive = $e;
$webhook = $webhook ?: env('SLACK_WEBHOOK');
$this->createClient($webhook, $settings);
}
public function send()
{
return $this->client->createMessage()->attach($this->buildPayload())->send();
}
protected function buildPayload()
{
return new Attachment([
'fallback' => 'Error Report',
'text' => $this->primitive->getMessage() ?: "Something broken :(",
'color' => 'danger',
'fields' => [
new AttachmentField([
'title' => 'localtime',
'value' => Carbon::now('Asia/Seoul')->toDateTimeString(),
]),
// ...
],
]);
}
protected function createClient($webhook, $overrides = [])
{
$settings = array_merge([
'channel' => '#l5essential',
'username' => 'aws-demo',
'link_names' => true,
'unfurl_links' => true,
'markdown_in_attachments' => ['title', 'text', 'fields'],
], $overrides);
return $this->client = new Client($webhook, $settings);
}
}
```
테스트를 위해 'Handler.php' 에서 `APP_ENV == 'production'` 일 때만 리포팅 하도록 한 `if ($this->shouldReport($e) and app()->environment('production')) {` 부분을 잠깐 주석 처리 하고, `App\Http\Controllers\WelcomeController::index()` 메소드에서 없는 뷰를 반환하도록 하고 홈 페이지를 방문하였다. 결과는 아래 그림과 같다.

---
- [목록으로 돌아가기](../readme.md)
- [42강 - 서버 사이드 개선](42-be-makeup.md)
================================================
FILE: lessons/44-api-basic.md
================================================
---
extends: _layouts.master
section: content
current_index: 46
---
# 실전 프로젝트 3 - RESTful API
**"실전 프로젝트 2 - Forum"** 에서 생성된 게시글/댓글을 JSON API 로 외부에 노출하여, 다양한 앱에서 "포럼" 을 이용할 수 있도록 서비스를 확장해 보자.
## 44강 - API 기본기 및 기획
RESTful API 의 이론에 대해 이해하는 시간을 가져보자.
### RESTful API
#### 먼저 REST 가 무엇인지 알아보자.
- **RE**presentational **S**tate **T**ransfer. 대응되는 한국말 번역이 없어, 대부분 "레스트" 라 그냥 읽는다.
- HTTP 의 특성을 잘 살려서 사용하는 방법에 대해, 그 창시자들이 제안한 **"이종(異種, heterogeneous) 시스템간의 네트워크 통신 구조"**다. 엄격하게 지켜야 하는 스펙은 아니지만, 남들, 특히 이름만 대면 아는 웹 거물들은 모두 쓰므로 꼭 써야 한다.
- RESTful, "레스트" 스러운 HTTP 사용법은 [13강 - RESTful 리소스 컨트롤러](13-restful-resource-controller.md) 를 필두로 앞선 실전 프로젝트에서도 계속 사용했었다. 이 강좌를 잘 따라오신 분이라면 알게 모르게 쓰고 있었고 이미 알고 있는 개념이다.
- REST 는 **1) Command** *(==Method. HEAD/GET, POST, PUT, ...)*, **2) Things** *(==Resource. articles, comments, ...)*, **3) Response** *(==Message. 200, 422 등의 HTTP 상태 코드와 text/html, application/json 등의 메시지 본문)*, 총 세 가지 큰 덩어리로 구성된다.
#### (HTTP) API 가 무엇인지 알아보자.
- API 는 시스템간의 커뮤니케이션에 사용된다. 가령, 우리가 만든 앱/서비스는 Laravel 에서 제공하는 API (e.g. `Route::resource()`) 를 이용해서 상호동작한다.
- 이종(異種) 시스템, 가령 Ruby 로 작성된 라이브러리(==API)를 PHP 에서 쓰려면, 양쪽 언어를 다 아는 번역사, 즉 Wrapping 이 필요하다. 사람 세상이랑 참 비슷하다.
- HTTP API 는 서로 다른 시스템간에도 커뮤니케이션을 할 수 있게 한다. 가령, iOS, Android, PC 등 다양한 플랫폼에서 다양한 언어로 구현된 클라이언트가 우리 API 와 데이터를 주고 받을 수 있다.
#### 종합해 보면.
영어가 공용어인것 처럼, HTTP 가 다양한 시스템에서 워낙 많이 쓰이기 때문에 거의 공용어 처럼 통한다라고 보면 된다. 모두 종합해 보면, **"서로 다른 시스템간에 네트워크를 경유해서 데이터를 교환할 때 HTTP API 라는 것을 이용하는데, 아무렇게나 짜는 게 아니라, 기계 뿐 아니라 사람이 이해하기 쉽도록, 모두가 사용하고 권장하는 형태인 REST 원칙을 따르도록 짠 API"** 가 **"RESTful API"** 인 것이다.
### RESTful API 베스트 프랙티스
아래는 [10 Best Practices for Better RESTful API](http://blog.mwaysolutions.com/2014/06/05/10-best-practices-for-better-restful-api/) 및 필자의 [RESTful API 제대로 개발하기](http://www.slideshare.net/ssuser7887b3/restful-api) 를 종합해서 정리한 내용이다. 실전 프로젝트를 진행하면서 하나씩 다시 보겠지만, 여기서 한번 정리하고 필요할 때 마다 돌아와서 잘 지키고 있는지 점검하기 위한 목적으로 나열해 본다.
1. Resource 는 명사를 쓴다.
[13강 - RESTful 리소스 컨트롤러](13-restful-resource-controller.md) 에서 배운 내용을 다시 한번 리마인드 하자. 대신 테이블 형태를 약간 바꾸었다. 여기서 Resource 란 클라이언트 사이드에서 보이는 요소인 URI Endpoint 를 의미한다. 물론 URI 뒤에는 모델이 있는데 클라이언트에게 보이진 않는다.
Type|Resource|GET(Read)|POST(Store)|PUT(Update)|DELETE(Destroy)
---|---|---|---|---|---
콜렉션|/articles|Article 목록|새 Article 만들기|`405 MethodNotAllowed`|`405 MethodNotAllowed`
인스턴스|/articles/{id}|id 를 가진 Article 상세 보기|`405 MethodNotAllowed`|id 를 가진 Article 수정|id 를 가진 Article 삭제
**`참고`** API 에서는 HTML 뷰를 응답하는 경우가 없으므로, 'GET /articles/create', 'GET /articles/{id}/edit', 2 개의 Endpoint 는 필요 없다.
**`ANTI-PATTERN`** Rosource 이름 (==Endpoint) 에 동사를 쓰지 않는 것이 좋다.
```
GET /getAllArticles
GET /getArticles?id={id}
POST /createArticles
POST /updateArticle?id={id}
POST /deleteArticles?id={id}
```
2. 적절한 HTTP 동사 (==Method) 를 사용한다.
Resource 의 상태를 변경할 때는 `POST`, `PUT`, `DELETE` 메소드를 사용한다.
**`ANTI-PATTERN`** 잘못된 메소드 사용이 불러올 재앙.
> My favorite WTF story is using a GET verb to delete resources. Which was interesting when Google crawled the API. by Jamie Hannaford
> 리소스 삭제를 위한 API Endpoint 를 DELETE 대신 GET 메소드로 정의했다. 구글 검색엔진 크롤러가 방문할 때 마다, 서비스는 안드로메다로 간다.
3. Resource 이름은 복수를 사용하고, 일관된 대소문자 규칙을 적용할 것을 권장한다.
1 번의 예에서 article 보다는 articles 가 더 낫다. Resource 이름 뿐 아니라, 필드명에서도 Snake case (e.g. snake_case), Camel case (e.g. camelCase), Dash case (e.g. dash-case) 를 일관되게 사용하자.
```
/article 보다는 /articles
/comment 보다는 /comments
```
**`ANTI-PATTERN`** 대소문자 혼용 사례.
```javascript
// GET /push_messages
{
"total": 1540,
"perPage": 10,
"current-page": 1,
"data": ["..."]
}
```
4. Resource 간 관계를 노출해야 할 경우에는 Resource 를 중첩 (==Nesting) 한다.
```
GET /tags/{id}/articles
```
5. 복잡한 것들은 모두 물음표 (?) 뒤에 둔다.
- 필터
```
GET /articles?filter=notsolved
```
- 정렬
```
GET /articles?sort=view_count&direction=asc
```
- 페이징
```
GET /articles?page=2
```
- 필드 선택
모바일에서 트래픽은 곧 사용자의 돈이다. 클라이언트가 꼭 필요한 필드만 요청할 수 있는 방법을 제공하는 것은 좋은 API 디자인이다.
```
GET /articles?fields=id,title
```
6. Content/Language Negotiation
응답을 받고자 하는 데이터 형태 (==Content-Type) 와 언어는 HTTP 헤더를 이용해서 주고 받는다.
```
// Request
GET /articles
Accept: application/json
Accept-Language: ko-KR
```
```
// Response
HTTP/1.1 200 OK
Content-Type: application/json
```
**`참고`** Resource 이름 뒤에 확장자를 붙여 Content Negotiation 을 하는 프레임웍도 있다 (e.g. /articles.json, /articles.xml). Anti-pattern 이라고는 할 수 없지만, 이 경우 API Endpoint 자체가 프레임웍에 의존성을 가지게 된다. 가령 /articles.json 을 그대로 두고, 백엔드 프레임웍을 다른 것으로 변경하고자 한다면, 확장자에 따른 Content Negotiation 로직을 별도로 구현해 주어야 한다.
7. 하위 또는 관련 리소스를 쉽게 찾을 수 있는 링크를 제공한다. (==HATEOAS)
HTML 의 경우 메뉴나 링크로 다른 페이지로 이동할 수 있는 방법을 제공하고 있다. 반면에, API 는 달랑 데이터만 제공하기 때문에, API 를 사용하는 사람이나 기계가 다음에 무엇을 해야 하고, 어디로 가야할 지 전혀 알 수가 없다. 이를 해결하기 위한 방안으로 제시된 것이 HATEOAS (Hypermedia as the Engine of Application State) 이다.
```javascript
// GET /articles
{
data: [
{
id: 1,
title: "...",
links: [
rel: "self",
href: "http://api.example.com/v1/articles"
]
author: {
id: 5,
name: "...",
links: [
rel: "self",
href: "http://api.example.com/v1/users/5"
]
}
},
{"..."}
]
}
```
8. API 버전
HTML 페이지의 경우에는 코드 배포만 하면, 사용자는 언제든지 최신 코드를 이용하게 된다. 반면, API 의 경우에는 클라이언트와 서버가 완전히 분리 (==Decoupling) 된다. 즉, 서버 사이드에서 API 를 변경하더라도, 클라이언트는 여전히 **정상적인 동작을 기대하면서** 변경 전 API 를 이용할 수 있다는 의미이다. 점진적 마이그레이션을 위해서 API 버저닝은 꼭 필요하고, 처음에 '/articles' 로 Endpoint 를 만든후, 변경되면 '/v2/articles' 로 가지 말고, 반드시 처음 부터 '/v1/articles' 로 만들 것을 권장한다.
```
GET http://api.example.com/v1/articles
GET http://example.com/api/v1/articles
```
9. 적절한 HTTP 응답 코드를 사용하자.
꽤 큰 회사/서비스 임에도 불구하고 에러일 경우에도 200 응답 코드를 사용하는 경우를 많이 봤다. HTTP 응답 코드는 괜히 있는 것이 아니며, 200 만 쓰는 것은 REST 원칙에도 어긋난다. 아래는 HTTP API 에서 주로 사용하는 응답 코드이다.
```
200 - Ok // 성공
201 - Created // 새로운 리소스 생성 요청에 대한 응답
204 - No Content // 리소스 삭제 요청 성공 등에 사용
304 - Not Modified // 클라이언트에 캐시된 리소스 대비 서버 리소스의 변경이 없을 때
400 - Bad Request // 클라이언트 쪽에서 뭔가 잘못했을 때
401 - Unauthorized // 인증 필요 (실제로는 Unauthenticated 의 의미)
403 - Forbidden // 권한 부족 (실제로는 Unauthorized 의 의미)
404 - Not Found // 요청한 리소스가 없을 때
405 - Method Not Allowed // 서버에 없는 Endpoint 일 때
406 - Not Acceptable // Accept* 헤더 또는 본문의 내용이 수용할 수 없을 경우
409 - Conflict // 기존 리소스와 충돌
410 - Gone // 404 가 아니라, 리소스가 삭제되어 응답을 줄 수 없을 경우
422 - Unprocessable Entity, // 유효성 검사 오류 등에 사용
429 - Too Many Requests, // Rate Limit 에 걸렸을 경우
500 - Internal Server Error // 서버 쪽 오류
503 - Service Unavailable // 점검 등으로 서버가 일시적으로 응답할 수 없는 경우
```
클라이언트 측 개발자가 잘 이해할 수 있는 에러 내용도 같이 담아 주는 것이 좋다.
```javascript
{
"errors": {
"code": 422,
"message": [
"title": "The title filed is required"
]
"dict": http://api.example.com/v1/docs#errors422
}
}
```
10. HTTP 메소드 오버라이드
[13강 - RESTful 리소스 컨트롤러](13-restful-resource-controller.md) 에서 이미 설명한 바 있는 내용이다. 모던 브라우저 또는 네트워크 프록시들이 GET, POST 메소드만 이해하기 때문에 PUT, DELETE 메소드를 사용할 때는 `_method=put` 과 같이 사용해야 한다고.. 라라벨은 `X-HTTP-Method-Override` HTTP 헤더를 이용한 메소드 오버라이드도 지원한다.
```
POST /articles
---payload---
_method=PUT&title=...&content=...
```
or
```
POST /articles
X-HTTP-Method-Override=PUT
---payload---
title=...&content=...
```
### 어떤 기능을 가질 것인가?
- 앞서 베스트 프랙티스라고 나열한 기능은 모두 구현해 보자.
- 기존과 다른 것은 응답의 형태 뿐이다. 앞 강에서 구현한 컨트롤러를 최대한 활용하자.
- 뷰 뒤에 숨은 데이터가 아니다. 여기서는 데이터가 서비스이다. 데이터 Transform/Serialization 을 지원한다.
- JWT (==JSON Web Token) 을 이용한 사용자 인증을 지원한다.
- Rate Limit 를 구현한다.
- 보안 목적으로 Auto-increment ID 를 숨길 것이다.
- 브라우저 클라이언트를 위해 CORS (Cross Origin Resource Sharing) 기능을 지원한다.
---
- [목록으로 돌아가기](../readme.md)
- [45강 - 기본 구조 잡기](45-api-big-picture.md)
================================================
FILE: lessons/45-api-big-picture.md
================================================
---
extends: _layouts.master
section: content
current_index: 47
---
# 실전 프로젝트 3 - RESTful API
## 45강 - 기본 구조 잡기
앞 강좌에서 기획한 내용을 가장 잘 수용할 수 있는 기본 구조를 잡아보자. 한 방에 완벽할 수는 없기에, 진행하면서 구조는 변경될 수도 있음을 감안하자.
### 단일 서버 vs. 복수 서버
대형 서비스의 경우, API 를 위해 물리적으로 분리된 별도의 서버를 두고, 데이터베이스를 공유하는 식의 아키텍처를 사용하기도 한다. 예를 들면, 서버 A 에 HTML 뷰와 RedirectResponse 를 응답하는 라라벨 어플리케이션을 구동하고, 서버 B 에 API 요청에 응답하는 라라벨 어플리케이션을 두는 식이다. 이 구조에서는 데이터 무결성 확보 등 신경 쓸 일이 굉장히 많아진다.
이 실전 프로젝트에서는 단일 서버, 단일 라라벨 프레임웍을 사용하되, API 를 위한 엔트포인트만 별도의 도메인인 `api.myproject.dev` 로 분리하도록 하자.
### 도메인 설정
앞서 얘기했듯이 `myproject.dev` 와 `api.myproject.dev` 란 도메인을 만들어 보자. 실제로 도메인 서비스에 등록하는 것은 아니고, 로컬에서 'hosts' 파일을 변경하도록 하자.
**`참고`** 운영체제에 포함된 'hosts' 파일은 DNS 로 api.myproject.dev 또는 myproject.dev 에 대한 ip 주소 Resolution 요청이 나가기 전에 요청을 낚아 채서, 'hosts' 파일 안에서 찾는다. 사용자가 요청한 도메인에 해당하는 레코드가 있으면 지정된 ip 주소로 이동할 것이다. 쓸데 없는 얘기긴한데... 보통 인터넷을 통해 라이센스 인증을 받는 상용소프트웨어의 경우, 이 hosts 파일을 이용해서 인증 서버에 해당하는 도메인을 로컬 주소로 바꾸고, 로컬 서버에서 인증된 것 처럼 꾸며 어둠의 소프트웨어를 사용할 수 있게 한다.
```bash
# Mac/Linux
$ sudo nano /etc/hosts
# Windows (코맨드프롬프트를 관리자 권한으로 실행해야 한다.)
\> notepad %SystemRoot%\System32\drivers\etc\hosts
```
```bash
# /etc/hosts
127.0.0.1 myproject.dev
127.0.0.1 api.myproject.dev
# ctrl + x, Y, Enter 순으로 변경 내용 저장
# Homestead 를 쓰신다면, 192.168.10.10 으로 해 주어야 한다.
```
로컬 서버를 띄울 때, 이전과는 명령이 달라졌으니 잘 기억해 두자. 어떻게 알았냐고? `$ php artisan help serve`.
```bash
$ php artisan serve --host=myproject.dev
```
브라우저를 열고 'http://myproject.dev:8000' 으로 접근해서 앞 강좌에서 개발한 페이지가 보이는 지 확인하자.
### Routing
도메인이 만들어 졌으니 신나게 Routing 을 정의해 보자. `Route::group()` 의 첫번째 배열 인자 안에 `domain`, `namespace`, `as` 를 썼다. `domain` 에 매칭되는 요청이 들어오면 이 Routing 블럭이 응답하게 된다. `Route::group()` 내부에서 컨트롤러를 연결시킬 때 매번 `'Api\WelcomeController@index'` 식으로 네임스페이스를 붙여주어야 하는 번거로움을 덜기 위해, `namespace` 라는 키워드를 사용한다. `'as' => 'api.'` 은 Route 이름 앞에 'api.' 을 붙이기 위해 사용하였다.
도메인 이름을 '.env' 파일의 `API_DOMAIN` 값으로 지정했는데, 이는 프로덕션으로 배포할 때마다 Route 파일을 고쳐서 배포해야 하는 번거로움을 피하기 위해서다.
`Route::group()` 안에 또 다른 `Route::group()` 이 중첩되어 있다. 두번 째 `Route::group()` 은 'http://api.myproject.dev:8000/v1' 요청에 응답하기 위한 것이다. 그래서 `'prefix' => 'v1'`, `'namespace' => 'V1'` 을 정의하고 있다.
```php
// app/Http/routes.php
Route::group(['domain' => env('API_DOMAIN'), 'as' => 'api.', namespace' => 'Api'], function() {
Route::get('/', [
'as' => 'index',
'uses' => 'WelcomeController@index'
]);
Route::group(['prefix' => 'v1', 'namespace' => 'V1'], function() {
/* Landing page */
Route::get('/', [
'as' => 'v1.index',
'uses' => 'WelcomeController@index'
]);
]);
}
// 기존 Routing ...
Route::group(['domain' => env('APP_DOMAIN')], function() {
Route::get('/', [
'as' => 'index',
'uses' => 'WelcomeController@index',
]);
});
```
사용자 인증을 위한 Routing 들인, 'auth/register', 'auth/login', 'auth/remind' 들도 정의하도록 하자. 유의할 점은 API 클라이언트와 데이터만으로 통신을 하기 때문에, 뷰를 반환하는 Route 는 필요없다는 점이다. 그리고, 비밀번호 초기화 기능에서는 사용자의 이메일 주소를 받아서 Reset 토큰을 메일로 보내는 Route 만 제공하고, 그 이후 프로세스는 API 클라이언트와 분리된 메일 클라이언트에서 이루어 지므로 Route 를 제외 했다.
### 컨트롤러
앞 절에서 봤듯이 `Api` 란 네임스페이스를 이용하고 있다. 'app/Http/Controllers/Api' 디렉토리를 만들자. 방금 만든 디렉토리에 기존에 만들었던 'WelcomeController.php' 를 복사하고 아래와 같이 내용을 변경하자.
'/v1' 요청에 응답하기 위한 'WelcomeController.php' 도, 아래 내용을 참고해서 'app/Http/Controllers/Api/V1' 디렉토리 아래에 만들도록 하자.
```php
// app/Http/Controllers/Api/WelcomeController.php
json([
'name' => 'myProject Api',
'message' => 'Welcome to myProject Api. This is a base endpoint.',
'version' => 'n/a',
'links' => [
[
'rel' => 'self',
'href' => route(\Route::currentRouteName())
],
[
'rel' => 'api.v1.index',
'href' => route('api.v1.index')
],
],
]);
}
}
```
브라우저로 테스트를 해도 되는데, API 이니까 [PostMan 크롬 확장 프로그램](https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop) 을 이용하자. 'GET http://api.myproject.dev:8000' 요청을 해 보자. 현재 컨트롤러는 무조건 JSON 만 응답하기 때문에 `Accept` HTTP Header 는 필요없지만, 좋은 습관이니 `application/json` 으로 지정하도록 하자.

### DRY 구조 설계
DRY (==Don't Repeat Yourself) 는 코드의 재사용을 의미한다.
우리는 기존에 개발한 컨트롤러들을 재활용할 것이다. 잘 생각해 보면, 기존에 개발한 컨트롤러 로직에서 API 서비스를 위해 변경되어야 하는 부분은 HTTP 응답 부분 뿐이다. 기존 컨트롤러에서는 뷰 (`\Illuminate\Contracts\View\Factory`) 또는 Redirect (`\Illuminate\Http\RedirectResponse`) 를 응답했다면, API 컨트롤러들에서는 JSON 응답 (`\Illuminate\Http\JsonResponse`) 을 반환하는 부분만 달라진다. 그래서, 기존 컨트롤러에서 뷰 또는 Redirect 를 응답하는 부분을 별도 메소드로 빼내고, API 컨트롤러들은 기존 컨트롤러를 일대일로 상속받되, 방금 추출한 응답 메소드만 오버라이드하면 깔끔할 것 같다.
**`참고`** PHP 에서는 오버로딩을 지원하지 않는다. 오버로딩은 부모 메소드와 같은 이름을 가지지만, 인자도 다를 수 있고 내부에서 완전히 다른 동작을 하고 완전히 다른 결과를 반환하는 반면, 오버라이드는 인자의 타입과 갯수가 부모와 정확히 같아야 하고, 내부의 동작만 다른 것을 의미한다. 대신 PHP에서는 반환값에 대한 타입 정의가 없어서 부모를 오버라이드한 자식 클래스의 메소드에서 반환값의 타입을 다르게 쓸 수 있다. (PHP 7에서는 부모 클래스에 반환값 타입이 선언되어 있으면, 자식 클래스에서 반환값 타입을 오버로딩을 할 수 없다.)
#### abstract 컨트롤러
기존 컨트롤러들이 상속을 받고 있는 abstract 컨트롤러인 `App\Http\Controllers\Controller` 에서 뷰에 공용 변수를 셋팅하는 부분은 Api 컨트롤러에서는 필요 없다.
```php
// app/Http/Controllers/Controller.php
abstract class Controller extends BaseController
{
public function __construct() {
if (! is_api_request()) {
$this->setSharedVariables();
}
}
// ...
}
```
`is_api_request()` 란 Helper 는 쓰일 일이 많을 것 같아서 만들었다.
```php
// app/helpers.php
function is_api_request()
{
return starts_with(Request::getHttpHost(), env('API_DOMAIN'));
}
```
#### 기존 컨트롤러 리팩토링
기존 컨트롤러에서 뷰 또는 Redirect 를 응답하는 부분을 메소드로 추출 (==Extract) 해 내자. 'routes.php' 에 정의한 대로 오늘 강좌의 대상이 되는 컨트롤러는 `UsersController`, `SessionsController`, `PasswordsController` 등이다. 몇 개만 같이 살펴보고, 나머지는 코드를 참조하기 바란다.
```php
// app/Http/Controllers/UsersController.php
namespace App\Http\Controllers;
class UsersController extends Controller
{
protected function syncAccountInfo(Request $request, User $user)
{
// ...
if ($validator->fails()) {
return $this->respondValidationError($validator);
}
// ...
return $this->respondCreated($user);
}
protected function respondValidationError(Validator $validator)
{
return back()->withInput()->withErrors($validator);
}
protected function respondCreated(User $user)
{
\Auth::login($user);
flash(trans('auth.welcome', ['name' => $user->name]));
return redirect(route('home'));
}
}
```
```php
// app/Http/Controllers/Api/UsersController.php
namespace App\Http\Controllers\Api;
class UsersController extends ParentController
{
protected function respondValidationError(Validator $validator)
{
return response()->json([
'code' => 422,
'errors' => $validator->errors()->all()
], 422);
}
protected function respondCreated(User $user)
{
// Todo 로그인 하는 대신 JSON Web Token 을 응답할 것이다.
return response()->json([
'code' => 201,
'message' => 'success',
'token' => 'token here',
], 201);
}
}
```

대충 감이 잡히는가? 하나만 더 살펴 보자.
```php
// app/Http/Controllers/SessionsController.php
namespace App\Http\Controllers;
class SessionsController extends Controller
{
public function store(Request $request)
{
// ...
if ($validator->fails()) {
return $this->respondValidationError($validator);
}
// API 에서는 쿠키를 이용한 세션 유지 (로그인)을 하지 않기에
// attempt() 메소드를 쓰지 않고 once() 메소드를 이용하였다.
// Auth::once() 는 JWT 인증으로 대체될 것이다.
$valid = is_api_request()
? Auth::once($request->only('email', 'password'))
: Auth::attempt($request->only('email', 'password'), $request->has('remember'));
if (! $valid) {
return $this->respondLoginFailed();
}
event('users.login', [Auth::user()]);
return $this->respondCreated($request->input('return'));
}
// ...
protected function respondValidationError(Validator $validator)
{
return back()->withInput()->withErrors($validator);
}
protected function respondLoginFailed()
{
flash()->error(trans('auth.failed'));
return back()->withInput();
}
protected function respondCreated($return = '')
{
flash(trans('auth.welcome', ['name' => Auth::user()->name]));
return ($return) ? redirect(urldecode($return)) : redirect()->intended();
}
}
```
```php
// app/Http/Controllers/Api/SessionsController.php
namespace App\Http\Controllers\Api;
class SessionsController extends ParentController
{
protected function respondValidationError(Validator $validator)
{
return response()->json([
'code' => 422,
'errors' => $validator->errors()->all()
], 422);
}
protected function respondLoginFailed()
{
return response()->json([
'code' => 401,
'errors' => 'invalid_credentials'
], 401);
}
protected function respondCreated($return = '')
{
// Todo 로그인 하는 대신 JSON Web Token 을 응답할 것이다.
return response()->json([
'code' => 201,
'message' => 'success',
'token' => 'token here',
], 201);
}
}
```
대충 보기에도, `respondValidationError()`, `respondCreated()` 등등 엄청난 중복이 보인다. 앞으로 진행될 강좌에서 중복들은 제거할 것이다.
**`참고`** API 클라이언트에서 소셜 로그인은 각 클라이언트 플랫폼에 맞는 SDK 를 이용해야 한다. 가령, Android 에서 Github 로그인을 지원한다면 [`wuman/android-oauth-client`](https://github.com/wuman/android-oauth-client) 와 같은 라이브러리를 이용하여 소셜 로그인을 구현한다. 그런데, Github 에서 받은 Oauth access_token 으로 Github 리소스에 접근하는 것이 아니다. 즉, 소셜 로그인은 소위 말하는 실명 확인 정도, 사용자 등록에 대한 거부감을 좀 덜어 주는 정도의 용도로만 사용하고 있다. 우리 서버의 리소스에 접근하기 위해서는 우리 서버에서 클라이언트 요청의 유효성을 인증 받을 수 있는 방법이 있어야 한다. Android Native SDK 를 이용한 소셜 인증은 받되, 가령 `onSuccess` Callback 을 받는 부분에서 서버와 인터랙션을 해야 할 것으로 생각된다. 어쩌면, 서버 측에서 이를 위한 새로운 Route 를 제공해야 할 수도 있을 것 같다. 진행하면서 같이 고민해 보자.
**`참고`** `logout()` 메소드/기능은 API 클라이언트에서는 필요하지 않다. 정당한 사용자로 부터의 API 요청인지를 서버 사이드에서 인증하는 방법으로 Oauth 또는 JWT 를 주로 사용하는데, 두 방법 모두 token 을 HTTP 요청에 포함해서 보낸다. token 이 없으면 로그인 과정을 거치지 않은 것으로 간주되고, token 이 만료되면 역시 로그인하지 않은 것으로 간주되므로 로그아웃이 필요하지 않다는 의미이다. 물론, `logout()` 기능을 제공하고, 정해진 토큰 만료 시간 이전에 토근을 강제로 삭제하거나 블랙리스트에 넣어 놓는 방법이 있기는 하지만, 필자 생각에 지금 당장은 필요성을 못 느끼겠다.
### CSRF
사용자 인증을 위해 JSON Web Token 을 사용할 것이다. 앞으로 곧 보겠지만, 모든 API 요청의 HTTP 헤더에는 `Authorization: Bearer {header.payload.signature}` 형태의 JWT 를 붙여서 보내야 하고, 이를 통해 사용자를 인식할 뿐 아니라, CSRF 와 같은 악의적인 공격으로 부터 방어할 것이다. 바꾸어 말하면 API 에서 CSRF 토큰 사용은 적절하지 않다는 말. 왜일까 잘 생각해 보면, API 에서는 HTML "폼"를 서버에서 클라이언트에 내려 주지 않기 때문에 서버에서 생성한 CSRF 토큰을 전달할 방법이 없다.
역시 [13강 - RESTful 리소스 컨트롤러](13-restful-resource-controller.md) 에서, 특정 Route 에 대해서 글로벌 미들웨어로 등록된 CSRF 를, `$except = []` 속성을 이용해서 제외시키는 방법을 살펴본 바 있다. 그런데, 이번에는 조금 특수하다. 기존 Route 와 API Route 가 동일한 형태이기 때문이다. 가령 로그인의 경우 'http://myproject.dev:8000/auth/login', 'http://api.myproject.dev:8000/auth/login' 으로 Route 에 정의된 'auth/login' Path 는 동일하기 때문이다. 둘 간에 서로 다른 부분은 도메인이라는 점에 착안해서, 필자는 아래 처럼 API 요청일 경우 CSRF 토큰 검사를 넘어가는 식으로 구현했다.
```php
// app/Http/Middlewares/VerifyCsrfToken.php
class VerifyCsrfToken extends BaseVerifier
{
public function handle($request, Closure $next)
{
if (is_api_request()) {
return $next($request);
}
return parent::handle($request, $next);
}
}
```
---
- [목록으로 돌아가기](../readme.md)
- [44강 - API 기본기 및 기획](44-api-basic.md)
- [46강 - JSON Web Token 을 이용한 인증](46-jwt.md)
================================================
FILE: lessons/46-jwt.md
================================================
---
extends: _layouts.master
section: content
current_index: 48
---
# 실전 프로젝트 3 - RESTful API
## 46강 - JWT 를 이용한 인증
### HTTP Stateless 특성에 대한 이해
HTTP 의 가장 큰 특징은 무상태 (==Stateless) 이다. 무슨 의미냐하면, 클라이언트 A 에서 Request A 와 Request B 를 했을 때, 서버 입장에서는 Request A 와 B 가 같은 클라이언트 A 로 부터의 요청인지 알 수 없다는 것이다. 공용 컴퓨터가 아닌 이상 "클라이언트"는 "사용자"랑 동일한 의미이다. 그럼, 서버에서 사용자를 어떻게 인식하는가? 라는 의문이 생긴다.
Request A 와 B 가 같은 클라이언트라는 것을 서버에게 말하는 방법은 Cookie 를 이용하거나 Url Paremeter (http:://example.com/?user=foo) 를 이용하는 방법 등이 있을 것이다. 그런데, 문제는 클라이언트 쪽에서 사용자의 신분을 조작하기가 너무 쉽다는 것이다.
이 문제를 해결하기 위해, HTTP 를 다루는 웹 서버 및 웹 프레임웍에서는 세션이라는 개념을 사용한다. 서버에서 Request A 에 대해서 고유한 세션 'key=foo' 를 생성하고, 클라이언트 A 에게 알려주면, 클라이언트 A 는 Request B 를 날릴 때 'key=foo' 를 달아서 "난 Request A 를 했던 클라이언트와 같은 놈이요" 라고 서버에게 말하는 식이다. 클라이언트 A 에서 사용자 'john@example.com' 이 자신의 신분을 서버에게 밝히면, 서버는 자신의 저장장치에서 사용자 'john@example.com' 의 신분을 확인하고 세션 정보에 기록해 두어 사용자까지도 인식하는 것이다. 이 과정을 우리는 흔히 "로그인"이라 한다.
실전에서 세션은 서버에서 생성되고, 클라이언트와 HTTP Cookie 메커니즘을 이용해서 교환된다. 아래 그림을 보자.

### API 인증 방법
위 그림은 브라우저의 경우이다. 브라우저는 쿠키를 파싱하여 내부 저장소에 보관하고, 다음 요청때 저장된 쿠키가 만료되지 않았다면 달아서 보내는 동작을 한다.
그런데, API 컨텍스트에서는 클라이언트가 꼭 브라우저라 할 수 없다. Android/iOS/PC 와 같은 다양한 플랫폼이 API 서버에 접속하게 되며, 심지어는 CURL 과 같은 콘솔형 HTTP Client 가 될 수도 있는데, 위에서 설명한 쿠키 메카니즘이 동작한다고 보장할 수 없다.
그래서, 일반적으로 아래와 같은 방법으로 API 클라이언트로 부터의 요청에 대한 유효성 검사를 수행한다.
1. HTTP Basic 인증
클라이언트에서 API 서버에 리소스 요청을 할 때, `Authorization: Basic xyz` 를 달아서 보내는 식이다. 여기서 'xyz' 는 `base64_encode('john@example.com:password')` 처럼, 사용자 인증을 위한 username:password 를 Base64 인코딩한 문자열이다. 이 방식의 좋은 점은 사용하기 쉽다는 점인데, 나쁜점은 보안에 굉장히 취약하다는 점이다. 네트워크 구간에서 탈취되면 그냥 빵 털리고, 서비스는 안드로메다로 가게 된다. https 를 이용하여 HTTP Header 의 탈취를 원천 봉쇄하는 방법이 있기는 하나, username 및 password 대한 만료 기간도 없고, 클라이언트 앱이 사용자 인증을 위한 정보를 어딘가에 저장을 해야한다는 위협도 존재한다.
```bash
$ php artisan tinker
>>> base64_encode('john@example.com:password');
=> "am9obkBleGFtcGxlLmNvbTpwYXNzd29yZA=="
>>> base64_decode('am9obkBleGFtcGxlLmNvbTpwYXNzd29yZA==');
=> "john@example.com:password"
```
**`참고`** 따지고 보면, HTML 폼을 이용한 사용자 인증도 마찬가지다. 따라서, 보안 전문가들은 https 를 항상 권장한다. 다만, API 와 달리 HTML 폼에서는 로그인을 위한 POST 요청때만 사용자 정보가 평문으로 날아간다. https 을 위한 SSL 인증서가 돈이기 때문에, 보안과 비용 사이에서 의사결정을 해야 한다. 해커들 입장에서도 사용자가 좀 되어야 털 이유가 있으니, SSL 인증서 도입 시기를 적절하게 결정해야 한다.
2. Oauth 인증
조대협님의 [REST API 의 이해와 설계 #3 API 보안](http://bcho.tistory.com/955) 편을 읽어 보자. 이 강좌에서 Github 를 이용한 소셜 인증에 적용된 기술이 Oauth2 이다. API 사용자 인증을 위해 자체 Oauth 인증 서버를 구축하고자 한다면 [`league/oauth2-server`](https://github.com/thephpleague/oauth2-server) 를 이용하자. 단점은 복잡하고 무겁다는 점이다. 이름만 대면 아는 대형 서비스들은 대부분 Oauth 를 이용한다는 점을 기억하자. 사용자가 많아 지면, 1 번이나 3 번으로 부터 적절한 시기에 Oauth 로 마이그레이션을 해야 한다.
3. JWT 인증
역시 조대협님의 [REST JWT 소개 #1 개념 소개](http://bcho.tistory.com/999) 을 읽어 보시기 바란다. 한마디로 말하자면, 사용자를 인식하기 위한 정보 (e.g. 사용자 ID) 가 이미 담겨 있는 변조가 불가능한 토큰이라 할 수 있다. 클라이언트가 API 서버에 리소스를 요청할 때 이 토큰을 `Authorization: Bearer header.payload.signature` 와 같은 HTTP Header 로 전달하고, 서버는 이 값을 해독하여 사용자를 인식하는 방식이다. 1 번과 2 번 사이에 있는, 즉, 무겁지 않지만 보안에도 강한 방식이라 할 수 있다.
### JWT 패키지
스펙을 이해하고 JWT 를 직접 구현한다는 것은 엄청난 일이다. 이미 만들어진, 그리고 커뮤니티에서도 검증된 [`tymon/jwt-auth`](https://github.com/tymondesigns/jwt-auth) 패키지를 끌어 와서 사용하자.
#### 설치
```bash
$ composer require "tymon/jwt-auth:0.5.*"
```
패키지 매뉴얼에 써진대로 서비스 프로바이더와 Facade 를 추가하자.
```php
// config/app.php
'providers' => [
// ...
Tymon\JWTAuth\Providers\JWTAuthServiceProvider::class,
],
'aliases' => [
// ...
'JWTAuth' => Tymon\JWTAuth\Facades\JWTAuth::class,
'JWTFactory' => Tymon\JWTAuth\Facades\JWTFactory::class,
];
```
패키지에서 제공하는 config 파일을 배포하고, 암호화 알고리즘에 사용할 씨드 키를 생성하자.
```bash
$ php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\JWTAuthServiceProvider"
$ php artisan jwt:generate
```
#### 설정
설정 파일을 열어 보면..
```php
// config/jwt.php
return [
'ttl' => 120,
'refresh_ttl' => 20160,
'identifier' => 'id',
// ...
];
```
중요한 설정만 살펴 보자.
- `ttl` 은 토큰의 유효 기간을 위한 설정이다. 120 이라고 한 것은 2 시간을 뜻하며, 토큰을 발행하고 난 이후 2 시간 동안 클라이언트와 서버간에 한번이라도 성공적인 인증이 없었다면 토큰은 만료된다는 의미이다. 바꾸어 말하면, 2시간 내에 한번이라도 서버에서 발급한 토큰을 이용하여 클라이언트가 서버 측에 `Authorization: Bearer header.payload.signature` HTTP Header 를 달아서 리소스 요청을 했고, 인증에 성공했다면 그 시점으로 부터 다시 2 시간 동안 토큰이 유효해진다는 의미이다.
- `refresh_ttl` 은 처음 발급 받은 토큰을 새 토큰으로 교체 발행 (==Token Refresh) 받을 수 있는 기간에 대한 설정이다. 가령 2 주 동안 클라이언트와 서버가 인터랙션이 없었다면, 처음 발급 받은 토큰을 이용해서 새 토큰을 교체 받을 수 없게 되며, 사용자이름과 비밀번호를 이용해서 새로 로그인하고 토큰을 발급 받아야 한다. `ttl` 로 지정한 2 시간을 지나서 API 서버에 리소스 요청을 하면, 401 응답을 받게 되고, 이 때 클라이언트는 토큰 교체를 위한 Endpoint 로 요청해서 토큰을 교체 받은 후, 교체 받은 토큰으로 리소스 요청을 계속 하면 된다. `refresh_ttl` 역시 토큰을 한번 교체하면 토큰의 교체 가능 기간은 다시 2주로 리셋된다.
보안 전문가들은 `ttl` 을 짧게 가져가고, Token Refresh 할 것을 권장한다. Token 이 털리더라도 `ttl` 로 지정한 시간이 지나면, 해커의 노력이 허무해지기 때문이다.
- `identifier` 는 토큰의 Subject 필드 값이며, 사용자 인증에 사용되는 필드 값이다. 아래는 User 1 번에 대한 토큰을 디코딩한 것인데, `Subject` 부분을 살펴 보자. 가령 `identifier => email` 로 지정했다면 `Subject::$value` 는 '1' 이 아니라 'john@example.com' 이었을 것이다.
```php
Payload {#283
-claims: array:6 [
0 => Subject {#251
#name: "sub"
-value: 1
}
1 => Issuer {#252
#name: "iss"
-value: "http://api.myproject.dev:8000/auth/login"
}
2 => IssuedAt {#250
#name: "iat"
-value: 1451288973
}
3 => Expiration {#280
#name: "exp"
-value: 1451296173
}
4 => NotBefore {#281
#name: "nbf"
-value: 1451288973
}
5 => JwtId {#282
#name: "jti"
-value: "e7e045b1c2f5c716b0ec19ac184344e6"
}
]
}
```
### JWT Integration
앞서도 얘기했다시피 꼭 기억해야할 점은, API 서비스는 HTTP Cookie 메커니즘을 이용할 수 없는 진정한 Stateless 라는 것이다. 그래서, 매번 리소스 요청시마다 서버 측에서는 HTTP Header 에 달린 token 에 해당하는 User 를 Resolve 해야 한다.
#### 미들웨어
`tymon/jwt-auth` 의 Integration 가이드에 제시된 패키지 내장 `Tymon\JWTAuth\Middlewar\GetUserFromToken`미들웨어를 사용하지 말고, 나름의 미들웨어를 만들 것이다. 왜냐하면, JSON 응답 포맷을 마음대로 변경할 수 없어서 이다.
```php
// app/Http/Middleware/GetUserFromToken.php
auth->setRequest($request)->getToken()) {
// HTTP Header 나 URL Parameter 에 token 값이 없으면 400 JWTException 을 던진다.
throw new JWTException('token_not_provided', 400);
}
if (! $user = $this->auth->authenticate($token)) {
// token 값으로 사용자 로그인을 한다. 해당 사용자가 없으면 404 JWTException 을 던진다.
throw new JWTException('user_not_found', 404);
}
$this->events->fire('tymon.jwt.valid', $user);
// 미들웨어는 Chain of Responsibility 디자인 패턴의 구현이다
// @see https://en.wikipedia.org/wiki/Chain-of-responsibility_pattern
return $next($request);
}
}
```
혹시, 필자가 꼭 설명할 내용을 빼먹고 개떡같이 말해도, 이제 독자 여러분들은 찰떡같이 이해할 수 있다고 생각한다. 다행히 빼먹지 않았다, 'Kernel.php' 에 방금 만들 미들웨어를 등록해주는 일 말이다.
```php
// app/Http/Kernel.php
class Kernel extends HttpKernel
{
protected $routeMiddleware = [
// ...
'jwt.auth' => \App\Http\Middleware\GetUserFromToken::class,
'jwt.refresh' => \App\Http\Middleware\RefreshToken::class,
];
}
```
`jwt.auth` 란 별칭은 곧 써야 하니 잘 기억해 두자. `RefreshToken` 미들웨어도 만들었지만 지금 당장은 사용할 계획은 없다. 강좌를 진행하면서 필요할 일이 있기를..
#### 컨트롤러
앞 강에서 JWT 관련 Todo 주석만 달아놓고 구현하지 않은 부분을 구현할 것이다.
```php
// app/Http/Controllers/SessionsController.php
class SessionsController extends Controller
{
public function store(Request $request)
{
// ...
$token = is_api_request()
// Auth::once() 를 JWTAuth::attempt() 로 변경했다.
// 이 메소드는 HTTP Request Header 의 token 을 파싱하여 일회용 로그인을 하는 역할을 한다.
? \JWTAuth::attempt($request->only('email', 'password'))
: Auth::attempt($request->only('email', 'password'), $request->has('remember'));
if (! $token) {
return $this->respondLoginFailed();
}
return $this->respondCreated($request->input('return'), $token);
}
// $token 인자가 추가 되었다.
protected function respondCreated($return = '', $token = '') {/*...*/}
}
```
`ParentController` 의 생성자에서 지정한 미들웨어를, API 인증 관련 모든 컨트롤러에서 `$this->middleware = [];` 로 무효화 시켰다. 이후 이 문서에 포함된 코드에서 생성자는 생략한다.
```php
// app/Http/Controllers/Api/SessionsController.php
class SessionsController extends ParentController
{
public function __construct()
{
parent::__construct();
// ParentController 의 미들웨어 정의 무력화.
$this->middleware = [];
}
// ...
protected function respondCreated($return = '', $token = '')
{
return response()->json([
'code' => 201,
'message' => 'success',
// 인자로 넘겨 받은 token (JSON Web Token) 을 반환한다.
// 클라이언트 사이드에서는 이 토큰을 저장하고 있다가
// Resource 요청시 Authorization Header 에 사용해야 한다.
'token' => $token,
], 201);
}
```
`UsersController` 에서는 `JWTAuth::fromUser()` 메소드를 사용하고 있다.
```php
// app/Http/Controllers/Api/UsersController.php
class UsersController extends ParentController
{
// ...
protected function respondCreated(User $user)
{
return response()->json([
'code' => 201,
'message' => 'success',
'token' => \JWTAuth::fromUser($user),
], 201);
}
}
```
Postman 을 구동하고 로그인을 해 보자. 응답으로 받은 `token` 은 곧 사용해야 하니, 어딘가에 저장해 두자, 마치 API 클라이언트가 자체 저장소에 token 을 저장해 놓는 것 처럼.

#### Exception 처리
`tymon/jwt` 는 라라벨의 철학을 따라, 여러가지 Exception 을 던지게 구현되어 있다, 우리가 자체 구현한 미들웨어에서도 그렇고. 척하면 착, 'app/Exceptions/Handler.php' 가 생각나야 한다.
```php
// app/Exceptions/Handler.php
class Handler extends ExceptionHandler
{
protected $dontReport = [
// ...
// 이 부분이 없으면 storage/logs/laravel.log 에 기록될 뿐 아니라,
// production 환경에서는 Slack 으로 Exception 이 리포트된다.
TokenExpiredException::class,
TokenInvalidException::class,
JWTException::class,
];
public function render($request, Exception $e)
{
// ...
if (is_api_request()) {
$code = method_exists($e, 'getStatusCode')
? $e->getStatusCode()
: $e->getCode();
// Exception 별로 메시지를 다르게 처리한다.
// 특히, 같은 400, 401 이라도 클라이언트가 이해하고 다음 액션을 취할 수 있는
// 메시지를 주는 것이 중요하다. 해서 xxx_yyy 식의 영어 메시지를 쓰고 있다.
if ($e instanceof TokenExpiredException) {
$message = 'token_expired';
} else if ($e instanceof TokenInvalidException) {
$message = 'token_invalid';
} else if ($e instanceof JWTException) {
$message = $e->getMessage() ?: 'could_not_create_token';
} else if ($e instanceof NotFoundHttpException) {
$message = $e->getMessage() ?: 'not_found';
} else if ($e instanceof Exception){
$message = $e->getMessage() ?: 'Something broken :(';
}
return response()->json([
'code' => $code ?: 400,
'errors' => $message,
], $code ?: 400);
}
return parent::render($request, $e);
}
}
```
### JWT 적용 및 테스트
실제 Resource 를 처리 하는 'GET /v1/articles' Route 와 `ArticlesController` 를 만들고, JWT 미들웨어를 적용해 보자.
```php
// app/Http/routes.php
Route::group(['domain' => $domain, 'as' => 'api.', 'namespace' => 'Api'], function() {
// ...
Route::group(['prefix' => 'v1', 'namespace' => 'V1'], function() {
// ...
Route::resource('articles', 'ArticlesController', ['only' => ['index']]);
});
});
```
`__construct()` 생성자에서 `$this->middleware('jwt.auth');` 를 적용한 것을 눈여겨 봐야 한다. 우선 테스트를 위한 임시 컨트롤러이므로 아래와 같이 작성한 것이고, 나중에 기존에 만들었던 컨트롤러를 상속하고 수정할 것이다.
```php
// app/Http/Controller/Api/V1/ArticlesController.php
middleware('jwt.auth');
parent::__construct();
}
public function index()
{
return \App\Article::all();
}
}
```
token 없이 'GET /v1/articles' 요청을 하거나, token 이 틀리면 아래와 같이 된다.

테스트를 위해 좀 전에 저장해 두었던 토큰이 필요하다.

인증이 완료되었으니, 다음 강좌에서는 지저분한 코드들을 좀 정리하도록 하자.
**`주의`** Apache 웹 서버를 사용한다면 `tymon/jwt-auth` 패키지의 가이드 대로 아래 내용을 꼭 추가하자. 필자의 라이브 데모 서버에서도 방금 겪은 문제이다.
```bash
# public/.htaccess
# ...
# Handle Authorization Header...
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
```
---
- [목록으로 돌아가기](../readme.md)
- [45강 - 기본 구조 잡기](45-api-big-picture.md)
- [47강 - 중복 제거 리팩토링](47-dry-refactoring.md)
================================================
FILE: lessons/47-dry-refactoring.md
================================================
---
extends: _layouts.master
section: content
current_index: 49
---
# 실전 프로젝트 3 - RESTful API
## 47강 - 중복 제거 리팩토링
앞 강에서 기존 컨트롤러의 HTTP 응답 메소드 부분을 API 관련 컨트롤러에서 오버라이드하는 과정에서, 상당히 많은 중복을 보았을 것이다. 이 중복을 제거하는 작업을 이번 강좌에서 해 볼 것이다.
### API Response 패키지
API Response 에서 중복을 피하고 Response Payload 를 좀 더 편리하게 만들 수 있는, 이 실전 프로젝트 규모에 적절한 패키지를 찾아 봤지만.. 못 찾았다. `App\Http\Controllers\Controller` 나 별도 Trait 로 API Response 를 위한 공용 메소드를 정의하는 방법이 있기도 하지만, 필자가 [Packagist](https://packagist.org/) 에 올려 놓은 [`appkr/api`](https://github.com/appkr/api) 패키지를 이용하도록 하자.
**`참고`** Laravel/Lumen 월드에서 API 관련 패키지 중에서는 [`dingo/api`](https://github.com/dingo/api) 가 갑 (甲) 인데, 라라벨의 네이티브 클래스들을 꽤 많이 오버라이드하고 있어서 사용법을 다시 익혀야 하는 단점이 있다. [45강 - 기본 구조 잡기](45-api-big-picture.md) 서두에서 같이 고민했던 "단일 서버 vs. 복수 서버" 섹션을 기억할 것이다. `dingo/api` 는 API 전용 독립 서버, 즉 복수 서버 구조에 더 적합하다고 생각된다. 거의 라라벨 프레임웍 수준의 큰 프로젝트로 API 관련 a-Z 를 모두 담고 있고, 베스트 프랙티스를 실천하고 있으므로 꼭 한번 설치해서 사용해 보기 바란다.
#### 설치
```bash
$ composer require "appkr/api:0.1.*"
```
ServiceProvider 를 설정하고 config 파일을 우리 프로젝트 안으로 끌고 오자.
```php
// config/app.php
'providers' => [
// ...
Appkr\Api\ApiServiceProvider::class,
],
```
```bash
$ php artisan vendor:publish --provider="Appkr\Api\ApiServiceProvider"
```
#### 설정
설정 파일을 확인해 보자.
```php
// config/fractal.php
return [
'pattern' => 'v1/*',
'domain' => 'api.myproject.dev',
// ...
'successFormat' => [
'success' => [
'code' => ':code',
'message' => ':message',
]
],
'errorFormat' => [
'error' => [
'code' => ':code',
'message' => ':message',
]
],
];
```
`pattern`, `domain`
: 이 패키지에서도 `is_api_request()` 란 Helper 를 포함하고 있는데, 이 Helper 에서 사용하는 설정 값들이다. 주의할 점은 이 패키지가 먼저 로드되고 난후, 우리가 정의한 Helper 가 로드되는데, 이 때 `function_exists()` 에 걸려서 우리 Helper 가 로드되지 않고,이 패키지의 `is_api_request()` 가 동작하게 된다는 점이다. PHP 네임스페이스가 필요한 이유를 방금 봤다.
`successFormat`
: 200 번 대의 성공 응답을 할 때, 이 포맷이 사용된다. `:code`, `:message` 는 `Appkr\Api\Response` 클래스의 HTTP 응답 메소드에서 HTTP Status Code 와 메소드에 넘긴 메시지로 치환된다.
`errorFormat`
: 400 번 이상의 에러 응답을 할 때, 이 포맷이 사용된다.
### 리팩토링
방금 끌어온 `appkr/api` 패키지에서는 `json(array $payload)` Helper 를 제공한다. 또, `json()` Helper 는 인자 없이 호출할 때는 `Appkr\Api\Response` 인스턴스를 리턴하기 때문에, 해당 클래스에 정의된
- `success(string $message)`
- `error(string|array|\Exception $message)`
- `respond***(string $message)`
- `setStatusCode(int $statusCode)`
- `setMeta(array $meta)`
- `...`
등 다양한 메소드를 `json()->success()` 처럼 체인해서 사용할 수 있다. `set*()` 메소드는 다른 응답 메소드보다 먼저 체인되어야 한다는 점을 주의하자.
#### 컨트롤러
하나씩 적용해 보자. `json(array $payload)` Helper 는 `response()->json(array $payload)` 와 같은 역할을 한다.
```php
// app/Http/Controllers/Api/WelcomeController.php
class WelcomeController extends Controller
{
/**
* Get the index page
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
return json([
'name' => 'myProject Api',
'message' => 'Welcome to myProject Api. This is a base endpoint.',
'version' => 'n/a',
'links' => [/* ... */],
]);
}
}
```
`unprocessableError(mixed $message)` 메소드는 HTTP 응답 코드를 422 로 설정하고, 인자로 넘겨 받은 `$message` 를, `config('fractal.errorFormat')` 으로 정의한 형태로 치환해서 HTTP 응답을 내 보내는 역할을 한다.
`setMeta(array $meta)` 는 HTTP 응답을 위한 Payload 에 `['meta' => $meta]` 를 추가해 준다.
`created(string|array|Illuminate\Database\Eloquent\Model $primitive)` 는 HTTP 응답 코드를 201 로 설정하고, 인자로 넘겨 받은 `$primitive` 의 형태에 따라 적절하게 포맷팅하여 HTTP 응답을 내보내는 일을 한다.
```php
// app/Http/Controllers/Api/UsersController.php
class UsersController extends ParentController
{
// ...
protected function respondValidationError(Validator $validator)
{
return json()->unprocessableError($validator->errors()->all());
}
protected function respondCreated(User $user)
{
return json()->setMeta(['token' => \JWTAuth::fromUser($user)])->created();
}
}
```

`SessionsController` 에서 위와 중복된 메소드는 설명을 생략했다.
`unauthorizedError(mixed $message)` 는 HTTP 응답 코드를 401로 설정하고, 넘겨 받은 `$message` 를 포맷팅해서 HTTP 응답을 반환한다.
```php
// app/Http/Controllers/Api/SessionsController.php
class SessionsController extends ParentController
{
// ...
protected function respondLoginFailed()
{
return json()->unauthorizedError('invalid_credentials');
}
}
```

`PasswordsController::respondError()` 는 어떤 에러가 넘어올 지 모르기 때문에, `notFoundError(mixed $message)` 를 쓰지 않고, 좀 더 일반적인 `error()` 메소드를 사용하였다.
`setStatusCode(int $statusCode)` 는 HTTP 응답 코드를 셋팅하는 역할을 한다. `error()` 메소드에는 'not_found' 란 스트링을 인자로 넘겨 주었다.
```php
// app/Http/Controllers/Api/PasswordsController.php
class PasswordsController extends ParentController
{
// ...
protected function respondError($message, $statusCode = 400)
{
return json()->setStatusCode($statusCode)->error('not_found');
}
}
```

`App\Http\Controllers\Api\V1\ArticlesController` 는 좀 더 다른 형태의 메소드를 사용해야 하기에, 다음 강좌에서 살펴 보기로 하자.
#### Handler
이번에는 라라벨의 글로벌 Exception Handling 을 하는 'App\Exceptions\Handler' 클래스에 끌어온 Response 의 메소드들을 적용하자.
```php
class Handler extends ExceptionHandler
{
public function render($request, Exception $e)
{
// ...
if (is_api_request()) {
// ...
return json()->setStatusCode($code ?: 400)->error($message);
}
}
}
```

---
- [목록으로 돌아가기](../readme.md)
- [46강 - JWT 를 이용한 인증](46-jwt.md)
- [48강 - all() is bad](48-all-is-bad.md)
================================================
FILE: lessons/48-all-is-bad.md
================================================
---
extends: _layouts.master
section: content
current_index: 50
---
# 실전 프로젝트 3 - RESTful API
## 48강 - `all()` is bad
앞 강에서 작성한 `App\Http\Controllers\Api\V1\ArticlesController::index()` 메소드를 살펴 보자.
```php
class ArticlesController extends Controller
{
// ...
public function index()
{
return \App\Article::all();
}
}
```
`all()` 이란 메소드를 이용해서, 리소스를 반환 하고 있다. `all|get|first\find|...` 등의 메소드를 이용하여, 컨트롤러에서 엘로퀀트 모델을 직접 반환하면, 라라벨이 자동으로 Json 으로 캐스팅해 주긴 한다. 그러나, 이렇게 엘로퀀트 모델을 직접 반환하면 다음과 같은 문제가 있다.
### Why `all()` is bad.
1. 페이징
가령 레코드가 10만개라고 생각해 보자. 응답 속도는 당연히 느릴 테고, 엄청난 네트워크 대역폭을 사용할 것이다. 그런데, 정작 클라이언트가 필요로 하는 레코드는 단 몇 개라면... 클라이언트가 필요한 데이터가 속한 구간을 탐색해서 사용할 수 있도록 API 에서 Pagination 은 필수이다.
2. 추가 데이터를 포함할 수 없다.
엘로퀀트 모델을 그대로 반환한다면, 앞 강에서 보았던 JSON Web Token, HATEOAS 를 위한 링크, 페이지네이션을 위한 정보들을 어떻게 추가할 것인가? 엘로퀀트 모델에서 [Accessor](https://laravel.com/docs/5.2/eloquent-mutators#accessors-and-mutators) 를 사용할 수 있지만 한계가 있고, API 응답만 분리하기도 쉽지 않다.
3. API 응답에 DB 의 구조가 그대로 드러난다.
엘로퀀트 모델의 속성 중에는 API 클라이언트에게 필요하지 않은 필드가 있을 수 있다. 또, 클라이언트에게 DB 의 필드 이름이 아닌 다른 필드 이름을 반환하고 싶을 수 도 있다. DB 필드가 그대로 노출되는 것은 보안 측면에서도 좋지 않고, 혹, 나중에 DB 리모데링을 하게 될 경우, 모든 API 클라이언트가 갑자기 동작하지 않고, 변경된 API 로 마이그레이션하는데 오랜 시간이 걸릴 수 있다.
4. HTTP 헤더와 응답 코드
엘로퀀트 모델을 그대로 반환하게 되면, 200, 404, 500 3 가지 응답 코드 밖에 쓸 수 없다. 뿐만 아니라, 커스텀 HTTP 헤더를 붙이기도 쉽지 않다.
그럼, 어쩌라고? `Response::make()` 또는 `response()` Helper 를 이용해서 잘 포맷팅 해서 내 보내야 하는데, 앞 강에서 끌어온 `appkr/api` 패키지가 그 역할을 해 준다.
### Transformer
앞 강에서 계속 봤듯이, 컨트롤러에서 뷰를 반환할 때 뷰에 바인딩할 데이터를 모델로 부터 뽑아서 전달한다. 그런데, 뷰에서 모델의 모든 속성 값을 표시하던가? 그리고 필요에 따라서는, 가령 `$model->created_at->diffForHumans()` 처럼 값의 형태를 변경하기여 뷰에 뿌리기도 한 것을 기억할 것이다.
그런데, API 에서는 뷰라는 것이 없다. 우리가 응답하는 JOSN, 즉 데이터 자체가 뷰 (==Presentation Layer) 라고 생각하면 되는데, 여기서도 필요한 데이터만을 표시하거나, 데이터 형태를 변경하는 일이 필요하다. 이 때 필요한 것이 Data Transformer (데이터 변환기) 이다.
Transformer (데이터 변환기) 를 이용함으로써, API 클라이언트에게 전달되는 데이터를 완벽하게 제어할 수 있다. 다시 말하면, 데이터 타입/포맷을 마음대로 변경할 수 있을 뿐더러, 필드를 추가하거나 숨기는 일이 가능해 진다. 앞 절의 3 번에서 언급한 데이터베이스 필드가 바뀌었을 때도, 이 Transformer 가 완충 역할을 할 수 있다. 우리 프로젝트의 `Article` 모델을 반환할 때, `author` 관계를 중첩 (Nesting) 하는 등의 조작도 쉬워진다.
Transformer 는 아래 'Simple Trasformer' 의 예처럼 배열을 순회하면서 간단히 구현할 수 있기는 하지만, 지난 강좌에서 가져온 `appkr/api` 패키지가 의존하는 `league/fractal` 패키지에서 제공하는 [Transformer](http://fractal.thephpleague.com/transformers/) 를 이용할 것이다.
#### Simple Transformer
우리의 실전 프로젝트에서 쓰지는 않을 것이지만, 기본은 이렇다 정도로 알아 두자.
`Transformer` 라는 추상 클래스에 `transformCollection()`, `transform()` 등의 메소드를 정의하고 있다. 자세히 보면 `transformCollection()` 메소드는 인자로 넘겨 받은 `$collection` 을 `array_map()` PHP 내장 함수를 이용해서 순회하면서 같은 클래스에 있는 `transform()` 메소드를 호출하는 것을 볼 수 있다. 그리고, `transform()` 메소드 자체는 이름만 있고, 내용이 없는 `abstract` 로 정의되어 있다.
`ArticleTransformer` 는 `Transformer` 추상 클래스를 상속하고 있기 때문에, 부모 클래스에서 `abstract` 로 정의한 `transform()` 메소드를 반드시 구현해야 한다. 여기서, 앞서 언급했던 필요한 필드명을 바꾼다거나, 데이터 타입을 변경하는 등의 작업을 수행한다.
`ArticlesController::index()` 메소드에서 JSON 을 응답할 때, 앞서 구현한 `ArticleTransformer::transformCollection()` 메소드를 이용하는 것을 볼 수 있다. 모델을 쿼리해서 얻은 엘로퀀트 Collection 을 메소드 인자로 넘기고 있는 것을 확인할 수 있다. 엘로퀀트 Collection 은 PHP 의 ArrayAccess 와 ArrayIterator 를 구현하고 있기에, 배열처럼 순회하면서 우리가 원하는 일들을 할 수 있는 것이다.
```php
// Transformer.php
abstract class Transformer
{
public function transformCollection(\Illuminate\Database\Eloquent\Collection $collection)
{
return array_map([$this, 'transform'], $collection);
}
public function transformPagination() { /* ... */ }
public abstract function transform($item);
}
```
```php
// ArticleTransformer.php
class ArticleTransformer extends Transformer
{
public function transform($article)
{
return [
'id' => (int) $article->id,
// ...
'created' => $article->created_at->toISO8601String(),
'author' => [
'name' => $article->author->name,
// ...
],
];
}
}
```
```php
// ArticlesController.php
class ArticlesController extends Controller
{
public function index()
{
return response()->json([
'data' => (new ArticleTransformer)->transformCollection(App\Article::get())
]);
}
}
```
#### Advanced Transformer
이제 이 프로젝트에서 사용할 Transformer 를 artisan CLI 로 만들것이다. CLI 사용법은 [`appkr/api` 문서](https://github.com/appkr/api) 를 참고하자.
```bash
$ php artisan make:transformer App\\Article --includes=App\\Comment:comments:true,App\\Author:author,App\\Tag:tags:true,App\\Attachment:attachments:true
$ php artisan make:transformer App\\Comment --includes=App\\Author:authors
$ php artisan make:transformer App\\Tag --includes=App\\Article::articles:true
$ php artisan make:transformer App\\Attachment
$ php artisan make:transformer App\\User --includes=App\\Article:articles:true,App\\Comment:comments:true
```
`ArticleTransformer` 하나만 살펴 보도록 하자.
```php
// app/Transformers/ArticleTransformer.php
(int) $article->id, // 정수형으로 캐스팅
'title' => $article->title,
'content_raw' => strip_tags($article->content), // HTML 태그를 모두 제거
'contant_html' => markdown($article->content), // 마크다운으로 컴파일
'created' => $article->created_at->toIso8601String(),
'view_count' => (int) $article->view_count,
'link' => [
'rel' => 'self',
'href' => route('api.v1.articles.show', $article->id), // URL
],
'comments' => (int) $article->comments->count(), // 댓글 수
'author' => sprintf('%s <%s>', $article->author->name, $article->author->email),
'tags' => $article->tags->pluck('slug'), // ['laravel', 'eloquent', '...']
'attachments' => (int) $article->attachments->count(), // 첨부파일 수
];
if ($fields = $this->getPartialFields()) {
$payload = array_only($payload, $fields);
}
return $payload;
}
// $availableIncludes 에 정의된 값들에 대응되는 includeXxx 이름의 메소드를 모두 정의해 주어야 한다.
// 이 메소드가 있어야 /v1/articles?include=comments 처럼 쿼리스트링을 통해서 하위 리소스를 포함하는 것이 가능해 진다.
// /v1/articles?include=comments 처럼 QueryString 이 달려 있으면,
// config('api.params.limit'), config('api.params.order') 에 정의한 개수와 정렬방식의 Collection 으로 응답된다.
// Article 와 Comment 의 관계는 morphMany() 로 정의되어 있어,
// Article 컨텍스트에서 Comment 는 항상 Collection 이 되어야 한다는 점을 상기하자.
public function includeComments(Article $article, ParamBag $params = null)
{
$transformer = new \App\Transformers\CommentTransformer($params);
$parsed = $this->getParsedParams();
$comments = $article->comments()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get();
return $this->collection($comments, $transformer);
}
// 얘는 belongsTo() 관계라 Item 을 응답한다.
// Simple Transformer 구현에서 봤던 내용과 크게 다르지 않다.
public function includeAuthor(Article $article, ParamBag $params = null)
{
return $this->item($article->author, new \App\Transformers\UserTransformer($params));
}
// 역시 마찬가지. 위에서 Transform 한대로 배열 형태의 Tag Slug 들만 나가지만,
// ?include=tags 이 있다면 Tag Collection 이 JSON 배열로 반환될 것이다.
public function includeTags(Article $article, ParamBag $params = null)
{
$transformer = new \App\Transformers\TagTransformer($params);
$parsed = $this->getParsedParams();
$tags = $article->tags()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get();
return $this->collection($tags, $transformer);
}
// Article 과 Attachment 는 hasMany 관계로 연결되어 있기 때문에 Collection 을 응답하는게 맞다.
public function includeAttachments(Article $article, ParamBag $params = null)
{
$transformer = new \App\Transformers\AttachmentTransformer($params);
$parsed = $this->getParsedParams();
$attachments = $article->attachments()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get();
return $this->collection($attachments, $transformer);
}
}
```
### Serializer
`league/fractal` 의 개발자인 Phil Sturgeon 의 포스트 ['The Importance of Serializing API Output'](https://philsturgeon.uk/api/2015/05/30/serializing-api-output/) 을 꼭 읽어 보자.
MSDN 정의에 따르면,
> 직렬화 (==Serialization) 란 객체를 메모리 데이터베이스, 또는 파일 등에 저장하기 위한 목적으로 바이트 스트림으로 변환하는 행위를 말한다. 직렬화를 하는 이유는, 현재 객체의 상태를 그대로 저장했다가 필요할 때 다시 꺼내 쓰기 위한 목적이다. 반대 개념은 역직렬화 (==Deserialization) 이다.

가령 객체의 속성이 변경된 상태에서 나중에 이전 상태 그대로 다시 꺼내 쓰고 싶으면 어떻게 할것인가? 객체를 `new` 키워드로 다시 생성하고, 속성값을 변경해서 사용할 것인가? 아니다, 이때 필요한 것이 직렬화이다. 쉽게 직렬화란 객체를 스트링 형태로 변환해서 저장했다가, 부활시키는 것이라고 보면 된다.
API 에서 직렬화란 Transformer 에서 변경된 모델/데이터의 상태에서의 직렬화를 의미한다. API 에서 직렬화란 위에서 얘기한 상태의 재복원 보다는 데이터를 전달하는 형태에 더 의미를 둔다. (그럼 직렬화가 맞나요? 라고 따지지 말고 그냥 그렇다고 수용하자.) `league/fractal` 에서도 여러가지 직렬화 방식 (==[Serializer](http://fractal.thephpleague.com/serializers/)) 을 지원하는데,
- `ArraySerializer`
: Collection 을 응답할 경우에만 `data` 필드를 사용한다.
- `DataArraySerializer`
: Item 이든 Collection 이든 무조건 `data` 필드를 사용한다.
- `JsonApiSerializer`
: [JSON API](http://jsonapi.org/) 스펙에 정의된 응답 형식을 따른다. `type`, `id`, `attributes` 란 필드를 필수적으로 사용한다.
우리 실전 프로젝트에서는 가장 간단한 `ArraySerializer` 를 사용할 것이다. `config/api.php` 에서 원하는 다른 Serializer 로 바꿀 수도 있고, 이미 정의된 Serializer 형식을 넘어서서 자신만의 Custom Serializer 를 만들 수도 있다.
### Controller
이제 이론을 배웠으니, 우리의 `ArticleController` 를 변경하자.
```php
// app/Http/Controllers/ArticlesController.php
class ArticlesController extends Controller
{
public function __construct()
{
$this->middleware('author:article', ['only' => ['update', 'destroy', 'pickBest']]);
if (! is_api_request()) {
// \App\Http\Controllers\Api\V1\ArticlesController 에서 이 컨트롤러를 상속할 것이므로,
// API 에 필요 없는 부분은 (! is_api_request()) 로 제외 시켰다.
$this->middleware('auth', ['except' => ['index', 'show']]);
$allTags = taggable()
? Tag::with('articles')->remember(5)->cacheTags('tags')->get()
: Tag::with('articles')->remember(5)->get();
view()->share('allTags', $allTags);
}
parent::__construct();
}
public function index(FilterArticlesRequest $request, $slug = null)
{
// ...
return $this->respondCollection($articles);
}
public function store(ArticlesRequest $request)
{
// ...
return $this->respondCreated($article);
}
public function show($id)
{
// ...
return $this->respondItem($article, $commentsCollection);
}
public function update(ArticlesRequest $request, $id)
{
// ...
return $this->respondUpdated($article);
}
public function destroy(Request $request, $id)
{
// ...
return $this->respondDeleted($article);
}
// ...
protected function respondCollection(LengthAwarePaginator $articles)
{
return view('articles.index', compact('articles'));
}
protected function respondCreated(Article $article)
{
flash()->success(trans('common.created'));
return redirect(route('articles.index'));
}
protected function respondItem(Article $article, Collection $commentsCollection = null)
{
return view('articles.show', [
'article' => $article,
'comments' => $commentsCollection,
'commentableType' => Article::class,
'commentableId' => $article->id,
]);
}
protected function respondUpdated(Article $article)
{
flash()->success(trans('common.updated'));
return redirect(route('articles.show', $article->id));
}
protected function respondDeleted(Article $article)
{
flash()->success(trans('common.deleted'));
return redirect(route('articles.index'));
}
}
```
앞선 강의에서 언급했다시피, Web Response 랑 API Response 부분은 로직에서 큰 차이가 없다, 다행시 아직까지는... 다만 차이가 나는 부분은 HTML 을 응답하냐?, JSON 을 응답하느냐? 의 차이만 있을 뿐이다. DRY (Don't Repeat Yourself) 원칙에 따라 로직을 최대한 사용하면서, `respondCollection()`, `respondCreated`, `...` 의 응답 메소드만 다르게 정의한 것을 볼 수 있다.
이제 위 클래스의 응답 메소들을 Override 하는 API 응답 메소드들을 만들어 보자. 응답에 사용한 메소드들은 `appkr/api` 의 `Appkr\Api\Http\Response` 클래스의 메소드들이다.
- `withPagination(\Illuminate\Pagination\LengthAwarePaginator $paginator, $transformer = null)`
인자로 넘겨 받은 `Paginator` 객체와 `Transformer` 객체를 이용하여, 페이징이 포함된 JSON 콜렉션을 응답한다.
- `withItem(\Illuminate\Pagination\LengthAwarePaginator $paginator, $transformer = null)`
역시, 인자로 넘겨 받은 `Paginator` 객체와 `Transformer` 객체를 이용하여, 단일 아이템에 대한 JSON 을 응답한다.
- `created(\Illuminate\Database\Eloquent\Model|array\String $primitive = 'Created')`
201 응답을 반환한다. 엘로퀀트 모델을 받으면 엘로퀀트 모델을 JSON 캐스팅해서 Response Body 에 덧붙이고, 문자열을 넘기면 'config/api.php' 의 `successFormat` 키에 지정된 형태로 JSON 응답을 한다.
- `success(array|string $message = 'Success')`
200 응답을 반환한다. `created` 와 유사하다.
- `noContent()`
204 응답을 반환한다.
```php
// app/Http/Controllers/Api/V1/ArticlesController.php
class ArticlesController extends ParentController
{
public function __construct()
{
// 'auth' 대신 'jwt.auth' 미들웨어를 사용하는데, 미들웨어를 적용시키지 않을 메소드는 동일하다.
// 읽기 요청인 'index' 와 'show' 를 제외했는데, 나중에 Rate Limit 로 시간당 요청 가능 횟수를 제한할 것이다.
$this->middleware('jwt.auth', ['except' => ['index', 'show']]);
parent::__construct();
}
// 부모 클래스를 Override 해서 JSON 응답을 반환한다.
protected function respondCollection(LengthAwarePaginator $articles)
{
return json()->withPagination($articles, new ArticleTransformer);
}
protected function respondCreated(Article $article)
{
return json()->created();
}
protected function respondItem(Article $article, Collection $commentsCollection = null)
{
return json()->withItem($article, new ArticleTransformer);
}
protected function respondUpdated(Article $article)
{
return json()->success('Updated');
}
protected function respondDeleted(Article $article)
{
return json()->noContent();
}
}
```
### Test
어떻게 나오는 지 보자. Article Collection 을 먼저 요청해 본다.
```HTTP
GET /v1/articles HTTP/1.1
Host: api.myproject.dev:8000
Accept: application/json
```

Article 개별 인스턴스를 요청한다. 그런데, 여기서는 `?include=comments:limit(2|0):order(id|desc),tags` 쿼리스트링을 덧 붙였다. 해석하자면, Comment 를 네스팅하되 0 개를 건너 뛰고 총 2개만, Tag 는 전체 콜렉션을 전부 응답해 달라는 요청이다.
```HTTP
GET /v1/articles/1?include=comments:limit(2|0):order(id|desc),tags HTTP/1.1
Host: api.myproject.dev:8000
Accept: application/json
```

이번에는 Article 을 생성하는 요청을 한다. 먼저 API_DOMAIN/auth/login 을 방문하여 JWT Token 을 얻어서, 이번 테스트 요청의 Authorization 헤더에 붙인다. title, content, tags[] 등의 내용을 입력하고 요청해 보자. 아무 내용 없는 상태로도 요청해 보면, 아마 422 Unprocessable Entity 에 에러가 있는 필드에 대한 설명이 담긴 JSON 응답을 받았을 것이다.
```HTTP
POST /v1/articles HTTP/1.1
Host: api.myproject.dev:8000
Accept: application/json
Authorization: bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6Imh0dHA6XC9cL2FwaS5teXByb2plY3QuZGV2OjgwMDBcL2F1dGhcL2xvZ2luIiwiaWF0IjoxNDUyNDM0MjU4LCJleHAiOjE0NTI0NDE0NTgsIm5iZiI6MTQ1MjQzNDI1OCwianRpIjoiNWM4ZjRhOTAxZWQ2YzljYTkxMjQ5NzU2NjVmZTMyODEifQ.bsLX0u5ZvAX2ZD3w1SSSGyhk6tg0F5q_C6nzR2Ez5Tg
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="title"
New Title
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="content"
New Content
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="tags[]"
1
----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="tags[]"
5
----WebKitFormBoundary7MA4YWxkTrZu0gW
```

나머지들은 스스로 테스트해 보자.
### Form Request
`App\Http\Controllers\ArticlesController::store()` 메소드에서 입력값 유효성 검사를 위해서 `App\Http\Requests\ArticlesRequest` 라는 [Form Request](https://laravel.com/docs/5.2/validation#form-request-validation) 인스턴스를 주입하고 있다 ([37강 - Article 기능 구현](37-articles.md)). 그런데, 이 Form Request 는 입력값 유효성 검사에 실패하면 422 JSON 응답을 반환하긴 하지만, 우리가 원하는 모양의 JOSN 포맷이 아니다. `App\Http\Requests\ArticlesRequest` 의 부모 클래스를 계속 따라가다 적절한 포인트를 찾아서 기존 응답 형식을 우리 형식에 맞도록 메소드 오버라이딩을 해 주자.
```php
// app/Http/Requests/Reqeust.php
abstract class Request extends FormRequest
{
// ...
public function response(array $errors)
{
if (is_api_request()) {
// API 요청인데, 입력값 유효성 검사에 실패했을 때, 그래서 response() 메소드에 왔을 때는
// 부모 클래스인 Illuminate\Foundation\Http\FormRequest::response() 를
// Override 해서 여기서 바로 JSON 응답을 우리 API 응답 포맷에 맞도록 내 보낸다.
return json()->unprocessableError($errors);
}
// parent::response() 를 사용하지 않고, 완전히 Overwriting 하였다.
return $this->redirector->to($this->getRedirectUrl())
->withInput($this->except($this->dontFlash))
->withErrors($errors, $this->errorBag);
}
public function forbiddenResponse()
{
if (is_api_request()) {
// 역시 위와 동일하다.
return json()->forbiddenError();
}
return response('Forbidden', 403);
}
}
```
### Integration Test
특히 API 의 경우에는 UI 가 없어서 육안 테스트가 번거로울 뿐 아니라, 수정할 일도 많아서 통합 테스트를 작성하는 것이 좋다. 이번 강좌에서 테스트 코드 구현에 대한 내용은 설명하지 않지만, tests 디렉토리 아래에 있는 테스트 코드들을 살펴보도록 하자.
```bash
$ phpunit
```

테스트 코드를 짜는 과정에서 `App\Http\Controllers\ArticlesController::update()` 동작 관련 몇가지 버그를 잡았다.
```php
// app/Http/Requests/ArticlesRequest.php
public function rules()
{
$rules = [];
if ($this->isUpdate()) {
// update 요청일 때와 아닐 때로 유효성 검사 규칙을 분리했다.
$rules = ['tags' => ['array']];
} else {
$rules = [
'title' => 'required',
'content' => 'required',
'tags' => 'required|array'
];
}
return $rules;
}
```
```php
// app/Http/Requests/Request.php
protected function isUpdate()
{
$needle = ['put', 'patch'];
return in_array(strtolower($this->input('_method')), $needle)
or in_array(strtolower($this->header('x-http-method-override')), $needle)
// _method=PUT 등으로 메소드 오버로딩을 하지 않아도 되는 클라이언트를 위해 아래 한줄을 보강했다.
or in_array(strtolower($this->method()), $needle);
}
```
```php
// app/Http/Controllers/ArticlesController.php
public function update(ArticlesRequest $request, $id)
{
// If Check 가 추가되었다. tags 필드를 넘기지 않으면 에러가 나므로...
if ($request->has('tags')) {
$article->tags()->sync($request->input('tags'));
}
// ...
}
```
또, Article 모델에 접근제한하는 부분에서도, API 요청일 경우에 적절한 JSON 응답을 하도록 고쳤다.
```php
// app/Http/Middleware/AuthorOnly.php
public function handle(Request $request, Closure $next, $param)
{
//...
if (! $model::whereId($modelId)->whereAuthorId($user->id)->exists() and ! $user->isAdmin()) {
if (is_api_request()) {
return json()->forbiddenError();
}
return back();
}
return $next($request);
}
```
---
- [목록으로 돌아가기](../readme.md)
- [47강 - 중복 제거 리팩토링](47-dry-refactoring.md)
- [49강 - API Rate Limit](49-rate-limit.md)
================================================
FILE: lessons/49-rate-limit.md
================================================
---
extends: _layouts.master
section: content
current_index: 51
---
# 실전 프로젝트 3 - RESTful API
## 49강 - API Rate Limit
### Why?
API 엔드포인트의 사용량을 제한하는 이유는 여러가지이다.
- DDoS 공격으로 부터 서비스를 보호한다.
- API 클라이언트 간에 API Resource 의 사용에 공평성을 제공한다. 가령, 소수의 Heavy 클라이언트로 인해 다른 선량한 클라이언트가 피해를 입지 않도록 말이다.
- API 를 통해 받는 데이터 자체가 돈과 직결되는 경우, 그 Business Model 로서의 역할을 한다.
### 적용
라라벨 5.2 를 사용하고 있다면, `\Illuminate\Routing\Middleware\ThrottleRequests` 라는 HTTP Middleware 가 이미 내장되어 있다. 우리의 `ArticlesController` 에 적용하기 전에 Route Middleware 로 잘 등록되어 있나 확인해 보자.
```php
// app/Http/Kernel.php
class Kernel extends HttpKernel
{
// ...
protected $routeMiddleware = [
// ...
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
];
}
```
적용하자.
```php
// app/Http/Controllers/Api/V1/ArticlesController.php
class ArticlesController extends ParentController
{
public function __construct()
{
$this->middleware('jwt.auth', ['except' => ['index', 'show']]);
$this->middleware('throttle:60,1');
parent::__construct();
}
// ...
}
```
아주 간단하다. `throttle:60,1` 이란, 클라이언트당 1 분에 60번 요청을 허용하겠다는 의미이다. `throttle:3,1` 정도로 수정하고 GET /v1/articles 요청을 여러번 연달아 해 보자. 네번째 요청에서 아래와 같은 응답을 받고, 1분 후에 다시 사용 가능한 상태가 되는 것을 확인할 수 있다.
```HTTP
HTTP/1.1 429 Too Many Requests
Host: api.myproject.dev:8000
# ...
Retry-After: 60
X-RateLimit-Limit: 3
X-RateLimit-Remaining: 0
```
### 다듬기
API 응답 포맷의 일관성을 위해, `text/application` 이 아니라 `application/json` 으로 응답하도록 수정하자. 라라벨 내장 `\Illuminate\Routing\Middleware\ThrottleRequests` 를 상속 받아 `\App\Http\Middleware\ThrottleApiRequests` 를 만들고 여기서 Rate Limit 에 걸렸을 경우 JSON 을 응답하게 수정하면 될 것이다.
```php
resolveRequestSignature($request);
if ($this->limiter->tooManyAttempts($key, $maxAttempts, $decayMinutes)) {
return json()->setHeaders([
'Retry-After' => $this->limiter->availableIn($key),
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => 0,
])->tooManyRequestsError();
}
// ...
}
}
```
새로 만들어진 HTTP Middleware 를 등록하고, `ArticlesController` 에 적용하자.
```php
// app/Http/Kernel.php
class Kernel extends HttpKernel
{
// ...
protected $routeMiddleware = [
// ...
'throttle.api' => \App\Http\Middleware\ThrottleApiRequests::class,
];
}
```
```php
// app/Http/Controllers/Api/V1/ArticlesController.php
class ArticlesController extends ParentController
{
public function __construct()
{
$this->middleware('jwt.auth', ['except' => ['index', 'show']]);
$this->middleware('throttle.api:60,1');
parent::__construct();
}
// ...
}
```
이제 `throttle.api:3,1` 정도로 수정하고 테스트해 보면, 우리의 일관된 포맷의 JSON 응답을 볼 수 있을 것이다.
```HTTP
HTTP/1.1 429 Too Many Requests
Host: api.myproject.dev:8000
Content-Type: application/json
# ...
Retry-After: 60
X-RateLimit-Limit: 3
X-RateLimit-Remaining: 0
{
"error": {
"code": 429,
"message": "Too Many Requests"
}
}
```
### 추가 적용
API 컨트롤러들, `SessionsController`, `UsersController`, `PasswordsController` 등에도 적용하자. 필자의 경우 `throttle.api:10,1` 로 정의했다. 상용에서는 서비스를 하면서 여러가지 통계 정보들에 기반해서 클라이언트 당 허용할 적절한 Rate Limit 값을 정해야 한다.
**`참고`** Laravel 5.2 를 이용하지 않는다면, [`graham-campbell/throttle`](https://github.com/GrahamCampbell/Laravel-Throttle) 패키지를 이용하자.
---
- [목록으로 돌아가기](../readme.md)
- [48강 - all() is bad](48-all-is-bad.md)
- [50강 - 리소스 id 난독화](50-id-obfuscation.md)
================================================
FILE: lessons/50-id-obfuscation.md
================================================
---
extends: _layouts.master
section: content
current_index: 52
---
# 실전 프로젝트 3 - RESTful API
## 50강 - 리소스 id 난독화
### Why?
엘로퀀트는 기본적으로 자동 증가 ID 를 PRIMARY KEY 로 사용한다. 대부분의 서비스들이 아무런 문제없이 이렇게 사용한다. 그런데, 어떨 때는 PRIMARY KEY 가 예측이 불가능한 것이 더 나은 경우가 있다. 아래 예를 생각해 보자.
예를 들어 식당을 예약하고 예약 내용을 얻어 오는 API 를 서비스한다고 가정하자. API 클라이언트가 예약 API 를 호출했고, 해당 클라이언트를 소유한 사용자의 예약 id 인 15 번을 담은 JSON 을 응답했다고 가정해 보자. 인증이나 권한 부여가 없는 API 서비스였다면... 이 클라이언트는 14 번 예약을 읽어 볼 수도 있고, 16 번 예약을 변경하거나 삭제할 수도 있게 된다.
또 이건 어떤가? `$id++` 이용해서 API 데이터 전체를 훔칠 수 있다. 가령, `/users/{id}` 처럼 사용자 profile 에 대해 API 요청을 할 수 있다면 사용자 정보를 훔칠수도 있고, 경쟁자가 우리 서비스의 전체 사용자 수도 카운트할 수 있다. 대형 서비스에서 자동 증가 ID 를 절대 사용하면 안되는 이유이다.
```php
class Scraper
{
protected $base;
protected $client;
protected $failCount;
public function __construct($base)
{
$this->base = $base;
$this->client = new \GuzzleHttp\Client;
}
public function steal($id)
{
try {
$response = $this->client->get($this->base . DIRECTORY_SEPARATOR . $id);
if ($response->getStatusCode() !== 200) {
throw new \Exception('Failed');
}
$this->failCount = 0;
return \File::put(storage_path("stealed/{$id}.json"), $response->getBody()->getContents());
} catch (\Exception $e) {
$this->failCount++;
if ($this->failCount !== 0 and $this->failCount <= 3) {
$this->steal($id);
}
return false;
}
}
}
$scraper = new Scraper('http://your-api-host/articles');
$id = 1;
while ($scraper->steal($id) !== false) {
$i++;
}
```
### How?
두가지 방법이 있다.
1. DB 에 기록할 때 Auto-increment ID 를 사용하지 않고, 난수화된 ID 로 기록하는 방법
2. DB 기록은 Auto-increment ID 를 사용하되, 사용자에게 전달될 때는 난수화하여 전달하는 방법
우리의 실전 프로젝트가 처음부터 MongoDB 와 같은 NoSQL Document DB 를 선택했다면, 이 문제는 처음부터 없었을 것이다. 우리 프로젝트는 MySql 을 사용하고 있으므로 두번째 방법을 이용할 것이다.
**`참고`** Auto-increment ID 를 사용하지 않을 경우, 각 모델에서 `public $incrementing = false;` 로 설정해 주고, 모델을 만들 때 마다 id 값을 직접 넣어 주어야 한다.
**`참고`** 라라벨에서 MongoDB 를 사용하고자 할 때는, `$ brew install homebrew/php/phpXX-mongodb` 명령으로 MongoDB 확장 모듈을 설치한 후, 라라벨용 [`jenssegers/mongodb`](https://github.com/jenssegers/laravel-mongodb) 드라이버 패키지를 설치해서 이용하자.
### 난독화 패키지 선택 및 설치
이 강좌에서는 [`jenssegers/optimus`](https://github.com/jenssegers/optimus) 패키지를 이용한다.
```bash
$ composer require jenssegers/optimus
```
난독화에 사용할 소수 (素數, Prime number) 를 만들자. 생성된 값은 곧 사용해야 하니 잘 기록해 두자.
```bash
$ vendor/bin/optimus spark
# Prime: 132961291
# Inverse: 1484265379
# Random: 37817169
```
Tinker 코맨드로 방금 설치한 패키지를 사용해 보자. 어떤 원리로 동작하는 지 쉽게 감을 잡을 수 있을 것이다.
```bash
$ php artisan tinker
>>> $optimus = new Jenssegers\Optimus\Optimus(132961291, 1484265379, 37817169);
=> Jenssegers\Optimus\Optimus {#805}
>>> $optimus->encode(1);
=> 95280986
>>> $optimus->decode(95280986);
=> 1
```
**`참고`** base62 를 이용한 [zackkitzmiller/tiny-php](https://github.com/zackkitzmiller/tiny-php) 난독화 패키지도 추천한다.
**`참고`** 또 하나의 옵션은 UUID 패키지, [`ramsey/uuid`](https://github.com/ramsey/uuid), [`webpatser/laravel-uuid`](https://github.com/webpatser/laravel-uuid) 를 이용하는 것이다. 다만 UUID 를 사용할 경우, 문자, 숫자, 대시가 포함된 36 Byte 가 id 값으로 사용되므로, 그에 맞게 `App\Providers\RouteServiceProviders` 에서 `$router->pattern()` 부분을 손 봐 주어야 한다.
### 난독화 패키지 정합
#### 디자인
우리 프로젝트의 요구에 맞도록 아래와 같이 동작 구조를 디자인 해 보자.
- 난독화 기능을 쉽게 사용할 수 있도록 Service Provider 를 만들자.
- API 에서만 난독화를 적용하자.
- JSON 응답을 내 보낼 때, [48강 - all() is bad](48-all-is-bad.md) 에서 만든 Transformer 에서 id 를 난독화하자.
- URL 을 통해서 넘어온 난독화된 id 값을 Route Middleware 에서 해독하자.
#### Service Provider and Helper
매번 `new` 키워드와 좀 전에 만든 소수를 넣어서 Optimus 를 깨울 수 없으니, Service Provider 를 만들자.
```php
// app/Providers/AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton(\Jenssegers\Optimus\Optimus::class, function () {
return new Optimus(132961291, 1484265379, 37817169);
});
// ...
}
}
```
내친 김에 `optimus()` Helper 도 만들자.
```php
// app/helpers.php
function optimus($id = null)
{
$factory = app(\Jenssegers\Optimus\Optimus::class);
if (func_num_args() === 0) {
return $factory;
}
return $factory->encode($id);
}
```
#### Transformer
적용 방법은 모두 동일하니 `ArticleTransformer` 하나만 살펴 보자.
```php
// app/Transformers/ArticleTransformer.php
class ArticleTransformer extends TransformerAbstract
{
public function transform(Article $article)
{
$id = optimus((int) $article->id);
return [
'id' => $id,
// ...
'link' => [
'rel' => 'self',
'href' => route('api.v1.articles.show', $id),
],
// ...
];
}
}
```
**`참고`** API 뿐아니라 서비스 전체에 걸쳐 난독화된 ID 를 사용하고 싶다면, Transformer 보다는 모든 Model 들이 상속 받고 있는 추상 부모 클래스에 Accessor 를 구현하는 것이 더 적절한 방법이라 생각된다. 아래 예처럼 말이다.
```php
// app/Model.php
abstract class Model extends Eloquent
{
public function getIdAttribute($value)
{
return optimus($value);
}
}
```
#### Route Middleware
앞 절에서 Transformer 를 이용해서 API 응답에만 난독화된 ID 를 제공하는 것으로 구현했으므로, API 컨트롤러에서 모델에 대한 쿼리를 하기 전에 난독화된 ID 를 해독해 주면 된다. 최선의 방법인지는 모르겠지만, API 요청일 경우에만 적용하기에 가장 적절한 방법을 필자는 Route Middleware 라고 생각했다.
```bash
$ php artisan make:middleware ObfuscateId
```
[37강 - Article 기능 구현](37-articles.md) 에서 소유자가 아닌 경우, Article 모델을 변경하거나 삭제할 수 없도록 하기 위해서 미들웨어 파라미터를 받을 수 있는 `CanAccess` 미들웨어를 만든 기억을 더듬어 보자. `$ php artisan route:list` 로 봤을 때, `Route::resource()` 를 이용한 URL Endpoint 에서 Route Parameter 는 {id} 가 아니라, {articles} 와 같이 이름이 지어진다는 점을 확인할 수 있었다. 여기서도 미들웨어 파라미터 `$param` 으로 넘긴 값을 이용해서 Route Parameter 키 값을 계산하고 있다. 여기서 `$routeParamName = 'articles'` 가 된다.
Route Parameter 가 {articles} 라는 것을 알았다. 값을 받아 보면 난독화되어 있을 것이다. 이 값을 `optimus()` 로 해독하여, 기존 Route Parameter 값을 오버라이드하는 작업을 해 주어야 한다. 여기서 `$routeParamValue = 1026009865` 와 같은 값이고, `optimus()->decode(1026009865) = 8` 처럼 처리된다. `getParameter()`, `setParameter()` 는 Request 인스턴스에 바인딩되어 있는 Route 인스턴스의 메소들이다.
```php
// app/Http/Middleware/ObfuscateId.php
class ObfuscateId
{
public function handle(Request $request, Closure $next, $param = null)
{
$routeParamName = $param ? str_plural($param) : 'id';
if ($routeParamValue = $request->route()->getParameter($routeParamName)) {
$request->route()->setParameter($routeParamName, optimus()->decode($routeParamValue));
}
return $next($request);
}
}
```
'app/Http/Kernel.php' 의 `$routeMiddleware` 섹션에 등록하는 것을 잊지 말자. 필자는 `obfuscate` 별칭으로 등록하였다.
**`참고`** 서비스 전체에 ID 난독화를 적용하고 싶다면, 방금 만든 미들웨어를 글로벌 미들웨어로 등록하면 될 것이다. 이 경우 Route 나 컨트롤러에서 `$this->middleware()` 를 정의할 필요가 없어지게 된다.
#### Controller
미들웨어를 적용하자. `obfuscate:article` 과 같이 Middleware Parameter 를 넘겨 준 것을 확인하자.
```php
// app/Http/Controllers/Api/V1/ArticlesController.php
class ArticlesController extends ParentController
{
public function __construct()
{
$this->middleware('obfuscate:article');
// ...
}
// ...
}
```
### 테스트

Integration Test 코드도 약간 수정되었다.
```bash
$ phpunit
```
---
- [목록으로 돌아가기](../readme.md)
- [49강 - API Rate Limit](49-rate-limit.md)
- [51강 - CORS](51-cors.md)
================================================
FILE: lessons/51-cors.md
================================================
---
extends: _layouts.master
section: content
current_index: 53
---
# 실전 프로젝트 3 - RESTful API
## 51강 - CORS
이번 강좌에서는 [CORS (Cross Origin Resource Sharing)](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing) 에 대해 공부해 보자. 개념적으로는 굉장히 어려운 내용이지만, 해결 방법 자체는 엄청 간단하다.
### CORS 란?
개념을 알고 넘어 가야 한다. 어려운 개념을 복잡하게 설명하면 고수가 아니다. 필자도 잘 모르기 때문에 딱 눈 높이에 맟추어 설명하자면...
- 일단 CORS 이슈는 JavaScript 를 이용하는 API Client 에서만 발생한다.
Android, iOS 에서는 신경쓸 필요가 없다. 왜냐하면, 브라우저를 통한 Ajax (== `XMLHttpRequest`) 요청을 할 때만, Same Origin Security Policy 가 적용되기 때문이다. XSS (== Cross Site Script) 공격을 방지하기 위해, W3C 와 브라우저 벤더들이 그렇게 하기로 오래전에 약속했다.
- Same Origin Security Policy (== 동일 출처 보안 정책) 란...
foo.com 에서 동작하는 JavaScript 가 bar.com 에 있는 API 서버에 Ajax 요청을 할 수 없다는 것이다. 심지어 http://foo.com -> https://foo.com 뿐 아니라, http://foo.com -> http://foo.com:8080 도 동작하지 않는다. 왜냐하면, JavaScript 는 태생적으로 클라이언트에 다운로드 되어 사용되는 프로그램이라, JavaScript 코드를 사용자가 임의로 변경할 수 있기 때문이다.
아래는 이번 강좌를 위해 개발한 JavaScript 기반의 [`appkr/api-client-demo`](https://github.com/appkr/api-client-demo) 코드를 http://localhost:3000 에서 구동하고, http://api.myproject.dev:8000 API 서버에 리소소를 요청하는 그림이다.

JavaScript 기반의 SPA (== Single Page Application) 들이 계속 늘어나고 있고, 클라이언트와 서버는 API 로 데이터만 주고 받고, 뷰/UI 는 클라이언트 쪽에 모두 맡기는 것이 모던 웹 개발의 베스트 프렉티스라는 점을 감안한다면, API 서버를 개발하는 백엔드 개발자 입장에서 Same Origin Security Policy 문제는 반드시 해결해야 할 숙제이다.
### 해결방안
여러가지 방법이 있다.
1. `JSONP` 를 이용하는 방법.
Legacy 이다. CORS 이슈가 대두된게 10년이 넘었고, W3C 스펙이 확정된지 5년이 넘었다. 쓰면 안된다는 얘기다. 게다가 JSONP 는 HTTP GET 요청만 적용할 수 있다는 한계도 있다.
2. Reverse Proxy 를 이용하는 방법.
Reverse Proxy (또는 L4) 가 클라이언트의 HTTP 요청을 보고, Reverse Proxy 내부에 위치한 web 또는 api 적절한 서버로 요청을 분기하는 방법이다. 클라이언트가 Reverse Proxy 주소로 요청하고, 서버오 Reverse Proxy 뒤에 있기에 문제가 발생하지 않는다. 다만, 자기 서비스에만 적용할 수 있다는 단점이 있다.
3. HTTP `Options` Pre-flight Request 를 이용해 White-list 하는 방법.
클라이언트가 HTTP `OPTIONS` 요청을 먼저 한 후, 응답을 해석한 후, 본 요청을 해야 한다. 서버 쪽에서도 로직이 복잡하다.
4. `Origin` 및 `Access-Control-*` HTTP 헤더를 이용하는 방법.
우리는 가장 간단한 4 번 방법을 이용할 것이다.
### 패키지 설치
[`barryvdh/laravel-cors`](https://github.com/barryvdh/laravel-cors) 패키지를 이용할 것이다.
이 패키지가 의존하는 [`asm89/stack-cors`](https://github.com/asm89/stack-cors) 패키지의 Git Tagging 이 늦어, Composer 설치시 충돌이 발생한다. 우리는 아래와 같이 우회해서 설치할 것이다.
```javascript
// composer.json
{
// ...
"require": {
// ...
"asm89/stack-cors": "dev-master as 0.2.2",
"barryvdh/laravel-cors": "^0.7.3"
}
// ...
}
```
```bash
$ composer update
```
공식 문서에 나온대로 ServiceProvider 를 활성화 해 준다.
```php
// config/app.php
return [
// ...
'providers' => [
// ...
Barryvdh\Cors\ServiceProvider::class,
],
// ...
];
```
### CORS 기능 정합
서두에 구현이 굉장히 간단하고 얘기했다. `barryvdh/laravel-cors` 패키지를 설치하고 나면, `cors` 라는 별칭을 가진 Route Middleware 를 사용할 수 있다. API 에만 이 미들웨어를 적용할 것이므로 가장 적절한 위치는 Route 정의 파일인 듯 하다.
```php
// app/Http/routes.php
Route::group(['domain' => env('API_DOMAIN'), /* ... */, 'middleware' => 'cors'], function() {
// ...
}
```
이걸로 끝이다.
**`참고`** Laravel 5.2 부터 Middleware Group 을 이용할 수 있다. 기본으로 `web`, `api` 두개의 그룹이 가용하다. 요는 여러개의 미들웨어를 모아 `api` 등의 별칭으로 한번에 적용할 수 있다는 것이다.
### 살펴 보기
설치한 `barryvdh/laravel-cors` 패키지와 `cors` 미들웨어는 어떤 일을 할까?

JavaScript 클라이언트에서 Ajax 요청을 할 때 `Origin` HTTP Header 를 달아서 보내면, API 서버에서 응답할 때 `Access-Control-Allow-Origin` HTTP Header 를 내려주는 식이다. 그림을 보면, "JavaScript 엔진아! 나 API 서버인데... http://localhost:3000 은 나랑 scheme:://host:port 가 달라도 내가 허용해 줄라니까, Same Origin Security Policy 적용하지 말고 내 Client 한테 데이터 좀 넘겨 줘~" 라고 부탁하는 식이다. 왼쪽 클라이언트 화면을 보면, API 서버로 데이터를 받아 뷰를 정상적으로 렌더링한 것을 볼 수 있다. 요약하자면, `Origin` 요청 헤더를 검사하고, `Access-Control-*` 응답 헤더를 붙여 주는 역할을 한다.
이 패키지는 `Access-Control-*` 헤더 방식을 이용한 CORS Handling 만 하는 것이 아니라, 앞서 언급한 Pre-flight 방식도 지원한다.
### api-client-demo
전체 코드는 [`appkr/api-client-demo`](https://github.com/appkr/api-client-demo) 에서 확인하기로 하자. 앞 강에서와 달리 [Vue.js](http://vuejs.org/) 프레임웍을 이용하고 있고, API 서버에 Ajax 요청을 하는 부분은 아래와 같다.
```javascript
// @appkr/api-client-demo
// app/scripts/main.js
(function(Vue, moment) {
'use strict';
var base = 'http://api.myproject.dev:8000';
var vm = new Vue({
el: '#demo',
data: {
articles: {}
},
ready: function() {
var resource = this.$resource(base + '/v1/articles');
resource.get('').then(function(response) {
this.$set('articles', response.data.data);
}, function(response) {
console.log(response);
});
},
filters: {
// ...
},
http: {
headers: {
Accept: 'application/json'
}
}
});
})(Vue, moment);
```
[`appkr/api-client-demo`](https://github.com/appkr/api-client-demo) 에서 뷰는 Bootstrap 대신 [Google Material Design Lite](http://www.getmdl.io/) 를 사용하고 있다. [Cordova](https://cordova.apache.org/) 를 이용하면 Android, iOS 모바일 앱으로도 패키징해서 사용해 볼 수 있다.
```html
```

---
- [목록으로 돌아가기](../readme.md)
- [50강 - 리소스 id 난독화](50-id-obfuscation.md)
- [52강 - Caching](52-caching.md)
================================================
FILE: lessons/52-caching.md
================================================
---
extends: _layouts.master
section: content
current_index: 54
---
# 실전 프로젝트 3 - RESTful API
## 52강 - Caching
강좌를 시작하기 전에 "적정 기술" 이란 개념에 대해 먼저 잡담을 좀 하자.
원래 의미와 약간 다를 수 있지만, 필자는 자신이 속한 환경, 즉 비즈니스/서비스의 성숙도나 규모에 따라 적합한 기술을 선택하는 행위로 해석한다. 스타트업이 MVP (==Minimum Viable Product) 를 개발하는 데, Caching 까지 고려해서 할 필요는 없다는 얘기다. 일 Page View 가 몇 천, 피크 타임에 동시 사용자가 수십 명 정도인 서비스에 Caching 까지 고려해서 API 서버와 API 클라이언트를 개발할 필요는 없다는 얘기다.
바꾸어 얘기하면, 트래픽이 많은 서비스는 서버 사이드 및 클라이언트 사이드 모두에서 캐시를 구현해야 한다는 얘기다. 좀 더 나아가서 서버 팜에는 L4, Web, DB, Cache 등 서버가 모두 분리되어 있고 오토스케일링 할 수 있도록 설정되어 있어야 하며, Session 이나 Cache 등 공용 저장소는 클러스터링이 되어 있어야 한다. 규모가 되었는데 이렇게 안하면, 사용자는 떠나고 사업은 망한다.
지금 내가 처한 상황에서 Caching 이 적정기술이 아니더라도, 이 강좌는 일단 학습 목적으로 배워 두자. 그리고, "아, 그거 거기에 있었지~" 정도로 기억해 두자, 필요할 때 꺼내 쓸 수 있도록. "페이스북 하니까..", "구글이 하니까.." 라고 하는 오류를 범하지 말자.
### Why?
Pagination, Transformer, Cache, Partial Response (원하는 필드만 골라 요청하고 응답하는 것) 등의 장치들은 모두 다음을 위한 것들이다.
1. **네트워크 사용량을 줄인다.**
가령, 전체 목록 요청에 응답되는 데이터량이 1 MByte 라고 가정해 보자. 모든 오버헤드를 무시하고, 1 초 내에 응답을 받으려면 적어도 네트워크 속도가 8 Mbps 가 되어야 한다. 다행히 대부분의 환경에서 그 이상 나온다. 그런데, 그렇지 않을 환경도 있다는 것을 염두해 두어야 한다. 만원 지하철 Wi-Fi , Lte 엣지, 강남역 처럼 사용자가 붐비는 곳의 Lte 등의 환경에 있는 API Client 도 있을 수 있기 때문이다. 가령, 200 Kbps 속도로 가정하면, 총 16 초가 소요되며, 그 사용자는 다시 방문하지 않을 것이다.
뿐만 아니다. API 클라이언트와 서버가 Lte 와 같은 유료 네트워크를 이용한다면 그 비용은 누가 내는가? 훌륭한 서비스라면 사용자의 비용을 아껴주어야 한다.
정리하자면, API 호출로 인한 데이터 사용량은 **서비스 품질** 뿐 이나라 **비용** 두 가지 모두에 영향을 미친다. 이 강좌에서는 **Etag/If-None-Match** HTTP Header 를 이용해 네트워크 사용량을 최소화하는 구현을 할 것이다.
2. **컴퓨팅 파워를 절약한다.**
PHP 7 이 나왔다. 외국 블로그들 보면, 동일 조건으로 PHP 5.6 과 비교하여, 몇 ms 가 더 빨라졌다고 비교 실험들을 하고 있다. Facebook 의 HHVM 과 실행 속도 면에 큰 차이가 없다는 기사도 보았다. 그런데, 서비스하는 입장에서 PHP Executable 에서 발생하는 몇 ms 는 큰 의미가 없다. 왜냐하면, 사실상의 병목은 파일, DB, 네트워크 등의 IO 에서 발생하기 때문이다. 경험이 있는 개발자라면 DB 에서 병목이 심하다는 것을 알고 있을 것이다.
서버 사이드 캐시는 DB 쿼리를 결과를, 상대적으로 병목이 덜한 파일이나 메모리에 일정 시간 동안 담아 놓아, DB 쿼리로 인한 컴퓨팅 파워 절약과 반응속도를 향상시키는 역할을 한다. 이 강좌에서는 **서버 사이드 캐싱** 을 구현할 것이다.
"폭탄 떠넘기기" 라고 필자는 자주 얘기하는데, 서버 개발자와 클라이언트 개발자가 어렵고 귀찮은 캐싱 기능 구현을 상대편으로 떠넘기는 것을 묘사한 것이다. 이 강좌에서는 서버 사이드에서 HTTP 표준을 이용해 캐싱 기능을 구현하고, HTTP 표준을 이용하는 클라이언트가 거부감 없이 캐싱 기능을 사용하도록 할 것이다.
### 서버 사이드 캐싱
#### 구현
[42강 - 서버 사이드 개선](42-be-makeup.md) 에서 모델 쿼리에다 `remember()` 메소드를 바로 체인하기 위해서 [watson/rememberable](https://github.com/dwightwatson/rememberable) 를 끌어 온 적이 있다. 이번에는 이 패키지를 걷어 내고 [라라벨 표준 방식](https://laravel.com/docs/cache#cache-usage)으로 구현할 것이다.
```bash
$ composer remove watson/rememberable
```
```php
// app/Model.php
// use Watson\Rememberable\Rememberable;
abstract class Model extends Eloquent
{
// use Rememberable;
}
```
`ArticlesController::index()`, `ArticlesController::show()` 에서 모델 쿼리 하는 부분에 캐싱 기능을 부여하자.
```php
// app/Http/Controllers/ArticlesController.php
class ArticlesController extends Controller
{
protected $cache;
public function __construct()
{
// taggable() Helper 를 이용하여 기본 쿼리를 만든다.
// .env 에 CACHE_DRIVER= 값이 file 이나 database 이면 Cache Tag 를 쓸 수 없다.
$this->cache = taggable() ? app('cache')->tags('articles') : app('cache');
// ...
}
public function index(FilterArticlesRequest $request, $slug = null)
{
// ...
// cache_key() Helper 를 이용해 캐시에 사용할 고유한 key 를 만든다.
$cacheKey = cache_key('articles.index');
$articles = $this->cache->remember($cacheKey, 5, function() use($query, $request) {
return $this->filter($query)->paginate($request->input('pp', 5));
});
// ...
}
public function show($id)
{
$cacheKey = cache_key("articles.show.{$id}");
$secondKey = cache_key("articles.show.{$id}.comments");
$article = $this->cache->remember($cacheKey, 5, function() use($id) {
return Article::with('comments', 'tags', 'attachments', 'solution')->findOrFail($id);
});
$commentsCollection = $this->cache->remember($secondKey, 5, function() use($article){
return $article->comments()->with('replies')->withTrashed()->whereNull('parent_id')->latest()->get();
});
// ...
}
}
```
`cache_key()` 란 Helper 를 사용하고 있다. 쿼리스트링을 포함한 요청 URL 과 함수 인자를 연결하여 고유한 스트링을 만드는 간단한 함수이다. 캐시 키를 만드는 데 정해진 규칙은 없다. `Article::find(2)` 와 `Article::find(3)` 쿼리는 서로 다른 쿼리이고, 그 결과가 다르기에 다른 키로 저장되어야 하며, 그 구분자 역할을 이 캐시 키가 하는 것이다.
```php
function cache_key($base) {
$key = ($uri = request()->fullUrl()) ? $base . '.' . urlencode($uri) : $base;
return md5($key);
}
```
#### 이벤트
앞 절에서 캐싱 기간을 5분으로 지정했다. 즉 5 분 내에 새로운 요청이 오면, DB 쿼리를 하지 않고 캐시 저장소에서 꺼내서 사용한다.
그렇다면, 5 분 내에 모델에 새로운 레코드가 생성되거나, 수정되거나, 삭제되면 어떻게 해야 하나? 이 부분은 이미 [42강 - 서버 사이드 개선](42-be-makeup.md) 에서 이미 배운 바 있다.
한번 더 상기해 보면, `ArticlesController::store()`, `ArticlesController::update()`, `ArticlesController::destroy()` 메소드에는 해당 작업이 성공했을 때 `App\Events\ModelChanged` 이벤트를 던지도록 되어 있다. 이 이벤트를 받은 `App\Listeners\CacheHandler` 가 넘겨 받은 캐시를 찾아서 삭제하도록 되어 있다.
이제 캐시 저장소는 비워졌고, 첫 사용자에게는 `ArticlesController` 에서 DB 쿼리를 통해 서비스를 제공하고 캐시 저장소에 다시 5 분간 캐싱할 수 있게 된다.
#### 테스트
쿼리를 찍어 보는 방법 외에 딱히 좋은 테스트 방법은 없다.
Route 정의 파일에 아래 내용을 넣고, 로컬 서버를 기동한 후, '/v1/articles' 를 방문해 보자. 다시 한 번 더 방문해서 쿼리 개수가 줄어든 것을 확인하자.
```php
// app/Http/routes.php
DB::listen(function($event){
var_dump($event->sql);
});
```
### Etag 와 304 Not Modified
#### 웹 서버
아래 그림을 보자.
우선, 우리가 사용하는 Apache 또는 Nginx 서버는 Html, Javascript, CSS, Image 등 Static Resource 에 대해서는 Etag 와 304 Not Modified 를 이용한 응답을 처리해 주고 있다. Static Resource 란 Content-Length 가 매 요청시 마다 변하지 않는 파일들을 말한다.

반면, 확장자가 php 이거나 쿼리 스트링이 달린 경우에 웹 서버는 해당 요청에 대한 응답 형식을 Dynamic Resource 라 생각한다. Dynamic Resource 란 매 요청시마다 Content-Length 가 변할 수 있는 파일이다. 가령, /abc.php 요청했을 경우, abc.php 내에서 응답할 컨텐츠를 만들기 때문에, 설령 그 응답값이 지난 번과 동일하다 할 지라도, 웹 서버 입장에서는 응답의 크기를 미리 알 수 없다. 그래서, 304 응답을 자동으로 처리해 줄 수 없는 것이다.

**참고** 우리는 .php 로 요청하지 않고, '/v1/articles' 로 요청했는데 웹 서버가 어떻게 알지? 라고 궁금증이 생길 수 있다. 이 내용은 [2강 - 라라벨 5 설치하기](02-hello-laravel.md) 에서 라라벨의 동작 시퀀스라는 그림으로 설명한 바 있다. 웹 서버의 설정에 의해 '/v1/articles' 라 해도 무조건 'public/index.php' 로 들어가게 되고, 'index.php' 가 URL 을 Router 에 넘겨 적절한 컨트롤러로 작업을 위임하기 때문이다. 즉, 웹 서버는 확장자가 php 인 요청인지 안다는 얘기다.
#### Etag 생성
일단, 모델과 베이스 컨트롤러에 필요한 메소드를 추가하는 구조로 구현해 보자. Repository 패턴을 구현했다면 그 쪽에 위치하는 것이 더 좋을 것 같긴 하다만, 우리 강좌에서는 오버인 듯 하다.
`Article::etag()` Helper 메소드를 보면, `$cacheKey` 를 인자로 받아, 테이블 이름, 모델 id, 모델의 수정 시각 등을 모두 스트링으로 연결한 뒤 `md5()` 내장 함수로 Hashing 하는 것을 볼 수 있다.
```php
// app/Articles.php
class Article extends Model
{
public function etag($cacheKey = null)
{
$etag = $this->getTable() . $this->getKey();
if ($this->usesTimestamps()) {
$etag .= $this->updated_at->timestamp;
}
return md5($etag.$cacheKey);
}
}
```
위는 `ArticlesController::show()` 메소드에서 사용할 수 있는, 단일 모델에 대한 Etag 생성 로직이다. `ArticlesController::index()` 는 Collection 을 다루게 되는데 이때는 Etag 를 어떻게 하면 좋을까? Collection 에 속한 각 Item (==Article 모델) 을 순회하면서, 위에서 만든 `Article::etag()` 메소드로 얻은 개별 Article Etag 들을 전부 조합한후 `md5()` Hashing 을 하면 될 것 같다.
```php
// app/Http/Controllers/Controller.php
class Controller extends BaseController
{
// ...
public function etags($collection, $cacheKey = null)
{
$etag = '';
foreach($collection as $instance) {
$etag .= $instance->etag();
}
return md5($etag.$cacheKey);
}
}
```
**참고** 3rd Party 에서 제공하는 Repository 패턴 구현체를 사용하려면, [`prettus/l5-repository`](https://github.com/prettus/l5-repository) 를 적극 권장한다. 위에서 살펴본 서버 사이드 캐싱 기능도 Ouf of Box 로 포함되어 있다.
#### Controller 수정
`ArticlesController::index()` 에서는 수정할 것이 없다. `ArticlesController::show()` 에서는 Etag 가 모델의 updated_at 필드 값에 의존하기 때문에, 조회수를 증가시키기 위한 이벤트를 API 요청일 경우에는 제외시키도록 하자.
```php
// app/Http/Controllers/ArticlesController.php
class ArticlesController extends Controller
{
public function show($id)
{
// ...
if (! is_api_request()) {
event(new ArticleConsumed($article));
}
return $this->respondItem($article, $commentsCollection, $cacheKey.$secondKey);
}
}
```
Etag 및 304 응답은 API 요청/응답 에만 적용할 것이다. 즉, 앞선 강좌에서 만든 응답 관련 메소드만 수정하면 된다. 설명은 코드 내 주석을 보자.
```php
// app/Http/Controllers/Api/V1/ArticlesController.php
class ArticlesController extends ParentController
{
protected function respondCollection(LengthAwarePaginator $articles, $cacheKey = null)
{
// Request 붙어 온 If-None-Match Header 가져오기
$reqEtag = request()->getETags();
// 베이스 클래스에 만든 Collection 에 대한 Etag 만들기
$genEtag = $this->etags($articles, $cacheKey);
if (isset($reqEtag[0]) and $reqEtag[0] === $genEtag) {
// $reqEtag = ["65f8322657950bdccdc48df21dddfc33"] 이기 때문에 Array Access 해야 함.
return $this->respondNotModified();
}
// 클라이언트가 If-None-Match Header 를 보내 오지 않았거나,
// 클라이언트가 보내온 Header 가 $genEtag 와 다를 경우.
// 즉, 모델이 수정되었을 경우.
return json()->setHeaders(['Etag' => $genEtag])->withPagination($articles, new ArticleTransformer);
}
protected function respondItem(Article $article, Collection $commentsCollection = null, $cacheKey = null)
{
$reqEtag = request()->getETags();
// 단일 Instance 에 대한 Etag 만들기
$genEtag = $article->etag($cacheKey);
if (isset($reqEtag[0]) and $reqEtag[0] === $genEtag) {
return $this->respondNotModified();
}
return json()->setHeaders(['Etag' => $genEtag])->withItem($article, new ArticleTransformer);
}
protected function respondNotModified()
{
return json()->notModified();
}
}
```
#### 테스트
브라우저로 쉽게 테스트해 볼 수 있다.

개발자 도구의 'Disable Cache' 를 이용하면 차이점을 보기 쉬우니 참고하자.
'Disable Cache' 를 켰다는 얘기는 Etag Header 를 클라이언트 측의 캐시 Key 로 해서 받은 컨텐츠를 브라우저 캐시 스토리지에 저장하지 않고, 다음 요청 때 If-Non-Match Header 를 보내지 않는다는 의미이다. 즉, 'Disable Cache' 를 켰다는 얘기는 API 클라이언트가 캐시 기능을 구현하지 않았다는 얘기이다.
그런데, API 클라이언트 개발자가 HTTP 스펙을 다 이해하고 HTTP 스택을 직접 구현하지 않을 테고, 클라이언트의 플랫폼/프레임웍에 포함된 라이브라리를 가져다 쓸 것이다. 이 경우, 대부분이 Etag 기능을 지원하고, 위 브라우저 예에서 보듯이 클라이언트 사이드에서도 캐싱을 자동으로 하게 된다. API 클라이언트 개발자가 거부감이 들리 없다고 생각된다.
---
- [목록으로 돌아가기](../readme.md)
- [51강 - CORS](51-cors.md)
- [53강 - Partial Response](53-partial-response.md)
================================================
FILE: lessons/53-partial-response.md
================================================
---
extends: _layouts.master
section: content
current_index: 55
---
# 실전 프로젝트 3 - RESTful API
## 53강 - Partial Response
[52강 - Caching](lessons/52-caching.md) 에서 네트워크 대역폭을 줄이는 일이, 대형 서비스로 발전할 때 얼마나 중요한 일인지 배워 보았다. Partial Response 는 API 클라이언트가 필요한 필드만 골라서 받을 수 있도록 하는 장치로서 역시 동일한 이유로 API 서버에서 제공되면 좋은 기능이다.
지난 강의에서 캐싱을 구현했는데, Production 환경에서 꼭 필요한 기능이지만, Development 환경에서는 수정한 결과가 캐싱때문에 바로 표시되지 않는 문제가 있다. 이번 강좌에서도 응답을 수정하고, 그 결과를 봐야 할 때 매번 `$ php artisan cache:clear` 명령으로 캐시를 버리는 개발 프로세스는 아주 비 효율적이다. 개발 환경에서 캐시를 끌 수 있도록 하는 Refactoring 을 먼저 하고, Partial Response 를 살펴 보도록 하자.
### 리팩토링 I - 캐싱 기능 토글
#### Config (설정)
캐싱을 On/Off 할 수 있는 설정이 필요할 것 같다. 라라벨의 좋은 점 중에 하나가, config 폴더 아래에 만든 파일은 `config('파일명.키')` 로 값을 읽을 수 있다는 점이다. 'config/project.php' 를 만들자.
```php
// config/project.php
return [
'cache' => ! env('APP_DEBUG', false),
];
```
부정 연산자 (`!`) 를 이용해서, '.env' 파일에 정의된 `APP_DEBUG=true` 이면 캐싱을 끄고, 반대이면 켜도록 하였다. 이제 프로젝트의 코드 어디서든 `config('project.cache')` 로 설정 값을 읽을 수 있다.
#### Controller
지난 강의에서 캐싱 기능 구현을 위해 수정한 컨트롤러 부분을 모두 수정해야 한다. 캐싱은 아주 일반적이고 자주 사용하는 기능인데, 모든 컨트롤러의 캐싱이 필요한 메소드에서 조건문으로 설정 값을 확인하는 일은 DRY (==Don't Repeat Yourself) 원칙에 어긋난다. 추출하여 부모 클래스로 옮기자.
새로운 기능을 추가할 때, 사용자 입장에서 어떻게 사용할지를 먼저 생각해 보고, 그에 맞게 기능을 개발하는 것은 좋은 습관이다. `ArticlesController` 에서 부모 클래스의 캐시 기능을 어떻게 사용할 지 먼저 작성해 보자. 참고로, 전 강좌에서도 지적한 바 있듯이 부모 클래스보다는 사실상 Repository 가 더 적절한 위치이다.
`cache(string $key, int $minites, mixed $query, string $method, mixed ...$param)` 으로 사용하면 좋을 것 같다.
- `$key`
: 캐시키
- `$minutes`
: 캐싱 유지 기간 (분)
- `$query`
: 모델 (==데이터베이스) 쿼리
- `$args`
: 여기서는 `paginate`. 메소드를 떼낸 이유는 `$query` 에 `paginate()` 를 붙여 버리면 DB 쿼리를 해 버리기 때문이다. 이러면 캐싱을 할 수 없기 때문이다.
- `...$param`
: PHP 의 [Splat Operator](http://php.net/manual/kr/migration56.new-features.php#migration56.new-features.splat) (`...`) 를 사용한 `$method` 의 인자이다. `cache()` 메소드의 5번째 인자를 포함한 그 뒤에 추가되는 인자들은 모두 이 Splat Operator 로 처리된다. Splat Operator 를 받는 쪽에서 `$args` 는 배열로 인식된다.
```php
// app/Http/Controllers/ArticlesController.php
class ArticlesController extends Controller
{
// ...
public function index(FilterArticlesRequest $request, $slug = null)
{
$query = $slug ? Tag::whereSlug($slug)->firstOrFail()->articles() : new Article;
$cacheKey = cache_key('articles.index');
$query = $this->filter($query->orderBy('pin', 'desc'));
$param = $request->input(config('project.params.limit'), 5);
// 기존 코드
// $articles = $this->cache->remember($cacheKey, 5, function() use($query, $request) {
// return $this->filter($query->orderBy('pin', 'desc'))->paginate($request->input(config('project.params.limit'), 5));
// });
$articles = $this->cache($cacheKey, 5, $query, $method, $param);
return $this->respondCollection($articles, $cacheKey);
}
// show() 메소드는 코드를 생략한다.
}
```
`cache()` 메소드를 구현하자.
Splat Operator 로 넘어온 값을 `implode(string $glue, array $pieces)` PHP 내장 함수로 조합했다. 캐싱 설정을 확인한 후, 꺼져 있으면 Early Return 을 하고 있다. `$this->cache->...` 로 시작하는 부분은 지난 강의에서 자식 클래스인 `ArticlesController` 에서 썼던 코드와 동일하다.
```php
// app/Http/Controllers/Controller.php
class Controller extends BaseController
{
// ...
protected function cache($key, $minutes, $query, $method, ...$args)
{
$args = (! empty($args)) ? implode(',', $args) : null;
if (config('project.cache') === false) {
return $query->{$method}($args);
}
return $this->cache->remember($key, $minutes, function() use($query, $method, $args){
return $query->{$method}($args);
});
}
```
**`참고`** 코드 내에서 `else` 를 사용하고 있다면, 다시 한번 살펴 보자. 기술 용어로 [Code Smell](https://en.wikipedia.org/wiki/Code_smell) 이라 하는데, `else` 가 들어가면 나쁜 코드일 가능성이 크다. 위의 경우도 Early Return 을 통해서 `if(...) {...} else {...}` 를 쓸 것을 `if` 로만 사용하고 있다. Early Return 을 사용하는 방법은 에러나, 예외를 던져야 할 조건을 먼저 검사해서 빨리 반환값을 던져 버리는 것이다.
**`참고`** `use` 키워드는 전역에 정의된 변수를 [`Closure`](http://php.net/manual/kr/class.closure.php) 컨텍스트로 넘길때 사용한다.
**`참고`** `{$method}($args);` 에서 중괄호를 사용하고 있다. 우리 프로젝트의 이 코드 부분에서 중괄호 (==Curly Brace) 가 없어도 동작하지만, 중괄호를 쓰는 것이 좋은 습관이다. `$foo='bar; ${$foo}='baz';` 처럼 변수의 값을 변수의 이름으로 사용할 때, 변수의 값을 메소드 이름으로 사용할 때 등에 중괄호를 사용한다.
자식 컨트롤러인 `ArticlesController::__construct()` 에 있던 `$this->cache` 속성 선언은 더 이상 필요 없다. 대신 부모 클래스인 `Controller::__construct()` 에 필요하므로 옮기자. 그런데 문제가 있다.
#### Interface
`Controller::__construct()` 에 `$this->cache` 를 선언하려고 보니, 캐시 태그가 필요하다. 이는 각 컨트롤러마다 달라져야 하는 값이다. 다른 구조도 있겠지만, 여기서는 다음과 같이 해 보자.
- `Interface Cacheable` 을 만들고, `cacheTags` 메소드를 정의하자.
- 캐싱이 필요한 컨트롤러는 `Cacheable` 인터페이스를 구현하도록 하자. 이렇게 함으로써, `Cacheable` 인터페이스를 구현한 컨트롤러는 `cacheTags` 메소드를 반드시 포함해야 한다.
- 부모 컨트롤러의 생성자에서 이 클래스 인스턴스 (==오브젝트, `$this`) 가 `Cacheable` 인터페이스를 구현한 객체인지 검사하고, 검사에 통과할 경우 `cacheTags` 메소드를 호출하여 캐시 태그를 얻어 오자.
인터페이스를 정의하자.
참고로 라라벨 프레임웍 코드를 보면, `Illuminate\Contracts` 라는 네임스페이스를 많이 접하게 된다. 여기에 라라벨 인터페이스들이 모두 정의되어 있다. 이름에서 보듯이 인터페이스는 계약 (==Contract) 이다. "인터페이스: 클래스야, 나를 구현하려면 내가 정한 원칙/계약을 따라야 해", "클래스: 니가 정한 원칙을 지킬께" 라고 서로 말하는 것이다.
인터페이스에서 정의한 메소드를 클래스에서 구현하지 않거나, 메소드 인자의 타입이 다르면 PHP 는 실행 오류를 낸다.
```php
// app/Http/Controllers/Cacheable.php
cacheTags()` 를 실행할 수 있다.
```php
// app/Http/Controllers/Controller.php
class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
protected $cache;
public function __construct() {
// ...
$this->cache = taggable() ? app('cache')->tags($this->cacheKeys()) : app('cache');
}
// ...
}
```
그런데, 여기서 또 문제가 있다.
#### Reflection
그럼, 모든 자식 컨트롤러가 `Cacheable` 인터페이스를 구현해야 하는가?
이런 방법이 있긴 하다.
```php
// NotExistingChildController.php
public function cacheTags()
{
throw new \Exception('This class does\'t require cache feature !!!');
}
```
사용하지도 않을 메소드를 구현하게 하는 것은 잘못된 디자인일 뿐더러, 생성자에서 `cacheTags()` 를 호출하기 때문에 저렇게 하면 컨트롤러 전체를 쓸 수 없게 된다.
캐싱 기능이 필요 없는 `CommentsController` 등은 인터페이스를 구현하지 않아도 되도록 하자. 다시 말하면, `CommentsController` 가 생성될 때 부모 클래스에서 `cacheTags` 가 호출되지 않도록 한다는 얘기다.
이를 위해 [`class_implements(mixed $class)`](http://php.net/manual/en/function.class-implements.php) PHP 내장함수를 응용할 수도 있지만, 우리는 여기서 OOP 의 꽃이라 불리우는 [`ReflectionClass`](http://php.net/manual/en/book.reflection.php) 기능을 이용해 볼 것이다. 무엇인지에 대해서는 [What is Reflection in PHP?](http://culttt.com/2014/07/02/reflection-php/) 포스트를 참고하도록 하자.
```php
// app/Http/Controllers/Controller.php
class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
protected $cache;
public function __construct() {
// ...
$this->cache = app('cache');
if ((new \ReflectionClass($this))->implementsInterface(\App\Http\Controllers\Cacheable::class) and taggable()) {
$this->cache = app('cache')->tags($this->cacheTags());
}
}
// ...
}
```
잠깐 객체 지향 프로그랭의 기본 몇가지만 짚고 넘어가자.
reflection 은 투영, 반사를 의미하는 영어단어이다. `ReflectionClass` 는 인자로 받은 인스턴스의 모습을 투영해 준다. 대부분의 OOP 언어들이 가지고 있는 기능이며, 일종의 Hack 이라 할 수 있다. 꽃이라 한 이유는, 이 기능을 잘 사용하면 `private` 로 정의된 메소드도 끌어내서 사용할 수 있을 만큼 강력하기 때문이다.
`$this` 키워드는 Instantiate 된 Object 를 의미한다. 아래 코드를 가정해 보자.
```php
1 $foo = new Bear;
2 $this->color = 'white';
3
4 $bar = new Bear;
5 $this->color = 'black';
```
`$foo` 라는 흰색 곰과 `$bar` 라는 검정색 곰을 만들었다. 이 둘은 모두 `Bear` 란 클래스의 인스턴스이다. 여기서 `$this` 는 `Bear` 클래스를 의미하는 것이 아니다. 2 라인의 `$this` 는 `$foo` 라는 `Bear` 클래스의 인스턴스, 5 라인은 `$bar` 라는 `Bear` 클래스의 인스턴스를 의미한다.
우리 코드의 `ReflectionClass($this)` 에서 `$this` 는 이 부모 클래스를 상속 받은 자식 클래스인 `ArticlesController` 의 인스턴스를 의미한다.
이제 코드가 깔끔해 졌다. 앞 강에서 DB 쿼리 부분에 대한 서버사이드 캐싱 뿐 아니라, 클라이언트를 위한 Etag/304 기능도 구현했다. 요것도 개발 중에는 상당히 불편하다.
#### Etag & 304
요 부분은 특별히 추출할 것이 없어 쉽다.
```php
// app/Http/Controllers/Api/V1/ArticlesController.php
class ArticlesController extends ParentController
{
protected function respondCollection(LengthAwarePaginator $articles, $cacheKey = null)
{
$reqEtag = request()->getETags();
$genEtag = $this->etags($articles, $cacheKey);
if (config('project.cache') === true and isset($reqEtag[0]) and $reqEtag[0] === $genEtag) {
return $this->respondNotModified();
}
// return json()->...
}
// respondItem() 메소드는 코드를 생략한다.
}
```
#### 테스트
브라우저 개발자 도구를 켜 놓은 상태에서 'project.php' 의 `cache` 설정 값을 `true`, `false` 로 바꾸어 가며, 같은 엔드포인트로 여러번 반복 요청을 했을 때, 응답 코드가 어떻게 바뀌는지 눈으로 확인해 보자. 또, 쿼리 개수에는 어떤 영향을 미치는 지도 실험해 보자.
### 리팩토링 II - 캐싱 기능
아주 오래전 앞 강의에서 쿼리를 위한 필드를 만들 때, `f`, `s`, `d`, `...` 등 짧은 필드명을 이용한 것은 Github 의 필드명을 참조한 것이다. 그런데, API 클라이언트, 즉 컴퓨터가 보기엔 `l` 이나 `limit` 나 같은 녀석이겠지만, 클라이언트를 개발하는 사람이 보기에는 가독성과 이해도가 너무 떨어 지는 것 같아, 필드명을 바꾸기로 결심했다.
또, 필드명이 나중에 바뀔 수도 있고 해서, API 를 사용하는 클라이언트 측과, 서버의 코드 사이에 완충 레이어를 하나 더 두기로 했다. 설령, 클라이언트가 사용해야 하는 필드명이 바뀌 더라도, 서버의 코드는 수정할 일이 없도록 말이다.
앞 절에서 만든 'project.php' 는 우리 서비스 전체를 위한 설정들을 저장하기에 가장 좋은 장소이다. 이용하자.
```php
// config/project.php
return [
// ...
'params' => [
'page' => 'page',
'filter' => 'filter',
'limit' => 'limit',
'sort' => 'sort',
'order' => 'order',
'search' => 'q',
'select' => 'fields',
],
'filters' => [
'article' => [
'no_comment' => 'No Comment',
'not_solved' => 'Not Solved'
]
],
];
```
> **`주의`** API 버전을 정의하는 부분에서 한번 설명했지만, 한번 더 강조한다.
> API 를 탑재한 클라이언트 앱이 사용자에게 뿌려지는 순간, 앞서 말한 필드명이 바뀌는 등, API 스펙을 바꾸는 행위는 자제해야 한다. 바뀐 API 스펙을 탑재하여 클라이언트 앱을 업데이트하였다 해도, 이를 사용하는 사용자가 즉시 업데이트를 할지는 전혀 알 수 없는 일이다. 갑작스런 API 스펙 변경에, 예전 스펙대로 동작하는 클라이언트 앱들은 전혀 동작하지 않게 될 것이고, 여러번 말했지만 서비스는 망한다.
> 클라이언트 코드를 서버 개발자가 언제든 배포할 수 있는 웹 (물론 웹도 클라이언트 측의 캐싱이 만료될 때 까지는 문제가 있을 수 있다.) 과는 차원이 다르다는 점을 기억하자. 그뿐인가, API 서버에 어떤 녀석 (User Agent) 이 요청을 할지.. CURL CLI 일 수도, `fsockopen(string $hostname, int $port)` PHP 내장 함수를 이용한 앱일 수도 있다는 점을 염두해 두자.
> 코드 에디터를 열기 전에, API 스펙을 설계하는 일이 먼저다. 바로 다음 강좌에서 다루려고 한다.
적용되는 부분이 여러군데 있는데 그 중 한군데만 살펴보자.
```php
// app/Http/Requests/FilterArticlesRequest.php
class FilterArticlesRequest extends Request
{
// ...
public function rules()
{
$params = config('project.params');
$filters = implode(',', array_keys(config('project.filters.article')));
return [
$params['filter'] => "in:{$filters}",
$params['limit'] => 'size:1,10',
$params['sort'] => 'in:created_at,view_count,created',
$params['order'] => 'in:asc,desc',
$params['search'] => 'alpha_dash',
$params['page'] => '',
];
}
}
```
주제에 조금 벗어난 얘기지만, 없는 필드에 대해서 DB 쿼리를 하면 `Illuminate\Database\QueryException` 이 발생하기 때문에, API 클라이언트에게 허용할 필드들을 미리 지정해 주고, DB 쿼리 까지 도달하기 전에 미리 걸러내는 것이 좋은 디자인이라 할 수 있다.
그 외 `App\Http\Controllers\ArticlesController::filter()` 메소드를 추상화해서 `App\Http\Controllers\Controller::filter()` 로 옮겼고, 'config/project.php' 의 설정을 이용해서 HTML 뷰의 내용을 수정하는 작업을 좀 더 했으나, 설명과 코드는 생략한다. [Github Commit 로그](https://github.com/appkr/l5essential/commits/master) 에서 확인해 보자.
### Partial Response
#### 패키지 업데이트
Partial Response 를 위해 특별히 더 구현할 것은 없다. 왜냐하면, 우리 프로젝트에서 사용하는 [`appkr/api`](https://github.com/appkr/api) 패키지에 이미 포함되어 있기 때문이다. 사용법만 살펴 볼 것이다.
해당 기능은 필자가 최근에 추가했으므로, 최신 버전이 아니라면 아래와 같이 업그레이드 하자.
```sh
$ composer update
# 수정한 설정이 있다면 수동으로 머지하자.
$ rm config/api.php
$ php artisan vendor:publish --provider="Appkr\Api\ApiServiceProvider"
```
기존에 artisan CLI 의 `make:transformer` 를 이용해 Transformer 를 만들었다면, 이번 강좌의 코드를 참조해서 수정하도록 하자.
```php
// app/Transformers/ArticleTransformer.php
class ArticleTransformer extends TransformerAbstract
{
public function transform(Article $article)
{
$payload = [/* ... */];
if ($fields = $this->getPartialFields()) {
$payload = array_only($payload, $fields);
}
return $payload;
}
public function includeComments(Article $article, ParamBag $params = null)
{
$transformer = new \App\Transformers\CommentTransformer($params);
$parsed = $this->getParsedParams();
// ...
}
public function includeAuthor(Article $article, ParamBag $params = null)
{
return $this->item($article->author, new \App\Transformers\UserTransformer($params));
}
// ...
}
```
#### 테스트
Partial Response 를 구현하는 방법은 여러 가지가 있을 텐데, 왜 Transformer (~=Presentation Layer) 에 구현했는가 라는 의문이 들 수 있다. 가령, fields 라는 쿼리스트링 필드명을 쓴다면, 모델 쿼리할 때 `Article::select($request->input('fields'))->...` 처럼 구현할 수도 있다. 그런데 이렇게 쿼리 레이어에서 수정해 버리면, 쿼리 결과에 의존하는 나머지 코드들이 모두 망가질 가능성이 크다.
우리가 HTML 뷰를 응답할 때, 모델에서 모든 속성값들을 쿼리해서 가져온 후, 뷰에서 필요한 속성들만 바인딩해서 썼다. 그거랑 동일한 개념이라고 보면 된다.
우선 테스트를 위해 앞 절에서 구현한 기능을 설정하자.
```php
// config/project.php
return [
'cache' => false, // ! env('APP_DEBUG', false),
// ...
];
```
서버를 부트업하고, 브라우저나 PostMan 에서 아래 주소를 방문해 보자.
```http
GET /v1/articles?fields=id,content_raw,link&include=comments:limit(1|0):fields(id|content_raw|author)
```

부모 리소스를 선택할 때는 `fields` 값의 구분자로 콤마 (`,`) 를 사용하고, 자식 리소스에 대해서는 파이프 (`|`) 문자를 사용한다는 점을 주의하자.
Presentation Layer 에서 응답할 내용을 제어해서 좋은 점 또 한가지는, API 클라이언트로 부터 넘겨 받은 필드들이 DB 쿼리에 사용되지 않으므로 `QueryException` 이 발생할 염려가 없다는 점이다. 없는 필드가 넘어오면 그냥 무시된다.
#### 설정
'config/api.php' 를 열어 어떤 설정을 수정할 수 있는 지 확인해 보자.
```php
// config/api.php
return [
'include' => [
// 자식 리소스를 포함할 때 사용할 쿼리스트링의 필드 이름을 바꿀 수 있다.
// 기본 값은 'include' 이다.
'key' => 'include',
'params' => [
// limit, sort 의 기본값을 정의할 수 있다.
'limit' => [3, 0],
'sort' => ['created_at', 'desc'],
],
],
'partial' => [
// Partial Response 에 사용할 쿼리스트링의 필드 이름을 바꿀 수 있다.
// 기본 값은 'fields' 이다.
'key' => 'fields',
],
// ...
];
```
---
- [목록으로 돌아가기](../readme.md)
- [52강 - Caching](52-caching.md)
- [54강 - API Documents](54-api-docs.md)
================================================
FILE: lessons/54-api-docs.md
================================================
---
extends: _layouts.master
section: content
current_index: 56
---
# 실전 프로젝트 3 - RESTful API
## 54강 - API Documents
### 잡담
필자의 경험에 비추어 보면 대기업이 좋은 점도 있었다.
- 실무에서 실수를 하더라도 결재 라인을 타는 과정에서 걸러질 가능성이 크다.
- 문제가 걸러지지 않아 세상에 제품이나 서비스 뿌려졌더라도 그로 인해 회사가 망할 지경에 이르지는 않는다.
- 업무 프로세스가 잘 갖추어져 있다.
바꾸어, 중소기업믜 문제점을 지적해 보자.
- 말단 신입 사원의 작은 실수 하나도 사업에 치명타를 입힌다. (e.g. 신입 사원에게 Production 서버의 root 권한을 줬는데, 실수로 `# rm -rf *`)
- 이런 걸 걸러 줄 시스템적인 장치가 탄탄하지 않다.
- 업무 프로세스가 잘 정리되지 않았다. (e.g. 개업날 식당에 가면, 허둥지둥 대기만 하고, 대접은 못받는 거랑 마찬가지~)
대신 대기업의 문제점은 업무 속도가 짜증날 정도로 늦다는 점이고, 중소기업은 반대다. [52강 - Caching](52-caching.md) 에서 "적정기술" 이란 잡담을 나누었는데, 어른이 되는 꿈을 품고 사업을 하는 것이고, 또 대기업으로 성장하기 위해서는 적정 수준에서 규모에 적합한 업무 프로세스를 개발하고 적용해야 한다.
그런데, 규모에 상관없이 아래 프로세스는 꼭 했으면 좋겠다. (기업마다 용어는 다를 수 있다)
- PRD (==Product/Service Require Document)
스펙이다. 가령 우리가 [31강 - 포럼 요구사항 기획](31-forum-features.md) 에서 봤던 내용을 좀 더 구체화해서, "사용자는 이메일과 비밀번호를 이용해 로그인한다" 처럼 상위 설계를 하는 것이다.
이 문서를 기반으로 개발팀이 Functional 스펙 과 개발 계획을 수립합니다. 이 문서를 보고 경영진이나 투자자는 회사가 기획한 제품/서비스가 무엇이고 어떤 기대효과가 있을 지를 이해할 수 있고, 그에 따라 필요한 의사결정을 할 수 있다. 영업이나 재무하시는 분들은 이 문서를 보고 장기 영업/자금 예측을 할 수 있고, 인사는 성과 평가의 기초 자료로 활용할 수 있다. PRD 는 한번에 완성되는 것이 아니라, 프로젝트를 진행하면서 여러 이해관계자가 지속적으로 논의하고 다듬어야 한다.
- Story board
PRD 를 기초로 제품/서비스를 좀 더 구체화한 화면 설계와 동작 시나리오이다. 스토리 보드는 업무량을 가늠하는 척도가 된다. UI 개발자들은 스토리보드를 참고해서 마크업을 작성하고, 서버 개발자는 백엔드 코드를 작성하게 된다.
- API Documentation
이번 강좌의 주제이다. 우리 강좌에서는 코드를 먼저 구현하고 문서를 작성하는 순으로 순서가 뒤집어 졌지만... 실무에서는 항상 문서를 먼저 만들 것을 권장한다. API 문서만 가지고 클라이언트 개발자는 작업을 시작할 수 있고, API 문서를 가지고 서버 개발자는 구현을 하게된다. 이런 이유로 API 문서가 반드시 먼저 개발되어야 한다.
- Testing
제품/서비스의 특징이 달라 집집마다 천차만별인게 테스팅이다. 유닛이나 통합테스트가 불가한 시나리오도 있어, 사람이 직접 필드를 돌거나 해야 하는 제품 이나 기능들도 있다. 상황이 어떻든 테스팅 프로세스가 있어야, 개발자는 발 뻗고 편히 잠자리에 누울 수 있다. 특히 테스트 코드가 있다면 코드 리팩토링이나 신규 기능 추가시 테스트에 소요되는 시간을 상당히 단축할 수 있다.
위 프로세스의 소유자는 누구냐? 라고 질문할 수 있다. 필자는 이 부분도 "폭탄 떠넘기기"라 생각한다. 작은 회사에 기획자, 개발자 구분이 어디 있으랴? 심지어 구글이나 페이스북에는 기획팀이 없고 개발팀이 a-Z, 다시 말하면 기획~개발~마케팅 까지 다 한다는 점을 타산지석으로 삼아야 한다. 시쳇말로 월급 받으면 다 할 수 있는 일이다. 디자인은 예술 영역이라 조금 다르다는 생각이 든다.
### 플랫폼 선택
잡담이 길었다. 본론으로 들어가서... API 문서 만드는 데 왜 플랫폼 선택이 필요하냐고 반문할 수 있다.
API 문서는 그냥 워드프로세서나 코드에디터 열고 쓰면 된다. API 를 만든다는 것은 API 문서를 만든다는 내용을 포함하고 있다. API 강좌 시작하면서 얘기했지만, API 를 잘 만드는 방법은 인터넷 공룡들을 따라하는 것이다. API 문서도 마찬가지이다.
- [Github API v3](https://developer.github.com/v3/)
- [YouTube Data API (v3)](https://developers.google.com/youtube/v3/docs/)
- [Twitter API](https://dev.twitter.com/rest/public)
이들의 공통적인 특징은 아래와 같다.
- REST 원칙을 따른다.
- API 베스트 프랙티스를 실천한다.
- (클라이언트 개발자가 바로 실험해 볼 수 있도록) Tester/Playground 를 제공한다.
- (클라이언트 개발자가 복붙해서 사용할 수 있도록) cURL 포함 다양한 플랫폼을 위한 샘플 코드를 제공한다.
워드프로세서로 만드는 것은 누구든 할 수 있으니 생략하고, 인터넷 공룡들의 API 의 장점을 모두 흡수하기 위해, 이 강좌에서는 API 문서를 만드는 플랫폼을 이용할 것이다.
선택 가능한 옵션은 아래와 같다.
- API Blueprint ([Project site](https://apiblueprint.org/), [Github](https://github.com/apiaryio/api-blueprint))
- [Apiary](https://apiary.io/) (==API Blueprint 의 SaaS 버전)
- RAML ([Project site](http://raml.org/), [Github](https://github.com/raml-org/raml-spec))
- Swagger ([Project site](http://swagger.io/), [Github](https://github.com/swagger-api))
### Apiary 가입 및 프로젝트 생성
Swagger 는 Java 라 패스, RAML 은 YAML 문법으로 스펙을 써야 해서 패스. 개발자라면 누구나 친숙한 마크다운 (아닌가요? 익숙해 지시길.) 문법으로 스펙을 쉽게 쓸 수 있고, 호스팅까지 제공되는 Apiary 로 선택하자. 필자는 에이피아이어리 라고 읽는데, 매일 쓰라고 다이어리라는 단어를 합성한 것 같아, 이름도 잘 지었다고 생각한다.
참고로 가입 후 30 일 동안 무료로 모든 기능을 사용해 볼 수 있지만, 이후 부터는 요금이 꽤 나온다. 신용카드 없이 가입할 수 있고, 계속 쓰지 않을 것이라면 신용카드 정보를 입력하지 않으면 되기에 걱정없이 사용하자.
[Apiary](https://apiary.io/) 에 가입하고, 첫 프로젝트를 만들자.

프로젝트 이름을 입력한다. 처음 가입했다면 무조건 하나를 만들라는 창이 뜨는데, 거기서 이름을 입력하면 된다.

에디터 창이 떴을 것이다. 이 에디터는 문법 오류를 잡아주고 미리 보기도 보여 주므로, 첫 프로젝트는 여기서 API 문서를 쓰자. 익숙해지면 로컬 프로젝트 디렉토리에서 `apiary.apib` 파일을 만들어 스펙을 쓰고, Github 에 배포하면 자동으로 API 문서가 업데이트되도록 하자.

### API Blueprint 문법
Apiary 는 API Bluepint 문법을 구현한 호스팅 서비스의 이름이다.
[API Blueprint 문법](https://github.com/apiaryio/api-blueprint/blob/master/API%20Blueprint%20Specification.md) 은 기본적으로 그냥 마크다운이다. [예약어](https://github.com/apiaryio/api-blueprint/blob/master/API%20Blueprint%20Specification.md#def-keywords) 는 문서의 타이틀 (`# title`, `## title`, `### title`) 영역이나, 목록 (`- list item`) 영역에 사용하지 말아야 한다. 이 약속만 지키면, 타이틀이나 목록을 자유롭게 쓸 수 있고, 최종 API 문서의 본문에 잘 렌더링되어 표출된다.
아래는 꼭 알아야 할 기본적인 API Blueprint 문법이다.
**`알림`** 이해도를 높이기 위해 블레이드의 `{{}}` 문법을 차용해서 String Interpolation 을 표기하였다. `{{}}` 를 제외한 나머지 기호 (e.g. `` ` ``, `{}`, `[]`, `()`, `-`, `#`) 들은 그대로 써야 하는 것들이다.
**`알림`** 리스트 표현을 위해 Dash (`-`) 를 썼는데, Asterisk (`*`), Plus (`+`) 기호도 쓸 수 있다.
- 문서의 가장 첫 줄은 `FORMAT: 1A` 로 시작한다.
- 두번째 줄은 `HOST: http://your-host` 를 쓴다.
- 세번째 줄은 사용자에게 표출될 API 이름, `# Welcome to Blah Blah API` 와 같은 타이틀과 그 아래에 설명을 자유롭게 담는다.
- 그 이후 내용들은 모두 본문으로 간주된다.
- 본문은 `# group {{Group Name}}`, `## {{Resource Name}} [{{Endpoint}}]`, `### {{Action Name}} [HEAD|GET|POST|PUT\PATCH|DELETE {{Endpoint override, if any...}}]` 순으로 진행된다.
- `### {{Action Name}}` 은 다시 `- request {{Optional Request Name}}`, `- response {{HTTP Status Code}} ({{Content Type}})` 와 같은 하위 요소를 포함할 수 있다.
- `- request` 는 `- headers`, `- body` 하위 요소를 가질 수 있다.
- `- response` 도 `- headers`, `- body` 하위 요소를 가질 수 있다.
- `- headers`, `- body` 의 내용은 ` Accept: application/json` 처럼, 12 space 들여쓰기 해야 한다.
- `- headers`, `- body` 키워드 없이 `- request`, `- response` 아래에 바로 Request 또는 Response Payload 를 쓰면 `- body` 의 내용으로 해석된다.
- Url Endpoint
- `/v1/articles/{id}` 처럼 Route Parameter 가 있을 때는, `### {{Action Name}}` 또는 `- request` 하위에 `- parameters` 를 정의할 수 있다.
- `- parameters` 는 `` - id: `{{example id}}` `` 처럼 Route Parameter 값을 정의할 수 있다.
- 쿼리스트링은 `/v1/articles{?field,another}` 식으로 쓸 수 있다.
이 정도만 알아도 훌륭한 Apiary 서비스에서 훌륭한 API 를 작성할 수 있다. 같이 해 보자.
### Hello Apiary
Apiary 를 방문하여 프로젝트를 생성한 후, 열린 에디터 창에서 아래와 같이 입력한 후 Save & Publish 버튼을 누르자. Resource 가 없는 정말 간단한 예제이다.
```apib
FORMAT: 1A
# The Simplest API
마크다운 영역 - jXUqC9KaU9Zr5ZIM
# GET /
마크다운 영역 - 8FgmhqJ6JmiboL0Q
- Response 200 (text/plain)
Hello World!
```

### Apiary with Resource
아래 예제를 꼼꼼이 읽어 보자. 에디터에 붙여 넣고 어떻게 표현되지는 확인하자. Save & Publish 버튼을 누른 후 시험해 보자.
```apib
FORMAT: 1A
HOST: http://api.appkr.kr
# myProject API
myProject API 에 오신 것을 환영합니다.
# group Authentication
마크다운 영역 - 0QUzt3lZmM6SNg13
## User Registration [/auth/register]
마크다운 영역 - unRx5VBYJloH8ZVi
### 예약어가 포함되지 않은 타이틀은 그냥 HTML
Element 로 렌더링 된다.
마크다운 영역 - K7C7ZjOTwxB5OlFi
- list - N1DGocGMwgHdy2c3
- list - yXuAdYAurN74hH4W
\```php
// fenced code block 도 안될리 없다.
echo 'Hello Apiary';
\```
### User Registration [POST]
- request
- headers
Accept: application/json
Content-type: application/json
- body
{
"name": "John Doe",
"email": "john@example.com",
"password": "password",
"password_confirmation": "password"
}
- response 201 (application/json)
- body
{
"success": {
"code": 201,
"message": "Created"
},
"meta": {
"token": "header.payload.signature"
}
}
```
위 예제에서 사용한 서버는 이 강좌의 [라이브 데모 API 서버](http://api.appkr.kr) 이다. 즉, Production 으로 실험을 해 볼 수 있다는 의미이다.
**`참고`** API 설계 과정, 즉 API 가 구현되기 전에는, Apiary 에서 제공하는 Mock Server 를 이용해서 설계와 시험을 할 수 있다. **클라이언트 개발자가 이것만 가지고 개발을 시작할 수 있다는 의미이다.**

이 강좌의 라이브 데모 서버에 john@example 사용자는 이미 있으므로 아래와 같은 422 응답을 받았을 것이다.
### Payload Content Type
라라벨은 정말 스마트하다. API 요청의 Payload 를 Form data 로 보내든 JSON 으로 보내든 라라벨은 다 잡아 낸다. 가령, 요청 Payload 를 `{"name": "John Doe"}` 로 보내면, 라라벨에서는 `Request::input('name') // 'John Doe'` 로 Form Data 와 동일하게 사용자 요청 값을 읽을 수 있다. PostMan 에서는 `raw` 버튼을 선택하고 JSON 을 직접 입력해서 실험해 볼 수 있다.

클라이언트에서 Form data 로 보내야 한다면, 아래 처럼 보내야 한다. Javascript Ajax 클라이언트는 Form data 를 아래와 같이 자동으로 포맷팅하는 기능을 가지고 있다. 요점은 API 클라이언트에게 요청 Content-type 에 대한 선택 자유도를 주었다는 점이다.
참고로 `Content-type: application/x-www-form-urlencoded` 으로 지정하고 Ampersand (`&`) 로 Payload 를 이어붙여 인코딩한 후 보낼 수도 있다. 자세한 내용은 [rfc1341](https://www.w3.org/Protocols/rfc1341/4_Content-Type.html) 을 참고.
```http
POST /auth/register HTTP/1.1
Host: api.appkr.kr
Content-Type: multipart/form-data; boundary=MultipartBoundryYQUn4B08rTQtuN4O
------MultipartBoundryYQUn4B08rTQtuN4O
Content-Disposition: form-data; name="name"
john
------MultipartBoundryYQUn4B08rTQtuN4O
Content-Disposition: form-data; name="email"
john@example.com
# ...
```
### Final Product
문법을 익혔으니 됐다. Apiary 의 모든 기능을 다룬 것이 아니므로, 문서도 읽어 보고 에디터에서 이것 저것 가지고 놀아 보길 권장한다. 그리고 스펙 작성시 중복을 줄여 주는 [MSON](https://github.com/apiaryio/mson) 도 공부해 보시기 바란다.
필자가 작성한 스펙의 소스와 최종 결과물은 여기에 있다. (JWT 가 꼭 있어야 하는 일부는 Apiary Console 이 동작하지 않는다)
- https://github.com/appkr/l5essential/blob/master/apiary.apib (우상단에 Raw 버튼을 눌러서 보자.)
- http://docs.forumv1.apiary.io
## 변경 사항 알림
JWT 리프레시 하는 부분이 빠져서 추가했다.
```php
// app/Http/routes.php
Route::group(['domain' => env('API_DOMAIN'), 'as' => 'api.', 'namespace' => 'Api', 'middleware' => 'cors'], function() {
Route::post('auth/refresh', [
'as' => 'sessions.refresh',
'uses' => 'SessionsController@refresh'
]);
// ...
});
```
```php
//app/Http/Controllers/Api/V1/SessionsController.php
class SessionsController extends ParentController
{
public function __construct()
{
$this->middleware('jwt.refresh', ['only' => 'refresh']);
// ...
}
public function refresh()
{
// 미들웨어에서 응답을 던지므로 이 메소드는 빈 메소드이다.
return true;
}
}
```
---
- [목록으로 돌아가기](../readme.md)
- [53강 - Partial Response](53-partial-response.md)
================================================
FILE: lessons/999-code-release.md
================================================
---
extends: _layouts.master
section: content
current_index: 59
---
# 코드 배포
웹 프로그래머의 삶이 여유롭지는 않다. 왜냐하면, 사용자와 접하게 되는 UI/UX 요소인 CSS/JS 부터 시작해서, 서버 설치 및 운영까지 다 할 줄 알아야 될 뿐 아니라, 하루가 다르게 쏟아져 나오는 새로운 기술과 툴들을 익혀야 하기 때문이다. 그 뿐인가, 개발에 필요한 디자인 패턴이며, 컴퓨터 네트워크의 동작 원리 등등 배워야 할 것이 산적해 있기도 하다. 운이 좋아 FE (==Front End), BE (==Back End), SE (==Systems Engineer) 로 업무가 모두 나뉘어 있는 큰 회사에 소속되어 있다면 좋을텐데... 모두가 그런 행운아의 주인공일 수는 없일 일. 참고로, FE, BE, SE 의 영역을 모두 아우르는 프로그래머를 [풀 스택 프로그래머](https://speakerdeck.com/driesvints/the-laravel-ecosystem?slide=17) 라고 한다.
꼭 웹 프로그래머가 아니더라도, 프로그래머의 삶이 좋은 이유가 있긴 하다. "배움에 소홀하지 않다면", 동네 내과 병원의 백발이 성성한 70대 원장님 처럼, 계속 프로그래밍으로 생계를 유지할 수도 있기 때문이다. **"배움에 소홀하지 않다면..."**
서론이 길었다. 이번 강좌에서는 코드 배포하는 것을 배울 것이다. 아무리 훌륭한 웹 어플리케이션 또는 서비스를 만들었다고 해도 서버에 올라가서 사용자들에게 서비스가 되지 않는다면, 만들지 않은 서비스 또는 존재하지 않는 서비스나 마찬가지이다. 즉, 코드 배포는 (웹) 프로그래머로서 꼭 알아야 할 주제라고 강조하고 싶다.
## 학습 목표
1. **Amazon Web Service 에 웹 서버를 생성해 본다.**
이 강좌를 쓰고 있는 시점이 2016년 1월 11일(월)인데, 2016년 1월 7일(목)에 Amazon Web Service 의 12번째 Region 인 Seoul Region 이 새롭게 추가되었다. Seoul Region 의 속도나 가격 비교는 [정창훈님의 블로그 포스트](http://blog.iamseapy.com/archives/250) 를 참고하면 되는데, 결론은 빠르고 싸다는 것이다. 필자도 Seoul Region 오픈 다음날, 이 강좌의 라이브 데모 사이트를 Seoul 로 옮겼다.
2. **Envoy (SSH Task Runner) 사용법을 익힌다.**
이 강좌를 통해서 FE 리소스를 관리하고 빌드하는 [Elixir](29-elixir.md), 팀 내 개발 환경을 표준화할 수 있는 [Homestead](02-install-homestead-osx.md) 등의 사용법을 살펴 보았다. 라라벨에서는 원격 서버에서의 복잡하고 반복되는 작업을 편리하게 할 수 있도록 도와 주는 [Envoy](https://laravel.com/docs/envoy) 라는 툴도 제공하고 있다. **Envoy 는 Non-Laravel, Non-PHP 프로젝트에서도 사용할 수 있다** 는 점을 강조하고 싶다.
3. **Envoy 와 Git 을 이용하여 코드를 배포해 본다.**
Envoy 는 코드 배포를 위한 도구가 아니다. Git 도 코드 배포를 위한 도구는 아니다. 하지만, 우리는 이 둘을 결합하여 깔끔하게 동작하는 코드 배포 도구를 만들것이다.
## Hello AWS Seoul
### 회원 가입 및 Free Tier 사용하기
Amazon 에 신규로 회원 가입을 하면 1년 동안 무료로 [Free Tier 서비스](http://aws.amazon.com/ko/free/) 를 이용할 수 있다. 가입 과정에서 해외 결재가 가능한 신용카드가 필요하니 미리 준비해야 한다. 신용카드는 신분 확인만을 위한 장치일 뿐, 당장 결재가 되는 것은 아니니 거부감을 가질 필요는 없다. 매해 신용카드를 바꾸어 가며, Free Tier 를 옮겨 다니는 분들도 봤다. Shared 호스팅이 아니라, 자신만의 독립서버를 1년 동안 무료로 쓸 수 있는데, 쓰지 않을 이유는 없지 않는가?
"개발 기간동안은 맘껏 써 봐라. 대신 서비스가 커지고 수익을 창출하면 돈을 좀 내 줘~ 장사 하루 이틀 할 것도 아닌데...". Amazon 을 갓마존 또는 대인배라고 하는 이유가 있다.
### EC2 인스턴스 생성
회원 가입을 하고 나서 로그인을 하면, AWS 의 모든 제품군이 나열된 AWS Management Console 화면을 볼 수 있을 것이다. 우리는 Compute 섹션의 EC2 만 사용할 것이다. **네비게이션 메뉴 오른쪽 위에 Region 선택 드롭다운에서 반드시 Seoul 로 선택하자.**

EC2 제품을 눌러 표시된 화면에서, 중앙에 위치한 **"Launch Instance"** 버튼을 눌러 새로운 서버 인스턴스를 만들자. 총 7 단계를 거치는데 거의 대부부분이 그냥 **"Next"** 로 넘어가면 된다. 이 강좌 작성 이후, Amazon 의 화면 구성이나 UI 는 시간이 지남에 따라 언제든 달라질 수 있으니, 아래 설명을 참고해서 적응적으로 적용하도록 하자.
**Step 1: Choose an Amazon Machine Image (AMI)** 화면에서 `Free Tier Eligible` 이라고 표시된 `Ubuntu Server 14.04 LTS (HVM), SSD Volume Type` 을 선택하자.

2 ~5 단계는 특별한 것이 없다. **Step 6: Configure Security Group** 화면에서 **"Add Rule"** 버튼을 눌러, `HTTP` 와 `MYSQL` 을 추가해 주자. 아래 그림의 보안 경고에 보이듯이, HTTP 를 제외하고는 IP 를 지정하는 것이 보안 측면에서 좋다. 참고로, 앞으로 진행하는 과정 중에 자연스럽게 OpenSSL 기반의 Self-signed 인증서가 설치되므로, HTTPS 를 상용으로 쓸 예정이라면 개발 과정에 HTTPS 룰도 추가해 주자.

**Step 6: Configure Security Group** 화면에서 **"Review and Launch"** 버튼, **Step 7: Review Instance Launch** 화면에서 **"Launch"** 버튼 순으로 진행한다. Step 7 에서 버튼을 누르는 순간, 서버에 SSH 로 접속하기 위한 Private Key 를 만드는 화면이 뜨는데, 키 이름을 입력하고, **"다운로드"** 버튼을 눌러 '~/.ssh' 디렉토리 아래에 저장하자. **"Launch"** 버튼을 한번 더 누르면 서버 생성이 시작된다. 대략 30초 이내로 끝난다.
**`참고`** AWS 에서 SSH Key 는 인스턴스가 생성될 때 딱 한번 발급/지정할 수 있다. Key 를 잊어 버리면, 서버를 지우고 다시 생성해야 하니, Key 관리를 잘 해야 한다.

콘솔 화면에 다시 돌아오면, 생성된 서버 인스턴스를 확인할 수 있다. IP 와 DNS 는 곧 써야 하니, 어떤 화면 어디 쯤에 위치해 있는지 잘 봐 두자.

### 서버에 접속
SSH 로 접속하기 위한 기본 설정을 수정한다.
좀 전에 다운로드 받은 키 파일은 나 (==소유자) 만 읽기 가능하도록 해야 한다.
```bash
$ chmod 400 ~/.ssh/aws-demo.pem
```
매번 `$ ssh ubuntu@52.79.54.81 -i ~/.ssh/aws-demo.pem` 을 쳐야 하는 번거로움을 피하기 위해 SSH config 를 작성한다.
```bash
$ nano ~/.ssh/config
```
config 파일에 아래 내용을 넣고 Ctrl + x, Y 를 눌러 저장한다.
```bash
Host aws-demo
Hostname 52.79.54.81 # AWS Console 에 표시된 IP 또는 DNS 주소
User ubuntu # 기본 계정은 'ubuntu' 이다.
IdentityFile ~/.ssh/aws-demo.pem # 좀 전에 다운로드 받은 Private Key 지정
```
서버로 접속해 보자.
```bash
# 로컬 컴퓨터에서
$ ssh aws-demo
# The authenticity of host '52.79.54.81 (52.79.54.81)' can't be established.
# ECDSA key fingerprint is SHA256:+xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.
# Are you sure you want to continue connecting (yes/no)?
# "yes" 를 타이핑하고 엔터.
ubuntu@ip-xxx-xx-x-xx:~$
```
**`참고`** 앞으로 진행될 내용에서 `ubuntu@ip-xxx-xx-x-xx:~$` 라고 표시되면 원격 서버의 SSH 접속된 콘솔을 의미하며, `...$` 는 사용자 계정, `...#` 는 root 계정을 의미한다. 로컬 컴퓨터의 콘솔은 `$` 로 구분된다.
## Hello Web Server
### 배포 사용자 계정 준비
SSH 로 서버에 접속되었다. 먼저 이 서버에 코드를 배포하고, 웹 서버의 사용자로 사용될 계정을 만들것이다.
```bash
$ ssh aws-demo
# sudo 를 계속 쳐야 하는 번거로움을 피하기 위해 아래 명령으로 root 로 로그인한다.
ubuntu@ip-xxx-xx-x-xx:~$ sudo -s
```
코드 배포에 사용할 `deployer` 란 계정을 만들고, 이 계정을 `www-data` 그룹에 추가하자.
```bash
root@ip-xxx-xx-x-xx:~# adduser deployer
# Enter new UNIX password: # deployer 계정이 사용할 비밀번호
# Retype new UNIX password: # 비밀번호 확인
# ...
# Enter the new value, or press ENTER for the default
# Full Name []: Deployer
# Is the information correct? [Y/n] Y
root@ip-xxx-xx-x-xx:~# usermod -G www-data deployer
```
`deployer` 계정이 `sudo` 입력 없이 쉘 (==콘솔) 명령을 수행할 수 있는 권한을 주자.
```bash
root@ip-xxx-xx-x-xx:~# visudo
```
열린 파일 마지막에 아래 내용을 추가하자.
```bash
# deployer 계정에 대한 권한 부여
deployer ALL=(ALL:ALL) NOPASSWD: ALL
# www-data 그룹에 대한 권한 부여
%www-data ALL=(ALL:ALL) NOPASSWD:/usr/sbin/service php5-fpm restart,/usr/sbin/service nginx restart
```
*참고로 필자는 SE 나 서버 보안 전문가가 아니므로, 이 강좌의 내용이 완벽한 서버 보안을 보장할 수 없다는 점을 양해해 주기 바란다.*
### 서버 프로비저닝
이제 LNMP (==`Linux` + `Nginx` + `MySql` + `PHP`) 스택을 설치하고 웹 서버로 기동할 준비를 해 보자.
필요한 모듈을 한줄씩 쳐서 설치하는 번거로움을 피하기 위해 [appkr/envoy](https://github.com/appkr/envoy) 리포지토리에서 제공하는 스크립트들을 이용할 것이다. 얘네들은 `Git`, `Build Tool`, `Nginx`, `PHP & required Modules`, `Composer`, `MySql`, `...` 등을 한번에 설치하는 역할을 하는 Bash Script 이다. 설치에 5 ~ 10 분 정도 소요된다.
```bash
root@ip-xxx-xx-x-xx:~# wget https://raw.githubusercontent.com/appkr/envoy/master/scripts/provision.sh
# 첫번째 인자는 배포 계정 이름, 두번째 인자는 MySql 로그인에 사용할 비밀번호
root@ip-xxx-xx-x-xx:~# bash provision.sh deployer secret
# 작업이 완료된 후, 필요한 모듈들이 잘 설치되었나 확인해 보자.
root@ip-xxx-xx-x-xx:~# which nginx
root@ip-xxx-xx-x-xx:~# which php5-fpm
root@ip-xxx-xx-x-xx:~# which mysql
root@ip-xxx-xx-x-xx:~# git --version
root@ip-xxx-xx-x-xx:~# php --version
root@ip-xxx-xx-x-xx:~# composer --version
root@ip-xxx-xx-x-xx:~# ...
```
Nginx Sites 를 빠르게 만들기 위한 `serve.sh` Bash Script 도 다운로드 받아 이용하자. 다행히 요건 순식간에 끝난다.
```bash
root@ip-xxx-xx-x-xx:~# wget https://raw.githubusercontent.com/appkr/envoy/master/scripts/serve.sh
# 첫번째 인자는 도메인 이름, 두번째 인자는 Document Root 경로
root@ip-xxx-xx-x-xx:~# bash serve.sh ec2-52-79-54-81.ap-northeast-2.compute.amazonaws.com /home/deployer/www/aws-demo/public
# 작업이 완료된 후 잘 설정되었나 확인해 보자. 특히 server_name 과 root 지시자가 정확한지 눈여겨 보자.
root@ip-xxx-xx-x-xx:~# cat /etc/nginx/sites-enabled/ec2-52-79-54-81.ap-northeast-2.compute.amazonaws.com
```
### `deployer` 계정으로 로그인하기
`deployer` 계정으로 로그인하기 위해서 로컬에서 `ssh-keygen` 으로 Key Pair 를 생성하고, 서버에 Public Key 를 등록하는 방법도 있지만, 편의상 여기서는 `ubuntu` 계정의 Public Key 를 그대로 사용하기로 하자.
```bash
# deployer 계정에 .ssh 디렉토리 생성
root@ip-xxx-xx-x-xx:~# mkdir /home/deployer/.ssh
root@ip-xxx-xx-x-xx:~# chown deployer:deployer /home/deployer/.ssh
# ubuntu 계정에 등록된 public key 레지스트리를 deployer 계정에 복사
root@ip-xxx-xx-x-xx:~# cp /home/ubuntu/.ssh/authorized_keys /home/deployer/.ssh/
# /home/deployer/.ssh 디렉토리 전체에 대한 소유권 부여
root@ip-xxx-xx-x-xx:~# chown deployer:deployer -R /home/deployer/.ssh
```
이제 로컬로 돌아와서, 생성한 `deployer` 계정으로 서버에 접속할 수 있도록 설정하자. 서버에서 로그아웃 할 때는, 로컬로 돌아올 때 까지 `exit` 를 여러 번 입력하면 된다.
`$ nano ~/.ssh/config` 명령으로 기존 'aws-demo' 외에 'aws-demo-deploy' 란 레코드를 하나 더 만들자.
```bash
Host aws-demo-deploy
Hostname 52.79.54.81
User deployer
IdentityFile ~/.ssh/aws-demo.pem
```
SSH 로그인을 해보자.
```bash
$ ssh aws-demo-deploy
deployer@ip-xxx-xx-x-xx:~$
```
### MySql 서버 접속
MySql 로그인을 해보자.

이제 서버 쪽에 모든 준비는 완료된 상태이다. Envoy 에 대해 배워 보고, 배포 스크립트를 만들어 보자.
## Hello Envoy
### Envoy Executable 설치
Envoy 문법에 대한 자세한 설명은 [공식 문서](https://laravel.com/docs/envoy) 와 필자의 [슬라이드](http://www.slideshare.net/ssuser7887b3/envoy-56730937) 를 참고하도록 하자.
요약하자면 Envoy 는 로컬 컴퓨터에서 원격 서버에 미리 정의된 작업을 시키는 도구라고 할 수 있다. Envoy Executable 을 로컬 컴퓨터에 설치하자.
```bash
# 로컬 컴퓨터에서
$ composer global require "laravel/envoy=~1.0"
$ envoy --version
```
자신의 콘솔 프로파일 (`.zshrc`, `bash_profile`, `.bashrc`, `...`) 에 Composer Global 컴포넌트에 대한 경로 설정 (`export PATH="$PATH:$HOME/.composer/vendor/bin"`) 이 되어 있지 않다면, 추가해 줘야, `$ envoy` 명령을 경로 지정없이 어떤 디렉토리에서든 실행할 수 있다.
**`참고`** Envoy 는 Global 로 설치하지 않고 프로젝트 단위로 설치해도 된다. 단, 이 경우에는 프로젝트 디렉토리에서 `$ vendor/bin/envoy` 로 명령을 실행해야 한다.
### Envoy Script
Envoy 는 항상 `envoy.blade.php` 가 위치한 프로젝트 디렉토리에서 실행해야 한다. `appkr/envoy` 리포지토리에서 예제 Envoy Script 를 다운로드 받아 수정하여 사용하도록 하자.
```bash
# 로컬 컴퓨터에서
$ cd myProject
$ wget https://raw.githubusercontent.com/appkr/envoy/master/envoy.blade.php
# wget 또는 curl 이 없다면 브라우저로 해당 주소를 방문해서, 본문 내용을 복붙한 envoy.blade.php 파일을 만들고 저장해도 된다.
```
일단 서버 주소만 수정하고, 첫 Envoy 명령을 수행해 보자. `aws-demo-deploy` 란 이름은 앞 절에서 '~/.ssh/config' 에서 정의한 Hostname 에 대한 별칭임을 기억하자. '~/.ssh/config' 정의가 없었다면 `deployer@52.79.54.81` 로 쓸 수도 있다.
```php
// envoy.blade.php
@servers(['web' => 'aws-demo-deploy'])
```
```bash
# 로컬 컴퓨터에서
$ envoy run hello
[aws-demo-deploy]: Hello Envoy! Responding from ip-xxx-xx-x-xx
```
와우~!!! 로컬 컴퓨터에서 SSH 로 접속하지 않고도, 명령 한줄으로 원격 서버에 지정된 계정으로 들어가서 `hello` Task 를 수행하고, 그 수행 결과를 로컬로 다시 되돌려 준 것이다.
### Envoy Script II
배포를 위한 설정들을 변경하자.
```php
// envoy.blade.php
@setup
$path = [
// release Task 수행 중에 변수로 사용할 디렉토리 경로들.
'base' => '/home/deployer/www',
'docroot' => '/home/deployer/www/aws-demo',
'shared' => '/home/deployer/www/shared',
'release' => '/home/deployer/www/releases',
];
$required_dirs = [
// release Task 수행 중에 없으면 만들어야 할 디렉토리 목록들.
$path['base'],
$path['shared'],
$path['release'],
];
$shared_item = [
// release Task 수행 중에 Symlink 로 연결되어야 할 공유 디렉토리/파일들.
'/home/deployer/www/shared/.env' => '.env',
'/home/deployer/www/shared/storage' => 'storage',
'/home/deployer/www/shared/cache' => 'cache',
];
$distribution = [
// 그냥 두자.
// 매 release Task 수행시 마다 아래 정의한 디렉토리에 Git Clone 을 하게 된다.
'name' => 'release_' . date('YmdHis'),
];
$git = [
// release Task 에서 Git Clone 을 할 대상이 되는 Git Repo 의 주소.
'repo' => 'git@github.com:vendor/project',
];
@endsetup
```
서버에 접속한 후, `$shared_item` 에 정의한 디렉토리/파일은 생성해 놓자. 없으면 Symlink 에러날 수 있으니..
```bash
# $shared_item 디렉토리/파일 목록은 라라벨 프로젝트를 가정하고 설정한 것이다.
# 다른 프레임웍이라면 공유될 리소스를 알맞게 지정하도록 하자.
$ ssh aws-demo-deloy
deployer@ip-xxx-xx-x-xx:~$ mkdir www
deployer@ip-xxx-xx-x-xx:~$ mkdir www/shared
deployer@ip-xxx-xx-x-xx:~$ mkdir www/shares/storage www/shared/cache
deployer@ip-xxx-xx-x-xx:~$ touch www/shared/.env
# .env 에 환경 설정 내용을 채워 놓도록 하자. 내용이 없다면 release Task 의 Composer Install 과정에서 에러가 날 가능성이 있다.
```
## 코드 배포
### Git Clone 을 위한 Key 설치
우리의 원격 서버가 Github 서버와 통신할 수 있기 위해서는, aws-demo 서버에 Github 접속을 위한 SSH Private Key 가 있어야 한다. 아래 내용은 [Github 공식 문서](https://help.github.com/articles/generating-ssh-keys/) 를 그대로 따라한 것이다.
```bash
$ ssh aws-demo-deloy
deployer@ip-xxx-xx-x-xx:~$ ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
# Key 이름과 passphrase 를 넣는 질문이 나오는데 필요하다면 입력하자. 필자의 경우에는 그냥 엔터했다.
deployer@ip-xxx-xx-x-xx:~$ cat .ssh/id_rsa.pub
# 콘솔에 출력된 내용을 블럭으로 잡아 복사해 두자.
```
복사한 내용을 [Github Setting 의 SSH keys 페이지](https://github.com/settings/ssh) 를 방문하여 **"Add SSH Key"** 버튼을 눌러 붙여 넣는다.

그러고나서, 아래 과정을 꼭 한번은 거쳐 주어야 한다. Github 서버가 aws-demo 서버를 인식하게 하는 과정이다.
```bash
deployer@ip-xxx-xx-x-xx:~$ ssh -T git@github.com
# Are you sure you want to continue connecting (yes/no)?
# yes 치고 엔터
```
### Release
이걸 할려고 이제까지 복잡한 과정을 수행했다. 모든 준비가 완료되었다. 보통 요 앞의 과정까지 숙달된 사람의 경우 스크립트 없이 4시간, 숙달되지 않은 사람의 경우 짧게는 이틀, 좀 헤메면 일주일 걸린다.
코드를 배포하자.
```bash
# 로컬 컴퓨터에서
$ envoy run release
```

앞으로 코드가 변경되어 `$ git push` 를 하고, 서버에 릴리즈해야 할 일이 있다면... 위 명령 한번으로 끝난다. 스크린샷이 aws-demo 가 아니라 필자가 라이브 데모로 사용하는 aws-seoul-deploy 로 되어 있는 점 양해 바란다. 필자의 라이브 데모 사이트의 경우, `release` Task 수행에 총 30 초 정도 소요되었다.
### `release` Task 의 동작 원리
`@servers(['web' => 'aws-demo-deploy'])` 와 `@task('release', ['on' => 'web'])` 에 지정된 서버에 백그라운드에서 SSH 로그인한 후 아래 작업을 순차적으로 진행하고, 그 수행 결과를 로컬 컴퓨터 터미널에 표시해 준다.
1. `$required_dirs` 에서 정의한 디렉토리가 없다면 생성한다.
2. `$path['release'] . '/' . $distribution['name']` 디렉토리에 `$git['repo']` 로 정의한 코드 베이스를 Clone 한다.
3. 방금 Clone 한 디렉토리에서 Composer Component 를 설치한다.
4. 방금 Clone 한 디렉토리에 `$shared_item` 에 정의한 공유 디렉토리와 파일을 Symbolic Link 로 연결한다.
5. 방금 Clone 한 디렉토리를 Nginx 의 Document Root 로 Symbolic Link 한다.
6. 방금 Clone 한 디렉토리 및 그 하위에 연결된 Symbolic Link 들에 대한 그룹 권한을 `www-data` 로 변경한다.
Git 과 Envoy 를 응용한 이 배포 스크립트의 장점은,
1. **Zero Conflict**
매번 Git Clone 하는 전략을 취함으로써 `$ git push --force` 를 했을 경우, 서버에서 발생할 수 있는 Code Conflict 를 없애준다.
2. **Zero Downtime**
DB, 캐시/세션 스토리지, 환경설정등은 릴리즈 디렉토리 밖에 존재하면서 릴리즈에 Symlink 로 연결되며, 코드, 의존 모듈 등 모든 준비가 완료된 후, 이번 릴리즈 디렉토리를 Document Root 로 Symlink 하기 때문에, 서비스의 다운 타임이 발생하지 않는다.
3. **Release 이력 관리 및 빠른 롤백**
매번 기존 코드를 엎어 쓰는 Git Checkout, Git Pull 전략이 아니라, Git Clone 전략을 이용하므로, 이전 릴리즈들도 바로 사용이 가능한 상태로 서버에 그대로 남아 있게 된다. 즉, 릴리즈에 문제가 있을 경우, 이전 릴리즈로 롤백이 가능하단 얘기다. 이 참고용 `envoy.blade.php` 스크립트에는 `release` Task 외에도 `list`, `checkout`, `prune` 등의 추가 Task 를 포함하고 있다. 사용법은 [`appkr/envoy` 문서](https://github.com/appkr/envoy) 또는 코드를 참고하자.
---
- [목록으로 돌아가기](../readme.md)
================================================
FILE: lessons/INDEX.md
================================================
- **입문코스-기본기**
- [1강 - 처음 만나는 라라벨](/lessons/01-welcome.md)
- [2강 - 라라벨 5 설치하기](/lessons/02-hello-laravel.md)
- [2강 - 라라벨 5 설치하기 (on Windows)](/lessons/02-install-on-windows.md)
- [3강 - 글로벌 설정 살펴보기](/lessons/03-configuration.md)
- [4강 - Routing 기본기](/lessons/04-routing-basics.md)
- [5강 - 뷰에 데이터 바인딩하기](/lessons/05-pass-data-to-view.md)
- [6강 - 블레이드 101](/lessons/06-blade-101.md)
- [7강 - 블레이드 201](/lessons/07-blade-201.md)
- [8강 - 날 쿼리 :(](/lessons/08-raw-queries.md)
- [9강 - 쿼리 빌더](/lessons/09-query-builder.md)
- [10강 - 엘로퀀트 ORM](/lessons/10-eloquent.md)
- [11강 - DB 마이그레이션](/lessons/11-migration.md)
- [12강 - 컨트롤러](/lessons/12-controller.md)
- [13강 - RESTful 리소스 컨트롤러](/lessons/13-restful-resource-controller.md)
- [14강 - 이름 있는 Route](/lessons/14-named-routes.md)
- [15강 - 중첩된 리소스](/lessons/15-nested-resources.md)
- [16강 - 사용자 인증 기본기](/lessons/16-authentication.md)
- [17강 - 라라벨에 내장된 사용자 인증](/lessons/17-authentication-201.md)
- [18강 - 모델간 관계 맺기](/lessons/18-eloquent-relationships.md)
- [19강 - 데이터 심기](/lessons/19-seeder.md)
- [20강 - Eager 로딩](/lessons/20-eager-loading.md)
- [추가 - 페이징](/lessons/20-1-pagination.md)
- [21강 - 메일 보내기](/lessons/21-mail.md)
- [22강 - 이벤트](/lessons/22-events.md)
- [23강 - 입력 값 유효성 검사](/lessons/23-validation.md)
- [24강 - 예외 처리](/lessons/24-exception-handling.md)
- [25강 - 컴포저](/lessons/25-composer.md)
- **중급코스1-Markdown Viewer**
- [26강 - Document 모델](/lessons/26-document-model.md)
- [27강 - Document 컨트롤러](/lessons/27-document-controller.md)
- [28강 - Cache](/lessons/28-cache.md)
- [29강 - Elixir, 만병통치약?](/lessons/29-elixir.md)
- [30강 - Debug & Final Touch](/lessons/30-final-touch.md)
- **중급코스2-Forum**
- [31강 - 포럼 요구사항 기획](/lessons/31-forum-features.md)
- [32강 - 사용자 로그인](/lessons/32-login.md)
- [33강 - 소셜 로그인](/lessons/33-social-login.md)
- [34강 - 사용자 역할](/lessons/34-role.md)
- [35강 - 다국어 지원](/lessons/35-locale.md)
- [36강 - 마이그레이션과 모델](/lessons/36-models.md)
- [37강 - Article 기능 구현](/lessons/37-articles.md)
- [38강 - Tag 기능 구현](/lessons/38-tags.md)
- [39강 - Attachment 기능 구현](/lessons/39-attachments.md)
- [32/33 보충 - 인증 리팩토링](/lessons/32n33-auth-refactoring.md)
- [40강 - Comment 기능 구현](/lessons/40-comments.md)
- [41강 - UI 개선](/lessons/41-ui-makeup.md)
- [42강 - 서버 사이드 개선](/lessons/42-be-makeup.md)
- [43강 - 변경 사항 알림](/lessons/43-change-note.md)
- **중급코스3-RESTFul API**
- [44강 - API 기본기 및 기획](/lessons/44-api-basic.md)
- [45강 - 기본 구조 잡기](/lessons/45-api-big-picture.md)
- [46강 - JWT 를 이용한 인증](/lessons/46-jwt.md)
- [47강 - 중복 제거 리팩토링](/lessons/47-dry-refactoring.md)
- [48강 - all() is bad](/lessons/48-all-is-bad.md)
- [49강 - API Rate Limit](/lessons/49-rate-limit.md)
- [50강 - 리소스 id 난독화](/lessons/50-id-obfuscation.md)
- [51강 - CORS](/lessons/51-cors.md)
- [52강 - Caching](/lessons/52-caching.md)
- [53강 - Partial Response](/lessons/53-partial-response.md)
- [54강 - API Documents](/lessons/54-api-docs.md)
- **번외-기타 잡다한 것들**
- [Homestead 설치 (on Mac)](/lessons/02-install-homestead-osx.md)
- [Homestead 설치 (on Windows)](/lessons/02-install-homestead-windows.md)
- [코드 배포](/lessons/999-code-release.md)
================================================
FILE: package.json
================================================
{
"private": true,
"devDependencies": {
"gulp": "^3.8.8"
},
"dependencies": {
"laravel-elixir": "^3.0.0"
}
}
================================================
FILE: phpunit.xml
================================================
./tests/integration/./tests/integration/Http/Controllers/AuthTest.php./tests/integration/Http/Controllers/Api/ApiTest.phpapp/
================================================
FILE: public/.htaccess
================================================
Options -MultiViews
RewriteEngine On
# Redirect to new domain
RewriteCond "%{HTTP_HOST}" "^ec2-52-193-67-224\.ap-northeast-1\.compute\.amazonaws\.com$"
RewriteRule "^/?(.*)" "http://l5.appkr.kr/$1"
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)/$ /$1 [L,R=301]
# Handle Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
# Handle Authorization Header...
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
================================================
FILE: public/attachments/.gitignore
================================================
*
!.gitignore
================================================
FILE: public/build/css/app-4cd4d601dd.css
================================================
/*!
* Bootstrap v3.3.5 (http://getbootstrap.com)
* Copyright 2011-2015 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */dfn,span.form-error{font-style:italic}.label,sub,sup{vertical-align:baseline}.btn,.btn-group,.btn-group-vertical,.caret,.checkbox-inline,.radio-inline,img{vertical-align:middle}.fa,.glyphicon{-moz-osx-font-smoothing:grayscale}body,figure{margin:0}.btn-group>.btn-group,.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.dropdown-menu{float:left}.img-responsive,.img-thumbnail,.table,label{max-width:100%}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.pre-scrollable{max-height:340px}html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}b,optgroup,strong{font-weight:700}h1{margin:.67em 0}mark{background:#ff0;color:#000}sub,sup{font-size:75%;line-height:0;position:relative}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}hr{box-sizing:content-box;margin-top:20px;margin-bottom:20px}pre,textarea{overflow:auto}code,kbd,pre,samp{font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}.glyphicon,.popover,.tooltip,address{font-style:normal}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{blockquote,img,pre,tr{page-break-inside:avoid}*,:after,:before{background:0 0!important;color:#000!important;box-shadow:none!important;text-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999}thead{display:table-header-group}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}.btn,.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active,.btn.active,.btn:active,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover,.form-control,.navbar-toggle,.open>.btn-danger.dropdown-toggle,.open>.btn-default.dropdown-toggle,.open>.btn-info.dropdown-toggle,.open>.btn-primary.dropdown-toggle,.open>.btn-success.dropdown-toggle,.open>.btn-warning.dropdown-toggle,div.dropzone,div.preview__forum{background-image:none}.img-thumbnail,body{background-color:#fff}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/bootstrap/glyphicons-halflings-regular.eot);src:url(../fonts/bootstrap/glyphicons-halflings-regular.eot?#iefix) format("embedded-opentype"),url(../fonts/bootstrap/glyphicons-halflings-regular.woff2) format("woff2"),url(../fonts/bootstrap/glyphicons-halflings-regular.woff) format("woff"),url(../fonts/bootstrap/glyphicons-halflings-regular.ttf) format("truetype"),url(../fonts/bootstrap/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format("svg")}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-weight:400;line-height:1;-webkit-font-smoothing:antialiased}.glyphicon-asterisk:before{content:"\2a"}.glyphicon-plus:before{content:"\2b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before,.glyphicon-btc:before,.glyphicon-xbt:before{content:"\e227"}.glyphicon-jpy:before,.glyphicon-yen:before{content:"\00a5"}.glyphicon-rub:before,.glyphicon-ruble:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*,:after,:before{box-sizing:border-box}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:dotted thin;outline:-webkit-focus-ring-color auto 5px;outline-offset:-2px}.img-responsive{display:block;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{padding:4px;line-height:1.428571429;border:1px solid #ddd;border-radius:4px;transition:all .2s ease-in-out;display:inline-block;height:auto}.img-circle{border-radius:50%}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}dl,ol,ul{margin-top:0}.lead,address,dl{margin-bottom:20px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{font-size:16px;font-weight:300;line-height:1.4}.badge,.label,dt,kbd kbd,label{font-weight:700}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child,ol ol,ol ul,ul ol,ul ul{margin-bottom:0}address,blockquote .small,blockquote footer,blockquote small,dd,dt,pre{line-height:1.428571429}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{background-color:#fcf8e3;padding:.2em}.list-inline,.list-unstyled{padding-left:0;list-style:none}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.initialism,.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}pre code,table{background-color:transparent}.page-header{padding-bottom:9px;border-bottom:1px solid #eee}ol,ul{margin-bottom:10px}.list-inline{margin-left:-5px}.list-inline>li{display:inline-block;padding-left:5px;padding-right:5px}dd{margin-left:0}.dl-horizontal dd:after,.dl-horizontal dd:before{content:" ";display:table}.dl-horizontal dd:after{clear:both}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;clear:left;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}.container{width:750px}}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dropdown-menu>li>a,.form-horizontal .form-group:after,.modal-footer:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;color:#777}legend,pre{color:#333}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;border-right:5px solid #eee;border-left:0;text-align:right}code,kbd{padding:2px 4px;font-size:90%}caption,th{text-align:left}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{color:#fff;background-color:#333;border-radius:3px;box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;box-shadow:none}pre{display:block;margin:0 0 10px;font-size:13px;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5}.container-fluid:after,.container-fluid:before,.container:after,.container:before,.row:after,.row:before{display:table;content:" "}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;border-radius:0}.container,.container-fluid{margin-right:auto;margin-left:auto;padding-left:15px;padding-right:15px}.pre-scrollable{overflow-y:scroll}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.row{margin-left:-15px;margin-right:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-left:15px;padding-right:15px}.col-xs-1{width:8.3333333333%}.col-xs-2{width:16.6666666667%}.col-xs-3{width:25%}.col-xs-4{width:33.3333333333%}.col-xs-5{width:41.6666666667%}.col-xs-6{width:50%}.col-xs-7{width:58.3333333333%}.col-xs-8{width:66.6666666667%}.col-xs-9{width:75%}.col-xs-10{width:83.3333333333%}.col-xs-11{width:91.6666666667%}.col-xs-12{width:100%}.col-xs-pull-0{right:auto}.col-xs-pull-1{right:8.3333333333%}.col-xs-pull-2{right:16.6666666667%}.col-xs-pull-3{right:25%}.col-xs-pull-4{right:33.3333333333%}.col-xs-pull-5{right:41.6666666667%}.col-xs-pull-6{right:50%}.col-xs-pull-7{right:58.3333333333%}.col-xs-pull-8{right:66.6666666667%}.col-xs-pull-9{right:75%}.col-xs-pull-10{right:83.3333333333%}.col-xs-pull-11{right:91.6666666667%}.col-xs-pull-12{right:100%}.col-xs-push-0{left:auto}.col-xs-push-1{left:8.3333333333%}.col-xs-push-2{left:16.6666666667%}.col-xs-push-3{left:25%}.col-xs-push-4{left:33.3333333333%}.col-xs-push-5{left:41.6666666667%}.col-xs-push-6{left:50%}.col-xs-push-7{left:58.3333333333%}.col-xs-push-8{left:66.6666666667%}.col-xs-push-9{left:75%}.col-xs-push-10{left:83.3333333333%}.col-xs-push-11{left:91.6666666667%}.col-xs-push-12{left:100%}.col-xs-offset-0{margin-left:0}.col-xs-offset-1{margin-left:8.3333333333%}.col-xs-offset-2{margin-left:16.6666666667%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-4{margin-left:33.3333333333%}.col-xs-offset-5{margin-left:41.6666666667%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-7{margin-left:58.3333333333%}.col-xs-offset-8{margin-left:66.6666666667%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-10{margin-left:83.3333333333%}.col-xs-offset-11{margin-left:91.6666666667%}.col-xs-offset-12{margin-left:100%}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-1{width:8.3333333333%}.col-sm-2{width:16.6666666667%}.col-sm-3{width:25%}.col-sm-4{width:33.3333333333%}.col-sm-5{width:41.6666666667%}.col-sm-6{width:50%}.col-sm-7{width:58.3333333333%}.col-sm-8{width:66.6666666667%}.col-sm-9{width:75%}.col-sm-10{width:83.3333333333%}.col-sm-11{width:91.6666666667%}.col-sm-12{width:100%}.col-sm-pull-0{right:auto}.col-sm-pull-1{right:8.3333333333%}.col-sm-pull-2{right:16.6666666667%}.col-sm-pull-3{right:25%}.col-sm-pull-4{right:33.3333333333%}.col-sm-pull-5{right:41.6666666667%}.col-sm-pull-6{right:50%}.col-sm-pull-7{right:58.3333333333%}.col-sm-pull-8{right:66.6666666667%}.col-sm-pull-9{right:75%}.col-sm-pull-10{right:83.3333333333%}.col-sm-pull-11{right:91.6666666667%}.col-sm-pull-12{right:100%}.col-sm-push-0{left:auto}.col-sm-push-1{left:8.3333333333%}.col-sm-push-2{left:16.6666666667%}.col-sm-push-3{left:25%}.col-sm-push-4{left:33.3333333333%}.col-sm-push-5{left:41.6666666667%}.col-sm-push-6{left:50%}.col-sm-push-7{left:58.3333333333%}.col-sm-push-8{left:66.6666666667%}.col-sm-push-9{left:75%}.col-sm-push-10{left:83.3333333333%}.col-sm-push-11{left:91.6666666667%}.col-sm-push-12{left:100%}.col-sm-offset-0{margin-left:0}.col-sm-offset-1{margin-left:8.3333333333%}.col-sm-offset-2{margin-left:16.6666666667%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-4{margin-left:33.3333333333%}.col-sm-offset-5{margin-left:41.6666666667%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-7{margin-left:58.3333333333%}.col-sm-offset-8{margin-left:66.6666666667%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-10{margin-left:83.3333333333%}.col-sm-offset-11{margin-left:91.6666666667%}.col-sm-offset-12{margin-left:100%}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-1{width:8.3333333333%}.col-md-2{width:16.6666666667%}.col-md-3{width:25%}.col-md-4{width:33.3333333333%}.col-md-5{width:41.6666666667%}.col-md-6{width:50%}.col-md-7{width:58.3333333333%}.col-md-8{width:66.6666666667%}.col-md-9{width:75%}.col-md-10{width:83.3333333333%}.col-md-11{width:91.6666666667%}.col-md-12{width:100%}.col-md-pull-0{right:auto}.col-md-pull-1{right:8.3333333333%}.col-md-pull-2{right:16.6666666667%}.col-md-pull-3{right:25%}.col-md-pull-4{right:33.3333333333%}.col-md-pull-5{right:41.6666666667%}.col-md-pull-6{right:50%}.col-md-pull-7{right:58.3333333333%}.col-md-pull-8{right:66.6666666667%}.col-md-pull-9{right:75%}.col-md-pull-10{right:83.3333333333%}.col-md-pull-11{right:91.6666666667%}.col-md-pull-12{right:100%}.col-md-push-0{left:auto}.col-md-push-1{left:8.3333333333%}.col-md-push-2{left:16.6666666667%}.col-md-push-3{left:25%}.col-md-push-4{left:33.3333333333%}.col-md-push-5{left:41.6666666667%}.col-md-push-6{left:50%}.col-md-push-7{left:58.3333333333%}.col-md-push-8{left:66.6666666667%}.col-md-push-9{left:75%}.col-md-push-10{left:83.3333333333%}.col-md-push-11{left:91.6666666667%}.col-md-push-12{left:100%}.col-md-offset-0{margin-left:0}.col-md-offset-1{margin-left:8.3333333333%}.col-md-offset-2{margin-left:16.6666666667%}.col-md-offset-3{margin-left:25%}.col-md-offset-4{margin-left:33.3333333333%}.col-md-offset-5{margin-left:41.6666666667%}.col-md-offset-6{margin-left:50%}.col-md-offset-7{margin-left:58.3333333333%}.col-md-offset-8{margin-left:66.6666666667%}.col-md-offset-9{margin-left:75%}.col-md-offset-10{margin-left:83.3333333333%}.col-md-offset-11{margin-left:91.6666666667%}.col-md-offset-12{margin-left:100%}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-1{width:8.3333333333%}.col-lg-2{width:16.6666666667%}.col-lg-3{width:25%}.col-lg-4{width:33.3333333333%}.col-lg-5{width:41.6666666667%}.col-lg-6{width:50%}.col-lg-7{width:58.3333333333%}.col-lg-8{width:66.6666666667%}.col-lg-9{width:75%}.col-lg-10{width:83.3333333333%}.col-lg-11{width:91.6666666667%}.col-lg-12{width:100%}.col-lg-pull-0{right:auto}.col-lg-pull-1{right:8.3333333333%}.col-lg-pull-2{right:16.6666666667%}.col-lg-pull-3{right:25%}.col-lg-pull-4{right:33.3333333333%}.col-lg-pull-5{right:41.6666666667%}.col-lg-pull-6{right:50%}.col-lg-pull-7{right:58.3333333333%}.col-lg-pull-8{right:66.6666666667%}.col-lg-pull-9{right:75%}.col-lg-pull-10{right:83.3333333333%}.col-lg-pull-11{right:91.6666666667%}.col-lg-pull-12{right:100%}.col-lg-push-0{left:auto}.col-lg-push-1{left:8.3333333333%}.col-lg-push-2{left:16.6666666667%}.col-lg-push-3{left:25%}.col-lg-push-4{left:33.3333333333%}.col-lg-push-5{left:41.6666666667%}.col-lg-push-6{left:50%}.col-lg-push-7{left:58.3333333333%}.col-lg-push-8{left:66.6666666667%}.col-lg-push-9{left:75%}.col-lg-push-10{left:83.3333333333%}.col-lg-push-11{left:91.6666666667%}.col-lg-push-12{left:100%}.col-lg-offset-0{margin-left:0}.col-lg-offset-1{margin-left:8.3333333333%}.col-lg-offset-2{margin-left:16.6666666667%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-4{margin-left:33.3333333333%}.col-lg-offset-5{margin-left:41.6666666667%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-7{margin-left:58.3333333333%}.col-lg-offset-8{margin-left:66.6666666667%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-10{margin-left:83.3333333333%}.col-lg-offset-11{margin-left:91.6666666667%}.col-lg-offset-12{margin-left:100%}}caption{padding-top:8px;padding-bottom:8px;color:#777}.table{width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.428571429;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered,.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover,.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}table col[class*=col-]{position:static;float:none;display:table-column}table td[class*=col-],table th[class*=col-]{position:static;float:none;display:table-cell}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{overflow-x:auto;min-height:.01%}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset,legend{padding:0;border:0}fieldset{margin:0;min-width:0}legend{display:block;width:100%;margin-bottom:20px;font-size:21px;line-height:inherit;border-bottom:1px solid #e5e5e5}label{display:inline-block;margin-bottom:5px}input[type=search]{box-sizing:border-box;-webkit-appearance:none}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}.form-control,div.dropzone,div.preview__forum,output{font-size:14px;line-height:1.428571429;color:#555;display:block}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=checkbox]:focus,input[type=file]:focus,input[type=radio]:focus{outline:dotted thin;outline:-webkit-focus-ring-color auto 5px;outline-offset:-2px}output{padding-top:7px}.form-control,div.dropzone,div.preview__forum{width:100%;height:34px;padding:6px 12px;background-color:#fff;border:1px solid #ccc;border-radius:4px;box-shadow:inset 0 1px 1px rgba(0,0,0,.075);transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus,div.dropzone:focus,div.preview__forum:focus{border-color:#66afe9;outline:0;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder,div.dropzone::-moz-placeholder,div.preview__forum::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder,div.dropzone:-ms-input-placeholder,div.preview__forum:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder,div.dropzone::-webkit-input-placeholder,div.preview__forum::-webkit-input-placeholder{color:#999}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .form-control-feedback,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.form-control[disabled],.form-control[readonly],div[disabled].dropzone,div[disabled].preview__forum,div[readonly].dropzone,div[readonly].preview__forum,fieldset[disabled] .form-control,fieldset[disabled] div.dropzone,fieldset[disabled] div.preview__forum{background-color:#eee;opacity:1}.form-control[disabled],div[disabled].dropzone,div[disabled].preview__forum,fieldset[disabled] .form-control,fieldset[disabled] div.dropzone,fieldset[disabled] div.preview__forum{cursor:not-allowed}textarea.form-control{height:auto}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=datetime-local].form-control,input[type=month].form-control,input[type=time].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],.input-group-sm input[type=time],.input-group-sm>.input-group-btn>input[type=date].btn,.input-group-sm>.input-group-btn>input[type=datetime-local].btn,.input-group-sm>.input-group-btn>input[type=month].btn,.input-group-sm>.input-group-btn>input[type=time].btn,.input-group-sm>input[type=date].form-control,.input-group-sm>input[type=date].input-group-addon,.input-group-sm>input[type=datetime-local].form-control,.input-group-sm>input[type=datetime-local].input-group-addon,.input-group-sm>input[type=month].form-control,.input-group-sm>input[type=month].input-group-addon,.input-group-sm>input[type=time].form-control,.input-group-sm>input[type=time].input-group-addon,input[type=date].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm,input[type=time].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],.input-group-lg input[type=time],.input-group-lg>.input-group-btn>input[type=date].btn,.input-group-lg>.input-group-btn>input[type=datetime-local].btn,.input-group-lg>.input-group-btn>input[type=month].btn,.input-group-lg>.input-group-btn>input[type=time].btn,.input-group-lg>input[type=date].form-control,.input-group-lg>input[type=date].input-group-addon,.input-group-lg>input[type=datetime-local].form-control,.input-group-lg>input[type=datetime-local].input-group-addon,.input-group-lg>input[type=month].form-control,.input-group-lg>input[type=month].input-group-addon,.input-group-lg>input[type=time].form-control,.input-group-lg>input[type=time].input-group-addon,input[type=date].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg,input[type=time].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-left:-20px;margin-top:4px\9}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}.checkbox-inline.disabled,.checkbox.disabled label,.radio-inline.disabled,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio label,fieldset[disabled] .radio-inline,fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.form-control-static{padding-top:7px;padding-bottom:7px;margin-bottom:0;min-height:34px}.form-control-static.input-lg,.form-control-static.input-sm,.input-group-lg>.form-control-static.form-control,.input-group-lg>.form-control-static.input-group-addon,.input-group-lg>.input-group-btn>.form-control-static.btn,.input-group-lg>div.form-control-static.dropzone,.input-group-lg>div.form-control-static.preview__forum,.input-group-sm>.form-control-static.form-control,.input-group-sm>.form-control-static.input-group-addon,.input-group-sm>.input-group-btn>.form-control-static.btn,.input-group-sm>div.form-control-static.dropzone,.input-group-sm>div.form-control-static.preview__forum{padding-left:0;padding-right:0}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn,.input-group-sm>div.dropzone,.input-group-sm>div.preview__forum,.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.input-group-sm>.input-group-btn>select.btn,.input-group-sm>select.form-control,.input-group-sm>select.input-group-addon,select.input-sm{height:30px;line-height:30px}.input-group-sm>.input-group-btn>select[multiple].btn,.input-group-sm>.input-group-btn>textarea.btn,.input-group-sm>select[multiple].form-control,.input-group-sm>select[multiple].input-group-addon,.input-group-sm>textarea.form-control,.input-group-sm>textarea.input-group-addon,select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control,.form-group-sm div.dropzone,.form-group-sm div.preview__forum{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn,.input-group-lg>div.dropzone,.input-group-lg>div.preview__forum,.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.input-group-lg>.input-group-btn>select.btn,.input-group-lg>select.form-control,.input-group-lg>select.input-group-addon,select.input-lg{height:46px;line-height:46px}.input-group-lg>.input-group-btn>select[multiple].btn,.input-group-lg>.input-group-btn>textarea.btn,.input-group-lg>select[multiple].form-control,.input-group-lg>select[multiple].input-group-addon,.input-group-lg>textarea.form-control,.input-group-lg>textarea.input-group-addon,select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control,.form-group-lg div.dropzone,.form-group-lg div.preview__forum{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control,.has-feedback div.dropzone,.has-feedback div.preview__forum{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.collapsing,.dropdown,.dropup{position:relative}.form-group-lg .form-control+.form-control-feedback,.form-group-lg div.dropzone+.form-control-feedback,.form-group-lg div.preview__forum+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-group-lg>.form-control+.form-control-feedback,.input-group-lg>.input-group-addon+.form-control-feedback,.input-group-lg>.input-group-btn>.btn+.form-control-feedback,.input-group-lg>div.dropzone+.form-control-feedback,.input-group-lg>div.preview__forum+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.form-group-sm div.dropzone+.form-control-feedback,.form-group-sm div.preview__forum+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-group-sm>.form-control+.form-control-feedback,.input-group-sm>.input-group-addon+.form-control-feedback,.input-group-sm>.input-group-btn>.btn+.form-control-feedback,.input-group-sm>div.dropzone+.form-control-feedback,.input-group-sm>div.preview__forum+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .form-control,.has-success div.dropzone,.has-success div.preview__forum{border-color:#3c763d;box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus,.has-success div.dropzone:focus,.has-success div.preview__forum:focus{border-color:#2b542c;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;border-color:#3c763d;background-color:#dff0d8}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .form-control-feedback,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control,.has-warning div.dropzone,.has-warning div.preview__forum{border-color:#8a6d3b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus,.has-warning div.dropzone:focus,.has-warning div.preview__forum:focus{border-color:#66512c;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;border-color:#8a6d3b;background-color:#fcf8e3}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .form-control-feedback,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control,.has-error div.dropzone,.has-error div.preview__forum{border-color:#a94442;box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus,.has-error div.dropzone:focus,.has-error div.preview__forum:focus{border-color:#843534;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;border-color:#a94442;background-color:#f2dede}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-control-static,.form-inline .form-group{display:inline-block}.form-inline .control-label,.form-inline .form-group{margin-bottom:0;vertical-align:middle}.form-inline .form-control,.form-inline div.dropzone,.form-inline div.preview__forum{display:inline-block;width:auto;vertical-align:middle}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn,.form-inline .input-group div.dropzone,.form-inline .input-group div.preview__forum{width:auto}.form-inline .input-group>.form-control,.form-inline .input-group>div.dropzone,.form-inline .input-group>div.preview__forum{width:100%}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}.form-horizontal .control-label{text-align:right;margin-bottom:0;padding-top:7px}}.btn-block,input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{margin-top:0;margin-bottom:0;padding-top:7px}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-left:-15px;margin-right:-15px}.form-horizontal .form-group:after,.form-horizontal .form-group:before{content:" ";display:table}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:14.33px;font-size:18px}.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;margin-bottom:0;font-weight:400;text-align:center;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;border:1px solid transparent;white-space:nowrap;padding:6px 12px;font-size:14px;line-height:1.428571429;border-radius:4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:dotted thin;outline:-webkit-focus-ring-color auto 5px;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{outline:0;box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;opacity:.65;filter:alpha(opacity=65);box-shadow:none}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.btn-default:hover,.open>.btn-default.dropdown-toggle{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.btn-default.dropdown-toggle.focus,.open>.btn-default.dropdown-toggle:focus,.open>.btn-default.dropdown-toggle:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary.active,.btn-primary:active,.btn-primary:hover,.open>.btn-primary.dropdown-toggle{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.btn-primary.dropdown-toggle.focus,.open>.btn-primary.dropdown-toggle:focus,.open>.btn-primary.dropdown-toggle:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success.active,.btn-success:active,.btn-success:hover,.open>.btn-success.dropdown-toggle{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.btn-success.dropdown-toggle.focus,.open>.btn-success.dropdown-toggle:focus,.open>.btn-success.dropdown-toggle:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info.active,.btn-info:active,.btn-info:hover,.open>.btn-info.dropdown-toggle{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.btn-info.dropdown-toggle.focus,.open>.btn-info.dropdown-toggle:focus,.open>.btn-info.dropdown-toggle:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning.active,.btn-warning:active,.btn-warning:hover,.open>.btn-warning.dropdown-toggle{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.btn-warning.dropdown-toggle.focus,.open>.btn-warning.dropdown-toggle:focus,.open>.btn-warning.dropdown-toggle:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger.active,.btn-danger:active,.btn-danger:hover,.open>.btn-danger.dropdown-toggle{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.btn-danger.dropdown-toggle.focus,.open>.btn-danger.dropdown-toggle:focus,.open>.btn-danger.dropdown-toggle:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{color:#337ab7;font-weight:400;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block}.btn-block+.btn-block{margin-top:5px}.fade{opacity:0;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{height:0;overflow:hidden;transition-property:height,visibility;transition-duration:.35s;transition-timing-function:ease}.caret{display:inline-block;width:0;height:0;margin-left:2px;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;min-width:160px;padding:5px 0;margin:2px 0 0;list-style:none;font-size:14px;text-align:left;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;box-shadow:0 6px 12px rgba(0,0,0,.175);background-clip:padding-box}.badge,.input-group-addon,.label,.pager,.progress-bar{text-align:center}.dropdown-menu-right,.dropdown-menu.pull-right{left:auto;right:0}.dropdown-header,.dropdown-menu>li>a{display:block;padding:3px 20px;line-height:1.428571429;white-space:nowrap}.btn-group-vertical>.btn:not(:first-child):not(:last-child),.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn,.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{font-weight:400;color:#333}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{text-decoration:none;color:#262626;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;outline:0;background-color:#337ab7}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;background-color:transparent;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);cursor:not-allowed}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-left{left:0;right:auto}.dropdown-header{font-size:12px;color:#777}.dropdown-backdrop{position:fixed;left:0;right:0;bottom:0;top:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9;content:""}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{left:0;right:auto}}.btn-group,.btn-group-vertical{position:relative;display:inline-block}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar:after,.btn-toolbar:before{content:" ";display:table}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn .caret,.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-top-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-bottom-left-radius:0;border-top-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-left:8px;padding-right:8px}.btn-group-lg.btn-group>.btn+.dropdown-toggle,.btn-group>.btn-lg+.dropdown-toggle{padding-left:12px;padding-right:12px}.btn-group.open .dropdown-toggle{box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{box-shadow:none}.btn-group-lg>.btn .caret,.btn-lg .caret{border-width:5px 5px 0}.dropup .btn-group-lg>.btn .caret,.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before{content:" ";display:table}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-bottom-left-radius:4px;border-top-right-radius:0;border-top-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn,.input-group .form-control:not(:first-child):not(:last-child),.input-group div.dropzone:not(:first-child):not(:last-child),.input-group div.preview__forum:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-right-radius:0;border-top-left-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{float:none;display:table-cell;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group,.input-group-btn,.input-group-btn>.btn,.nav>li,.nav>li>a,.navbar{position:relative}.input-group{display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-left:0;padding-right:0}.input-group .form-control,.input-group div.dropzone,.input-group div.preview__forum{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group .form-control,.input-group div.dropzone,.input-group div.preview__forum,.input-group-addon,.input-group-btn{display:table-cell}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.input-group-addon.btn{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.input-group-addon.btn{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group div.dropzone:first-child,.input-group div.preview__forum:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-top-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group div.dropzone:last-child,.input-group div.preview__forum:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-bottom-left-radius:0;border-top-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{font-size:0;white-space:nowrap}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{margin-bottom:0;padding-left:0;list-style:none}.nav:after,.nav:before{content:" ";display:table}.nav>li,.nav>li>a{display:block}.nav>li>a{padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;background-color:transparent;cursor:not-allowed}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.428571429;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent;cursor:default}.nav-pills>li{float:left}.nav-justified>li,.nav-stacked>li,.nav-tabs.nav-justified>li{float:none}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified,.nav-tabs.nav-justified{width:100%}.nav-justified>li>a,.nav-tabs.nav-justified>li>a{text-align:center;margin-bottom:5px}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}.nav-tabs-justified,.nav-tabs.nav-justified{border-bottom:0}.nav-tabs-justified>li>a,.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-justified>li,.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a,.nav-tabs.nav-justified>li>a{margin-bottom:0}.nav-tabs-justified>li>a,.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before{content:" ";display:table}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-right-radius:0;border-top-left-radius:0}.navbar{min-height:50px;margin-bottom:20px;border:1px solid transparent}.navbar-collapse{overflow-x:visible;padding-right:15px;padding-left:15px;border-top:1px solid transparent;box-shadow:inset 0 1px 0 rgba(255,255,255,.1);-webkit-overflow-scrolling:touch}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar{border-radius:4px}.navbar-header{float:left}.navbar-collapse{width:auto;border-top:0;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-left:0;padding-right:0}}.embed-responsive,.modal,.modal-open,.progress{overflow:hidden}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}.navbar-static-top{z-index:1000;border-width:0 0 1px}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;font-size:18px;line-height:20px;height:50px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;margin-right:15px;padding:9px 10px;margin-top:8px;margin-bottom:8px;background-color:transparent;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}.progress-bar-striped,.progress-striped .progress-bar,.progress-striped .progress-bar-danger,.progress-striped .progress-bar-info,.progress-striped .progress-bar-success,.progress-striped .progress-bar-warning{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}@media (min-width:768px){.navbar-toggle{display:none}.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;border-top:1px solid transparent;border-bottom:1px solid transparent;box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);margin:8px -15px}@media (min-width:768px){.navbar-form .form-control-static,.navbar-form .form-group{display:inline-block}.navbar-form .control-label,.navbar-form .form-group{margin-bottom:0;vertical-align:middle}.navbar-form .form-control,.navbar-form div.dropzone,.navbar-form div.preview__forum{display:inline-block;width:auto;vertical-align:middle}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn,.navbar-form .input-group div.dropzone,.navbar-form .input-group div.preview__forum{width:auto}.navbar-form .input-group>.form-control,.navbar-form .input-group>div.dropzone,.navbar-form .input-group>div.preview__forum{width:100%}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}.navbar-form{width:auto;border:0;margin-left:0;margin-right:0;padding-top:0;padding-bottom:0;box-shadow:none}.navbar-text{float:left;margin-left:15px;margin-right:15px}.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.breadcrumb>li,.pagination{display:inline-block}.btn .badge,.btn .label{top:-1px;position:relative}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-right-radius:0;border-top-left-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-radius:4px 4px 0 0}.navbar-btn{margin-top:8px;margin-bottom:8px}.btn-group-sm>.navbar-btn.btn,.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.btn-group-xs>.navbar-btn.btn,.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-nav>li>a,.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{background-color:#e7e7e7;color:#555}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#090909}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>li>a,.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#090909}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{background-color:#090909;color:#fff}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#090909}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#090909}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#090909}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li+li:before{content:"/ ";padding:0 5px;color:#ccc}.breadcrumb>.active{color:#777}.pagination{padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;line-height:1.428571429;text-decoration:none;color:#337ab7;background-color:#fff;border:1px solid #ddd;margin-left:-1px}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-bottom-left-radius:4px;border-top-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-bottom-right-radius:4px;border-top-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:3;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7;cursor:default}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;background-color:#fff;border-color:#ddd;cursor:not-allowed}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-bottom-left-radius:6px;border-top-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-bottom-right-radius:6px;border-top-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.badge,.label{line-height:1;white-space:nowrap}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-bottom-left-radius:3px;border-top-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-bottom-right-radius:3px;border-top-right-radius:3px}.pager{padding-left:0;margin:20px 0;list-style:none}.pager:after,.pager:before{content:" ";display:table}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;background-color:#fff;cursor:not-allowed}.label{display:inline;padding:.2em .6em .3em;font-size:75%;color:#fff;border-radius:.25em}.label:empty{display:none}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;color:#fff;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.media-object,.thumbnail{display:block}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.jumbotron,.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;background-color:#eee}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.alert .alert-link,.close{font-weight:700}.alert,.thumbnail{margin-bottom:20px}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-left:60px;padding-right:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{padding:4px;line-height:1.428571429;background-color:#fff;border:1px solid #ddd;border-radius:4px;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto;margin-left:auto;margin-right:auto}.thumbnail .caption{padding:9px;color:#333}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.alert{padding:15px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.modal,.modal-backdrop{top:0;right:0;bottom:0;left:0}.alert-success{background-color:#dff0d8;border-color:#d6e9c6;color:#3c763d}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{background-color:#d9edf7;border-color:#bce8f1;color:#31708f}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{background-color:#fcf8e3;border-color:#faebcc;color:#8a6d3b}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{background-color:#f2dede;border-color:#ebccd1;color:#a94442}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;background-color:#f5f5f5;border-radius:4px;box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;background-color:#337ab7;box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-bar-info{background-color:#5bc0de}.progress-bar-warning{background-color:#f0ad4e}.progress-bar-danger{background-color:#d9534f}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{zoom:1;overflow:hidden}.media-body{width:10000px}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{margin-bottom:20px;padding-left:0}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-right-radius:4px;border-top-left-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{text-decoration:none;color:#555;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{background-color:#eee;color:#777;cursor:not-allowed}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.panel-heading>.dropdown .dropdown-toggle,.panel-title,.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-title,.panel>.list-group,.panel>.panel-collapse>.list-group,.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel-body{padding:15px}.panel-body:after,.panel-body:before{content:" ";display:table}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-right-radius:3px;border-top-left-radius:3px}.panel-title{margin-top:0;font-size:16px}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel-group .panel-heading,.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-responsive:last-child>.table:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-left-radius:3px;border-bottom-right-radius:3px}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-right-radius:3px;border-top-left-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-right-radius:0;border-top-left-radius:0}.panel>.table-responsive:first-child>.table:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-right-radius:3px;border-top-left-radius:3px}.list-group+.panel-footer,.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-left:15px;padding-right:15px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-responsive{border:0;margin-bottom:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;left:0;bottom:0;height:100%;width:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.2;filter:alpha(opacity=20)}.popover,.tooltip{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:400;letter-spacing:normal;line-break:auto;line-height:1.428571429;text-shadow:none;text-transform:none;white-space:normal;word-break:normal;word-spacing:normal;word-wrap:normal;text-decoration:none}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;opacity:.5;filter:alpha(opacity=50)}button.close{padding:0;cursor:pointer;background:0 0;border:0;-webkit-appearance:none}.modal-content,.popover{background-clip:padding-box}.modal{display:none;position:fixed;z-index:1050;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transform:translate(0,-25%);transform:translate(0,-25%);transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out}.modal.in .modal-dialog{-webkit-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;box-shadow:0 3px 9px rgba(0,0,0,.5);outline:0}.modal-backdrop{position:fixed;z-index:1040;background-color:#000}.modal-backdrop.fade{opacity:0;filter:alpha(opacity=0)}.carousel-control,.modal-backdrop.in{opacity:.5;filter:alpha(opacity=50)}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5;min-height:16.43px}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.428571429}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer:after,.modal-footer:before{content:" ";display:table}.modal-footer .btn+.btn{margin-left:5px;margin-bottom:0}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}.tooltip.top-left .tooltip-arrow,.tooltip.top-right .tooltip-arrow{bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;text-align:left;text-align:start;font-size:12px;opacity:0;filter:alpha(opacity=0)}.tooltip.in{opacity:.9;filter:alpha(opacity=90)}.tooltip.top{margin-top:-3px;padding:5px 0}.tooltip.right{margin-left:3px;padding:0 5px}.tooltip.bottom{margin-top:3px;padding:5px 0}.tooltip.left{margin-left:-3px;padding:0 5px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px}.tooltip.top-right .tooltip-arrow{left:5px}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow,.tooltip.bottom-left .tooltip-arrow,.tooltip.bottom-right .tooltip-arrow{border-width:0 5px 5px;border-bottom-color:#000;top:0}.tooltip.bottom .tooltip-arrow{left:50%;margin-left:-5px}.tooltip.bottom-left .tooltip-arrow{right:5px;margin-top:-5px}.tooltip.bottom-right .tooltip-arrow{left:5px;margin-top:-5px}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;text-align:left;text-align:start;font-size:14px;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;box-shadow:0 5px 10px rgba(0,0,0,.2)}.carousel-caption,.carousel-control{color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.6);text-align:center}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{margin:0;padding:8px 14px;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.carousel,.carousel-inner{position:relative}.popover>.arrow{border-width:11px}.popover>.arrow:after{border-width:10px;content:""}.popover.top>.arrow{left:50%;margin-left:-11px;border-bottom-width:0;border-top-color:#999;border-top-color:rgba(0,0,0,.25);bottom:-11px}.popover.top>.arrow:after{content:" ";bottom:1px;margin-left:-10px;border-bottom-width:0;border-top-color:#fff}.popover.left>.arrow:after,.popover.right>.arrow:after{content:" ";bottom:-10px}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-left-width:0;border-right-color:#999;border-right-color:rgba(0,0,0,.25)}.popover.right>.arrow:after{left:1px;border-left-width:0;border-right-color:#fff}.popover.bottom>.arrow{left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25);top:-11px}.popover.bottom>.arrow:after{content:" ";top:1px;margin-left:-10px;border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;border-right-width:0;border-left-color:#fff}.carousel-inner{overflow:hidden;width:100%}.carousel-inner>.item{display:none;position:relative;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{display:block;max-width:100%;height:auto;line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{transition:-webkit-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);left:0}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);left:0}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);left:0}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;left:0;bottom:0;width:15%;font-size:20px}.carousel-control.left{background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1)}.carousel-control.right{left:auto;right:0;background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1)}.carousel-control:focus,.carousel-control:hover{outline:0;color:#fff;text-decoration:none;opacity:.9;filter:alpha(opacity=90)}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;margin-top:-10px;z-index:5;display:inline-block}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;line-height:1;font-family:serif}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;margin-left:-30%;padding-left:0;list-style:none;text-align:center}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;border:1px solid #fff;border-radius:10px;cursor:pointer;background-color:#000\9;background-color:transparent}.carousel-indicators .active{margin:0;width:12px;height:12px;background-color:#fff}.carousel-caption{position:absolute;left:15%;right:15%;bottom:20px;z-index:10;padding-top:20px;padding-bottom:20px}.carousel-caption .btn,.text-hide,::selection{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-15px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-15px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-15px}.carousel-caption{left:20%;right:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.clearfix:after,.clearfix:before{content:" ";display:table}.center-block{display:block;margin-left:auto;margin-right:auto}.fa.fa-pull-left,.fa.pull-left{margin-right:.3em}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.hidden,.visible-lg,.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;background-color:transparent;border:0}.affix{position:fixed}@-ms-viewport{width:device-width}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}.visible-xs-block{display:block!important}.visible-xs-inline{display:inline!important}.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}.visible-sm-block{display:block!important}.visible-sm-inline{display:inline!important}.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}.visible-md-block{display:block!important}.visible-md-inline{display:inline!important}.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}.visible-lg-block{display:block!important}.visible-lg-inline{display:inline!important}.visible-lg-inline-block{display:inline-block!important}.hidden-lg{display:none!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}.hidden-print{display:none!important}}/*!
* Font Awesome 4.4.0 by @davegandy - http://fontawesome.io - @fontawesome
* License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
*/@font-face{font-family:FontAwesome;src:url(../fonts/fontawesome-webfont.eot?v=4.4.0);src:url(../fonts/fontawesome-webfont.eot?#iefix&v=4.4.0) format("embedded-opentype"),url(../fonts/fontawesome-webfont.woff2?v=4.4.0) format("woff2"),url(../fonts/fontawesome-webfont.woff?v=4.4.0) format("woff"),url(../fonts/fontawesome-webfont.ttf?v=4.4.0) format("truetype"),url(../fonts/fontawesome-webfont.svg?v=4.4.0#fontawesomeregular) format("svg");font-weight:400;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased}.fa-lg{font-size:1.3333333333em;line-height:.75em;vertical-align:-15%}.fa-stack,.select2-container{display:inline-block;vertical-align:middle}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.2857142857em;text-align:center}.fa-ul{padding-left:0;margin-left:2.1428571429em;list-style-type:none}.fa.fa-pull-right,.fa.pull-right{margin-left:.3em}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.1428571429em;width:2.1428571429em;top:.1428571429em;text-align:center}.fa-li.fa-lg{left:-1.8571428571em}.fa-border{padding:.2em .25em .15em;border:.08em solid #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0);-webkit-transform:scale(-1,1);transform:scale(-1,1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:scale(1,-1);transform:scale(1,-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-rotate-90{-webkit-filter:none;filter:none}.fa-stack{position:relative;width:2em;height:2em;line-height:2em}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:""}.fa-music:before{content:""}.fa-search:before{content:""}.fa-envelope-o:before{content:""}.fa-heart:before{content:""}.fa-star:before{content:""}.fa-star-o:before{content:""}.fa-user:before{content:""}.fa-film:before{content:""}.fa-th-large:before{content:""}.fa-th:before{content:""}.fa-th-list:before{content:""}.fa-check:before{content:""}.fa-close:before,.fa-remove:before,.fa-times:before{content:""}.fa-search-plus:before{content:""}.fa-search-minus:before{content:""}.fa-power-off:before{content:""}.fa-signal:before{content:""}.fa-cog:before,.fa-gear:before{content:""}.fa-trash-o:before{content:""}.fa-home:before{content:""}.fa-file-o:before{content:""}.fa-clock-o:before{content:""}.fa-road:before{content:""}.fa-download:before{content:""}.fa-arrow-circle-o-down:before{content:""}.fa-arrow-circle-o-up:before{content:""}.fa-inbox:before{content:""}.fa-play-circle-o:before{content:""}.fa-repeat:before,.fa-rotate-right:before{content:""}.fa-refresh:before{content:""}.fa-list-alt:before{content:""}.fa-lock:before{content:""}.fa-flag:before{content:""}.fa-headphones:before{content:""}.fa-volume-off:before{content:""}.fa-volume-down:before{content:""}.fa-volume-up:before{content:""}.fa-qrcode:before{content:""}.fa-barcode:before{content:""}.fa-tag:before{content:""}.fa-tags:before{content:""}.fa-book:before{content:""}.fa-bookmark:before{content:""}.fa-print:before{content:""}.fa-camera:before{content:""}.fa-font:before{content:""}.fa-bold:before{content:""}.fa-italic:before{content:""}.fa-text-height:before{content:""}.fa-text-width:before{content:""}.fa-align-left:before{content:""}.fa-align-center:before{content:""}.fa-align-right:before{content:""}.fa-align-justify:before{content:""}.fa-list:before{content:""}.fa-dedent:before,.fa-outdent:before{content:""}.fa-indent:before{content:""}.fa-video-camera:before{content:""}.fa-image:before,.fa-photo:before,.fa-picture-o:before{content:""}.fa-pencil:before{content:""}.fa-map-marker:before{content:""}.fa-adjust:before{content:""}.fa-tint:before{content:""}.fa-edit:before,.fa-pencil-square-o:before{content:""}.fa-share-square-o:before{content:""}.fa-check-square-o:before{content:""}.fa-arrows:before{content:""}.fa-step-backward:before{content:""}.fa-fast-backward:before{content:""}.fa-backward:before{content:""}.fa-play:before{content:""}.fa-pause:before{content:""}.fa-stop:before{content:""}.fa-forward:before{content:""}.fa-fast-forward:before{content:""}.fa-step-forward:before{content:""}.fa-eject:before{content:""}.fa-chevron-left:before{content:""}.fa-chevron-right:before{content:""}.fa-plus-circle:before{content:""}.fa-minus-circle:before{content:""}.fa-times-circle:before{content:""}.fa-check-circle:before{content:""}.fa-question-circle:before{content:""}.fa-info-circle:before{content:""}.fa-crosshairs:before{content:""}.fa-times-circle-o:before{content:""}.fa-check-circle-o:before{content:""}.fa-ban:before{content:""}.fa-arrow-left:before{content:""}.fa-arrow-right:before{content:""}.fa-arrow-up:before{content:""}.fa-arrow-down:before{content:""}.fa-mail-forward:before,.fa-share:before{content:""}.fa-expand:before{content:""}.fa-compress:before{content:""}.fa-plus:before{content:""}.fa-minus:before{content:""}.fa-asterisk:before{content:""}.fa-exclamation-circle:before{content:""}.fa-gift:before{content:""}.fa-leaf:before{content:""}.fa-fire:before{content:""}.fa-eye:before{content:""}.fa-eye-slash:before{content:""}.fa-exclamation-triangle:before,.fa-warning:before{content:""}.fa-plane:before{content:""}.fa-calendar:before{content:""}.fa-random:before{content:""}.fa-comment:before{content:""}.fa-magnet:before{content:""}.fa-chevron-up:before{content:""}.fa-chevron-down:before{content:""}.fa-retweet:before{content:""}.fa-shopping-cart:before{content:""}.fa-folder:before{content:""}.fa-folder-open:before{content:""}.fa-arrows-v:before{content:""}.fa-arrows-h:before{content:""}.fa-bar-chart-o:before,.fa-bar-chart:before{content:""}.fa-twitter-square:before{content:""}.fa-facebook-square:before{content:""}.fa-camera-retro:before{content:""}.fa-key:before{content:""}.fa-cogs:before,.fa-gears:before{content:""}.fa-comments:before{content:""}.fa-thumbs-o-up:before{content:""}.fa-thumbs-o-down:before{content:""}.fa-star-half:before{content:""}.fa-heart-o:before{content:""}.fa-sign-out:before{content:""}.fa-linkedin-square:before{content:""}.fa-thumb-tack:before{content:""}.fa-external-link:before{content:""}.fa-sign-in:before{content:""}.fa-trophy:before{content:""}.fa-github-square:before{content:""}.fa-upload:before{content:""}.fa-lemon-o:before{content:""}.fa-phone:before{content:""}.fa-square-o:before{content:""}.fa-bookmark-o:before{content:""}.fa-phone-square:before{content:""}.fa-twitter:before{content:""}.fa-facebook-f:before,.fa-facebook:before{content:""}.fa-github:before{content:""}.fa-unlock:before{content:""}.fa-credit-card:before{content:""}.fa-feed:before,.fa-rss:before{content:""}.fa-hdd-o:before{content:""}.fa-bullhorn:before{content:""}.fa-bell:before{content:""}.fa-certificate:before{content:""}.fa-hand-o-right:before{content:""}.fa-hand-o-left:before{content:""}.fa-hand-o-up:before{content:""}.fa-hand-o-down:before{content:""}.fa-arrow-circle-left:before{content:""}.fa-arrow-circle-right:before{content:""}.fa-arrow-circle-up:before{content:""}.fa-arrow-circle-down:before{content:""}.fa-globe:before{content:""}.fa-wrench:before{content:""}.fa-tasks:before{content:""}.fa-filter:before{content:""}.fa-briefcase:before{content:""}.fa-arrows-alt:before{content:""}.fa-group:before,.fa-users:before{content:""}.fa-chain:before,.fa-link:before{content:""}.fa-cloud:before{content:""}.fa-flask:before{content:""}.fa-cut:before,.fa-scissors:before{content:""}.fa-copy:before,.fa-files-o:before{content:""}.fa-paperclip:before{content:""}.fa-floppy-o:before,.fa-save:before{content:""}.fa-square:before{content:""}.fa-bars:before,.fa-navicon:before,.fa-reorder:before{content:""}.fa-list-ul:before{content:""}.fa-list-ol:before{content:""}.fa-strikethrough:before{content:""}.fa-underline:before{content:""}.fa-table:before{content:""}.fa-magic:before{content:""}.fa-truck:before{content:""}.fa-pinterest:before{content:""}.fa-pinterest-square:before{content:""}.fa-google-plus-square:before{content:""}.fa-google-plus:before{content:""}.fa-money:before{content:""}.fa-caret-down:before{content:""}.fa-caret-up:before{content:""}.fa-caret-left:before{content:""}.fa-caret-right:before{content:""}.fa-columns:before{content:""}.fa-sort:before,.fa-unsorted:before{content:""}.fa-sort-desc:before,.fa-sort-down:before{content:""}.fa-sort-asc:before,.fa-sort-up:before{content:""}.fa-envelope:before{content:""}.fa-linkedin:before{content:""}.fa-rotate-left:before,.fa-undo:before{content:""}.fa-gavel:before,.fa-legal:before{content:""}.fa-dashboard:before,.fa-tachometer:before{content:""}.fa-comment-o:before{content:""}.fa-comments-o:before{content:""}.fa-bolt:before,.fa-flash:before{content:""}.fa-sitemap:before{content:""}.fa-umbrella:before{content:""}.fa-clipboard:before,.fa-paste:before{content:""}.fa-lightbulb-o:before{content:""}.fa-exchange:before{content:""}.fa-cloud-download:before{content:""}.fa-cloud-upload:before{content:""}.fa-user-md:before{content:""}.fa-stethoscope:before{content:""}.fa-suitcase:before{content:""}.fa-bell-o:before{content:""}.fa-coffee:before{content:""}.fa-cutlery:before{content:""}.fa-file-text-o:before{content:""}.fa-building-o:before{content:""}.fa-hospital-o:before{content:""}.fa-ambulance:before{content:""}.fa-medkit:before{content:""}.fa-fighter-jet:before{content:""}.fa-beer:before{content:""}.fa-h-square:before{content:""}.fa-plus-square:before{content:""}.fa-angle-double-left:before{content:""}.fa-angle-double-right:before{content:""}.fa-angle-double-up:before{content:""}.fa-angle-double-down:before{content:""}.fa-angle-left:before{content:""}.fa-angle-right:before{content:""}.fa-angle-up:before{content:""}.fa-angle-down:before{content:""}.fa-desktop:before{content:""}.fa-laptop:before{content:""}.fa-tablet:before{content:""}.fa-mobile-phone:before,.fa-mobile:before{content:""}.fa-circle-o:before{content:""}.fa-quote-left:before{content:""}.fa-quote-right:before{content:""}.fa-spinner:before{content:""}.fa-circle:before{content:""}.fa-mail-reply:before,.fa-reply:before{content:""}.fa-github-alt:before{content:""}.fa-folder-o:before{content:""}.fa-folder-open-o:before{content:""}.fa-smile-o:before{content:""}.fa-frown-o:before{content:""}.fa-meh-o:before{content:""}.fa-gamepad:before{content:""}.fa-keyboard-o:before{content:""}.fa-flag-o:before{content:""}.fa-flag-checkered:before{content:""}.fa-terminal:before{content:""}.fa-code:before{content:""}.fa-mail-reply-all:before,.fa-reply-all:before{content:""}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:""}.fa-location-arrow:before{content:""}.fa-crop:before{content:""}.fa-code-fork:before{content:""}.fa-chain-broken:before,.fa-unlink:before{content:""}.fa-question:before{content:""}.fa-info:before{content:""}.fa-exclamation:before{content:""}.fa-superscript:before{content:""}.fa-subscript:before{content:""}.fa-eraser:before{content:""}.fa-puzzle-piece:before{content:""}.fa-microphone:before{content:""}.fa-microphone-slash:before{content:""}.fa-shield:before{content:""}.fa-calendar-o:before{content:""}.fa-fire-extinguisher:before{content:""}.fa-rocket:before{content:""}.fa-maxcdn:before{content:""}.fa-chevron-circle-left:before{content:""}.fa-chevron-circle-right:before{content:""}.fa-chevron-circle-up:before{content:""}.fa-chevron-circle-down:before{content:""}.fa-html5:before{content:""}.fa-css3:before{content:""}.fa-anchor:before{content:""}.fa-unlock-alt:before{content:""}.fa-bullseye:before{content:""}.fa-ellipsis-h:before{content:""}.fa-ellipsis-v:before{content:""}.fa-rss-square:before{content:""}.fa-play-circle:before{content:""}.fa-ticket:before{content:""}.fa-minus-square:before{content:""}.fa-minus-square-o:before{content:""}.fa-level-up:before{content:""}.fa-level-down:before{content:""}.fa-check-square:before{content:""}.fa-pencil-square:before{content:""}.fa-external-link-square:before{content:""}.fa-share-square:before{content:""}.fa-compass:before{content:""}.fa-caret-square-o-down:before,.fa-toggle-down:before{content:""}.fa-caret-square-o-up:before,.fa-toggle-up:before{content:""}.fa-caret-square-o-right:before,.fa-toggle-right:before{content:""}.fa-eur:before,.fa-euro:before{content:""}.fa-gbp:before{content:""}.fa-dollar:before,.fa-usd:before{content:""}.fa-inr:before,.fa-rupee:before{content:""}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen:before{content:""}.fa-rouble:before,.fa-rub:before,.fa-ruble:before{content:""}.fa-krw:before,.fa-won:before{content:""}.fa-bitcoin:before,.fa-btc:before{content:""}.fa-file:before{content:""}.fa-file-text:before{content:""}.fa-sort-alpha-asc:before{content:""}.fa-sort-alpha-desc:before{content:""}.fa-sort-amount-asc:before{content:""}.fa-sort-amount-desc:before{content:""}.fa-sort-numeric-asc:before{content:""}.fa-sort-numeric-desc:before{content:""}.fa-thumbs-up:before{content:""}.fa-thumbs-down:before{content:""}.fa-youtube-square:before{content:""}.fa-youtube:before{content:""}.fa-xing:before{content:""}.fa-xing-square:before{content:""}.fa-youtube-play:before{content:""}.fa-dropbox:before{content:""}.fa-stack-overflow:before{content:""}.fa-instagram:before{content:""}.fa-flickr:before{content:""}.fa-adn:before{content:""}.fa-bitbucket:before{content:""}.fa-bitbucket-square:before{content:""}.fa-tumblr:before{content:""}.fa-tumblr-square:before{content:""}.fa-long-arrow-down:before{content:""}.fa-long-arrow-up:before{content:""}.fa-long-arrow-left:before{content:""}.fa-long-arrow-right:before{content:""}.fa-apple:before{content:""}.fa-windows:before{content:""}.fa-android:before{content:""}.fa-linux:before{content:""}.fa-dribbble:before{content:""}.fa-skype:before{content:""}.fa-foursquare:before{content:""}.fa-trello:before{content:""}.fa-female:before{content:""}.fa-male:before{content:""}.fa-gittip:before,.fa-gratipay:before{content:""}.fa-sun-o:before{content:""}.fa-moon-o:before{content:""}.fa-archive:before{content:""}.fa-bug:before{content:""}.fa-vk:before{content:""}.fa-weibo:before{content:""}.fa-renren:before{content:""}.fa-pagelines:before{content:""}.fa-stack-exchange:before{content:""}.fa-arrow-circle-o-right:before{content:""}.fa-arrow-circle-o-left:before{content:""}.fa-caret-square-o-left:before,.fa-toggle-left:before{content:""}.fa-dot-circle-o:before{content:""}.fa-wheelchair:before{content:""}.fa-vimeo-square:before{content:""}.fa-try:before,.fa-turkish-lira:before{content:""}.fa-plus-square-o:before{content:""}.fa-space-shuttle:before{content:""}.fa-slack:before{content:""}.fa-envelope-square:before{content:""}.fa-wordpress:before{content:""}.fa-openid:before{content:""}.fa-bank:before,.fa-institution:before,.fa-university:before{content:""}.fa-graduation-cap:before,.fa-mortar-board:before{content:""}.fa-yahoo:before{content:""}.fa-google:before{content:""}.fa-reddit:before{content:""}.fa-reddit-square:before{content:""}.fa-stumbleupon-circle:before{content:""}.fa-stumbleupon:before{content:""}.fa-delicious:before{content:""}.fa-digg:before{content:""}.fa-pied-piper:before{content:""}.fa-pied-piper-alt:before{content:""}.fa-drupal:before{content:""}.fa-joomla:before{content:""}.fa-language:before{content:""}.fa-fax:before{content:""}.fa-building:before{content:""}.fa-child:before{content:""}.fa-paw:before{content:""}.fa-spoon:before{content:""}.fa-cube:before{content:""}.fa-cubes:before{content:""}.fa-behance:before{content:""}.fa-behance-square:before{content:""}.fa-steam:before{content:""}.fa-steam-square:before{content:""}.fa-recycle:before{content:""}.fa-automobile:before,.fa-car:before{content:""}.fa-cab:before,.fa-taxi:before{content:""}.fa-tree:before{content:""}.fa-spotify:before{content:""}.fa-deviantart:before{content:""}.fa-soundcloud:before{content:""}.fa-database:before{content:""}.fa-file-pdf-o:before{content:""}.fa-file-word-o:before{content:""}.fa-file-excel-o:before{content:""}.fa-file-powerpoint-o:before{content:""}.fa-file-image-o:before,.fa-file-photo-o:before,.fa-file-picture-o:before{content:""}.fa-file-archive-o:before,.fa-file-zip-o:before{content:""}.fa-file-audio-o:before,.fa-file-sound-o:before{content:""}.fa-file-movie-o:before,.fa-file-video-o:before{content:""}.fa-file-code-o:before{content:""}.fa-vine:before{content:""}.fa-codepen:before{content:""}.fa-jsfiddle:before{content:""}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-ring:before,.fa-life-saver:before,.fa-support:before{content:""}.fa-circle-o-notch:before{content:""}.fa-ra:before,.fa-rebel:before{content:""}.fa-empire:before,.fa-ge:before{content:""}.fa-git-square:before{content:""}.fa-git:before{content:""}.fa-hacker-news:before,.fa-y-combinator-square:before,.fa-yc-square:before{content:""}.fa-tencent-weibo:before{content:""}.fa-qq:before{content:""}.fa-wechat:before,.fa-weixin:before{content:""}.fa-paper-plane:before,.fa-send:before{content:""}.fa-paper-plane-o:before,.fa-send-o:before{content:""}.fa-history:before{content:""}.fa-circle-thin:before{content:""}.fa-header:before{content:""}.fa-paragraph:before{content:""}.fa-sliders:before{content:""}.fa-share-alt:before{content:""}.fa-share-alt-square:before{content:""}.fa-bomb:before{content:""}.fa-futbol-o:before,.fa-soccer-ball-o:before{content:""}.fa-tty:before{content:""}.fa-binoculars:before{content:""}.fa-plug:before{content:""}.fa-slideshare:before{content:""}.fa-twitch:before{content:""}.fa-yelp:before{content:""}.fa-newspaper-o:before{content:""}.fa-wifi:before{content:""}.fa-calculator:before{content:""}.fa-paypal:before{content:""}.fa-google-wallet:before{content:""}.fa-cc-visa:before{content:""}.fa-cc-mastercard:before{content:""}.fa-cc-discover:before{content:""}.fa-cc-amex:before{content:""}.fa-cc-paypal:before{content:""}.fa-cc-stripe:before{content:""}.fa-bell-slash:before{content:""}.fa-bell-slash-o:before{content:""}.fa-trash:before{content:""}.fa-copyright:before{content:""}.fa-at:before{content:""}.fa-eyedropper:before{content:""}.fa-paint-brush:before{content:""}.fa-birthday-cake:before{content:""}.fa-area-chart:before{content:""}.fa-pie-chart:before{content:""}.fa-line-chart:before{content:""}.fa-lastfm:before{content:""}.fa-lastfm-square:before{content:""}.fa-toggle-off:before{content:""}.fa-toggle-on:before{content:""}.fa-bicycle:before{content:""}.fa-bus:before{content:""}.fa-ioxhost:before{content:""}.fa-angellist:before{content:""}.fa-cc:before{content:""}.fa-ils:before,.fa-shekel:before,.fa-sheqel:before{content:""}.fa-meanpath:before{content:""}.fa-buysellads:before{content:""}.fa-connectdevelop:before{content:""}.fa-dashcube:before{content:""}.fa-forumbee:before{content:""}.fa-leanpub:before{content:""}.fa-sellsy:before{content:""}.fa-shirtsinbulk:before{content:""}.fa-simplybuilt:before{content:""}.fa-skyatlas:before{content:""}.fa-cart-plus:before{content:""}.fa-cart-arrow-down:before{content:""}.fa-diamond:before{content:""}.fa-ship:before{content:""}.fa-user-secret:before{content:""}.fa-motorcycle:before{content:""}.fa-street-view:before{content:""}.fa-heartbeat:before{content:""}.fa-venus:before{content:""}.fa-mars:before{content:""}.fa-mercury:before{content:""}.fa-intersex:before,.fa-transgender:before{content:""}.fa-transgender-alt:before{content:""}.fa-venus-double:before{content:""}.fa-mars-double:before{content:""}.fa-venus-mars:before{content:""}.fa-mars-stroke:before{content:""}.fa-mars-stroke-v:before{content:""}.fa-mars-stroke-h:before{content:""}.fa-neuter:before{content:""}.fa-genderless:before{content:""}.fa-facebook-official:before{content:""}.fa-pinterest-p:before{content:""}.fa-whatsapp:before{content:""}.fa-server:before{content:""}.fa-user-plus:before{content:""}.fa-user-times:before{content:""}.fa-bed:before,.fa-hotel:before{content:""}.fa-viacoin:before{content:""}.fa-train:before{content:""}.fa-subway:before{content:""}.fa-medium:before{content:""}.fa-y-combinator:before,.fa-yc:before{content:""}.fa-optin-monster:before{content:""}.fa-opencart:before{content:""}.fa-expeditedssl:before{content:""}.fa-battery-4:before,.fa-battery-full:before{content:""}.fa-battery-3:before,.fa-battery-three-quarters:before{content:""}.fa-battery-2:before,.fa-battery-half:before{content:""}.fa-battery-1:before,.fa-battery-quarter:before{content:""}.fa-battery-0:before,.fa-battery-empty:before{content:""}.fa-mouse-pointer:before{content:""}.fa-i-cursor:before{content:""}.fa-object-group:before{content:""}.fa-object-ungroup:before{content:""}.fa-sticky-note:before{content:""}.fa-sticky-note-o:before{content:""}.fa-cc-jcb:before{content:""}.fa-cc-diners-club:before{content:""}.fa-clone:before{content:""}.fa-balance-scale:before{content:""}.fa-hourglass-o:before{content:""}.fa-hourglass-1:before,.fa-hourglass-start:before{content:""}.fa-hourglass-2:before,.fa-hourglass-half:before{content:""}.fa-hourglass-3:before,.fa-hourglass-end:before{content:""}.fa-hourglass:before{content:""}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:""}.fa-hand-paper-o:before,.fa-hand-stop-o:before{content:""}.fa-hand-scissors-o:before{content:""}.fa-hand-lizard-o:before{content:""}.fa-hand-spock-o:before{content:""}.fa-hand-pointer-o:before{content:""}.fa-hand-peace-o:before{content:""}.fa-trademark:before{content:""}.fa-registered:before{content:""}.fa-creative-commons:before{content:""}.fa-gg:before{content:""}.fa-gg-circle:before{content:""}.fa-tripadvisor:before{content:""}.fa-odnoklassniki:before{content:""}.fa-odnoklassniki-square:before{content:""}.fa-get-pocket:before{content:""}.fa-wikipedia-w:before{content:""}.fa-safari:before{content:""}.fa-chrome:before{content:""}.fa-firefox:before{content:""}.fa-opera:before{content:""}.fa-internet-explorer:before{content:""}.fa-television:before,.fa-tv:before{content:""}.fa-contao:before{content:""}.fa-500px:before{content:""}.fa-amazon:before{content:""}.fa-calendar-plus-o:before{content:""}.fa-calendar-minus-o:before{content:""}.fa-calendar-times-o:before{content:""}.fa-calendar-check-o:before{content:""}.fa-industry:before{content:""}.fa-map-pin:before{content:""}.fa-map-signs:before{content:""}.fa-map-o:before{content:""}.fa-map:before{content:""}.fa-commenting:before{content:""}.fa-commenting-o:before{content:""}.fa-houzz:before{content:""}.fa-vimeo:before{content:""}.fa-black-tie:before{content:""}.fa-fonticons:before{content:""}.select2-container{box-sizing:border-box;margin:0;position:relative}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir=rtl] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:#fff;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0!important;clip:rect(0 0 0 0)!important;height:1px!important;margin:-1px!important;overflow:hidden!important;padding:0!important;position:absolute!important;width:1px!important}.select2-container--bootstrap .select2-results>.select2-results__options,.select2-container--classic .select2-results>.select2-results__options,.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:700}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent;border-style:solid;border-width:5px 4px 0;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir=rtl] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir=rtl] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888;border-width:0 4px 5px}.select2-container--default .select2-selection--multiple{background-color:#fff;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__placeholder{color:#999;margin-top:5px;float:left}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:700;margin-top:5px;margin-right:10px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:700;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir=rtl] .select2-selection--multiple .select2-search--inline,.select2-container--default[dir=rtl] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir=rtl] .select2-selection--multiple .select2-selection__placeholder{float:right}.select2-container--default[dir=rtl] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir=rtl] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:1px solid #000;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple,.select2-container--default.select2-container--open.select2-container--above .select2-selection--single{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple,.select2-container--default.select2-container--open.select2-container--below .select2-selection--single{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:0 0;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:#fff}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:linear-gradient(to bottom,#fff 50%,#eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:700;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:linear-gradient(to bottom,#eee 50%,#ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent;border-style:solid;border-width:5px 4px 0;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir=rtl] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir=rtl] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:4px 0 0 4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:0 0;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888;border-width:0 4px 5px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:linear-gradient(to bottom,#fff 0,#eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:linear-gradient(to bottom,#eee 50%,#fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:#fff;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:700;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir=rtl] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir=rtl] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--bootstrap .select2-search--dropdown .select2-search__field,.select2-container--bootstrap .select2-selection{box-shadow:inset 0 1px 1px rgba(0,0,0,.075);background-color:#fff;color:#555;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb}/*! Select2 Bootstrap Theme v0.1.0-beta.4 | MIT License | github.com/select2/select2-bootstrap-theme */.select2-container--bootstrap{display:block}.select2-container--bootstrap .select2-selection{border:1px solid #ccc;border-radius:4px;outline:0}.select2-container--bootstrap .select2-search--dropdown .select2-search__field{border:1px solid #ccc;border-radius:4px}.select2-container--bootstrap .select2-search__field{outline:0}.select2-container--bootstrap .select2-search__field::-webkit-input-placeholder{color:#999}.select2-container--bootstrap .select2-search__field:-moz-placeholder{color:#999}.select2-container--bootstrap .select2-search__field::-moz-placeholder{color:#999;opacity:1}.select2-container--bootstrap .select2-search__field:-ms-input-placeholder{color:#999}.select2-container--bootstrap .select2-results__option[role=group]{padding:0}.select2-container--bootstrap .select2-results__option[aria-disabled=true]{color:#777;cursor:not-allowed}.select2-container--bootstrap .select2-results__option[aria-selected=true]{background-color:#f5f5f5;color:#262626}.select2-container--bootstrap .select2-results__option--highlighted[aria-selected]{background-color:#337ab7;color:#fff}.select2-container--bootstrap .select2-results__option .select2-results__option{padding:6px 12px}.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option{margin-left:-12px;padding-left:24px}.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-24px;padding-left:36px}.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-36px;padding-left:48px}.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-48px;padding-left:60px}.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-60px;padding-left:72px}.select2-container--bootstrap .select2-results__group{color:#777;display:block;padding:6px 12px;font-size:12px;line-height:1.428571429;white-space:nowrap}.select2-container--bootstrap.select2-container--focus .select2-selection,.select2-container--bootstrap.select2-container--open .select2-selection{box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;border-color:#66afe9}.select2-container--bootstrap.select2-container--open .select2-selection .select2-selection__arrow b{border-color:transparent transparent #999;border-width:0 4px 4px}.select2-container--bootstrap.select2-container--open.select2-container--below .select2-selection{border-bottom-right-radius:0;border-bottom-left-radius:0;border-bottom-color:transparent}.select2-container--bootstrap.select2-container--open.select2-container--above .select2-selection{border-top-right-radius:0;border-top-left-radius:0;border-top-color:transparent}.select2-container--bootstrap .select2-selection__clear{color:#999;cursor:pointer;float:right;font-weight:700;margin-right:10px}.select2-container--bootstrap .select2-selection__clear:hover{color:#333}.select2-container--bootstrap.select2-container--disabled .select2-selection{border-color:#ccc;box-shadow:none}.select2-container--bootstrap.select2-container--disabled .select2-search__field,.select2-container--bootstrap.select2-container--disabled .select2-selection{cursor:not-allowed}.select2-container--bootstrap.select2-container--disabled .select2-selection,.select2-container--bootstrap.select2-container--disabled .select2-selection--multiple .select2-selection__choice{background-color:#eee}.select2-container--bootstrap.select2-container--disabled .select2-selection--multiple .select2-selection__choice__remove,.select2-container--bootstrap.select2-container--disabled .select2-selection__clear{display:none}.select2-container--bootstrap .select2-dropdown{box-shadow:0 6px 12px rgba(0,0,0,.175);border-color:#66afe9;overflow-x:hidden;margin-top:-1px}.select2-container--bootstrap .select2-dropdown--above{margin-top:1px}.select2-container--bootstrap .select2-selection--single{height:34px;line-height:1.428571429;padding:6px 24px 6px 12px}.select2-container--bootstrap .select2-selection--single .select2-selection__arrow{position:absolute;bottom:0;right:12px;top:0;width:4px}.select2-container--bootstrap .select2-selection--single .select2-selection__arrow b{border-color:#999 transparent transparent;border-style:solid;border-width:4px 4px 0;height:0;left:0;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--bootstrap .select2-selection--single .select2-selection__rendered{color:#555;padding:0}.select2-container--bootstrap .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--bootstrap .select2-selection--multiple{min-height:34px}.select2-container--bootstrap .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;display:block;line-height:1.428571429;list-style:none;margin:0;overflow:hidden;padding:0;width:100%;text-overflow:ellipsis;white-space:nowrap}.select2-container--bootstrap .select2-selection--multiple .select2-selection__placeholder{color:#999;float:left;margin-top:5px}.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice{color:#555;background:#fff;border:1px solid #ccc;border-radius:4px;cursor:default;float:left;margin:5px 0 0 6px;padding:0 6px}.back-to-top,.dropzone.dz-clickable,.tags__forum li,.tags__lessons li{cursor:pointer}.select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field{background:0 0;padding:0 12px;height:32px;line-height:1.428571429;margin-top:0;min-width:5em}.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:700;margin-right:3px}.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--bootstrap .select2-selection--multiple .select2-selection__clear{margin-top:6px}.input-group-lg>.input-group-btn>.select2-container--bootstrap.btn,.input-group-lg>.select2-container--bootstrap.form-control,.input-group-lg>.select2-container--bootstrap.input-group-addon,.input-group-lg>div.select2-container--bootstrap.dropzone,.input-group-lg>div.select2-container--bootstrap.preview__forum,.input-group-sm>.input-group-btn>.select2-container--bootstrap.btn,.input-group-sm>.select2-container--bootstrap.form-control,.input-group-sm>.select2-container--bootstrap.input-group-addon,.input-group-sm>div.select2-container--bootstrap.dropzone,.input-group-sm>div.select2-container--bootstrap.preview__forum,.select2-container--bootstrap.input-lg,.select2-container--bootstrap.input-sm{border-radius:0;font-size:12px;height:auto;line-height:1;padding:0}.form-group-sm .select2-container--bootstrap .select2-selection--single,.input-group-sm .select2-container--bootstrap .select2-selection--single,.input-group-sm>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--single,.input-group-sm>.select2-container--bootstrap.form-control .select2-selection--single,.input-group-sm>.select2-container--bootstrap.input-group-addon .select2-selection--single,.input-group-sm>div.select2-container--bootstrap.dropzone .select2-selection--single,.input-group-sm>div.select2-container--bootstrap.preview__forum .select2-selection--single,.select2-container--bootstrap.input-sm .select2-selection--single{border-radius:3px;font-size:12px;height:30px;line-height:1.5;padding:5px 22px 5px 10px}.form-group-sm .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,.input-group-sm .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,.input-group-sm>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--single .select2-selection__arrow b,.input-group-sm>.select2-container--bootstrap.form-control .select2-selection--single .select2-selection__arrow b,.input-group-sm>.select2-container--bootstrap.input-group-addon .select2-selection--single .select2-selection__arrow b,.input-group-sm>div.select2-container--bootstrap.dropzone .select2-selection--single .select2-selection__arrow b,.input-group-sm>div.select2-container--bootstrap.preview__forum .select2-selection--single .select2-selection__arrow b,.select2-container--bootstrap.input-sm .select2-selection--single .select2-selection__arrow b{margin-left:-5px}.form-group-sm .select2-container--bootstrap .select2-selection--multiple,.input-group-sm .select2-container--bootstrap .select2-selection--multiple,.input-group-sm>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--multiple,.input-group-sm>.select2-container--bootstrap.form-control .select2-selection--multiple,.input-group-sm>.select2-container--bootstrap.input-group-addon .select2-selection--multiple,.input-group-sm>div.select2-container--bootstrap.dropzone .select2-selection--multiple,.input-group-sm>div.select2-container--bootstrap.preview__forum .select2-selection--multiple,.select2-container--bootstrap.input-sm .select2-selection--multiple{min-height:30px}.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,.input-group-sm>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--multiple .select2-selection__choice,.input-group-sm>.select2-container--bootstrap.form-control .select2-selection--multiple .select2-selection__choice,.input-group-sm>.select2-container--bootstrap.input-group-addon .select2-selection--multiple .select2-selection__choice,.input-group-sm>div.select2-container--bootstrap.dropzone .select2-selection--multiple .select2-selection__choice,.input-group-sm>div.select2-container--bootstrap.preview__forum .select2-selection--multiple .select2-selection__choice,.select2-container--bootstrap.input-sm .select2-selection--multiple .select2-selection__choice{font-size:12px;line-height:1.5;margin:4px 0 0 5px;padding:0 5px}.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-sm>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-sm>.select2-container--bootstrap.form-control .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-sm>.select2-container--bootstrap.input-group-addon .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-sm>div.select2-container--bootstrap.dropzone .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-sm>div.select2-container--bootstrap.preview__forum .select2-selection--multiple .select2-search--inline .select2-search__field,.select2-container--bootstrap.input-sm .select2-selection--multiple .select2-search--inline .select2-search__field{padding:0 10px;font-size:12px;height:28px;line-height:1.5}.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,.input-group-sm>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--multiple .select2-selection__clear,.input-group-sm>.select2-container--bootstrap.form-control .select2-selection--multiple .select2-selection__clear,.input-group-sm>.select2-container--bootstrap.input-group-addon .select2-selection--multiple .select2-selection__clear,.input-group-sm>div.select2-container--bootstrap.dropzone .select2-selection--multiple .select2-selection__clear,.input-group-sm>div.select2-container--bootstrap.preview__forum .select2-selection--multiple .select2-selection__clear,.select2-container--bootstrap.input-sm .select2-selection--multiple .select2-selection__clear{margin-top:5px}.form-group-lg .select2-container--bootstrap .select2-selection--single,.input-group-lg .select2-container--bootstrap .select2-selection--single,.input-group-lg>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--single,.input-group-lg>.select2-container--bootstrap.form-control .select2-selection--single,.input-group-lg>.select2-container--bootstrap.input-group-addon .select2-selection--single,.input-group-lg>div.select2-container--bootstrap.dropzone .select2-selection--single,.input-group-lg>div.select2-container--bootstrap.preview__forum .select2-selection--single,.select2-container--bootstrap.input-lg .select2-selection--single{border-radius:6px;font-size:18px;height:46px;line-height:1.3333333;padding:10px 31px 10px 16px}.form-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow,.input-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow,.input-group-lg>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--single .select2-selection__arrow,.input-group-lg>.select2-container--bootstrap.form-control .select2-selection--single .select2-selection__arrow,.input-group-lg>.select2-container--bootstrap.input-group-addon .select2-selection--single .select2-selection__arrow,.input-group-lg>div.select2-container--bootstrap.dropzone .select2-selection--single .select2-selection__arrow,.input-group-lg>div.select2-container--bootstrap.preview__forum .select2-selection--single .select2-selection__arrow,.select2-container--bootstrap.input-lg .select2-selection--single .select2-selection__arrow{width:5px}.form-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,.input-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,.input-group-lg>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--single .select2-selection__arrow b,.input-group-lg>.select2-container--bootstrap.form-control .select2-selection--single .select2-selection__arrow b,.input-group-lg>.select2-container--bootstrap.input-group-addon .select2-selection--single .select2-selection__arrow b,.input-group-lg>div.select2-container--bootstrap.dropzone .select2-selection--single .select2-selection__arrow b,.input-group-lg>div.select2-container--bootstrap.preview__forum .select2-selection--single .select2-selection__arrow b,.select2-container--bootstrap.input-lg .select2-selection--single .select2-selection__arrow b{border-width:5px 5px 0;margin-left:-10px;margin-top:-2.5px}.form-group-lg .select2-container--bootstrap .select2-selection--multiple,.input-group-lg .select2-container--bootstrap .select2-selection--multiple,.input-group-lg>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--multiple,.input-group-lg>.select2-container--bootstrap.form-control .select2-selection--multiple,.input-group-lg>.select2-container--bootstrap.input-group-addon .select2-selection--multiple,.input-group-lg>div.select2-container--bootstrap.dropzone .select2-selection--multiple,.input-group-lg>div.select2-container--bootstrap.preview__forum .select2-selection--multiple,.select2-container--bootstrap.input-lg .select2-selection--multiple{min-height:46px}.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,.input-group-lg>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--multiple .select2-selection__choice,.input-group-lg>.select2-container--bootstrap.form-control .select2-selection--multiple .select2-selection__choice,.input-group-lg>.select2-container--bootstrap.input-group-addon .select2-selection--multiple .select2-selection__choice,.input-group-lg>div.select2-container--bootstrap.dropzone .select2-selection--multiple .select2-selection__choice,.input-group-lg>div.select2-container--bootstrap.preview__forum .select2-selection--multiple .select2-selection__choice,.select2-container--bootstrap.input-lg .select2-selection--multiple .select2-selection__choice{font-size:18px;line-height:1.3333333;border-radius:4px;margin:9px 0 0 8px;padding:0 10px}.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-lg>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-lg>.select2-container--bootstrap.form-control .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-lg>.select2-container--bootstrap.input-group-addon .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-lg>div.select2-container--bootstrap.dropzone .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-lg>div.select2-container--bootstrap.preview__forum .select2-selection--multiple .select2-search--inline .select2-search__field,.select2-container--bootstrap.input-lg .select2-selection--multiple .select2-search--inline .select2-search__field{padding:0 16px;font-size:18px;height:44px;line-height:1.3333333}.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,.input-group-lg>.input-group-btn>.select2-container--bootstrap.btn .select2-selection--multiple .select2-selection__clear,.input-group-lg>.select2-container--bootstrap.form-control .select2-selection--multiple .select2-selection__clear,.input-group-lg>.select2-container--bootstrap.input-group-addon .select2-selection--multiple .select2-selection__clear,.input-group-lg>div.select2-container--bootstrap.dropzone .select2-selection--multiple .select2-selection__clear,.input-group-lg>div.select2-container--bootstrap.preview__forum .select2-selection--multiple .select2-selection__clear,.select2-container--bootstrap.input-lg .select2-selection--multiple .select2-selection__clear{margin-top:10px}.input-group-lg .select2-container--bootstrap.select2-container--open .select2-selection--single .select2-selection__arrow b,.input-group-lg>.input-group-btn>.select2-container--bootstrap.select2-container--open.btn .select2-selection--single .select2-selection__arrow b,.input-group-lg>.select2-container--bootstrap.select2-container--open.form-control .select2-selection--single .select2-selection__arrow b,.input-group-lg>.select2-container--bootstrap.select2-container--open.input-group-addon .select2-selection--single .select2-selection__arrow b,.input-group-lg>div.select2-container--bootstrap.select2-container--open.dropzone .select2-selection--single .select2-selection__arrow b,.input-group-lg>div.select2-container--bootstrap.select2-container--open.preview__forum .select2-selection--single .select2-selection__arrow b,.select2-container--bootstrap.input-lg.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #999;border-width:0 5px 5px}.select2-container--bootstrap[dir=rtl] .select2-selection--single{padding-left:24px;padding-right:12px}.select2-container--bootstrap[dir=rtl] .select2-selection--single .select2-selection__rendered{padding-right:0;padding-left:0;text-align:right}.select2-container--bootstrap[dir=rtl] .select2-selection--single .select2-selection__clear{float:left}.select2-container--bootstrap[dir=rtl] .select2-selection--single .select2-selection__arrow{left:12px;right:auto}.select2-container--bootstrap[dir=rtl] .select2-selection--single .select2-selection__arrow b{margin-left:0}.select2-container--bootstrap[dir=rtl] .select2-selection--multiple .select2-selection__choice,.select2-container--bootstrap[dir=rtl] .select2-selection--multiple .select2-selection__placeholder{float:right}.select2-container--bootstrap[dir=rtl] .select2-selection--multiple .select2-selection__choice{margin-left:0;margin-right:6px}.select2-container--bootstrap[dir=rtl] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.has-warning .select2-dropdown,.has-warning .select2-selection{border-color:#8a6d3b}.has-warning .select2-container--focus .select2-selection,.has-warning .select2-container--open .select2-selection{box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;border-color:#66512c}.has-warning.select2-drop-active{border-color:#66512c}.has-warning.select2-drop-active.select2-drop.select2-drop-above{border-top-color:#66512c}.has-error .select2-dropdown,.has-error .select2-selection{border-color:#a94442}.has-error .select2-container--focus .select2-selection,.has-error .select2-container--open .select2-selection{box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;border-color:#843534}.has-error.select2-drop-active{border-color:#843534}.has-error.select2-drop-active.select2-drop.select2-drop-above{border-top-color:#843534}.has-success .select2-dropdown,.has-success .select2-selection{border-color:#3c763d}.has-success .select2-container--focus .select2-selection,.has-success .select2-container--open .select2-selection{box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;border-color:#2b542c}.has-success.select2-drop-active{border-color:#2b542c}.has-success.select2-drop-active.select2-drop.select2-drop-above{border-top-color:#2b542c}.input-group .select2-container--bootstrap{display:table;table-layout:fixed;position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group.select2-bootstrap-prepend .select2-container--bootstrap .select2-selection{border-bottom-left-radius:0;border-top-left-radius:0}.input-group.select2-bootstrap-append .select2-container--bootstrap .select2-selection{border-bottom-right-radius:0;border-top-right-radius:0}.select2-bootstrap-append .input-group-btn,.select2-bootstrap-append .input-group-btn .btn,.select2-bootstrap-append .select2-container--bootstrap,.select2-bootstrap-prepend .input-group-btn,.select2-bootstrap-prepend .input-group-btn .btn,.select2-bootstrap-prepend .select2-container--bootstrap{vertical-align:top}.form-control.select2-hidden-accessible,div.select2-hidden-accessible.dropzone,div.select2-hidden-accessible.preview__forum{position:absolute!important;width:1px!important}.form-inline .select2-container--bootstrap{display:inline-block}body,html{width:100%;color:#444;font-size:16px;font-weight:400;-webkit-font-smoothing:antialiased;line-height:24px;background:#f5f5f5;overflow-x:hidden;box-sizing:border-box}hr{display:block;height:1px;border:0;border-top:1px solid #e9e9e9;margin:1em 0;padding:0}::selection{background:#fff7a8;color:#444}::-moz-selection{background:#fff7a8;color:#444;text-shadow:none}.footer,.footer a{color:#98978B}body>div.container{margin-top:5rem}.navbar-brand{padding:10px 15px}.flash-message,.js-flash-message{display:inline-block;position:fixed;bottom:50px;right:15px;max-width:450px;opacity:.8;z-index:9999}.page-header{margin:20px 0}.footer{min-height:60px;padding:1rem;margin-top:48px;margin-bottom:24px;border-top:1px solid #e9e9e9;font-size:14px}.footer .locale li.active a,.footer a:hover{color:#444}.footer .locale li.active{background-color:#e9e9e9}i.icon{display:inline-block;width:10px;margin-right:10px}span.form-error{margin-top:5px;display:block;font-size:.8em;color:#d9534f;font-weight:100}.divider{display:block;margin:1rem auto;width:100%}.back-to-top{position:fixed;bottom:1px;right:1px;display:none;z-index:9998;transition:all .5s ease-in-out;padding:8px 10px}.landing{margin-top:50px}.landing hr{margin:4rem}.landing #laracroft{background:url(/images/459033.jpg) center 50px no-repeat fixed;background-size:cover;position:relative;transition:all .5s ease;margin-bottom:5rem}.landing #laracroft .container-fluid{color:#fff;padding:calc(100% / 4 - 125px) 0}.landing #laracroft .container-fluid .row{background:rgba(0,0,0,.5);text-align:center;padding-bottom:20px}.landing #laracroft span.selection{background:#fff7a8;color:#444;text-shadow:none}.landing #laracroft .credit{position:absolute;right:2rem;bottom:1rem;color:#fff;font-weight:700;font-size:.8rem}.landing #laracroft .credit a{color:#fff;text-decoration:underline}.container__lessons aside li.active a,.container__lessons aside li.active a:active,.container__lessons aside li.active a:focus,.container__lessons aside li.active a:hover,.tags__lessons li a{color:#444}.landing #feature h1,.landing #story h1{margin-bottom:2rem}.landing #feature blockquote,.landing #story blockquote{margin:2rem auto}.landing #feature pre{width:95%;margin:2rem auto}.landing #courses img{width:8rem;max-width:50%;display:block;margin:2rem auto}.landing #courses h1,.landing #courses h2,.landing #courses h3,.landing #courses h4,.landing #courses p{margin:1rem auto}.landing #courses .btn{display:inline-block;margin:1rem auto}.landing #mailing-list form{display:block;margin-left:auto;margin-right:auto}.form-auth{max-width:330px;width:100%;padding:15px;margin:0 auto}.form-auth .checkbox,.form-auth .form-signin-heading{margin-bottom:1rem}.form-auth .checkbox{font-weight:400}.form-auth .form-control,.form-auth div.dropzone,.form-auth div.preview__forum{position:relative;height:auto;box-sizing:border-box;padding:.6rem}.form-auth .form-control:focus,.form-auth div.dropzone:focus,.form-auth div.preview__forum:focus{z-index:2}.form-auth input{margin-bottom:10px}.container__lessons{margin-top:1rem}.container__lessons article{background-color:#fff;padding:1rem}.container__lessons .media{margin:24px 0}.container__lessons aside li{display:block}.container__lessons aside li a{display:block;padding:.5rem 1rem}.container__lessons aside li.active{background-color:#e9e9e9}.container__lessons .article__lessons article ul,.container__lessons aside ul{list-style:none;padding-left:1rem}.container__lessons dl{width:auto;margin:1rem auto;font-weight:300}.container__lessons dd,.container__lessons dt{padding:.5rem}.tags__lessons{display:inline-block;padding:0}.tags__lessons li a:active,.tags__lessons li a:focus,.tags__lessons li a:hover{color:#fff}.tags__lessons li.active,.tags__lessons li:focus,.tags__lessons li:hover{background-color:#2f7dc8;color:#fff;transition:all .3s ease}.tags__lessons .label{display:inline-block;margin-left:0!important;margin-right:5px;margin-bottom:5px;padding:5px 7px;font-weight:100;border-radius:1px;background-color:#e9e9e9;color:#444}.sidebar__lessons aside>ul>li>ul>li:first-child{margin-top:.5rem}.sidebar__lessons aside>ul>li{padding:0 0 1rem;margin-bottom:24px;border-bottom:1px solid #e9e9e9}.sidebar__lessons aside>ul>li:last-child{border-bottom:none}.article__lessons article:first-child{line-height:1.6rem}.article__lessons article:first-child li{padding:.5rem 0}.article__lessons article:first-child h2:before,.article__lessons article:first-child h3:before,.article__lessons article:first-child h4:before,.article__lessons article:first-child h5:before,.article__lessons article:first-child h6:before,.article__lessons article:first-child ul li:before{content:"# ";padding-right:5px;opacity:.4;color:#2f7dc8}.article__lessons article:first-child pre{margin-bottom:24px}.article__lessons article:first-child h1{margin-bottom:.8rem;margin-top:.5rem}.article__lessons article:first-child h2{margin-top:1rem;margin-bottom:24px;padding-top:48px;border-top:1px solid #e9e9e9}.article__lessons article:first-child h4{font-size:1.2rem}.article__lessons article:first-child h2 a,.article__lessons article:first-child h3 a,.article__lessons article:first-child h4 a,.article__lessons article:first-child h5 a,.article__lessons article:first-child h6 a{transition:250ms linear all}.article__lessons article:first-child p>img,.container__forum img{line-height:1rem;transition:all .2s ease-in-out;max-width:100%;height:auto}.article__lessons article:first-child h2 a:active,.article__lessons article:first-child h2 a:focus,.article__lessons article:first-child h2 a:hover,.article__lessons article:first-child h3 a:active,.article__lessons article:first-child h3 a:focus,.article__lessons article:first-child h3 a:hover,.article__lessons article:first-child h4 a:active,.article__lessons article:first-child h4 a:focus,.article__lessons article:first-child h4 a:hover,.article__lessons article:first-child h5 a:active,.article__lessons article:first-child h5 a:focus,.article__lessons article:first-child h5 a:hover,.article__lessons article:first-child h6 a:active,.article__lessons article:first-child h6 a:focus,.article__lessons article:first-child h6 a:hover{outline:0}.article__lessons article:first-child p{margin-bottom:24px}.article__lessons article:first-child blockquote{background-color:#e9e9e9;font-size:1rem;border-radius:2px;box-sizing:border-box;margin-bottom:24px;max-width:100%;padding:1rem;opacity:.7}.article__lessons article:first-child blockquote p{margin-bottom:0}.article__lessons article:first-child p>img{display:inline-block;padding:4px;margin:0 auto;background-color:#fff}.article__lessons article:first-child table{width:100%;max-width:100%;overflow-y:hidden;overflow-x:auto;border-radius:2px;background-color:transparent;margin:24px auto}.article__lessons article:first-child table>tbody>tr>td,.article__lessons article:first-child table>tbody>tr>th,.article__lessons article:first-child table>tfoot>tr>td,.article__lessons article:first-child table>tfoot>tr>th,.article__lessons article:first-child table>thead>tr>td,.article__lessons article:first-child table>thead>tr>th{padding:.5rem;border-top:1px solid #eee}.article__lessons article:first-child table>tbody>tr:nth-child(odd)>td,.article__lessons article:first-child table>tbody>tr:nth-child(odd)>th{background-color:#efefef}.article__lessons article:first-child ul.pager li:before{content:""!important;color:#2f7dc8;padding-right:5px;opacity:0}.container__forum aside li.active a,.container__forum aside li.active a:active,.container__forum aside li.active a:focus,.container__forum aside li.active a:hover,.tags__forum li a{color:#444}div.nav__lessons{display:none}.container__forum{margin-top:1rem}.container__forum .form__forum,.container__forum article{background-color:#fff;padding:1rem}.container__forum .media{margin:24px 0}.container__forum aside li{display:block}.container__forum aside li a{display:block;padding:.5rem 1rem}.container__forum aside li.active{background-color:#e9e9e9}.container__forum img{display:inline-block;padding:4px;margin:0 auto;background-color:#fff}.container__forum table{width:100%;max-width:100%;overflow-y:hidden;overflow-x:auto;border-radius:2px;background-color:transparent;margin:24px auto}.container__forum table>tbody>tr>td,.container__forum table>tbody>tr>th,.container__forum table>tfoot>tr>td,.container__forum table>tfoot>tr>th,.container__forum table>thead>tr>td,.container__forum table>thead>tr>th{padding:.5rem;border-top:1px solid #eee}.container__forum table>tbody>tr:nth-child(odd)>td,.container__forum table>tbody>tr:nth-child(odd)>th{background-color:#efefef}.sidebar__forum aside>ul>li{margin-bottom:1rem}.sidebar__forum aside form{padding-left:1rem;margin-bottom:1rem}.sidebar__forum aside p.lead{margin:0;padding:1rem}.sidebar__forum aside ul{padding-left:1rem}.tags__forum{display:inline-block;padding:0}.tags__forum li a:active,.tags__forum li a:focus,.tags__forum li a:hover{color:#fff}.tags__forum li.active,.tags__forum li:focus,.tags__forum li:hover{background-color:#2f7dc8;color:#fff;transition:all .3s ease}.tags__forum .label{display:inline-block;margin-left:0!important;margin-right:5px;margin-bottom:5px;padding:5px 7px;font-weight:100;border-radius:1px;background-color:#e9e9e9;color:#444}div.dropzone,div.nav__forum{display:none}div.preview__forum{display:none;margin-top:24px;height:auto}div.dropzone{height:auto}textarea.forum__content{width:100%;padding:1rem}pre{padding:0;border:none;border-radius:0}pre>code.hljs{padding:1rem}.login__forum{padding:1rem;border-radius:3px;border:1px solid #e9e9e9;width:100%}.border__item{padding:1rem;border-radius:3px;position:relative;overflow:visible;float:right;width:calc(100% - 80px);border:1px solid #e9e9e9}.border__item:before{content:"";display:block;position:absolute;top:21px;left:-6px;width:10px;height:10px;background:#fff;border-left:1px solid #e9e9e9;border-top:1px solid #e9e9e9;-moz-transform:rotate(-45deg);-webkit-transform:rotate(-45deg)}@media screen and (max-width:991px){.landing h1{font-size:32px}.landing h2{font-size:26px}.landing p.lead{font-size:16px}.landing hr{margin:2rem auto}.sidebar__forum,.sidebar__lessons{display:none;overflow:hidden;margin-bottom:24px}.sidebar__forum li a,.sidebar__lessons li a{display:block}.sidebar__forum li a:hover,.sidebar__lessons li a:hover{background-color:#29ABE0;color:#fff}div.sort__forum{display:none}div.nav__forum,div.nav__lessons{position:fixed;bottom:1px;left:1px;z-index:9999;display:block;transition:all .5s ease-in-out}.border__item{width:100%}.border__item:before{content:none}}@media screen and (max-width:767px){.landing h1{font-size:29px}.landing h2{font-size:23px}.landing p.lead{font-size:13px}.landing #laracroft{background-size:contain}.landing #laracroft .container-fluid{padding:4rem 0}.landing button{white-space:normal!important;word-wrap:break-word!important;padding:6px 12px!important;line-height:1.42!important;border-radius:4px!important}.landing #feature pre{width:100%}}@media screen and (max-width:540px){.landing #laracroft{background-size:540px 342px}}@media screen and (max-width:480px){.landing h1{font-size:26px}.landing h2{font-size:20px}.landing p.lead{font-size:12px}.landing #laracroft{background-size:480px 300px}article img{display:block;width:100%;max-width:100%;height:auto}}@-webkit-keyframes passing-through{0%{opacity:0;-webkit-transform:translateY(40px);-moz-transform:translateY(40px);-ms-transform:translateY(40px);-o-transform:translateY(40px);transform:translateY(40px)}30%,70%{opacity:1;-webkit-transform:translateY(0);-moz-transform:translateY(0);-ms-transform:translateY(0);-o-transform:translateY(0);transform:translateY(0)}100%{opacity:0;-webkit-transform:translateY(-40px);-moz-transform:translateY(-40px);-ms-transform:translateY(-40px);-o-transform:translateY(-40px);transform:translateY(-40px)}}@-moz-keyframes passing-through{0%{opacity:0;-webkit-transform:translateY(40px);-moz-transform:translateY(40px);-ms-transform:translateY(40px);-o-transform:translateY(40px);transform:translateY(40px)}30%,70%{opacity:1;-webkit-transform:translateY(0);-moz-transform:translateY(0);-ms-transform:translateY(0);-o-transform:translateY(0);transform:translateY(0)}100%{opacity:0;-webkit-transform:translateY(-40px);-moz-transform:translateY(-40px);-ms-transform:translateY(-40px);-o-transform:translateY(-40px);transform:translateY(-40px)}}@keyframes passing-through{0%{opacity:0;-webkit-transform:translateY(40px);-moz-transform:translateY(40px);-ms-transform:translateY(40px);-o-transform:translateY(40px);transform:translateY(40px)}30%,70%{opacity:1;-webkit-transform:translateY(0);-moz-transform:translateY(0);-ms-transform:translateY(0);-o-transform:translateY(0);transform:translateY(0)}100%{opacity:0;-webkit-transform:translateY(-40px);-moz-transform:translateY(-40px);-ms-transform:translateY(-40px);-o-transform:translateY(-40px);transform:translateY(-40px)}}@-webkit-keyframes slide-in{0%{opacity:0;-webkit-transform:translateY(40px);-moz-transform:translateY(40px);-ms-transform:translateY(40px);-o-transform:translateY(40px);transform:translateY(40px)}30%{opacity:1;-webkit-transform:translateY(0);-moz-transform:translateY(0);-ms-transform:translateY(0);-o-transform:translateY(0);transform:translateY(0)}}@-moz-keyframes slide-in{0%{opacity:0;-webkit-transform:translateY(40px);-moz-transform:translateY(40px);-ms-transform:translateY(40px);-o-transform:translateY(40px);transform:translateY(40px)}30%{opacity:1;-webkit-transform:translateY(0);-moz-transform:translateY(0);-ms-transform:translateY(0);-o-transform:translateY(0);transform:translateY(0)}}@keyframes slide-in{0%{opacity:0;-webkit-transform:translateY(40px);-moz-transform:translateY(40px);-ms-transform:translateY(40px);-o-transform:translateY(40px);transform:translateY(40px)}30%{opacity:1;-webkit-transform:translateY(0);-moz-transform:translateY(0);-ms-transform:translateY(0);-o-transform:translateY(0);transform:translateY(0)}}@-webkit-keyframes pulse{0%,20%{-webkit-transform:scale(1);-moz-transform:scale(1);-ms-transform:scale(1);-o-transform:scale(1);transform:scale(1)}10%{-webkit-transform:scale(1.1);-moz-transform:scale(1.1);-ms-transform:scale(1.1);-o-transform:scale(1.1);transform:scale(1.1)}}@-moz-keyframes pulse{0%,20%{-webkit-transform:scale(1);-moz-transform:scale(1);-ms-transform:scale(1);-o-transform:scale(1);transform:scale(1)}10%{-webkit-transform:scale(1.1);-moz-transform:scale(1.1);-ms-transform:scale(1.1);-o-transform:scale(1.1);transform:scale(1.1)}}@keyframes pulse{0%,20%{-webkit-transform:scale(1);-moz-transform:scale(1);-ms-transform:scale(1);-o-transform:scale(1);transform:scale(1)}10%{-webkit-transform:scale(1.1);-moz-transform:scale(1.1);-ms-transform:scale(1.1);-o-transform:scale(1.1);transform:scale(1.1)}}.dropzone,.dropzone *{box-sizing:border-box}.dropzone{min-height:150px;border:2px solid rgba(0,0,0,.3);background:#fff;padding:20px}.dropzone.dz-clickable *{cursor:default}.dropzone.dz-clickable .dz-message,.dropzone.dz-clickable .dz-message *{cursor:pointer}.dropzone.dz-started .dz-message{display:none}.dropzone.dz-drag-hover{border-style:solid}.dropzone.dz-drag-hover .dz-message{opacity:.5}.dropzone .dz-preview.dz-file-preview .dz-details,.dropzone .dz-preview:hover .dz-details{opacity:1}.dropzone .dz-message{text-align:center;margin:2em 0}.dropzone .dz-preview{position:relative;display:inline-block;vertical-align:top;margin:16px;min-height:100px}.dropzone .dz-preview:hover{z-index:1000}.dropzone .dz-preview.dz-file-preview .dz-image{border-radius:20px;background:#999;background:linear-gradient(to bottom,#eee,#ddd)}.dropzone .dz-preview.dz-image-preview{background:#fff}.dropzone .dz-preview.dz-image-preview .dz-details{-webkit-transition:opacity .2s linear;-moz-transition:opacity .2s linear;-ms-transition:opacity .2s linear;-o-transition:opacity .2s linear;transition:opacity .2s linear}.dropzone .dz-preview .dz-remove{font-size:14px;text-align:center;display:block;cursor:pointer;border:none}.dropzone .dz-preview .dz-remove:hover{text-decoration:underline}.dropzone .dz-preview .dz-details{z-index:20;position:absolute;top:0;left:0;opacity:0;font-size:13px;min-width:100%;max-width:100%;padding:2em 1em;text-align:center;color:rgba(0,0,0,.9);line-height:150%}.dropzone .dz-preview .dz-details .dz-size{margin-bottom:1em;font-size:16px}.dropzone .dz-preview .dz-details .dz-filename{white-space:nowrap}.dropzone .dz-preview .dz-details .dz-filename:hover span{border:1px solid rgba(200,200,200,.8);background-color:rgba(255,255,255,.8)}.dropzone .dz-preview .dz-details .dz-filename:not(:hover){overflow:hidden;text-overflow:ellipsis}.dropzone .dz-preview .dz-details .dz-filename:not(:hover) span{border:1px solid transparent}.dropzone .dz-preview .dz-details .dz-filename span,.dropzone .dz-preview .dz-details .dz-size span{background-color:rgba(255,255,255,.4);padding:0 .4em;border-radius:3px}.dropzone .dz-preview:hover .dz-image img{-webkit-transform:scale(1.05,1.05);-moz-transform:scale(1.05,1.05);-ms-transform:scale(1.05,1.05);-o-transform:scale(1.05,1.05);transform:scale(1.05,1.05);-webkit-filter:blur(8px);filter:blur(8px)}.dropzone .dz-preview .dz-image{border-radius:20px;overflow:hidden;width:120px;height:120px;position:relative;display:block;z-index:10}.dropzone .dz-preview .dz-image img{display:block}.dropzone .dz-preview.dz-success .dz-success-mark{-webkit-animation:passing-through 3s cubic-bezier(.77,0,.175,1);-moz-animation:passing-through 3s cubic-bezier(.77,0,.175,1);-ms-animation:passing-through 3s cubic-bezier(.77,0,.175,1);-o-animation:passing-through 3s cubic-bezier(.77,0,.175,1);animation:passing-through 3s cubic-bezier(.77,0,.175,1)}.dropzone .dz-preview.dz-error .dz-error-mark{opacity:1;-webkit-animation:slide-in 3s cubic-bezier(.77,0,.175,1);-moz-animation:slide-in 3s cubic-bezier(.77,0,.175,1);-ms-animation:slide-in 3s cubic-bezier(.77,0,.175,1);-o-animation:slide-in 3s cubic-bezier(.77,0,.175,1);animation:slide-in 3s cubic-bezier(.77,0,.175,1)}.dropzone .dz-preview .dz-error-mark,.dropzone .dz-preview .dz-success-mark{pointer-events:none;opacity:0;z-index:500;position:absolute;display:block;top:50%;left:50%;margin-left:-27px;margin-top:-27px}.dropzone .dz-preview .dz-error-mark svg,.dropzone .dz-preview .dz-success-mark svg{display:block;width:54px;height:54px}.dropzone .dz-preview.dz-processing .dz-progress{opacity:1;-webkit-transition:all .2s linear;-moz-transition:all .2s linear;-ms-transition:all .2s linear;-o-transition:all .2s linear;transition:all .2s linear}.dropzone .dz-preview.dz-complete .dz-progress{opacity:0;-webkit-transition:opacity .4s ease-in;-moz-transition:opacity .4s ease-in;-ms-transition:opacity .4s ease-in;-o-transition:opacity .4s ease-in;transition:opacity .4s ease-in}.dropzone .dz-preview:not(.dz-processing) .dz-progress{-webkit-animation:pulse 6s ease infinite;-moz-animation:pulse 6s ease infinite;-ms-animation:pulse 6s ease infinite;-o-animation:pulse 6s ease infinite;animation:pulse 6s ease infinite}.dropzone .dz-preview .dz-progress{opacity:1;z-index:1000;pointer-events:none;position:absolute;height:16px;left:50%;top:50%;margin-top:-8px;width:80px;margin-left:-40px;background:rgba(255,255,255,.9);-webkit-transform:scale(1);border-radius:8px;overflow:hidden}.dropzone .dz-preview .dz-progress .dz-upload{background:#333;background:linear-gradient(to bottom,#666,#444);position:absolute;top:0;left:0;bottom:0;width:0;-webkit-transition:width .3s ease-in-out;-moz-transition:width .3s ease-in-out;-ms-transition:width .3s ease-in-out;-o-transition:width .3s ease-in-out;transition:width .3s ease-in-out}.dropzone .dz-preview.dz-error .dz-error-message{display:block}.dropzone .dz-preview.dz-error:hover .dz-error-message{opacity:1;pointer-events:auto}.dropzone .dz-preview .dz-error-message{pointer-events:none;z-index:1000;position:absolute;display:block;display:none;opacity:0;-webkit-transition:opacity .3s ease;-moz-transition:opacity .3s ease;-ms-transition:opacity .3s ease;-o-transition:opacity .3s ease;transition:opacity .3s ease;border-radius:8px;font-size:13px;top:130px;left:-10px;width:140px;background:#be2626;background:linear-gradient(to bottom,#be2626,#a92222);padding:.5em 1.2em;color:#fff}.dropzone .dz-preview .dz-error-message:after{content:'';position:absolute;top:-6px;left:64px;width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid #be2626}.hljs{display:block;padding:.5em;background:#12100f;color:#EBD1B7}.hljs-comment,.hljs-javadoc,.hljs-template_comment{color:#7A7267}.hljs-keyword,.hljs-request,.hljs-status,.nginx .hljs-title,.ruby .hljs-function .hljs-keyword{color:#DB784D}.hljs-function .hljs-keyword,.hljs-list .hljs-title,.hljs-sub .hljs-keyword,.method{color:#60A365}.apache .hljs-cbracket,.coffeescript .hljs-attribute,.hljs-attr_selector,.hljs-cdata,.hljs-date,.hljs-filter .hljs-argument,.hljs-string,.hljs-tag .hljs-value,.tex .hljs-command{color:#F8BB39}.hljs-subst{color:#DAEFA3}.hljs-regexp{color:#E9C062}.hljs-decorator,.hljs-pi,.hljs-prompt,.hljs-shebang,.hljs-sub .hljs-identifier,.hljs-tag,.hljs-tag .hljs-keyword,.hljs-title{color:#DB784D}.hljs-number,.hljs-symbol,.ruby .hljs-symbol .hljs-string{color:#F8BB39}.clojure .hljs-attribute,.hljs-params,.hljs-variable{color:#95CC5E}.css .hljs-tag,.hljs-pseudo,.hljs-rules .hljs-property,.tex .hljs-special{color:#DB784D}.css .hljs-class,.hljs-rules .hljs-keyword{color:#95CC5E}.hljs-rules .hljs-value{color:#DB784D}.css .hljs-id{color:#8B98AB}.apache .hljs-sqbracket,.hljs-annotation,.nginx .hljs-built_in{color:#9B859D}.hljs-pragma,.hljs-preprocessor{color:#8996A8}.css .hljs-value .hljs-number,.hljs-hexcolor{color:#DD7B3B}.css .hljs-function{color:#DAD085}.diff .hljs-header,.hljs-chunk,.tex .hljs-formula{background-color:#0E2231;color:#F8F8F8;font-style:italic}.diff .hljs-change{background-color:#4A410D;color:#F8F8F8}.hljs-addition{background-color:#253B22;color:#F8F8F8}.hljs-deletion{background-color:#420E09;color:#F8F8F8}.coffeescript .javascript,.javascript .xml,.tex .hljs-formula,.xml .css,.xml .hljs-cdata,.xml .javascript,.xml .vbscript{opacity:.5}
================================================
FILE: public/build/js/app-038b8ad709.js
================================================
/*!
* jQuery JavaScript Library v2.1.4
* http://jquery.com/
*
* Includes Sizzle.js
* http://sizzlejs.com/
*
* Copyright 2005, 2014 jQuery Foundation, Inc. and other contributors
* Released under the MIT license
* http://jquery.org/license
*
* Date: 2015-04-28T16:01Z
*/
(function( global, factory ) {
if ( typeof module === "object" && typeof module.exports === "object" ) {
// For CommonJS and CommonJS-like environments where a proper `window`
// is present, execute the factory and get jQuery.
// For environments that do not have a `window` with a `document`
// (such as Node.js), expose a factory as module.exports.
// This accentuates the need for the creation of a real `window`.
// e.g. var jQuery = require("jquery")(window);
// See ticket #14549 for more info.
module.exports = global.document ?
factory( global, true ) :
function( w ) {
if ( !w.document ) {
throw new Error( "jQuery requires a window with a document" );
}
return factory( w );
};
} else {
factory( global );
}
// Pass this if window is not defined yet
}(typeof window !== "undefined" ? window : this, function( window, noGlobal ) {
// Support: Firefox 18+
// Can't be in strict mode, several libs including ASP.NET trace
// the stack via arguments.caller.callee and Firefox dies if
// you try to trace through "use strict" call chains. (#13335)
//
var arr = [];
var slice = arr.slice;
var concat = arr.concat;
var push = arr.push;
var indexOf = arr.indexOf;
var class2type = {};
var toString = class2type.toString;
var hasOwn = class2type.hasOwnProperty;
var support = {};
var
// Use the correct document accordingly with window argument (sandbox)
document = window.document,
version = "2.1.4",
// Define a local copy of jQuery
jQuery = function( selector, context ) {
// The jQuery object is actually just the init constructor 'enhanced'
// Need init if jQuery is called (just allow error to be thrown if not included)
return new jQuery.fn.init( selector, context );
},
// Support: Android<4.1
// Make sure we trim BOM and NBSP
rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,
// Matches dashed string for camelizing
rmsPrefix = /^-ms-/,
rdashAlpha = /-([\da-z])/gi,
// Used by jQuery.camelCase as callback to replace()
fcamelCase = function( all, letter ) {
return letter.toUpperCase();
};
jQuery.fn = jQuery.prototype = {
// The current version of jQuery being used
jquery: version,
constructor: jQuery,
// Start with an empty selector
selector: "",
// The default length of a jQuery object is 0
length: 0,
toArray: function() {
return slice.call( this );
},
// Get the Nth element in the matched element set OR
// Get the whole matched element set as a clean array
get: function( num ) {
return num != null ?
// Return just the one element from the set
( num < 0 ? this[ num + this.length ] : this[ num ] ) :
// Return all the elements in a clean array
slice.call( this );
},
// Take an array of elements and push it onto the stack
// (returning the new matched element set)
pushStack: function( elems ) {
// Build a new jQuery matched element set
var ret = jQuery.merge( this.constructor(), elems );
// Add the old object onto the stack (as a reference)
ret.prevObject = this;
ret.context = this.context;
// Return the newly-formed element set
return ret;
},
// Execute a callback for every element in the matched set.
// (You can seed the arguments with an array of args, but this is
// only used internally.)
each: function( callback, args ) {
return jQuery.each( this, callback, args );
},
map: function( callback ) {
return this.pushStack( jQuery.map(this, function( elem, i ) {
return callback.call( elem, i, elem );
}));
},
slice: function() {
return this.pushStack( slice.apply( this, arguments ) );
},
first: function() {
return this.eq( 0 );
},
last: function() {
return this.eq( -1 );
},
eq: function( i ) {
var len = this.length,
j = +i + ( i < 0 ? len : 0 );
return this.pushStack( j >= 0 && j < len ? [ this[j] ] : [] );
},
end: function() {
return this.prevObject || this.constructor(null);
},
// For internal use only.
// Behaves like an Array's method, not like a jQuery method.
push: push,
sort: arr.sort,
splice: arr.splice
};
jQuery.extend = jQuery.fn.extend = function() {
var options, name, src, copy, copyIsArray, clone,
target = arguments[0] || {},
i = 1,
length = arguments.length,
deep = false;
// Handle a deep copy situation
if ( typeof target === "boolean" ) {
deep = target;
// Skip the boolean and the target
target = arguments[ i ] || {};
i++;
}
// Handle case when target is a string or something (possible in deep copy)
if ( typeof target !== "object" && !jQuery.isFunction(target) ) {
target = {};
}
// Extend jQuery itself if only one argument is passed
if ( i === length ) {
target = this;
i--;
}
for ( ; i < length; i++ ) {
// Only deal with non-null/undefined values
if ( (options = arguments[ i ]) != null ) {
// Extend the base object
for ( name in options ) {
src = target[ name ];
copy = options[ name ];
// Prevent never-ending loop
if ( target === copy ) {
continue;
}
// Recurse if we're merging plain objects or arrays
if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) {
if ( copyIsArray ) {
copyIsArray = false;
clone = src && jQuery.isArray(src) ? src : [];
} else {
clone = src && jQuery.isPlainObject(src) ? src : {};
}
// Never move original objects, clone them
target[ name ] = jQuery.extend( deep, clone, copy );
// Don't bring in undefined values
} else if ( copy !== undefined ) {
target[ name ] = copy;
}
}
}
}
// Return the modified object
return target;
};
jQuery.extend({
// Unique for each copy of jQuery on the page
expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ),
// Assume jQuery is ready without the ready module
isReady: true,
error: function( msg ) {
throw new Error( msg );
},
noop: function() {},
isFunction: function( obj ) {
return jQuery.type(obj) === "function";
},
isArray: Array.isArray,
isWindow: function( obj ) {
return obj != null && obj === obj.window;
},
isNumeric: function( obj ) {
// parseFloat NaNs numeric-cast false positives (null|true|false|"")
// ...but misinterprets leading-number strings, particularly hex literals ("0x...")
// subtraction forces infinities to NaN
// adding 1 corrects loss of precision from parseFloat (#15100)
return !jQuery.isArray( obj ) && (obj - parseFloat( obj ) + 1) >= 0;
},
isPlainObject: function( obj ) {
// Not plain objects:
// - Any object or value whose internal [[Class]] property is not "[object Object]"
// - DOM nodes
// - window
if ( jQuery.type( obj ) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) {
return false;
}
if ( obj.constructor &&
!hasOwn.call( obj.constructor.prototype, "isPrototypeOf" ) ) {
return false;
}
// If the function hasn't returned already, we're confident that
// |obj| is a plain object, created by {} or constructed with new Object
return true;
},
isEmptyObject: function( obj ) {
var name;
for ( name in obj ) {
return false;
}
return true;
},
type: function( obj ) {
if ( obj == null ) {
return obj + "";
}
// Support: Android<4.0, iOS<6 (functionish RegExp)
return typeof obj === "object" || typeof obj === "function" ?
class2type[ toString.call(obj) ] || "object" :
typeof obj;
},
// Evaluates a script in a global context
globalEval: function( code ) {
var script,
indirect = eval;
code = jQuery.trim( code );
if ( code ) {
// If the code includes a valid, prologue position
// strict mode pragma, execute code by injecting a
// script tag into the document.
if ( code.indexOf("use strict") === 1 ) {
script = document.createElement("script");
script.text = code;
document.head.appendChild( script ).parentNode.removeChild( script );
} else {
// Otherwise, avoid the DOM node creation, insertion
// and removal by using an indirect global eval
indirect( code );
}
}
},
// Convert dashed to camelCase; used by the css and data modules
// Support: IE9-11+
// Microsoft forgot to hump their vendor prefix (#9572)
camelCase: function( string ) {
return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase );
},
nodeName: function( elem, name ) {
return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase();
},
// args is for internal usage only
each: function( obj, callback, args ) {
var value,
i = 0,
length = obj.length,
isArray = isArraylike( obj );
if ( args ) {
if ( isArray ) {
for ( ; i < length; i++ ) {
value = callback.apply( obj[ i ], args );
if ( value === false ) {
break;
}
}
} else {
for ( i in obj ) {
value = callback.apply( obj[ i ], args );
if ( value === false ) {
break;
}
}
}
// A special, fast, case for the most common use of each
} else {
if ( isArray ) {
for ( ; i < length; i++ ) {
value = callback.call( obj[ i ], i, obj[ i ] );
if ( value === false ) {
break;
}
}
} else {
for ( i in obj ) {
value = callback.call( obj[ i ], i, obj[ i ] );
if ( value === false ) {
break;
}
}
}
}
return obj;
},
// Support: Android<4.1
trim: function( text ) {
return text == null ?
"" :
( text + "" ).replace( rtrim, "" );
},
// results is for internal usage only
makeArray: function( arr, results ) {
var ret = results || [];
if ( arr != null ) {
if ( isArraylike( Object(arr) ) ) {
jQuery.merge( ret,
typeof arr === "string" ?
[ arr ] : arr
);
} else {
push.call( ret, arr );
}
}
return ret;
},
inArray: function( elem, arr, i ) {
return arr == null ? -1 : indexOf.call( arr, elem, i );
},
merge: function( first, second ) {
var len = +second.length,
j = 0,
i = first.length;
for ( ; j < len; j++ ) {
first[ i++ ] = second[ j ];
}
first.length = i;
return first;
},
grep: function( elems, callback, invert ) {
var callbackInverse,
matches = [],
i = 0,
length = elems.length,
callbackExpect = !invert;
// Go through the array, only saving the items
// that pass the validator function
for ( ; i < length; i++ ) {
callbackInverse = !callback( elems[ i ], i );
if ( callbackInverse !== callbackExpect ) {
matches.push( elems[ i ] );
}
}
return matches;
},
// arg is for internal usage only
map: function( elems, callback, arg ) {
var value,
i = 0,
length = elems.length,
isArray = isArraylike( elems ),
ret = [];
// Go through the array, translating each of the items to their new values
if ( isArray ) {
for ( ; i < length; i++ ) {
value = callback( elems[ i ], i, arg );
if ( value != null ) {
ret.push( value );
}
}
// Go through every key on the object,
} else {
for ( i in elems ) {
value = callback( elems[ i ], i, arg );
if ( value != null ) {
ret.push( value );
}
}
}
// Flatten any nested arrays
return concat.apply( [], ret );
},
// A global GUID counter for objects
guid: 1,
// Bind a function to a context, optionally partially applying any
// arguments.
proxy: function( fn, context ) {
var tmp, args, proxy;
if ( typeof context === "string" ) {
tmp = fn[ context ];
context = fn;
fn = tmp;
}
// Quick check to determine if target is callable, in the spec
// this throws a TypeError, but we will just return undefined.
if ( !jQuery.isFunction( fn ) ) {
return undefined;
}
// Simulated bind
args = slice.call( arguments, 2 );
proxy = function() {
return fn.apply( context || this, args.concat( slice.call( arguments ) ) );
};
// Set the guid of unique handler to the same of original handler, so it can be removed
proxy.guid = fn.guid = fn.guid || jQuery.guid++;
return proxy;
},
now: Date.now,
// jQuery.support is not used in Core but other projects attach their
// properties to it so it needs to exist.
support: support
});
// Populate the class2type map
jQuery.each("Boolean Number String Function Array Date RegExp Object Error".split(" "), function(i, name) {
class2type[ "[object " + name + "]" ] = name.toLowerCase();
});
function isArraylike( obj ) {
// Support: iOS 8.2 (not reproducible in simulator)
// `in` check used to prevent JIT error (gh-2145)
// hasOwn isn't used here due to false negatives
// regarding Nodelist length in IE
var length = "length" in obj && obj.length,
type = jQuery.type( obj );
if ( type === "function" || jQuery.isWindow( obj ) ) {
return false;
}
if ( obj.nodeType === 1 && length ) {
return true;
}
return type === "array" || length === 0 ||
typeof length === "number" && length > 0 && ( length - 1 ) in obj;
}
var Sizzle =
/*!
* Sizzle CSS Selector Engine v2.2.0-pre
* http://sizzlejs.com/
*
* Copyright 2008, 2014 jQuery Foundation, Inc. and other contributors
* Released under the MIT license
* http://jquery.org/license
*
* Date: 2014-12-16
*/
(function( window ) {
var i,
support,
Expr,
getText,
isXML,
tokenize,
compile,
select,
outermostContext,
sortInput,
hasDuplicate,
// Local document vars
setDocument,
document,
docElem,
documentIsHTML,
rbuggyQSA,
rbuggyMatches,
matches,
contains,
// Instance-specific data
expando = "sizzle" + 1 * new Date(),
preferredDoc = window.document,
dirruns = 0,
done = 0,
classCache = createCache(),
tokenCache = createCache(),
compilerCache = createCache(),
sortOrder = function( a, b ) {
if ( a === b ) {
hasDuplicate = true;
}
return 0;
},
// General-purpose constants
MAX_NEGATIVE = 1 << 31,
// Instance methods
hasOwn = ({}).hasOwnProperty,
arr = [],
pop = arr.pop,
push_native = arr.push,
push = arr.push,
slice = arr.slice,
// Use a stripped-down indexOf as it's faster than native
// http://jsperf.com/thor-indexof-vs-for/5
indexOf = function( list, elem ) {
var i = 0,
len = list.length;
for ( ; i < len; i++ ) {
if ( list[i] === elem ) {
return i;
}
}
return -1;
},
booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",
// Regular expressions
// Whitespace characters http://www.w3.org/TR/css3-selectors/#whitespace
whitespace = "[\\x20\\t\\r\\n\\f]",
// http://www.w3.org/TR/css3-syntax/#characters
characterEncoding = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",
// Loosely modeled on CSS identifier characters
// An unquoted value should be a CSS identifier http://www.w3.org/TR/css3-selectors/#attribute-selectors
// Proper syntax: http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier
identifier = characterEncoding.replace( "w", "w#" ),
// Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors
attributes = "\\[" + whitespace + "*(" + characterEncoding + ")(?:" + whitespace +
// Operator (capture 2)
"*([*^$|!~]?=)" + whitespace +
// "Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]"
"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + whitespace +
"*\\]",
pseudos = ":(" + characterEncoding + ")(?:\\((" +
// To reduce the number of selectors needing tokenize in the preFilter, prefer arguments:
// 1. quoted (capture 3; capture 4 or capture 5)
"('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" +
// 2. simple (capture 6)
"((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" +
// 3. anything else (capture 2)
".*" +
")\\)|)",
// Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter
rwhitespace = new RegExp( whitespace + "+", "g" ),
rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ),
rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ),
rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ),
rattributeQuotes = new RegExp( "=" + whitespace + "*([^\\]'\"]*?)" + whitespace + "*\\]", "g" ),
rpseudo = new RegExp( pseudos ),
ridentifier = new RegExp( "^" + identifier + "$" ),
matchExpr = {
"ID": new RegExp( "^#(" + characterEncoding + ")" ),
"CLASS": new RegExp( "^\\.(" + characterEncoding + ")" ),
"TAG": new RegExp( "^(" + characterEncoding.replace( "w", "w*" ) + ")" ),
"ATTR": new RegExp( "^" + attributes ),
"PSEUDO": new RegExp( "^" + pseudos ),
"CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace +
"*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace +
"*(\\d+)|))" + whitespace + "*\\)|)", "i" ),
"bool": new RegExp( "^(?:" + booleans + ")$", "i" ),
// For use in libraries implementing .is()
// We use this for POS matching in `select`
"needsContext": new RegExp( "^" + whitespace + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" +
whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" )
},
rinputs = /^(?:input|select|textarea|button)$/i,
rheader = /^h\d$/i,
rnative = /^[^{]+\{\s*\[native \w/,
// Easily-parseable/retrievable ID or TAG or CLASS selectors
rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,
rsibling = /[+~]/,
rescape = /'|\\/g,
// CSS escapes http://www.w3.org/TR/CSS21/syndata.html#escaped-characters
runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ),
funescape = function( _, escaped, escapedWhitespace ) {
var high = "0x" + escaped - 0x10000;
// NaN means non-codepoint
// Support: Firefox<24
// Workaround erroneous numeric interpretation of +"0x"
return high !== high || escapedWhitespace ?
escaped :
high < 0 ?
// BMP codepoint
String.fromCharCode( high + 0x10000 ) :
// Supplemental Plane codepoint (surrogate pair)
String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 );
},
// Used for iframes
// See setDocument()
// Removing the function wrapper causes a "Permission Denied"
// error in IE
unloadHandler = function() {
setDocument();
};
// Optimize for push.apply( _, NodeList )
try {
push.apply(
(arr = slice.call( preferredDoc.childNodes )),
preferredDoc.childNodes
);
// Support: Android<4.0
// Detect silently failing push.apply
arr[ preferredDoc.childNodes.length ].nodeType;
} catch ( e ) {
push = { apply: arr.length ?
// Leverage slice if possible
function( target, els ) {
push_native.apply( target, slice.call(els) );
} :
// Support: IE<9
// Otherwise append directly
function( target, els ) {
var j = target.length,
i = 0;
// Can't trust NodeList.length
while ( (target[j++] = els[i++]) ) {}
target.length = j - 1;
}
};
}
function Sizzle( selector, context, results, seed ) {
var match, elem, m, nodeType,
// QSA vars
i, groups, old, nid, newContext, newSelector;
if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) {
setDocument( context );
}
context = context || document;
results = results || [];
nodeType = context.nodeType;
if ( typeof selector !== "string" || !selector ||
nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) {
return results;
}
if ( !seed && documentIsHTML ) {
// Try to shortcut find operations when possible (e.g., not under DocumentFragment)
if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) {
// Speed-up: Sizzle("#ID")
if ( (m = match[1]) ) {
if ( nodeType === 9 ) {
elem = context.getElementById( m );
// Check parentNode to catch when Blackberry 4.6 returns
// nodes that are no longer in the document (jQuery #6963)
if ( elem && elem.parentNode ) {
// Handle the case where IE, Opera, and Webkit return items
// by name instead of ID
if ( elem.id === m ) {
results.push( elem );
return results;
}
} else {
return results;
}
} else {
// Context is not a document
if ( context.ownerDocument && (elem = context.ownerDocument.getElementById( m )) &&
contains( context, elem ) && elem.id === m ) {
results.push( elem );
return results;
}
}
// Speed-up: Sizzle("TAG")
} else if ( match[2] ) {
push.apply( results, context.getElementsByTagName( selector ) );
return results;
// Speed-up: Sizzle(".CLASS")
} else if ( (m = match[3]) && support.getElementsByClassName ) {
push.apply( results, context.getElementsByClassName( m ) );
return results;
}
}
// QSA path
if ( support.qsa && (!rbuggyQSA || !rbuggyQSA.test( selector )) ) {
nid = old = expando;
newContext = context;
newSelector = nodeType !== 1 && selector;
// qSA works strangely on Element-rooted queries
// We can work around this by specifying an extra ID on the root
// and working up from there (Thanks to Andrew Dupont for the technique)
// IE 8 doesn't work on object elements
if ( nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) {
groups = tokenize( selector );
if ( (old = context.getAttribute("id")) ) {
nid = old.replace( rescape, "\\$&" );
} else {
context.setAttribute( "id", nid );
}
nid = "[id='" + nid + "'] ";
i = groups.length;
while ( i-- ) {
groups[i] = nid + toSelector( groups[i] );
}
newContext = rsibling.test( selector ) && testContext( context.parentNode ) || context;
newSelector = groups.join(",");
}
if ( newSelector ) {
try {
push.apply( results,
newContext.querySelectorAll( newSelector )
);
return results;
} catch(qsaError) {
} finally {
if ( !old ) {
context.removeAttribute("id");
}
}
}
}
}
// All others
return select( selector.replace( rtrim, "$1" ), context, results, seed );
}
/**
* Create key-value caches of limited size
* @returns {Function(string, Object)} Returns the Object data after storing it on itself with
* property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength)
* deleting the oldest entry
*/
function createCache() {
var keys = [];
function cache( key, value ) {
// Use (key + " ") to avoid collision with native prototype properties (see Issue #157)
if ( keys.push( key + " " ) > Expr.cacheLength ) {
// Only keep the most recent entries
delete cache[ keys.shift() ];
}
return (cache[ key + " " ] = value);
}
return cache;
}
/**
* Mark a function for special use by Sizzle
* @param {Function} fn The function to mark
*/
function markFunction( fn ) {
fn[ expando ] = true;
return fn;
}
/**
* Support testing using an element
* @param {Function} fn Passed the created div and expects a boolean result
*/
function assert( fn ) {
var div = document.createElement("div");
try {
return !!fn( div );
} catch (e) {
return false;
} finally {
// Remove from its parent by default
if ( div.parentNode ) {
div.parentNode.removeChild( div );
}
// release memory in IE
div = null;
}
}
/**
* Adds the same handler for all of the specified attrs
* @param {String} attrs Pipe-separated list of attributes
* @param {Function} handler The method that will be applied
*/
function addHandle( attrs, handler ) {
var arr = attrs.split("|"),
i = attrs.length;
while ( i-- ) {
Expr.attrHandle[ arr[i] ] = handler;
}
}
/**
* Checks document order of two siblings
* @param {Element} a
* @param {Element} b
* @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b
*/
function siblingCheck( a, b ) {
var cur = b && a,
diff = cur && a.nodeType === 1 && b.nodeType === 1 &&
( ~b.sourceIndex || MAX_NEGATIVE ) -
( ~a.sourceIndex || MAX_NEGATIVE );
// Use IE sourceIndex if available on both nodes
if ( diff ) {
return diff;
}
// Check if b follows a
if ( cur ) {
while ( (cur = cur.nextSibling) ) {
if ( cur === b ) {
return -1;
}
}
}
return a ? 1 : -1;
}
/**
* Returns a function to use in pseudos for input types
* @param {String} type
*/
function createInputPseudo( type ) {
return function( elem ) {
var name = elem.nodeName.toLowerCase();
return name === "input" && elem.type === type;
};
}
/**
* Returns a function to use in pseudos for buttons
* @param {String} type
*/
function createButtonPseudo( type ) {
return function( elem ) {
var name = elem.nodeName.toLowerCase();
return (name === "input" || name === "button") && elem.type === type;
};
}
/**
* Returns a function to use in pseudos for positionals
* @param {Function} fn
*/
function createPositionalPseudo( fn ) {
return markFunction(function( argument ) {
argument = +argument;
return markFunction(function( seed, matches ) {
var j,
matchIndexes = fn( [], seed.length, argument ),
i = matchIndexes.length;
// Match elements found at the specified indexes
while ( i-- ) {
if ( seed[ (j = matchIndexes[i]) ] ) {
seed[j] = !(matches[j] = seed[j]);
}
}
});
});
}
/**
* Checks a node for validity as a Sizzle context
* @param {Element|Object=} context
* @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value
*/
function testContext( context ) {
return context && typeof context.getElementsByTagName !== "undefined" && context;
}
// Expose support vars for convenience
support = Sizzle.support = {};
/**
* Detects XML nodes
* @param {Element|Object} elem An element or a document
* @returns {Boolean} True iff elem is a non-HTML XML node
*/
isXML = Sizzle.isXML = function( elem ) {
// documentElement is verified for cases where it doesn't yet exist
// (such as loading iframes in IE - #4833)
var documentElement = elem && (elem.ownerDocument || elem).documentElement;
return documentElement ? documentElement.nodeName !== "HTML" : false;
};
/**
* Sets document-related variables once based on the current document
* @param {Element|Object} [doc] An element or document object to use to set the document
* @returns {Object} Returns the current document
*/
setDocument = Sizzle.setDocument = function( node ) {
var hasCompare, parent,
doc = node ? node.ownerDocument || node : preferredDoc;
// If no document and documentElement is available, return
if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) {
return document;
}
// Set our document
document = doc;
docElem = doc.documentElement;
parent = doc.defaultView;
// Support: IE>8
// If iframe document is assigned to "document" variable and if iframe has been reloaded,
// IE will throw "permission denied" error when accessing "document" variable, see jQuery #13936
// IE6-8 do not support the defaultView property so parent will be undefined
if ( parent && parent !== parent.top ) {
// IE11 does not have attachEvent, so all must suffer
if ( parent.addEventListener ) {
parent.addEventListener( "unload", unloadHandler, false );
} else if ( parent.attachEvent ) {
parent.attachEvent( "onunload", unloadHandler );
}
}
/* Support tests
---------------------------------------------------------------------- */
documentIsHTML = !isXML( doc );
/* Attributes
---------------------------------------------------------------------- */
// Support: IE<8
// Verify that getAttribute really returns attributes and not properties
// (excepting IE8 booleans)
support.attributes = assert(function( div ) {
div.className = "i";
return !div.getAttribute("className");
});
/* getElement(s)By*
---------------------------------------------------------------------- */
// Check if getElementsByTagName("*") returns only elements
support.getElementsByTagName = assert(function( div ) {
div.appendChild( doc.createComment("") );
return !div.getElementsByTagName("*").length;
});
// Support: IE<9
support.getElementsByClassName = rnative.test( doc.getElementsByClassName );
// Support: IE<10
// Check if getElementById returns elements by name
// The broken getElementById methods don't pick up programatically-set names,
// so use a roundabout getElementsByName test
support.getById = assert(function( div ) {
docElem.appendChild( div ).id = expando;
return !doc.getElementsByName || !doc.getElementsByName( expando ).length;
});
// ID find and filter
if ( support.getById ) {
Expr.find["ID"] = function( id, context ) {
if ( typeof context.getElementById !== "undefined" && documentIsHTML ) {
var m = context.getElementById( id );
// Check parentNode to catch when Blackberry 4.6 returns
// nodes that are no longer in the document #6963
return m && m.parentNode ? [ m ] : [];
}
};
Expr.filter["ID"] = function( id ) {
var attrId = id.replace( runescape, funescape );
return function( elem ) {
return elem.getAttribute("id") === attrId;
};
};
} else {
// Support: IE6/7
// getElementById is not reliable as a find shortcut
delete Expr.find["ID"];
Expr.filter["ID"] = function( id ) {
var attrId = id.replace( runescape, funescape );
return function( elem ) {
var node = typeof elem.getAttributeNode !== "undefined" && elem.getAttributeNode("id");
return node && node.value === attrId;
};
};
}
// Tag
Expr.find["TAG"] = support.getElementsByTagName ?
function( tag, context ) {
if ( typeof context.getElementsByTagName !== "undefined" ) {
return context.getElementsByTagName( tag );
// DocumentFragment nodes don't have gEBTN
} else if ( support.qsa ) {
return context.querySelectorAll( tag );
}
} :
function( tag, context ) {
var elem,
tmp = [],
i = 0,
// By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too
results = context.getElementsByTagName( tag );
// Filter out possible comments
if ( tag === "*" ) {
while ( (elem = results[i++]) ) {
if ( elem.nodeType === 1 ) {
tmp.push( elem );
}
}
return tmp;
}
return results;
};
// Class
Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) {
if ( documentIsHTML ) {
return context.getElementsByClassName( className );
}
};
/* QSA/matchesSelector
---------------------------------------------------------------------- */
// QSA and matchesSelector support
// matchesSelector(:active) reports false when true (IE9/Opera 11.5)
rbuggyMatches = [];
// qSa(:focus) reports false when true (Chrome 21)
// We allow this because of a bug in IE8/9 that throws an error
// whenever `document.activeElement` is accessed on an iframe
// So, we allow :focus to pass through QSA all the time to avoid the IE error
// See http://bugs.jquery.com/ticket/13378
rbuggyQSA = [];
if ( (support.qsa = rnative.test( doc.querySelectorAll )) ) {
// Build QSA regex
// Regex strategy adopted from Diego Perini
assert(function( div ) {
// Select is set to empty string on purpose
// This is to test IE's treatment of not explicitly
// setting a boolean content attribute,
// since its presence should be enough
// http://bugs.jquery.com/ticket/12359
docElem.appendChild( div ).innerHTML = "" +
"" +
"";
// Support: IE8, Opera 11-12.16
// Nothing should be selected when empty strings follow ^= or $= or *=
// The test attribute must be unknown in Opera but "safe" for WinRT
// http://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section
if ( div.querySelectorAll("[msallowcapture^='']").length ) {
rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" );
}
// Support: IE8
// Boolean attributes and "value" are not treated correctly
if ( !div.querySelectorAll("[selected]").length ) {
rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" );
}
// Support: Chrome<29, Android<4.2+, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.7+
if ( !div.querySelectorAll( "[id~=" + expando + "-]" ).length ) {
rbuggyQSA.push("~=");
}
// Webkit/Opera - :checked should return selected option elements
// http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked
// IE8 throws error here and will not see later tests
if ( !div.querySelectorAll(":checked").length ) {
rbuggyQSA.push(":checked");
}
// Support: Safari 8+, iOS 8+
// https://bugs.webkit.org/show_bug.cgi?id=136851
// In-page `selector#id sibing-combinator selector` fails
if ( !div.querySelectorAll( "a#" + expando + "+*" ).length ) {
rbuggyQSA.push(".#.+[+~]");
}
});
assert(function( div ) {
// Support: Windows 8 Native Apps
// The type and name attributes are restricted during .innerHTML assignment
var input = doc.createElement("input");
input.setAttribute( "type", "hidden" );
div.appendChild( input ).setAttribute( "name", "D" );
// Support: IE8
// Enforce case-sensitivity of name attribute
if ( div.querySelectorAll("[name=d]").length ) {
rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" );
}
// FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled)
// IE8 throws error here and will not see later tests
if ( !div.querySelectorAll(":enabled").length ) {
rbuggyQSA.push( ":enabled", ":disabled" );
}
// Opera 10-11 does not throw on post-comma invalid pseudos
div.querySelectorAll("*,:x");
rbuggyQSA.push(",.*:");
});
}
if ( (support.matchesSelector = rnative.test( (matches = docElem.matches ||
docElem.webkitMatchesSelector ||
docElem.mozMatchesSelector ||
docElem.oMatchesSelector ||
docElem.msMatchesSelector) )) ) {
assert(function( div ) {
// Check to see if it's possible to do matchesSelector
// on a disconnected node (IE 9)
support.disconnectedMatch = matches.call( div, "div" );
// This should fail with an exception
// Gecko does not error, returns false instead
matches.call( div, "[s!='']:x" );
rbuggyMatches.push( "!=", pseudos );
});
}
rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join("|") );
rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join("|") );
/* Contains
---------------------------------------------------------------------- */
hasCompare = rnative.test( docElem.compareDocumentPosition );
// Element contains another
// Purposefully does not implement inclusive descendent
// As in, an element does not contain itself
contains = hasCompare || rnative.test( docElem.contains ) ?
function( a, b ) {
var adown = a.nodeType === 9 ? a.documentElement : a,
bup = b && b.parentNode;
return a === bup || !!( bup && bup.nodeType === 1 && (
adown.contains ?
adown.contains( bup ) :
a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16
));
} :
function( a, b ) {
if ( b ) {
while ( (b = b.parentNode) ) {
if ( b === a ) {
return true;
}
}
}
return false;
};
/* Sorting
---------------------------------------------------------------------- */
// Document order sorting
sortOrder = hasCompare ?
function( a, b ) {
// Flag for duplicate removal
if ( a === b ) {
hasDuplicate = true;
return 0;
}
// Sort on method existence if only one input has compareDocumentPosition
var compare = !a.compareDocumentPosition - !b.compareDocumentPosition;
if ( compare ) {
return compare;
}
// Calculate position if both inputs belong to the same document
compare = ( a.ownerDocument || a ) === ( b.ownerDocument || b ) ?
a.compareDocumentPosition( b ) :
// Otherwise we know they are disconnected
1;
// Disconnected nodes
if ( compare & 1 ||
(!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) {
// Choose the first element that is related to our preferred document
if ( a === doc || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) {
return -1;
}
if ( b === doc || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) {
return 1;
}
// Maintain original order
return sortInput ?
( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) :
0;
}
return compare & 4 ? -1 : 1;
} :
function( a, b ) {
// Exit early if the nodes are identical
if ( a === b ) {
hasDuplicate = true;
return 0;
}
var cur,
i = 0,
aup = a.parentNode,
bup = b.parentNode,
ap = [ a ],
bp = [ b ];
// Parentless nodes are either documents or disconnected
if ( !aup || !bup ) {
return a === doc ? -1 :
b === doc ? 1 :
aup ? -1 :
bup ? 1 :
sortInput ?
( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) :
0;
// If the nodes are siblings, we can do a quick check
} else if ( aup === bup ) {
return siblingCheck( a, b );
}
// Otherwise we need full lists of their ancestors for comparison
cur = a;
while ( (cur = cur.parentNode) ) {
ap.unshift( cur );
}
cur = b;
while ( (cur = cur.parentNode) ) {
bp.unshift( cur );
}
// Walk down the tree looking for a discrepancy
while ( ap[i] === bp[i] ) {
i++;
}
return i ?
// Do a sibling check if the nodes have a common ancestor
siblingCheck( ap[i], bp[i] ) :
// Otherwise nodes in our document sort first
ap[i] === preferredDoc ? -1 :
bp[i] === preferredDoc ? 1 :
0;
};
return doc;
};
Sizzle.matches = function( expr, elements ) {
return Sizzle( expr, null, null, elements );
};
Sizzle.matchesSelector = function( elem, expr ) {
// Set document vars if needed
if ( ( elem.ownerDocument || elem ) !== document ) {
setDocument( elem );
}
// Make sure that attribute selectors are quoted
expr = expr.replace( rattributeQuotes, "='$1']" );
if ( support.matchesSelector && documentIsHTML &&
( !rbuggyMatches || !rbuggyMatches.test( expr ) ) &&
( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) {
try {
var ret = matches.call( elem, expr );
// IE 9's matchesSelector returns false on disconnected nodes
if ( ret || support.disconnectedMatch ||
// As well, disconnected nodes are said to be in a document
// fragment in IE 9
elem.document && elem.document.nodeType !== 11 ) {
return ret;
}
} catch (e) {}
}
return Sizzle( expr, document, null, [ elem ] ).length > 0;
};
Sizzle.contains = function( context, elem ) {
// Set document vars if needed
if ( ( context.ownerDocument || context ) !== document ) {
setDocument( context );
}
return contains( context, elem );
};
Sizzle.attr = function( elem, name ) {
// Set document vars if needed
if ( ( elem.ownerDocument || elem ) !== document ) {
setDocument( elem );
}
var fn = Expr.attrHandle[ name.toLowerCase() ],
// Don't get fooled by Object.prototype properties (jQuery #13807)
val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ?
fn( elem, name, !documentIsHTML ) :
undefined;
return val !== undefined ?
val :
support.attributes || !documentIsHTML ?
elem.getAttribute( name ) :
(val = elem.getAttributeNode(name)) && val.specified ?
val.value :
null;
};
Sizzle.error = function( msg ) {
throw new Error( "Syntax error, unrecognized expression: " + msg );
};
/**
* Document sorting and removing duplicates
* @param {ArrayLike} results
*/
Sizzle.uniqueSort = function( results ) {
var elem,
duplicates = [],
j = 0,
i = 0;
// Unless we *know* we can detect duplicates, assume their presence
hasDuplicate = !support.detectDuplicates;
sortInput = !support.sortStable && results.slice( 0 );
results.sort( sortOrder );
if ( hasDuplicate ) {
while ( (elem = results[i++]) ) {
if ( elem === results[ i ] ) {
j = duplicates.push( i );
}
}
while ( j-- ) {
results.splice( duplicates[ j ], 1 );
}
}
// Clear input after sorting to release objects
// See https://github.com/jquery/sizzle/pull/225
sortInput = null;
return results;
};
/**
* Utility function for retrieving the text value of an array of DOM nodes
* @param {Array|Element} elem
*/
getText = Sizzle.getText = function( elem ) {
var node,
ret = "",
i = 0,
nodeType = elem.nodeType;
if ( !nodeType ) {
// If no nodeType, this is expected to be an array
while ( (node = elem[i++]) ) {
// Do not traverse comment nodes
ret += getText( node );
}
} else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) {
// Use textContent for elements
// innerText usage removed for consistency of new lines (jQuery #11153)
if ( typeof elem.textContent === "string" ) {
return elem.textContent;
} else {
// Traverse its children
for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) {
ret += getText( elem );
}
}
} else if ( nodeType === 3 || nodeType === 4 ) {
return elem.nodeValue;
}
// Do not include comment or processing instruction nodes
return ret;
};
Expr = Sizzle.selectors = {
// Can be adjusted by the user
cacheLength: 50,
createPseudo: markFunction,
match: matchExpr,
attrHandle: {},
find: {},
relative: {
">": { dir: "parentNode", first: true },
" ": { dir: "parentNode" },
"+": { dir: "previousSibling", first: true },
"~": { dir: "previousSibling" }
},
preFilter: {
"ATTR": function( match ) {
match[1] = match[1].replace( runescape, funescape );
// Move the given value to match[3] whether quoted or unquoted
match[3] = ( match[3] || match[4] || match[5] || "" ).replace( runescape, funescape );
if ( match[2] === "~=" ) {
match[3] = " " + match[3] + " ";
}
return match.slice( 0, 4 );
},
"CHILD": function( match ) {
/* matches from matchExpr["CHILD"]
1 type (only|nth|...)
2 what (child|of-type)
3 argument (even|odd|\d*|\d*n([+-]\d+)?|...)
4 xn-component of xn+y argument ([+-]?\d*n|)
5 sign of xn-component
6 x of xn-component
7 sign of y-component
8 y of y-component
*/
match[1] = match[1].toLowerCase();
if ( match[1].slice( 0, 3 ) === "nth" ) {
// nth-* requires argument
if ( !match[3] ) {
Sizzle.error( match[0] );
}
// numeric x and y parameters for Expr.filter.CHILD
// remember that false/true cast respectively to 0/1
match[4] = +( match[4] ? match[5] + (match[6] || 1) : 2 * ( match[3] === "even" || match[3] === "odd" ) );
match[5] = +( ( match[7] + match[8] ) || match[3] === "odd" );
// other types prohibit arguments
} else if ( match[3] ) {
Sizzle.error( match[0] );
}
return match;
},
"PSEUDO": function( match ) {
var excess,
unquoted = !match[6] && match[2];
if ( matchExpr["CHILD"].test( match[0] ) ) {
return null;
}
// Accept quoted arguments as-is
if ( match[3] ) {
match[2] = match[4] || match[5] || "";
// Strip excess characters from unquoted arguments
} else if ( unquoted && rpseudo.test( unquoted ) &&
// Get excess from tokenize (recursively)
(excess = tokenize( unquoted, true )) &&
// advance to the next closing parenthesis
(excess = unquoted.indexOf( ")", unquoted.length - excess ) - unquoted.length) ) {
// excess is a negative index
match[0] = match[0].slice( 0, excess );
match[2] = unquoted.slice( 0, excess );
}
// Return only captures needed by the pseudo filter method (type and argument)
return match.slice( 0, 3 );
}
},
filter: {
"TAG": function( nodeNameSelector ) {
var nodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase();
return nodeNameSelector === "*" ?
function() { return true; } :
function( elem ) {
return elem.nodeName && elem.nodeName.toLowerCase() === nodeName;
};
},
"CLASS": function( className ) {
var pattern = classCache[ className + " " ];
return pattern ||
(pattern = new RegExp( "(^|" + whitespace + ")" + className + "(" + whitespace + "|$)" )) &&
classCache( className, function( elem ) {
return pattern.test( typeof elem.className === "string" && elem.className || typeof elem.getAttribute !== "undefined" && elem.getAttribute("class") || "" );
});
},
"ATTR": function( name, operator, check ) {
return function( elem ) {
var result = Sizzle.attr( elem, name );
if ( result == null ) {
return operator === "!=";
}
if ( !operator ) {
return true;
}
result += "";
return operator === "=" ? result === check :
operator === "!=" ? result !== check :
operator === "^=" ? check && result.indexOf( check ) === 0 :
operator === "*=" ? check && result.indexOf( check ) > -1 :
operator === "$=" ? check && result.slice( -check.length ) === check :
operator === "~=" ? ( " " + result.replace( rwhitespace, " " ) + " " ).indexOf( check ) > -1 :
operator === "|=" ? result === check || result.slice( 0, check.length + 1 ) === check + "-" :
false;
};
},
"CHILD": function( type, what, argument, first, last ) {
var simple = type.slice( 0, 3 ) !== "nth",
forward = type.slice( -4 ) !== "last",
ofType = what === "of-type";
return first === 1 && last === 0 ?
// Shortcut for :nth-*(n)
function( elem ) {
return !!elem.parentNode;
} :
function( elem, context, xml ) {
var cache, outerCache, node, diff, nodeIndex, start,
dir = simple !== forward ? "nextSibling" : "previousSibling",
parent = elem.parentNode,
name = ofType && elem.nodeName.toLowerCase(),
useCache = !xml && !ofType;
if ( parent ) {
// :(first|last|only)-(child|of-type)
if ( simple ) {
while ( dir ) {
node = elem;
while ( (node = node[ dir ]) ) {
if ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) {
return false;
}
}
// Reverse direction for :only-* (if we haven't yet done so)
start = dir = type === "only" && !start && "nextSibling";
}
return true;
}
start = [ forward ? parent.firstChild : parent.lastChild ];
// non-xml :nth-child(...) stores cache data on `parent`
if ( forward && useCache ) {
// Seek `elem` from a previously-cached index
outerCache = parent[ expando ] || (parent[ expando ] = {});
cache = outerCache[ type ] || [];
nodeIndex = cache[0] === dirruns && cache[1];
diff = cache[0] === dirruns && cache[2];
node = nodeIndex && parent.childNodes[ nodeIndex ];
while ( (node = ++nodeIndex && node && node[ dir ] ||
// Fallback to seeking `elem` from the start
(diff = nodeIndex = 0) || start.pop()) ) {
// When found, cache indexes on `parent` and break
if ( node.nodeType === 1 && ++diff && node === elem ) {
outerCache[ type ] = [ dirruns, nodeIndex, diff ];
break;
}
}
// Use previously-cached element index if available
} else if ( useCache && (cache = (elem[ expando ] || (elem[ expando ] = {}))[ type ]) && cache[0] === dirruns ) {
diff = cache[1];
// xml :nth-child(...) or :nth-last-child(...) or :nth(-last)?-of-type(...)
} else {
// Use the same loop as above to seek `elem` from the start
while ( (node = ++nodeIndex && node && node[ dir ] ||
(diff = nodeIndex = 0) || start.pop()) ) {
if ( ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) && ++diff ) {
// Cache the index of each encountered element
if ( useCache ) {
(node[ expando ] || (node[ expando ] = {}))[ type ] = [ dirruns, diff ];
}
if ( node === elem ) {
break;
}
}
}
}
// Incorporate the offset, then check against cycle size
diff -= last;
return diff === first || ( diff % first === 0 && diff / first >= 0 );
}
};
},
"PSEUDO": function( pseudo, argument ) {
// pseudo-class names are case-insensitive
// http://www.w3.org/TR/selectors/#pseudo-classes
// Prioritize by case sensitivity in case custom pseudos are added with uppercase letters
// Remember that setFilters inherits from pseudos
var args,
fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] ||
Sizzle.error( "unsupported pseudo: " + pseudo );
// The user may use createPseudo to indicate that
// arguments are needed to create the filter function
// just as Sizzle does
if ( fn[ expando ] ) {
return fn( argument );
}
// But maintain support for old signatures
if ( fn.length > 1 ) {
args = [ pseudo, pseudo, "", argument ];
return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ?
markFunction(function( seed, matches ) {
var idx,
matched = fn( seed, argument ),
i = matched.length;
while ( i-- ) {
idx = indexOf( seed, matched[i] );
seed[ idx ] = !( matches[ idx ] = matched[i] );
}
}) :
function( elem ) {
return fn( elem, 0, args );
};
}
return fn;
}
},
pseudos: {
// Potentially complex pseudos
"not": markFunction(function( selector ) {
// Trim the selector passed to compile
// to avoid treating leading and trailing
// spaces as combinators
var input = [],
results = [],
matcher = compile( selector.replace( rtrim, "$1" ) );
return matcher[ expando ] ?
markFunction(function( seed, matches, context, xml ) {
var elem,
unmatched = matcher( seed, null, xml, [] ),
i = seed.length;
// Match elements unmatched by `matcher`
while ( i-- ) {
if ( (elem = unmatched[i]) ) {
seed[i] = !(matches[i] = elem);
}
}
}) :
function( elem, context, xml ) {
input[0] = elem;
matcher( input, null, xml, results );
// Don't keep the element (issue #299)
input[0] = null;
return !results.pop();
};
}),
"has": markFunction(function( selector ) {
return function( elem ) {
return Sizzle( selector, elem ).length > 0;
};
}),
"contains": markFunction(function( text ) {
text = text.replace( runescape, funescape );
return function( elem ) {
return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1;
};
}),
// "Whether an element is represented by a :lang() selector
// is based solely on the element's language value
// being equal to the identifier C,
// or beginning with the identifier C immediately followed by "-".
// The matching of C against the element's language value is performed case-insensitively.
// The identifier C does not have to be a valid language name."
// http://www.w3.org/TR/selectors/#lang-pseudo
"lang": markFunction( function( lang ) {
// lang value must be a valid identifier
if ( !ridentifier.test(lang || "") ) {
Sizzle.error( "unsupported lang: " + lang );
}
lang = lang.replace( runescape, funescape ).toLowerCase();
return function( elem ) {
var elemLang;
do {
if ( (elemLang = documentIsHTML ?
elem.lang :
elem.getAttribute("xml:lang") || elem.getAttribute("lang")) ) {
elemLang = elemLang.toLowerCase();
return elemLang === lang || elemLang.indexOf( lang + "-" ) === 0;
}
} while ( (elem = elem.parentNode) && elem.nodeType === 1 );
return false;
};
}),
// Miscellaneous
"target": function( elem ) {
var hash = window.location && window.location.hash;
return hash && hash.slice( 1 ) === elem.id;
},
"root": function( elem ) {
return elem === docElem;
},
"focus": function( elem ) {
return elem === document.activeElement && (!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex);
},
// Boolean properties
"enabled": function( elem ) {
return elem.disabled === false;
},
"disabled": function( elem ) {
return elem.disabled === true;
},
"checked": function( elem ) {
// In CSS3, :checked should return both checked and selected elements
// http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked
var nodeName = elem.nodeName.toLowerCase();
return (nodeName === "input" && !!elem.checked) || (nodeName === "option" && !!elem.selected);
},
"selected": function( elem ) {
// Accessing this property makes selected-by-default
// options in Safari work properly
if ( elem.parentNode ) {
elem.parentNode.selectedIndex;
}
return elem.selected === true;
},
// Contents
"empty": function( elem ) {
// http://www.w3.org/TR/selectors/#empty-pseudo
// :empty is negated by element (1) or content nodes (text: 3; cdata: 4; entity ref: 5),
// but not by others (comment: 8; processing instruction: 7; etc.)
// nodeType < 6 works because attributes (2) do not appear as children
for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) {
if ( elem.nodeType < 6 ) {
return false;
}
}
return true;
},
"parent": function( elem ) {
return !Expr.pseudos["empty"]( elem );
},
// Element/input types
"header": function( elem ) {
return rheader.test( elem.nodeName );
},
"input": function( elem ) {
return rinputs.test( elem.nodeName );
},
"button": function( elem ) {
var name = elem.nodeName.toLowerCase();
return name === "input" && elem.type === "button" || name === "button";
},
"text": function( elem ) {
var attr;
return elem.nodeName.toLowerCase() === "input" &&
elem.type === "text" &&
// Support: IE<8
// New HTML5 attribute values (e.g., "search") appear with elem.type === "text"
( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === "text" );
},
// Position-in-collection
"first": createPositionalPseudo(function() {
return [ 0 ];
}),
"last": createPositionalPseudo(function( matchIndexes, length ) {
return [ length - 1 ];
}),
"eq": createPositionalPseudo(function( matchIndexes, length, argument ) {
return [ argument < 0 ? argument + length : argument ];
}),
"even": createPositionalPseudo(function( matchIndexes, length ) {
var i = 0;
for ( ; i < length; i += 2 ) {
matchIndexes.push( i );
}
return matchIndexes;
}),
"odd": createPositionalPseudo(function( matchIndexes, length ) {
var i = 1;
for ( ; i < length; i += 2 ) {
matchIndexes.push( i );
}
return matchIndexes;
}),
"lt": createPositionalPseudo(function( matchIndexes, length, argument ) {
var i = argument < 0 ? argument + length : argument;
for ( ; --i >= 0; ) {
matchIndexes.push( i );
}
return matchIndexes;
}),
"gt": createPositionalPseudo(function( matchIndexes, length, argument ) {
var i = argument < 0 ? argument + length : argument;
for ( ; ++i < length; ) {
matchIndexes.push( i );
}
return matchIndexes;
})
}
};
Expr.pseudos["nth"] = Expr.pseudos["eq"];
// Add button/input type pseudos
for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) {
Expr.pseudos[ i ] = createInputPseudo( i );
}
for ( i in { submit: true, reset: true } ) {
Expr.pseudos[ i ] = createButtonPseudo( i );
}
// Easy API for creating new setFilters
function setFilters() {}
setFilters.prototype = Expr.filters = Expr.pseudos;
Expr.setFilters = new setFilters();
tokenize = Sizzle.tokenize = function( selector, parseOnly ) {
var matched, match, tokens, type,
soFar, groups, preFilters,
cached = tokenCache[ selector + " " ];
if ( cached ) {
return parseOnly ? 0 : cached.slice( 0 );
}
soFar = selector;
groups = [];
preFilters = Expr.preFilter;
while ( soFar ) {
// Comma and first run
if ( !matched || (match = rcomma.exec( soFar )) ) {
if ( match ) {
// Don't consume trailing commas as valid
soFar = soFar.slice( match[0].length ) || soFar;
}
groups.push( (tokens = []) );
}
matched = false;
// Combinators
if ( (match = rcombinators.exec( soFar )) ) {
matched = match.shift();
tokens.push({
value: matched,
// Cast descendant combinators to space
type: match[0].replace( rtrim, " " )
});
soFar = soFar.slice( matched.length );
}
// Filters
for ( type in Expr.filter ) {
if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] ||
(match = preFilters[ type ]( match ))) ) {
matched = match.shift();
tokens.push({
value: matched,
type: type,
matches: match
});
soFar = soFar.slice( matched.length );
}
}
if ( !matched ) {
break;
}
}
// Return the length of the invalid excess
// if we're just parsing
// Otherwise, throw an error or return tokens
return parseOnly ?
soFar.length :
soFar ?
Sizzle.error( selector ) :
// Cache the tokens
tokenCache( selector, groups ).slice( 0 );
};
function toSelector( tokens ) {
var i = 0,
len = tokens.length,
selector = "";
for ( ; i < len; i++ ) {
selector += tokens[i].value;
}
return selector;
}
function addCombinator( matcher, combinator, base ) {
var dir = combinator.dir,
checkNonElements = base && dir === "parentNode",
doneName = done++;
return combinator.first ?
// Check against closest ancestor/preceding element
function( elem, context, xml ) {
while ( (elem = elem[ dir ]) ) {
if ( elem.nodeType === 1 || checkNonElements ) {
return matcher( elem, context, xml );
}
}
} :
// Check against all ancestor/preceding elements
function( elem, context, xml ) {
var oldCache, outerCache,
newCache = [ dirruns, doneName ];
// We can't set arbitrary data on XML nodes, so they don't benefit from dir caching
if ( xml ) {
while ( (elem = elem[ dir ]) ) {
if ( elem.nodeType === 1 || checkNonElements ) {
if ( matcher( elem, context, xml ) ) {
return true;
}
}
}
} else {
while ( (elem = elem[ dir ]) ) {
if ( elem.nodeType === 1 || checkNonElements ) {
outerCache = elem[ expando ] || (elem[ expando ] = {});
if ( (oldCache = outerCache[ dir ]) &&
oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) {
// Assign to newCache so results back-propagate to previous elements
return (newCache[ 2 ] = oldCache[ 2 ]);
} else {
// Reuse newcache so results back-propagate to previous elements
outerCache[ dir ] = newCache;
// A match means we're done; a fail means we have to keep checking
if ( (newCache[ 2 ] = matcher( elem, context, xml )) ) {
return true;
}
}
}
}
}
};
}
function elementMatcher( matchers ) {
return matchers.length > 1 ?
function( elem, context, xml ) {
var i = matchers.length;
while ( i-- ) {
if ( !matchers[i]( elem, context, xml ) ) {
return false;
}
}
return true;
} :
matchers[0];
}
function multipleContexts( selector, contexts, results ) {
var i = 0,
len = contexts.length;
for ( ; i < len; i++ ) {
Sizzle( selector, contexts[i], results );
}
return results;
}
function condense( unmatched, map, filter, context, xml ) {
var elem,
newUnmatched = [],
i = 0,
len = unmatched.length,
mapped = map != null;
for ( ; i < len; i++ ) {
if ( (elem = unmatched[i]) ) {
if ( !filter || filter( elem, context, xml ) ) {
newUnmatched.push( elem );
if ( mapped ) {
map.push( i );
}
}
}
}
return newUnmatched;
}
function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) {
if ( postFilter && !postFilter[ expando ] ) {
postFilter = setMatcher( postFilter );
}
if ( postFinder && !postFinder[ expando ] ) {
postFinder = setMatcher( postFinder, postSelector );
}
return markFunction(function( seed, results, context, xml ) {
var temp, i, elem,
preMap = [],
postMap = [],
preexisting = results.length,
// Get initial elements from seed or context
elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ),
// Prefilter to get matcher input, preserving a map for seed-results synchronization
matcherIn = preFilter && ( seed || !selector ) ?
condense( elems, preMap, preFilter, context, xml ) :
elems,
matcherOut = matcher ?
// If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results,
postFinder || ( seed ? preFilter : preexisting || postFilter ) ?
// ...intermediate processing is necessary
[] :
// ...otherwise use results directly
results :
matcherIn;
// Find primary matches
if ( matcher ) {
matcher( matcherIn, matcherOut, context, xml );
}
// Apply postFilter
if ( postFilter ) {
temp = condense( matcherOut, postMap );
postFilter( temp, [], context, xml );
// Un-match failing elements by moving them back to matcherIn
i = temp.length;
while ( i-- ) {
if ( (elem = temp[i]) ) {
matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem);
}
}
}
if ( seed ) {
if ( postFinder || preFilter ) {
if ( postFinder ) {
// Get the final matcherOut by condensing this intermediate into postFinder contexts
temp = [];
i = matcherOut.length;
while ( i-- ) {
if ( (elem = matcherOut[i]) ) {
// Restore matcherIn since elem is not yet a final match
temp.push( (matcherIn[i] = elem) );
}
}
postFinder( null, (matcherOut = []), temp, xml );
}
// Move matched elements from seed to results to keep them synchronized
i = matcherOut.length;
while ( i-- ) {
if ( (elem = matcherOut[i]) &&
(temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) {
seed[temp] = !(results[temp] = elem);
}
}
}
// Add elements to results, through postFinder if defined
} else {
matcherOut = condense(
matcherOut === results ?
matcherOut.splice( preexisting, matcherOut.length ) :
matcherOut
);
if ( postFinder ) {
postFinder( null, results, matcherOut, xml );
} else {
push.apply( results, matcherOut );
}
}
});
}
function matcherFromTokens( tokens ) {
var checkContext, matcher, j,
len = tokens.length,
leadingRelative = Expr.relative[ tokens[0].type ],
implicitRelative = leadingRelative || Expr.relative[" "],
i = leadingRelative ? 1 : 0,
// The foundational matcher ensures that elements are reachable from top-level context(s)
matchContext = addCombinator( function( elem ) {
return elem === checkContext;
}, implicitRelative, true ),
matchAnyContext = addCombinator( function( elem ) {
return indexOf( checkContext, elem ) > -1;
}, implicitRelative, true ),
matchers = [ function( elem, context, xml ) {
var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || (
(checkContext = context).nodeType ?
matchContext( elem, context, xml ) :
matchAnyContext( elem, context, xml ) );
// Avoid hanging onto element (issue #299)
checkContext = null;
return ret;
} ];
for ( ; i < len; i++ ) {
if ( (matcher = Expr.relative[ tokens[i].type ]) ) {
matchers = [ addCombinator(elementMatcher( matchers ), matcher) ];
} else {
matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches );
// Return special upon seeing a positional matcher
if ( matcher[ expando ] ) {
// Find the next relative operator (if any) for proper handling
j = ++i;
for ( ; j < len; j++ ) {
if ( Expr.relative[ tokens[j].type ] ) {
break;
}
}
return setMatcher(
i > 1 && elementMatcher( matchers ),
i > 1 && toSelector(
// If the preceding token was a descendant combinator, insert an implicit any-element `*`
tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" })
).replace( rtrim, "$1" ),
matcher,
i < j && matcherFromTokens( tokens.slice( i, j ) ),
j < len && matcherFromTokens( (tokens = tokens.slice( j )) ),
j < len && toSelector( tokens )
);
}
matchers.push( matcher );
}
}
return elementMatcher( matchers );
}
function matcherFromGroupMatchers( elementMatchers, setMatchers ) {
var bySet = setMatchers.length > 0,
byElement = elementMatchers.length > 0,
superMatcher = function( seed, context, xml, results, outermost ) {
var elem, j, matcher,
matchedCount = 0,
i = "0",
unmatched = seed && [],
setMatched = [],
contextBackup = outermostContext,
// We must always have either seed elements or outermost context
elems = seed || byElement && Expr.find["TAG"]( "*", outermost ),
// Use integer dirruns iff this is the outermost matcher
dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1),
len = elems.length;
if ( outermost ) {
outermostContext = context !== document && context;
}
// Add elements passing elementMatchers directly to results
// Keep `i` a string if there are no elements so `matchedCount` will be "00" below
// Support: IE<9, Safari
// Tolerate NodeList properties (IE: "length"; Safari: ) matching elements by id
for ( ; i !== len && (elem = elems[i]) != null; i++ ) {
if ( byElement && elem ) {
j = 0;
while ( (matcher = elementMatchers[j++]) ) {
if ( matcher( elem, context, xml ) ) {
results.push( elem );
break;
}
}
if ( outermost ) {
dirruns = dirrunsUnique;
}
}
// Track unmatched elements for set filters
if ( bySet ) {
// They will have gone through all possible matchers
if ( (elem = !matcher && elem) ) {
matchedCount--;
}
// Lengthen the array for every element, matched or not
if ( seed ) {
unmatched.push( elem );
}
}
}
// Apply set filters to unmatched elements
matchedCount += i;
if ( bySet && i !== matchedCount ) {
j = 0;
while ( (matcher = setMatchers[j++]) ) {
matcher( unmatched, setMatched, context, xml );
}
if ( seed ) {
// Reintegrate element matches to eliminate the need for sorting
if ( matchedCount > 0 ) {
while ( i-- ) {
if ( !(unmatched[i] || setMatched[i]) ) {
setMatched[i] = pop.call( results );
}
}
}
// Discard index placeholder values to get only actual matches
setMatched = condense( setMatched );
}
// Add matches to results
push.apply( results, setMatched );
// Seedless set matches succeeding multiple successful matchers stipulate sorting
if ( outermost && !seed && setMatched.length > 0 &&
( matchedCount + setMatchers.length ) > 1 ) {
Sizzle.uniqueSort( results );
}
}
// Override manipulation of globals by nested matchers
if ( outermost ) {
dirruns = dirrunsUnique;
outermostContext = contextBackup;
}
return unmatched;
};
return bySet ?
markFunction( superMatcher ) :
superMatcher;
}
compile = Sizzle.compile = function( selector, match /* Internal Use Only */ ) {
var i,
setMatchers = [],
elementMatchers = [],
cached = compilerCache[ selector + " " ];
if ( !cached ) {
// Generate a function of recursive functions that can be used to check each element
if ( !match ) {
match = tokenize( selector );
}
i = match.length;
while ( i-- ) {
cached = matcherFromTokens( match[i] );
if ( cached[ expando ] ) {
setMatchers.push( cached );
} else {
elementMatchers.push( cached );
}
}
// Cache the compiled function
cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) );
// Save selector and tokenization
cached.selector = selector;
}
return cached;
};
/**
* A low-level selection function that works with Sizzle's compiled
* selector functions
* @param {String|Function} selector A selector or a pre-compiled
* selector function built with Sizzle.compile
* @param {Element} context
* @param {Array} [results]
* @param {Array} [seed] A set of elements to match against
*/
select = Sizzle.select = function( selector, context, results, seed ) {
var i, tokens, token, type, find,
compiled = typeof selector === "function" && selector,
match = !seed && tokenize( (selector = compiled.selector || selector) );
results = results || [];
// Try to minimize operations if there is no seed and only one group
if ( match.length === 1 ) {
// Take a shortcut and set the context if the root selector is an ID
tokens = match[0] = match[0].slice( 0 );
if ( tokens.length > 2 && (token = tokens[0]).type === "ID" &&
support.getById && context.nodeType === 9 && documentIsHTML &&
Expr.relative[ tokens[1].type ] ) {
context = ( Expr.find["ID"]( token.matches[0].replace(runescape, funescape), context ) || [] )[0];
if ( !context ) {
return results;
// Precompiled matchers will still verify ancestry, so step up a level
} else if ( compiled ) {
context = context.parentNode;
}
selector = selector.slice( tokens.shift().value.length );
}
// Fetch a seed set for right-to-left matching
i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length;
while ( i-- ) {
token = tokens[i];
// Abort if we hit a combinator
if ( Expr.relative[ (type = token.type) ] ) {
break;
}
if ( (find = Expr.find[ type ]) ) {
// Search, expanding context for leading sibling combinators
if ( (seed = find(
token.matches[0].replace( runescape, funescape ),
rsibling.test( tokens[0].type ) && testContext( context.parentNode ) || context
)) ) {
// If seed is empty or no tokens remain, we can return early
tokens.splice( i, 1 );
selector = seed.length && toSelector( tokens );
if ( !selector ) {
push.apply( results, seed );
return results;
}
break;
}
}
}
}
// Compile and execute a filtering function if one is not provided
// Provide `match` to avoid retokenization if we modified the selector above
( compiled || compile( selector, match ) )(
seed,
context,
!documentIsHTML,
results,
rsibling.test( selector ) && testContext( context.parentNode ) || context
);
return results;
};
// One-time assignments
// Sort stability
support.sortStable = expando.split("").sort( sortOrder ).join("") === expando;
// Support: Chrome 14-35+
// Always assume duplicates if they aren't passed to the comparison function
support.detectDuplicates = !!hasDuplicate;
// Initialize against the default document
setDocument();
// Support: Webkit<537.32 - Safari 6.0.3/Chrome 25 (fixed in Chrome 27)
// Detached nodes confoundingly follow *each other*
support.sortDetached = assert(function( div1 ) {
// Should return 1, but returns 4 (following)
return div1.compareDocumentPosition( document.createElement("div") ) & 1;
});
// Support: IE<8
// Prevent attribute/property "interpolation"
// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx
if ( !assert(function( div ) {
div.innerHTML = "";
return div.firstChild.getAttribute("href") === "#" ;
}) ) {
addHandle( "type|href|height|width", function( elem, name, isXML ) {
if ( !isXML ) {
return elem.getAttribute( name, name.toLowerCase() === "type" ? 1 : 2 );
}
});
}
// Support: IE<9
// Use defaultValue in place of getAttribute("value")
if ( !support.attributes || !assert(function( div ) {
div.innerHTML = "";
div.firstChild.setAttribute( "value", "" );
return div.firstChild.getAttribute( "value" ) === "";
}) ) {
addHandle( "value", function( elem, name, isXML ) {
if ( !isXML && elem.nodeName.toLowerCase() === "input" ) {
return elem.defaultValue;
}
});
}
// Support: IE<9
// Use getAttributeNode to fetch booleans when getAttribute lies
if ( !assert(function( div ) {
return div.getAttribute("disabled") == null;
}) ) {
addHandle( booleans, function( elem, name, isXML ) {
var val;
if ( !isXML ) {
return elem[ name ] === true ? name.toLowerCase() :
(val = elem.getAttributeNode( name )) && val.specified ?
val.value :
null;
}
});
}
return Sizzle;
})( window );
jQuery.find = Sizzle;
jQuery.expr = Sizzle.selectors;
jQuery.expr[":"] = jQuery.expr.pseudos;
jQuery.unique = Sizzle.uniqueSort;
jQuery.text = Sizzle.getText;
jQuery.isXMLDoc = Sizzle.isXML;
jQuery.contains = Sizzle.contains;
var rneedsContext = jQuery.expr.match.needsContext;
var rsingleTag = (/^<(\w+)\s*\/?>(?:<\/\1>|)$/);
var risSimple = /^.[^:#\[\.,]*$/;
// Implement the identical functionality for filter and not
function winnow( elements, qualifier, not ) {
if ( jQuery.isFunction( qualifier ) ) {
return jQuery.grep( elements, function( elem, i ) {
/* jshint -W018 */
return !!qualifier.call( elem, i, elem ) !== not;
});
}
if ( qualifier.nodeType ) {
return jQuery.grep( elements, function( elem ) {
return ( elem === qualifier ) !== not;
});
}
if ( typeof qualifier === "string" ) {
if ( risSimple.test( qualifier ) ) {
return jQuery.filter( qualifier, elements, not );
}
qualifier = jQuery.filter( qualifier, elements );
}
return jQuery.grep( elements, function( elem ) {
return ( indexOf.call( qualifier, elem ) >= 0 ) !== not;
});
}
jQuery.filter = function( expr, elems, not ) {
var elem = elems[ 0 ];
if ( not ) {
expr = ":not(" + expr + ")";
}
return elems.length === 1 && elem.nodeType === 1 ?
jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : [] :
jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) {
return elem.nodeType === 1;
}));
};
jQuery.fn.extend({
find: function( selector ) {
var i,
len = this.length,
ret = [],
self = this;
if ( typeof selector !== "string" ) {
return this.pushStack( jQuery( selector ).filter(function() {
for ( i = 0; i < len; i++ ) {
if ( jQuery.contains( self[ i ], this ) ) {
return true;
}
}
}) );
}
for ( i = 0; i < len; i++ ) {
jQuery.find( selector, self[ i ], ret );
}
// Needed because $( selector, context ) becomes $( context ).find( selector )
ret = this.pushStack( len > 1 ? jQuery.unique( ret ) : ret );
ret.selector = this.selector ? this.selector + " " + selector : selector;
return ret;
},
filter: function( selector ) {
return this.pushStack( winnow(this, selector || [], false) );
},
not: function( selector ) {
return this.pushStack( winnow(this, selector || [], true) );
},
is: function( selector ) {
return !!winnow(
this,
// If this is a positional/relative selector, check membership in the returned set
// so $("p:first").is("p:last") won't return true for a doc with two "p".
typeof selector === "string" && rneedsContext.test( selector ) ?
jQuery( selector ) :
selector || [],
false
).length;
}
});
// Initialize a jQuery object
// A central reference to the root jQuery(document)
var rootjQuery,
// A simple way to check for HTML strings
// Prioritize #id over to avoid XSS via location.hash (#9521)
// Strict HTML recognition (#11290: must start with <)
rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,
init = jQuery.fn.init = function( selector, context ) {
var match, elem;
// HANDLE: $(""), $(null), $(undefined), $(false)
if ( !selector ) {
return this;
}
// Handle HTML strings
if ( typeof selector === "string" ) {
if ( selector[0] === "<" && selector[ selector.length - 1 ] === ">" && selector.length >= 3 ) {
// Assume that strings that start and end with <> are HTML and skip the regex check
match = [ null, selector, null ];
} else {
match = rquickExpr.exec( selector );
}
// Match html or make sure no context is specified for #id
if ( match && (match[1] || !context) ) {
// HANDLE: $(html) -> $(array)
if ( match[1] ) {
context = context instanceof jQuery ? context[0] : context;
// Option to run scripts is true for back-compat
// Intentionally let the error be thrown if parseHTML is not present
jQuery.merge( this, jQuery.parseHTML(
match[1],
context && context.nodeType ? context.ownerDocument || context : document,
true
) );
// HANDLE: $(html, props)
if ( rsingleTag.test( match[1] ) && jQuery.isPlainObject( context ) ) {
for ( match in context ) {
// Properties of context are called as methods if possible
if ( jQuery.isFunction( this[ match ] ) ) {
this[ match ]( context[ match ] );
// ...and otherwise set as attributes
} else {
this.attr( match, context[ match ] );
}
}
}
return this;
// HANDLE: $(#id)
} else {
elem = document.getElementById( match[2] );
// Support: Blackberry 4.6
// gEBID returns nodes no longer in the document (#6963)
if ( elem && elem.parentNode ) {
// Inject the element directly into the jQuery object
this.length = 1;
this[0] = elem;
}
this.context = document;
this.selector = selector;
return this;
}
// HANDLE: $(expr, $(...))
} else if ( !context || context.jquery ) {
return ( context || rootjQuery ).find( selector );
// HANDLE: $(expr, context)
// (which is just equivalent to: $(context).find(expr)
} else {
return this.constructor( context ).find( selector );
}
// HANDLE: $(DOMElement)
} else if ( selector.nodeType ) {
this.context = this[0] = selector;
this.length = 1;
return this;
// HANDLE: $(function)
// Shortcut for document ready
} else if ( jQuery.isFunction( selector ) ) {
return typeof rootjQuery.ready !== "undefined" ?
rootjQuery.ready( selector ) :
// Execute immediately if ready is not present
selector( jQuery );
}
if ( selector.selector !== undefined ) {
this.selector = selector.selector;
this.context = selector.context;
}
return jQuery.makeArray( selector, this );
};
// Give the init function the jQuery prototype for later instantiation
init.prototype = jQuery.fn;
// Initialize central reference
rootjQuery = jQuery( document );
var rparentsprev = /^(?:parents|prev(?:Until|All))/,
// Methods guaranteed to produce a unique set when starting from a unique set
guaranteedUnique = {
children: true,
contents: true,
next: true,
prev: true
};
jQuery.extend({
dir: function( elem, dir, until ) {
var matched = [],
truncate = until !== undefined;
while ( (elem = elem[ dir ]) && elem.nodeType !== 9 ) {
if ( elem.nodeType === 1 ) {
if ( truncate && jQuery( elem ).is( until ) ) {
break;
}
matched.push( elem );
}
}
return matched;
},
sibling: function( n, elem ) {
var matched = [];
for ( ; n; n = n.nextSibling ) {
if ( n.nodeType === 1 && n !== elem ) {
matched.push( n );
}
}
return matched;
}
});
jQuery.fn.extend({
has: function( target ) {
var targets = jQuery( target, this ),
l = targets.length;
return this.filter(function() {
var i = 0;
for ( ; i < l; i++ ) {
if ( jQuery.contains( this, targets[i] ) ) {
return true;
}
}
});
},
closest: function( selectors, context ) {
var cur,
i = 0,
l = this.length,
matched = [],
pos = rneedsContext.test( selectors ) || typeof selectors !== "string" ?
jQuery( selectors, context || this.context ) :
0;
for ( ; i < l; i++ ) {
for ( cur = this[i]; cur && cur !== context; cur = cur.parentNode ) {
// Always skip document fragments
if ( cur.nodeType < 11 && (pos ?
pos.index(cur) > -1 :
// Don't pass non-elements to Sizzle
cur.nodeType === 1 &&
jQuery.find.matchesSelector(cur, selectors)) ) {
matched.push( cur );
break;
}
}
}
return this.pushStack( matched.length > 1 ? jQuery.unique( matched ) : matched );
},
// Determine the position of an element within the set
index: function( elem ) {
// No argument, return index in parent
if ( !elem ) {
return ( this[ 0 ] && this[ 0 ].parentNode ) ? this.first().prevAll().length : -1;
}
// Index in selector
if ( typeof elem === "string" ) {
return indexOf.call( jQuery( elem ), this[ 0 ] );
}
// Locate the position of the desired element
return indexOf.call( this,
// If it receives a jQuery object, the first element is used
elem.jquery ? elem[ 0 ] : elem
);
},
add: function( selector, context ) {
return this.pushStack(
jQuery.unique(
jQuery.merge( this.get(), jQuery( selector, context ) )
)
);
},
addBack: function( selector ) {
return this.add( selector == null ?
this.prevObject : this.prevObject.filter(selector)
);
}
});
function sibling( cur, dir ) {
while ( (cur = cur[dir]) && cur.nodeType !== 1 ) {}
return cur;
}
jQuery.each({
parent: function( elem ) {
var parent = elem.parentNode;
return parent && parent.nodeType !== 11 ? parent : null;
},
parents: function( elem ) {
return jQuery.dir( elem, "parentNode" );
},
parentsUntil: function( elem, i, until ) {
return jQuery.dir( elem, "parentNode", until );
},
next: function( elem ) {
return sibling( elem, "nextSibling" );
},
prev: function( elem ) {
return sibling( elem, "previousSibling" );
},
nextAll: function( elem ) {
return jQuery.dir( elem, "nextSibling" );
},
prevAll: function( elem ) {
return jQuery.dir( elem, "previousSibling" );
},
nextUntil: function( elem, i, until ) {
return jQuery.dir( elem, "nextSibling", until );
},
prevUntil: function( elem, i, until ) {
return jQuery.dir( elem, "previousSibling", until );
},
siblings: function( elem ) {
return jQuery.sibling( ( elem.parentNode || {} ).firstChild, elem );
},
children: function( elem ) {
return jQuery.sibling( elem.firstChild );
},
contents: function( elem ) {
return elem.contentDocument || jQuery.merge( [], elem.childNodes );
}
}, function( name, fn ) {
jQuery.fn[ name ] = function( until, selector ) {
var matched = jQuery.map( this, fn, until );
if ( name.slice( -5 ) !== "Until" ) {
selector = until;
}
if ( selector && typeof selector === "string" ) {
matched = jQuery.filter( selector, matched );
}
if ( this.length > 1 ) {
// Remove duplicates
if ( !guaranteedUnique[ name ] ) {
jQuery.unique( matched );
}
// Reverse order for parents* and prev-derivatives
if ( rparentsprev.test( name ) ) {
matched.reverse();
}
}
return this.pushStack( matched );
};
});
var rnotwhite = (/\S+/g);
// String to Object options format cache
var optionsCache = {};
// Convert String-formatted options into Object-formatted ones and store in cache
function createOptions( options ) {
var object = optionsCache[ options ] = {};
jQuery.each( options.match( rnotwhite ) || [], function( _, flag ) {
object[ flag ] = true;
});
return object;
}
/*
* Create a callback list using the following parameters:
*
* options: an optional list of space-separated options that will change how
* the callback list behaves or a more traditional option object
*
* By default a callback list will act like an event callback list and can be
* "fired" multiple times.
*
* Possible options:
*
* once: will ensure the callback list can only be fired once (like a Deferred)
*
* memory: will keep track of previous values and will call any callback added
* after the list has been fired right away with the latest "memorized"
* values (like a Deferred)
*
* unique: will ensure a callback can only be added once (no duplicate in the list)
*
* stopOnFalse: interrupt callings when a callback returns false
*
*/
jQuery.Callbacks = function( options ) {
// Convert options from String-formatted to Object-formatted if needed
// (we check in cache first)
options = typeof options === "string" ?
( optionsCache[ options ] || createOptions( options ) ) :
jQuery.extend( {}, options );
var // Last fire value (for non-forgettable lists)
memory,
// Flag to know if list was already fired
fired,
// Flag to know if list is currently firing
firing,
// First callback to fire (used internally by add and fireWith)
firingStart,
// End of the loop when firing
firingLength,
// Index of currently firing callback (modified by remove if needed)
firingIndex,
// Actual callback list
list = [],
// Stack of fire calls for repeatable lists
stack = !options.once && [],
// Fire callbacks
fire = function( data ) {
memory = options.memory && data;
fired = true;
firingIndex = firingStart || 0;
firingStart = 0;
firingLength = list.length;
firing = true;
for ( ; list && firingIndex < firingLength; firingIndex++ ) {
if ( list[ firingIndex ].apply( data[ 0 ], data[ 1 ] ) === false && options.stopOnFalse ) {
memory = false; // To prevent further calls using add
break;
}
}
firing = false;
if ( list ) {
if ( stack ) {
if ( stack.length ) {
fire( stack.shift() );
}
} else if ( memory ) {
list = [];
} else {
self.disable();
}
}
},
// Actual Callbacks object
self = {
// Add a callback or a collection of callbacks to the list
add: function() {
if ( list ) {
// First, we save the current length
var start = list.length;
(function add( args ) {
jQuery.each( args, function( _, arg ) {
var type = jQuery.type( arg );
if ( type === "function" ) {
if ( !options.unique || !self.has( arg ) ) {
list.push( arg );
}
} else if ( arg && arg.length && type !== "string" ) {
// Inspect recursively
add( arg );
}
});
})( arguments );
// Do we need to add the callbacks to the
// current firing batch?
if ( firing ) {
firingLength = list.length;
// With memory, if we're not firing then
// we should call right away
} else if ( memory ) {
firingStart = start;
fire( memory );
}
}
return this;
},
// Remove a callback from the list
remove: function() {
if ( list ) {
jQuery.each( arguments, function( _, arg ) {
var index;
while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) {
list.splice( index, 1 );
// Handle firing indexes
if ( firing ) {
if ( index <= firingLength ) {
firingLength--;
}
if ( index <= firingIndex ) {
firingIndex--;
}
}
}
});
}
return this;
},
// Check if a given callback is in the list.
// If no argument is given, return whether or not list has callbacks attached.
has: function( fn ) {
return fn ? jQuery.inArray( fn, list ) > -1 : !!( list && list.length );
},
// Remove all callbacks from the list
empty: function() {
list = [];
firingLength = 0;
return this;
},
// Have the list do nothing anymore
disable: function() {
list = stack = memory = undefined;
return this;
},
// Is it disabled?
disabled: function() {
return !list;
},
// Lock the list in its current state
lock: function() {
stack = undefined;
if ( !memory ) {
self.disable();
}
return this;
},
// Is it locked?
locked: function() {
return !stack;
},
// Call all callbacks with the given context and arguments
fireWith: function( context, args ) {
if ( list && ( !fired || stack ) ) {
args = args || [];
args = [ context, args.slice ? args.slice() : args ];
if ( firing ) {
stack.push( args );
} else {
fire( args );
}
}
return this;
},
// Call all the callbacks with the given arguments
fire: function() {
self.fireWith( this, arguments );
return this;
},
// To know if the callbacks have already been called at least once
fired: function() {
return !!fired;
}
};
return self;
};
jQuery.extend({
Deferred: function( func ) {
var tuples = [
// action, add listener, listener list, final state
[ "resolve", "done", jQuery.Callbacks("once memory"), "resolved" ],
[ "reject", "fail", jQuery.Callbacks("once memory"), "rejected" ],
[ "notify", "progress", jQuery.Callbacks("memory") ]
],
state = "pending",
promise = {
state: function() {
return state;
},
always: function() {
deferred.done( arguments ).fail( arguments );
return this;
},
then: function( /* fnDone, fnFail, fnProgress */ ) {
var fns = arguments;
return jQuery.Deferred(function( newDefer ) {
jQuery.each( tuples, function( i, tuple ) {
var fn = jQuery.isFunction( fns[ i ] ) && fns[ i ];
// deferred[ done | fail | progress ] for forwarding actions to newDefer
deferred[ tuple[1] ](function() {
var returned = fn && fn.apply( this, arguments );
if ( returned && jQuery.isFunction( returned.promise ) ) {
returned.promise()
.done( newDefer.resolve )
.fail( newDefer.reject )
.progress( newDefer.notify );
} else {
newDefer[ tuple[ 0 ] + "With" ]( this === promise ? newDefer.promise() : this, fn ? [ returned ] : arguments );
}
});
});
fns = null;
}).promise();
},
// Get a promise for this deferred
// If obj is provided, the promise aspect is added to the object
promise: function( obj ) {
return obj != null ? jQuery.extend( obj, promise ) : promise;
}
},
deferred = {};
// Keep pipe for back-compat
promise.pipe = promise.then;
// Add list-specific methods
jQuery.each( tuples, function( i, tuple ) {
var list = tuple[ 2 ],
stateString = tuple[ 3 ];
// promise[ done | fail | progress ] = list.add
promise[ tuple[1] ] = list.add;
// Handle state
if ( stateString ) {
list.add(function() {
// state = [ resolved | rejected ]
state = stateString;
// [ reject_list | resolve_list ].disable; progress_list.lock
}, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock );
}
// deferred[ resolve | reject | notify ]
deferred[ tuple[0] ] = function() {
deferred[ tuple[0] + "With" ]( this === deferred ? promise : this, arguments );
return this;
};
deferred[ tuple[0] + "With" ] = list.fireWith;
});
// Make the deferred a promise
promise.promise( deferred );
// Call given func if any
if ( func ) {
func.call( deferred, deferred );
}
// All done!
return deferred;
},
// Deferred helper
when: function( subordinate /* , ..., subordinateN */ ) {
var i = 0,
resolveValues = slice.call( arguments ),
length = resolveValues.length,
// the count of uncompleted subordinates
remaining = length !== 1 || ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0,
// the master Deferred. If resolveValues consist of only a single Deferred, just use that.
deferred = remaining === 1 ? subordinate : jQuery.Deferred(),
// Update function for both resolve and progress values
updateFunc = function( i, contexts, values ) {
return function( value ) {
contexts[ i ] = this;
values[ i ] = arguments.length > 1 ? slice.call( arguments ) : value;
if ( values === progressValues ) {
deferred.notifyWith( contexts, values );
} else if ( !( --remaining ) ) {
deferred.resolveWith( contexts, values );
}
};
},
progressValues, progressContexts, resolveContexts;
// Add listeners to Deferred subordinates; treat others as resolved
if ( length > 1 ) {
progressValues = new Array( length );
progressContexts = new Array( length );
resolveContexts = new Array( length );
for ( ; i < length; i++ ) {
if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) {
resolveValues[ i ].promise()
.done( updateFunc( i, resolveContexts, resolveValues ) )
.fail( deferred.reject )
.progress( updateFunc( i, progressContexts, progressValues ) );
} else {
--remaining;
}
}
}
// If we're not waiting on anything, resolve the master
if ( !remaining ) {
deferred.resolveWith( resolveContexts, resolveValues );
}
return deferred.promise();
}
});
// The deferred used on DOM ready
var readyList;
jQuery.fn.ready = function( fn ) {
// Add the callback
jQuery.ready.promise().done( fn );
return this;
};
jQuery.extend({
// Is the DOM ready to be used? Set to true once it occurs.
isReady: false,
// A counter to track how many items to wait for before
// the ready event fires. See #6781
readyWait: 1,
// Hold (or release) the ready event
holdReady: function( hold ) {
if ( hold ) {
jQuery.readyWait++;
} else {
jQuery.ready( true );
}
},
// Handle when the DOM is ready
ready: function( wait ) {
// Abort if there are pending holds or we're already ready
if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) {
return;
}
// Remember that the DOM is ready
jQuery.isReady = true;
// If a normal DOM Ready event fired, decrement, and wait if need be
if ( wait !== true && --jQuery.readyWait > 0 ) {
return;
}
// If there are functions bound, to execute
readyList.resolveWith( document, [ jQuery ] );
// Trigger any bound ready events
if ( jQuery.fn.triggerHandler ) {
jQuery( document ).triggerHandler( "ready" );
jQuery( document ).off( "ready" );
}
}
});
/**
* The ready event handler and self cleanup method
*/
function completed() {
document.removeEventListener( "DOMContentLoaded", completed, false );
window.removeEventListener( "load", completed, false );
jQuery.ready();
}
jQuery.ready.promise = function( obj ) {
if ( !readyList ) {
readyList = jQuery.Deferred();
// Catch cases where $(document).ready() is called after the browser event has already occurred.
// We once tried to use readyState "interactive" here, but it caused issues like the one
// discovered by ChrisS here: http://bugs.jquery.com/ticket/12282#comment:15
if ( document.readyState === "complete" ) {
// Handle it asynchronously to allow scripts the opportunity to delay ready
setTimeout( jQuery.ready );
} else {
// Use the handy event callback
document.addEventListener( "DOMContentLoaded", completed, false );
// A fallback to window.onload, that will always work
window.addEventListener( "load", completed, false );
}
}
return readyList.promise( obj );
};
// Kick off the DOM ready check even if the user does not
jQuery.ready.promise();
// Multifunctional method to get and set values of a collection
// The value/s can optionally be executed if it's a function
var access = jQuery.access = function( elems, fn, key, value, chainable, emptyGet, raw ) {
var i = 0,
len = elems.length,
bulk = key == null;
// Sets many values
if ( jQuery.type( key ) === "object" ) {
chainable = true;
for ( i in key ) {
jQuery.access( elems, fn, i, key[i], true, emptyGet, raw );
}
// Sets one value
} else if ( value !== undefined ) {
chainable = true;
if ( !jQuery.isFunction( value ) ) {
raw = true;
}
if ( bulk ) {
// Bulk operations run against the entire set
if ( raw ) {
fn.call( elems, value );
fn = null;
// ...except when executing function values
} else {
bulk = fn;
fn = function( elem, key, value ) {
return bulk.call( jQuery( elem ), value );
};
}
}
if ( fn ) {
for ( ; i < len; i++ ) {
fn( elems[i], key, raw ? value : value.call( elems[i], i, fn( elems[i], key ) ) );
}
}
}
return chainable ?
elems :
// Gets
bulk ?
fn.call( elems ) :
len ? fn( elems[0], key ) : emptyGet;
};
/**
* Determines whether an object can have data
*/
jQuery.acceptData = function( owner ) {
// Accepts only:
// - Node
// - Node.ELEMENT_NODE
// - Node.DOCUMENT_NODE
// - Object
// - Any
/* jshint -W018 */
return owner.nodeType === 1 || owner.nodeType === 9 || !( +owner.nodeType );
};
function Data() {
// Support: Android<4,
// Old WebKit does not have Object.preventExtensions/freeze method,
// return new empty object instead with no [[set]] accessor
Object.defineProperty( this.cache = {}, 0, {
get: function() {
return {};
}
});
this.expando = jQuery.expando + Data.uid++;
}
Data.uid = 1;
Data.accepts = jQuery.acceptData;
Data.prototype = {
key: function( owner ) {
// We can accept data for non-element nodes in modern browsers,
// but we should not, see #8335.
// Always return the key for a frozen object.
if ( !Data.accepts( owner ) ) {
return 0;
}
var descriptor = {},
// Check if the owner object already has a cache key
unlock = owner[ this.expando ];
// If not, create one
if ( !unlock ) {
unlock = Data.uid++;
// Secure it in a non-enumerable, non-writable property
try {
descriptor[ this.expando ] = { value: unlock };
Object.defineProperties( owner, descriptor );
// Support: Android<4
// Fallback to a less secure definition
} catch ( e ) {
descriptor[ this.expando ] = unlock;
jQuery.extend( owner, descriptor );
}
}
// Ensure the cache object
if ( !this.cache[ unlock ] ) {
this.cache[ unlock ] = {};
}
return unlock;
},
set: function( owner, data, value ) {
var prop,
// There may be an unlock assigned to this node,
// if there is no entry for this "owner", create one inline
// and set the unlock as though an owner entry had always existed
unlock = this.key( owner ),
cache = this.cache[ unlock ];
// Handle: [ owner, key, value ] args
if ( typeof data === "string" ) {
cache[ data ] = value;
// Handle: [ owner, { properties } ] args
} else {
// Fresh assignments by object are shallow copied
if ( jQuery.isEmptyObject( cache ) ) {
jQuery.extend( this.cache[ unlock ], data );
// Otherwise, copy the properties one-by-one to the cache object
} else {
for ( prop in data ) {
cache[ prop ] = data[ prop ];
}
}
}
return cache;
},
get: function( owner, key ) {
// Either a valid cache is found, or will be created.
// New caches will be created and the unlock returned,
// allowing direct access to the newly created
// empty data object. A valid owner object must be provided.
var cache = this.cache[ this.key( owner ) ];
return key === undefined ?
cache : cache[ key ];
},
access: function( owner, key, value ) {
var stored;
// In cases where either:
//
// 1. No key was specified
// 2. A string key was specified, but no value provided
//
// Take the "read" path and allow the get method to determine
// which value to return, respectively either:
//
// 1. The entire cache object
// 2. The data stored at the key
//
if ( key === undefined ||
((key && typeof key === "string") && value === undefined) ) {
stored = this.get( owner, key );
return stored !== undefined ?
stored : this.get( owner, jQuery.camelCase(key) );
}
// [*]When the key is not a string, or both a key and value
// are specified, set or extend (existing objects) with either:
//
// 1. An object of properties
// 2. A key and value
//
this.set( owner, key, value );
// Since the "set" path can have two possible entry points
// return the expected data based on which path was taken[*]
return value !== undefined ? value : key;
},
remove: function( owner, key ) {
var i, name, camel,
unlock = this.key( owner ),
cache = this.cache[ unlock ];
if ( key === undefined ) {
this.cache[ unlock ] = {};
} else {
// Support array or space separated string of keys
if ( jQuery.isArray( key ) ) {
// If "name" is an array of keys...
// When data is initially created, via ("key", "val") signature,
// keys will be converted to camelCase.
// Since there is no way to tell _how_ a key was added, remove
// both plain key and camelCase key. #12786
// This will only penalize the array argument path.
name = key.concat( key.map( jQuery.camelCase ) );
} else {
camel = jQuery.camelCase( key );
// Try the string as a key before any manipulation
if ( key in cache ) {
name = [ key, camel ];
} else {
// If a key with the spaces exists, use it.
// Otherwise, create an array by matching non-whitespace
name = camel;
name = name in cache ?
[ name ] : ( name.match( rnotwhite ) || [] );
}
}
i = name.length;
while ( i-- ) {
delete cache[ name[ i ] ];
}
}
},
hasData: function( owner ) {
return !jQuery.isEmptyObject(
this.cache[ owner[ this.expando ] ] || {}
);
},
discard: function( owner ) {
if ( owner[ this.expando ] ) {
delete this.cache[ owner[ this.expando ] ];
}
}
};
var data_priv = new Data();
var data_user = new Data();
// Implementation Summary
//
// 1. Enforce API surface and semantic compatibility with 1.9.x branch
// 2. Improve the module's maintainability by reducing the storage
// paths to a single mechanism.
// 3. Use the same single mechanism to support "private" and "user" data.
// 4. _Never_ expose "private" data to user code (TODO: Drop _data, _removeData)
// 5. Avoid exposing implementation details on user objects (eg. expando properties)
// 6. Provide a clear path for implementation upgrade to WeakMap in 2014
var rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,
rmultiDash = /([A-Z])/g;
function dataAttr( elem, key, data ) {
var name;
// If nothing was found internally, try to fetch any
// data from the HTML5 data-* attribute
if ( data === undefined && elem.nodeType === 1 ) {
name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase();
data = elem.getAttribute( name );
if ( typeof data === "string" ) {
try {
data = data === "true" ? true :
data === "false" ? false :
data === "null" ? null :
// Only convert to a number if it doesn't change the string
+data + "" === data ? +data :
rbrace.test( data ) ? jQuery.parseJSON( data ) :
data;
} catch( e ) {}
// Make sure we set the data so it isn't changed later
data_user.set( elem, key, data );
} else {
data = undefined;
}
}
return data;
}
jQuery.extend({
hasData: function( elem ) {
return data_user.hasData( elem ) || data_priv.hasData( elem );
},
data: function( elem, name, data ) {
return data_user.access( elem, name, data );
},
removeData: function( elem, name ) {
data_user.remove( elem, name );
},
// TODO: Now that all calls to _data and _removeData have been replaced
// with direct calls to data_priv methods, these can be deprecated.
_data: function( elem, name, data ) {
return data_priv.access( elem, name, data );
},
_removeData: function( elem, name ) {
data_priv.remove( elem, name );
}
});
jQuery.fn.extend({
data: function( key, value ) {
var i, name, data,
elem = this[ 0 ],
attrs = elem && elem.attributes;
// Gets all values
if ( key === undefined ) {
if ( this.length ) {
data = data_user.get( elem );
if ( elem.nodeType === 1 && !data_priv.get( elem, "hasDataAttrs" ) ) {
i = attrs.length;
while ( i-- ) {
// Support: IE11+
// The attrs elements can be null (#14894)
if ( attrs[ i ] ) {
name = attrs[ i ].name;
if ( name.indexOf( "data-" ) === 0 ) {
name = jQuery.camelCase( name.slice(5) );
dataAttr( elem, name, data[ name ] );
}
}
}
data_priv.set( elem, "hasDataAttrs", true );
}
}
return data;
}
// Sets multiple values
if ( typeof key === "object" ) {
return this.each(function() {
data_user.set( this, key );
});
}
return access( this, function( value ) {
var data,
camelKey = jQuery.camelCase( key );
// The calling jQuery object (element matches) is not empty
// (and therefore has an element appears at this[ 0 ]) and the
// `value` parameter was not undefined. An empty jQuery object
// will result in `undefined` for elem = this[ 0 ] which will
// throw an exception if an attempt to read a data cache is made.
if ( elem && value === undefined ) {
// Attempt to get data from the cache
// with the key as-is
data = data_user.get( elem, key );
if ( data !== undefined ) {
return data;
}
// Attempt to get data from the cache
// with the key camelized
data = data_user.get( elem, camelKey );
if ( data !== undefined ) {
return data;
}
// Attempt to "discover" the data in
// HTML5 custom data-* attrs
data = dataAttr( elem, camelKey, undefined );
if ( data !== undefined ) {
return data;
}
// We tried really hard, but the data doesn't exist.
return;
}
// Set the data...
this.each(function() {
// First, attempt to store a copy or reference of any
// data that might've been store with a camelCased key.
var data = data_user.get( this, camelKey );
// For HTML5 data-* attribute interop, we have to
// store property names with dashes in a camelCase form.
// This might not apply to all properties...*
data_user.set( this, camelKey, value );
// *... In the case of properties that might _actually_
// have dashes, we need to also store a copy of that
// unchanged property.
if ( key.indexOf("-") !== -1 && data !== undefined ) {
data_user.set( this, key, value );
}
});
}, null, value, arguments.length > 1, null, true );
},
removeData: function( key ) {
return this.each(function() {
data_user.remove( this, key );
});
}
});
jQuery.extend({
queue: function( elem, type, data ) {
var queue;
if ( elem ) {
type = ( type || "fx" ) + "queue";
queue = data_priv.get( elem, type );
// Speed up dequeue by getting out quickly if this is just a lookup
if ( data ) {
if ( !queue || jQuery.isArray( data ) ) {
queue = data_priv.access( elem, type, jQuery.makeArray(data) );
} else {
queue.push( data );
}
}
return queue || [];
}
},
dequeue: function( elem, type ) {
type = type || "fx";
var queue = jQuery.queue( elem, type ),
startLength = queue.length,
fn = queue.shift(),
hooks = jQuery._queueHooks( elem, type ),
next = function() {
jQuery.dequeue( elem, type );
};
// If the fx queue is dequeued, always remove the progress sentinel
if ( fn === "inprogress" ) {
fn = queue.shift();
startLength--;
}
if ( fn ) {
// Add a progress sentinel to prevent the fx queue from being
// automatically dequeued
if ( type === "fx" ) {
queue.unshift( "inprogress" );
}
// Clear up the last queue stop function
delete hooks.stop;
fn.call( elem, next, hooks );
}
if ( !startLength && hooks ) {
hooks.empty.fire();
}
},
// Not public - generate a queueHooks object, or return the current one
_queueHooks: function( elem, type ) {
var key = type + "queueHooks";
return data_priv.get( elem, key ) || data_priv.access( elem, key, {
empty: jQuery.Callbacks("once memory").add(function() {
data_priv.remove( elem, [ type + "queue", key ] );
})
});
}
});
jQuery.fn.extend({
queue: function( type, data ) {
var setter = 2;
if ( typeof type !== "string" ) {
data = type;
type = "fx";
setter--;
}
if ( arguments.length < setter ) {
return jQuery.queue( this[0], type );
}
return data === undefined ?
this :
this.each(function() {
var queue = jQuery.queue( this, type, data );
// Ensure a hooks for this queue
jQuery._queueHooks( this, type );
if ( type === "fx" && queue[0] !== "inprogress" ) {
jQuery.dequeue( this, type );
}
});
},
dequeue: function( type ) {
return this.each(function() {
jQuery.dequeue( this, type );
});
},
clearQueue: function( type ) {
return this.queue( type || "fx", [] );
},
// Get a promise resolved when queues of a certain type
// are emptied (fx is the type by default)
promise: function( type, obj ) {
var tmp,
count = 1,
defer = jQuery.Deferred(),
elements = this,
i = this.length,
resolve = function() {
if ( !( --count ) ) {
defer.resolveWith( elements, [ elements ] );
}
};
if ( typeof type !== "string" ) {
obj = type;
type = undefined;
}
type = type || "fx";
while ( i-- ) {
tmp = data_priv.get( elements[ i ], type + "queueHooks" );
if ( tmp && tmp.empty ) {
count++;
tmp.empty.add( resolve );
}
}
resolve();
return defer.promise( obj );
}
});
var pnum = (/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/).source;
var cssExpand = [ "Top", "Right", "Bottom", "Left" ];
var isHidden = function( elem, el ) {
// isHidden might be called from jQuery#filter function;
// in that case, element will be second argument
elem = el || elem;
return jQuery.css( elem, "display" ) === "none" || !jQuery.contains( elem.ownerDocument, elem );
};
var rcheckableType = (/^(?:checkbox|radio)$/i);
(function() {
var fragment = document.createDocumentFragment(),
div = fragment.appendChild( document.createElement( "div" ) ),
input = document.createElement( "input" );
// Support: Safari<=5.1
// Check state lost if the name is set (#11217)
// Support: Windows Web Apps (WWA)
// `name` and `type` must use .setAttribute for WWA (#14901)
input.setAttribute( "type", "radio" );
input.setAttribute( "checked", "checked" );
input.setAttribute( "name", "t" );
div.appendChild( input );
// Support: Safari<=5.1, Android<4.2
// Older WebKit doesn't clone checked state correctly in fragments
support.checkClone = div.cloneNode( true ).cloneNode( true ).lastChild.checked;
// Support: IE<=11+
// Make sure textarea (and checkbox) defaultValue is properly cloned
div.innerHTML = "";
support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue;
})();
var strundefined = typeof undefined;
support.focusinBubbles = "onfocusin" in window;
var
rkeyEvent = /^key/,
rmouseEvent = /^(?:mouse|pointer|contextmenu)|click/,
rfocusMorph = /^(?:focusinfocus|focusoutblur)$/,
rtypenamespace = /^([^.]*)(?:\.(.+)|)$/;
function returnTrue() {
return true;
}
function returnFalse() {
return false;
}
function safeActiveElement() {
try {
return document.activeElement;
} catch ( err ) { }
}
/*
* Helper functions for managing events -- not part of the public interface.
* Props to Dean Edwards' addEvent library for many of the ideas.
*/
jQuery.event = {
global: {},
add: function( elem, types, handler, data, selector ) {
var handleObjIn, eventHandle, tmp,
events, t, handleObj,
special, handlers, type, namespaces, origType,
elemData = data_priv.get( elem );
// Don't attach events to noData or text/comment nodes (but allow plain objects)
if ( !elemData ) {
return;
}
// Caller can pass in an object of custom data in lieu of the handler
if ( handler.handler ) {
handleObjIn = handler;
handler = handleObjIn.handler;
selector = handleObjIn.selector;
}
// Make sure that the handler has a unique ID, used to find/remove it later
if ( !handler.guid ) {
handler.guid = jQuery.guid++;
}
// Init the element's event structure and main handler, if this is the first
if ( !(events = elemData.events) ) {
events = elemData.events = {};
}
if ( !(eventHandle = elemData.handle) ) {
eventHandle = elemData.handle = function( e ) {
// Discard the second event of a jQuery.event.trigger() and
// when an event is called after a page has unloaded
return typeof jQuery !== strundefined && jQuery.event.triggered !== e.type ?
jQuery.event.dispatch.apply( elem, arguments ) : undefined;
};
}
// Handle multiple events separated by a space
types = ( types || "" ).match( rnotwhite ) || [ "" ];
t = types.length;
while ( t-- ) {
tmp = rtypenamespace.exec( types[t] ) || [];
type = origType = tmp[1];
namespaces = ( tmp[2] || "" ).split( "." ).sort();
// There *must* be a type, no attaching namespace-only handlers
if ( !type ) {
continue;
}
// If event changes its type, use the special event handlers for the changed type
special = jQuery.event.special[ type ] || {};
// If selector defined, determine special event api type, otherwise given type
type = ( selector ? special.delegateType : special.bindType ) || type;
// Update special based on newly reset type
special = jQuery.event.special[ type ] || {};
// handleObj is passed to all event handlers
handleObj = jQuery.extend({
type: type,
origType: origType,
data: data,
handler: handler,
guid: handler.guid,
selector: selector,
needsContext: selector && jQuery.expr.match.needsContext.test( selector ),
namespace: namespaces.join(".")
}, handleObjIn );
// Init the event handler queue if we're the first
if ( !(handlers = events[ type ]) ) {
handlers = events[ type ] = [];
handlers.delegateCount = 0;
// Only use addEventListener if the special events handler returns false
if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) {
if ( elem.addEventListener ) {
elem.addEventListener( type, eventHandle, false );
}
}
}
if ( special.add ) {
special.add.call( elem, handleObj );
if ( !handleObj.handler.guid ) {
handleObj.handler.guid = handler.guid;
}
}
// Add to the element's handler list, delegates in front
if ( selector ) {
handlers.splice( handlers.delegateCount++, 0, handleObj );
} else {
handlers.push( handleObj );
}
// Keep track of which events have ever been used, for event optimization
jQuery.event.global[ type ] = true;
}
},
// Detach an event or set of events from an element
remove: function( elem, types, handler, selector, mappedTypes ) {
var j, origCount, tmp,
events, t, handleObj,
special, handlers, type, namespaces, origType,
elemData = data_priv.hasData( elem ) && data_priv.get( elem );
if ( !elemData || !(events = elemData.events) ) {
return;
}
// Once for each type.namespace in types; type may be omitted
types = ( types || "" ).match( rnotwhite ) || [ "" ];
t = types.length;
while ( t-- ) {
tmp = rtypenamespace.exec( types[t] ) || [];
type = origType = tmp[1];
namespaces = ( tmp[2] || "" ).split( "." ).sort();
// Unbind all events (on this namespace, if provided) for the element
if ( !type ) {
for ( type in events ) {
jQuery.event.remove( elem, type + types[ t ], handler, selector, true );
}
continue;
}
special = jQuery.event.special[ type ] || {};
type = ( selector ? special.delegateType : special.bindType ) || type;
handlers = events[ type ] || [];
tmp = tmp[2] && new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" );
// Remove matching events
origCount = j = handlers.length;
while ( j-- ) {
handleObj = handlers[ j ];
if ( ( mappedTypes || origType === handleObj.origType ) &&
( !handler || handler.guid === handleObj.guid ) &&
( !tmp || tmp.test( handleObj.namespace ) ) &&
( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) {
handlers.splice( j, 1 );
if ( handleObj.selector ) {
handlers.delegateCount--;
}
if ( special.remove ) {
special.remove.call( elem, handleObj );
}
}
}
// Remove generic event handler if we removed something and no more handlers exist
// (avoids potential for endless recursion during removal of special event handlers)
if ( origCount && !handlers.length ) {
if ( !special.teardown || special.teardown.call( elem, namespaces, elemData.handle ) === false ) {
jQuery.removeEvent( elem, type, elemData.handle );
}
delete events[ type ];
}
}
// Remove the expando if it's no longer used
if ( jQuery.isEmptyObject( events ) ) {
delete elemData.handle;
data_priv.remove( elem, "events" );
}
},
trigger: function( event, data, elem, onlyHandlers ) {
var i, cur, tmp, bubbleType, ontype, handle, special,
eventPath = [ elem || document ],
type = hasOwn.call( event, "type" ) ? event.type : event,
namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split(".") : [];
cur = tmp = elem = elem || document;
// Don't do events on text and comment nodes
if ( elem.nodeType === 3 || elem.nodeType === 8 ) {
return;
}
// focus/blur morphs to focusin/out; ensure we're not firing them right now
if ( rfocusMorph.test( type + jQuery.event.triggered ) ) {
return;
}
if ( type.indexOf(".") >= 0 ) {
// Namespaced trigger; create a regexp to match event type in handle()
namespaces = type.split(".");
type = namespaces.shift();
namespaces.sort();
}
ontype = type.indexOf(":") < 0 && "on" + type;
// Caller can pass in a jQuery.Event object, Object, or just an event type string
event = event[ jQuery.expando ] ?
event :
new jQuery.Event( type, typeof event === "object" && event );
// Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true)
event.isTrigger = onlyHandlers ? 2 : 3;
event.namespace = namespaces.join(".");
event.namespace_re = event.namespace ?
new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ) :
null;
// Clean up the event in case it is being reused
event.result = undefined;
if ( !event.target ) {
event.target = elem;
}
// Clone any incoming data and prepend the event, creating the handler arg list
data = data == null ?
[ event ] :
jQuery.makeArray( data, [ event ] );
// Allow special events to draw outside the lines
special = jQuery.event.special[ type ] || {};
if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) {
return;
}
// Determine event propagation path in advance, per W3C events spec (#9951)
// Bubble up to document, then to window; watch for a global ownerDocument var (#9724)
if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) {
bubbleType = special.delegateType || type;
if ( !rfocusMorph.test( bubbleType + type ) ) {
cur = cur.parentNode;
}
for ( ; cur; cur = cur.parentNode ) {
eventPath.push( cur );
tmp = cur;
}
// Only add window if we got to document (e.g., not plain obj or detached DOM)
if ( tmp === (elem.ownerDocument || document) ) {
eventPath.push( tmp.defaultView || tmp.parentWindow || window );
}
}
// Fire handlers on the event path
i = 0;
while ( (cur = eventPath[i++]) && !event.isPropagationStopped() ) {
event.type = i > 1 ?
bubbleType :
special.bindType || type;
// jQuery handler
handle = ( data_priv.get( cur, "events" ) || {} )[ event.type ] && data_priv.get( cur, "handle" );
if ( handle ) {
handle.apply( cur, data );
}
// Native handler
handle = ontype && cur[ ontype ];
if ( handle && handle.apply && jQuery.acceptData( cur ) ) {
event.result = handle.apply( cur, data );
if ( event.result === false ) {
event.preventDefault();
}
}
}
event.type = type;
// If nobody prevented the default action, do it now
if ( !onlyHandlers && !event.isDefaultPrevented() ) {
if ( (!special._default || special._default.apply( eventPath.pop(), data ) === false) &&
jQuery.acceptData( elem ) ) {
// Call a native DOM method on the target with the same name name as the event.
// Don't do default actions on window, that's where global variables be (#6170)
if ( ontype && jQuery.isFunction( elem[ type ] ) && !jQuery.isWindow( elem ) ) {
// Don't re-trigger an onFOO event when we call its FOO() method
tmp = elem[ ontype ];
if ( tmp ) {
elem[ ontype ] = null;
}
// Prevent re-triggering of the same event, since we already bubbled it above
jQuery.event.triggered = type;
elem[ type ]();
jQuery.event.triggered = undefined;
if ( tmp ) {
elem[ ontype ] = tmp;
}
}
}
}
return event.result;
},
dispatch: function( event ) {
// Make a writable jQuery.Event from the native event object
event = jQuery.event.fix( event );
var i, j, ret, matched, handleObj,
handlerQueue = [],
args = slice.call( arguments ),
handlers = ( data_priv.get( this, "events" ) || {} )[ event.type ] || [],
special = jQuery.event.special[ event.type ] || {};
// Use the fix-ed jQuery.Event rather than the (read-only) native event
args[0] = event;
event.delegateTarget = this;
// Call the preDispatch hook for the mapped type, and let it bail if desired
if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) {
return;
}
// Determine handlers
handlerQueue = jQuery.event.handlers.call( this, event, handlers );
// Run delegates first; they may want to stop propagation beneath us
i = 0;
while ( (matched = handlerQueue[ i++ ]) && !event.isPropagationStopped() ) {
event.currentTarget = matched.elem;
j = 0;
while ( (handleObj = matched.handlers[ j++ ]) && !event.isImmediatePropagationStopped() ) {
// Triggered event must either 1) have no namespace, or 2) have namespace(s)
// a subset or equal to those in the bound event (both can have no namespace).
if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) {
event.handleObj = handleObj;
event.data = handleObj.data;
ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler )
.apply( matched.elem, args );
if ( ret !== undefined ) {
if ( (event.result = ret) === false ) {
event.preventDefault();
event.stopPropagation();
}
}
}
}
}
// Call the postDispatch hook for the mapped type
if ( special.postDispatch ) {
special.postDispatch.call( this, event );
}
return event.result;
},
handlers: function( event, handlers ) {
var i, matches, sel, handleObj,
handlerQueue = [],
delegateCount = handlers.delegateCount,
cur = event.target;
// Find delegate handlers
// Black-hole SVG
{!! markdown($comment->content) !!}
================================================
FILE: resources/views/emails/password.blade.php
================================================
Click here to reset your password: {{ route('reset.create', $token) }}
================================================
FILE: resources/views/errors/503.blade.php
================================================
Be right back.
... when during a Skiing holiday with Lara Croft, an unfortunate tumble led to me becoming Enveloped within her ample bosom. I had become Laraveloped ...
라라벨 4 프로젝트 이름으로 사용했었고, 지금은 라라벨 코어 콤포넌트들의 네임스페이스로도 사용하고 있는 Illumination 도 툼레이더 Tomb Raider 에서 등장하는 비밀결사 조직인 Illuminati 에서 따온 것으로 추정된다. 여러가지 정황상 라라벨의 라라는 툼레이더의 주인공인 라라크로프트 Lara Croft 일 가능성이 높다. 역시 동서고금을 막론하고, 남자들이 모이면 기-승-전-여자 인가 보다 ^^/. 어쨌든...
라라벨은 여성 이름으로도 사용되기도 하는데, 이 이름을 가진 이들의 공통 특징은 침착함, 우아함, 준비됨의 성격을 가진다고 알려져 있다. 라라벨에 입문하고, 프로젝트를 진행해 보면, 쓰면 쓸수록 참 매력적인 프레임웍인 것을 몸소 체험하게 될 것이다.
라라벨을 배워야 하는 이유? 그것도 오늘 당장?
라라벨은 표현력이 풍부한 API 와 우아한 문법, 무한한 확장성을 제공한다. 그 이유는 PSR PHP Standard Recommendations, 컴포저 Composer 에 의한 의존성 관리 등 PHP 생태계에서 제공하는 개발 표준을 준수하기 때문이다.
# PHP 에서 Image 조작을 위해서 아래 컴포넌트를 composer 로 설치한다.
$ composer require "intervention/image:2.3.*"
// 웹 서버 Document Root 디렉토리 밖에 있는 이미지를 요청하기 위한 Route 정의이다.
// app/Http/routes.php
Route::get('lessons/{file}', 'LessonsController@image');
// 좀 전에 Composer 로 설치한 intervention/image 컴포넌트를 이용해서 image/png 응답을 한다.
// app/Http/Controllers/LessonsController.php
<\?php
namespace App\Http\Controllers;
class LessonsController extends Controller {
public function image($file)
{
$image = \Image::make('images/' . $file);
return response($image->encode('png'), 200, ['Content-Type' => 'image/png']);
}
}
라라벨은 프론트엔드 프레임웍 jQuery, VueJs, ReactJs, ... 의 선택에는 관여하지 않지만, 클라이언트 사이드를 위한 기능들도 포함하고 있는 풀 스택 프레임웍이다. MVC Model View Controller 웹 프레임웍이 가진 기본 기능이라 할 수 있는 자체 템플릿 문법을 이용한 서버사이드 뷰 렌더링을 제공할 뿐 아니라, 프론트엔드 빌드 자동화를 위한 엘릭서 Elixir - a Gulp Task Runner 기능도 제공한다. 엘릭서에는 Babel, BrowserSync, Cache Bursting, ... 거의 모든 최신 빌드 레시피가 기본 포함되어 있다. 그 뿐인가? 팀 협업 및 개발 환경의 단일화를 위한 홈스테드 Homestead - Ubuntu VM powered by Vagrant, 원격 서버로의 코드 배포 등을 자동화할 수 있는 엔보이 Envoy - SSH Task Runner 까지 제공되니, 그 세심한 배려에 감동하지 않을 수 없다.
Laravel continues its path toward world conquest with Lumen, a new (and well-done) PHP micro framework
Modern PHP, PHP The Right Way 를 쓰면서 PHP 사용자 커뮤니티에서 위대한 리더로 자리 잡고 있는 조시 Josh Lockhart - Modern PHP Evangelist 도, 라라벨의 간결하고 우아함에 감탄하며, 자신이 개발하고 유지하던 Slim Framework 에서 잠시 손을 놓았다가 최근에 3.0 업데이트를 내 놓기도 했다. 가장 일관성이 없는 언어라는 비난을 받던 PHP 가, 조시와 같은 선구자 및 라라벨의 등장으로 인해 그 위상이 굉장히 많이 높아졌다고 필자는 평가한다.
PHP 개발자라면 지금 당장 라라벨 우주선에 올라타야 한다. 조시의 말대로, 해외에서는 라라벨과 루멘 Lumen - A Micro Framework based on Laravel 으로 대동단결하는 추세다, 마치 Java/Spring, Ruby on Rails, python/Django, 각 언어별로 하나의 프레임웍이 사용자들의 사랑을 받는 것 처럼. 과거의 절차 지향 방식의 PHP 개발방법론은 대형 프로젝트에 사용이 어렵다. 물론, 토이 프로젝트에 프레임웍을 사용할 필요는 없다. 하지만, 개발자 본인 스스로와 PHP 개발자 그룹 전체의 몸값을 높이기 위해서, 꼭 라라벨이 아니더라도 CodeIgniter, CakePHP, Slim 과 같은 PHP 프레임웍을 공부할 것을 권장한다.
이유야 어떻든 PHP 를 배우려는 분이라면, 라라벨을 선택할 것을 강력히 추천한다. 필자도 Ruby On Rails 를 쓰다가 2013년 경에 라라벨 3 버전을 때 넘어 왔다. 각 프레임웍들이 가지고 있는 기능들의 많고 적음 및 언어 고유의 특성에 의한 약간의 차이가 있지만, 각 기능들의 사용법은 크게 다르지 않다고 생각한다. 하나의 프레임웍을 자유자재로 쓸 수 있다면, 다른 언어/프레임웍으로 전향할 때 학습속도는 말도 못하게 빨라진다. 필자도 그랬으니까...
코스 소개
입문 코스는 완성되었고 내용이 더 변경될 것이 없을 것 같다. 중급 및 실전 코스는 강좌를 계속 작성하고 있다. 중급 이상 강좌를 쓰는 중에 느낀 점은, 라라벨 고유의 기능보다는 프로그래밍 일반적인 것들이 많다는 것인데, 문맥상 설명에 필요하지 않는 부분들은 과감히 생략되었으니 독자들의 양해를 부탁드린다.
입문 코스
입문자가 꼭 알아야 할 내용만을 추렸다. 이 정도만 익혀도 실전에 투입할 수 있다고 생각한다.
라라벨 설치 및 Hello World 출력, 환경 변수 설정, 라우팅, 블레이드 템플릿 문법, 데이터베이스 연결, 테이블 마이그레이션, 데이터 씨딩, 데이터베이스 쿼리, 엘로퀀트 Eloquent - 라라벨의 ORM, 모델과 컨트롤러, 사용자 인증, 메일보내기, 이벤트 트리거 및 처리, 사용자 입력갑 유효성 검사, 컴포저 사용법 등 을 배운다.
실전 프로젝트를 통해서, 서비스를 기획하고, 구현 방안 및 구조에 대한 디자인 의사결정을 하는 과정을 같이 공부해 보고 싶었다.
이 강좌를 웹 페이지에서 볼 수 있도록 하는 Markdown Viewer 를 먼저 만들어 볼 것이다. 커뮤니티에서 주로 사용하는 댓글이 가능한 포럼을 만들어 볼 것이며, 이 포럼을 다른 디바이스에서도 사용할 수 있도록 RESTful API 서비스로 포장하는 과정을 진행해 볼 것이다.
>Blockquotes are very handy in email to emulate reply text. > This line is part of the same
quote.
Quote break.
> This is a very long line that will still be quoted properly
when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone.
Oh, you can *put* **Markdown** into a blockquote.
{!! markdown('>Blockquotes are very handy in email to emulate reply text.
>This line is part of the same quote.
Quote break.
> This is a very long line that will still be quoted properly when it wraps. Oh boy let\'s keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.') !!}
---
{!! markdown('---') !!}
[](http://www.youtube.com/watch?v=dhI9bsQwFRw)
{!! markdown('[](http://www.youtube.com/watch?v=dhI9bsQwFRw)') !!}
That's some text with a footnote.[^1] [^1]: And that's the footnote.
{!! markdown('That\'s some text with a footnote.[^1]
[^1]: And that\'s the footnote.') !!}
\_Markdown tries to translate paired underscores as an emphasis. Use back slash to escape_
{!! markdown('\_Markdown tries to translate paired underscores as an emphasis. Use back slash to escape_') !!}