diff --git a/.gitignore b/.gitignore index 37ce1aa50..1a86cb06b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ nosetests.xml # Virtual environment venv + +# Environment files +.env +.env-mysql diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..88d94d100 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.6-alpine + +ENV FLASK_APP flasky.py +ENV FLASK_CONFIG production + +RUN adduser -D flasky +USER flasky + +WORKDIR /home/flasky + +COPY requirements requirements +RUN python -m venv venv +RUN venv/bin/pip install -r requirements/docker.txt + +COPY app app +COPY migrations migrations +COPY flasky.py config.py boot.sh ./ + +# run-time configuration +EXPOSE 5000 +ENTRYPOINT ["./boot.sh"] diff --git a/Procfile b/Procfile new file mode 100644 index 000000000..541c902b3 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn flasky:app diff --git a/app/__init__.py b/app/__init__.py index 0326ca671..a0d325a37 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -29,10 +29,17 @@ def create_app(config_name): login_manager.init_app(app) pagedown.init_app(app) + if app.config['SSL_REDIRECT']: + from flask_sslify import SSLify + sslify = SSLify(app) + from .main import main as main_blueprint app.register_blueprint(main_blueprint) from .auth import auth as auth_blueprint app.register_blueprint(auth_blueprint, url_prefix='/auth') + from .api import api as api_blueprint + app.register_blueprint(api_blueprint, url_prefix='/api/v1') + return app diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 000000000..f029d5385 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +api = Blueprint('api', __name__) + +from . import authentication, posts, users, comments, errors diff --git a/app/api/authentication.py b/app/api/authentication.py new file mode 100644 index 000000000..a9c66f4e9 --- /dev/null +++ b/app/api/authentication.py @@ -0,0 +1,44 @@ +from flask import g, jsonify +from flask_httpauth import HTTPBasicAuth +from ..models import User +from . import api +from .errors import unauthorized, forbidden + +auth = HTTPBasicAuth() + + +@auth.verify_password +def verify_password(email_or_token, password): + if email_or_token == '': + return False + if password == '': + g.current_user = User.verify_auth_token(email_or_token) + g.token_used = True + return g.current_user is not None + user = User.query.filter_by(email=email_or_token.lower()).first() + if not user: + return False + g.current_user = user + g.token_used = False + return user.verify_password(password) + + +@auth.error_handler +def auth_error(): + return unauthorized('Invalid credentials') + + +@api.before_request +@auth.login_required +def before_request(): + if not g.current_user.is_anonymous and \ + not g.current_user.confirmed: + return forbidden('Unconfirmed account') + + +@api.route('/tokens/', methods=['POST']) +def get_token(): + if g.current_user.is_anonymous or g.token_used: + return unauthorized('Invalid credentials') + return jsonify({'token': g.current_user.generate_auth_token( + expiration=3600), 'expiration': 3600}) diff --git a/app/api/comments.py b/app/api/comments.py new file mode 100644 index 000000000..1ecd4b03f --- /dev/null +++ b/app/api/comments.py @@ -0,0 +1,67 @@ +from flask import jsonify, request, g, url_for, current_app +from .. import db +from ..models import Post, Permission, Comment +from . import api +from .decorators import permission_required + + +@api.route('/comments/') +def get_comments(): + page = request.args.get('page', 1, type=int) + pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate( + page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], + error_out=False) + comments = pagination.items + prev = None + if pagination.has_prev: + prev = url_for('api.get_comments', page=page-1) + next = None + if pagination.has_next: + next = url_for('api.get_comments', page=page+1) + return jsonify({ + 'comments': [comment.to_json() for comment in comments], + 'prev': prev, + 'next': next, + 'count': pagination.total + }) + + +@api.route('/comments/') +def get_comment(id): + comment = Comment.query.get_or_404(id) + return jsonify(comment.to_json()) + + +@api.route('/posts//comments/') +def get_post_comments(id): + post = Post.query.get_or_404(id) + page = request.args.get('page', 1, type=int) + pagination = post.comments.order_by(Comment.timestamp.asc()).paginate( + page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], + error_out=False) + comments = pagination.items + prev = None + if pagination.has_prev: + prev = url_for('api.get_post_comments', id=id, page=page-1) + next = None + if pagination.has_next: + next = url_for('api.get_post_comments', id=id, page=page+1) + return jsonify({ + 'comments': [comment.to_json() for comment in comments], + 'prev': prev, + 'next': next, + 'count': pagination.total + }) + + +@api.route('/posts//comments/', methods=['POST']) +@permission_required(Permission.COMMENT) +def new_post_comment(id): + post = Post.query.get_or_404(id) + comment = Comment.from_json(request.json) + comment.author = g.current_user + comment.post = post + db.session.add(comment) + db.session.commit() + return jsonify(comment.to_json()), 201, \ + {'Location': url_for('api.get_comment', id=comment.id)} diff --git a/app/api/decorators.py b/app/api/decorators.py new file mode 100644 index 000000000..4b7086821 --- /dev/null +++ b/app/api/decorators.py @@ -0,0 +1,14 @@ +from functools import wraps +from flask import g +from .errors import forbidden + + +def permission_required(permission): + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not g.current_user.can(permission): + return forbidden('Insufficient permissions') + return f(*args, **kwargs) + return decorated_function + return decorator diff --git a/app/api/errors.py b/app/api/errors.py new file mode 100644 index 000000000..d176c8999 --- /dev/null +++ b/app/api/errors.py @@ -0,0 +1,26 @@ +from flask import jsonify +from app.exceptions import ValidationError +from . import api + + +def bad_request(message): + response = jsonify({'error': 'bad request', 'message': message}) + response.status_code = 400 + return response + + +def unauthorized(message): + response = jsonify({'error': 'unauthorized', 'message': message}) + response.status_code = 401 + return response + + +def forbidden(message): + response = jsonify({'error': 'forbidden', 'message': message}) + response.status_code = 403 + return response + + +@api.errorhandler(ValidationError) +def validation_error(e): + return bad_request(e.args[0]) diff --git a/app/api/posts.py b/app/api/posts.py new file mode 100644 index 000000000..c0123b825 --- /dev/null +++ b/app/api/posts.py @@ -0,0 +1,57 @@ +from flask import jsonify, request, g, url_for, current_app +from .. import db +from ..models import Post, Permission +from . import api +from .decorators import permission_required +from .errors import forbidden + + +@api.route('/posts/') +def get_posts(): + page = request.args.get('page', 1, type=int) + pagination = Post.query.paginate( + page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], + error_out=False) + posts = pagination.items + prev = None + if pagination.has_prev: + prev = url_for('api.get_posts', page=page-1) + next = None + if pagination.has_next: + next = url_for('api.get_posts', page=page+1) + return jsonify({ + 'posts': [post.to_json() for post in posts], + 'prev': prev, + 'next': next, + 'count': pagination.total + }) + + +@api.route('/posts/') +def get_post(id): + post = Post.query.get_or_404(id) + return jsonify(post.to_json()) + + +@api.route('/posts/', methods=['POST']) +@permission_required(Permission.WRITE) +def new_post(): + post = Post.from_json(request.json) + post.author = g.current_user + db.session.add(post) + db.session.commit() + return jsonify(post.to_json()), 201, \ + {'Location': url_for('api.get_post', id=post.id)} + + +@api.route('/posts/', methods=['PUT']) +@permission_required(Permission.WRITE) +def edit_post(id): + post = Post.query.get_or_404(id) + if g.current_user != post.author and \ + not g.current_user.can(Permission.ADMIN): + return forbidden('Insufficient permissions') + post.body = request.json.get('body', post.body) + db.session.add(post) + db.session.commit() + return jsonify(post.to_json()) diff --git a/app/api/users.py b/app/api/users.py new file mode 100644 index 000000000..31d05dd6c --- /dev/null +++ b/app/api/users.py @@ -0,0 +1,53 @@ +from flask import jsonify, request, current_app, url_for +from . import api +from ..models import User, Post + + +@api.route('/users/') +def get_user(id): + user = User.query.get_or_404(id) + return jsonify(user.to_json()) + + +@api.route('/users//posts/') +def get_user_posts(id): + user = User.query.get_or_404(id) + page = request.args.get('page', 1, type=int) + pagination = user.posts.order_by(Post.timestamp.desc()).paginate( + page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], + error_out=False) + posts = pagination.items + prev = None + if pagination.has_prev: + prev = url_for('api.get_user_posts', id=id, page=page-1) + next = None + if pagination.has_next: + next = url_for('api.get_user_posts', id=id, page=page+1) + return jsonify({ + 'posts': [post.to_json() for post in posts], + 'prev': prev, + 'next': next, + 'count': pagination.total + }) + + +@api.route('/users//timeline/') +def get_user_followed_posts(id): + user = User.query.get_or_404(id) + page = request.args.get('page', 1, type=int) + pagination = user.followed_posts.order_by(Post.timestamp.desc()).paginate( + page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], + error_out=False) + posts = pagination.items + prev = None + if pagination.has_prev: + prev = url_for('api.get_user_followed_posts', id=id, page=page-1) + next = None + if pagination.has_next: + next = url_for('api.get_user_followed_posts', id=id, page=page+1) + return jsonify({ + 'posts': [post.to_json() for post in posts], + 'prev': prev, + 'next': next, + 'count': pagination.total + }) diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 000000000..2851fa718 --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,2 @@ +class ValidationError(ValueError): + pass diff --git a/app/main/errors.py b/app/main/errors.py index 416c15142..60b5f2276 100644 --- a/app/main/errors.py +++ b/app/main/errors.py @@ -1,17 +1,32 @@ -from flask import render_template +from flask import render_template, request, jsonify from . import main @main.app_errorhandler(403) def forbidden(e): + if request.accept_mimetypes.accept_json and \ + not request.accept_mimetypes.accept_html: + response = jsonify({'error': 'forbidden'}) + response.status_code = 403 + return response return render_template('403.html'), 403 @main.app_errorhandler(404) def page_not_found(e): + if request.accept_mimetypes.accept_json and \ + not request.accept_mimetypes.accept_html: + response = jsonify({'error': 'not found'}) + response.status_code = 404 + return response return render_template('404.html'), 404 @main.app_errorhandler(500) def internal_server_error(e): + if request.accept_mimetypes.accept_json and \ + not request.accept_mimetypes.accept_html: + response = jsonify({'error': 'internal server error'}) + response.status_code = 500 + return response return render_template('500.html'), 500 diff --git a/app/main/forms.py b/app/main/forms.py index d28c7c139..770edb2b4 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -54,3 +54,8 @@ def validate_username(self, field): class PostForm(FlaskForm): body = PageDownField("What's on your mind?", validators=[DataRequired()]) submit = SubmitField('Submit') + + +class CommentForm(FlaskForm): + body = StringField('Enter your comment', validators=[DataRequired()]) + submit = SubmitField('Submit') diff --git a/app/main/views.py b/app/main/views.py index edbed206b..5b30b245c 100644 --- a/app/main/views.py +++ b/app/main/views.py @@ -1,13 +1,37 @@ from flask import render_template, redirect, url_for, abort, flash, request,\ - current_app + current_app, make_response from flask_login import login_required, current_user +from flask_sqlalchemy import get_debug_queries from . import main -from .forms import EditProfileForm, EditProfileAdminForm, PostForm +from .forms import EditProfileForm, EditProfileAdminForm, PostForm,\ + CommentForm from .. import db -from ..models import Permission, Role, User, Post +from ..models import Permission, Role, User, Post, Comment from ..decorators import admin_required, permission_required +@main.after_app_request +def after_request(response): + for query in get_debug_queries(): + if query.duration >= current_app.config['FLASKY_SLOW_DB_QUERY_TIME']: + current_app.logger.warning( + 'Slow query: %s\nParameters: %s\nDuration: %fs\nContext: %s\n' + % (query.statement, query.parameters, query.duration, + query.context)) + return response + + +@main.route('/shutdown') +def server_shutdown(): + if not current_app.testing: + abort(404) + shutdown = request.environ.get('werkzeug.server.shutdown') + if not shutdown: + abort(500) + shutdown() + return 'Shutting down...' + + @main.route('/', methods=['GET', 'POST']) def index(): form = PostForm() @@ -18,12 +42,19 @@ def index(): db.session.commit() return redirect(url_for('.index')) page = request.args.get('page', 1, type=int) - pagination = Post.query.order_by(Post.timestamp.desc()).paginate( + show_followed = False + if current_user.is_authenticated: + show_followed = bool(request.cookies.get('show_followed', '')) + if show_followed: + query = current_user.followed_posts + else: + query = Post.query + pagination = query.order_by(Post.timestamp.desc()).paginate( page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], error_out=False) posts = pagination.items return render_template('index.html', form=form, posts=posts, - pagination=pagination) + show_followed=show_followed, pagination=pagination) @main.route('/user/') @@ -84,10 +115,28 @@ def edit_profile_admin(id): return render_template('edit_profile.html', form=form, user=user) -@main.route('/post/') +@main.route('/post/', methods=['GET', 'POST']) def post(id): post = Post.query.get_or_404(id) - return render_template('post.html', posts=[post]) + form = CommentForm() + if form.validate_on_submit(): + comment = Comment(body=form.body.data, + post=post, + author=current_user._get_current_object()) + db.session.add(comment) + db.session.commit() + flash('Your comment has been published.') + return redirect(url_for('.post', id=post.id, page=-1)) + page = request.args.get('page', 1, type=int) + if page == -1: + page = (post.comments.count() - 1) // \ + current_app.config['FLASKY_COMMENTS_PER_PAGE'] + 1 + pagination = post.comments.order_by(Comment.timestamp.asc()).paginate( + page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], + error_out=False) + comments = pagination.items + return render_template('post.html', posts=[post], form=form, + comments=comments, pagination=pagination) @main.route('/edit/', methods=['GET', 'POST']) @@ -174,3 +223,56 @@ def followed_by(username): return render_template('followers.html', user=user, title="Followed by", endpoint='.followed_by', pagination=pagination, follows=follows) + + +@main.route('/all') +@login_required +def show_all(): + resp = make_response(redirect(url_for('.index'))) + resp.set_cookie('show_followed', '', max_age=30*24*60*60) + return resp + + +@main.route('/followed') +@login_required +def show_followed(): + resp = make_response(redirect(url_for('.index'))) + resp.set_cookie('show_followed', '1', max_age=30*24*60*60) + return resp + + +@main.route('/moderate') +@login_required +@permission_required(Permission.MODERATE) +def moderate(): + page = request.args.get('page', 1, type=int) + pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate( + page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], + error_out=False) + comments = pagination.items + return render_template('moderate.html', comments=comments, + pagination=pagination, page=page) + + +@main.route('/moderate/enable/') +@login_required +@permission_required(Permission.MODERATE) +def moderate_enable(id): + comment = Comment.query.get_or_404(id) + comment.disabled = False + db.session.add(comment) + db.session.commit() + return redirect(url_for('.moderate', + page=request.args.get('page', 1, type=int))) + + +@main.route('/moderate/disable/') +@login_required +@permission_required(Permission.MODERATE) +def moderate_disable(id): + comment = Comment.query.get_or_404(id) + comment.disabled = True + db.session.add(comment) + db.session.commit() + return redirect(url_for('.moderate', + page=request.args.get('page', 1, type=int))) diff --git a/app/models.py b/app/models.py index e7ddb5e24..8832f76cc 100644 --- a/app/models.py +++ b/app/models.py @@ -4,8 +4,9 @@ from itsdangerous import TimedJSONWebSignatureSerializer as Serializer from markdown import markdown import bleach -from flask import current_app, request +from flask import current_app, request, url_for from flask_login import UserMixin, AnonymousUserMixin +from app.exceptions import ValidationError from . import db, login_manager @@ -104,6 +105,15 @@ class User(UserMixin, db.Model): backref=db.backref('followed', lazy='joined'), lazy='dynamic', cascade='all, delete-orphan') + comments = db.relationship('Comment', backref='author', lazy='dynamic') + + @staticmethod + def add_self_follows(): + for user in User.query.all(): + if not user.is_following(user): + user.follow(user) + db.session.add(user) + db.session.commit() def __init__(self, **kwargs): super(User, self).__init__(**kwargs) @@ -114,6 +124,7 @@ def __init__(self, **kwargs): self.role = Role.query.filter_by(default=True).first() if self.email is not None and self.avatar_hash is None: self.avatar_hash = self.gravatar_hash() + self.follow(self) @property def password(self): @@ -229,6 +240,33 @@ def followed_posts(self): return Post.query.join(Follow, Follow.followed_id == Post.author_id)\ .filter(Follow.follower_id == self.id) + def to_json(self): + json_user = { + 'url': url_for('api.get_user', id=self.id), + 'username': self.username, + 'member_since': self.member_since, + 'last_seen': self.last_seen, + 'posts_url': url_for('api.get_user_posts', id=self.id), + 'followed_posts_url': url_for('api.get_user_followed_posts', + id=self.id), + 'post_count': self.posts.count() + } + return json_user + + def generate_auth_token(self, expiration): + s = Serializer(current_app.config['SECRET_KEY'], + expires_in=expiration) + return s.dumps({'id': self.id}).decode('utf-8') + + @staticmethod + def verify_auth_token(token): + s = Serializer(current_app.config['SECRET_KEY']) + try: + data = s.loads(token) + except: + return None + return User.query.get(data['id']) + def __repr__(self): return '' % self.username @@ -255,6 +293,7 @@ class Post(db.Model): body_html = db.Column(db.Text) timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) author_id = db.Column(db.Integer, db.ForeignKey('users.id')) + comments = db.relationship('Comment', backref='post', lazy='dynamic') @staticmethod def on_changed_body(target, value, oldvalue, initiator): @@ -265,4 +304,64 @@ def on_changed_body(target, value, oldvalue, initiator): markdown(value, output_format='html'), tags=allowed_tags, strip=True)) + def to_json(self): + json_post = { + 'url': url_for('api.get_post', id=self.id), + 'body': self.body, + 'body_html': self.body_html, + 'timestamp': self.timestamp, + 'author_url': url_for('api.get_user', id=self.author_id), + 'comments_url': url_for('api.get_post_comments', id=self.id), + 'comment_count': self.comments.count() + } + return json_post + + @staticmethod + def from_json(json_post): + body = json_post.get('body') + if body is None or body == '': + raise ValidationError('post does not have a body') + return Post(body=body) + + db.event.listen(Post.body, 'set', Post.on_changed_body) + + +class Comment(db.Model): + __tablename__ = 'comments' + id = db.Column(db.Integer, primary_key=True) + body = db.Column(db.Text) + body_html = db.Column(db.Text) + timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) + disabled = db.Column(db.Boolean) + author_id = db.Column(db.Integer, db.ForeignKey('users.id')) + post_id = db.Column(db.Integer, db.ForeignKey('posts.id')) + + @staticmethod + def on_changed_body(target, value, oldvalue, initiator): + allowed_tags = ['a', 'abbr', 'acronym', 'b', 'code', 'em', 'i', + 'strong'] + target.body_html = bleach.linkify(bleach.clean( + markdown(value, output_format='html'), + tags=allowed_tags, strip=True)) + + def to_json(self): + json_comment = { + 'url': url_for('api.get_comment', id=self.id), + 'post_url': url_for('api.get_post', id=self.post_id), + 'body': self.body, + 'body_html': self.body_html, + 'timestamp': self.timestamp, + 'author_url': url_for('api.get_user', id=self.author_id), + } + return json_comment + + @staticmethod + def from_json(json_comment): + body = json_comment.get('body') + if body is None or body == '': + raise ValidationError('comment does not have a body') + return Comment(body=body) + + +db.event.listen(Comment.body, 'set', Comment.on_changed_body) diff --git a/app/static/styles.css b/app/static/styles.css index 2b305a424..0dae2c34f 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -5,12 +5,19 @@ min-height: 260px; margin-left: 280px; } +div.post-tabs { + margin-top: 16px; +} ul.posts { list-style-type: none; padding: 0px; margin: 16px 0px 0px 0px; border-top: 1px solid #e0e0e0; } +div.post-tabs ul.posts { + margin: 0px; + border-top: none; +} ul.posts li.post { padding: 8px; border-bottom: 1px solid #e0e0e0; @@ -34,6 +41,38 @@ div.post-content { div.post-footer { text-align: right; } +ul.comments { + list-style-type: none; + padding: 0px; + margin: 16px 0px 0px 0px; +} +ul.comments li.comment { + margin-left: 32px; + padding: 8px; + border-bottom: 1px solid #e0e0e0; +} +ul.comments li.comment:nth-child(1) { + border-top: 1px solid #e0e0e0; +} +ul.comments li.comment:hover { + background-color: #f0f0f0; +} +div.comment-date { + float: right; +} +div.comment-author { + font-weight: bold; +} +div.comment-thumbnail { + position: absolute; +} +div.comment-content { + margin-left: 48px; + min-height: 48px; +} +div.comment-form { + margin: 16px 0px 16px 32px; +} div.pagination { width: 100%; text-align: right; diff --git a/app/templates/_comments.html b/app/templates/_comments.html new file mode 100644 index 000000000..aa278f724 --- /dev/null +++ b/app/templates/_comments.html @@ -0,0 +1,35 @@ +
    + {% for comment in comments %} +
  • +
    + + + +
    +
    +
    {{ moment(comment.timestamp).fromNow() }}
    + +
    + {% if comment.disabled %} +

    This comment has been disabled by a moderator.

    + {% endif %} + {% if moderate or not comment.disabled %} + {% if comment.body_html %} + {{ comment.body_html | safe }} + {% else %} + {{ comment.body }} + {% endif %} + {% endif %} +
    + {% if moderate %} +
    + {% if comment.disabled %} + Enable + {% else %} + Disable + {% endif %} + {% endif %} +
    +
  • + {% endfor %} +
diff --git a/app/templates/_macros.html b/app/templates/_macros.html index b5d55a394..a4789c896 100644 --- a/app/templates/_macros.html +++ b/app/templates/_macros.html @@ -1,7 +1,7 @@ -{% macro pagination_widget(pagination, endpoint) %} +{% macro pagination_widget(pagination, endpoint, fragment='') %}
    - + « @@ -9,11 +9,11 @@ {% if p %} {% if p == pagination.page %}
  • - {{ p }} + {{ p }}
  • {% else %}
  • - {{ p }} + {{ p }}
  • {% endif %} {% else %} @@ -21,7 +21,7 @@ {% endif %} {% endfor %} - + » diff --git a/app/templates/_posts.html b/app/templates/_posts.html index 596a8b4e6..399b46ffd 100644 --- a/app/templates/_posts.html +++ b/app/templates/_posts.html @@ -29,6 +29,9 @@ Permalink + + {{ post.comments.count() }} Comments + diff --git a/app/templates/base.html b/app/templates/base.html index edd5640f2..fb0139cb1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -29,6 +29,9 @@ {% endif %}