Merge branch 'dev' into preprod

This commit is contained in:
Darks 2020-07-21 22:14:04 +02:00
commit 68b3bdd943
Signed by: Darks
GPG Key ID: F61F10FA138E797C
24 changed files with 369 additions and 78 deletions

View File

@ -8,10 +8,12 @@ La liste de paquets fourni est pour Archlinux, les paquets peuvent avoir des nom
python3
python-flask
python-flask-login
python-flask-mail
python-flask-migrate
python-flask-script
python-flask-sqlalchemy
python-flask-wtf
python-itsdangerous
python-ldap
python-uwsgi
python-psycopg2

View File

@ -2,6 +2,7 @@ from flask import Flask, g
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_mail import Mail
from config import Config
import time
@ -14,6 +15,7 @@ if Config.SECRET_KEY == "a-random-secret-key":
db = SQLAlchemy(app)
migrate = Migrate(app, db)
mail = Mail(app)
login = LoginManager(app)
login.login_view = 'login'

View File

@ -7,7 +7,7 @@
-
name: Membre de CreativeCalc
is_title: True
description: Membre de l'association qui gère Planète Casio !
description: Membre de l'association qui gère Planète Casio!
hidden: True
-
name: Membre d'honneur
@ -49,7 +49,7 @@
-
name: Romancier émérite
is_title: True
description: Écrire 5000 messages !
description: Écrire 5000 messages!
hidden: False
# Number of posted tutorials
@ -66,7 +66,7 @@
-
name: Guerrier du savoir
is_title: True
description: Élaborer 25 tutoriels !
description: Élaborer 25 tutoriels!
hidden: False
# Account age (awarded on login only)
@ -93,7 +93,7 @@
-
name: Vétéran mythique
is_title: True
description: Être membre pendant 10 ans !
description: Être membre pendant 10 ans!
hidden: False
# Number of "good" programs
@ -110,7 +110,7 @@
-
name: Je code donc je suis
is_title: True
description: Publier 20 programmes !
description: Publier 20 programmes!
hidden: False
# Number of posted tests
@ -127,7 +127,7 @@
-
name: Hard tester
is_title: True
description: Partager 100 tests !
description: Partager 100 tests!
hidden: False
# Number of event participations
@ -144,7 +144,7 @@
-
name: Concurrent de l'extrême
is_title: True
description: Participer à 15 événements du site !
description: Participer à 15 événements du site!
hidden: False
# Number of posted art
@ -161,7 +161,7 @@
-
name: Roi du pixel
is_title: True
description: Publier 100 assets graphiques !
description: Publier 100 assets graphiques!
hidden: False
# Miscellaneous automatically awarded
@ -178,10 +178,10 @@
-
name: Maître du code
is_title: True
description: Être décoré 5 fois du label de qualité !
description: Être décoré 5 fois du label de qualité!
hidden: False
-
name: Bourreau des cœurs
is_title: True
description: Foudroyer les cœurs à 5 reprises !
description: Foudroyer les cœurs à 5 reprises!
hidden: False

View File

@ -137,6 +137,35 @@ class DeleteAccountForm(FlaskForm):
)
class AskResetPasswordForm(FlaskForm):
email = EmailField(
'Adresse email',
validators=[
Optional(),
Email(message="Addresse email invalide."),
],
)
submit = SubmitField('Valider')
class ResetPasswordForm(FlaskForm):
password = PasswordField(
'Mot de passe',
validators=[
Optional(),
vd.password,
],
)
password2 = PasswordField(
'Répéter le mot de passe',
validators=[
Optional(),
EqualTo('password', message="Les mots de passe doivent être identiques."),
],
)
submit = SubmitField('Valider')
class AdminUpdateAccountForm(FlaskForm):
username = StringField(
'Pseudonyme',

View File

@ -76,6 +76,7 @@ class Member(User):
norm = db.Column(db.Unicode(V5Config.USER_NAME_MAXLEN), index=True,
unique=True)
email = db.Column(db.Unicode(120), index=True, unique=True)
email_confirmed = db.Column(db.Boolean)
password_hash = db.Column(db.String(255))
xp = db.Column(db.Integer)
register_date = db.Column(db.Date, default=date.today)
@ -118,6 +119,7 @@ class Member(User):
self.name = name
self.norm = unicode_names.normalize(name)
self.email = email
self.email_confirmed = not V5Config.ENABLE_EMAIL_CONFIRMATION
if not V5Config.USE_LDAP:
self.set_password(password)
# Workflow with LDAP enabled is User → Postgresql → LDAP → set password

View File

@ -1,10 +1,14 @@
from flask import redirect, url_for, request, flash
from flask import redirect, url_for, request, flash, abort
from flask_login import login_required, current_user, logout_user
from app import app, db
from app.forms.account import UpdateAccountForm, RegistrationForm, DeleteAccountForm
from app.forms.account import UpdateAccountForm, RegistrationForm, \
DeleteAccountForm, AskResetPasswordForm, ResetPasswordForm
from app.models.users import Member
from app.utils.render import render
from app.utils.send_mail import send_validation_mail, send_reset_password_mail
from app.utils.priv_required import guest_only
import app.utils.ldap as ldap
from itsdangerous import URLSafeTimedSerializer
from config import V5Config
@ -33,6 +37,45 @@ def edit_account():
return render('account/account.html', form=form)
@app.route('/compte/reinitialiser', methods=['GET', 'POST'])
@guest_only
def ask_reset_password():
form = AskResetPasswordForm()
if form.submit.data:
m = Member.query.filter_by(email=form.email.data).first()
if m is not None:
send_reset_password_mail(m.name, m.email)
flash('Un email a été envoyé à l\'adresse renseignée', 'ok')
return redirect(url_for('login'))
elif request.method == "POST":
flash('Une erreur est survenue', 'error')
return render('account/ask_reset_password.html', form=form)
@app.route('/compte/reinitialiser/<token>', methods=['GET', 'POST'])
@guest_only
def reset_password(token):
try:
ts = URLSafeTimedSerializer(app.config["SECRET_KEY"])
email = ts.loads(token, salt="email-confirm-key", max_age=86400)
except Exception as e:
print(f"Error: {e}")
abort(404)
form = ResetPasswordForm()
if form.submit.data:
if form.validate_on_submit():
m = Member.query.filter_by(email=email).first_or_404()
m.set_password(form.password.data)
db.session.merge(m)
db.session.commit()
flash('Modifications effectuées', 'ok')
return redirect(url_for('login'))
else:
flash('Erreur lors de la modification', 'error')
return render('account/reset_password.html', form=form)
@app.route('/compte/supprimer', methods=['GET', 'POST'])
@login_required
@ -52,9 +95,8 @@ def delete_account():
@app.route('/inscription', methods=['GET', 'POST'])
@guest_only
def register():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = RegistrationForm()
if form.validate_on_submit():
member = Member(form.username.data, form.email.data, form.password.data)
@ -66,13 +108,42 @@ def register():
ldap.add_member(member)
ldap.set_password(member, form.password.data)
flash('Inscription réussie', 'ok')
# Email validation message
send_validation_mail(member.name, member.email)
return redirect(url_for('validation') + "?email=" + form.email.data)
return render('account/register.html', title='Register', form=form)
@app.route('/register/validation/', methods=['GET', 'POST'])
@app.route('/inscription/validation', methods=['GET'])
@guest_only
def validation():
mail = request.args['email']
try:
mail = request.args['email']
except Exception as e:
print("Error: {e}")
abort(404)
if current_user.is_authenticated:
return redirect(url_for('index'))
return render('account/validation.html', mail=mail)
@app.route('/inscription/validation/<token>', methods=['GET'])
@guest_only
def activate_account(token):
try:
ts = URLSafeTimedSerializer(app.config["SECRET_KEY"])
email = ts.loads(token, salt="email-confirm-key", max_age=86400)
except Exception as e:
print(f"Error: {e}")
abort(404)
m = Member.query.filter_by(email=email).first_or_404()
m.email_confirmed = True
db.session.add(m)
db.session.commit()
flash("L'email a bien été confirmé", "ok")
return redirect(url_for('login'))

View File

@ -6,11 +6,19 @@ from app.forms.login import LoginForm
from app.models.users import Member
from app.models.privs import Group
from app.utils.render import render
from app.utils.send_mail import send_validation_mail
from config import V5Config
@app.route('/connexion', methods=['GET', 'POST'])
def login():
# If something failed, return abort("Message")
def _abort(msg):
flash(msg, 'error')
if request.referrer:
return redirect(request.referrer)
return redirect(url_for('index'))
if current_user.is_authenticated:
return redirect(url_for('index'))
@ -24,17 +32,17 @@ def login():
# Check if member can login
if member is not None and "No login" in [g.name for g in member.groups]:
flash('Cet utilisateur ne peut pas se connecter', 'error')
if request.referrer:
return redirect(request.referrer)
return redirect(url_for('index'))
return _abort('Cet utilisateur ne peut pas se connecter')
# Check if password is ok
if member is None or not member.check_password(form.password.data):
flash('Pseudo ou mot de passe invalide', 'error')
if request.referrer:
return redirect(request.referrer)
return redirect(url_for('index'))
return _abort('Pseudo ou mot de passe invalide')
# Check if user is activated
if member.email_confirmed == False:
# Send another mail
send_validation_mail(member.name, member.email)
return _abort(f"Email non confirmé. Un mail de confirmation a de nouveau été envoyé à l'adresse {member.email}")
# Login & update time-based trophies
login_user(member, remember=form.remember_me.data,

View File

@ -68,18 +68,9 @@ table.topiclist tr > td:last-child {
table.thread {
width: 100%;
}
table.thread td.member {
table.thread td.author {
width: 20%;
}
table.thread td.guest {
width: 20%; padding-top: 12px;
text-align: center;
}
table.thread td.guest em {
display: block;
font-weight: bold; font-style: normal;
margin-bottom: 8px;
}
table.thread td {
vertical-align: top;
}

View File

@ -32,26 +32,36 @@
}
.profile-xp div {
height: 10px;
background: var(--background-xp);
border: var(--border-xp);
margin: -1px;
background: var(--background-xp);
border: var(--border-xp);
margin: -1px;
}
.profile.guest {
flex-direction: column;
width: 100%; padding-top: 12px;
text-align: center;
}
.profile.guest em {
display: block;
font-weight: bold; font-style: normal;
margin-bottom: 8px;
}
/* Trophies */
.trophies {
display: flex; flex-wrap: wrap; justify-content: space-between;
display: flex; flex-wrap: wrap; justify-content: space-between;
}
.trophy {
display: flex; align-items: center;
width: 260px;
margin: 5px; padding: 5px;
display: flex; align-items: center;
width: 260px;
margin: 5px; padding: 5px;
border: 1px solid #c5c5c5;
border-left: 5px solid var(--links);
border-radius: 2px;
border-radius: 2px;
}
.trophy img {
height: 50px; margin-right: 5px;
height: 50px; margin-right: 5px;
}
.trophy div > * {
display: block;
@ -65,7 +75,7 @@
}
.trophy.disabled {
filter: grayscale(100%);
filter: grayscale(100%);
opacity: .5;
border-left: 1px solid #c5c5c5;
}

View File

@ -0,0 +1,22 @@
{% extends "base/base.html" %}
{% block content %}
<section class="form" style="width:40%;">
<h1>Réinitialiser le mot de passe</h1>
<p>Vous recevrez un mail contenant un lien pour réintialiser votre mot de passe.</p>
<form action="" method="post">
{{ form.hidden_tag() }}
<div>
{{ form.email.label }}
<div class=desc>{{ form.email.description }}</div>
{{ form.email() }}
{% for error in form.email.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ form.submit(class_="bg-ok") }}</div>
</form>
</section>
{% endblock %}

View File

@ -23,5 +23,6 @@
<p>{{ form.submit(class_="bg-ok") }}</p>
</form>
<p>Pas encore de compte ? <a href="{{ url_for('register') }}">Créé-en un !</a></p>
</form>
<p><a href="{{ url_for('ask_reset_password') }}">Mot de passe oublié ?</a></p>
</section>
{% endblock %}

View File

@ -0,0 +1,28 @@
{% extends "base/base.html" %}
{% block content %}
<section class="form" style="width:40%;">
<h1>Réinitialiser le mot de passe</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<div>
{{ form.password.label }}
<div class=desc>{{ form.password.description }}</div>
{{ form.password() }}
{% for error in form.password.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.password2.label }}
<div class=desc>{{ form.password2.description }}</div>
{{ form.password2() }}
{% for error in form.password2.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ form.submit(class_="bg-ok") }}</div>
</form>
</section>
{% endblock %}

