diff --git a/app/__init__.py b/app/__init__.py index 84a1ca2d23f1cd72ccdd33676845594753371613..1793281c23eaf21c6e09ea3a4cf4aad62a2a67ee 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -43,4 +43,7 @@ def create_app(config_name): 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 0000000000000000000000000000000000000000..f029d5385551b7015387c976a07b5ea7c7808d94 --- /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 0000000000000000000000000000000000000000..4174ff3cedca2dc97d5445ca7fb1950c4dd09a41 --- /dev/null +++ b/app/api/authentication.py @@ -0,0 +1,43 @@ +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(username_or_token, password): + if username_or_token == '': + return False + if password == '': + g.current_user = User.verify_auth_token(token=username_or_token) + g.token_used = True + return g.current_user is not None + user = User.query.filter_by(username=username_or_token).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(): + pass + # if not g.current_user.is_anonymous: + # return forbidden('Unconfirmed account') + + +@api.route('/tokens/', methods=['POST']) +def get_token(): + if g.token_used: + return unauthorized('Invalid credentials') + return jsonify({'token': g.current_user.generate_auth_token()}) diff --git a/app/api/comments.py b/app/api/comments.py new file mode 100644 index 0000000000000000000000000000000000000000..1d5f18e2dd653f7b1eecfc20da4d4bb162724ee4 --- /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=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=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 0000000000000000000000000000000000000000..4b70868212ebb98069ed86968d956623bfbe65ca --- /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 0000000000000000000000000000000000000000..d176c89996814708e9a4a8907e0440989f6e4ce5 --- /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 0000000000000000000000000000000000000000..79d0f7e23698af86fa80b923c30b54c320747f3b --- /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=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 0000000000000000000000000000000000000000..6cfaf2ca2526922de8f52d2e67e076178d23c797 --- /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( + 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=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 0000000000000000000000000000000000000000..2851fa7181a090934029582c186379511bee6a9b --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,2 @@ +class ValidationError(ValueError): + pass diff --git a/app/models.py b/app/models.py index dc2349f84287f7f9a8700f8d837d55fdca9b638e..daa73d75c5852d1a723e009502a0a8d6fa120d99 100644 --- a/app/models.py +++ b/app/models.py @@ -19,7 +19,7 @@ from datetime import datetime import bleach from flask import current_app, request, url_for -from itsdangerous import Serializer +from authlib.jose import jwt, JoseError from markdown import markdown from wtforms import ValidationError @@ -114,6 +114,7 @@ class Post(db.Model): 'body': self.body, 'body_html': self.body_html, 'timestamp': self.timestamp, + 'update_timestamp': self.update_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() @@ -128,6 +129,9 @@ class Post(db.Model): return Post(body=body) +db.event.listen(Post.body, 'set', Post.on_changed_body) + + class Follow(db.Model): __tablename__ = 'follows' follower_id = db.Column(db.Integer, db.ForeignKey('users.id'), @@ -229,17 +233,38 @@ class User(UserMixin, db.Model): return Post.query.join(Follow, Follow.followed_id == Post.author_id) \ .filter(Follow.follower_id == self.id) - def generate_auth_token(self, expiration): - s = Serializer(current_app.config['SECRET_KEY']) - return s.dumps({'id': self.id}).decode('utf-8') + # def generate_auth_token(self, expiration): + # s = Serializer(current_app.config['SECRET_KEY']) + # return s.dumps({'id': self.id}) + # + # @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 generate_auth_token(self, **kwargs): + """生成用于邮箱验证的JWT(json web token)""" + # 签名算法 + header = {'alg': 'HS256'} + # 待签名的数据负载 + data = {'id': self.id} + data.update(**kwargs) + + token = jwt.encode(header=header, payload=data, key=current_app.config['SECRET_KEY']) + return token.decode() @staticmethod def verify_auth_token(token): - s = Serializer(current_app.config['SECRET_KEY']) + """用于验证用户注册和用户修改密码或邮箱的token, 并完成相应的确认操作""" + try: - data = s.loads(token) - except: - return None + data = jwt.decode(token, current_app.config['SECRET_KEY']) + except JoseError: + return False return User.query.get(data['id']) def to_json(self): @@ -285,6 +310,24 @@ class Comment(db.Model): 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) @@ -292,6 +335,3 @@ db.event.listen(Comment.body, 'set', Comment.on_changed_body) @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id)) - - -db.event.listen(Post.body, 'set', Post.on_changed_body) diff --git a/first_blog.py b/first_blog.py index 7ea0c7012d6a7b2ab9ba4c50c4b63de59ba41a0a..ec58983ed9a8d05982253564f290d5530c87f67c 100644 --- a/first_blog.py +++ b/first_blog.py @@ -10,7 +10,7 @@ import os import sys import click from app import create_app, db -from app.models import User, Role +from app.models import User, Role, Follow, Permission, Post, Comment from flask_migrate import Migrate app = create_app(os.getenv('FLASK_CONFIG') or 'default') @@ -19,5 +19,6 @@ migrate = Migrate(app, db) @app.shell_context_processor def make_shell_context(): - return dict(db=db, User=User, Role=Role) + return dict(db=db, User=User, Follow=Follow, Role=Role, + Permission=Permission, Post=Post, Comment=Comment)