Initial commit

This commit is contained in:
2023-12-29 18:31:43 +00:00
commit b09f0ad7bd
24 changed files with 1530 additions and 0 deletions

6
README.md Normal file
View File

@@ -0,0 +1,6 @@
# SupportMe
## A Flask-based support ticket system powered by SQLite
My sister works at a dance studio and often has to deal with many enquiries and issues. Usually, people raise their issues via a telephone call or by email.
The development of a web-based support ticket system would provide a central service which users can use to submit issues, and will also allow for assistants to quickly see unclaimed or unanswered tickets and respond to them promptly.

BIN
data.db Normal file

Binary file not shown.

8
data.sqbpro Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?><sqlb_project><db path="H:/Subjects/Computer Science Y13/NEA/Build/data.db" readonly="0" foreign_keys="1" case_sensitive_like="0" temp_store="0" wal_autocheckpoint="1000" synchronous="2"/><attached/><window><main_tabs open="structure browser pragmas query" current="3"/></window><tab_structure><column_width id="0" width="300"/><column_width id="1" width="0"/><column_width id="2" width="100"/><column_width id="3" width="1272"/><column_width id="4" width="0"/><expanded_item id="0" parent="1"/><expanded_item id="1" parent="1"/><expanded_item id="2" parent="1"/><expanded_item id="3" parent="1"/></tab_structure><tab_browse><current_table name="4,4:mainUser"/><default_encoding codec=""/><browse_table_settings><table schema="main" name="User" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_"><sort/><column_widths><column index="1" value="51"/><column index="2" value="72"/><column index="3" value="71"/><column index="4" value="75"/><column index="5" value="91"/><column index="6" value="77"/><column index="7" value="66"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table></browse_table_settings></tab_browse><tab_sql><sql name="SQL 1">CREATE TABLE &quot;Message&quot; (
&quot;messageID&quot; INTEGER PRIMARY KEY,
&quot;ticketID&quot; INTEGER REFERENCES Ticket(ticketID),
&quot;authorID&quot; INTEGER REFERENCES User(userID),

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
PyJWT==2.6.0
flask==2.2.3

607
server.py Normal file
View File

