This commit is contained in:
Gergely Hegedus 2024-03-30 21:51:25 +02:00
commit a4c68ca9e2
76 changed files with 2737 additions and 0 deletions

3
server/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
instance/
__pycache__/

22
server/Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM python:3.9-bullseye
# ENV setup
RUN ["python","--version"]
RUN ["apt-get", "update"]
RUN ["pip", "install", "uwsgi"]
RUN ["uwsgi", "--version"]
RUN ["pip", "install", "flask"]
RUN ["pip", "install", "passlib"]
RUN ["pip", "install", "firebase_admin"]
RUN ["pip", "install", "pycryptodome"]
RUN ["pip", "install", "watchdog"]
RUN adduser --system --no-create-home flask
USER flask
WORKDIR /home/flask/server
# load source files
COPY ./application /home/flask/server
CMD uwsgi --ini notification-service.ini --touch-reload=notification-service.ini

View file

View file

@ -0,0 +1,5 @@
{
"DATABASE_NAME": "sqlitedb",
"TOKEN_SHOW_LIMIT": 4,
"FIREBASE_JSON": "instance/servernotification-767d6-4d8506505911.json"
}

View file

@ -0,0 +1,40 @@
from .data_models import Device
from .data_models import DataError
from .db import get_cursor
from sqlite3 import IntegrityError
def _device_from_row(row):
return Device(
name = row['device_name'],
token = row['device_token'],
encryption_key = row['encryption_key'],
)
def get_devices():
with get_cursor() as db_cursor:
db_cursor.execute("SELECT * FROM device")
rows = db_cursor.fetchall()
return map(_device_from_row, rows)
_INSERT_DEVICE_SQL = "INSERT INTO device(device_name, device_token, encryption_key)"\
"VALUES(:device_name, :device_token, :encryption_key)"
def insert_device(device: Device):
params = {
"device_name": device.name,
"device_token": device.token,
"encryption_key": device.encryption_key,
}
with get_cursor() as db_cursor:
try:
db_cursor.execute(_INSERT_DEVICE_SQL, params)
except IntegrityError as e:
return DataError.DEVICE_INSERT_ERROR
return db_cursor.lastrowid
def delete_device_by_name(name: str):
with get_cursor() as db_cursor:
db_cursor.execute('DELETE FROM device WHERE device_name=:name',{'name':name})

View file

@ -0,0 +1,32 @@
from enum import Enum
from enum import IntEnum
class Device:
def __init__(self, token, encryption_key, name):
self.token = token
self.encryption_key = encryption_key
self.name = name
def __eq__(self, other):
if not isinstance(other, Device):
return False
return self.token == other.token \
and self.encrpytion_key == other.encryption_key \
and self.name == other.mname
def __str__(self):
return 'Device(token={},encryption_key={},name={})'.format(self.token, self.encryption_key, self.name)
def __repr__(self):
return self.__str__
class DataError(Enum):
DEVICE_INSERT_ERROR = -1
class ResponseCode(IntEnum):
EMPTY_DEVICE_TOKEN = 401
EMPTY_DEVICE_NAME = 402
EMPTY_DEVICE_ENCRYPTION = 403
DEVICE_SAVE_FAILURE = 404
NOTIFICATION_PARAMS_MISSING = 405

View file

@ -0,0 +1,62 @@
import sqlite3
from os import path
from flask import current_app, g
from contextlib import contextmanager
@contextmanager
def get_cursor():
db = get_db()
cursor = db.cursor()
try:
yield (cursor)
finally:
cursor.close()
db.commit()
default_database_name = "sqlitedb"
def get_db():
current_app.config.get('DATABASE_PATH')
if 'db' not in g:
db_path = current_app.config.get('DATABASE_PATH')
if (db_path is None):
db_path = path.join(current_app.instance_path, current_app.config['DATABASE_NAME'])
g.db = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES)
g.db.row_factory = sqlite3.Row
return g.db
def close_db(e=None):
db = g.pop('db', None)
if db is not None:
db.close()
def init_db(db_path = None, schema_path = None):
if db_path is None:
db = get_db()
else:
db = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES)
if schema_path is None:
with current_app.open_resource('data/schema.sql') as f:
script = f.read().decode('UTF-8')
else:
with open(schema_path, "r") as f:
script = f.read()
db.executescript(script)
db.commit()
db.close()
def init_app(app):
app.teardown_appcontext(close_db)
if __name__ == "__main__":
db_path = path.join('/home/flask/server/instance/', default_database_name)
if path.exists(db_path):
print('Database already exists at {}. Will NOT override, if necessary first delete first then restart initialization!'.format(db_path))
exit(1)
schema_path = path.join(path.dirname(__file__), 'schema.sql')
init_db(db_path = db_path, schema_path = schema_path)
print('done')

View file

@ -0,0 +1,8 @@
DROP TABLE IF EXISTS user;
CREATE TABLE device (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_token TEXT NOT NULL,
encryption_key TEXT NOT NULL,
device_name TEXT NOT NULL
);

View file

