diff --git a/gae/.gcloudignore b/gae/.gcloudignore new file mode 100644 index 0000000..603f0b6 --- /dev/null +++ b/gae/.gcloudignore @@ -0,0 +1,19 @@ +# This file specifies files that are *not* uploaded to Google Cloud +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +# Python pycache: +__pycache__/ +# Ignored by the build system +/setup.cfg \ No newline at end of file diff --git a/gae/.tool-versions b/gae/.tool-versions new file mode 100644 index 0000000..5922314 --- /dev/null +++ b/gae/.tool-versions @@ -0,0 +1 @@ +python 3.10.0 diff --git a/gae/app.yaml b/gae/app.yaml index f5f35df..d1142cd 100644 --- a/gae/app.yaml +++ b/gae/app.yaml @@ -1,7 +1,12 @@ # use --project=pebble-notes for gcloud -runtime: python27 -api_version: 1 -threadsafe: true +runtime: python310 +# api_version: 1 +# threadsafe: true +# app_engine_apis: true +entrypoint: gunicorn -b :$PORT main:app + +env_variables: + ENVIRONMENT: production handlers: - url: / diff --git a/gae/auth.py b/gae/auth.py deleted file mode 100644 index d9b6084..0000000 --- a/gae/auth.py +++ /dev/null @@ -1,171 +0,0 @@ -import webapp2 -from urllib import urlencode -import json -import urllib2 -import random -from google.appengine.api import memcache - -from secret import client_id, client_secret -import config - - -# These words are used to generate word-based passcode. -# Here are 35 words, so there are 35*34*33*32=1256640 possible 4-word codes. -WORDS = ( - 'red green blue white brown violet purple black yellow orange ' - 'dog cat cow unicorn animal hedgehog chicken ' - 'task computer phone watch android robot apple ' - 'rambler rogue warrior king ' - 'jeans muffin cake bake cookie oven bread ' -).split() - -# Hey guys, I know this passcode scheme is "not very secure": it is possible -# (in theory) that somebody will guess some passcode in the 10-minutes interval -# while that code is not used and not deleted yet. -# But if you can offer any better solution then feel free to make a PR. -# "TV&Device-style" auth is not applicable because it doesn't give access -# to Tasks API. - -def query_json(url, data): - """ - Query JSON data from Google server using POST request. - Returns only data and ignores result code. - """ - if not isinstance(data, str): - data = urlencode(data) - try: - return json.loads(urllib2.urlopen(url, data).read()) - except urllib2.HTTPError as e: # exception is a file-like object - return json.loads(e.read()) - - -class AuthRedirect(webapp2.RequestHandler): - def get(self): - url = 'https://accounts.google.com/o/oauth2/auth?' + urlencode(dict( - client_id=client_id, - redirect_uri=config.auth_redir_uri, - response_type='code', - scope='https://www.googleapis.com/auth/tasks', - state='', - access_type='offline', - approval_prompt='force', - include_granted_scopes='true', - )) - self.response.location = url - self.response.status_int = 302 - - -class AuthCheck(webapp2.RequestHandler): - def post(self): - passcode = self.request.POST.get('passcode', '') - # normalize case and spaces - passcode = ' '.join(passcode.lower().split()) - tokendata = memcache.get(passcode, namespace='passcode') - if tokendata: - memcache.delete(passcode, namespace='passcode') - - self.response.headers['Content-Type'] = \ - "application/json; charset=UTF-8" - self.response.write(tokendata or '{"error": "Incorrect token"}') - - -class AuthPoller(webapp2.RequestHandler): - def get(self): - args = self.request.GET - args["client_id"] = client_id - args["redirect_uri"] = config.auth_redir_uri - url = "https://accounts.google.com/o/oauth2/auth?"+urlencode(args) - self.response.location = url - self.response.status_int = 302 - - -def json_compactify(data): - return json.dumps(data, separators=(',', ':')) # compact encoding - - -class AuthCallback(webapp2.RequestHandler): - """ - This page is called by Google when user finished auth process. - It receives state (currently unused), code (if success) - or error (if failure). - Then it queries Google for access and refresh tokens - and passes them in urlencode form - to intermediate static page, which will show status - and pass data to js code. - """ - def get(self): - #state = self.request.get("state") - code = self.request.get("code") - #error = self.request.get("error") - q = { - "code": code, - "client_id": client_id, - "client_secret": client_secret, - "redirect_uri": config.auth_redir_uri, - "grant_type": "authorization_code", - } - result = query_json("https://accounts.google.com/o/oauth2/token", q) - if 'access_token' not in result: - self.response.write('ERROR: %s' % result) - return - - passcode = ' '.join(random.sample(WORDS, 4)) - passcode2 = str(random.randrange(10**4, 10**5)) - data = json_compactify(result) - lifetime = 10 # store for 10 minutes - for code in (passcode, passcode2): - memcache.add(code, data, namespace='passcode', - time=lifetime*60) - - self.response.headers['Content-Type'] = 'text/html'; - self.response.write( - ' ' - '