@@ -0,0 +1,607 @@
import json, sqlite3, time, jwt, random, re
from flask import Flask, redirect, url_for, request, send_from_directory, render_template
JWT_SECRET_KEY = "".join([random.choice(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"]) for i in range(64)])
### start of global constants ###
ACCOUNT_TYPE_CUSTOMER = 1
ACCOUNT_TYPE_ASSISTANT = 2
TICKET_STATUS_OPEN = 1
TICKET_STATUS_CLOSED = 2
SYSTEM_USER_ID = 0
### end of global constants ###
App = Flask(
__name__,
static_url_path='',
static_folder='static',
template_folder='templates'
)
### start of server-side methods ###
def create_user(username, email, password):
with sqlite3.connect("data.db") as connection:
cursor = connection.cursor()
cursor.execute("SELECT * FROM User WHERE username = ?", [ username ])
if len(cursor.fetchall()) != 0:
return (False, "That username is already taken.")
cursor.execute("SELECT * FROM User WHERE email = ?", [ email ])
if len(cursor.fetchall()) != 0:
return (False, "A user is already registered with that email address.")
cursor.execute(
"INSERT INTO User (username, password, createdAt, accountType, profileIcon, email) VALUES (?, ?, ?, ?, ?, ?)",
[ username, password, int(time.time()), ACCOUNT_TYPE_CUSTOMER, f"/profile-icons/{random.choice(['blue', 'green', 'purple', 'red'])}.png", email ]
)
user_id = cursor.lastrowid
return (True, user_id)
def login_user(username, password):
with sqlite3.connect("data.db") as connection:
cursor = connection.cursor()
cursor.execute("SELECT userID, password, accountType FROM User WHERE username = ?", [ username ])
user_list = cursor.fetchall()
if len(user_list) == 0:
return (False, "A user with the supplied username and password was not found.")
_user_id, _password, _account_type = user_list[0]
if password == _password:
access_token = generate_access_token(_user_id, _account_type)
return (True, access_token)
else:
return (False, "A user with the supplied username and password was not found.")
def get_profile(user_id):
with sqlite3.connect("data.db") as connection:
cursor = connection.cursor()
cursor.execute("SELECT username, createdAt, accountType, profileIcon FROM User WHERE userID = ?", [ user_id ])
user_list = cursor.fetchall()
if len(user_list) == 0:
return None
_username, _created_at, _account_type, _profile_icon = user_list[0]
return {
"user_id": user_id,
"username": _username,
"created_at": _created_at,
"account_type": _account_type,
"profile_icon": _profile_icon
}
def generate_access_token(user_id, account_type):
access_token = jwt.encode({
"user_id": user_id,
"account_type": account_type,
"exp": int(time.time()) + 86400
}, JWT_SECRET_KEY, algorithm="HS256")
return access_token
def verify_access_token(access_token):
try:
d_at = jwt.decode(access_token.split(" ")[1], JWT_SECRET_KEY, algorithms=["HS256"])
if d_at["exp"] < time.time():
return None
else:
return d_at
except:
return None
def is_valid_username(username):
if not username.isalnum():
return False, "Username can only contain alphanumeric characters."
elif len(username) > 16:
return False, "Username cannot be more than 16 characters."
return True, True
def is_valid_password(password):
if len(password) < 8:
return False, "Password must be at least 8 characters."
elif len(password) > 32:
return False, "Password cannot be more than 32 characters."
return True, True
def is_valid_email(email):
if re.search('^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,63}$', email):
return True, True
else:
return False, "The provided email was invalid."
def is_valid_profile_icon(profile_icon):
if profile_icon not in [ f"/profile-icons/{c}.png" for c in ["blue", "green", "purple", "red"] ]:
return False, "Profile icon is invalid."
return True, True
def is_valid_ticket_title(ticket_title):
if not all(x.isalnum() or x.isspace() for x in ticket_title):
return False, "Ticket title can only contain spaces and alphanumeric characters."
elif len(ticket_title) < 8:
return False, "Ticket title must be at least 8 characters - be descriptive."
elif len(ticket_title) > 64:
return False, "Ticket title cannot be more than 64 characters - keep it concise."
return True, True
def is_valid_message(message):
if len(message) < 8 and not message.startswith("!"):
return False, "Message length must be at least 8 characters - be descriptive."
elif len(message) > 512:
return False, "Message length cannot be more than 512 characters."
return True, True
### end of server-side methods ###
### start of flask endpoints ###
@App.route('/', methods=['GET'])
@App.route('/home', methods=['GET'])
def home_req():
if request.method == 'GET':
return (render_template('home.html'), 200)
@App.route('/register', methods=['GET', 'POST'])
def register_req():
if request.method == 'GET':
return (render_template('register.html'), 200)
elif request.method == 'POST':
try:
registration_data = json.loads(request.get_data())
if list(registration_data.keys()) != ["username", "email", "password"]:
return ({ "error": "The request failed." }, 400)
if "" in list(registration_data.values()):
return ({ "error": "Please don't leave any blank fields." }, 400)
_valid_username = is_valid_username(registration_data["username"])
if not _valid_username[0]:
return ({ "error": _valid_username[1] }, 400)
_valid_password = is_valid_password(registration_data["password"])
if not _valid_password[0]:
return ({ "error": _valid_password[1] }, 400)
_valid_email = is_valid_email(registration_data["email"])
if not _valid_email[0]:
return ({ "error": _valid_email[1] }, 400)
cu_success, cu_res = create_user(registration_data["username"], registration_data["email"], registration_data["password"])
if cu_success:
return ({ "access_token": generate_access_token(cu_res, ACCOUNT_TYPE_CUSTOMER) }, 200)
else:
return ({ "error": cu_res }, 400)
except:
return ({ "error": "Server failed to parse the request." }, 400)
@App.route('/login', methods=['GET', 'POST'])
def login_req():
if request.method == 'GET':
return (render_template('login.html'), 200)
elif request.method == 'POST':
try:
login_data = json.loads(request.get_data())
if list(login_data.keys()) != ["username", "password"]:
return ({ "error": "The request failed." }, 400)
if "" in list(login_data.values()):
return ({ "error": "Please don't leave any blank fields." }, 400)
lu_success, lu_res = login_user(login_data["username"], login_data["password"])
if lu_success:
return ({ "access_token": lu_res }, 200)
else:
return ({ "error": lu_res }, 400)
except:
return ({ "error": "Server failed to parse the request." }, 400)
@App.route('/get-profile/<int:user_id>', methods=['GET'])
def get_profile_by_user_id_req(user_id):
if request.method == 'GET':
auth_user = verify_access_token(request.headers.get("Authorization"))
if auth_user == None:
return ({ "error": "User is not authenticated to make this request." }, 403)
with sqlite3.connect("data.db") as connection:
cursor = connection.cursor()
cursor.execute("SELECT username, createdAt, accountType, profileIcon FROM User WHERE userID = ?", [ user_id ])
user_list = cursor.fetchall()
if len(user_list) == 0:
return ({ "error": "User not found." }, 400)
_username, _created_at, _account_type, _profile_icon = user_list[0]
return ({ "user": {
"user_id": user_id,
"username": _username,
"created_at": _created_at,
"account_type": _account_type,
"profile_icon": _profile_icon
} }, 200)
@App.route('/ticket/new', methods=['GET', 'POST'])
def create_ticket_req():
if request.method == 'GET':
return (render_template('ticket/new.html'), 200)
elif request.method == 'POST':
try:
auth_user = verify_access_token(request.headers.get("Authorization"))
if auth_user == None or auth_user["account_type"] != ACCOUNT_TYPE_CUSTOMER:
return ({ "error": "User is not authenticated to make this request." }, 403)
ticket_data = json.loads(request.get_data())
if list(ticket_data.keys()) != ["ticket_title", "message"]:
return ({ "error": "The request failed." }, 400)
if "" in list(ticket_data.values()):
return ({ "error": "Please don't leave any blank fields." }, 400)
_valid_ticket_title = is_valid_ticket_title(ticket_data["ticket_title"])
if not _valid_ticket_title[0]:
return ({ "error": _valid_ticket_title[1] }, 400)
_valid_message = is_valid_message(ticket_data["message"])
if not _valid_message[0]:
return ({ "error": _valid_message[1] }, 400)
with sqlite3.connect("data.db") as connection:
cursor = connection.cursor()
cursor.execute("INSERT INTO Ticket (customerID, assistantID, openedAt, closedAt, title) VALUES (?, ?, ?, ?, ?);", [ auth_user["user_id"], -1, int(time.time()), -1, ticket_data["ticket_title"] ])
ticket_id = cursor.lastrowid
cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, auth_user["user_id"], ticket_data["message"], int(time.time()) ])
message_id = cursor.lastrowid
return ({
"ticket_id": ticket_id,
"message_id": message_id
}, 200)
except:
return ({ "error": "Server failed to parse the request." }, 400)
@App.route('/get-ticket/<int:ticket_id>', methods=['GET'])
def ticket_json_by_id_req(ticket_id):
if request.method == 'GET':
auth_user = verify_access_token(request.headers.get("Authorization"))
if auth_user == None:
return ({ "error": "User is not authenticated to make this request." }, 403)
with sqlite3.connect("data.db") as connection:
cursor = connection.cursor()
cursor.execute("SELECT customerID, assistantID, openedAt, closedAt, title FROM Ticket WHERE ticketID = ?;", [ ticket_id ])
ticket_list = cursor.fetchall()
if len(ticket_list) == 0:
return ({ "error": "Ticket with given ID was not found in the database." }, 404)
_customer_id, _assistant_id, _opened_at, _closed_at, _title = ticket_list[0]
if auth_user["user_id"] not in [_customer_id] and auth_user["account_type"] != ACCOUNT_TYPE_ASSISTANT:
return ({ "error": "User is not authenticated to make this request." }, 403)
return ({
"ticket_data": {
"ticket_id": ticket_id,
"customer_id": _customer_id,
"assistant_id": _assistant_id,
"opened_at": _opened_at,
"closed_at": _closed_at,
"title": _title
}
}, 200)
@App.route('/ticket/<int:ticket_id>', methods=['GET', 'POST'])
def ticket_by_id_req(ticket_id):
if request.method == 'GET':
return (render_template('ticket/ticket.html', ticket_id=ticket_id), 200)
elif request.method == 'POST':
try:
auth_user = verify_access_token(request.headers.get("Authorization"))
if auth_user == None:
return ({ "error": "User is not authenticated to make this request." }, 403)
message_data = json.loads(request.get_data())
if list(message_data.keys()) != ["message"]:
return ({ "error": "The request failed." }, 400)
if "" in list(message_data.values()):
return ({ "error": "Please don't leave any blank fields." }, 400)
_valid_message = is_valid_message(message_data["message"])
if not _valid_message[0]:
return ({ "error": _valid_message[1] }, 400)
with sqlite3.connect("data.db") as connection:
cursor = connection.cursor()
cursor.execute("SELECT customerID, assistantID, closedAt FROM Ticket WHERE ticketID = ?;", [ ticket_id ])
ticket_list = cursor.fetchall()
if len(ticket_list) == 0:
return ({ "error": "Ticket with given ID was not found in the database." }, 404)
_customer_id, _assistant_id, _closed_at = ticket_list[0]
if auth_user["user_id"] not in [_customer_id] and auth_user["account_type"] != ACCOUNT_TYPE_ASSISTANT:
return ({ "error": "User is not authenticated to make this request." }, 403)
user_profile = get_profile(auth_user["user_id"])
if message_data["message"] == "!close":
cursor.execute("UPDATE Ticket SET closedAt = ? WHERE (ticketID = ?);", [ int(time.time()), ticket_id ])
cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, SYSTEM_USER_ID, f"This ticket has been closed by {user_profile['username']} (ID: {auth_user['user_id']}).", int(time.time()) ])
elif message_data["message"] == "!open":
cursor.execute("UPDATE Ticket SET closedAt = -1 WHERE (ticketID = ?);", [ ticket_id ])
cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, SYSTEM_USER_ID, f"This ticket has been reopened by {user_profile['username']} (ID: {auth_user['user_id']}).", int(time.time()) ])
elif message_data["message"] == "!claim" and auth_user["account_type"] == ACCOUNT_TYPE_ASSISTANT:
cursor.execute("UPDATE Ticket SET assistantID = ?, closedAt = ? WHERE (ticketID = ?);", [ auth_user["user_id"], -1, ticket_id ])
if _closed_at != -1:
cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, SYSTEM_USER_ID, f"This ticket has been claimed and reopened by {user_profile['username']} (ID: {auth_user['user_id']}).", int(time.time()) ])
else:
cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, SYSTEM_USER_ID, f"This ticket has been claimed by {user_profile['username']} (ID: {auth_user['user_id']}).", int(time.time()) ])
else:
if _closed_at != -1:
cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, SYSTEM_USER_ID, f"This ticket has been reopened by {user_profile['username']} (ID: {auth_user['user_id']}).", int(time.time())-1 ])
cursor.execute("UPDATE Ticket SET closedAt = ? WHERE (ticketID = ?);", [ -1, ticket_id ])
cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, auth_user["user_id"], message_data["message"], int(time.time())+1 ])
message_id = cursor.lastrowid
return ({
"ticket_id": ticket_id,
"message_id": message_id
}, 200)
except:
return ({ "error": "Server failed to parse the request." }, 400)
@App.route('/get-open-tickets/<int:user_id>', methods=['GET'])
def get_open_tickets_by_user_id_req(user_id):
if request.method == 'GET':
auth_user = verify_access_token(request.headers.get("Authorization"))
if auth_user == None or auth_user["user_id"] != user_id:
return ({ "error": "User is not authenticated to make this request." }, 403)
with sqlite3.connect("data.db") as connection:
cursor = connection.cursor()
cursor.execute("SELECT * FROM User WHERE userID = ?;", [ user_id ])
if len(cursor.fetchall()) == 0:
return ({ "error": "User with given ID was not found in the database." }, 404)
cursor.execute("SELECT ticketID, customerID, assistantID, openedAt, closedAt, title FROM Ticket WHERE (customerID = ? OR assistantID = ?) AND closedAt = -1 ORDER BY openedAt ASC;", [ user_id, user_id ])
ticket_list = cursor.fetchall()
response = []
for t in ticket_list:
_ticket_id, _customer_id, _assistant_id, _opened_at, _closed_at, _title = t
response.append({
"ticket_id": _ticket_id,
"customer_id": _customer_id,
"assistant_id": _assistant_id,
"opened_at": _opened_at,
"closed_at": _closed_at,
"title": _title
})
return ({
"tickets": response
}, 200)
@App.route('/get-closed-tickets/<int:user_id>', methods=['GET'])
def get_closed_tickets_by_user_id_req(user_id):
if request.method == 'GET':
auth_user = verify_access_token(request.headers.get("Authorization"))
if auth_user == None or auth_user["user_id"] != user_id:
return ({ "error": "User is not authenticated to make this request." }, 403)
with sqlite3.connect("data.db") as connection:
cursor = connection.cursor()
cursor.execute("SELECT * FROM User WHERE userID = ?;", [ user_id ])
if len(cursor.fetchall()) == 0:
return ({ "error": "User with given ID was not found in the database." }, 404)
cursor.execute("SELECT ticketID, customerID, assistantID, openedAt, closedAt, title FROM Ticket WHERE (customerID = ? OR assistantID = ?) AND closedAt != -1 ORDER BY closedAt DESC;", [ user_id, user_id ])
ticket_list = cursor.fetchall()
response = []
for t in ticket_list:
_ticket_id, _customer_id, _assistant_id, _opened_at, _closed_at, _title = t
response.append({
"ticket_id": _ticket_id,
"customer_id": _customer_id,
"assistant_id": _assistant_id,
"opened_at": _opened_at,
"closed_at": _closed_at,
"title": _title
})
return ({
"tickets": response
}, 200)
@App.route('/get-unclaimed-tickets', methods=['GET'])
def get_unclaimed_tickets_req():
if request.method == 'GET':
auth_user = verify_access_token(request.headers.get("Authorization"))
if auth_user == None or auth_user["account_type"] != ACCOUNT_TYPE_ASSISTANT:
return ({ "error": "User is not authenticated to make this request." }, 403)
with sqlite3.connect("data.db") as connection:
cursor = connection.cursor()
cursor.execute("SELECT ticketID, customerID, assistantID, openedAt, closedAt, title FROM Ticket WHERE assistantID = -1 ORDER BY openedAt ASC;")
ticket_list = cursor.fetchall()
response = []
for t in ticket_list:
_ticket_id, _customer_id, _assistant_id, _opened_at, _closed_at, _title = t
response.append({
"ticket_id": _ticket_id,
"customer_id": _customer_id,
"assistant_id": _assistant_id,
"opened_at": _opened_at,
"closed_at": _closed_at,
"title": _title
})
return ({
"tickets": response
}, 200)
@App.route('/get-messages/<int:ticket_id>', methods=['GET'])
def get_messages_by_ticket_id_req(ticket_id):
if request.method == 'GET':
auth_user = verify_access_token(request.headers.get("Authorization"))
if auth_user == None:
return ({ "error": "User is not authenticated to make this request." }, 403)
with sqlite3.connect("data.db") as connection:
cursor = connection.cursor()
cursor.execute("SELECT customerID, assistantID FROM Ticket WHERE ticketID = ?;", [ ticket_id ])
ticket_list = cursor.fetchall()
if len(ticket_list) == 0:
return ({ "error": "Ticket with given ID was not found in the database." }, 404)
_customer_id, _assistant_id = ticket_list[0]
if auth_user["user_id"] not in [_customer_id] and auth_user["account_type"] != ACCOUNT_TYPE_ASSISTANT:
return ({ "error": "User is not authenticated to make this request." }, 403)
cursor.execute("SELECT messageID, authorID, body, sentAt FROM Message WHERE ticketID = ? ORDER BY sentAt DESC;", [ ticket_id ])
message_list = cursor.fetchall()
response = []
for m in message_list:
_message_id, _author_id, _body, _sent_at = m
response.append({
"message_id": _message_id,
"author_id": _author_id,
"body": _body,
"sent_at": _sent_at
})
return ({
"ticket_id": ticket_id,
"message_list": response
}, 200)
@App.route('/profile', methods=['GET', 'POST'])
def profile_req():
if request.method == 'GET':
return (render_template('profile.html'), 200)
elif request.method == 'POST':
try:
auth_user = verify_access_token(request.headers.get("Authorization"))
if auth_user == None:
return ({ "error": "User is not authenticated to make this request." }, 403)
profile_data = json.loads(request.get_data())
if list(profile_data.keys()) != ["new_password", "old_password", "profile_icon"]:
return ({ "error": "The request failed." }, 400)
if profile_data["new_password"] == "":
profile_data["new_password"] = profile_data["old_password"]
if "" in list(profile_data.values()):
return ({ "error": "Please don't leave any blank fields." }, 400)
with sqlite3.connect("data.db") as connection:
cursor = connection.cursor()
cursor.execute("SELECT password FROM User WHERE userID = ?;", [ auth_user["user_id"] ])
user_list = cursor.fetchall()
if len(user_list) == 0:
return ({ "error": "User with given ID was not found in the database." }, 404)
if user_list[0][0] != profile_data["old_password"]:
return ({ "error": "Incorrect password was provided." }, 403)
_valid_password = is_valid_password(profile_data["new_password"])
if not _valid_password[0]:
return ({ "error": _valid_password[1] }, 400)
_valid_profile_icon = is_valid_profile_icon(profile_data["profile_icon"])
if not _valid_profile_icon[0]:
return ({ "error": _valid_profile_icon[1] }, 400)
cursor.execute("UPDATE User SET password = ?, profileIcon = ? WHERE (userID = ?)", [ profile_data["new_password"], profile_data["profile_icon"], auth_user["user_id"] ])
return ({ "msg": "Profile has been updated." }, 200)
except:
return ({ "error": "Server failed to parse the request." }, 400)
### end of flask endpoints ###
if __name__ == "__main__":
App.run(host="0.0.0.0")

