diff --git a/app/__init__.py b/app/__init__.py index a231147..aaf0713 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -28,8 +28,8 @@ app.url_map.converters['topicpage'] = TopicPageConverter # Register routes from app import routes -# Register utils -from app import utils +# Register filters +from app.utils import filters # Register processors from app import processors diff --git a/app/data/groups.yaml b/app/data/groups.yaml index fbc8f4f..4e34b0c 100644 --- a/app/data/groups.yaml +++ b/app/data/groups.yaml @@ -11,7 +11,7 @@ shoutbox-kick shoutbox-ban unlimited-pms footer-statistics community-login access-admin-panel edit-account delete-account edit-trophies - delete_notification + delete_notification no-upload-limits - name: Modérateur css: "color: green;" @@ -21,7 +21,7 @@ move-public-content extract-posts delete-notes delete-tests shoutbox-kick shoutbox-ban - unlimited-pms + unlimited-pms no-upload-limits - name: Développeur css: "color: #4169e1;" @@ -31,7 +31,7 @@ scheduled-posting edit-static-content unlimited-pms footer-statistics community-login - access-admin-panel + access-admin-panel no-upload-limits - name: Rédacteur css: "color: blue;" @@ -41,6 +41,7 @@ upload-shared-files delete-shared-files scheduled-posting showcase-content edit-static-content + no-upload-limits - name: Responsable communauté css: "color: DarkOrange;" diff --git a/app/forms/forum.py b/app/forms/forum.py index 7162415..58e820e 100644 --- a/app/forms/forum.py +++ b/app/forms/forum.py @@ -1,23 +1,25 @@ from flask_wtf import FlaskForm -from wtforms import StringField, FormField, SubmitField, TextAreaField -from wtforms.validators import DataRequired, Length +from wtforms import StringField, FormField, SubmitField, TextAreaField, \ + MultipleFileField +from wtforms.validators import DataRequired, Length, Optional import app.utils.validators as vd -class TopicCreationForm(FlaskForm): - title = StringField('Nom du sujet', - validators=[DataRequired(), Length(min=3, max=32)]) - message = TextAreaField('Message principal', validators=[DataRequired()]) - submit = SubmitField('Créer le sujet') - -class AnonymousTopicCreationForm(TopicCreationForm): - pseudo = StringField('Pseudo', - validators=[DataRequired(), vd.name_valid, vd.name_available]) - - class CommentForm(FlaskForm): - message = TextAreaField('Commentaire', validators=[DataRequired()]) + message = TextAreaField('Message', validators=[DataRequired()]) + attachments = MultipleFileField('Pièces-jointes', + validators=[vd.file.optional, vd.file.count, vd.file.extension, + vd.file.size]) submit = SubmitField('Commenter') class AnonymousCommentForm(CommentForm): pseudo = StringField('Pseudo', validators=[DataRequired(), vd.name_valid, vd.name_available]) + + +class TopicCreationForm(CommentForm): + title = StringField('Nom du sujet', + validators=[DataRequired(), Length(min=3, max=32)]) + submit = SubmitField('Créer le sujet') + +class AnonymousTopicCreationForm(TopicCreationForm, AnonymousCommentForm): + pass diff --git a/app/models/attachment.py b/app/models/attachment.py index 099076f..c237fab 100644 --- a/app/models/attachment.py +++ b/app/models/attachment.py @@ -1,5 +1,8 @@ +from werkzeug.utils import secure_filename +from sqlalchemy.orm import backref from app import db -from hashlib import sha256 +from app.utils.filesize import filesize +from config import V5Config import os class Attachment(db.Model): @@ -9,24 +12,39 @@ class Attachment(db.Model): # Original name of the file name = db.Column(db.Unicode(64)) - # Hash of the value - hashed = db.Column(db.Unicode(64)) - # The comment linked with comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=False) - comment = db.relationship('Comment', backref=backref('attachments', lazy='dynamic')) + comment = db.relationship('Comment', backref=backref('attachments')) # The size of the file size = db.Column(db.Integer) + # Storage file path + @property + def path(self): + return os.path.join(V5Config.DATA_FOLDER, "attachments", + f"{self.id:05}", self.name) + + @property + def url(self): + return f"/fichiers/{self.id:05}/{self.name}" + + def __init__(self, file, comment): - self.name = file.filename - self.size = os.stat(file).st_size - self.hashed = self.hash_file(file) + self.name = secure_filename(file.filename) + self.size = filesize(file) self.comment = comment - def hash_file(file): - with open(file,"rb") as f: - bytes = f.read() # read entire file as bytes - hashed = sha256(bytes).hexdigest() - return hashed \ No newline at end of file + def set_file(self, file): + os.mkdir(os.path.dirname(self.path)) + file.save(self.path) + + def edit_file(self, file): + file.name = secure_filename(file.filename) + self.set_file(file) + + def delete_file(self): + try: + os.delete(self.path) + except FileNotFoundError: + pass diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 9bc4576..b23dd5b 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,6 +1,6 @@ # Register routes here -from app.routes import index, search, users, tools +from app.routes import index, search, users, tools, development from app.routes.account import login, account, notification -from app.routes.admin import index, groups, account, trophies, forums +from app.routes.admin import index, groups, account, trophies, forums, attachments from app.routes.forum import index, topic diff --git a/app/routes/admin/attachments.py b/app/routes/admin/attachments.py new file mode 100644 index 0000000..b8b3bdf --- /dev/null +++ b/app/routes/admin/attachments.py @@ -0,0 +1,13 @@ +from app import app +from app.models.attachment import Attachment +from app.utils.priv_required import priv_required +from app.utils.render import render + +# TODO: add pagination & moderation tools (deletion) + +@app.route('/admin/fichiers', methods=['GET']) +@priv_required('access-admin-panel') +def adm_attachments(): + attachments = Attachment.query.all() + + return render('admin/attachments.html', attachments=attachments) diff --git a/app/routes/development.py b/app/routes/development.py new file mode 100644 index 0000000..0cbad14 --- /dev/null +++ b/app/routes/development.py @@ -0,0 +1,25 @@ +from flask import send_file, redirect, url_for, abort +from werkzeug.utils import secure_filename +from app import app +from config import V5Config +import os + +# These routes are used in development +# In production, those files should be served by the web server (nginx) + +@app.route('/avatar/') +def avatar(filename): + 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) + return redirect(url_for('static', filename='images/default_avatar.png')) + +@app.route('/fichiers//') +def attachment(path, name): + file = os.path.join(V5Config.DATA_FOLDER, "attachments", + secure_filename(path), secure_filename(name)) + if os.path.isfile(file): + return send_file(file) + else: + abort(404) diff --git a/app/routes/forum/index.py b/app/routes/forum/index.py index f87910d..4e8176d 100644 --- a/app/routes/forum/index.py +++ b/app/routes/forum/index.py @@ -10,6 +10,7 @@ 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.attachment import Attachment @app.route('/forum/') @@ -34,17 +35,18 @@ def forum_page(f): or ("/actus" not in f.url and not f.sub_forums)) and ( V5Config.ENABLE_GUEST_POST or current_user.is_authenticated): - # First create the thread, then the comment, then the topic - th = Thread() - db.session.add(th) - db.session.commit() - + # Manage author if current_user.is_authenticated: author = current_user else: author = Guest(form.pseudo.data) db.session.add(author) + # First create the thread, then the comment, then the topic + th = Thread() + db.session.add(th) + db.session.commit() + c = Comment(author, form.message.data, th) db.session.add(c) db.session.commit() @@ -56,6 +58,17 @@ def forum_page(f): db.session.add(t) db.session.commit() + # Manage files + attachments = [] + for file in form.attachments.data: + if file.filename != "": + a = Attachment(file, c) + attachments.append((a, file)) + db.session.add(a) + db.session.commit() + for a, file in attachments: + a.set_file(file) + # Update member's xp and trophies if current_user.is_authenticated: current_user.add_xp(2) # 2 points for a topic diff --git a/app/routes/forum/topic.py b/app/routes/forum/topic.py index bdd4ab6..f3ab5ec 100644 --- a/app/routes/forum/topic.py +++ b/app/routes/forum/topic.py @@ -10,6 +10,7 @@ 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.attachment import Attachment @app.route('/forum//', methods=['GET', 'POST']) @@ -27,16 +28,29 @@ def forum_topic(f, page): if form.validate_on_submit() and \ (V5Config.ENABLE_GUEST_POST or current_user.is_authenticated): + # Manage author if current_user.is_authenticated: author = current_user else: author = Guest(form.pseudo.data) db.session.add(author) + # Create comment c = Comment(author, form.message.data, t.thread) db.session.add(c) db.session.commit() + # Manage files + attachments = [] + for file in form.attachments.data: + if file.filename != "": + a = Attachment(file, c) + attachments.append((a, file)) + db.session.add(a) + db.session.commit() + for a, file in attachments: + a.set_file(file) + # Update member's xp and trophies if current_user.is_authenticated: current_user.add_xp(1) # 1 point for a comment diff --git a/app/routes/users.py b/app/routes/users.py index 5f8a91c..7172654 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -21,11 +21,3 @@ def user(username): def user_by_id(user_id): member = Member.query.filter_by(id=user_id).first_or_404() return redirect(url_for('user', username=member.name)) - -@app.route('/avatar/') -def avatar(filename): - 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) - return redirect(url_for('static', filename='images/default_avatar.png')) diff --git a/app/templates/admin/attachments.html b/app/templates/admin/attachments.html new file mode 100644 index 0000000..f5f468e --- /dev/null +++ b/app/templates/admin/attachments.html @@ -0,0 +1,45 @@ +{% extends "base/base.html" %} + +{% block title %} +Panneau d'administration »

Pièces jointes

+{% endblock %} + +{% block content %} +
+

Cette page présente une vue d'ensemble des pièces-jointes postées sur le site.

+ +

Pièces jointes

+ + + + + {% for a in attachments %} + + + + + + + {% endfor %} +
IDNomAuteurTaille
{{ a.id }}{{ a.name }}{{ a.comment.author.name }}{{ a.size }}
+ +

Liste des groupes

+ + + + + {% for group in groups %} + + {% endfor %} +
GroupeMembresPrivilèges
{{ group.name }} + {% for user in group.members %} + {{ user.name }} + {% endfor %} + + {% for priv in group.privs() %} + {{ priv }} + {{- ', ' if not loop.last }} + {% endfor %} +
+
+{% endblock %} diff --git a/app/templates/admin/index.html b/app/templates/admin/index.html index 03f32f9..eef615f 100644 --- a/app/templates/admin/index.html +++ b/app/templates/admin/index.html @@ -8,9 +8,10 @@

Pages générales du panneau d'administration :

{% endblock %} diff --git a/app/templates/forum/forum.html b/app/templates/forum/forum.html index 3ce57e9..1dcac5c 100644 --- a/app/templates/forum/forum.html +++ b/app/templates/forum/forum.html @@ -69,6 +69,11 @@ {{ widget_editor.text_editor(form.message) }} + {{ form.attachments }} + {% for error in form.attachments.errors %} + {{ error }} + {% endfor %} +
{{ form.submit(class_='bg-ok') }}
diff --git a/app/templates/forum/topic.html b/app/templates/forum/topic.html index 8ea0d4e..1b6265b 100644 --- a/app/templates/forum/topic.html +++ b/app/templates/forum/topic.html @@ -2,6 +2,7 @@ {% import "widgets/editor.html" as widget_editor %} {% import "widgets/user.html" as widget_user %} {% import "widgets/pagination.html" as widget_pagination with context %} +{% import "widgets/attachments.html" as widget_attachments %} {% block title %} Forum de Planète Casio » {{ t.forum.name }} »

{{ t.title }}

@@ -34,10 +35,11 @@

{{ c.text }}

+ {{ widget_attachments.attachments(c) }} {% elif loop.index0 != 0 %}
Ce message est le top comment
{% endif %} - + {% endfor %} @@ -60,6 +62,11 @@ {{ widget_editor.text_editor(form.message, label=False) }} + {{ form.attachments }} + {% for error in form.attachments.errors %} + {{ error }} + {% endfor %} +
{{ form.submit(class_='bg-ok') }}
{% endif %} diff --git a/app/templates/widgets/attachments.html b/app/templates/widgets/attachments.html new file mode 100644 index 0000000..c10ab09 --- /dev/null +++ b/app/templates/widgets/attachments.html @@ -0,0 +1,16 @@ +{% macro attachments(comment) %} +{% if comment.attachments %} +Pièces-jointes +
+ + + {% for a in comment.attachments %} + + + + + {% endfor %} +
NomTaille
{{ a.name }}{{ a.size }}
+
+{% endif %} +{% endmacro %} diff --git a/app/utils/__init__.py b/app/utils/__init__.py deleted file mode 100644 index 03b88c2..0000000 --- a/app/utils/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# Register utils here - -from app.utils import pluralize -from app.utils import date -from app.utils import is_title diff --git a/app/utils/filesize.py b/app/utils/filesize.py new file mode 100644 index 0000000..56e900e --- /dev/null +++ b/app/utils/filesize.py @@ -0,0 +1,9 @@ +import os +from random import getrandbits + +def filesize(file): + """Return the filesize. Save in /tmp and delete it when done""" + file.seek(0, os.SEEK_END) + size = file.tell() + file.seek(0) + return size diff --git a/app/utils/filters/__init__.py b/app/utils/filters/__init__.py new file mode 100644 index 0000000..54f614b --- /dev/null +++ b/app/utils/filters/__init__.py @@ -0,0 +1,3 @@ +# Register filters here + +from app.utils.filters import date, is_title, pluralize diff --git a/app/utils/date.py b/app/utils/filters/date.py similarity index 100% rename from app/utils/date.py rename to app/utils/filters/date.py diff --git a/app/utils/is_title.py b/app/utils/filters/is_title.py similarity index 100% rename from app/utils/is_title.py rename to app/utils/filters/is_title.py diff --git a/app/utils/pluralize.py b/app/utils/filters/pluralize.py similarity index 100% rename from app/utils/pluralize.py rename to app/utils/filters/pluralize.py diff --git a/app/utils/validators.py b/app/utils/validators/__init__.py similarity index 98% rename from app/utils/validators.py rename to app/utils/validators/__init__.py index c214aac..b93b9e6 100644 --- a/app/utils/validators.py +++ b/app/utils/validators/__init__.py @@ -8,8 +8,12 @@ from app.utils.unicode_names import normalize from math import log from werkzeug.exceptions import NotFound import app.utils.ldap as ldap + from config import V5Config +from app.utils.validators.file import * + +# TODO: clean this shit into split files def name_valid(form, name): valid = valid_name(name.data) @@ -35,7 +39,6 @@ def name_valid(form, name): 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: @@ -53,14 +56,11 @@ def name_available(form, name): 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: @@ -87,14 +87,12 @@ def password(form, password): 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: @@ -102,7 +100,6 @@ def old_password(form, field): 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""" def _id_exists(form, id): @@ -115,7 +112,6 @@ 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 diff --git a/app/utils/validators/file.py b/app/utils/validators/file.py new file mode 100644 index 0000000..747ca0e --- /dev/null +++ b/app/utils/validators/file.py @@ -0,0 +1,51 @@ +from flask_login import current_user +from wtforms.validators import ValidationError, StopValidation +from werkzeug.utils import secure_filename +from app.utils.filesize import filesize +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 + 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 + ] + r = re.compile("|".join(valid_extensions), re.IGNORECASE) + errors = [] + + for f in files.data: + name = secure_filename(f.filename) + ext = name.split(".")[-1] + if not r.fullmatch(ext): + errors.append("." + ext) + + if len(errors) > 0: + raise ValidationError(f"Extension(s) invalide(s) ({', '.join(errors)})") + +def size(form, files): + """There is no global limit to file sizes""" + size = sum([filesize(f) for f in files.data]) + if current_user.is_authenticated: + if current_user.priv("no-upload-limits"): + return + 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 + raise ValidationError("Fichiers trop lourds (max 500 ko)") diff --git a/migrations/versions/cd4868f312c5_added_attachments.py b/migrations/versions/cd4868f312c5_added_attachments.py new file mode 100644 index 0000000..547b909 --- /dev/null +++ b/migrations/versions/cd4868f312c5_added_attachments.py @@ -0,0 +1,35 @@ +"""Added attachments + +Revision ID: cd4868f312c5 +Revises: 001d2eaf0413 +Create Date: 2020-08-01 19:22:12.405038 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'cd4868f312c5' +down_revision = '001d2eaf0413' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('attachment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.Unicode(length=64), nullable=True), + sa.Column('comment_id', sa.Integer(), nullable=False), + sa.Column('size', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['comment_id'], ['comment.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('attachment') + # ### end Alembic commands ###