Initial commit
This commit is contained in:
commit
150fe2f77d
9 changed files with 357 additions and 0 deletions
16
LICENSE
Normal file
16
LICENSE
Normal file
|
@ -0,0 +1,16 @@
|
|||
Copyright 2022 s3lph
|
||||
|
||||
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.
|
72
README.md
Normal file
72
README.md
Normal file
|
@ -0,0 +1,72 @@
|
|||
# rC3 Counter-Badge
|
||||
|
||||
This project was created during [rC3 2021 NOWHERE][rc3] in order to
|
||||
issue badges (i.e. achievements) for a treasure hunt style game on a
|
||||
[WorkAdventure][wa] map.
|
||||
|
||||
## License
|
||||
|
||||
See LICENSE
|
||||
|
||||
## Dependencies
|
||||
|
||||
* Python 3 >= 3.6
|
||||
* Bottle
|
||||
* Beaker
|
||||
|
||||
## How To Set Up
|
||||
|
||||
### Create your Badges in Maschinenraum
|
||||
|
||||
![Create your badge in Maschinenraum](./howto/01-create-badge.png)
|
||||
|
||||
1. Click the `+` button next to the `Badges` menu entry
|
||||
1. Give you badge a name
|
||||
1. Make the badge an achievement
|
||||
1. Upload a badge picture
|
||||
1. Save the badge
|
||||
|
||||
Repeat for every badge you want to create.
|
||||
|
||||
### Issue Badge Redeem Tokens
|
||||
|
||||
![Issue Badge Redeem Tokens](./howto/02-issue-badge-tokens.png)
|
||||
|
||||
1. Click on the `Badges` menu entry
|
||||
1. Select your badge
|
||||
1. Under `Create Badge Redeem Tokens`, choose the `Permanent` type.
|
||||
1. Click Create Redeem Token. **Save the redeem token for later use.**
|
||||
|
||||
Repeat for every badge you want to create.
|
||||
|
||||
### Create OAuth2 Client
|
||||
|
||||
![Create OAuth2 Client](./howto/03-register-oauth-client.png)
|
||||
|
||||
1. Click on the `Authentication` menu entry
|
||||
1. If not already done, create a rC3 API token by clicking `Request a new token`. **Save the API token for later use.*
|
||||
1. Give your new OAuth2 client a name.
|
||||
1. Set its type to `Public`.
|
||||
1. Set the grant type to `Auhtoziation code`.
|
||||
1. Click `New application` to create the OAuth2 client.
|
||||
|
||||
### Generate Counter Tokens
|
||||
|
||||
![Create Layers in Tiled](./howto/04-create-tiled-layer.png)
|
||||
|
||||
1. For each unique place on your WorkAdventure map, generate a unique token, e.g. using `uuidgen`.
|
||||
1. Create a layer in Tiled for each unique place and add at least the `openWebsite` layer property with a value of e.g. `https://<yourdomain>/counter/<my-counter-name>/<the-unique-token>`.
|
||||
|
||||
### Deploy
|
||||
|
||||
Set up counterbadge.py as a service on your system, e.g. as a systemd
|
||||
service (see `counterbadge.service` as an example). Set up a
|
||||
TLS-terminating reverse proxy.
|
||||
|
||||
### Configure
|
||||
|
||||
See `counterbadge.example.json` as a config example. Put in the API, OAuth2 and Badge credentials you created earlier.
|
||||
|
||||
|
||||
[rc3]: http://web.archive.org/web/20211229071129/https://links.rc3.world/
|
||||
[wa]: https://workadventu.re/
|
31
counterbadge.example.json
Normal file
31
counterbadge.example.json
Normal file
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"counters": {
|
||||
"my-treasure-hunt": {
|
||||
"display_name": "My rC3 Treasure Hunt",
|
||||
"unique_tokens": [
|
||||
"bc243542-f7d9-4474-943a-15e5936c9943",
|
||||
"d6cf7658-a556-4784-bcb7-06d12fc47f54",
|
||||
"ee81ad35-2a04-4781-9a09-e925ce2b5eab",
|
||||
"202102f8-aca9-42e1-b6f9-0cb7d0dbb380",
|
||||
"generate unique, non-guessable tokens for each place to visit"
|
||||
],
|
||||
"profile_url": "https://api.rc3.world/api/me",
|
||||
"redeem_url": "https://api.rc3.world/api/c/rc3_21/badges/redeem_token",
|
||||
"api_token": "Your assembly's API token. Generate it at Maschinenraum / Authentication / Request a New Token",
|
||||
"issue_at": {
|
||||
"1": "A permanent redeem token",
|
||||
"5": "Another permanent redeem token. Generate it at Maschinenraum / Badges / <the badge> / Create Badge Redeem Token"
|
||||
},
|
||||
"oauth": {
|
||||
"client_id": "Your OAuth2 Client ID. Register a public OAuth2-Client at Maschinenraum / Authentication / New Application",
|
||||
"client_secret": "Your OAuth2 Client Secret",
|
||||
"authorize_url": "https://rc3.world/sso/authorize/",
|
||||
"token_url": "https://rc3.world/sso/token/",
|
||||
"scope": "rc3_21_attendee",
|
||||
"redirect_url": "https://example.org/counter/my-treasure-hunt/redirect",
|
||||
"response_type": "code"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
226
counterbadge.py
Normal file
226
counterbadge.py
Normal file
|
@ -0,0 +1,226 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import bottle
|
||||
from bottle import run, get, abort, request, redirect
|
||||
from beaker.middleware import SessionMiddleware
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from urllib.error import HTTPError
|
||||
from uuid import uuid4
|
||||
|
||||
session_opts = {
|
||||
'session.type': 'file',
|
||||
'session.cookie_expires': 3600,
|
||||
'session.data_dir': './data',
|
||||
'session.auto': True,
|
||||
'session.secure': True,
|
||||
'session.samesite': 'None'
|
||||
}
|
||||
app = SessionMiddleware(bottle.app(), session_opts)
|
||||
|
||||
|
||||
DB = sqlite3.connect('counterbadge.sqlite3')
|
||||
DBC = DB.cursor()
|
||||
try:
|
||||
DBC.execute('''
|
||||
CREATE TABLE IF NOT EXISTS counters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
counter TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
UNIQUE (counter, token, username)
|
||||
)
|
||||
''')
|
||||
DBC.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_counters ON counters (counter, username)
|
||||
''')
|
||||
DB.commit()
|
||||
except:
|
||||
DB.rollback()
|
||||
finally:
|
||||
DBC.close()
|
||||
|
||||
|
||||
def update_counter(counter, token, username):
|
||||
cur = DB.cursor()
|
||||
try:
|
||||
changes = DB.total_changes
|
||||
cur.execute('INSERT OR IGNORE INTO counters (counter, token, username) VALUES (:counter, :token, :username)', {
|
||||
'counter': counter,
|
||||
'token': token,
|
||||
'username': username
|
||||
})
|
||||
changes = DB.total_changes - changes
|
||||
cur.execute('SELECT COUNT(token) FROM counters WHERE counter = :counter AND username = :username', {
|
||||
'counter': counter,
|
||||
'username': username
|
||||
})
|
||||
rows = cur.fetchone()
|
||||
DB.commit()
|
||||
except BaseException as e:
|
||||
DB.rollback()
|
||||
raise e
|
||||
finally:
|
||||
cur.close()
|
||||
return rows[0], changes > 0
|
||||
|
||||
|
||||
def get_user(counterconf):
|
||||
oauthconf = counterconf.get('oauth', {})
|
||||
for key in ['client_id', 'client_secret', 'authorize_url', 'token_url', 'scope', 'redirect_url', 'response_type']:
|
||||
if key not in oauthconf:
|
||||
raise KeyError(key)
|
||||
s = request.environ.get('beaker.session')
|
||||
if 'username' in s:
|
||||
return s['username']
|
||||
s['redirect_url'] = request.url
|
||||
s['state'] = uuid4()
|
||||
query = {
|
||||
'client_id': oauthconf['client_id'],
|
||||
'response_type': oauthconf['response_type'],
|
||||
'redirect_uri': oauthconf['redirect_url'],
|
||||
'scope': oauthconf['scope'],
|
||||
'state': s['state']
|
||||
}
|
||||
authorize = oauthconf['authorize_url'] + '?' + urllib.parse.urlencode(query)
|
||||
redirect(authorize)
|
||||
|
||||
|
||||
def redeem_code(oauthconf, code):
|
||||
query = {
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'client_id': oauthconf['client_id'],
|
||||
'client_secret': oauthconf['client_secret'],
|
||||
'redirect_uri': oauthconf['redirect_url']
|
||||
}
|
||||
req = urllib.request.urlopen(oauthconf['token_url'], data=urllib.parse.urlencode(query).encode())
|
||||
resp = json.loads(req.read().decode())
|
||||
return resp.get('access_token')
|
||||
|
||||
|
||||
def get_user_profile(counterconf, token):
|
||||
headers = {
|
||||
'Authorization': f'Bearer {token}'
|
||||
}
|
||||
req = urllib.request.Request(counterconf.get('profile_url'), headers=headers)
|
||||
req = urllib.request.urlopen(req)
|
||||
resp = json.loads(req.read().decode())
|
||||
return resp.get('username')
|
||||
|
||||
|
||||
@get('/counter/<counter>/redirect')
|
||||
def oauth_redirect(counter):
|
||||
with open('counterbadge.json', 'r') as f:
|
||||
config = json.load(f)
|
||||
counterconf = config.get('counters', {}).get(counter)
|
||||
oauthconf = counterconf.get('oauth', {})
|
||||
for key in ['client_id', 'client_secret', 'authorize_url', 'token_url', 'scope', 'redirect_url', 'response_type']:
|
||||
if key not in oauthconf:
|
||||
raise KeyError(key)
|
||||
s = request.environ.get('beaker.session')
|
||||
if 'username' in s and 'redirect_url' in s:
|
||||
redirect(s['redirect_url'])
|
||||
for key in ['code', 'state']:
|
||||
if key not in request.query:
|
||||
abort(400, 'Bad Request')
|
||||
if 'state' not in s:
|
||||
abort(400, 'Unknown state')
|
||||
state = str(s['state'])
|
||||
del s['state']
|
||||
if state != request.query.state:
|
||||
abort(401, 'Invalid state')
|
||||
if 'token' in request.query:
|
||||
token = request.query.token
|
||||
else:
|
||||
token = redeem_code(oauthconf, request.query.code)
|
||||
username = None
|
||||
if token:
|
||||
username = get_user_profile(counterconf, token)
|
||||
if username:
|
||||
s['username'] = username
|
||||
redirect(s['redirect_url'])
|
||||
abort(401, 'Something went wrong' + token)
|
||||
|
||||
@get('/counter/<counter>/ping')
|
||||
def ping(counter):
|
||||
with open('counterbadge.json', 'r') as f:
|
||||
config = json.load(f)
|
||||
counterconf = config.get('counters', {}).get(counter)
|
||||
username = get_user(counterconf)
|
||||
if not username:
|
||||
abort(401, 'Unautorized')
|
||||
display_name = counterconf.get('display_name', counter)
|
||||
return f'''
|
||||
<h1>{display_name}</h1>
|
||||
<p>
|
||||
Hi {username}
|
||||
</p>
|
||||
'''
|
||||
|
||||
@get('/counter/<counter>/<token>')
|
||||
def counter(counter, token):
|
||||
with open('counterbadge.json', 'r') as f:
|
||||
config = json.load(f)
|
||||
counterconf = config.get('counters', {}).get(counter)
|
||||
username = get_user(counterconf)
|
||||
if not username:
|
||||
abort(401, 'Unautorized')
|
||||
display_name = counterconf.get('display_name', counter)
|
||||
n_max = len(counterconf.get('unique_tokens', []))
|
||||
if not counterconf:
|
||||
abort(404, 'Not found')
|
||||
if token not in counterconf.get('unique_tokens', []):
|
||||
abort(404, 'Not found')
|
||||
redeem_url = counterconf.get('redeem_url')
|
||||
if not redeem_url:
|
||||
abort(500, 'Internal Server Error')
|
||||
api_token = counterconf.get('api_token')
|
||||
if not api_token:
|
||||
abort(500, 'Internal Server Error')
|
||||
issue_at = counterconf.get('issue_at', {})
|
||||
n, changed = update_counter(counter, token, username)
|
||||
issued = False
|
||||
for i, redeem_token in issue_at.items():
|
||||
if int(i) <= n:
|
||||
body = json.dumps({'token': redeem_token, 'username': username}).encode()
|
||||
try:
|
||||
headers = {
|
||||
'Authorization': f'Bearer {api_token}',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'rc3-counterbadge/0.1 (https://git.kabelsalat.ch/rc3-counterbadge)',
|
||||
}
|
||||
request = urllib.request.Request(redeem_url, data=body, headers=headers)
|
||||
response = urllib.request.urlopen(request)
|
||||
resp = json.loads(response.read().decode())
|
||||
# The API documentation is lying :( it doesn't in fact return
|
||||
# whether the badge was actually issued
|
||||
#issued = issued or resp.get('created', False)
|
||||
if changed and int(i) == n:
|
||||
issued = True
|
||||
except HTTPError as e:
|
||||
print(e, e.read().decode())
|
||||
abort(500, 'Internal Server Error')
|
||||
if issued:
|
||||
if n < n_max:
|
||||
issued = f'<p>Du hast eine Badge erhalten. Finde alle {n_max} Gegenstände, um mehr Badges zu erhalten.</p>'
|
||||
else:
|
||||
issued = f'<p>Du hast alle {n_max} Gegenstände gefunden und die letzte Badge erhalten. Herzlichen Glückwunsch!</p>'
|
||||
else:
|
||||
issued = ''
|
||||
return f'''
|
||||
<h1>{display_name}</h1>
|
||||
<p>
|
||||
Hi {username}
|
||||
</p>
|
||||
<p>
|
||||
Du hast <strong>{n}</strong> von {n_max} Gegenstände gefunden!
|
||||
</p>
|
||||
{issued}
|
||||
'''
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(app=app, host='127.0.0.1', port=8080)
|
12
counterbadge.service
Normal file
12
counterbadge.service
Normal file
|
@ -0,0 +1,12 @@
|
|||
[Unit]
|
||||
Description=rC3 Counter Badge Service
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /var/lib/counterbadge/rc3counterbadge.py
|
||||
User=counterbadge
|
||||
Group=counterbadge
|
||||
WorkingDirectory=/var/lib/counterbadge
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
BIN
howto/01-create-badge.png
Normal file
BIN
howto/01-create-badge.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
BIN
howto/02-issue-badge-tokens.png
Normal file
BIN
howto/02-issue-badge-tokens.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 81 KiB |
BIN
howto/03-register-oauth-client.png
Normal file
BIN
howto/03-register-oauth-client.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 97 KiB |
BIN
howto/04-create-tiled-layer.png
Normal file
BIN
howto/04-create-tiled-layer.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
Loading…
Reference in a new issue