BIN
static/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

7
static/src/constants.js Normal file
View File

@@ -0,0 +1,7 @@
const ACCOUNT_TYPE_CUSTOMER = 1
const ACCOUNT_TYPE_ASSISTANT = 2
const TICKET_STATUS_OPEN = 1
const TICKET_STATUS_CLOSED = 2
const SYSTEM_USER_ID = 0

125
static/src/jwt-decode.js Normal file
View File

@@ -0,0 +1,125 @@
// CODE IS FROM https://github.com/auth0/jwt-decode
(function (factory) {
typeof define === 'function' && define.amd ? define(factory) :
factory();
}((function () { 'use strict';
/**
* The code was extracted from:
* https://github.com/davidchambers/Base64.js
*/
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
function InvalidCharacterError(message) {
this.message = message;
}
InvalidCharacterError.prototype = new Error();
InvalidCharacterError.prototype.name = "InvalidCharacterError";
function polyfill(input) {
var str = String(input).replace(/=+$/, "");
if (str.length % 4 == 1) {
throw new InvalidCharacterError(
"'atob' failed: The string to be decoded is not correctly encoded."
);
}
for (
// initialize result and counters
var bc = 0, bs, buffer, idx = 0, output = "";
// get next character
(buffer = str.charAt(idx++));
// character found in table? initialize bit storage and add its ascii value;
~buffer &&
((bs = bc % 4 ? bs * 64 + buffer : buffer),
// and if not first of each 4 characters,
// convert the first 8 bits to one ascii character
bc++ % 4) ?
(output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6)))) :
0
) {
// try to find character in table (0-63, not found => -1)
buffer = chars.indexOf(buffer);
}
return output;
}
var atob = (typeof window !== "undefined" &&
window.atob &&
window.atob.bind(window)) ||
polyfill;
function b64DecodeUnicode(str) {
return decodeURIComponent(
atob(str).replace(/(.)/g, function(m, p) {
var code = p.charCodeAt(0).toString(16).toUpperCase();
if (code.length < 2) {
code = "0" + code;
}
return "%" + code;
})
);
}
function base64_url_decode(str) {
var output = str.replace(/-/g, "+").replace(/_/g, "/");
switch (output.length % 4) {
case 0:
break;
case 2:
output += "==";
break;
case 3:
output += "=";
break;
default:
throw "Illegal base64url string!";
}
try {
return b64DecodeUnicode(output);
} catch (err) {
return atob(output);
}
}
function InvalidTokenError(message) {
this.message = message;
}
InvalidTokenError.prototype = new Error();
InvalidTokenError.prototype.name = "InvalidTokenError";
function jwtDecode(token, options) {
if (typeof token !== "string") {
throw new InvalidTokenError("Invalid token specified");
}
options = options || {};
var pos = options.header === true ? 0 : 1;
try {
return JSON.parse(base64_url_decode(token.split(".")[pos]));
} catch (e) {
throw new InvalidTokenError("Invalid token specified: " + e.message);
}
}
/*
* Expose the function on the window object
*/
//use amd or just through the window object.
if (window) {
if (typeof window.define == "function" && window.define.amd) {
window.define("jwt_decode", function() {
return jwtDecode;
});
} else if (window) {
window.jwt_decode = jwtDecode;
}
}
})));
//# sourceMappingURL=jwt-decode.js.map

143
static/src/ticketTools.js Normal file
View File

@@ -0,0 +1,143 @@
async function openTicket() {
let ticket_title_input = document.getElementById("ticket_title_input");
let message_input = document.getElementById("message_input");
const res = await (await fetch('/ticket/new', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem("access_token")
},
redirect: 'manual',
body: JSON.stringify({
ticket_title: ticket_title_input.value,
message: message_input.value
})
})).json();
if (res["error"] != undefined) {
alert(res["error"]);
} else {
window.location.href = "/ticket/" + res["ticket_id"];
};
}
async function getTicket(ticket_id) {
const res = await (await fetch('/get-ticket/' + ticket_id, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem("access_token")
}
})).json();
if (res["error"] != undefined) {
alert(res["error"]);
return false;
} else {
return res;
};
}
async function getOpenTicketsByUserID(user_id) {
const res = await (await fetch('/get-open-tickets/' + user_id, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem("access_token")
}
})).json();
if (res["error"] != undefined) {
alert(res["error"]);
return [];
} else {
return res["tickets"];
};
}
async function getClosedTicketsByUserID(user_id) {
const res = await (await fetch('/get-closed-tickets/' + user_id, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem("access_token")
}
})).json();
if (res["error"] != undefined) {
alert(res["error"]);
return [];
} else {
return res["tickets"];
};
}
async function getUnclaimedTickets() {
const res = await (await fetch('/get-unclaimed-tickets', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem("access_token")
}
})).json();
if (res["error"] != undefined) {
alert(res["error"]);
return [];
} else {
return res["tickets"];
};
}
async function sendMessage(message_input=document.getElementById("message_input").value) {
const res = await (await fetch('/ticket/' + ticketID, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem("access_token")
},
body: JSON.stringify({
message: message_input
})
})).json();
if (res["error"] != undefined) {
alert(res["error"]);
return [];
} else {
window.location.reload();
return res;
}
}
async function getMessages(ticket_id) {
const res = await (await fetch('/get-messages/' + ticket_id, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem("access_token")
}
})).json();
if (res["error"] != undefined) {
alert(res["error"]);
return [];
} else {
return res["message_list"];
};
}

