init
This commit is contained in:
commit
a4c68ca9e2
76 changed files with 2737 additions and 0 deletions
3
server/.gitignore
vendored
Normal file
3
server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
instance/
|
||||
__pycache__/
|
||||
|
||||
22
server/Dockerfile
Normal file
22
server/Dockerfile
Normal 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
|
||||
0
server/application/backend/__init__.py
Normal file
0
server/application/backend/__init__.py
Normal file
5
server/application/backend/config.json
Normal file
5
server/application/backend/config.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"DATABASE_NAME": "sqlitedb",
|
||||
"TOKEN_SHOW_LIMIT": 4,
|
||||
"FIREBASE_JSON": "instance/servernotification-767d6-4d8506505911.json"
|
||||
}
|
||||
0
server/application/backend/data/__init__.py
Normal file
0
server/application/backend/data/__init__.py
Normal file
40
server/application/backend/data/dao_device.py
Normal file
40
server/application/backend/data/dao_device.py
Normal 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})
|
||||
|
||||
32
server/application/backend/data/data_models.py
Normal file
32
server/application/backend/data/data_models.py
Normal 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
|
||||
62
server/application/backend/data/db.py
Normal file
62
server/application/backend/data/db.py
Normal 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')
|
||||
8
server/application/backend/data/schema.sql
Normal file
8
server/application/backend/data/schema.sql
Normal 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
|
||||
);
|
||||
24
server/application/backend/encrypt.py
Normal file
24
server/application/backend/encrypt.py
Normal 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','')
|
||||
109
server/application/backend/flask_project.py
Normal file
109
server/application/backend/flask_project.py
Normal 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')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
9
server/application/backend/static/android.js
Normal file
9
server/application/backend/static/android.js
Normal 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()
|
||||
}
|
||||
16
server/application/backend/static/index.js
Normal file
16
server/application/backend/static/index.js
Normal 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();
|
||||
});
|
||||
94
server/application/backend/static/style.css
Normal file
94
server/application/backend/static/style.css
Normal 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;
|
||||
}
|
||||
58
server/application/backend/templates/index.html
Normal file
58
server/application/backend/templates/index.html
Normal 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>
|
||||
5
server/application/flask-wsgi.py
Normal file
5
server/application/flask-wsgi.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from backend.flask_project import create_app
|
||||
|
||||
app = create_app()
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
15
server/application/notification-service.ini
Normal file
15
server/application/notification-service.ini
Normal 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
8
server/create-db.sh
Executable 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
11
server/docker-compose.yml
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue