diff --git a/.gitignore b/.gitignore index 93d2f8b..b7d5fc6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,12 @@ app/__pycache__/ app/static/avatars/ app/static/images/trophies/ -## Devlopement files +# Development files + +# Flask env +.env +.flaskenv # virtualenv requirements.txt venv/ @@ -14,6 +18,7 @@ venv/ Pipfile Pipfile.lock + ## Deployment files # uWSGI configuration file @@ -25,10 +30,12 @@ update.sh # Config to set up some server specific config local_config.py + ## Wiki wiki/ + ## Personal folder exclude/ diff --git a/V5.py b/V5.py index f395e0e..5745db6 100644 --- a/V5.py +++ b/V5.py @@ -1,6 +1,4 @@ -from app import app, db -from app.models.users import User, Guest, Member, Group, GroupPrivilege -from app.models.topic import Topic +from app import app @app.shell_context_processor diff --git a/app/forms/account.py b/app/forms/account.py index 3115ddb..e9bee41 100644 --- a/app/forms/account.py +++ b/app/forms/account.py @@ -1,9 +1,8 @@ from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, TextAreaField, SubmitField, DecimalField, SelectField from wtforms.fields.html5 import DateField, EmailField -from wtforms.validators import DataRequired, InputRequired, Optional, Email, EqualTo +from wtforms.validators import InputRequired, Optional, Email, EqualTo from flask_wtf.file import FileField # Cuz' wtforms' FileField is shitty -from app.models.trophies import Trophy import app.utils.validators as vd @@ -12,15 +11,15 @@ class RegistrationForm(FlaskForm): 'Pseudonyme', description='Ce nom est définitif !', validators=[ - DataRequired(), - vd.name_valid, - vd.name_available, + InputRequired(), + vd.name.valid, + vd.name.available, ], ) email = EmailField( 'Adresse Email', validators=[ - DataRequired(), + InputRequired(), Email(message="Adresse email invalide."), vd.email, ], @@ -28,21 +27,21 @@ class RegistrationForm(FlaskForm): password = PasswordField( 'Mot de passe', validators=[ - DataRequired(), - vd.password, + InputRequired(), + vd.password.is_strong, ], ) password2 = PasswordField( 'Répéter le mot de passe', validators=[ - DataRequired(), + InputRequired(), EqualTo('password', message="Les mots de passe doivent être identiques."), ], ) guidelines = BooleanField( """J'accepte les CGU""", validators=[ - DataRequired(), + InputRequired(), ], ) newsletter = BooleanField( @@ -59,7 +58,8 @@ class UpdateAccountForm(FlaskForm): 'Avatar', validators=[ Optional(), - vd.avatar, + vd.file.is_image, + vd.file.avatar_size, ], ) email = EmailField( @@ -68,15 +68,15 @@ class UpdateAccountForm(FlaskForm): Optional(), Email(message="Addresse email invalide."), vd.email, - vd.old_password, + vd.password.old_password, ], ) password = PasswordField( 'Nouveau mot de passe', validators=[ Optional(), - vd.password, - vd.old_password, + vd.password.is_strong, + vd.password.old_password, ], ) password2 = PasswordField( @@ -129,15 +129,15 @@ class DeleteAccountForm(FlaskForm): delete = BooleanField( 'Confirmer la suppression', validators=[ - DataRequired(), + InputRequired(), ], description='Attention, cette opération est irréversible !' ) old_password = PasswordField( 'Mot de passe', validators=[ - DataRequired(), - vd.old_password, + InputRequired(), + vd.password.old_password, ], ) submit = SubmitField( @@ -161,7 +161,7 @@ class ResetPasswordForm(FlaskForm): 'Mot de passe', validators=[ Optional(), - vd.password, + vd.password.is_strong, ], ) password2 = PasswordField( @@ -179,14 +179,16 @@ class AdminUpdateAccountForm(FlaskForm): 'Pseudonyme', validators=[ Optional(), - vd.name_valid, + vd.name.valid, + vd.name.available, ], ) avatar = FileField( 'Avatar', validators=[ Optional(), - vd.avatar, + vd.file.is_image, + vd.file.avatar_size, ], ) email = EmailField( @@ -199,7 +201,7 @@ class AdminUpdateAccountForm(FlaskForm): ) email_confirmed = BooleanField( "Confirmer l'email", - description="Si décoché, l'utilisateur devra demander explicitement un email "\ + description="Si décoché, l'utilisateur devra demander explicitement un email " "de validation, ou faire valider son adresse email par un administrateur.", ) password = PasswordField( @@ -207,7 +209,7 @@ class AdminUpdateAccountForm(FlaskForm): description="L'ancien mot de passe ne pourra pas être récupéré !", validators=[ Optional(), - vd.password, + vd.password.is_strong, ], ) xp = DecimalField( @@ -269,7 +271,7 @@ class AdminDeleteAccountForm(FlaskForm): delete = BooleanField( 'Confirmer la suppression', validators=[ - DataRequired(), + InputRequired(), ], description='Attention, cette opération est irréversible !', ) diff --git a/app/forms/forum.py b/app/forms/forum.py index c9ab038..4cf4d2d 100644 --- a/app/forms/forum.py +++ b/app/forms/forum.py @@ -1,32 +1,35 @@ from flask_wtf import FlaskForm -from wtforms import StringField, FormField, SubmitField, TextAreaField, \ - MultipleFileField -from wtforms.validators import DataRequired, Length, Optional +from wtforms import StringField, SubmitField, TextAreaField, MultipleFileField +from wtforms.validators import InputRequired, Length import app.utils.validators as vd class CommentForm(FlaskForm): - message = TextAreaField('Message', validators=[DataRequired()]) + message = TextAreaField('Message', validators=[InputRequired()]) attachments = MultipleFileField('Pièces-jointes', validators=[vd.file.optional, vd.file.count, vd.file.extension, vd.file.size, vd.file.namelength]) submit = SubmitField('Commenter') preview = SubmitField('Prévisualiser') + class AnonymousCommentForm(CommentForm): pseudo = StringField('Pseudo', - validators=[DataRequired(), vd.name_valid, vd.name_available]) + validators=[InputRequired(), vd.name.valid, vd.name.available]) + class CommentEditForm(CommentForm): submit = SubmitField('Modifier') + class AnonymousCommentEditForm(CommentEditForm, AnonymousCommentForm): pass class TopicCreationForm(CommentForm): title = StringField('Nom du sujet', - validators=[DataRequired(), Length(min=3, max=32)]) + validators=[InputRequired(), Length(min=3, max=32)]) submit = SubmitField('Créer le sujet') + class AnonymousTopicCreationForm(TopicCreationForm, AnonymousCommentForm): pass diff --git a/app/forms/login.py b/app/forms/login.py index 72bec53..498a319 100644 --- a/app/forms/login.py +++ b/app/forms/login.py @@ -1,19 +1,19 @@ from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField -from wtforms.validators import DataRequired +from wtforms.validators import InputRequired class LoginForm(FlaskForm): username = StringField( 'Identifiant', validators=[ - DataRequired(), + InputRequired(), ], ) password = PasswordField( 'Mot de passe', validators=[ - DataRequired(), + InputRequired(), ], ) remember_me = BooleanField( diff --git a/app/forms/search.py b/app/forms/search.py index 6ebac62..5ab431f 100644 --- a/app/forms/search.py +++ b/app/forms/search.py @@ -1,7 +1,7 @@ from flask_wtf import FlaskForm from wtforms import StringField, SubmitField from wtforms.fields.html5 import DateField -from wtforms.validators import DataRequired, Optional +from wtforms.validators import InputRequired, Optional # TODO: compléter le formulaire de recherche avancée @@ -9,7 +9,7 @@ class AdvancedSearchForm(FlaskForm): q = StringField( 'Rechercher :', validators=[ - DataRequired(), + InputRequired(), ], ) date = DateField( @@ -27,6 +27,6 @@ class SearchForm(FlaskForm): q = StringField( 'Rechercher', validators=[ - DataRequired(), + InputRequired(), ], ) diff --git a/app/forms/trophies.py b/app/forms/trophy.py similarity index 91% rename from app/forms/trophies.py rename to app/forms/trophy.py index 62825c9..4a77b02 100644 --- a/app/forms/trophies.py +++ b/app/forms/trophy.py @@ -1,6 +1,6 @@ from flask_wtf import FlaskForm from wtforms import StringField, SubmitField, BooleanField -from wtforms.validators import DataRequired, Optional +from wtforms.validators import InputRequired, Optional from flask_wtf.file import FileField # Cuz' wtforms' FileField is shitty @@ -8,7 +8,7 @@ class TrophyForm(FlaskForm): name = StringField( 'Nom', validators=[ - DataRequired(), + InputRequired(), ], ) icon = FileField( @@ -43,7 +43,7 @@ class DeleteTrophyForm(FlaskForm): delete = BooleanField( 'Confirmer la suppression', validators=[ - DataRequired(), + InputRequired(), ], description='Attention, cette opération est irréversible !', ) diff --git a/app/models/forum.py b/app/models/forum.py index 48aaffc..d661b4b 100644 --- a/app/models/forum.py +++ b/app/models/forum.py @@ -16,8 +16,8 @@ class Forum(db.Model): # Relationships parent_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=True) - parent = db.relationship('Forum', backref='sub_forums', remote_side=id, - lazy=True, foreign_keys=parent_id) + parent = db.relationship('Forum', backref='sub_forums', remote_side=id, + lazy=True, foreign_keys=parent_id) # Other fields populated automatically through relations: # List of topics in this exact forum (of type Topic) diff --git a/app/models/post.py b/app/models/post.py index 88daabb..0f6dc2c 100644 --- a/app/models/post.py +++ b/app/models/post.py @@ -1,5 +1,4 @@ from app import db -from app.models.users import User from datetime import datetime diff --git a/app/models/privs.py b/app/models/priv.py similarity index 97% rename from app/models/privs.py rename to app/models/priv.py index 692b3ae..ff96ae4 100644 --- a/app/models/privs.py +++ b/app/models/priv.py @@ -1,5 +1,5 @@ # Planète Casio v5 -# models.privs: Database models for groups and privilege management +# models.priv: Database models for groups and privilege management from app import db diff --git a/app/models/program.py b/app/models/program.py index ef762f2..8ce6095 100644 --- a/app/models/program.py +++ b/app/models/program.py @@ -16,8 +16,8 @@ class Program(Post): # TODO: Compatible calculator models thread_id = db.Column(db.Integer,db.ForeignKey('thread.id'),nullable=False) - thread = db.relationship('Thread', foreign_keys=thread_id, - back_populates='owner_program') + thread = db.relationship('Thread', foreign_keys=thread_id, + back_populates='owner_program') # TODO: Number of views, statistics, attached files, etc diff --git a/app/models/thread.py b/app/models/thread.py index 6b62341..e623fee 100644 --- a/app/models/thread.py +++ b/app/models/thread.py @@ -10,11 +10,11 @@ class Thread(db.Model): # Top comment top_comment_id = db.Column(db.Integer, db.ForeignKey('comment.id')) - top_comment = db.relationship('Comment', foreign_keys=top_comment_id) + top_comment = db.relationship('Comment', foreign_keys=top_comment_id) # Post owning the thread, set only by Topic, Program, etc. In general, you # should use [owner_post] which groups them together. - owner_topic = db.relationship('Topic') + owner_topic = db.relationship('Topic') owner_program = db.relationship('Program') # Other fields populated automatically through relations: diff --git a/app/models/topic.py b/app/models/topic.py index 4afe5db..1dcd8a8 100644 --- a/app/models/topic.py +++ b/app/models/topic.py @@ -16,19 +16,19 @@ class Topic(Post): # Post that the topic was promoted into. If this is not None, then the # topic was published into a project and a redirection should be emitted promotion_id = db.Column(db.Integer,db.ForeignKey('post.id'),nullable=True) - promotion = db.relationship('Post', foreign_keys=promotion_id) + promotion = db.relationship('Post', foreign_keys=promotion_id) # Topic title title = db.Column(db.Unicode(128)) # Parent forum forum_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=False) - forum = db.relationship('Forum', - backref=backref('topics', lazy='dynamic'),foreign_keys=forum_id) + forum = db.relationship('Forum', + backref=backref('topics', lazy='dynamic'), foreign_keys=forum_id) # Associated thread thread_id = db.Column(db.Integer,db.ForeignKey('thread.id'),nullable=False) - thread = db.relationship('Thread', foreign_keys=thread_id, + thread = db.relationship('Thread', foreign_keys=thread_id, back_populates='owner_topic') # Number of views in the forum diff --git a/app/models/trophies.py b/app/models/trophy.py similarity index 100% rename from app/models/trophies.py rename to app/models/trophy.py diff --git a/app/models/users.py b/app/models/user.py similarity index 96% rename from app/models/users.py rename to app/models/user.py index 63493c2..13c8ee2 100644 --- a/app/models/users.py +++ b/app/models/user.py @@ -1,21 +1,18 @@ from datetime import date -from flask import flash from flask_login import UserMixin from sqlalchemy import func as SQLfunc from os.path import isfile from PIL import Image from app import app, db -from app.models.privs import SpecialPrivilege, Group, GroupMember, \ +from app.models.priv import SpecialPrivilege, Group, GroupMember, \ GroupPrivilege -from app.models.trophies import Trophy, TrophyMember, Title +from app.models.trophy import Trophy, TrophyMember, Title from app.models.notification import Notification import app.utils.unicode_names as unicode_names -from app.utils.notify import notify import app.utils.ldap as ldap from config import V5Config import werkzeug.security -import re import math import app import os @@ -78,8 +75,7 @@ class Member(User): # Primary attributes (needed for the system to work) name = db.Column(db.Unicode(User.NAME_MAXLEN), index=True) - norm = db.Column(db.Unicode(User.NAME_MAXLEN), index=True, - unique=True) + norm = db.Column(db.Unicode(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)) @@ -107,7 +103,7 @@ class Member(User): # Displayed title, if set title_id = db.Column(db.Integer, db.ForeignKey('title.id'), nullable=True) - title = db.relationship('Title', foreign_keys=title_id) + title = db.relationship('Title', foreign_keys=title_id) # Settings newsletter = db.Column(db.Boolean, default=False) @@ -116,6 +112,7 @@ class Member(User): trophies = db.relationship('Trophy', secondary=TrophyMember, back_populates='owners') topics = db.relationship('Topic') + programs = db.relationship('Program') comments = db.relationship('Comment') # Displayed title @@ -158,7 +155,7 @@ class Member(User): if SpecialPrivilege.query.filter_by(mid=self.id, priv=priv).first(): return True return db.session.query(Group, GroupPrivilege).filter( - Group.id.in_([ g.id for g in self.groups ]), + Group.id.in_([g.id for g in self.groups]), GroupPrivilege.gid==Group.id, GroupPrivilege.priv==priv).first() is not None @@ -377,8 +374,7 @@ class Member(User): progress(levels, post_count) if context in ["new-program", None]: - # TODO: Amount of programs by the user - program_count = 0 + program_count = self.programs.count() levels = { 5: "Programmeur du dimanche", diff --git a/app/processors/menu.py b/app/processors/menu.py index eae5737..d7b2007 100644 --- a/app/processors/menu.py +++ b/app/processors/menu.py @@ -3,9 +3,7 @@ from app.forms.login import LoginForm from app.forms.search import SearchForm from app.models.forum import Forum from app.models.topic import Topic -from app.models.thread import Thread -from app.models.comment import Comment -from app.models.users import Member + @app.context_processor def menu_processor(): diff --git a/app/processors/utilities.py b/app/processors/utilities.py index ad6dbde..7200fb8 100644 --- a/app/processors/utilities.py +++ b/app/processors/utilities.py @@ -9,7 +9,7 @@ def utilities_processor(): return dict( len=len, # enumerate=enumerate, - _url_for = lambda route, args, **other: url_for(route, **args, **other), - V5Config = V5Config, + _url_for=lambda route, args, **other: url_for(route, **args, **other), + V5Config=V5Config, slugify=slugify, ) diff --git a/app/routes/account/account.py b/app/routes/account/account.py index 18fad37..532c472 100644 --- a/app/routes/account/account.py +++ b/app/routes/account/account.py @@ -3,8 +3,8 @@ from flask_login import login_required, current_user, logout_user from app import app, db from app.forms.account import UpdateAccountForm, RegistrationForm, \ DeleteAccountForm, AskResetPasswordForm, ResetPasswordForm -from app.models.users import Member -from app.models.trophies import Title +from app.models.user import Member +from app.models.trophy import Title 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 diff --git a/app/routes/account/login.py b/app/routes/account/login.py index 2c4238e..058219f 100644 --- a/app/routes/account/login.py +++ b/app/routes/account/login.py @@ -3,8 +3,8 @@ from flask_login import login_user, logout_user, login_required, current_user from urllib.parse import urlparse, urljoin from app import app from app.forms.login import LoginForm -from app.models.users import Member -from app.models.privs import Group +from app.models.user import Member +from app.models.priv import Group from app.utils.render import render from app.utils.send_mail import send_validation_mail import datetime diff --git a/app/routes/admin/account.py b/app/routes/admin/account.py index 1abd8c0..1d4c04c 100644 --- a/app/routes/admin/account.py +++ b/app/routes/admin/account.py @@ -2,9 +2,9 @@ from flask import flash, redirect, url_for, request from flask_login import current_user from wtforms import BooleanField from app.utils.priv_required import priv_required -from app.models.users import Member -from app.models.trophies import Trophy, Title -from app.models.privs import Group +from app.models.user import Member +from app.models.trophy import Trophy, Title +from app.models.priv import Group from app.forms.account import AdminUpdateAccountForm, AdminDeleteAccountForm, \ AdminAccountEditTrophyForm, AdminAccountEditGroupForm from app.utils.render import render diff --git a/app/routes/admin/groups.py b/app/routes/admin/groups.py index 35a03cb..7e667cd 100644 --- a/app/routes/admin/groups.py +++ b/app/routes/admin/groups.py @@ -1,8 +1,8 @@ from app.utils.priv_required import priv_required from flask_wtf import FlaskForm from wtforms import SubmitField -from app.models.users import Member, Group, GroupPrivilege -from app.models.privs import SpecialPrivilege +from app.models.user import Member, Group, GroupPrivilege +from app.models.priv import SpecialPrivilege from app.utils.render import render from app import app, db import yaml diff --git a/app/routes/admin/trophies.py b/app/routes/admin/trophies.py index 5eb59d0..80265bc 100644 --- a/app/routes/admin/trophies.py +++ b/app/routes/admin/trophies.py @@ -1,7 +1,7 @@ from flask import request, flash, redirect, url_for from app.utils.priv_required import priv_required -from app.models.trophies import Trophy, Title -from app.forms.trophies import TrophyForm, DeleteTrophyForm +from app.models.trophy import Trophy, Title +from app.forms.trophy import TrophyForm, DeleteTrophyForm from app.utils.render import render from app import app, db diff --git a/app/routes/development.py b/app/routes/development.py index 0cbad14..f4f4cc0 100644 --- a/app/routes/development.py +++ b/app/routes/development.py @@ -9,7 +9,7 @@ import os @app.route('/avatar/') def avatar(filename): - filename = secure_filename(filename) # No h4ckers allowed + filename = secure_filename(filename) # No h4ckers allowed filepath = os.path.join(V5Config.DATA_FOLDER, "avatars", filename) if os.path.isfile(filepath): return send_file(filepath) diff --git a/app/routes/forum/index.py b/app/routes/forum/index.py index 892ad90..aa0d209 100644 --- a/app/routes/forum/index.py +++ b/app/routes/forum/index.py @@ -9,7 +9,7 @@ from app.models.forum import Forum from app.models.topic import Topic from app.models.thread import Thread from app.models.comment import Comment -from app.models.users import Guest +from app.models.user import Guest from app.models.attachment import Attachment @@ -72,7 +72,7 @@ def forum_page(f, page=1): # Update member's xp and trophies if current_user.is_authenticated: - current_user.add_xp(2) # 2 points for a topic + current_user.add_xp(2) # 2 points for a topic current_user.update_trophies('new-post') flash('Le sujet a bien été créé', 'ok') diff --git a/app/routes/forum/topic.py b/app/routes/forum/topic.py index f3ab5ec..173e066 100644 --- a/app/routes/forum/topic.py +++ b/app/routes/forum/topic.py @@ -9,7 +9,7 @@ from app.models.forum import Forum from app.models.topic import Topic from app.models.thread import Thread from app.models.comment import Comment -from app.models.users import Guest +from app.models.user import Guest from app.models.attachment import Attachment @@ -53,7 +53,7 @@ def forum_topic(f, page): # Update member's xp and trophies if current_user.is_authenticated: - current_user.add_xp(1) # 1 point for a comment + current_user.add_xp(1) # 1 point for a comment current_user.update_trophies('new-post') flash('Message envoyé', 'ok') diff --git a/app/routes/users.py b/app/routes/users.py index 7172654..3cb4779 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -2,8 +2,8 @@ from flask import redirect, url_for, send_file from werkzeug.utils import secure_filename import os.path from app import app -from app.models.users import Member -from app.models.trophies import Trophy +from app.models.user import Member +from app.models.trophy import Trophy from app.utils import unicode_names from app.utils.render import render from config import V5Config diff --git a/app/utils/converters.py b/app/utils/converters.py index 7fb7a81..3caae7b 100644 --- a/app/utils/converters.py +++ b/app/utils/converters.py @@ -20,8 +20,7 @@ from werkzeug.routing import BaseConverter, ValidationError from app.models.forum import Forum from app.models.topic import Topic from slugify import slugify -import re -import sys + class ForumConverter(BaseConverter): diff --git a/app/utils/filesize.py b/app/utils/filesize.py index 56e900e..888afff 100644 --- a/app/utils/filesize.py +++ b/app/utils/filesize.py @@ -1,5 +1,5 @@ import os -from random import getrandbits + def filesize(file): """Return the filesize. Save in /tmp and delete it when done""" diff --git a/app/utils/filters/is_title.py b/app/utils/filters/is_title.py index bee2c42..15e3c39 100644 --- a/app/utils/filters/is_title.py +++ b/app/utils/filters/is_title.py @@ -1,5 +1,5 @@ from app import app -from app.models.trophies import Title +from app.models.trophy import Title @app.template_filter('is_title') diff --git a/app/utils/ldap.py b/app/utils/ldap.py index c3d21fb..78eaddb 100644 --- a/app/utils/ldap.py +++ b/app/utils/ldap.py @@ -1,13 +1,15 @@ import ldap from ldap.modlist import addModlist, modifyModlist +from app.utils.unicode_names import normalize from config import V5Config def get_member(username): - """ Get informations about member. Username must be normalized! """ + """ Get informations about member""" + username = normalize(username) # Never safe enough conn = ldap.initialize("ldap://localhost") # Search for user - r = conn.search_s(V5Config.LDAP_ORGANIZATION, ldap.SCOPE_SUBTREE, - f'(cn={username})') + r = conn.search_s(f"{V5Config.LDAP_ENV},{V5Config.LDAP_ROOT}", + ldap.SCOPE_SUBTREE, f'(cn={username})') if len(r) > 0: return r[0] else: @@ -17,13 +19,15 @@ def get_member(username): def edit(user, fields): """ Edit a user. Fields is {'name': ['value'], …} """ conn = ldap.initialize("ldap://localhost") + # TODO: do this # Connect as root - # conn.simple_bind_s(f'cn=ldap-root,{LDAP_ORGANIZATION}', LDAP_PASSWORD) + # conn.simple_bind_s(f'cn=ldap-root,{V5Config.LDAP_ENV}', + # V5Config.LDAP_PASSWORD) # old_value = {"userPassword": ["my_old_password"]} # new_value = {"userPassword": ["my_new_password"]} - modlist = ldap.modlist.modifyModlist(old_value, new_value) - con.modify_s(dn, modlist) + # modlist = modifyModlist(old_value, new_value) + # conn.modify_s(dn, modlist) def set_email(user, email): @@ -34,9 +38,9 @@ def set_password(user, password): """ Set password for a user. """ conn = ldap.initialize("ldap://localhost") # Connect as root - conn.simple_bind_s(f'cn=ldap-root,{V5Config.LDAP_ORGANIZATION}', + conn.simple_bind_s(f'cn=ldap-root,{V5Config.LDAP_ROOT}', V5Config.LDAP_PASSWORD) - conn.passwd_s(f"cn={user.norm},{V5Config.LDAP_ORGANIZATION}", + conn.passwd_s(f"cn={user.norm},{V5Config.LDAP_ENV},{V5Config.LDAP_ROOT}", None, password) @@ -44,8 +48,8 @@ def check_password(user, password): """ Try to login a user through LDAP register. """ conn = ldap.initialize("ldap://localhost") try: - conn.simple_bind_s(f"cn={user.norm},{V5Config.LDAP_ORGANIZATION}", - password) + conn.simple_bind_s(f"cn={user.norm},{V5Config.LDAP_ENV}," \ + f"{V5Config.LDAP_ROOT}", password) except ldap.INVALID_CREDENTIALS: return False return True @@ -58,10 +62,10 @@ def add_member(member): return conn = ldap.initialize("ldap://localhost") # Connect as root - conn.simple_bind_s(f'cn=ldap-root,{V5Config.LDAP_ORGANIZATION}', + conn.simple_bind_s(f'cn=ldap-root,{V5Config.LDAP_ROOT}', V5Config.LDAP_PASSWORD) # Create fields - dn = f'cn={member.norm},{V5Config.LDAP_ORGANIZATION}' + dn = f'cn={member.norm},{V5Config.LDAP_ENV},{V5Config.LDAP_ROOT}' modlist = addModlist({ 'objectClass': [bytes('inetOrgPerson', 'UTF8')], 'cn': [bytes(member.norm, 'UTF8')], @@ -79,9 +83,9 @@ def delete_member(member): """ Remove a member from LDAP register """ conn = ldap.initialize("ldap://localhost") # Connect as root - conn.simple_bind_s(f'cn=ldap-root,{V5Config.LDAP_ORGANIZATION}', + conn.simple_bind_s(f'cn=ldap-root,{V5Config.LDAP_ROOT}', V5Config.LDAP_PASSWORD) # Create fields - dn = f'cn={member.norm},{V5Config.LDAP_ORGANIZATION}' + dn = f'cn={member.norm},{V5Config.LDAP_ENV},{V5Config.LDAP_ROOT}' # Delete the user conn.delete_s(dn) diff --git a/app/utils/notify.py b/app/utils/notify.py index 2a98dc8..245885f 100644 --- a/app/utils/notify.py +++ b/app/utils/notify.py @@ -1,6 +1,6 @@ from app import db from app.models.notification import Notification -# from app.models.users import Member +from app.models.user import Member def notify(user, message, href=None): """ Notify a user (by id, name or object reference) with a message. diff --git a/app/utils/render.py b/app/utils/render.py index f2f9f75..262944f 100644 --- a/app/utils/render.py +++ b/app/utils/render.py @@ -1,7 +1,4 @@ from flask import render_template -from app.forms.login import LoginForm -from app.forms.search import SearchForm -from app.models.forum import Forum def render(*args, styles=[], scripts=[], **kwargs): # TODO: debugguer cette merde : au logout, ça foire diff --git a/app/utils/valid_name.py b/app/utils/valid_name.py index 158cc40..5b94228 100644 --- a/app/utils/valid_name.py +++ b/app/utils/valid_name.py @@ -1,5 +1,5 @@ from app.utils.unicode_names import normalize -from app.models.users import User +from app.models.user import User import re def valid_name(name, msg=False): diff --git a/app/utils/validators/__init__.py b/app/utils/validators/__init__.py index b93b9e6..08899e4 100644 --- a/app/utils/validators/__init__.py +++ b/app/utils/validators/__init__.py @@ -1,104 +1,19 @@ from flask_login import current_user from wtforms.validators import ValidationError -from PIL import Image -from app.models.users import Member, User -from app.models.trophies import Title -from app.utils.valid_name import valid_name -from app.utils.unicode_names import normalize -from math import log +from app.models.user import Member +from app.models.trophy import Title from werkzeug.exceptions import NotFound -import app.utils.ldap as ldap - -from config import V5Config from app.utils.validators.file import * +from app.utils.validators.name import * +from app.utils.validators.password import * -# TODO: clean this shit into split files - -def name_valid(form, name): - valid = valid_name(name.data) - default = "Nom d'utilisateur invalide (erreur interne)" - msg = { - "too-short": - "Le nom d'utilisateur doit faire au moins " - f"{User.NAME_MINLEN} caractères.", - "too-long": - "Le nom d'utilisateur doit faire au plus " - f"{User.NAME_MAXLEN} caractères.", - "cant-normalize": - "Ce nom d'utilisateur contient des caractères interdits. Les " - "caractères autorisés sont les lettres, lettres accentuées, " - 'chiffres ainsi que "-" (tiret), "." (point), "~" (tilde) et ' - '"_" (underscore).', - "no-letter": - "Le nom d'utilisateur doit contenir au moins une lettre.", - "forbidden": - "Ce nom d'utilisateur est interdit." - } - if valid is not True: - err = ' '.join(msg.get(code, default) for code in valid) - raise ValidationError(err) - -def name_available(form, name): - # If the name is invalid, name_valid() will return a meaningful message - try: - norm = normalize(name.data) - except ValueError: - return - - member = Member.query.filter_by(norm=norm).first() - if member is not None: - raise ValidationError("Ce nom d'utilisateur est indisponible.") - - # Double check with LDAP if needed - if V5Config.USE_LDAP: - member = ldap.get_member(norm) - if member is not None: - raise ValidationError("Ce nom d'utilisateur est indisponible.") def email(form, email): member = Member.query.filter_by(email=email.data).first() if member is not None: raise ValidationError('Adresse email déjà utilisée.') -def password(form, password): - # To avoid errors in forms where password is optionnal - if len(password.data) == 0: - return - - def entropy(password): - """Estimate entropy of a password, in bits""" - # If you edit this function, please edit accordingly the JS one - # in static/script/entropy.js - chars = [ - "abcdefghijklmnopqrstuvwxyz", - "ABCDFEGHIJKLMNOPQRSTUVWXYZ", - "0123456789", - " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~§", # OWASP special chars - "áàâéèêíìîóòôúùûç", - ] - used = set() - for c in password: - for i in chars: - if c in i: - used.add(i) - return log(len(''.join(used)) ** len(password), 2) - - if entropy(password.data) < 60: - raise ValidationError("Mot de passe pas assez complexe") - -def avatar(form, avatar): - try: - Image.open(avatar.data) - except IOError: - raise ValidationError("Avatar invalide") - -def old_password(form, field): - if field.data: - if not form.old_password.data: - raise ValidationError('Votre ancien mot de passe est requis pour cette modification.') - if not current_user.check_password(form.old_password.data): - raise ValidationError('Mot de passe actuel erroné.') def id_exists(object): """Check if an id exists in a table""" @@ -112,10 +27,12 @@ def id_exists(object): raise ValidationError('L\'id n\'existe pas dans la BDD') return _id_exists + def css(form, css): """Check if input is valid and sane CSS""" pass + def own_title(form, title): # Everyone can use "Member" if title.data == -1: diff --git a/app/utils/validators/file.py b/app/utils/validators/file.py index fd1f743..7c2da7b 100644 --- a/app/utils/validators/file.py +++ b/app/utils/validators/file.py @@ -2,29 +2,33 @@ from flask_login import current_user from wtforms.validators import ValidationError, StopValidation from werkzeug.utils import secure_filename from app.utils.filesize import filesize +from PIL import Image import re + def optional(form, files): if(len(files.data) == 0 or files.data[0].filename == ""): raise StopValidation() + def count(form, files): if current_user.is_authenticated: if current_user.priv("no-upload-limits"): return - if len(files.data) > 100: # 100 files for a authenticated user + if len(files.data) > 100: # 100 files for a authenticated user raise ValidationError("100 fichiers maximum autorisés") else: if len(files.data) > 3: raise ValidationError("3 fichiers maximum autorisés") + def extension(form, files): valid_extensions = [ - "g[123][a-z]|cpa|c1a|fxi|cat|mcs|xcp|fls", # Casio files - "png|jpg|jpeg|bmp|tiff|gif|xcf", # Images - "[ch](pp|\+\+|xx)?|s|py|bide|lua|lc", # Source code - "txt|md|tex|pdf|odt|ods|docx|xlsx", # Office files - "zip|7z|tar|bz2?|t?gz|xz|zst", # Archives + "g[123][a-z]|cpa|c1a|fxi|cat|mcs|xcp|fls", # Casio files + "png|jpg|jpeg|bmp|tiff|gif|xcf", # Images + "[ch](pp|\+\+|xx)?|s|py|bide|lua|lc", # Source code + "txt|md|tex|pdf|odt|ods|docx|xlsx", # Office files + "zip|7z|tar|bz2?|t?gz|xz|zst", # Archives ] r = re.compile("|".join(valid_extensions), re.IGNORECASE) errors = [] @@ -36,7 +40,9 @@ def extension(form, files): errors.append("." + ext) if len(errors) > 0: - raise ValidationError(f"Extension(s) invalide(s) ({', '.join(errors)})") + raise ValidationError("Extension(s) invalide(s)" + f"({', '.join(errors)})") + def size(form, files): """There is no global limit to file sizes""" @@ -44,12 +50,13 @@ def size(form, files): if current_user.is_authenticated: if current_user.priv("no-upload-limits"): return - if size > 5e6: # 5 Mo per comment for an authenticated user + if size > 5e6: # 5 Mo per comment for an authenticated user raise ValidationError("Fichiers trop lourds (max 5 Mo)") else: - if size > 500e3: # 500 ko per comment for a guest + if size > 500e3: # 500 ko per comment for a guest raise ValidationError("Fichiers trop lourds (max 500 ko)") + def namelength(form, files): errors = [] for f in files.data: @@ -57,5 +64,17 @@ def namelength(form, files): if len(name) > 64: errors.append(f.filename) if len(errors) > 0: - raise ValidationError(f"Noms trop longs, 64 caractères max " \ + raise ValidationError("Noms trop longs, 64 caractères max " f"({', '.join(errors)})") + + +def is_image(form, avatar): + try: + Image.open(avatar.data) + except IOError: + raise ValidationError("Avatar invalide") + + +def avatar_size(form, file): + if filesize(file.data) > 200e3: + raise ValidationError("Fichier trop lourd (max 200 ko)") diff --git a/app/utils/validators/name.py b/app/utils/validators/name.py new file mode 100644 index 0000000..9ec6b69 --- /dev/null +++ b/app/utils/validators/name.py @@ -0,0 +1,49 @@ +from wtforms.validators import ValidationError +from app.utils.valid_name import valid_name +from app.models.user import User, Member +import app.utils.ldap as ldap +from app.utils.unicode_names import normalize +from config import V5Config + + +def valid(form, name): + valid = valid_name(name.data) + default = "Nom d'utilisateur invalide (erreur interne)" + msg = { + "too-short": + "Le nom d'utilisateur doit faire au moins " + f"{User.NAME_MINLEN} caractères.", + "too-long": + "Le nom d'utilisateur doit faire au plus " + f"{User.NAME_MAXLEN} caractères.", + "cant-normalize": + "Ce nom d'utilisateur contient des caractères interdits. Les " + "caractères autorisés sont les lettres, lettres accentuées, " + 'chiffres ainsi que "-" (tiret), "." (point), "~" (tilde) et ' + '"_" (underscore).', + "no-letter": + "Le nom d'utilisateur doit contenir au moins une lettre.", + "forbidden": + "Ce nom d'utilisateur est interdit." + } + if valid is not True: + err = ' '.join(msg.get(code, default) for code in valid) + raise ValidationError(err) + + +def available(form, name): + # If the name is invalid, name_valid() will return a meaningful message + try: + norm = normalize(name.data) + except ValueError: + return + + member = Member.query.filter_by(norm=norm).first() + if member is not None: + raise ValidationError("Ce nom d'utilisateur est indisponible.") + + # Double check with LDAP if needed + if V5Config.USE_LDAP: + member = ldap.get_member(norm) + if member is not None: + raise ValidationError("Ce nom d'utilisateur est indisponible.") diff --git a/app/utils/validators/password.py b/app/utils/validators/password.py new file mode 100644 index 0000000..7f1b9ab --- /dev/null +++ b/app/utils/validators/password.py @@ -0,0 +1,38 @@ +from wtforms.validators import ValidationError +from flask_login import current_user +from math import log + + +def is_strong(form, password): + # To avoid errors in forms where password is optionnal + if len(password.data) == 0: + return + + def entropy(password): + """Estimate entropy of a password, in bits""" + # If you edit this function, please edit accordingly the JS one + # in static/script/entropy.js + chars = [ + "abcdefghijklmnopqrstuvwxyz", + "ABCDFEGHIJKLMNOPQRSTUVWXYZ", + "0123456789", + " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~§", # OWASP special chars + "áàâéèêíìîóòôúùûç", + ] + used = set() + for c in password: + for i in chars: + if c in i: + used.add(i) + return log(len(''.join(used)) ** len(password), 2) + + if entropy(password.data) < 60: + raise ValidationError("Mot de passe pas assez complexe") + + +def old_password(form, field): + if field.data: + if not form.old_password.data: + raise ValidationError('Votre ancien mot de passe est requis pour cette modification.') + if not current_user.check_password(form.old_password.data): + raise ValidationError('Mot de passe actuel erroné.') diff --git a/config.py b/config.py index ae5df4d..be5e06f 100644 --- a/config.py +++ b/config.py @@ -29,7 +29,8 @@ class DefaultConfig(object): USE_LDAP = False # LDAP configuration LDAP_PASSWORD = "openldap" - LDAP_ORGANIZATION = "o=planet-casio" + LDAP_ROOT = "o=planet-casio" + LDAP_ENV = "o=prod" # Secret key used to authenticate tokens. **USE YOURS!** SECRET_KEY = "a-random-secret-key" # Uploaded data folder diff --git a/local_config.py.default b/local_config.py.default index 7885f5f..22986fe 100644 --- a/local_config.py.default +++ b/local_config.py.default @@ -5,7 +5,7 @@ class LocalConfig(object): DB_NAME = "pcv5" USE_LDAP = True LDAP_PASSWORD = "openldap" - LDAP_ORGANIZATION = "o=planet-casio" + LDAP_ENV = "o=prod" SECRET_KEY = "a-random-secret-key" # CHANGE THIS VALUE *NOW* AVATARS_FOLDER = '/home/pc/data/avatars/' ENABLE_GUEST_POST = True diff --git a/master.py b/master.py index 5e617c8..dd588f0 100755 --- a/master.py +++ b/master.py @@ -1,18 +1,18 @@ #! /usr/bin/python3 from app import app, db -from app.models.users import Member, Group, GroupPrivilege -from app.models.privs import SpecialPrivilege -from app.models.trophies import Trophy, Title, TrophyMember +from app.models.user import Member, Group, GroupPrivilege +from app.models.priv import SpecialPrivilege +from app.models.trophy import Trophy, Title from app.models.forum import Forum from app.utils import unicode_names import os import sys import yaml -import readline import slugify from PIL import Image + help_msg = """ This is the Planète Casio master shell. Type 'exit' or C-D to leave. @@ -186,7 +186,7 @@ def create_trophies(): print(f"Created {len(tr)} trophies.") # Create their icons in /app/static/images/trophies - names = [ slugify.slugify(t["name"]) for t in tr ] + names = [slugify.slugify(t["name"]) for t in tr] src = os.path.join(app.root_path, "data", "trophies.png") dst = os.path.join(app.root_path, "static", "images", "trophies")