130
static/src/userTools.js Normal file
View File

@@ -0,0 +1,130 @@
const getDecodedAccessToken = () => {
return localStorage.getItem("access_token") != null ? jwt_decode(localStorage.getItem("access_token")) : null;
}
function getLoggedInUser() {
let d_at = getDecodedAccessToken();
if (d_at == null) return null;
if (d_at["exp"] < (Date.now() / 1000)) {
localStorage.removeItem("access_token");
return null
};
return d_at;
}
async function register() {
let username_input = document.getElementById("username_input");
let email_input = document.getElementById("email_input");
let password_input = document.getElementById("password_input");
let passwordc_input = document.getElementById("passwordc_input");
if (password_input.value != passwordc_input.value) return alert("Passwords do not match.")
const res = await (await fetch('/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
redirect: 'manual',
body: JSON.stringify({
username: username_input.value,
email: email_input.value,
password: password_input.value
})
})).json();
if (res["error"] != undefined) {
alert(res["error"]);
} else {
localStorage.setItem("access_token", res["access_token"]);
window.location.href = "/home";
};
}
async function login() {
let username_input = document.getElementById("username_input");
let password_input = document.getElementById("password_input");
const res = await (await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
redirect: 'manual',
body: JSON.stringify({
username: username_input.value,
password: password_input.value
})
})).json();
if (res["error"] != undefined) {
alert(res["error"]);
} else {
localStorage.setItem("access_token", res["access_token"]);
window.location.href = "/home";
};
}
async function logout() {
localStorage.removeItem("access_token");
alert("Successfully logged out.");
window.location.href = "/login";
}
async function getProfile(user_id) {
const res = await (await fetch('/get-profile/' + user_id, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem("access_token")
}
})).json();
if (res["error"] != undefined) {
alert(res["error"]);
return false;
} else {
return res["user"];
};
}
async function updateProfile() {
let new_password_input = document.getElementById("new_password_input");
let new_passwordc_input = document.getElementById("new_passwordc_input");
let old_password_input = document.getElementById("old_password_input");
let profile_icon_select = document.getElementById("profile_icon_select");
if (new_password_input.value != new_passwordc_input.value) return alert("Passwords do not match.");
if (old_password_input.value == "") return alert("Old password is needed to confirm changes.");
const res = await (await fetch('/profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem("access_token")
},
redirect: 'manual',
body: JSON.stringify({
new_password: new_password_input.value,
old_password: old_password_input.value,
profile_icon: profile_icon_select.value
})
})).json();
if (res["error"] != undefined) {
alert(res["error"]);
} else {
alert("Profile updated.");
window.location.reload();
};
}

