はじめに
前回作成した基本的なインターフェースを拡充してチャットアプリとしての体裁を整えたいと思います。
今回追加する機能は、ログイン機能とユーザー登録機能、簡易的なセキュリティ対策、スタンプ機能、画像アップロード機能です。
環境構築
前回に引き続きお手軽なクラウドサービスを使って環境構築を行います。
Paiza Cloud
Paiza Cloudeにアクセスしてメールアドレスを登録すると、すぐに環境構築ができるようになります。
サーバー作成
アカウントを作成したらサーバー作成ボタンを押しましょう
新規サーバー作成のポップアップで、Node.jsとMongoDBを選択してください。
数秒間待っているとサーバー環境ができあがります。
ちなみに無料プランの場合は
* サーバーの最長利用時間は24時間
* サービスは外部へ公開されない
逆に言うと練習にはもってこいという事でしょうか。
アプリケーション構築
次に各種インストールを行い、アプリケーションの実行環境を構築します。
まずは画面からターミナルのアイコンをクリックしてください。
起動したターミナルに下記のコマンドを入れてgitからファイルを展開します。
git clone https://github.com/nstshirotays/chatapp-shot2.git
つぎにディレクトリを移動し、必要なパッケージを導入します。
cd chatapp-shot2
npm install
実行に必要なモジュールなどがpackage.jsonに従って自動的にインストールされます。
以上で必要な準備が整いましたので、あとはnodejsを起動してアプリを立ち上げます。
npm start
エラーがでなければ、左側に緑色のブラウザアイコンが新しく点滅し始めます。
このアイコンをクリックするとアプリが起動します。
アプリ実行
ログイン画面
まずはログイン画面です。
初回は誰も登録されていないので、Create an Account を押してユーザー登録画面に移ります。
ユーザー登録画面
NickNameとPassCodeを入れてユーザーを登録しましょう。
NickNameは英文字で4から12文字。PassCodeは数字で6から12文字です。
お好みでFaceIconを変更(png 32kbまで)できます。
ユーザーを登録したら実際にログインしてみましょう。
友達選択画面
登録されている自分以外の友達が一覧で表示されます。今回もデフォルトでEchoさんが登録されています。友達を選択すると会話画面に遷移します。
会話画面
ベースとなる会話画面です。前回からスタンプと画像アップロード機能が追加されています。
Echoさんはこちらの会話に相槌を打ってくれるチャットボットです。
スタンプ画面
スタンプボタンを押すと一覧が表示されます。
今回はクリスマススタンプを入れてみました。
お好きなpngを public/files/stampsに入れてください。
アイコンネタ元 speckyboy.com
(ちなみにEchoさんはスタンプをもらうとそのスタンプ名を言ってくれる仕様にしました)
画像アップロード
今回は画像アップロード機能を加えています。画像はjpegのみで、サイズは1000×1000以下です。
残念ですが現時点ではEchoさんは画像の内容を認識できません。次回あたりにチャレンジしたいと思います。
アプリケーション解説
画面も増えましたので前回のソースをnode.jsのアプリケーションフレームワークであるExpressで再構成しました。
このためディレクトリ構造は下記のようになっています。
|-helper 共通機能系
|-models データモデル系
|-public
|---files
|-----stamps スタンプの場所
|---images
|---javascripts
|---stylesheets
|-routes get,postで呼ばれるjavascript
|-views html
参考にした記事
Express + Node.jsで基本を理解した次の一歩 - ディレクトリ構成をルーティング・ミドルウェアを理解して考えてみる
コード解説
それでは今回加えた主なソースを解説していきます。チャット画面については前回とほぼ同様ですので割愛します。
app.js
メインのプログラムです。前回はserver.jsとして実装しました。今回はexpressのアプリケーション自動生成(generator)機能を使ったのでこの生成されたapp.jsに各画面の呼び出しを加えています。
参考サイト 初心者のための Node.jsプログラミング入門
varcreateError=require('http-errors');varexpress=require('express');varpath=require('path');varcookieParser=require('cookie-parser');varlogger=require('morgan');varmongoose=require('mongoose');// ルーティング処理の呼び出し先を追加varloginRouter=require('./routes/login');varregisterRouter=require('./routes/register');varlistRouter=require('./routes/list');varchatRouter=require('./routes/chatapp');varerrRouter=require('./routes/errorpage');varapiRouter=require('./routes/api');varlogoutRouter=require('./routes/logout');varapp=express();// 変数宣言varMyID="";varMyName="";varFrID="";varFrName="";varbotTimer;// view engine setupapp.set('views',path.join(__dirname,'views'));app.set('view engine','ejs');// ファイルアップロードに関する拡張app.use(logger('dev'));//app.use(express.json());//app.use(express.urlencoded({ extended: false }));app.use(express.urlencoded({extended:true,limit:'10mb'}));app.use(express.json({extended:true,limit:'10mb'}));app.use(cookieParser());app.use(express.static(path.join(__dirname,'public')));// ルーティング処理の登録app.use('/',loginRouter);app.use('/auth',loginRouter);app.use('/register',registerRouter);app.use('/home',listRouter);app.use('/chat',chatRouter);app.use('/errorpage',errRouter);app.use('/api/messages',apiRouter);app.use('/logout',logoutRouter);// catch 404 and forward to error handlerapp.use(function(req,res,next){next(createError(404));});// error handlerapp.use(function(err,req,res,next){// set locals, only providing error in developmentres.locals.message=err.message;res.locals.error=req.app.get('env')==='development'?err:{};// render the error pageres.status(err.status||500);res.render('error');});module.exports=app;
基本的にはクライアントからのGETやPOSTに対して応答する処理(ルーティング)を登録しています。requireでルーティング処理が書かれたJavascriptを変数に登録し、それをapp.useでクライアントからのURLに紐づけています。
処理全体で利用する変数として自分と相手のID,Nameを変数として宣言しています。また、チャットボットの本体となるタイマー起動処理用の変数もここで宣言しています。本来であれば、別ファイルにしてexportsでオブジェクト風に見せるのがお作法かとも思うのですが、シンプルに直接宣言したほうがわかりやすいかと思ってこっちにしました。
今回はファイルのアップロードがあるためpostデータが大きくなり、そのままでは413エラー(request entity too large)となってしまいます。このための設定としてオプション値を設定しています。
参考にした記事
Express4でエラー「request entity too large」が発生する
login.js ログイン処理
varexpress=require('express');varrouter=express.Router();var{check,validationResult}=require('express-validator');varsanitize=require('mongo-sanitize');vardb=require('../helper/db');varUser=db.User;varbcrypt=require('bcryptjs');varjsonwebtoken=require('jsonwebtoken');constconfig=require('../config');//--------------------------------------------------------// ログイン画面の表示//--------------------------------------------------------/* GET home page. */router.get('/',function(req,res,next){res.clearCookie("auth");res.render('login',{error:false,errors:false});});//--------------------------------------------------------// ログイン処理//--------------------------------------------------------//ユーザ認証router.post('/',[check('nickName','ニックネームを入力して下さい').not().isEmpty().trim().escape().customSanitizer(value=>{// MongoDB Operator Injectionを防ぐために、ユーザー提供のデータをサニタイズしますvalue=value.replace(/[$.]/g,"");returnvalue;}),check('passCode','パスコードを入力して下さい').not().isEmpty().trim().escape(),],(req,res)=>{varerrors=validationResult(req);//検証エラーif(!errors.isEmpty()){res.render('register.ejs',{data:req.body,errors:errors.mapped()});}constnickName=sanitize(req.body.nickName);constpasscode=sanitize(req.body.passCode);User.findOne({nickName},(err,user)=>{if(err)returnres.status(500).send(err);if(!user)returnres.render('login.ejs',{error:'ユーザーが見つかりません',errors:false});//パスコードチェックbcrypt.compare(passcode,user.passCode,function(err,result){if(!result)returnres.render('login.ejs',{error:'パスコードが違います',errors:false});//JSON Webトークンを生成する,トークンの有効期限を15分に設定constexpiresIn=900;constaccessToken=jsonwebtoken.sign({id:user._id,name:user.nickName},config.secret,{expiresIn:expiresIn});//トークンをクッキーに保存するres.cookie('auth',accessToken,{maxAge:900000,httpOnly:true});MyID=user._id;MyName=user.nickName;res.redirect('/home');});});});module.exports=router;
login.jsは冒頭の宣言部分と、クライアントからのGET処理とPOST処理の3パートで構成されています。
GET処理ではクッキーをクリアして、res.renderでログインフォームをレンダリングしています。それだけです。
POSTの方は実際のログイン処理を実施しています。
router.post( URL、処理1、処理2、処理3・・・)
という感じでポスト後の処理を書いています。
まずはニックネームとパスワードの未入力をチェックしたあと、実際のDBへ接続してユーザーの有無を問い合わせています。DBへの接続についてはいわゆるSQLインジェクションという攻撃への備えが必要です。mongodbはSQLデーターベースではありませんが、やはり検索文字列に特殊なコードを入れると悪意のあるコードが実行されてしまいます。このためニックネームとパスワードについてはサニタイズ処理をしています。これはmongo-sanitizeというパッケージを利用しています。
参考サイト: HACKING NODEJS AND MONGODB
ニックネームとパスワードが一致すると、ユーザーIDとNameをセットしたクッキーが発行されます。これ以降はこのクッキーが認証済みの証となります。
ということで、クッキーが改ざんされても判別できるようにここではJson Web Token という仕様を使ってクッキーを署名付きにします。
参考記事: NodeJS + MongoDB - Simple API for Authentication, Registration and User Management
参考記事: JSON Web Token の効用
login.ejs ログイン画面
<!doctype html><html><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1"><title>Login</title><linkrel="stylesheet"href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm"crossorigin="anonymous"><linkrel="stylesheet"type="text/css"href="/stylesheets/style.css"></head><body><divclass="container"><divclass="row "><divclass="col-xs-1 col-sm-2 col-md-3 col-lg-4"></div><divclass="col-xs-10 col-sm-8 col-md-6 col-lg-4"><h1>Chat App</h1><formmethod="post"action="auth"><divclass="form-group"><%if(error){%><divclass="text-danger"style="width: 90%;margin:5%;"><%=error%></div><%}%><%if(errors.nickName){%><divclass="text-danger"style="width: 90%;margin:5%;"><%=errors.nickName.msg%></div><%}%><%if(errors.passCode){%><divclass="text-danger"style="width: 90%;margin:5%;"><%=errors.passCode.msg%></div><%}%><inputtype="text"class="form-control"style="width: 90%;margin:5%;"id="nickName"name="nickName"placeholder="NickName"required><inputtype="password"class="form-control"style="width: 90%;margin:5%;"id="passCode"name="passCode"placeholder="PassCode"required><buttontype="submit"class="btn"style="width: 90%;margin:5%;">Log in</button><center><ahref="register">Create an account</a></center></div></form></div></div></div></body></html>
今回は画面をレスポンシブにするためにbootstrapのグリッドシステムを利用しています。
このシステムは全体を12の列に分け、画面の解像度に応じて利用する列数を変更することで、一定の見栄えを維持するものです。
LINE風ということで、スマホの縦長画面をイメージしたいので、PC画面やタブレット画面では左右にマージンを置きたいと考えました。本来であればoffset指定でできるはずですが、上手くいかなかったので、空のカラムdivを挟んであります。
また、スマホなどの高解像度換算表示をさせないために、メタタグとしてwidth=device-widthを指定しています。
参考サイト: Bootstrap3の使い方
register.js ユーザー登録処理
ログイン画面から「Create an account」を選択すると表示されます。
varexpress=require('express');varrouter=express.Router();var{check,validationResult}=require('express-validator');vardb=require('../helper/db');varUser=db.User;constuserService=require('../models/user.service');//--------------------------------------------------------// ユーザー登録画面の表示//--------------------------------------------------------/* GET home page. */router.get('/',function(req,res,next){res.clearCookie("auth");res.render('register',{data:req.body,error:false,errors:false});});//--------------------------------------------------------// ユーザー登録処理//--------------------------------------------------------router.post('/',[check('nickName','Nick name は英文字のみです').not().isEmpty().isAlpha().trim().escape(),check('passCode','Pass code は数字のみです').not().isEmpty().isAlphanumeric().trim().escape(),check('nickName','Nick name は4文字以上12文字までです.').not().isEmpty().isLength({min:4,max:12}).trim().escape(),check('passCode','Pass code は6文字以上12文字までです.').not().isEmpty().isLength({min:6,max:12}).trim().escape(),check('nickName').custom(value=>{// MongoDB Operator Injectionを防ぐために、ユーザー提供のデータをサニタイズしますvalue=value.replace(/[$.]/g,"");//ニックネームを検証するreturnUser.findOne({'nickName':value}).then(user=>{if(user){returnPromise.reject(user['nickName']+'さんは登録済みです.');}});})],(req,res)=>{varerrors=validationResult(req);//検証エラーif(!errors.isEmpty()){res.render('register.ejs',{data:req.body,errors:errors.mapped()});}else{//エラーなし, データベースにユーザー情報を保存する userService.create(req.body);res.redirect('/');}});module.exports=router;
ここもGET処理は単にユーザー登録画面をレンダリングするだけです。POST処理でユーザー登録をしています。
この際にexpress-validatorを使って文字種別や文字長の検査をしています。そしてDBを確認して既登録がなければ登録を行います。
実際の登録はuserService = require('../models/user.service');で指定されたソースで行っています。
共通関数:ユーザー登録処理(user.service.js)
// ユーザー登録操作varbcrypt=require('bcryptjs');constdb=require('../helper/db');constUser=db.User;constsaltRounds=10;module.exports={create};// ユーザーモデルを作成し、データベースに保存するfunctioncreate(userParam){constuser=newUser();user.nickName=userParam.nickName;// 画像はオプションです。デフォルト画像を使用して選択されていませんif(userParam.ufile){user.userImage=userParam.ufile;}if(userParam.passCode){// ハッシュパスコードを保存するuser.passCode=bcrypt.hashSync(userParam.passCode,bcrypt.genSaltSync(saltRounds));}// save useruser.save();}
パスワードはbcryptを使って10多重でハッシュ化しています。
参考記事: BCryptのすすめ
参考サイト: 本当は怖いパスワードの話 (1/4)
register.ejs ユーザー登録画面
<!DOCTYPE html><html><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1"><title>Register</title><linkrel="stylesheet"href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm"crossorigin="anonymous"><linkrel="stylesheet"type="text/css"href="/stylesheets/style.css"><script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script></head><body><formmethod="post"action="register"name="register-form"id="register-form"><divclass="form-group"><divclass="container"><divclass="row "><divclass="col-xs-12 col-sm-12 col-md-12 col-lg-12"><h1>Chat App</h1></div></div><divclass="row"><divclass=" col-md-2 col-lg-3"></div><divclass="col-xs-8 col-sm-8 col-md-5 col-lg-4"><labelfor="nickName"class="col-form-label">NickName</label><inputtype="text"class="form-control"id="nickName"name ="nickName"placeholder="Nickname"maxlength="12"required><%if(errors.nickName){%><divclass="text-danger"><%=errors.nickName.msg%></div><%}%><labelfor="passCode"class="col-form-label">PassCode</label><inputtype="password"class="form-control"id="passCode"name="passCode"placeholder="Passcode"maxlength="12"required><%if(errors.passCode){%><divclass="text-danger"><%=errors.passCode.msg%></div><%}%></div><divclass="col-xs-4 col-sm-4 col-md-3 col-lg-2"><labelfor="FaceIcon"class="col-form-label">FaceIcon</label><imgsrc="/images/defaultFace.png"class="image"id="image-frame"height="50pv"width="50pv"/><inputid="imageFile"type="file"style="visibility:hidden"name="imageFile"/><inputtype="button"style="width: 100%;"value="Change"onclick="$('#imageFile').click();"class="btn"name="imagePath"/><inputid="b64"name="ufile"type="hidden"value=""/><divclass="text-danger"id="error"></div></div></div><divclass="row"><divclass=" col-md-2 col-lg-3"></div><divclass="col-xs-12 col-sm-12 col-md-8 col-lg-6"></br><buttontype="submit"style="width: 100%;"class="btn rgst">Create</button></div></div></div></div></form></body><script>/*$("#imageFile").change(function(){
readURL(this);
});
function readURL(input) {
if (input.files && input.files[0]) {
var reader = new FileReader();
reader.onload = function (e) {
console.log(e.target.result);
$('#imgsrc').attr('src', e.target.result);
}
reader.readAsDataURL(input.files[0]);
}
}*/showImage(true);vartargetfile=null;$("#imageFile").onchange=function(evt){$("#error").innerHTML='';showImage(true);varfiles=evt.target.files;if(files.length==0)return;targetFile=files[0];console.log(targetFile);if(!targetFile.type.match(/image/)){$("#error").innerHTML='Select Image File';return;}if(targetFile.size>35000){$("#error").innerHTML='Image file size should be less than 35KB';return;}varbreader=newFileReader();breader.onload=readPNGFile;breader.readAsBinaryString(targetFile);}functionreadPNGFile(evt){varbin=evt.target.result;varsig=String.fromCharCode(0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a);varhead=bin.substr(0,8);if(sig!=head){$("#error").innerHTML="Image file type should be PNG";return;}showImage(true);varwidth=getBinValue(bin,8+0x08,4);varheight=getBinValue(bin,8+0x0c,4);vardepth=bin.charCodeAt(8+0x10);/*$("#info").innerHTML =
"width: " + width + "px<br>" +
"height: " + height + "px<br>" +
"depth: " + depth + "bit";*/varreader=newFileReader();reader.onload=function(e){console.log(reader);$("#image-frame").src=reader.result;$("#b64").value=reader.result;}reader.readAsDataURL(targetFile)}functiongetBinValue(bin,i,size){varv=0;for(varj=0;j<size;j++){varb=bin.charCodeAt(i+j);v=(v<<8)+b;}returnv;}functionshowImage(b){varval=b?"block":"none";//$("#upbtn").style.display = val;console.log("val",val);$("#image-frame").style.display=val;//$("#info").style.display = val;}function$(id){returndocument.querySelector(id);}</script></html>
ここではpngファイルの選択と表示を行っています。
参考サイト: HTML5のFile APIでローカルファイル情報取得してやんよ!!!
list.js 友達リスト処理
varexpress=require('express');varrouter=express.Router();vardb=require('../helper/db');varUser=db.User;constverifyToken=require('../helper/VerifyToken');//--------------------------------------------------------// 友達リスト画面の出力//--------------------------------------------------------router.get('/',verifyToken,function(req,res,next){varusers=[];// ログインしたユーザーを除くすべての登録ユーザーをデータベースから取得し、ユーザー名とプロファイル画像のjsonオブジェクトを作成しますUser.find({nickName:{$ne:req.name}}).stream().on('data',function(doc){varbase64Data;if(doc.userImage!==undefined){base64Data=doc.userImage.replace(/^data:image\/png;base64,/,"")}users.push({id:doc._id,nickName:doc.nickName,userImage:base64Data});}).on('error',function(err){res.send(err);}).on('end',function(){res.render('list.ejs',{listUsers:users});});});module.exports=router;
データベースから自分以外の友達を検索し、その一覧を引数としてリスト画面のレンダリング処理を呼び出しています。
ちなみに、冒頭のrouter.get('/',verifyToken, function(req, res, next) { に書かれている verifyTokenが前述のJson Web Tokenの検証処理です。
共通関数:JSON Web Tokenの処理(VerifyToken.js)
// Json Web Tokenのチェック処理varjwt=require('jsonwebtoken');constconfig=require('../config');vardb=require('../helper/db');varWaste=db.Waste;functionverifyToken(req,res,next){vartoken=req.cookies.auth;if(!token){returnres.status(403).render('errorpage.ejs',{error:'15分間無操作のためログアウトしました',errors:false});}// 破棄済みトークンを検索Waste.findOne({'cookie':token}).then(data=>{if(data){returnres.status(403).render('errorpage.ejs',{error:'不正なトークンです',errors:false});}});jwt.verify(token,config.secret,function(err,decoded){if(err){returnres.status(500).render('errorpage.ejs',{error:'Failed to authenticate token.',errors:false});}else{// すべてうまくいけば、他のルートで使用するために保存して次へreq.name=decoded.name;req.id=decoded.id;next();}});}module.exports=verifyToken;
JWTが期限切れ(15分)もしくは不正なトークンの場合はエラーを返しています。
また、ここではログアウト時に登録された破棄済みのクッキーと照合することによりクッキーの使い回しを防御しています。
最後に
いかがだったでしょうか。ちょっと前回から間が空いてしまいましたが、一応チャットサイトとして使えるようになったかと思います。パスワード変更ができないとか、エラー処理が中途半端とか、ログ機能がないとか、色々と細かくは実装していませんが友人同士での遊び程度では利用できるかと思っています。
Special Thanks
今回のソースはRupaliさんの支援を受けています。ありがとうございます。
次回は...
次回はこのチャットサイトからGoogle Dialogflowを呼び出して、本格的なチャットボットを実現してみたいと思います。
最終的には自然言語処理や画像認識、ブロックチェーンなんかにも足を伸ばせればと思ってます。