diff --git a/Highscore-Server/Dockerfile b/Highscore-Server/Dockerfile index a19c11e..2086ffb 100644 --- a/Highscore-Server/Dockerfile +++ b/Highscore-Server/Dockerfile @@ -1,15 +1,16 @@ # syntax=docker/dockerfile:1 -FROM python:3.10-alpine +FROM ubuntu:22.04 EXPOSE 8080 -RUN apk add --no-cache postgresql-client build-base +RUN apt update && apt install -y python3 python3-pip postgresql-client WORKDIR /data COPY requirements.txt requirements.txt RUN pip install -r requirements.txt RUN mkdir /storage -COPY ./server . +COPY ./server ./server +COPY ./run.sh ./run.sh RUN chmod +x ./run.sh CMD ["./run.sh"] diff --git a/Highscore-Server/requirements.txt b/Highscore-Server/requirements.txt index 010569f..6a74c6a 100644 --- a/Highscore-Server/requirements.txt +++ b/Highscore-Server/requirements.txt @@ -4,8 +4,8 @@ Flask-SQLAlchemy psycopg2-binary Flask-Migrate Flask-Caching -Flask-wtf Flask-Assets +Flask-Login libsass jsmin cssmin diff --git a/Highscore-Server/server/run.sh b/Highscore-Server/run.sh similarity index 88% rename from Highscore-Server/server/run.sh rename to Highscore-Server/run.sh index 0fd728f..0eb873a 100644 --- a/Highscore-Server/server/run.sh +++ b/Highscore-Server/run.sh @@ -17,7 +17,7 @@ then fi # Check if there are any changes to the database -if $(flask --app server db check); +if ! $(flask --app server db check | grep -q "No changes in schema detected."); then echo "Database changes detected! Migrating..." flask --app server db migrate diff --git a/Highscore-Server/server/__init__.py b/Highscore-Server/server/__init__.py new file mode 100644 index 0000000..78b7c34 --- /dev/null +++ b/Highscore-Server/server/__init__.py @@ -0,0 +1,50 @@ +from random import randint +from flask import Flask, render_template, abort +from flask_assets import Bundle +from werkzeug.exceptions import HTTPException +from server.extensions import db, migrate, cache, assets, login_manager +from server.models import Users +from server import views, auth, api + +app = Flask(__name__) +app.config.from_pyfile('config.py') + +db.init_app(app) +migrate.init_app(app, db) + +with app.app_context(): + db.create_all() + +login_manager.init_app(app) +login_manager.login_view = "auth.auth" + +assets.init_app(app) + +scripts = Bundle("js/*.js", filters="jsmin", output="gen/scripts.js", depends="js/*.js") +assets.register("scripts", scripts) + +styles = Bundle("sass/style.sass", filters="libsass, cssmin", output="gen/styles.css", depends="sass/*.sass") +assets.register("styles", styles) + +cache.init_app(app) +app.register_blueprint(views.blueprint) +app.register_blueprint(auth.blueprint) +app.register_blueprint(api.blueprint) + + +@login_manager.user_loader +def load_user(user_id): + return Users.query.filter_by(alt_id=user_id).first() + + +@app.errorhandler(Exception) +def error_page(err): + if not isinstance(err, HTTPException): + abort(500) + return ( + render_template("error.html", + error=err.code, + msg=err.description, + image=str(randint(1, 3))), + err.code, + ) diff --git a/Highscore-Server/server/api.py b/Highscore-Server/server/api.py new file mode 100644 index 0000000..3213722 --- /dev/null +++ b/Highscore-Server/server/api.py @@ -0,0 +1,96 @@ +import uuid + +from flask import Blueprint, request, jsonify +from flask_login import login_required, current_user + +from server.models import Tokens, Scores +from server.extensions import db +from server.config import BEARER_TOKEN + + +blueprint = Blueprint('api', __name__, url_prefix='/api') + + +@blueprint.route('/tokens', methods=['DELETE', 'POST']) +@login_required +def tokens(): + if request.method == 'DELETE': + token_id = request.form['token_id'] + if not token_id: + return jsonify({"error": "No token ID provided!"}), 400 + + token = Tokens.query.filter_by(id=token_id).first() + if not token: + return jsonify({"error": "Token not found!"}), 404 + if token.holder != current_user.id: + return jsonify({"error": "You do not own this token!"}), 403 + + db.session.delete(token) + db.session.commit() + + return jsonify({"success": "Token deleted!"}), 200 + elif request.method == 'POST': + if len(Tokens.query.filter_by(holder=current_user.id).all()) >= 5: + return jsonify({"error": "You already have 5 tokens!"}), 403 + + token = Tokens(token=str(uuid.uuid4()), holder=current_user.id) + db.session.add(token) + db.session.commit() + + return jsonify({"success": "Token added!"}), 200 + + +@blueprint.route('/post', methods=['POST']) +def post(): + form = request.form + + if not form: + return "Invalid form", 400 + if not request.headers.get('Authentication'): + return "Invalid authentication", 401 + + if not isinstance(form['score'], int): + return "Score must be an integer", 400 + if int(form['score']) < 0: + return "Score must be greater than 0", 400 + if form['difficulty'] not in [0, 1, 2, 3, 4]: + # 0 = Easy, Level 1 + # 1 = Easy, Level 2 + # 2 = Easy, Level 3 + # 3 = Normal + # 4 = Hard + return "Invalid difficulty", 400 + + if token_data := Tokens.query.filter_by(token=request.headers.get('Authentication')).first(): + # User is authenticated + # This is a registered user + + score = Scores( + score=form['score'], + difficulty=form['difficulty'], + achievements=form['achievements'], + user_id=token_data.holder, + ) + db.session.add(score) + db.session.commit() + + return "Success!", 200 + elif request.headers.get('Authentication') == BEARER_TOKEN: + # User is not authenticated, but has the correct token + # This is an anonymous user + + if not form['playerName'] or len(form['playerId']) != 4: + return "Invalid player name", 400 + + score = Scores( + anonymous=True, + username=form['playerName'], + score=form['score'], + difficulty=form['difficulty'], + ) + db.session.add(score) + db.session.commit() + + return "Success!", 200 + + return "Authentication failed", 401 diff --git a/Highscore-Server/server/auth.py b/Highscore-Server/server/auth.py new file mode 100644 index 0000000..a187db2 --- /dev/null +++ b/Highscore-Server/server/auth.py @@ -0,0 +1,101 @@ +import re +import uuid + +from flask import Blueprint, render_template, request, flash, redirect, url_for +from flask_login import login_required, login_user, logout_user, current_user +from werkzeug.security import generate_password_hash, check_password_hash + +from server.extensions import db +from server.models import Users, Tokens + + +blueprint = Blueprint('auth', __name__) + + +@blueprint.route('/auth', methods=['GET']) +def auth(): + return render_template('auth.html') + + +@blueprint.route('/account', methods=['GET']) +@login_required +def account(): + action = request.args.get('action', None) + + if action == "logout": + logout_user() + flash("Successfully logged out!", "success") + return redirect(url_for("views.index")) + if action == "delete": + flash("Insert delete function", "error") + if action == "password": + flash("Insert password change function", "error") + + token_list = Tokens.query.filter_by(holder=current_user.id).all() + return render_template('account.html', token_list=token_list) + + +@blueprint.route('/register', methods=['POST']) +def register(): + # Get the form data + username = request.form["username"].strip() + password = request.form["password"].strip() + username_regex = re.compile(r"\b[A-Za-z0-9._-]+\b") + + error = [] + + # Validate the form + if not username or not username_regex.match(username): + error.append("Username is empty or invalid! Must be alphanumeric, and can contain ._-") + if not password: + error.append("Password is empty!") + elif len(password) < 8: + error.append("Password is too short! Must be at least 8 characters long.") + if Users.query.filter_by(username=username).first(): + error.append("Username already exists!") + + # If there are errors, return them + if error: + for err in error: + flash(err, "error") + return redirect(url_for("auth.auth")) + + register_user = Users( + alt_id=str(uuid.uuid4()), + username=username, + password=generate_password_hash(password, method="scrypt") + ) + db.session.add(register_user) + db.session.commit() + + flash("Successfully registered!", "success") + return redirect(url_for("auth.auth")) + + +@blueprint.route('/login', methods=['POST']) +def login(): + # Get the form data + username = request.form["username"].strip() + password = request.form["password"].strip() + username_regex = re.compile(r"\b[A-Za-z0-9._-]+\b") + + error = [] + + # Validate the form + if not username or not username_regex.match(username) or not password: + error.append("Username or Password is incorrect!") + + user = Users.query.filter_by(username=username).first() + + if not user or not check_password_hash(user.password, password): + error.append("Username or Password is incorrect!") + + # If there are errors, return them + if error: + for err in error: + flash(err, "error") + return redirect(url_for("auth.account")) + + login_user(user, remember=True) + flash("Successfully logged in!", "success") + return redirect(url_for("views.index")) diff --git a/Highscore-Server/server/config.py b/Highscore-Server/server/config.py index 49dea54..0bc4249 100644 --- a/Highscore-Server/server/config.py +++ b/Highscore-Server/server/config.py @@ -1,20 +1,20 @@ -import os - -# Purely to make the code a bit more readable -def env(key): - return os.getenv(key) - -SECRET_KEY = env('FLASK_KEY') -BEARER_TOKEN = env('BEARER_TOKEN') - -user = env('DB_USER') -password = env('DB_PASSWORD') -host = env('DB_HOST') -database = env('DB_NAME') - -SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{user}:{password}@{host}:5432/{database}" -SQLALCHEMY_TRACK_MODIFICATIONS = False -SQLALCHEMY_POOL_RECYCLE = 621 - -MIGRATION_DIR = '/data/storage/migrations' +import os + +# Purely to make the code a bit more readable +def env(key): + return os.getenv(key) + +SECRET_KEY = env('FLASK_KEY') +BEARER_TOKEN = env('BEARER_TOKEN') + +user = env('DB_USER') +password = env('DB_PASSWORD') +host = env('DB_HOST') +database = env('DB_NAME') + +SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{user}:{password}@{host}:5432/{database}" +SQLALCHEMY_TRACK_MODIFICATIONS = False +SQLALCHEMY_POOL_RECYCLE = 621 + +MIGRATION_DIR = '/data/storage/migrations' INSTANCE_DIR = '/data/storage/instance' \ No newline at end of file diff --git a/Highscore-Server/server/extensions.py b/Highscore-Server/server/extensions.py index d361aa8..b3014c9 100644 --- a/Highscore-Server/server/extensions.py +++ b/Highscore-Server/server/extensions.py @@ -1,9 +1,11 @@ -from flask_sqlalchemy import SQLAlchemy -from flask_migrate import Migrate -from flask_assets import Environment -from flask_caching import Cache - -db = SQLAlchemy() -migrate = Migrate() -assets = Environment() -cache = Cache(config={'CACHE_TYPE': 'simple'}) +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_assets import Environment +from flask_caching import Cache +from flask_login import LoginManager + +db = SQLAlchemy() +migrate = Migrate() +assets = Environment() +cache = Cache(config={'CACHE_TYPE': 'simple'}) +login_manager = LoginManager() diff --git a/Highscore-Server/server/models.py b/Highscore-Server/server/models.py index e2d35d4..720da0b 100644 --- a/Highscore-Server/server/models.py +++ b/Highscore-Server/server/models.py @@ -1,43 +1,70 @@ -""" -Database models for the server -""" -from extensions import db - - -class Scores(db.Model): - """ - Post table - """ - __tablename__ = "scores" - - id = db.Column(db.Integer, primary_key=True) - - score = db.Column(db.Integer, nullable=False) - difficulty = db.Column(db.String, nullable=False) - achievements = db.Column(db.String, nullable=False) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - - scored_at = db.Column( - db.DateTime, - nullable=False, - server_default=db.func.now(), - ) - - -class Users(db.Model): - """ - User table - """ - __tablename__ = "users" - - id = db.Column(db.Integer, primary_key=True) - steam_uuid = db.Column(db.String, unique=True, nullable=False) - steam_name = db.Column(db.String, nullable=False) - - scores = db.relationship('Scores', backref='user', lazy=True) - - creation_data = db.Column( - db.DateTime, - nullable=False, - server_default=db.func.now(), - ) +""" +Database models for the server +""" +import uuid +from flask_login import UserMixin +from server.extensions import db + + +class Scores(db.Model): + """ + Post table + Scores supports anonymous posting, and instead just wants to post a score, + then the username must be provided.Otherwise, it's grabbed from the user table + """ + __tablename__ = "scores" + + id = db.Column(db.Integer, primary_key=True) + + anonymous = db.Column(db.Boolean, nullable=False, default=False) + username = db.Column(db.String(32), nullable=True) + + score = db.Column(db.Float, nullable=False) + difficulty = db.Column(db.Integer, nullable=False) + scored_at = db.Column( + db.DateTime, + nullable=False, + server_default=db.func.now(), + ) + + scorer = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + + +class Users(db.Model, UserMixin): + """ + User table + """ + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + alt_id = db.Column(db.String, nullable=False, unique=True) + + username = db.Column(db.String(32), unique=True, nullable=False) + password = db.Column(db.String, nullable=False) + joined_at = db.Column( + db.DateTime, + nullable=False, + server_default=db.func.now(), + ) + + scores = db.relationship('Scores', backref='user', lazy=True) + tokens = db.relationship('Tokens', backref='user', lazy=True) + + def get_id(self): + return str(self.alt_id) + + +class Tokens(db.Model): + """ + Token table + """ + __tablename__ = "tokens" + + id = db.Column(db.Integer, primary_key=True) + holder = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + token = db.Column(db.String, nullable=False, unique=True) + created_at = db.Column( + db.DateTime, + nullable=False, + server_default=db.func.now(), + ) diff --git a/Highscore-Server/server/server.py b/Highscore-Server/server/server.py deleted file mode 100644 index a22fdcf..0000000 --- a/Highscore-Server/server/server.py +++ /dev/null @@ -1,21 +0,0 @@ -from flask import Flask -from flask_assets import Bundle -from extensions import db, migrate, cache, assets -from config import MIGRATION_DIR, INSTANCE_DIR -from views import blueprint - -app = Flask(__name__, instance_path=INSTANCE_DIR) -app.config.from_pyfile('config.py') - -db.init_app(app) -migrate.init_app(app, db, directory=MIGRATION_DIR) - -with app.app_context(): - db.create_all() - -assets.init_app(app) -styles = Bundle("style.sass", filters="libsass, cssmin", output="gen/styles.css", depends="style.sass") -assets.register("styles", styles) - -cache.init_app(app) -app.register_blueprint(blueprint) diff --git a/Highscore-Server/server/static/error-images/1.jpg b/Highscore-Server/server/static/error-images/1.jpg new file mode 100644 index 0000000..86117b8 Binary files /dev/null and b/Highscore-Server/server/static/error-images/1.jpg differ diff --git a/Highscore-Server/server/static/error-images/2.jpg b/Highscore-Server/server/static/error-images/2.jpg new file mode 100644 index 0000000..f5f7e0a Binary files /dev/null and b/Highscore-Server/server/static/error-images/2.jpg differ diff --git a/Highscore-Server/server/static/error-images/3.jpg b/Highscore-Server/server/static/error-images/3.jpg new file mode 100644 index 0000000..32a3680 Binary files /dev/null and b/Highscore-Server/server/static/error-images/3.jpg differ diff --git a/Highscore-Server/server/static/gen/scripts.js b/Highscore-Server/server/static/gen/scripts.js new file mode 100644 index 0000000..5bb0bf1 --- /dev/null +++ b/Highscore-Server/server/static/gen/scripts.js @@ -0,0 +1,6 @@ +function addFlashMessage(message,type='success'){let flask=document.createElement('p');flask.onclick=()=>flask.remove();flask.classList.add(type);flask.innerHTML=message;let close=document.createElement('span');close.innerHTML='';flask.appendChild(close);document.querySelector('.flash').appendChild(flask);} +function ajax(url,form,callback,method='POST'){console.log(form) +fetch(url,{method:method,body:form,}).then(response=>response.json()).then(data=>callback(data)).catch(error=>addFlashMessage(error.error,'error'));} +function deleteToken(id){let form=new FormData();form.append('token_id',id);ajax('/api/tokens',form,(data)=>{if(data.success){addFlashMessage(data.success,'success');document.querySelector(`#token-${id}`).remove();}else{addFlashMessage(data.error,'error');}},'DELETE');} +function addToken(){ajax('/api/tokens',null,(data)=>{if(data.success){window.location.reload();}else{addFlashMessage(data.error,'error');}});} +function viewToken(id){let token=document.querySelector(`#token-${id}`);let hidden=token.children[2];hidden.classList.toggle('hidden');} \ No newline at end of file diff --git a/Highscore-Server/server/static/gen/styles.css b/Highscore-Server/server/static/gen/styles.css new file mode 100644 index 0000000..23786f5 --- /dev/null +++ b/Highscore-Server/server/static/gen/styles.css @@ -0,0 +1 @@ +@import url("https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700&display=swap");:root{--black:21,21,21;--white:232,227,227;--primary:210,206,97;--secondary:185,77,77;--gold:255,222,70;--silver:229,220,206;--bronze:193,145,69;--darkBlue:9,9,39}*{box-sizing:border-box;font-family:'Merriweather',serif}html{margin:0;padding:0}body{margin:0;padding:0;display:flex;flex-direction:row;background-color:RGB(var(--darkBlue));color:RGB(var(--white))}.background{width:100%;height:100%;object-fit:cover;position:absolute;z-index:1}.app{margin:0 auto;padding:0;width:800px;min-height:100vh;position:relative;display:flex;flex-direction:column;background-color:rgba(var(--darkBlue),0.7);backdrop-filter:blur(5px);z-index:2}.app>table{width:100%}header{padding:1rem;background-color:RGBA(var(--darkBlue),0.7)}header>img{margin-bottom:1rem;width:100%;height:auto;text-align:center}nav{margin-top:.3rem;padding:0;display:flex;flex-direction:row;justify-content:center}nav>span{width:100%}nav>a{margin:auto .15rem;padding:.5rem .7rem;text-decoration:none;white-space:nowrap;font-size:.9em;color:RGB(var(--primary))}nav>a.button{text-decoration:none;background-color:RGBA(var(--white),0.02);color:RGB(var(--white));border-radius:2px;border:0 solid transparent;transition:background-color .2s ease-in-out,transform .1s ease-in-out}nav>a.button:hover{background-color:RGBA(var(--white),0.3);transform:translateY(-0.1rem)}nav>a.button.primary{text-decoration:none;background-color:RGBA(var(--primary),0.02);color:RGB(var(--primary));border-radius:2px;border:0 solid transparent;transition:background-color .2s ease-in-out,transform .1s ease-in-out}nav>a.button.primary:hover{background-color:RGBA(var(--primary),0.3);transform:translateY(-0.1rem)}nav>a.button.secondary{text-decoration:none;background-color:RGBA(var(--secondary),0.02);color:RGB(var(--secondary));border-radius:2px;border:0 solid transparent;transition:background-color .2s ease-in-out,transform .1s ease-in-out}nav>a.button.secondary:hover{background-color:RGBA(var(--secondary),0.3);transform:translateY(-0.1rem)}nav>a>i{font-size:1.25em;display:block}.flash{display:flex;flex-direction:column;justify-content:center;align-items:center}.flash>p{margin:0;padding:.75rem 1rem;width:100%;position:relative;background-color:RGB(var(--darkBlue));color:RGB(var(--primary));transition:background-color .2s ease-in-out,padding .2s ease-in-out}.flash>p>span{position:absolute;top:0;left:0;bottom:0;width:.25rem;display:flex;justify-content:center;align-items:center;background-color:RGB(var(--primary));color:transparent;overflow:hidden;transition:width .2s ease-in-out,color .2s ease-in-out,background-color .2s ease-in-out}.flash>p>span>i{font-size:1.25em}.flash>p:hover{padding:.75rem 1rem .75rem 4rem;background-color:RGBA(var(--primary),0.1);cursor:pointer}.flash>p:hover>span{width:3rem;color:RGB(var(--black))}.flash>p.success{color:RGB(var(--primary))}.flash>p.success>span{background-color:RGB(var(--primary))}.flash>p.success:hover{background-color:RGBA(var(--primary),0.1)}.flash>p.error{color:RGB(var(--secondary))}.flash>p.error>span{background-color:RGB(var(--secondary))}.flash>p.error:hover{background-color:RGBA(var(--secondary),0.1)}main{padding:1rem;height:100%;display:flex;flex-direction:column}main>h2{margin:0 0 1rem 0;font-size:1.5em;color:RGB(var(--white))}main .center-text{height:100%;display:flex;flex-direction:column;justify-content:center;align-items:center}main .center-text>h2{margin:0;text-align:center;font-size:2em;color:RGB(var(--white))}main .center-text>p{margin:0;text-align:center;font-size:1em;color:RGB(var(--white))}main .center-text>img{margin:1rem auto 0;max-width:100%;max-height:15rem;border-radius:2px}main .block{margin-bottom:1rem;padding:1rem;display:flex;flex-direction:column;background-color:rgba(var(--darkBlue),0.7);border-radius:2px}main .block>h2{margin:0 0 .2rem 0;font-size:1.3em;color:RGB(var(--white))}main .block>p{margin:0 0 1rem 0;font-size:1em}main .block .button{margin:0;padding:.5rem .7rem;text-decoration:none;white-space:nowrap;font-size:.9em;color:RGB(var(--primary));text-decoration:none;background-color:RGBA(var(--white),0.02);color:RGB(var(--white));border-radius:2px;border:0 solid transparent;transition:background-color .2s ease-in-out,transform .1s ease-in-out}main .block .button>i{font-size:1.25em;display:block}main .block .button:hover{background-color:RGBA(var(--white),0.3);transform:translateY(-0.1rem)}main .block .button.primary{text-decoration:none;background-color:RGBA(var(--primary),0.02);color:RGB(var(--primary));border-radius:2px;border:0 solid transparent;transition:background-color .2s ease-in-out,transform .1s ease-in-out}main .block .button.primary:hover{background-color:RGBA(var(--primary),0.3);transform:translateY(-0.1rem)}main .block .button.secondary{text-decoration:none;background-color:RGBA(var(--secondary),0.02);color:RGB(var(--secondary));border-radius:2px;border:0 solid transparent;transition:background-color .2s ease-in-out,transform .1s ease-in-out}main .block .button.secondary:hover{background-color:RGBA(var(--secondary),0.3);transform:translateY(-0.1rem)}main .block>table{width:100%;border-collapse:collapse}main .block>table>tbody>tr>td{padding:0 .5rem .5rem 0;text-align:left;font-size:.9em;color:RGB(var(--white));transition:filter .2s ease-in-out}main .block>table>tbody>tr>td:last-child{width:100%}main .block>table>tbody>tr>td.hidden{filter:blur(5px)}main .block.secondary{border:1px solid RGB(var(--secondary))}main .block.secondary>h2{color:RGB(var(--secondary))}main form{display:flex;flex-direction:column}main form>input{margin:0 0 1rem 0;padding:.7rem 1rem;border:1px solid RGB(var(--white));border-radius:2px;background-color:RGB(var(--darkBlue));color:RGB(var(--white))}main form>input:focus{outline:none;border-color:RGB(var(--primary))}main form>input.error{border-color:RGB(var(--secondary))}main form>button{margin:0;padding:.75rem 1rem;font-weight:bolder;border:transparent;border-radius:2px;background-color:RGB(var(--primary));color:RGB(var(--black))}main form>button:focus-visible,main form>button:hover{outline:none;background-color:RGBA(var(--primary),0.3);color:RGB(var(--primary))}main form>button.disabled{pointer-events:none;opacity:.5}main form>button.secondary{background-color:RGB(var(--secondary));color:RGB(var(--black))}main form>button.secondary:focus-visible,main form>button.secondary:hover{background-color:RGBA(var(--secondary),0.3);color:RGB(var(--secondary))}footer{padding:.5rem 1rem;width:100%;display:flex;flex-direction:row;background-color:RGBA(var(--darkBlue),0.7)}footer>p{margin:0;width:100%;text-align:center;font-size:.8em;white-space:nowrap;color:RGB(var(--white))}footer>p>a{color:RGB(var(--secondary));text-decoration:none}footer>p>a:hover{text-decoration:underline} \ No newline at end of file diff --git a/Highscore-Server/server/static/js/main.js b/Highscore-Server/server/static/js/main.js new file mode 100644 index 0000000..ca5689e --- /dev/null +++ b/Highscore-Server/server/static/js/main.js @@ -0,0 +1,106 @@ +function addFlashMessage(message, type='success') { + /** + * Add a flash message to the page + * + * @param {string} message + * @return {void} + * + * @example + * addFlashMessage('Hello World!', 'success') + * + * @example + * addFlashMessage('Oopsie!', 'error') + */ + let flask = document.createElement('p'); + flask.onclick = () => flask.remove(); + flask.classList.add(type); + flask.innerHTML = message; + + let close = document.createElement('span'); + close.innerHTML = ''; + + flask.appendChild(close); + document.querySelector('.flash').appendChild(flask); +} + +function ajax(url, form, callback, method='POST') { + /** + * Send a request to the server and get a response + * Mostly a wrapper for fetch(), since most of the + * requests are made with FormData and POST method + * + * @param {string} url + * @param {FormData} form + * @param {function} callback + * @param {string} method + * @return {void} + * + * @example + * ajax('/api', formData, callback = (data) => { console.log(data) }, 'POST') + */ + console.log(form) + fetch(url, { + method: method, + body: form, + }) + .then(response => response.json()) + .then(data => callback(data)) + .catch(error => addFlashMessage(error.error, 'error')); +} + +function deleteToken(id) { + /** + * Delete user token + * + * @return {void} + * @{integer} id + * + * @example + * deleteToken(id) + */ + let form = new FormData(); + form.append('token_id', id); + + ajax('/api/tokens', form, (data) => { + if (data.success) { + addFlashMessage(data.success, 'success'); + document.querySelector(`#token-${id}`).remove(); + } else { + addFlashMessage(data.error, 'error'); + } + }, 'DELETE'); +} + +function addToken() { + /** + * Add a new token + * + * @return {void} + * + * @example + * addToken() + */ + ajax('/api/tokens', null, (data) => { + if (data.success) { + window.location.reload(); + } else { + addFlashMessage(data.error, 'error'); + } + }); +} + +function viewToken(id) { + /** + * View a token + * + * @return {void} + * @{integer} id + * + * @example + * viewToken(id) + */ + let token = document.querySelector(`#token-${id}`); + let hidden = token.children[2]; + + hidden.classList.toggle('hidden'); +} \ No newline at end of file diff --git a/Highscore-Server/server/static/sass/style.sass b/Highscore-Server/server/static/sass/style.sass new file mode 100644 index 0000000..4d42e24 --- /dev/null +++ b/Highscore-Server/server/static/sass/style.sass @@ -0,0 +1,360 @@ +$black: var(--black) +$white: var(--white) +$primary: var(--primary) +$secondary: var(--secondary) +$gold: var(--gold) +$silver: var(--silver) +$bronze: var(--bronze) +$darkBlue: var(--darkBlue) + +@mixin button($color) + text-decoration: none + background-color: RGBA($color, 0.02) + color: RGB($color) + border-radius: 2px + border: 0 solid transparent + transition: background-color 0.2s ease-in-out, transform 0.1s ease-in-out + + &:hover + background-color: RGBA($color, 0.3) + transform: translateY(-0.1rem) + +@import url('https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700&display=swap') + +\:root + --black: 21, 21, 21 + --white: 232, 227, 227 + --primary: 210, 206, 97 + --secondary: 185, 77, 77 + --gold: 255, 222, 70 + --silver: 229, 220, 206 + --bronze: 193, 145, 69 + --darkBlue: 9, 9, 39 + +* + box-sizing: border-box + font-family: 'Merriweather', serif + +html + margin: 0 + padding: 0 + +body + margin: 0 + padding: 0 + display: flex + flex-direction: row + background-color: RGB($darkBlue) + color: RGB($white) + +.background + width: 100% + height: 100% + + object-fit: cover + position: absolute + z-index: 1 + +.app + margin: 0 auto + padding: 0 + + width: 800px + min-height: 100vh + + position: relative + display: flex + flex-direction: column + + background-color: rgba($darkBlue, 0.7) + backdrop-filter: blur(5px) + z-index: 2 + + > table + width: 100% + +header + padding: 1rem + + background-color: RGBA($darkBlue, 0.7) + + > img + margin-bottom: 1rem + width: 100% + height: auto + text-align: center + +nav + margin-top: 0.3rem + padding: 0 + + display: flex + flex-direction: row + justify-content: center + + > span + width: 100% + + > a + margin: auto 0.15rem + padding: 0.5rem 0.7rem + + text-decoration: none + white-space: nowrap + font-size: 0.9em + + color: RGB($primary) + + &.button + @include button($white) + + &.primary + @include button($primary) + + &.secondary + @include button($secondary) + + > i + font-size: 1.25em + display: block + +.flash + display: flex + flex-direction: column + justify-content: center + align-items: center + + > p + margin: 0 + padding: 0.75rem 1rem + + width: 100% + position: relative + + background-color: RGB($darkBlue) + color: RGB($primary) + + transition: background-color 0.2s ease-in-out, padding 0.2s ease-in-out + + > span + position: absolute + top: 0 + left: 0 + bottom: 0 + width: 0.25rem + + display: flex + justify-content: center + align-items: center + + background-color: RGB($primary) + color: transparent + + overflow: hidden + transition: width 0.2s ease-in-out, color 0.2s ease-in-out, background-color 0.2s ease-in-out + + > i + font-size: 1.25em + + &:hover + padding: 0.75rem 1rem 0.75rem 4rem + background-color: RGBA($primary, 0.1) + cursor: pointer + + > span + width: 3rem + color: RGB($black) + + &.success + color: RGB($primary) + + > span + background-color: RGB($primary) + + &:hover + background-color: RGBA($primary, 0.1) + + &.error + color: RGB($secondary) + + > span + background-color: RGB($secondary) + + &:hover + background-color: RGBA($secondary, 0.1) + +main + padding: 1rem + height: 100% + + display: flex + flex-direction: column + + > h2 + margin: 0 0 1rem 0 + font-size: 1.5em + color: RGB($white) + + .center-text + height: 100% + + display: flex + flex-direction: column + justify-content: center + align-items: center + + > h2 + margin: 0 + text-align: center + font-size: 2em + color: RGB($white) + + > p + margin: 0 + text-align: center + font-size: 1em + color: RGB($white) + + > img + margin: 1rem auto 0 + max-width: 100% + max-height: 15rem + border-radius: 2px + + .block + margin-bottom: 1rem + padding: 1rem + + display: flex + flex-direction: column + + background-color: rgba($darkBlue, 0.7) + border-radius: 2px + + > h2 + margin: 0 0 0.2rem 0 + font-size: 1.3em + color: RGB($white) + + > p + margin: 0 0 1rem 0 + font-size: 1em + + .button + margin: 0 + padding: 0.5rem 0.7rem + + text-decoration: none + white-space: nowrap + font-size: 0.9em + + color: RGB($primary) + + > i + font-size: 1.25em + display: block + + @include button($white) + + &.primary + @include button($primary) + + &.secondary + @include button($secondary) + + > table + width: 100% + border-collapse: collapse + > tbody > tr > td + padding: 0 0.5rem 0.5rem 0 + + text-align: left + font-size: 0.9em + + color: RGB($white) + + transition: filter 0.2s ease-in-out + + &:last-child + width: 100% + + &.hidden + filter: blur(5px) + + &.secondary + border: 1px solid RGB($secondary) + + > h2 + color: RGB($secondary) + + form + display: flex + flex-direction: column + + > input + margin: 0 0 1rem 0 + padding: 0.7rem 1rem + + border: 1px solid RGB($white) + border-radius: 2px + + background-color: RGB($darkBlue) + color: RGB($white) + + &:focus + outline: none + border-color: RGB($primary) + + &.error + border-color: RGB($secondary) + + > button + margin: 0 + padding: 0.75rem 1rem + + font-weight: bolder + + border: transparent + border-radius: 2px + + background-color: RGB($primary) + color: RGB($black) + + &:focus-visible, &:hover + outline: none + background-color: RGBA($primary, 0.3) + color: RGB($primary) + + &.disabled + pointer-events: none + opacity: 0.5 + + &.secondary + background-color: RGB($secondary) + color: RGB($black) + + &:focus-visible, &:hover + background-color: RGBA($secondary, 0.3) + color: RGB($secondary) + +footer + padding: 0.5rem 1rem + width: 100% + display: flex + flex-direction: row + background-color: RGBA($darkBlue, 0.7) + + > p + margin: 0 + width: 100% + text-align: center + font-size: 0.8em + white-space: nowrap + color: RGB($white) + + > a + color: RGB($secondary) + text-decoration: none + + &:hover + text-decoration: underline \ No newline at end of file diff --git a/Highscore-Server/server/static/style.sass b/Highscore-Server/server/static/style.sass deleted file mode 100644 index d8121c1..0000000 --- a/Highscore-Server/server/static/style.sass +++ /dev/null @@ -1,111 +0,0 @@ -$black: var(--black) -$white: var(--white) -$primary: var(--primary) -$secondary: var(--secondary) -$gold: var(--gold) -$silver: var(--silver) -$bronze: var(--bronze) - -@import url('https://fonts.cdnfonts.com/css/cmu-serif') - -\:root - --black: 21, 21, 21 - --white: 232, 227, 227 - --primary: 213, 214, 130 - --secondary: 185, 77, 77 - --gold: 255, 222, 70 - --silver: 229, 220, 206 - --bronze: 193, 145, 69 - -* - box-sizing: border-box - font-family: 'CMU Serif', serif - -html - margin: 0 - padding: 0 - -body - margin: 0 - padding: 0 - display: flex - flex-direction: row - background-color: RGB($black) - color: RGB($white) - -.background - width: 100% - height: 100vh - height: 100dvh - - object-fit: cover - position: absolute - z-index: 1 - -.app - margin: 0 auto - padding: 2rem - width: 800px - min-height: 100vh - background-color: RGBA($black, 0.9) - backdrop-filter: blur(0.5rem) - position: relative - z-index: 2 - - > table - width: 100% - -.title - width: 100% - height: auto - text-align: center - -.subtitle - margin-bottom: 1rem - padding: 0 - - text-align: center - font-weight: bolder - font-size: 1.2em - - color: RGB($secondary) - - > span - padding: 0 0.1rem - - color: transparent - background: RGB($secondary) - -nav - margin: 0 - padding: 0 - - height: 3rem - - display: flex - flex-direction: row - justify-content: center - - > hr - margin: auto 0.25rem - - height: 1.75rem - width: 3px - - background-color: RGB($primary) - border: none - - > a - margin: auto 0.25rem - padding: 0.5rem 1rem - - text-decoration: none - - background-color: transparent - color: RGB($primary) - border-radius: 2px - - transition: background-color 0.2s ease-in-out - - > a:hover - background-color: RGBA($primary, 0.3) diff --git a/Highscore-Server/server/templates/about.html b/Highscore-Server/server/templates/about.html new file mode 100644 index 0000000..62c1a60 --- /dev/null +++ b/Highscore-Server/server/templates/about.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block content %} +

What is The Front Rooms?

+

Project Redacted

+{% endblock %} \ No newline at end of file diff --git a/Highscore-Server/server/templates/account.html b/Highscore-Server/server/templates/account.html new file mode 100644 index 0000000..80a1ff3 --- /dev/null +++ b/Highscore-Server/server/templates/account.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block nav %} + +{% endblock %} +{% block content %} +
+

Tokens

+

These are your API tokens. Used to link the uploaded scores with your account.

+ + {% for token in token_list %} + + + + + + {% endfor %} +
+ +
+ +
+

Danger Zone

+

These actions are irreversible. Be careful!

+ Delete Account + Reset Password +
+{% endblock %} diff --git a/Highscore-Server/server/templates/auth.html b/Highscore-Server/server/templates/auth.html new file mode 100644 index 0000000..6779ad3 --- /dev/null +++ b/Highscore-Server/server/templates/auth.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block content %} +
+

Login

+

Welcome back!

+
+ + + +
+
+ +
+

Register

+

Don't have an account? Register here!

+
+ + + +
+
+{% endblock %} \ No newline at end of file diff --git a/Highscore-Server/server/templates/base.html b/Highscore-Server/server/templates/base.html index 76f5c74..4673178 100644 --- a/Highscore-Server/server/templates/base.html +++ b/Highscore-Server/server/templates/base.html @@ -1,32 +1,66 @@ - - - - - - - Front Rooms Highscores - {% assets "styles" %} - - {% endassets %} - - - The Front Rooms pause menu -
- The Front Rooms logo - - - - - {% block content %}{% endblock %} -
- + + + + + + + Front Rooms Highscores + + + + {% assets "scripts" %} + + {% endassets %} + + {% assets "styles" %} + + {% endassets %} + + + The Front Rooms pause menu +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +

+ + {{ message }} +

+ {% endfor %} + {% endif %} + {% endwith %} +
+ +
+ The Front Rooms logo + + + + {% block nav %}{% endblock %} +
+ +
+ {% block content %}{% endblock %} +
+ + +
+ \ No newline at end of file diff --git a/Highscore-Server/server/templates/error.html b/Highscore-Server/server/templates/error.html new file mode 100644 index 0000000..51a5eb3 --- /dev/null +++ b/Highscore-Server/server/templates/error.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block content %} +
+

{{ error }}

+

{{ msg }}

+ Error +
+{% endblock %} \ No newline at end of file diff --git a/Highscore-Server/server/templates/scores.html b/Highscore-Server/server/templates/scores.html index 5d278d0..f76b2aa 100644 --- a/Highscore-Server/server/templates/scores.html +++ b/Highscore-Server/server/templates/scores.html @@ -1,20 +1,42 @@ -{% extends "base.html" %} -{% block content %} - - - - - - - - - {% for score in top_scores %} - - - - - - - {% endfor %} -
ScoreDifficultyAchievementsPlayer
{{ score.score }}{{ score.difficulty }}{{ score.achievements }}{{ score.user.steam_name }}
+{% extends "base.html" %} +{% block nav %} + +{% endblock %} +{% block content %} + {% if scores %} + + + + + + + + {% for score in top_scores %} + + + {% if score.anonymous %} + + {% else %} + + {% endif %} + + + + {% endfor %} +
PositionPlayerDifficultyScore
{{ loop.index }}{{ score.username }}{{ score.scorer.username }}{{ score.difficulty }}{{ score.score }}
+ {% else %} +
+