@ -0,0 +1,24 @@
from Crypto.PublicKey import RSA
from Crypto.Random import get_random_bytes
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.Util.Padding import pad
import base64
def encrypt(message: str, encryption_key: str):
base64_decoded_key = base64.b64decode(encryption_key)
recipient_key = RSA.import_key(base64_decoded_key)
cipher_rsa = PKCS1_OAEP.new(recipient_key)
session_key = get_random_bytes(16)
enc_session_key = cipher_rsa.encrypt(session_key)
iv_key = get_random_bytes(16)
enc_iv_key = cipher_rsa.encrypt(iv_key)
cipher_aes = AES.new(session_key, AES.MODE_CBC, iv_key)
ciphertext = cipher_aes.encrypt(pad(message.encode("utf-8"), AES.block_size))
return {
"session_key": _encodeBytes(enc_session_key),
"iv": _encodeBytes(enc_iv_key),
"message": _encodeBytes(ciphertext),
}
def _encodeBytes(bytes: bytes) :
return base64.encodebytes(bytes).decode('ASCII').replace('\n','')

View file

@ -0,0 +1,109 @@
from os import path
from flask import render_template
from .data import db as db
from .data.data_models import ResponseCode
from .data.data_models import DataError
from .data.data_models import Device
from .data import dao_device
from .encrypt import encrypt
import firebase_admin
from firebase_admin import credentials, messaging
import json
from flask import request, jsonify, redirect
from flask import Flask
def create_app(test_config=None):
app = Flask(__name__)
if (test_config == None):
app.config.from_file('config.json', silent=True, load=json.load)
else:
app.config.from_mapping(test_config)
db.init_app(app)
firebase_cred = credentials.Certificate(app.config.get('FIREBASE_JSON'))
firebase_app = firebase_admin.initialize_app(firebase_cred)
@app.route('/', methods=['GET'])
def home():
devices = dao_device.get_devices()
token_limit = app.config.get('TOKEN_SHOW_LIMIT')
if (token_limit is None):
token_limit = 2
device_name_and_token = map(lambda device: format_device(device=device, token_limit=token_limit), devices)
return render_template('index.html', devices=device_name_and_token)
def format_device(device: Device, token_limit: int):
device_token_first = device.token[:token_limit]
device_token_last = device.token[-token_limit:]
device_token = device_token_first + ' ... ' + device_token_last
return (device.name, device_token)
@app.route('/delete', methods=['POST'])
def registerDelete():
device_name = request.form.get('device_name')
if device_name is None:
errorResponse = jsonify({'message':'DeviceName cannot be empty!','code':ResponseCode.EMPTY_DEVICE_NAME})
return errorResponse, 400
dao_device.delete_device_by_name(name=device_name)
return redirect("/")
@app.route("/register", methods=['POST'])
def register():
device_token = request.form.get('device_token')
device_name = request.form.get('device_name')
encryption_key = request.form.get('encryption_key')
if device_token is None:
errorResponse = jsonify({'message':'DeviceToken cannot be empty!','code':ResponseCode.EMPTY_DEVICE_TOKEN, 'request': request.form})
return errorResponse, 400
if device_name is None:
errorResponse = jsonify({'message':'DeviceName cannot be empty!','code':ResponseCode.EMPTY_DEVICE_NAME})
return errorResponse, 400
if encryption_key is None:
errorResponse = jsonify({'message':'DeviceEncryption cannot be empty!','code':ResponseCode.EMPTY_DEVICE_ENCRYPTION})
return errorResponse, 400
device = Device(name=device_name, token=device_token, encryption_key=encryption_key)
dao_device.delete_device_by_name(name=device.name)
result = dao_device.insert_device(device)
if result == DataError.DEVICE_INSERT_ERROR:
errorResponse = jsonify({'message':'Couldn\'t save device!','code':ResponseCode.DEVICE_SAVE_FAILURE})
return errorResponse, 400
return redirect("/")
@app.route("/notify", methods=['POST'])
def notify():
service = request.form.get('service') # name of the service
priority = request.form.get('priority') # Low, Medium, High
log = request.form.get('log') # log message
# could use batching but there shouldn't be that many devices so ¯\_(ツ)_/¯
devices = dao_device.get_devices()
if service and priority and log:
for device in devices:
dataWithEncryptedLog = encrypt(message=log, encryption_key=device.encryption_key)
dataWithEncryptedLog['priority'] = priority
dataWithEncryptedLog['service'] = service
message = messaging.Message(
data = dataWithEncryptedLog,
token = device.token
)
messaging.send(message)
return redirect("/")
else:
errorResponse = jsonify({'message':'service, priority & log cannot be empty!','code':ResponseCode.NOTIFICATION_PARAMS_MISSING})
return errorResponse, 400
return app
if __name__ == "__main__":
app = create_app()
app.run(host='0.0.0.0')

View file

@ -0,0 +1,9 @@
const deviceNameInput = document.getElementById("add_device_name");
const deviceTokenInput = document.getElementById("add_device_token");
const encryptionKeyInput = document.getElementById("add_encryption_key");
if (typeof Android !== 'undefined') {
encryptionKeyInput.value = Android.publicKey()
deviceTokenInput.value = Android.messagingToken()
deviceNameInput.value = Android.deviceName()
}