Passcode: {passcode}

' - '

Or: {passcode2}

' - '

It will expire in {lifetime} minutes.

' - '

Enter it on application settings page

' - .format( - passcode=passcode, - passcode2=passcode2, - lifetime=lifetime, - ) - ) - - -class AuthRefresh(webapp2.RequestHandler): - """ - This page is used by client to refresh their access tokens. - An access token has lifetime of 1 hour, after that it becomes invalid and - needs to be refreshed. - So we receive refresh_token as a parameter - and return a new access_token with its lifetime as a json result. - """ - def get(self): - refresh_token = self.request.get("refresh_token") - if not refresh_token: - self.response.status_int = 400 - return - q = { - "refresh_token": refresh_token, - "client_id": client_id, - "client_secret": client_secret, - "grant_type": "refresh_token", - } - result = query_json("https://accounts.google.com/o/oauth2/token", q) - self.response.headers['Content-Type'] = \ - "application/json; charset=UTF-8" - self.response.write(json_compactify(result)) - # return result as JSON - - -application = webapp2.WSGIApplication([ - ('/auth', AuthRedirect), - ('/auth/check', AuthCheck), - ('/auth/result', AuthCallback), - ('/auth/refresh', AuthRefresh), -], debug=True) diff --git a/gae/config.py b/gae/config.py index 4b5ae32..8fac20c 100644 --- a/gae/config.py +++ b/gae/config.py @@ -1,13 +1,15 @@ import os # get our version id -version = os.environ['CURRENT_VERSION_ID'].split('.')[0] +# version = os.environ['CURRENT_VERSION_ID'].split('.')[0] -# construct hostname for the current version -# (use -dot- instead of . to avoid SSL problems) -# see also: https://developers.google.com/appengine/kb/general#https -host = "https://%s-dot-pebble-notes.appspot.com" % version +environment = os.getenv('ENVIRONMENT', 'local') +# Set the host based on the environment +if environment == 'production': + host = "https://pebble-notes-426618.uc.r.appspot.com" +else: + host = "http://127.0.0.1:5000" # where user will be redirected after logging in with Google auth_redir_uri = host+"/auth/result" diff --git a/gae/main.py b/gae/main.py new file mode 100644 index 0000000..647a9d2 --- /dev/null +++ b/gae/main.py @@ -0,0 +1,115 @@ +from flask import Flask, redirect, request, jsonify +import json +import random +import urllib.request +from urllib.parse import urlencode +import os + +try: + from google.appengine.api import memcache +except ImportError: + # Mock memcache for local development + class SimpleCache: + def __init__(self): + self.store = {} + + def set(self, key, value, time=0): + self.store[key] = value + + def get(self, key): + return self.store.get(key) + + memcache = SimpleCache() + +from secret import client_id, client_secret +import config + +app = Flask(__name__) + +WORDS = ( + 'red green blue white brown violet purple black yellow orange ' + 'dog cat cow unicorn animal hedgehog chicken ' + 'task computer phone watch android robot apple ' + 'rambler rogue warrior king ' + 'jeans muffin cake bake cookie oven bread ' +).split() + +def query_json(url, data): + if not isinstance(data, str): + data = urlencode(data).encode() + try: + with urllib.request.urlopen(url, data) as response: + return json.loads(response.read()) + except urllib.error.HTTPError as e: + return json.loads(e.read()) + +@app.route('/auth') +def auth_redirect(): + url = 'https://accounts.google.com/o/oauth2/auth?' + urlencode({ + 'client_id': client_id, + 'redirect_uri': config.auth_redir_uri, + 'response_type': 'code', + 'scope': 'https://www.googleapis.com/auth/tasks', + 'state': '', + 'access_type': 'offline', + 'approval_prompt': 'force', + 'include_granted_scopes': 'true', + }) + return redirect(url) + +@app.route('/auth/check') +def auth_check(): + lifetime = 10 + passcode = '-'.join(random.sample(WORDS, 4)) + passcode2 = passcode.replace('-', '') + + memcache.set(passcode, request.args.get('state'), time=lifetime*60) + memcache.set(passcode2, request.args.get('state'), time=lifetime*60) + + html = f''' + +