89
static/style.css Normal file
View File

@@ -0,0 +1,89 @@
body {
background-color: rgb(203, 255, 238);
font-family: 'Barlow', 'Gill Sans Nova', 'Calibri';
padding: 20px;
text-align: center;
}
p {
margin: 20px 0 0 0;
}
h1 {
text-align: center;
font-size: min(8vw, 45px);
margin: 20px 0 0 0;
}
h2 {
font-size: 30px;
margin: 20px 0 0 0;
}
h3 {
font-size: 25px;
margin: 20px 0 0 0;
}
p, label, input, button, a, li {
font-size: 20px;
}
p#loggedInMessage {
position: absolute;
right: 20px;
top: 10px;
}
p#ticket_id {
margin: 0 0 0 0;
}
ul {
text-align: left;
}
input {
width: min(225px, 80%);
}
textarea {
width: min(225px, 80%);
}
button {
padding: 10px 30px;
margin: 5px 0 0 0;
}
button#close_ticket_btn, button#open_ticket_btn, button#claim_ticket_btn {
display: none;
}
section#claimable_tickets {
display: none;
}
table#ticket_messages tr img {
padding-bottom: 20px;
}
table#ticket_messages tr td {
text-align: left;
padding: 0 0 0 20px;
vertical-align: top;
}
table#ticket_messages tr td * {
margin: 0;
}
table#ticket_messages p.message_sent_at {
font-size: 15px
}
@media only screen and (max-width: 600px) {
input {
width: min(200px, 80%);
}
}

46
templates/base.html Normal file
View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SupportMe</title>
<link rel="stylesheet" href="/style.css">
<link rel="icon" href="/icon.png">
</head>
<body>
<p id="loggedInMessage">...</p>
<br>
<a href="/home" style="text-decoration: none; color: black;">
<h1>💬 SupportMe 💁</h1>
</a>
<script src="/src/constants.js"></script>
<script src="/src/jwt-decode.js"></script>
<script src="/src/userTools.js"></script>
<script src="/src/ticketTools.js"></script>
<script>
const currentUser = getLoggedInUser();
if (currentUser != null) {
getProfile(currentUser["user_id"]).then(profile => {
document.getElementById("loggedInMessage").innerText = "Logged in: " + profile["username"];
});
} else {
document.getElementById("loggedInMessage").innerText = "You are not logged in.";
};
</script>
{% block content %} {% endblock %}
</body>
</html>

91
templates/home.html Normal file
View File