View File

@ -55,7 +55,7 @@
{{ login_form.remember_me.label }} {{ login_form.remember_me() }}
</form>
<hr />
<a href="{{ url_for('register') }}">Mot de passe oublié ?</a>
<a href="{{ url_for('register') }}">Inscription</a>
<a href="{{ url_for('ask_reset_password') }}">Mot de passe oublié ?</a>
</div>
{% endif %}

View File

@ -0,0 +1,14 @@
<html>
<body>
<div>Bonjour {{ name }},</div>
<div>Bienvenue sur Planète Casio!</div>
<div>Afin de pouvoir vous connecter et utiliser nos services, merci de confirmer cette adresse email en cliquant <a href="{{ confirm_url }}">ici</a>.</div>
<div>Si ça ne fonctionne pas, utilisez ce lien : <a href="{{ confirm_url }}">{{ confirm_url }}</a></div>
<div>À bientôt sur Planète Casio!</div>
<div>L'équipe du site.</div>
</body>
</html>

View File

@ -0,0 +1,9 @@
Bonjour {{ name }},
Bienvenue sur Planète Casio!
Afin de pouvoir vous connecter et utiliser nos services, merci de confirmer cette adresse email en cliquant sur ce lien : {{ confirm_url }}
À bientôt sur Planète Casio!
L'équipe du site.

