Skip to content

Commit 988acf5

Browse files
authored
Add firebase-based tic-tac-toe sample. (GoogleCloudPlatform#560)
* Fix app engine readme * Add firebase-based tic-tac-toe sample. Mostly a refactor of stuff from @mogar1980 * Use Application Default Credentials. * Refactor client with jquery + css
1 parent 8bc19a3 commit 988acf5

File tree

10 files changed

+616
-1
lines changed

10 files changed

+616
-1
lines changed

appengine/standard/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Google App Engine Samples
22

3-
This section contains samples for [Google Cloud Storage](https://cloud.google.com/storage). Most of these samples have associated documentation that is linked
3+
This section contains samples for [Google App Engine](https://cloud.google.com/appengine). Most of these samples have associated documentation that is linked
44
within the docstring of the sample itself.
55

66
## Running the samples locally
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Tic Tac Toe, using Firebase, on App Engine Standard
2+
3+
This sample shows how to use the [Firebase](https://firebase.google.com/)
4+
realtime database to implement a simple Tic Tac Toe game on [Google App Engine
5+
Standard](https://cloud.google.com/appengine).
6+
7+
## Setup
8+
9+
Make sure you have the [Google Cloud SDK](https://cloud.google.com/sdk/)
10+
installed. You'll need this to test and deploy your App Engine app.
11+
12+
### Authentication
13+
14+
* Create a project in the [Firebase
15+
console](https://firebase.google.com/console)
16+
* In the Overview section, click 'Add Firebase to your web app' and replace the
17+
contents of the file
18+
[`templates/_firebase_config.html`](templates/_firebase_config.html) with the
19+
given snippet. This provides credentials for the javascript client.
20+
* For running the sample locally, you'll need to download a service account to
21+
provide credentials that would normally be provided automatically in the App
22+
Engine environment. Click the gear icon in the Firebase Console and select
23+
'Permissions'; then go to the 'Service accounts' tab. Download a new or
24+
existing App Engine service account credentials file. Then set the environment
25+
variable `GOOGLE_APPLICATION_CREDENTIALS` to the path to this file:
26+
27+
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json
28+
29+
This allows the server to create unique secure tokens for each user for
30+
Firebase to validate.
31+
32+
### Install dependencies
33+
34+
Before running or deploying this application, install the dependencies using
35+
[pip](http://pip.readthedocs.io/en/stable/):
36+
37+
pip install -t lib -r requirements.txt
38+
39+
## Running the sample
40+
41+
dev_appserver.py .
42+
43+
For more information on running or deploying the sample, see the [App Engine
44+
Standard README](../../README.md).
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
runtime: python27
2+
api_version: 1
3+
threadsafe: true
4+
5+
handlers:
6+
- url: /static
7+
static_dir: static
8+
9+
- url: /.*
10+
script: firetactoe.app
11+
login: required
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from google.appengine.ext import vendor
2+
3+
# Add any libraries installed in the "lib" folder.
4+
vendor.add('lib')
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
# Copyright 2016 Google Inc. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tic Tac Toe with the Firebase API"""
16+
17+
import base64
18+
import json
19+
import os
20+
import re
21+
import time
22+
import urllib
23+
24+
25+
import flask
26+
from flask import request
27+
from google.appengine.api import app_identity
28+
from google.appengine.api import users
29+
from google.appengine.ext import ndb
30+
import httplib2
31+
from oauth2client.client import GoogleCredentials
32+
33+
34+
_FIREBASE_CONFIG = '_firebase_config.html'
35+
36+
_IDENTITY_ENDPOINT = ('https://identitytoolkit.googleapis.com/'
37+
'google.identity.identitytoolkit.v1.IdentityToolkit')
38+
_FIREBASE_SCOPES = [
39+
'https://www.googleapis.com/auth/firebase.database',
40+
'https://www.googleapis.com/auth/userinfo.email']
41+
42+
_X_WIN_PATTERNS = [
43+
'XXX......', '...XXX...', '......XXX', 'X..X..X..', '.X..X..X.',
44+
'..X..X..X', 'X...X...X', '..X.X.X..']
45+
_O_WIN_PATTERNS = map(lambda s: s.replace('X', 'O'), _X_WIN_PATTERNS)
46+
47+
X_WINS = map(lambda s: re.compile(s), _X_WIN_PATTERNS)
48+
O_WINS = map(lambda s: re.compile(s), _O_WIN_PATTERNS)
49+
50+
51+
app = flask.Flask(__name__)
52+
53+
54+
def _get_firebase_db_url(_memo={}):
55+
"""Grabs the databaseURL from the Firebase config snippet."""
56+
# Memoize the value, to avoid parsing the code snippet every time
57+
if 'dburl' not in _memo:
58+
regex = re.compile(r'\bdatabaseURL\b.*?["\']([^"\']+)')
59+
cwd = os.path.dirname(__file__)
60+
with open(os.path.join(cwd, 'templates', _FIREBASE_CONFIG)) as f:
61+
url = next(regex.search(line) for line in f if regex.search(line))
62+
_memo['dburl'] = url.group(1)
63+
return _memo['dburl']
64+
65+
66+
def _get_http(_memo={}):
67+
"""Provides an authed http object."""
68+
if 'http' not in _memo:
69+
# Memoize the authorized http, to avoid fetching new access tokens
70+
http = httplib2.Http()
71+
# Use application default credentials to make the Firebase calls
72+
# https://firebase.google.com/docs/reference/rest/database/user-auth
73+
creds = GoogleCredentials.get_application_default().create_scoped(
74+
_FIREBASE_SCOPES)
75+
creds.authorize(http)
76+
_memo['http'] = http
77+
return _memo['http']
78+
79+
80+
def _send_firebase_message(u_id, message=None):
81+
url = '{}/channels/{}.json'.format(_get_firebase_db_url(), u_id)
82+
83+
if message:
84+
return _get_http().request(url, 'PATCH', body=message)
85+
else:
86+
return _get_http().request(url, 'DELETE')
87+
88+
89+
def create_custom_token(uid, valid_minutes=60):
90+
"""Create a secure token for the given id.
91+
92+
This method is used to create secure custom JWT tokens to be passed to
93+
clients. It takes a unique id (uid) that will be used by Firebase's
94+
security rules to prevent unauthorized access. In this case, the uid will
95+
be the channel id which is a combination of user_id and game_key
96+
"""
97+
header = base64.b64encode(json.dumps({'typ': 'JWT', 'alg': 'RS256'}))
98+
99+
client_email = app_identity.get_service_account_name()
100+
now = int(time.time())
101+
payload = base64.b64encode(json.dumps({
102+
'iss': client_email,
103+
'sub': client_email,
104+
'aud': _IDENTITY_ENDPOINT,
105+
'uid': uid,
106+
'iat': now,
107+
'exp': now + (valid_minutes * 60),
108+
}))
109+
110+
to_sign = '{}.{}'.format(header, payload)
111+
112+
# Sign the jwt
113+
return '{}.{}'.format(to_sign, base64.b64encode(
114+
app_identity.sign_blob(to_sign)[1]))
115+
116+
117+
class Game(ndb.Model):
118+
"""All the data we store for a game"""
119+
userX = ndb.UserProperty()
120+
userO = ndb.UserProperty()
121+
board = ndb.StringProperty()
122+
moveX = ndb.BooleanProperty()
123+
winner = ndb.StringProperty()
124+
winning_board = ndb.StringProperty()
125+
126+
def to_json(self):
127+
d = self.to_dict()
128+
d['winningBoard'] = d.pop('winning_board')
129+
return json.dumps(d, default=lambda user: user.user_id())
130+
131+
def send_update(self):
132+
"""Updates Firebase's copy of the board."""
133+
message = self.to_json()
134+
# send updated game state to user X
135+
_send_firebase_message(
136+
self.userX.user_id() + self.key.id(),
137+
message=message)
138+
# send updated game state to user O
139+
if self.userO:
140+
_send_firebase_message(
141+
self.userO.user_id() + self.key.id(),
142+
message=message)
143+
144+
def _check_win(self):
145+
if self.moveX:
146+
# O just moved, check for O wins
147+
wins = O_WINS
148+
potential_winner = self.userO.user_id()
149+
else:
150+
# X just moved, check for X wins
151+
wins = X_WINS
152+
potential_winner = self.userX.user_id()
153+
154+
for win in wins:
155+
if win.match(self.board):
156+
self.winner = potential_winner
157+
self.winning_board = win.pattern
158+
return
159+
160+
# In case of a draw, everyone loses.
161+
if ' ' not in self.board:
162+
self.winner = 'Noone'
163+
164+
def make_move(self, position, user):
165+
# If the user is a player, and it's their move
166+
if (user in (self.userX, self.userO)) and (
167+
self.moveX == (user == self.userX)):
168+
boardList = list(self.board)
169+
# If the spot you want to move to is blank
170+
if (boardList[position] == ' '):
171+
boardList[position] = 'X' if self.moveX else 'O'
172+
self.board = ''.join(boardList)
173+
self.moveX = not self.moveX
174+
self._check_win()
175+
self.put()
176+
self.send_update()
177+
return
178+
179+
180+
@app.route('/move', methods=['POST'])
181+
def move():
182+
game = Game.get_by_id(request.args.get('g'))
183+
position = int(request.form.get('i'))
184+
if not (game and (0 <= position <= 8)):
185+
return 'Game not found, or invalid position', 400
186+
game.make_move(position, users.get_current_user())
187+
return ''
188+
189+
190+
@app.route('/delete', methods=['POST'])
191+
def delete():
192+
game = Game.get_by_id(request.args.get('g'))
193+
if not game:
194+
return 'Game not found', 400
195+
user = users.get_current_user()
196+
_send_firebase_message(
197+
user.user_id() + game.key.id(), message=None)
198+
return ''
199+
200+
201+
@app.route('/opened', methods=['POST'])
202+
def opened():
203+
game = Game.get_by_id(request.args.get('g'))
204+
if not game:
205+
return 'Game not found', 400
206+
game.send_update()
207+
return ''
208+
209+
210+
@app.route('/')
211+
def main_page():
212+
"""Renders the main page. When this page is shown, we create a new
213+
channel to push asynchronous updates to the client."""
214+
user = users.get_current_user()
215+
game_key = request.args.get('g')
216+
217+
if not game_key:
218+
game_key = user.user_id()
219+
game = Game(id=game_key, userX=user, moveX=True, board=' '*9)
220+
game.put()
221+
else:
222+
game = Game.get_by_id(game_key)
223+
if not game:
224+
return 'No such game', 404
225+
if not game.userO:
226+
game.userO = user
227+
game.put()
228+
229+
# choose a unique identifier for channel_id
230+
channel_id = user.user_id() + game_key
231+
# encrypt the channel_id and send it as a custom token to the
232+
# client
233+
# Firebase's data security rules will be able to decrypt the
234+
# token and prevent unauthorized access
235+
client_auth_token = create_custom_token(channel_id)
236+
_send_firebase_message(
237+
channel_id, message=game.to_json())
238+
239+
game_link = '{}?g={}'.format(request.base_url, game_key)
240+
241+
# push all the data to the html template so the client will
242+
# have access
243+
template_values = {
244+
'token': client_auth_token,
245+
'channel_id': channel_id,
246+
'me': user.user_id(),
247+
'game_key': game_key,
248+
'game_link': game_link,
249+
'initial_message': urllib.unquote(game.to_json())
250+
}
251+
252+
return flask.render_template('fire_index.html', **template_values)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
flask==0.11.1
2+
requests==2.11.1
3+
requests_toolbelt==0.7.0
4+
oauth2client==2.2.0

0 commit comments

Comments
 (0)