No scores yet

+

Set some!

+
+ {% endif %} {% endblock %} \ No newline at end of file diff --git a/Highscore-Server/server/views.py b/Highscore-Server/server/views.py index 750bede..34e22e7 100644 --- a/Highscore-Server/server/views.py +++ b/Highscore-Server/server/views.py @@ -1,62 +1,23 @@ -from flask import Blueprint, jsonify, request, render_template -from flask_wtf import FlaskForm -from wtforms import StringField, IntegerField -from wtforms.validators import DataRequired - -from models import Scores, Users -from extensions import db, cache -from config import BEARER_TOKEN - - -blueprint = Blueprint('views', __name__) - - -class ScoreForm(FlaskForm): - playerName = StringField('Player Name', validators=[DataRequired()]) - playerId = StringField('Player ID', validators=[DataRequired()]) - score = IntegerField('Score', validators=[DataRequired()]) - difficulty = StringField('Difficulty', validators=[DataRequired()]) - achievements = StringField('Achievements', validators=[DataRequired()]) - - -@blueprint.route('/', methods=['GET']) -@cache.cached(timeout=60) -def index(): - top_scores = Scores.query.order_by(Scores.score.desc()).limit(10).all() - return render_template('scores.html', top_scores=top_scores) - - -@blueprint.route('/post', methods=['POST']) -def post(): - form = ScoreForm() - - if not form: - return "Invalid form", 400 - if request.headers.get('Authentication') != 'Bearer ' + BEARER_TOKEN: - return "Invalid authentication", 401 - - if not isinstance(form.score.data, int): - return "Score must be an integer", 400 - if form.score.data < 0: - return "Score must be greater than 0", 400 - if form.difficulty.data not in ['easy', 'medium', 'hard']: - return "Invalid difficulty", 400 - - user = Users.query.filter_by(steam_uuid=form.playerId.data).first() - if not user: - user = Users( - steam_uuid=form.playerId.data, - steam_name=form.playerName.data, - ) - db.session.add(user) - db.session.commit() - - score = Scores( - score=form.score.data, - difficulty=form.difficulty.data, - achievements=form.achievements.data, - user_id=user.id, - ) - db.session.add(score) - db.session.commit() - return jsonify({'message': 'Success!'}) +from flask import Blueprint, request, render_template +from server.models import Scores + + +blueprint = Blueprint('views', __name__) + + +@blueprint.route('/') +# @cache.cached(timeout=60) +def index(): + difficulty = request.args.get('diff', 0) + + top_scores = (Scores.query + .order_by(Scores.score.desc()) + .filter_by(difficulty=difficulty) + .limit(10) + .all()) + return render_template('scores.html', top_scores=top_scores) + + +@blueprint.route('/about') +def about(): + return render_template('about.html') diff --git a/docker-compose.yml b/docker-compose.yml index b49c2a0..7452893 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: image: caddy:alpine restart: unless-stopped ports: - - 80:80 + # - 80:80 - 443:443 volumes: - ./Caddy/Caddyfile:/etc/caddy/Caddyfile