View File

@ -0,0 +1,16 @@
<html>
<body>
<div>Bonjour {{ name }},</div>
<div>Quelqu'un (probablement vous) a demandé a réinitialiser le mot de passe de ce compte.</div>
<div>Cliquez <a href="{{ confirm_url }}">ici</a> pour changer le mot de passe.</div>
<div>Si ça ne fonctionne pas, utilisez ce lien : <a href="{{ confirm_url }}">{{ reset_url }}</a></div>
<div>Si vous n'êtes pas à l'origine de cette opération, vous pouvez ignorer ce message.</div>
<div>À bientôt sur Planète Casio!</div>
<div>L'équipe du site.</div>
</body>
</html>

View File

@ -0,0 +1,11 @@
Bonjour {{ name }}
Quelqu'un (probablement vous) a demandé a réinitialiser le mot de passe de ce compte.
Cliquez sur ce lien pour changer votre mot de passe : {{ reset_url }}
Si vous n'êtes pas à l'origine de cette opération, vous pouvez ignorer ce message.
À bientôt sur Planète Casio!
L'équipe du site.

View File

@ -1,6 +1,6 @@
{% extends "base/base.html" %}
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/member.html" as widget_member %}
{% import "widgets/user.html" as widget_user %}
{% import "widgets/pagination.html" as widget_pagination with context %}
{% block title %}
@ -11,11 +11,7 @@
<section>
<h1>{{ t.title }}</h1>
<table class="thread"><tr>
{% if t.author.type == "member" %}
<td class="member">{{ widget_member.profile(t.author ) }}</td>
{% else %}
<td class="guest"><em>Invité</em>{{ t.author.name }}</td>
{% endif %}
<td class="author">{{ widget_user.profile(t.author ) }}</td>
<td>{{ t.thread.top_comment.text }}</td>
</tr></table>
@ -25,11 +21,7 @@
{% for c in comments.items %}
<tr id="{{ c.id }}">
{% if c != t.thread.top_comment %}
{% if c.author.type == "member" %}
<td class="member">{{ widget_member.profile(c.author ) }}</td>
{% else %}
<td class="guest"><em>Invité</em>{{ c.author.name }}</td>
{% endif %}
<td class="author">{{ widget_user.profile(c.author) }}</td>
<td>
<div>{% if c.date_created != c.date_modified %}
Posté le {{ c.date_created|date }} (Modifié le {{ c.date_modified|date }})

View File

@ -1,12 +0,0 @@
{% macro profile(member) %}
<div class=profile>
<img class=profile-avatar src="{{ url_for('avatar', filename=member.avatar) }}" alt="Avatar de {{ member.name }}">
<div>
<div class=profile-name><a href="{{ url_for('user', username=member.name) }}">{{ member.name }}</a></div>
<div class=profile-title>Membre</div>
<div class=profile-points>Niveau {{ member.level[0] }} <span>({{ member.xp }})</span></div>
<div class=profile-points-small>N{{ member.level[0] }} <span>({{ member.xp }})</span></div>
<div class=profile-xp><div style='width: {{ member.level[1] }}%;'></div></div>
</div>
</div>
{% endmacro %}

View File

