『NoSQLってどう使うの? CouchDBでポケモンGOレコーダーを作ってみた』では、CouchDBについて紹介しました。今回は学んだことを活かして本格的なアプリを作ります。記事の最後では、データベースの保護方法についても解説します。
プロジェクトの概要
ポケモンの出現場所を記録するアプリを作成します。
このアプリではユーザーがポケモンGOで出会ったポケモンの場所を保存できます。Googleマップを位置検索に使い、マーカーで正確な位置を示します。ここだと思える場所でマーカーに触ると、ユーザーがポケモンの名前を入力して場所を保存できるモーダルウィンドウが表示されます。次のユーザーが参加して同じ場所を検索すると、前のユーザーが追加したデータが地図上にマーカーで表示されます。アプリは次のような感じになります。
このプロジェクトの全ソースコードはGithubにあります。
開発環境のセットアップ
アプリの開発に適した単独で動作する開発環境がなければ、Homestead Improvedをお勧めします。
HomesteadのボックスにはCouchDBが含まれていないので 、手動でインストールする必要があります(CouchDB以外にも手動でインストールが必要なものがあります)。アプリはジオデータ(緯度と経度)を利用するので、 バウンディングボックスの情報をGoogleマップから取得してCouchDBに格納します。バウンディングボックスはマップで現在表示している地域を表し、GeoCouchというプラグインをインストールしてCouchDBがデフォルトでは対応していない空間機能を追加します。
もっとも簡単な方法はGeoCouch Dockerコンテナの利用です。GeoCouchは手動でもインストールできますが、ソースからビルドして環境を構築したCouchDBをインストールする必要があります。UNIXの知識がない人にはまったくお勧めできない方法です。
作業を開始し、Dockerを使用する仮想マシンにインストールします。完了したら記事に戻ってください。
GeoCouchのインストール
はじめにリポジトリを複製し、作成したディレクトリへ移動します。
git clone git@github.com:elecnix/docker-geocouch.git
cd docker-geocouch
次にDockerfileを開き、CouchDBをダウンロードする次のようなスクリプトに書き換えます。
# Get the CouchDB source
RUN cd /opt; wget http://www-eu.apache.org/dist/couchdb/source/${COUCH_VERSION}/a$
tar xzf /opt/apache-couchdb-${COUCH_VERSION}.tar.gz
現在使われているダウンロードURLはすでに機能していないため、この作業が必要です。
Dockerイメージを作成します。
docker build -t elecnix/docker-geocouch:1.6.1 .
インターネットの接続状況によっては時間がかかるので休憩でもしてください。完了したら、コンテナを作成して起動します。
docker create -ti -p 5984:5984 elecnix/docker-geocouch:1.6.1
docker start <container id>
起動できたら、次のコマンドを実行して動作しているかテストします。
curl localhost:5984
仮想マシン外でポートを正確に転送する場合、コマンドは次のようになります。
curl 192.168.33.10:5984
次のように返ってくるはずです。
{"couchdb":"Welcome","uuid":"2f0b5e00e9ce08996ace6e66ffc1dfa3","version":"1.6.1","vendor":{"version":"1.6.1","name":"The Apache Software Foundation"}}
記事では主に192.168.33.10をIPに使います。これは私が使用しているVagrant boxのScotchboxに割り当てられたIPです。Homestead Improvedを使う場合のIPは192.168.10.10です。アプリへアクセスするときにはこのIPを使用できます。もしまったく別のツールを使用している場合は、必要に応じて適切なIPを使ってください。
プロジェクトのセットアップ
アプリの開発速度を上げるため、Slimフレームワークを使用します。Composerを使って新しいプロジェクトを作成しましょう。
php composer create-project slim/slim-skeleton pokespawn
pokespawnはプロジェクト名です。Composerのインストールができたらディレクトリに移動して作業を進めます。
composer require danrovito/pokephp guzzlehttp/guzzle gregwar/image vlucas/phpdotenv
各コマンドの概要は次のとおりです。
- danrovito/pokephp – ポケモンGOのAPIの呼び出し
- guzzlehttp/guzzle – CouchDBサーバーへリクエストを送信
- gregwar/image – ポケモンGOのAPIから返ってくるポケモンスプライト画像のリサイズ
- vlucas/phpdotenv – 構成データの格納
データベースのセットアップ
ブラウザーからFutonへアクセスし、pokespawnという名前で新しいデータベースを作成します。作成できたらデータベース上で新規ビューを作成します。ビューのドロップダウンメニューをクリックし、Temporary viewを選択すると作成できます。Map Functionのテキストエリアに次のように入力します。
function(doc){
if(doc.doc_type == 'pokemon'){
emit(doc.name, null);
}
}
入力できたらデザインドキュメント名(Design Document)にpokemon、ビューの名前(View Name)にby_nameと入力してsaveボタンをクリックします。後ほど、ユーザーの入力にもとづいたポケモンネームの指定にこのビューを使います。
次に、空間検索にレスポンスするためのデザインドキュメントを作成します。ビューのドロップダウンメニューからデザインドキュメントを選択し、新規ドキュメントをクリックします。デザインドキュメントの作成ページでは、フィールドボタンをクリックしてフィールド名にspatial、valueに次のように入力します。
{
"points": "function(doc) {\n if (doc.loc) {\n emit([{\n type: \"Point\",\n coordinates: [doc.loc[0], doc.loc[1]]\n }], [doc.name, doc.sprite]);\n }};"
}
このデザインドキュメントはGeoCouchの空間関数を利用します。最初にドキュメントにlocフィールドがあるかをチェックします。locフィールドは空間位置座標を含む配列(1列目に緯度、2列目に経度)です。ドキュメントがこの基準を満たしていれば、普通のビューのようにemit()関数を使用します。keyはGeoJSONジオメトリで、データ配列にはポケモンの名前とスプライト画像が含まれています。
デザインドキュメントにリクエストを送信するときは、JSON形式の配列でstart_rangeとend_rangeを指定する必要あります。どちらにも数字かnullを入れられます。nullはopen rangeにしたいときに使用します。次はリクエストのサンプルです。
curl -X GET --globoff 'http://192.168.33.10:5984/pokespawn/_design/location/_spatial/points?start_range=[-33.87049924568689,151.2149563379288]&end_range=[33.86709181198735,151.22298150730137]'
出力は次のようになります。
{
"update_seq": 289,
"rows":[{
"id":"c8cc500c68f679a6949a7ff981005729",
"key":[
[
-33.869107336588,
-33.869107336588
],
[
151.21772705984,
151.21772705984
]
],
"bbox":[
-33.869107336588,
151.21772705984,
-33.869107336588,
151.21772705984
],
"geometry":{
"type":"Point",
"coordinates":[
-33.869107336588,
151.21772705984
]
},
"value":[
"snorlax",
"143.png"
]
}]
}
GeoCouchで可能な個別のオペレーションについては、documentationまたはWikiを読んで確認してください。
プロジェクトの作成
さてここからはコードを書いていきます。最初にバックエンドのコードをざっと確認してからフロントエンドのコードに移ります。
Poke Importer
このアプリではデータベース内にあらかじめある程度のポケモンデータを必要とするので、部分的に実行されるスクリプトが必要です。プロジェクトのルートディレクトリにpoke-importer.phpファイルを作成し、次のように入力します。
<?php
require 'vendor/autoload.php';
set_time_limit(0);
use PokePHP\PokeApi;
use Gregwar\Image\Image;
$api = new PokeApi;
$client = new GuzzleHttp\Client(['base_uri' => 'http://192.168.33.10:5984']); //create a client for talking to CouchDB
$pokemons = $api->pokedex(2); //make a request to the API
$pokemon_data = json_decode($pokemons); //convert the json response to array
foreach ($pokemon_data->pokemon_entries as $row) {
$pokemon = [
'id' => $row->entry_number,
'name' => $row->pokemon_species->name,
'sprite' => "{$row->entry_number}.png",
'doc_type' => "pokemon"
];
//get image from source, save it then resize.
Image::open("https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{$row->entry_number}.png")
->resize(50, 50)
->save('public/img/' . $row->entry_number . '.png');
//save the pokemon data to the database
$client->request('POST', "/pokespawn", [
'headers' => [
'Content-Type' => 'application/json'
],
'body' => json_encode($pokemon)
]);
echo $row->pokemon_species->name . "\n";
}
echo "done!";
このスクリプトはPokemon APIのPokedexエンドポイントにリクエストを送信します。このエンドポイントではレスポンスを得たいPokedexのバージョンIDが必要です。現在のポケモンGOではプレイヤーは初代ポケモンだけを捕まえられるので、IDは2です。オリジナルのポケモンゲームに出てくる関東エリアのすべてのポケモンをレスポンスで得られます。データをループして必要な情報をすべて取得し、スプライト画像を保存、さらに出力データを使って新しいドキュメントを作成します。
ルーティング
src/routes.phpファイルを開いて次のルーティングを入力します。
<?php
$app->get('/', 'HomeController:index');
$app->get('/search', 'HomeController:search');
$app->post('/save-location', 'HomeController:saveLocation');
$app->post('/fetch', 'HomeController:fetch');
各ルーティングはアプリで実行されたアクションに対してレスポンスを返します。rootルーティングはホームページを、searchルーティングはポケモン名候補を返します。save-locationルーティングは位置を保存、fetchルーティングは特定位置でポケモンを返します。
Home Controller
srcディレクトリ直下にapp/Controllersフォルダを作成し、中にHomeController.phpファイルを作成します。このファイルは各ルーティングに対して必要なアクションを実行します。コードは次のとおりです。
renderer = $renderer; //the twig renderer
$this->db = new \App\Utils\DB; //custom class for talking to couchdb
}
public function index($request, $response, $args)
{
//render the home page
return $this->renderer->render($response, 'index.html', $args);
}
public function search()
{
$name = $_GET['name']; //name of the pokemon being searched
return $this->db->searchPokemon($name); //returns an array of suggestions based on the user input
}
public function saveLocation()
{
$id = $_POST['pokemon_id']; //the ID assigned by CouchDB to the Pokemon
return $this->db->savePokemonLocation($id, $_POST['pokemon_lat'], $_POST['pokemon_lng']); //saves the pokemon location to CouchDB and returns the data needed to plot the pokemon in the map
}
public function fetch()
{
return json_encode($this->db->fetchPokemons($_POST['north_east'], $_POST['south_west'])); //returns the pokemon's within the bounding box of Google map.
}
}
Home Controllerは$renderer変数を使い、この変数はアプリのホームページをレンダリングするコンストラクタへ渡されます。またすぐあとで作成するDBクラスも使用します。
CouchDBの呼び出し
Utils/DB.phpファイルをappディレクトリ直下に作成します。ファイルを開き、クラスを作成します。
<?php
namespace App\Utils;
class DB
{
}
クラス内では、新規Guzzleクライアントを作成します。数あるPHPクライアントライブラリからGuzzleをCouchDBに使う理由は、やりたいことに対してGuzzleがオールラウンドに対応できるからです。
private $client;
public function __construct()
{
$this->client = new \GuzzleHttp\Client([
'base_uri' => getenv('BASE_URI')
]);
}
この構成はプロジェクトのルート下にある.envファイルからのものです。このファイルにはCouchDBのベースURLが含まれています。
BASE_URI="http://192.168.33.10:5984"
searchPokemonは自動補完機能に使われるデータのレスポンスを担います。CouchDBは実際にはSQLで使っているLIKE演算子に対応していないので、少し工夫をします。完全一致だけを返すkeyの代わりにstart_keyとend_keyを使うのがコツです。fff0は特殊文字の1つで、基本多言語面で最後に割り当てられています。検索されている実際の文字列の末尾に付けることで適切な候補を作成できます。候補リストの精度が高いので、残りの文字は打ち込まずに候補から選択するだけです。この方法はポケモン名のような短い単語だけに有効なので注意してください。
public function searchPokemon($name)
{
$unicode_char = '\ufff0';
$data = [
'include_docs' => 'true',
'start_key' => '"' . $name . '"',
'end_key' => '"' . $name . json_decode('"' . $unicode_char .'"') . '"'
];
//make a request to the view you created earlier
$doc = $this->makeGetRequest('/pokespawn/_design/pokemon/_view/by_name', $data);
if (count($doc->rows) > 0) {
$data = [];
foreach ($doc->rows as $row) {
$data[] = [
$row->key,
$row->id
];
}
return json_encode($data);
}
$result = ['no_result' => true];
return json_encode($result);
}
makeGetRequestはCouchDBへの読み込みリクエストを実行するのに使われ、makePostRequestは書き込み用です。
public function makeGetRequest($endpoint, $data = [])
{
if (!empty($data)) {
//make a GET request to the endpoint specified, with the $data passed in as a query parameter
$response = $this->client->request('GET', $endpoint, [
'query' => $data
]);
} else {
$response = $this->client->request('GET', $endpoint);
}
return $this->handleResponse($response);
}
private function makePostRequest($endpoint, $data)
{
//make a POST request to the endpoint specified, passing in the $data for the request body
$response = $this->client->request('POST', $endpoint, [
'headers' => [
'Content-Type' => 'application/json'
],
'body' => json_encode($data)
]);
return $this->handleResponse($response);
}
savePokemonLocationはGoogleマップのマーカーが現在示している座標を、nameとspriteを合わせて保存します。またdoc_typeフィールドを位置に関連するすべてのドキュメントの簡易検索のために追加します。
public function savePokemonLocation($id, $lat, $lng)
{
$pokemon = $this->makeGetRequest("/pokespawn/{$id}"); //get pokemon details based on ID
//check if supplied data are valid
if (!empty($pokemon->name) && $this->isValidCoordinates($lat, $lng)) {
$lat = (double) $lat;
$lng = (double) $lng;
//construct the data to be saved to the database
$data = [
'name' => $pokemon->name,
'sprite' => $pokemon->sprite,
'loc' => [$lat, $lng],
'doc_type' => 'pokemon_location'
];
$this->makePostRequest('/pokespawn', $data); //save the location data
$pokemon_data = [
'type' => 'ok',
'lat' => $lat,
'lng' => $lng,
'name' => $pokemon->name,
'sprite' => $pokemon->sprite
];
return json_encode($pokemon_data); //return the data needed by the pokemon marker
}
return json_encode(['type' => 'fail']); //invalid data
}
isValidCoordinatesは経緯と緯度の値が有効な形式かをチェックします。
private function isValidCoordinates($lat = '', $lng = '')
{
$coords_pattern = '/^[+\-]?[0-9]{1,3}\.[0-9]{3,}\z/';
if (preg_match($coords_pattern, $lat) && preg_match($coords_pattern, $lng)) {
return true;
}
return false;
}
fetchPokemonsは先に作成した空間検索をデザインドキュメントにリクエストする関数です。ここで、start_rangeの値として南西座標を、end_rangeの値として北東座標を指定します。またリクエストが送られすぎるのを防ぐため、レスポンスを最初の100行に限定します。前にCouchDBから不要なデータが返されていたのを覚えているでしょうか。レスポンスを限定することは、フロントエンドで必要なデータだけを出力するのに役立ちます。これについてはまた別の日に最適化するのでこのままにしておきます。
public function fetchPokemons($north_east, $south_west)
{
$north_east = array_map('doubleval', $north_east); //convert all array items to double
$south_west = array_map('doubleval', $south_west);
$data = [
'start_range' => json_encode($south_west),
'end_range' => json_encode($north_east),
'limit' => 100
];
$pokemons = $this->makeGetRequest('/pokespawn/_design/location/_spatial/points', $data); //fetch all pokemon's that are in the current area
return $pokemons;
}
handleResponseはCouchDBから返されたJSON文字列を配列に変換します。
private function handleResponse($response)
{
$doc = json_decode($response->getBody()->getContents());
return $doc;
}
ルートディレクトリのcomposer.jsonを開いてrequireプロパティのすぐ下に次のように追加し、composer dump-autoloadを実行します。これでsrc/appディレクトリ内の全ファイルをオートロードし、Appネームスペース内で使用できるようになります。
"autoload": {
"psr-4": {
"App\\": "src/app"
}
}
最後に、Home Controllerをコンテナにセットします。src/dependencies.phpファイルを開き、ファイルの最後に次のように追加します。
$container['HomeController'] = function ($c) {
return new App\Controllers\HomeController($c->renderer);
};
このコードはHome ControllerにTwigレンダラーを渡し、ルーティングからHome Controllerへアクセスできるようにしてくれます。
ホームページテンプレート
ここからフロントエンドに取り掛かります。最初にtemplates/index.htmlファイルをプロジェクトのルートディレクトリに作成し、次のように入力します。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PokéSpawn</title>
<link rel="stylesheet" href="lib/picnic/picnic.min.css">
<link rel="stylesheet" href="lib/remodal/dist/remodal.css">
<link rel="stylesheet" href="lib/remodal/dist/remodal-default-theme.css">
<link rel="stylesheet" href="lib/javascript-auto-complete/auto-complete.css">
<link rel="stylesheet" href="css/style.css">
<link rel="icon" href="favicon.ico"><!-- by Maicol Torti https://www.iconfinder.com/Maicol-Torti -->
</head>
<body>
<div id="header">
<div id="title">
<img src="img/logo.png" alt="logo" class="header-item" />
<h1 class="header-item">PokéSpawn</h1>
</div>
<input type="text" id="place" class="controls" placeholder="Where are you?"><!-- text field for typing the location -->
</div>
<div id="map"></div>
<!-- modal for saving pokemon location -->
<div id="add-pokemon" class="remodal" data-remodal-id="modal">
<h3>Plot Pokémon Location</h3>
<form method="POST" id="add-pokemon-form">
<div>
<input type="hidden" name="pokemon_id" id="pokemon_id"><!-- id of the pokemon in CouchDB-->
<input type="hidden" name="pokemon_lat" id="pokemon_lat"><!--latitude of the red marker -->
<input type="hidden" name="pokemon_lng" id="pokemon_lng"><!--longitude of the red marker -->
<input type="text" name="pokemon_name" id="pokemon_name" placeholder="Pokémon name"><!--name of the pokemon whose location is being added -->
</div>
<div>
<button type="button" id="save-location">Save Location</button><!-- trigger the submission of location to CouchDB -->
</div>
</form>
</div>
<script src="lib/zepto.js/dist/zepto.min.js"></script><!-- event listening, ajax -->
<script src="lib/remodal/dist/remodal.min.js"></script><!-- for modal box -->
<script src="lib/javascript-auto-complete/auto-complete.min.js"></script><!-- for autocomplete text field -->
<script src="js/main.js"></script>
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_GOOGLEMAP_APIKEY&callback=initMap&libraries=places" defer></script><!-- for showing a map-->
</body>
</html>
<head>内はアプリが使用するさまざまなライブラリからのスタイルシートと同様にアプリのスタイルシートです。<body>内は位置検索用のテキストフィールド、マップコンテナ、新規位置を保存するためのモーダルウィンドウです。下のスクリプトはアプリで使われるものです。Googleマップスクリプト内のYOUR_GOOGLEMAP_APIKEYを自分のAPIキーに書き換えるのを忘れないでください。
JavaScript
メインとなるJavaScriptファイル(public/js/main.js)用に、ファイル全体で必要となるデータを格納するための変数を作成します。
var modal = $('#add-pokemon').remodal(); //initialize modal
var map; //the google map
var markers = []; //an array for storing all the pokemon markers currently plotted in the map
次に、マップを初期化する関数を作成します。min_zoomlevelはユーザーが世界地図全体が見えるほどにズームアウトするのを防ぐために指定します。CouchDBから返されるレスポンス数はすでに制限してありますが、世界中のデータが利用できるとユーザーに期待させないよう、追加しておくとよいでしょう。
function initMap() {
var min_zoomlevel = 18;
map = new google.maps.Map(document.getElementById('map'), {
center: {lat: -33.8688, lng: 151.2195}, //set
disableDefaultUI: true, //hide default UI controls
zoom: min_zoomlevel, //set default zoom level
mapTypeId: 'roadmap' //set type of map
});
//continue here...
}
ユーザーが追加したいピンポイントの位置にマーカーを作成します。それから、マーカーが押された時に位置を登録するためのモーダルウィンドウが開くイベントリスナーを追加します。
marker = new google.maps.Marker({
map: map,
position: map.getCenter(),
draggable: true
});
marker.addListener('click', function(){
var position = marker.getPosition();
$('#pokemon_lat').val(position.lat());
$('#pokemon_lng').val(position.lng());
modal.open();
});
検索ボックスの初期化:
var header = document.getElementById('header');
var input = document.getElementById('place');
var searchBox = new google.maps.places.SearchBox(input); //create a google map search box
map.controls[google.maps.ControlPosition.TOP_LEFT].push(header); //position the header at the top left side of the screen
いろいろなマップリスナーを追加:
map.addListener('bounds_changed', function() { //executes when user drags the map
searchBox.setBounds(map.getBounds()); //make places inside the current area a priority when searching
});
map.addListener('zoom_changed', function() { //executes when user zooms in or out of the map
//immediately set the zoom to the minimum zoom level if the current zoom goes over the minimum
if (map.getZoom() < min_zoomlevel) map.setZoom(min_zoomlevel);
});
map.addListener('dragend', function() { //executes the moment after the map has been dragged
//loop through all the pokemon markers and remove them from the map
markers.forEach(function(marker) {
marker.setMap(null);
});
markers = [];
marker.setPosition(map.getCenter()); //always place the marker at the center of the map
fetchPokemon(); //fetch some pokemon in the current viewable area
});
検索ボックスの場所が変わったときのイベントリスナーを追加します。
searchBox.addListener('places_changed', function() { //executes when the place in the searchbox changes
var places = searchBox.getPlaces();
if (places.length == 0) {
return;
}
var bounds = new google.maps.LatLngBounds();
var place = places[0]; //only get the first place
if (!place.geometry) {
return;
}
marker.setPosition(place.geometry.location); //put the marker at the location being searched
if (place.geometry.viewport) {
// only geocodes have viewport
bounds.union(place.geometry.viewport);
} else {
bounds.extend(place.geometry.location);
}
map.fitBounds(bounds); //adjust the current map bounds to that of the place being searched
fetchPokemon(); //fetch some Pokemon in the current viewable area
});
fetchPokemon関数は、現在見えているマップ上のエリアで以前表示されたポケモンを取得する関数です。
function fetchPokemon(){
//get the northeast and southwest coordinates of the viewable area of the map
var bounds = map.getBounds();
var north_east = [bounds.getNorthEast().lat(), bounds.getNorthEast().lng()];
var south_west = [bounds.getSouthWest().lat(), bounds.getSouthWest().lng()];
$.post(
'/fetch',
{
north_east: north_east,
south_west: south_west
},
function(response){
var response = JSON.parse(response);
response.rows.forEach(function(row){ //loop through all the results returned
var position = new google.maps.LatLng(row.geometry.coordinates[0], row.geometry.coordinates[1]); //create a new google map position
//create a new marker using the position created above
var poke_marker = new google.maps.Marker({
map: map,
title: row.value[0], //name of the pokemon
position: position,
icon: 'img/' + row.value[1] //pokemon image that was saved locally
});
//create an infowindow for the marker
var infowindow = new google.maps.InfoWindow({
content: "" + row.value[0] + ""
});
//when clicked it will show the name of the pokemon
poke_marker.addListener('click', function() {
infowindow.open(map, poke_marker);
});
markers.push(poke_marker);
});
}
);
}
このコードでポケモンの名前を入力するテキストフィールドに入力補完機能を追加します。renderItem関数は、それぞれの名前候補のレンダリングに使われるHTMLのカスタマイズを指定します。これによってデータ属性としてポケモンのIDを追加でき、名前の候補が選択されたときにpokemon_idフィールドの値をIDで設定できます。
new autoComplete({
selector: '#pokemon_name', //the text field to add the auto-complete
source: function(term, response){
//use the results returned by the search route as a data source
$.getJSON('/search?name=' + term, function(data){
response(data);
});
},
renderItem: function (item, search){ //the code for rendering each suggestions.
search = search.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
var re = new RegExp("(" + search.split(' ').join('|') + ")", "gi");
return '<div class="autocomplete-suggestion" data-id="' + item[1] + '" data-val="' + item[0] + '">' + item[0].replace(re, "<b>$1</b>")+'</div>';
},
onSelect: function(e, term, item){ //executed when a suggestion is selected
$('#pokemon_id').val(item.getAttribute('data-id'));
}
});
「位置を保存」のボタンが押されると、CouchDBにポケモンの位置を追加するリクエストがサーバーに送信されます。
$('#save-location').click(function(e){
$.post('/save-location', $('#add-pokemon-form').serialize(), function(response){
var data = JSON.parse(response);
if(data.type == 'ok'){
var position = new google.maps.LatLng(data.lat, data.lng); //create a location
//create a new marker and use the location
var poke_marker = new google.maps.Marker({
map: map,
title: data.name, //name of the pokemon
position: position,
icon: 'img/' + data.sprite //pokemon image
});
//create an infowindow for showing the name of the pokemon
var infowindow = new google.maps.InfoWindow({
content: "" + data.name + ""
});
//show name of pokemon when marker is clicked
poke_marker.addListener('click', function() {
infowindow.open(map, poke_marker);
});
markers.push(poke_marker);
}
modal.close();
$('#pokemon_id, #pokemon_lat, #pokemon_lng, #pokemon_name').val(''); //reset the form
});
});
$('#add-pokemon-form').submit(function(e){
e.preventDefault(); //prevent the form from being submited on enter
})
スタイルシート
public/css/styles.cssファイルを作成し、次のように入力します。
html, body {
height: 100%;
margin: 0;
padding: 0;
}
#header {
text-align: center;
}
#title {
float: left;
padding: 5px;
color: #f5716a;
}
.header-item {
padding-top: 10px;
}
h1.header-item {
font-size: 14px;
margin: 0;
padding: 0;
}
#map {
height: 100%;
}
.controls {
margin-top: 10px;
border: 1px solid transparent;
border-radius: 2px 0 0 2px;
box-sizing: border-box;
-moz-box-sizing: border-box;
height: 32px;
outline: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
#place {
background-color: #fff;
margin-left: 12px;
padding: 0 11px 0 13px;
text-overflow: ellipsis;
width: 300px;
margin-top: 20px;
}
#place:focus {
border-color: #4d90fe;
}
#type-selector {
color: #fff;
background-color: #4d90fe;
padding: 5px 11px 0px 11px;
}
#type-selector label {
font-family: Roboto;
font-size: 13px;
font-weight: 300;
}
#target {
width: 345px;
}
.remodal-wrapper {
z-index: 100;
}
.remodal-overlay {
z-index: 100;
}
CouchDBの保護
デフォルトのCouchDBはすべてのユーザーに開かれています。ということは、一度インターネット上にデータベースを公開すると、誰でもそれを荒らせるということになります。CurlやPostman、そのほかのHTTPリクエストを送信できるツールを使うだけで、あらゆるデータベースのオペレーションが誰でも可能です。実は、この一時的な状態には「管理者パーティ」という名前までつけられています。この名前は以前のチュートリアルを実行したとき、または新規データベース、ビューやデザインドキュメントを作成したときにも見たことがあるはずです。これらのアクションはサーバー管理者のみ実行できますが、ログインする必要はありません。なんだかまだしっくりきませんか? ローカルマシンでこれを実践してみてください。
curl -X PUT http://192.168.33.10:5984/my_newdatabase
CouchDBインストール時にサーバー管理者を設定していない場合、次のようなレスポンスを得ます。
{"ok":true}
びっくりしたでしょうか? 幸いにもこれは簡単に修正できます。サーバー管理者を作成すればいいだけです。次のコマンドで作成できます。
curl -X PUT http://192.168.33.10:5984/_config/admins/kami -d '"mysupersecurepassword"'
上のコマンドは「kami」という名前の新規サーバー管理者をパスワード「mysupersecurepassword」で作成しています。
デフォルトのCouchDBはサーバー管理者がいないので、管理者が作成されるともう管理者パーティではなくなります。サーバー管理者は神のような権限を持っているので、作成しても1つもしくは2つ程度にしておいたほうが良いでしょう。それからクラッドオペレーションのみ実行できるデータベース管理者を少し作成しておきます。この管理者は次のコマンドで作成できます。
curl -HContent-Type:application/json -vXPUT http://kami:mysupersecurepassword@192.168.33.10:5984/_users/org.couchdb.user:plebian --data-binary '{"_id": "org.couchdb.user:plebian","name": "plebian","roles": [],"type": "user","password": "mypass"}'
成功すると、次のようなレスポンスが返ってきます。
* Trying 192.168.33.10...
* Connected to 192.168.33.10 (192.168.33.10) port 5984 (#0)
* Server auth using Basic with user 'root'
> PUT /_users/org.couchdb.user:plebian HTTP/1.1
> Host: 192.168.33.10:5984
> Authorization: Basic cm9vdDpteXN1cGVyc2VjdXJlcGFzc3dvcmQ=
> User-Agent: curl/7.47.0
> Accept: */*
> Content-Type:application/json
> Content-Length: 101
>
* upload completely sent off: 101 out of 101 bytes
< HTTP/1.1 201 Created
< Server: CouchDB/1.6.1 (Erlang OTP/R16B03)
< Location: http://192.168.33.10:5984/_users/org.couchdb.user:plebian
< ETag: "1-9c4abdc905ecdc9f0f56921d7de915b9"
< Date: Thu, 18 Aug 2016 07:57:20 GMT
< Content-Type: text/plain; charset=utf-8
< Content-Length: 87
< Cache-Control: must-revalidate
<
{"ok":true,"id":"org.couchdb.user:plebian","rev":"1-9c4abdc905ecdc9f0f56921d7de915b9"}
* Connection #0 to host 192.168.33.10 left intact
今度は前と同じコマンドを違うデータベース名で実行してみます。
curl -X PUT http://192.168.33.10:5984/my_awesomedatabase
するとCouchDBから冷たいレスポンスが。
{"error":"unauthorized","reason":"You are not a server admin."}
成功するには、 URL内にユーザーネームとパスワードを入力する必要があります。
curl -X PUT http://{your_username}:{your_password}@192.168.33.10:5984/my_awesomedatabase
どうでしょうか? 実践してたのはサーバー管理者だけが可能なデータベースオペレーションに限られていたので、大したことはなかったかもしれません。この処理に含まれるのは、新規データベースの作成、データベースの削除、ユーザーの管理、全データベース(システムテーブルを含む)へのフルアクセス、全ドキュメントへのクラッドオペレーションです。つまりあらゆるデータベースへのクラッドの処理権限は、まだ認証されていないユーザーが持ったままです。これを確かめるにはFutonをログアウトし、データベースを適当に選んでクラッドします。CouchDBはこれらのオペレーションを躊躇なく実行します。
それでは残っている穴はどのように塞ぎますか? これは書き込み処理(挿入またはアップデート)をしようとしているユーザーのユーザーネームが、処理権限のあるユーザーと同じかどうかをチェックするデザインドキュメントを作成することで可能です。Futonでサーバー管理者またはデータベース管理アカウントを使ってログインし、処理したいデータベースを選択、新規デザインドキュメントを作成します。IDを_design/blockAnonymousWritesとして設定し、フィールド名をvalidate_doc_updateと付け、Valueに次のように設定します。
function(new_doc, old_doc, userCtx){
if(userCtx.name != 'kami'){
throw({forbidden: "Not Authorized"});
}
}
新バージョンのドキュメント、既存のドキュメント、ユーザコンテキストは引数としてこの関数に渡されます。データベースの名前、処理しているユーザー名、ユーザーに割り当てられたロールの配列を含むuserCtxだけはチェックする必要があります。
secObjもまた4番目の引数として渡されますが、実際には必要がないので省略されています。基本的に、secObjはデータベースに設定されている管理者特権を取得します。
Valueに入力できたらデザインドキュメントを保存しログアウトします。そしてドキュメントの新規作成、または既存のドキュメントのアップデートを試し、CouchDBのクレーム(エラーメッセージ)を見て終了です。
ユーザーネームだけをチェックしているので、アタッカーがユーザーネームさえ推測できれば、適当な値をパスワードに入れて認証を得られてしまうのではないかと考えていませんか? CouchDBははじめに、それもデザインドキュメントが実行される前に、ユーザーネームとパスワードが正しいかをチェックするので、そんなことはありません。
1つのデータベースにユーザーがたくさんいる場合、各ユーザーのロールをチェックするという方法もあります。次の関数は「ポケモンマスター」のロールを持たないユーザーにエラーを投げます。
function(new_doc, old_doc, userCtx) {
if(userCtx.roles.indexOf('pokemon_master') == -1){
throw({forbidden: "Not Authorized"});
}
}
もしCouchDBの保護についてもっとよく知りたければ、次のリソースは要チェックです。
- CouchDB The Definitive Guide – Security(CouchDBのセキュリティガイド決定版)
- The Definitive Guide to CouchDB Authentication and Security(CouchDBの認証とセキュリティ、決定版ガイド)
- Security Features Overview(セキュリティ機能の概要)
- Document Update Validation(ドキュメントアップデードバリデーション)
アプリのセキュリティ対策
データベースに用いたセキュリティ対策を活用し、アプリをアップデートして完成させます。最初に.envファイルをアップデートします。BASE_URIをIPアドレスとポートに変更し、作成したCouchDBのユーザーネームとパスワードを追加します。
BASE_URI="192.168.33.10:5984"
COUCH_USER="plebian"
COUCH_PASS="mypass"
それから、次のようにDBクラスのコンストラクタをアップデートします。
public function __construct()
{
$this->client = new \GuzzleHttp\Client([
'base_uri' => 'http://' . getenv('COUCH_USER') . ':' . getenv('COUCH_PASS') . '@' . getenv('BASE_URI')
]);
}
最後に
以上です! このチュートリアルではポケモン出現場所記録アプリの作り方を学びました。GeoCouchプラグインの助けを借りて空間クエリを実行でき、さらにCouchDBデータベースを保護する方法を学びました。
(原文:How to Create a Pokemon Spawn Locations Recorder with CouchDB)
[翻訳:May Hasan/編集:Livit]