@@ -0,0 +1,91 @@
{% extends 'base.html' %}
{% block content %}
<h2>Home</h2>
<br>
<a href="/ticket/new"><button id="open_ticket_button">Open a support ticket</button></a>
<br>
<a href="/profile"><button>Edit profile</button></a>
<button onclick=logout()>Log out</button>
<section>
<h3>Open tickets</h3>
<ul id="open_tickets_list"></ul>
</section>
<section id="claimable_tickets">
<h3>Claimable tickets</h3>
<ul id="unclaimed_tickets_list"></ul>
</section>
<section>
<h3>Closed tickets</h3>
<ul id="closed_tickets_list"></ul>
</section>
<script>
if (currentUser == null) window.location.href = "/login";
const openTicketsList = document.getElementById("open_tickets_list")
const closedTicketsList = document.getElementById("closed_tickets_list")
const unclaimedTicketsList = document.getElementById("unclaimed_tickets_list")
getOpenTicketsByUserID(currentUser["user_id"]).then(openTickets => {
if (openTickets.length == 0) openTicketsList.innerHTML = "<i>No tickets to show.</i>"
for (t of openTickets) {
let _a = document.createElement("a");
_a.innerText = "#" + t["ticket_id"] + " - " + t["title"];
_a.href = "/ticket/" + t["ticket_id"];
let _li = document.createElement("li");
_li.appendChild(_a);
openTicketsList.appendChild(_li);
};
});
getClosedTicketsByUserID(currentUser["user_id"]).then(closedTickets => {
if (closedTickets.length == 0) closedTicketsList.innerHTML = "<i>No tickets to show.</i>"
for (t of closedTickets) {
let _a = document.createElement("a");
_a.innerText = "#" + t["ticket_id"] + " - " + t["title"];
_a.href = "/ticket/" + t["ticket_id"];
let _li = document.createElement("li");
_li.appendChild(_a);
closedTicketsList.appendChild(_li);
};
});
if (currentUser["account_type"] == ACCOUNT_TYPE_CUSTOMER) {
document.getElementById("claimable_tickets").style.display = "none";
} else if (currentUser["account_type"] == ACCOUNT_TYPE_ASSISTANT) {
document.getElementById("claimable_tickets").style.display = "block";
document.getElementById("open_ticket_button").style.display = "none";
getUnclaimedTickets().then(unclaimedTickets => {
if (unclaimedTickets.length == 0) unclaimedTicketsList.innerHTML = "<i>No tickets to show.</i>"
for (t of unclaimedTickets) {
_a = document.createElement("a");
_a.innerText = "#" + t["ticket_id"] + " - " + t["title"];
_a.href = "/ticket/" + t["ticket_id"];
_li = document.createElement("li");
_li.appendChild(_a);
unclaimedTicketsList.appendChild(_li);
};
});
}
</script>
{% endblock %}

27
templates/login.html Normal file
View File

@@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block content %}
<h2>Login</h2>
<br>
<label for="username">Username</label><br>
<input type="text" id="username_input" name="username" />
<br><br>
<label for="password">Password</label><br>
<input type="password" id="password_input" name="password" />
<br><br>
<button type="submit" onclick="login()">Login</button>
<br><br>
<a href="register">Don't have an account? Click here to sign up!</a>
<script>
if (currentUser != null) window.location.href = "/home";
</script>
{% endblock %}

68
templates/profile.html Normal file
View File

@@ -0,0 +1,68 @@
{% extends 'base.html' %}
{% block content %}
<h2>Profile</h2>
<br>
<img id="profile_icon">
<br><br>
<label for="user_id">Your user ID</label><br>
<input type="text" id="user_id_input" name="user_id" disabled />
<br><br>
<label for="username">Your username (cannot be changed)</label><br>
<input type="text" id="username_input" name="username" disabled />
<br><br>
<label for="new_password">New password</label><br>
<input type="password" id="new_password_input" name="new_password" />
<br><br>
<label for="new_passwordc">Confirm new password</label><br>
<input type="password" id="new_passwordc_input" name="new_passwordc" />
<br><br>
<label for="old_password">Old password</label><br>
<input type="password" id="old_password_input" name="old_password" />
<br><br>
<label for="profile_icon">Profile picture</label><br>
<select name="profile_icon" id="profile_icon_select" oninput="updateProfileIconPreview()">
<option value="/profile-icons/blue.png">Blue</option>
<option value="/profile-icons/green.png">Green</option>
<option value="/profile-icons/purple.png">Purple</option>
<option value="/profile-icons/red.png">Red</option>
</select>
<br><br>
<button type="submit" onclick="updateProfile()">Save changes</button>
<br><br>
<a href="register">Don't have an account? Click here to sign up!</a>
<script>
if (currentUser == null) window.location.href = "/login";
getProfile(currentUser["user_id"]).then(profile => {
document.getElementById("profile_icon").src = profile["profile_icon"];
document.getElementById("profile_icon").width = 150;
document.getElementById("user_id_input").value = profile["user_id"];
document.getElementById("username_input").value = profile["username"];
document.getElementById("profile_icon_select").value = profile["profile_icon"];
});
function updateProfileIconPreview() {
document.getElementById("profile_icon").src = document.getElementById("profile_icon_select").value;
};
</script>
{% endblock %}

37
templates/register.html Normal file
View File

@@ -0,0 +1,37 @@
{% extends 'base.html' %}
{% block content %}
<h2>Register</h2>
<br>
<label for="username">Username</label><br>
<input type="text" id="username_input" name="username" />
<br><br>
<label for="email">Email</label><br>
<input type="email" id="email_input" name="email" />
<br><br>
<label for="password">Password</label><br>
<input type="password" id="password_input" name="password" />
<br><br>
<label for="passwordc">Confirm Password</label><br>
<input type="password" id="passwordc_input" name="passwordc" />
<br><br>
<button type="submit" onclick="register()">Register</button>
<br><br>
<a href="login">Already have an account? Click here to log in!</a>
<script>
if (currentUser != null) window.location.href = "/home";
</script>
{% endblock %}