@ -1,12 +1,19 @@
{% macro profile(member) %}
<div class=profile>
<img class=profile-avatar src="{{ url_for('avatar', filename=member.avatar) }}" alt="Avatar de {{ member.name }}">
{% macro profile(user) %}
{% if user.type == "member" %}
<div class="profile">
<img class="profile-avatar" src="{{ url_for('avatar', filename=user.avatar) }}" alt="Avatar de {{ user.name }}">
<div>
<div class=profile-name>{{ member.name }}</div>
<div class=profile-title>Membre</div>
<div class=profile-points>Niveau {{ member.level[0] }} <span>({{ member.xp }})</span></div>
<div class=profile-points-small>N{{ member.level[0] }} <span>({{ member.xp }})</span></div>
<div class=profile-xp><div style='width: {{ member.level[1] }}%;'></div></div>
<div class="profile-name">{{ user.name }}</div>
<div class="profile-title">Membre</div>
<div class="profile-points">Niveau {{ user.level[0] }} <span>({{ user.xp }})</span></div>
<div class="profile-points-small">N{{ user.level[0] }} <span>({{ user.xp }})</span></div>
<div class="profile-xp"><div style='width: {{ user.level[1] }}%;'></div></div>
</div>
</div>
</div>
{% else %}
<div class="profile guest">
<em>{{ user.name }}</em>
<span>Invité</span>
</div>
{% endif %}
{% endmacro %}

View File

@ -36,3 +36,15 @@ def priv_required(*perms):
return func(*args, **kwargs)
return wrapped
return decorated_view
def guest_only(func):
"""
Opposite decorator of @login_required
"""
@wraps(func)
def wrapped(*args, **kwargs):
if current_user.is_authenticated:
abort(404)
return func(*args, **kwargs)
return wrapped

39
app/utils/send_mail.py Normal file
View File

@ -0,0 +1,39 @@
from flask import url_for, render_template
from flask_mail import Message
from app import app, mail
from itsdangerous import URLSafeTimedSerializer
from config import V5Config
def send_mail(dest, subject, html="", body=""):
m = Message(recipients=[dest], subject=subject, html=html, body=body)
mail.send(m)
def send_reset_password_mail(name, email):
ts = URLSafeTimedSerializer(app.config["SECRET_KEY"])
token = ts.dumps(email, salt='email-confirm-key')
reset_url = f"https://{V5Config.DOMAIN}" \
+ url_for('reset_password', token=token)
subject = "Confirmez votre email pour compléter l'inscription"
html = render_template('email/reset_password.html', reset_url=reset_url,
name=name)
body = render_template('email/reset_password.md', reset_url=reset_url,
name=name)
if V5Config.SEND_MAILS:
send_mail(member.email, subject, html=html, body=body)
else:
print(f"Reset password url: {reset_url}")
def send_validation_mail(name, email):
ts = URLSafeTimedSerializer(app.config["SECRET_KEY"])
token = ts.dumps(email, salt='email-confirm-key')
confirm_url = f"https://{V5Config.DOMAIN}" \
+ url_for('activate_account', token=token)
subject = "Confirmez votre email pour compléter l'inscription"
html = render_template('email/activate.html', confirm_url=confirm_url,
name=name)
body = render_template('email/activate.md', confirm_url=confirm_url,
name=name)
if V5Config.SEND_MAILS:
send_mail(member.email, subject, html=html, body=body)
else:
print(f"Email confirmation url: {confirm_url}")

View File

@ -16,9 +16,14 @@ class Config(object):
+ LocalConfig.DB_NAME
SQLALCHEMY_TRACK_MODIFICATIONS = False
MAIL_DEFAULT_SENDER = "noreply@v5.planet-casio.com"
MAIL_SUPPRESS_SEND = None
class DefaultConfig(object):
"""Every value here can be overrided in the local_config.py class"""
# Domain
DOMAIN = "v5.planet-casio.com"
# Length allocated to privilege names (slugs)
PRIVS_MAXLEN = 64
# Forbidden user names
@ -57,6 +62,10 @@ class DefaultConfig(object):
AVATARS_FOLDER = '/avatar/folder/'
# Enable guest post
ENABLE_GUEST_POST = True
# Disable email confimation
ENABLE_EMAIL_CONFIRMATION = True
# Send emails
SEND_MAILS = True
class V5Config(LocalConfig, DefaultConfig):
# Values put here cannot be overidden with local_config

View File

@ -0,0 +1,28 @@
"""Added email_validated property in model `Member`
Revision ID: acf72cf31eea
Revises: 6fd4c15b8a7b
Create Date: 2020-07-21 20:29:44.876704
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'acf72cf31eea'
down_revision = '6fd4c15b8a7b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('member', sa.Column('email_confirmed', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('member', 'email_confirmed')
# ### end Alembic commands ###