Passcode: {passcode}

+

Or: {passcode2}

+

It will expire in {lifetime} minutes.

+

Enter it on application settings page

+ ''' + return html + +@app.route('/auth/result') +def auth_callback(): + code = request.args.get('code') + if not code: + return 'Code parameter missing', 400 + + result = query_json('https://accounts.google.com/o/oauth2/token', { + 'code': code, + 'client_id': client_id, + 'client_secret': client_secret, + 'redirect_uri': config.auth_redir_uri, + 'grant_type': 'authorization_code' + }) + return jsonify(result) + +@app.route('/auth/refresh') +def auth_refresh(): + refresh_token = request.args.get('refresh_token') + if not refresh_token: + return 'Refresh token parameter missing', 400 + + result = query_json('https://accounts.google.com/o/oauth2/token', { + 'refresh_token': refresh_token, + 'client_id': client_id, + 'client_secret': client_secret, + 'grant_type': 'refresh_token' + }) + return jsonify(result) + +if __name__ == '__main__': + app.run(debug=True) diff --git a/gae/requirements.txt b/gae/requirements.txt new file mode 100644 index 0000000..877982e --- /dev/null +++ b/gae/requirements.txt @@ -0,0 +1,6 @@ +Flask==2.3.2 +gunicorn==20.1.0 +google-api-python-client==2.92.0 +google-auth==2.21.0 +google-auth-httplib2==0.1.0 +google-auth-oauthlib==1.0.0 diff --git a/gae/static/index.html b/gae/static/index.html index f60c326..a1e0c4f 100644 --- a/gae/static/index.html +++ b/gae/static/index.html @@ -9,7 +9,7 @@

GoogleTasks for Pebble (ex. PebbleNotes)

This is a Google Tasks client for Pebble smartwatch.

-

You may discuss it here.

+

You may discuss it here.

Available in Rebble AppStore.


Authentication

@@ -21,7 +21,7 @@

Authentication

Pebble is fading away, but is still alive. This application is one of the examples. - You can donate to support it. + You can donate to support it.

Privacy Policy

diff --git a/gae/static/notes-config.html b/gae/static/notes-config.html index e13f684..398bdbb 100644 --- a/gae/static/notes-config.html +++ b/gae/static/notes-config.html @@ -20,7 +20,7 @@

Account

-

Please open https://pebble-notes.appspot.com/ (or goo.gl/6U5Zxn) in your browser +

Please open https://pebble-notes-426618.uc.r.appspot.com// in your browser and login there to obtain your passcode.

diff --git a/src/js/main.js b/src/js/main.js index aa4bf5e..c077be7 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -3,7 +3,7 @@ var g_xhr_timeout = 30000; // Timeout for sending appmessage to Pebble, in milliseconds var g_msg_timeout = 8000; -var g_server_url = "https://2-dot-pebble-notes.appspot.com"; +var g_server_url = "https://pebble-notes-426618.uc.r.appspot.com/"; var g_options = { "sort_status": false,