View file

@ -0,0 +1,16 @@
const addDeviceDialog = document.getElementById("add_device_dialog");
const openAddDeviceCTA = document.getElementById("add_device");
// close dialog on backdrop click
addDeviceDialog.addEventListener('click', function(event) {
var rect = addDeviceDialog.getBoundingClientRect();
var isInDialog = (rect.top <= event.clientY && event.clientY <= rect.top + rect.height &&
rect.left <= event.clientX && event.clientX <= rect.left + rect.width);
if (!isInDialog) {
addDeviceDialog.close();
}
});
openAddDeviceCTA.addEventListener("click", () => {
addDeviceDialog.showModal();
});

View file

@ -0,0 +1,94 @@
body {
background-color: #111;
color: #ddd;
align-items: center;
}
dialog {
background-color: #111;
border-color: #222;
color: #ddd;
align-items: center;
}
dialog {
padding: 16px 24px;
}
#container {
display: flex;
flex-direction: column;
align-items: center;
}
span {
cursor: pointer;
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none; /* Standard syntax */
}
#devices {
font-family: Arial, Helvetica, sans-serif;
border-collapse: collapse;
}
#devices td, #devices th {
border: 1px solid #222;
padding: 8px;
}
#devices tr {
color: #ddd;
}
td.delete {
text-align: center;
cursor: pointer;
}
#devices tr:nth-child(even){background-color: #121212;}
#devices tr:nth-child(odd){background-color: #060606;}
#devices tr:hover {background-color: #202020;}
#devices th {
padding-top: 12px;
padding-bottom: 12px;
text-align: left;
background-color: #310D78;
color: white;
}
::backdrop {
background-color: #000000;
opacity: 0.9;
}
.icon_button {
background-color: #00000000;
border: none;
color: white;
padding: 12px 12px;
cursor: pointer;
}
label {
font-size: 0.8rem;
padding: 0px 0px 4px 2px;
}
input[type=text], input[type=password] {
background-color: #101010;
color: #DDD;
border: 2px solid #222;
outline: none;
}
input[type=text]:focus, input[type=password]:focus {
background-color: #060606;
}
input[type=submit].primary {
background-color: #310D78;
color: #FFF;
padding: 4px 8px;
outline: none;
border: 0;
box-shadow: none;
border-radius: 0px;
}

View file

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registered devices</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
<script type="text/javascript" src="{{ url_for('static', filename='index.js') }}" defer></script>
<script type="text/javascript" src="{{ url_for('static', filename='android.js') }}" defer></script>
</head>
<body>
<div id="container">
<h1>Registered devices <button class="icon_button" id="add_device"> <span class="material-symbols-outlined">add_circle</span></button></h1>
<table id="devices">
<tr>
<th>Device Name</th>
<th>Token</th>
<th>Action</th>
</tr>
{% for device in devices %}
<tr>
<td>{{ device[0] }}</td>
<td>{{ device[1] }}</td>
<td>
<form action="/delete" method="post">
<input type="hidden" name="device_name" value="{{ device[0] }}"/>
<input type="submit" class="icon_button material-symbols-outlined" value="delete"/>
</form>
</td>
</tr>
{% endfor %}
</table>
<form action="/notify" method="post">
<h3>Send test notification
<input type="hidden" value="test-service" name="service"/>
<input type="hidden" value="Medium" name="priority"/>
<input type="hidden" value="Test Log" name="log"/>
<input type="submit" class="icon_button material-symbols-outlined" value="send"/>
</h3>
</form>
</div>
<dialog id="add_device_dialog">
<form action="/register" method="post">
<label for="add_device_name">Device name</label><br>
<input type="text" id="add_device_name" name="device_name" placeholder="Device name"><br><br>
<label for="add_device_token">Device Token</label><br>
<input type="text" id="add_device_token" name="device_token" placeholder="Device Token"><br><br>
<label for="add_encryption_key">Encryption Key</label><br>
<input type="password" id="add_encryption_key" name="encryption_key" placeholder="Encryption Key"><br><br>
<input type="submit" class="primary" value="Submit" style="float: right;" autofocus="true">
</form>
</dialog>
</body>
</html>

View file

@ -0,0 +1,5 @@
from backend.flask_project import create_app
app = create_app()
if __name__ == "__main__":
app.run()

View file

@ -0,0 +1,15 @@
[uwsgi]
module = flask-wsgi:app
master = true
processes = 5
socket=:3000
chmod-socket = 666
protocol = http
vacuum = true
die-on-term = true
enable-threads = true

8
server/create-db.sh Executable file
View file

@ -0,0 +1,8 @@
IMAGE=pns
docker build --tag $IMAGE .
mkdir application/instance
docker run -v $PWD/application:/home/flask/server --rm -it $IMAGE python backend/data/db.py
docker image rm $IMAGE

11
server/docker-compose.yml Normal file
View file

@ -0,0 +1,11 @@
services:
notification_service:
build:
context: .
dockerfile: ./Dockerfile
restart: "unless-stopped"
ports:
- 127.0.0.1:8080:3000
#- 8080:80
volumes:
- ./application:/home/flask/server