Add templates and many other things

This commit is contained in:
Michał Gdula 2023-05-06 23:11:20 +01:00
parent 330201be3a
commit 62945c943d
414 changed files with 14758 additions and 4145 deletions

View file

@ -1,12 +1,21 @@
from flask import Flask
from server.extensions import db, migrate, cache
from server.views import blueprint
from flask_assets import Bundle
from server.extensions import db, migrate, cache, assets
from server import views, auth
app = Flask(__name__)
app.config.from_pyfile('config.py')
db.init_app(app)
migrate.init_app(app, db)
cache.init_app(app)
app.register_blueprint(blueprint)
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(views.blueprint)
app.register_blueprint(auth.blueprint)

25
server/auth.py Normal file
View file

@ -0,0 +1,25 @@
from flask import Blueprint, jsonify, request, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField
from wtforms.validators import DataRequired
from server.models import Scores, Users, Tokens
from server.extensions import db, cache
from server.config import BEARER_TOKEN
blueprint = Blueprint('auth', __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('/auth', methods=['GET'])
@cache.cached(timeout=60)
def auth():
return render_template('auth.html')

View file

@ -1,4 +1,5 @@
SECRET_KEY = 'dev'
SECRET_KEY = "dev"
BEARER_TOKEN = 1234
SQLALCHEMY_DATABASE_URI = 'sqlite:///db.sqlite'
SQLALCHEMY_DATABASE_URI = f"sqlite:///db.sqlite"
SQLALCHEMY_TRACK_MODIFICATIONS = False

View file

@ -1,7 +1,9 @@
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_assets import Environment
from flask_caching import Cache
db = SQLAlchemy()
migrate = Migrate()
cache = Cache()
assets = Environment()
cache = Cache(config={'CACHE_TYPE': 'simple'})

View file

@ -1,28 +1,33 @@
"""
Database models for the server
"""
from uuid import uuid4
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)
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)
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.String, nullable=False)
scored_at = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.now(),
server_default=db.func.utcnow(),
)
scorer = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
class Users(db.Model):
"""
@ -31,13 +36,35 @@ class Users(db.Model):
__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)
alt_id = db.Column(db.String, nullable=False, unique=True, default=str(uuid4()))
scores = db.relationship('Scores', backref='user', lazy=True)
creation_data = db.Column(
username = db.Column(db.String(32), unique=True, nullable=False)
email = db.Column(db.String, unique=True, nullable=False)
password = db.Column(db.String(128), nullable=False)
joined_at = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.now(),
server_default=db.func.utcnow(), # pylint: disable=E1102
)
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)
token = db.Column(db.String, nullable=False, unique=True, default=str(uuid4()))
created_at = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.utcnow(), # pylint: disable=E1102
)
holder = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)

BIN
server/static/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View file

@ -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:213,214,130;--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:2rem;width:800px;min-height:100vh;position:relative;display:flex;flex-direction:column;background-color:rgba(var(--darkBlue),0.9);z-index:2}.app>table{width:100%}.app .center-text{height:100%;display:flex;flex-direction:column;justify-content:center;align-items:center}.app .center-text>h2{margin:0;text-align:center;font-size:2em;color:RGB(var(--white))}.app .center-text>p{margin:0;text-align:center;font-size:1em;color:RGB(var(--white))}.app .auth{margin:auto 1rem;padding:1rem;display:flex;flex-direction:column;background-color:rgba(var(--darkBlue),0.5);border-radius:2px}.app .auth>h2{margin:0 0 1rem 0;font-size:1.3em;color:RGB(var(--white))}.title{margin-bottom:2rem;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(var(--secondary))}.subtitle>span{padding:0 .1rem;color:transparent;background:RGB(var(--secondary))}nav{margin:0;padding:0;height:3rem;display:flex;flex-direction:row;justify-content:center}nav>span{width:100%}nav>a{margin:auto .25rem;padding:.5rem 1rem;text-decoration:none;white-space:nowrap;color:RGB(var(--primary))}nav>a.button{text-decoration:none;background-color:transparent;color:RGB(var(--white));border-radius:2px;transition:background-color .2s ease-in-out,box-shadow .2s ease-in-out,transform .2s 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:transparent;color:RGB(var(--primary));border-radius:2px;transition:background-color .2s ease-in-out,box-shadow .2s ease-in-out,transform .2s 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:transparent;color:RGB(var(--secondary));border-radius:2px;transition:background-color .2s ease-in-out,box-shadow .2s ease-in-out,transform .2s ease-in-out}nav>a.button.secondary:hover{background-color:RGBA(var(--secondary),0.3);transform:translateY(-0.1rem)}form{display:flex;flex-direction:column}form>input{margin:0 0 1rem 0;padding:.5rem 1rem;border:1px solid RGB(var(--white));border-radius:2px;background-color:RGB(var(--darkBlue));color:RGB(var(--white))}form>input:focus{outline:none;border-color:RGB(var(--primary))}form>input.error{border-color:RGB(var(--secondary))}form>button{margin:0;padding:.5rem 1rem;font-weight:bolder;border:transparent;border-radius:2px;background-color:RGB(var(--primary));color:RGB(var(--black))}form>button:focus-visible,form>button:hover{outline:none;background-color:RGBA(var(--primary),0.3);color:RGB(var(--primary))}form>button.disabled{pointer-events:none;opacity:.5}form>button.secondary{background-color:RGB(var(--secondary));color:RGB(var(--black))}form>button.secondary:focus-visible,form>button.secondary:hover{background-color:RGBA(var(--secondary),0.3);color:RGB(var(--secondary))}

212
server/static/style.sass Normal file
View file

@ -0,0 +1,212 @@
$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: transparent
color: RGB($color)
border-radius: 2px
// box-shadow: 0 0 0 0 RGBA($color, 0)
transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out, transform 0.2s ease-in-out
&:hover
background-color: RGBA($color, 0.3)
transform: translateY(-0.1rem)
// box-shadow: 0 0.1rem 0.4rem 0.1rem RGBA($color, 0.2)
@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: 213, 214, 130
--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: 2rem
width: 800px
min-height: 100vh
position: relative
display: flex
flex-direction: column
background-color: rgba($darkBlue, 0.9)
z-index: 2
> table
width: 100%
.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)
.auth
margin: auto 1rem
padding: 1rem
display: flex
flex-direction: column
background-color: rgba($darkBlue, 0.5)
border-radius: 2px
> h2
margin: 0 0 1rem 0
font-size: 1.3em
color: RGB($white)
.title
margin-bottom: 2rem
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
> span
width: 100%
> a
margin: auto 0.25rem
padding: 0.5rem 1rem
text-decoration: none
white-space: nowrap
color: RGB($primary)
&.button
@include button($white)
&.primary
@include button($primary)
&.secondary
@include button($secondary)
form
display: flex
flex-direction: column
> input
margin: 0 0 1rem 0
padding: 0.5rem 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.5rem 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)

BIN
server/static/title.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<div class="auth">
<h2>Login</h2>
<form action="" method="POST">
<input type="text" name="username" placeholder="Username | Email" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
</div>
<p style="text-align: center;">or</p>
<div class="auth">
<h2>Register</h2>
<form action="" method="POST">
<input type="text" name="username" placeholder="Username" required>
<input type="email" name="email" placeholder="Email - Optional">
<input type="password" name="password" placeholder="Password" required>
<button type="submit" class="secondary">Register</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,31 @@
<!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>Front Rooms Highscores</title>
{% assets "styles" %}
<link rel="stylesheet" href="{{ ASSET_URL }}" type="text/css">
{% endassets %}
</head>
<body>
<img src="{{ url_for("static", filename="bg.png") }}" alt="The Front Rooms pause menu" class="background">
<div class="app">
<img src="{{ url_for("static", filename="title.png") }}" alt="The Front Rooms logo" class="title">
<nav>
<a href="{{ url_for('views.index', diff=0) }}" class="button">Level 1</a>
<a href="{{ url_for('views.index', diff=1) }}" class="button">Level 2</a>
<a href="{{ url_for('views.index', diff=2) }}" class="button">Level 3</a>
<a href="{{ url_for('views.index', diff=3) }}" class="button">Normal</a>
<a href="{{ url_for('views.index', diff=4) }}" class="button">Hard</a>
<span></span> <!-- This is a spacer -->
<a href="{{ url_for('auth.auth') }}" class="button secondary">Login</a>
</nav>
{% block content %}{% endblock %}
</div>
</body>
</html>

View file

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
{% if scores %}
<table>
<tr>
<th>Position</th>
<th>Player</th>
<th>Difficulty</th>
<th>Score</th>
</tr>
{% for score in top_scores %}
<tr>
<td>{{ loop.index }}</td>
{% if score.anonymous %}
<td>{{ score.username }}</td>
{% else %}
<td>{{ score.scorer.username }}</td>
{% endif %}
<td>{{ score.difficulty }}</td>
<td>{{ score.score }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="center-text">
<h2>No scores yet</h2>
<p>Set some!</p>
</div>
{% endif %}
{% endblock %}

View file

@ -1,10 +1,12 @@
from flask import Blueprint, jsonify, render_template_string, request, abort
from flask import Blueprint, jsonify, request, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField
from wtforms.validators import DataRequired
from server.models import Scores, Users
from server.models import Scores, Users, Tokens
from server.extensions import db, cache
from server.config import BEARER_TOKEN
blueprint = Blueprint('views', __name__)
@ -17,44 +19,18 @@ class ScoreForm(FlaskForm):
achievements = StringField('Achievements', validators=[DataRequired()])
@blueprint.route('/', methods=['GET'])
@blueprint.route('/')
@cache.cached(timeout=60)
def index():
top_scores = Scores.query.order_by(Scores.score.desc()).limit(10).all()
users = Users.query.all()
return render_template_string('''
<h1>Top Scores</h1>
<table>
<tr>
<th>Score</th>
<th>Difficulty</th>
<th>Achievements</th>
<th>Player</th>
</tr>
{% for score in top_scores %}
<tr>
<td>{{ score.score }}</td>
<td>{{ score.difficulty }}</td>
<td>{{ score.achievements }}</td>
<td>{{ score.user.steam_name }}</td>
</tr>
{% endfor %}
</table>
<h1>Players</h1>
<table>
<tr>
<th>Steam ID</th>
<th>Steam Name</th>
</tr>
{% for user in users %}
<tr>
<td>{{ user.steam_uuid }}</td>
<td>{{ user.steam_name }}</td>
</tr>
{% endfor %}
</table>
''', top_scores=top_scores, users=users)
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('/post', methods=['POST'])
@ -63,33 +39,50 @@ def post():
if not form:
return "Invalid form", 400
if request.headers.get('Authentication') != 'Bearer 1234':
if not request.headers.get('Authentication'):
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']:
if form.difficulty.data 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
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,
if request.headers.get('Authentication') == BEARER_TOKEN:
# User is not authenticated, but has the correct token
# This is an anonymous user
if not form.playerName.data or len(form.playerId.data) != 4:
return "Invalid player name", 400
score = Scores(
anonymous=True,
username=form.playerName.data,
score=form.score.data,
difficulty=form.difficulty.data,
)
db.session.add(user)
db.session.add(score)
db.session.commit()
return "Success!", 200
elif Tokens.query.filter_by(token=request.headers.get('Authentication')).first():
# User is authenticated
# This is a registered user
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!'})
user = Tokens.query.filter_by(token=request.headers.get('Authentication')).first().holder
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 "Success!", 200
return "Authentication failed", 401