25
templates/ticket/new.html Normal file
View File

@@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% block content %}
<h2>Open a new support ticket</h2>
<br>
<label for="ticket_title">Ticket Title</label><br>
<input type="text" id="ticket_title_input" name="ticket_title" />
<br><br>
<label for="message">Ticket Description</label><br>
<textarea type="text" id="message_input" name="message" rows="10"></textarea>
<br><br>
<button type="submit" onclick="openTicket()">Open ticket</button>
<br><br>
<script>
if (currentUser == null) window.location.href = "/login";
</script>
{% endblock %}

View File

@@ -0,0 +1,102 @@
{% extends 'base.html' %}
{% block content %}
<h2 id="ticket_title">...</h2>
<p id="ticket_id">Ticket #{{ ticket_id }}</p>
<p>
<span id="opened_at"></span><br>
<span id="closed_at"></span>
</p>
<p>
<span id="opened_by">...</span><br>
<span id="claimed_by">This ticket has not been claimed</span>
</p>
<br>
<label for="message">Send a message:</label><br>
<textarea type="text" id="message_input" name="message" rows="10"></textarea>
<br><br>
<button id="close_ticket_btn" type="submit" onclick="sendMessage('!close')">Close ticket</button>
<button id="open_ticket_btn" type="submit" onclick="sendMessage('!open')">Reopen ticket</button>
<button id="send_message_btn" type="submit" onclick="sendMessage()">Send</button>
<button id="claim_ticket_btn" type="submit" onclick="sendMessage('!claim')">Claim ticket</button>
<br><br>
<table id="ticket_messages"></table>
<script>
if (currentUser == null) window.location.href = "/login";
const profiles = {};
const ticketID = parseInt("{{ ticket_id }}");
getTicket(ticketID).then(ticket => {
if (ticket != false) {
ticket = ticket["ticket_data"];
document.getElementById("ticket_title").innerText = "[" + (ticket["closed_at"] == -1 ? "OPEN" : "CLOSED") + "] " + ticket["title"];
document.getElementById("opened_at").innerText = "Opened: " + new Date(ticket["opened_at"] * 1000).toLocaleString();
if (ticket["closed_at"] != -1) {
document.getElementById("closed_at").innerText = "Closed: " + new Date(ticket["closed_at"] * 1000).toLocaleString();
document.getElementById("open_ticket_btn").style.display = "inline";
} else {
document.getElementById("close_ticket_btn").style.display = "inline";
};
getProfile(ticket["customer_id"]).then(customer => {
document.getElementById("opened_by").innerHTML = "Opened by <b>" + customer["username"] + "</b> (ID: " + ticket["customer_id"] + ")";
});
if (currentUser["account_type"] == ACCOUNT_TYPE_ASSISTANT && ticket["assistant_id"] != currentUser["user_id"]) {
document.getElementById("claim_ticket_btn").style.display = "inline";
};
if (ticket["assistant_id"] != -1) {
getProfile(ticket["assistant_id"]).then(assistant => {
document.getElementById("claimed_by").innerHTML = "Claimed by <b>" + assistant["username"] + "</b> (ID: " + ticket["assistant_id"] + ")";
});
};
};
});
const ticketMessagesTable = document.getElementById("ticket_messages");
getMessages(ticketID).then(async messages => {
for (a_id of messages.map(m => m["author_id"])) { profiles[a_id] = await getProfile(a_id); }
for (msg of messages) {
let _tr = document.createElement("tr");
let _td1 = document.createElement("td");
let _img = document.createElement("img");
_img.src = profiles[msg["author_id"]]["profile_icon"];
_img.width = 150;
let _td2 = document.createElement("td");
let _h3_sender = document.createElement("h3");
_h3_sender.classList.add("message_sender");
_h3_sender.innerText = profiles[msg["author_id"]]["username"];
let _p_body = document.createElement("p");
_p_body.classList.add("message_body");
_p_body.innerText = msg["body"];
let _br1 = document.createElement("br");
let _p_sent_at = document.createElement("p");
_p_sent_at.classList.add("message_sent_at");
_p_sent_at.innerText = new Date(msg["sent_at"] * 1000).toLocaleString();
let _br2 = document.createElement("br");
_td1.appendChild(_img);
_td2.appendChild(_h3_sender);
_td2.appendChild(_p_body);
_td2.appendChild(_br1);
_td2.appendChild(_p_sent_at);
_td2.appendChild(_br2);
_tr.appendChild(_td1);
_tr.appendChild(_td2);
ticketMessagesTable.appendChild(_tr);
};
});
</script>
{% endblock %}

View File

@@ -0,0 +1,17 @@
import sqlite3
user_id = int(input("Enter the user ID of the account: "))
account_type = int(input("Enter an account type to set (1 = customer, 2 = assistant): "))
with sqlite3.connect("../data.db") as connection:
try:
cursor = connection.cursor()
cursor.execute("UPDATE User SET accountType = ? WHERE userID = ?;", [ account_type, user_id ])
if cursor.rowcount == 0:
print("User with given ID was not found in the database. Terminating.")
else:
print(f"User with ID {user_id}'s account type has been successfully updated to {account_type}.")
except sqlite3.Error as error:
print(error)