diff --git a/config.sample.py b/config.sample.py index 9c728ff..41b2db0 100644 --- a/config.sample.py +++ b/config.sample.py @@ -1,15 +1,14 @@ -import random, string, time +import random +import string +import time -SQLALCHEMY_DATABASE_URI='sqlite:///hole.db' -SQLALCHEMY_TRACK_MODIFICATIONS=False -JSON_AS_ASCII=False -CLIENT_ID='' -CLIENT_SECRET='' -MASTODON_URL='https://thu.closed.social' +SQLALCHEMY_DATABASE_URI = 'sqlite:///hole.db' +SQLALCHEMY_TRACK_MODIFICATIONS = False +JSON_AS_ASCII = False +CLIENT_ID = '' +CLIENT_SECRET = '' +MASTODON_URL = 'https://mastodon.social' REDIRECT_URI = 'http://hole.thu.monster/_auth' -THUHOLE_ADDRESS='https://thuhole.com' -THUHOLE_HOST='thuhole.com' -THUHOLE_PID=1 SALT = ''.join(random.choices(string.ascii_letters + string.digits, k=32)) ADMINS = ['cs_114514'] START_TIME = int(time.time()) diff --git a/hole.py b/hole.py index b416b71..4ee9721 100644 --- a/hole.py +++ b/hole.py @@ -1,12 +1,13 @@ -from flask import Flask, request, render_template, send_from_directory, abort, redirect -from flask_sqlalchemy import SQLAlchemy +import re +import random +import string + +from flask import Flask, request, abort, redirect from flask_limiter import Limiter from flask_limiter.util import get_remote_address from flask_migrate import Migrate from mastodon import Mastodon -import re, random, string, datetime, hashlib,requests - from models import db, User, Post, Comment, Attention, TagRecord, Syslog from utils import require_token, map_post, map_comment, map_syslog, check_attention, hash_name, look, get_num, tmp_token @@ -19,18 +20,13 @@ app.config.from_pyfile('config.py') db.init_app(app) migrate = Migrate(app, db) -#with app.app_context(): -# db.create_all() CS_LOGIN_URL = Mastodon(api_base_url=app.config['MASTODON_URL']) \ - .auth_request_url( - client_id = app.config['CLIENT_ID'], - redirect_uris = app.config['REDIRECT_URI'], - scopes = ['read:accounts'] - ) -THUHOLE_SEND_URL = f"{app.config.get('THUHOLE_ADDRESS')}/services/thuhole/api.php?action=docomment&PKUHelperAPI=3.0&jsapiver=v0.3.1.133-444340&user_token=" - -THUHOLE_GET_URL = f"{app.config.get('THUHOLE_ADDRESS')}/services/thuhole/api.php?action=getcomment&pid={app.config.get('THUHOLE_PID')}&PKUHelperAPI=3.0&jsapiver=v0.3.1.133-444340&user_token=" + .auth_request_url( + client_id=app.config['CLIENT_ID'], + redirect_uris=app.config['REDIRECT_URI'], + scopes=['read:accounts'] +) limiter = Limiter( app, @@ -40,77 +36,32 @@ limiter = Limiter( PER_PAGE = 50 + @app.route('/_login') @limiter.limit("5 / minute, 50 / hour") def login(): provider = request.args.get('p') if provider == 'cs': return redirect(CS_LOGIN_URL) - elif provider == 'thuhole': - token = request.args.get('token') - try: - rt = ''.join(random.choices(string.ascii_letters + string.digits, k=16)) - headers = { - 'user-agent': 'holeBot; hole.thu.monster', - 'host': app.config.get('THUHOLE_HOST') - } - r = requests.post( - THUHOLE_SEND_URL+token, - headers=headers, - data={ - 'pid': app.config.get('THUHOLE_PID'), - 'text': rt, - 'user_token': token - } - ) - r = requests.get( - THUHOLE_GET_URL+token, - headers=headers - ) - c = r.json() - data = c.get('data') - - mat = [c['name'] for c in data if c['text'].endswith(rt)] - - if mat: - name = mat[0] - else: - abort(401) - - name = 'th_' + ''.join(map(lambda s: s[0], name.split())) - - u = v = User.query.filter_by(name=name).first() - - if not u: - u = User(name=name) - db.session.add(u) - - if not v or False: #TODO: reset token - u.token = ''.join(random.choices(string.ascii_letters + string.digits, k=16)) - db.session.commit() - - return redirect('/?token='+ u.token) - except : - abort(401) - abort(404) + @app.route('/_auth') @limiter.limit("5 / minute") def auth(): # Currently, only for closed.social code = request.args.get('code') client = Mastodon( - client_id = app.config['CLIENT_ID'], - client_secret = app.config['CLIENT_SECRET'], - api_base_url = app.config['MASTODON_URL'] - ) - token = client.log_in( - code=code, - redirect_uri=app.config['REDIRECT_URI'], - scopes=['read:accounts'] - ) + client_id=app.config['CLIENT_ID'], + client_secret=app.config['CLIENT_SECRET'], + api_base_url=app.config['MASTODON_URL'] + ) + client.log_in( + code=code, + redirect_uri=app.config['REDIRECT_URI'], + scopes=['read:accounts'] + ) info = client.account_verify_credentials() name = 'cs_' + str(info.id) @@ -121,11 +72,16 @@ def auth(): u = User(name=name) db.session.add(u) - if not v or False: #TODO: reset token - u.token = ''.join(random.choices(string.ascii_letters + string.digits, k=16)) + if not v or False: # TODO: reset token + u.token = ''.join( + random.choices( + string.ascii_letters + + string.digits, + k=16)) db.session.commit() - return redirect('/?token='+ u.token) + return redirect('/?token=%s' % u.token) + @app.route('/_api/v1/getlist') def get_list(): @@ -136,54 +92,86 @@ def get_list(): posts = Post.query.filter_by(deleted=False) if 'no_cw' in request.args: posts = posts.filter_by(cw=None) - posts = posts.order_by(db.desc('comment_timestamp')) if 'by_c' in request.args else posts.order_by(db.desc('id')) + posts = posts.order_by( + db.desc('comment_timestamp')) if 'by_c' in request.args else posts.order_by( + db.desc('id')) posts = posts.paginate(p, PER_PAGE) - data =list(map(map_post, posts.items, [u.name] * len(posts.items))) + data = list(map(map_post, posts.items, [u.name] * len(posts.items))) return { - 'code': 0, - 'tmp_token': tmp_token(), - 'count': len(data), - 'data': data - } + 'code': 0, + 'tmp_token': tmp_token(), + 'count': len(data), + 'data': data + } + + @app.route('/_api/v1/getone') def get_one(): u = require_token() - - pid = get_num(request.args.get('pid')) + + pid = request.args.get('pid', type=int) post = Post.query.get(pid) - if not post: abort(404) - if post.deleted: abort(451) + if not post: + abort(404) + if post.deleted: + abort(451) data = map_post(post, u.name) return { - 'code': 0, - 'data': data - } + 'code': 0, + 'data': data + } + @app.route('/_api/v1/search') def search(): u = require_token() - page = get_num(request.args.get('page')) - pagesize = max(get_num(request.args.get('pagesize')), 200) + page = request.args.get('page', type=int, default=1) + pagesize = min(request.args.get('pagesize', type=int, default=200), 200) keywords = request.args.get('keywords') + if not keywords: + abort(422) - pids = [tr.pid for tr in TagRecord.query.filter_by(tag=keywords).order_by(db.desc('pid')).paginate(page, pagesize).items] + tag_pids = TagRecord.query.with_entities( + TagRecord.pid + ).filter_by( + tag=keywords + ).all() - data = [ map_post(Post.query.get(pid), u.name) - for pid in pids if Post.query.get(pid) and not Post.query.get(pid).deleted - ] + print(tag_pids) - return { - 'code': 0, - 'count': len(data), - 'data': data - } + tag_pids = [tag_pid for tag_pid, in tag_pids] or [0] # sql not allowed empty in + + posts = Post.query.filter( + Post.search_text.like("%{}%".format(keywords)) + ).filter( + Post.id.notin_(tag_pids) + ).order_by( + Post.id.desc() + ).limit(pagesize).offset((page - 1) * pagesize).all() + if page == 1: + posts = Post.query.filter( + Post.id.in_(tag_pids) + ).filter_by(deleted=False).order_by( + Post.id.desc() + ).all() + posts + + data = [ + map_post(post, u.name) + for post in posts + ] + + return { + 'code': 0, + 'count': len(data), + 'data': data + } @app.route('/_api/v1/dopost', methods=['POST']) @@ -191,24 +179,32 @@ def search(): def do_post(): u = require_token() + allow_search = request.form.get('allow_search') + print(allow_search) content = request.form.get('text') - content = content.strip() if content else None + content = content.strip() if content else None content = '[tmp]\n' + content if u.name[:4] == 'tmp_' else content post_type = request.form.get('type') cw = request.form.get('cw') - cw = cw.strip() if cw else None + cw = cw.strip() if cw else None + + if not content or len(content) > 4096: + abort(422) + if cw and len(cw) > 32: + abort(422) - if not content or len(content) > 4096: abort(422) - if cw and len(cw)>32: abort(422) + search_text = content.replace( + '\n', '') if allow_search else '' p = Post( - name_hash = hash_name(u.name), - content = content, - post_type = post_type, - cw = cw or None, - likenum = 1, - comments = [] - ) + name_hash=hash_name(u.name), + content=content, + search_text=search_text, + post_type=post_type, + cw=cw or None, + likenum=1, + comments=[] + ) if post_type == 'text': pass @@ -221,20 +217,21 @@ def do_post(): db.session.add(p) db.session.commit() - tags = re.findall('(^|\s)#([^#\s]{1,32})', content) - #print(tags) + tags = re.findall('(^|\\s)#([^#\\s]{1,32})', content) + # print(tags) for t in tags: tag = t[1] - if not re.match('\d+', tag): + if not re.match('\\d+', tag): db.session.add(TagRecord(tag=tag, pid=p.id)) db.session.add(Attention(name_hash=hash_name(u.name), pid=p.id)) db.session.commit() return { - 'code': 0, - 'date': p.id - } + 'code': 0, + 'date': p.id + } + @app.route('/_api/v1/editcw', methods=['POST']) @limiter.limit("50 / hour; 1 / 2 second") @@ -244,17 +241,21 @@ def edit_cw(): cw = request.form.get('cw') pid = get_num(request.form.get('pid')) - cw = cw.strip() if cw else None - if cw and len(cw)>32: abort(422) + cw = cw.strip() if cw else None + if cw and len(cw) > 32: + abort(422) post = Post.query.get(pid) - if not post: abort(404) - if post.deleted: abort(451) + if not post: + abort(404) + if post.deleted: + abort(451) - if not (u.name in app.config.get('ADMINS') or hash_name(u.name) == post.name_hash): + if not (u.name in app.config.get('ADMINS') + or hash_name(u.name) == post.name_hash): abort(403) - post.cw = cw; + post.cw = cw db.session.commit() return {'code': 0} @@ -267,17 +268,20 @@ def get_comment(): pid = get_num(request.args.get('pid')) post = Post.query.get(pid) - if not post: abort(404) - if post.deleted: abort(451) + if not post: + abort(404) + if post.deleted: + abort(451) data = map_comment(post, u.name) - + return { - 'code': 0, - 'attention': check_attention(u.name, pid), - 'likenum': post.likenum, - 'data': data - } + 'code': 0, + 'attention': check_attention(u.name, pid), + 'likenum': post.likenum, + 'data': data + } + @app.route('/_api/v1/docomment', methods=['POST']) @limiter.limit("50 / hour; 1 / 3 second") @@ -287,42 +291,51 @@ def do_comment(): pid = get_num(request.form.get('pid')) post = Post.query.get(pid) - if not post: abort(404) - if post.deleted: abort(451) + if not post: + abort(404) + if post.deleted: + abort(451) content = request.form.get('text') - content = content.strip() if content else None + content = content.strip() if content else None content = '[tmp]\n' + content if u.name[:4] == 'tmp_' else content - if not content or len(content) > 4096: abort(422) + if not content or len(content) > 4096: + abort(422) c = Comment( - name_hash = hash_name(u.name), - content = content, - ) + name_hash=hash_name(u.name), + content=content, + ) post.comments.append(c) post.comment_timestamp = c.timestamp db.session.commit() return { - 'code': 0, - 'data': pid - } + 'code': 0, + 'data': pid + } + @app.route('/_api/v1/attention', methods=['POST']) @limiter.limit("200 / hour; 1 / second") def attention(): u = require_token() - if u.name[:4] == 'tmp_': abort(403) + if u.name[:4] == 'tmp_': + abort(403) s = request.form.get('switch') - if s not in ['0', '1']: abort(422) + if s not in ['0', '1']: + abort(422) pid = get_num(request.form.get('pid')) - + post = Post.query.get(pid) - if not post: abort(404) + if not post: + abort(404) - at = Attention.query.filter_by(name_hash=hash_name(u.name), pid=pid).first() + at = Attention.query.filter_by( + name_hash=hash_name( + u.name), pid=pid).first() if not at: at = Attention(name_hash=hash_name(u.name), pid=pid, disabled=True) @@ -330,32 +343,36 @@ def attention(): if(at.disabled != (s == '0')): at.disabled = (s == '0') - post.likenum += 1 - 2 * int(s == '0'); + post.likenum += 1 - 2 * int(s == '0') db.session.commit() return { - 'code': 0, - 'likenum': post.likenum, - 'attention': (s=='1') - } + 'code': 0, + 'likenum': post.likenum, + 'attention': (s == '1') + } + @app.route('/_api/v1/getattention') def get_attention(): u = require_token() - ats = Attention.query.filter_by(name_hash=hash_name(u.name), disabled=False) + ats = Attention.query.filter_by( + name_hash=hash_name( + u.name), disabled=False) posts = [Post.query.get(at.pid) for at in ats.all()] - data = [ map_post(post, u.name, 10) + data = [map_post(post, u.name, 10) for post in posts[::-1] - if post and not post.deleted - ] + if post and not post.deleted + ] return { - 'code': 0, - 'count': len(data), - 'data': data - } + 'code': 0, + 'count': len(data), + 'data': data + } + @app.route('/_api/v1/delete', methods=['POST']) @limiter.limit("50 / hour; 1 / 3 second") @@ -366,18 +383,21 @@ def delete(): obj_id = get_num(request.form.get('id')) note = request.form.get('note') - if note and len(note)>100: abort(422) + if note and len(note) > 100: + abort(422) obj = None if obj_type == 'pid': obj = Post.query.get(obj_id) elif obj_type == 'cid': obj = Comment.query.get(obj_id) - if not obj: abort(404) + if not obj: + abort(404) if obj.name_hash == hash_name(u.name): if obj_type == 'pid': - if len(obj.comments): abort(403) + if len(obj.comments): + abort(403) Attention.query.filter_by(pid=obj.id).delete() TagRecord.query.filter_by(pid=obj.id).delete() db.session.delete(obj) @@ -389,31 +409,33 @@ def delete(): log_type='ADMIN DELETE', log_detail=f"{obj_type}={obj_id}\n{note}", name_hash=hash_name(u.name) - )) + )) if note.startswith('!ban'): db.session.add(Syslog( log_type='BANNED', log_detail=f"=> {obj_type}={obj_id}", name_hash=obj.name_hash - )) + )) else: abort(403) db.session.commit() return {'code': 0} + @app.route('/_api/v1/systemlog') def system_log(): - u = require_token() + require_token() ss = Syslog.query.order_by(db.desc('timestamp')).limit(100).all() return { - 'start_time': app.config['START_TIME'], - 'salt': look(app.config['SALT']), - 'tmp_token': tmp_token(), - 'data' : list(map(map_syslog, ss)) - } + 'start_time': app.config['START_TIME'], + 'salt': look(app.config['SALT']), + 'tmp_token': tmp_token(), + 'data': list(map(map_syslog, ss)) + } + @app.route('/_api/v1/report', methods=['POST']) @limiter.limit("50 / hour; 1 / 3 second") @@ -425,10 +447,10 @@ def report(): reason = request.form.get('reason', '') db.session.add(Syslog( - log_type='REPORT', - log_detail=f"pid={pid}\n{reason}", - name_hash=hash_name(u.name) - )) + log_type='REPORT', + log_detail=f"pid={pid}\n{reason}", + name_hash=hash_name(u.name) + )) db.session.commit() return {'code': 0} @@ -436,4 +458,3 @@ def report(): if __name__ == '__main__': app.run(debug=True) - diff --git a/models.py b/models.py index f845282..9d23cec 100644 --- a/models.py +++ b/models.py @@ -3,6 +3,7 @@ import time db = SQLAlchemy() + class User(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(16)) @@ -11,10 +12,12 @@ class User(db.Model): def __repr__(self): return f"{self.name}({self.token})" + class Post(db.Model): id = db.Column(db.Integer, primary_key=True) name_hash = db.Column(db.String(64)) content = db.Column(db.String(4096)) + search_text = db.Column(db.String(4096), default='', index=True) post_type = db.Column(db.String(8)) cw = db.Column(db.String(32)) file_url = db.Column(db.String(256)) @@ -32,6 +35,7 @@ class Post(db.Model): def __repr__(self): return f"{self.name_hash}:[{self.content}]" + class Comment(db.Model): id = db.Column(db.Integer, primary_key=True) name_hash = db.Column(db.String(64)) @@ -40,26 +44,29 @@ class Comment(db.Model): deleted = db.Column(db.Boolean, default=False) post_id = db.Column(db.Integer, db.ForeignKey('post.id'), - nullable=False) + nullable=False) def __init__(self, **kwargs): super(Comment, self).__init__(**kwargs) self.timestamp = int(time.time()) - + def __repr__(self): return f"{self.name_hash}:[{self.content}->{self.post_id}]" + class Attention(db.Model): id = db.Column(db.Integer, primary_key=True) name_hash = db.Column(db.String(64)) pid = db.Column(db.Integer) disabled = db.Column(db.Boolean, default=False) + class TagRecord(db.Model): id = db.Column(db.Integer, primary_key=True) tag = db.Column(db.String(32)) pid = db.Column(db.Integer) + class Syslog(db.Model): id = db.Column(db.Integer, primary_key=True) log_type = db.Column(db.String(16)) diff --git a/requirements.txt b/requirements.txt index ee2671b..09a3901 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -Flask==1.1.2 -Flask-Limit==1.0.2 -Flask-Limiter==1.3.1 -Flask-Login==0.5.0 -Flask-Migrate==2.5.3 -Flask-SQLAlchemy==2.4.4 -Mastodon.py==1.5.1 +Flask>=1.1.2 +Flask-Limit>=1.0.2 +Flask-Limiter>=1.3.1 +Flask-Login>=0.5.0 +Flask-Migrate>=2.5.3 +Flask-SQLAlchemy>=2.4.4 +Mastodon.py>=1.5.1 diff --git a/utils.py b/utils.py index ab8373e..7d9c479 100644 --- a/utils.py +++ b/utils.py @@ -1,44 +1,57 @@ -import hashlib, time +import hashlib +import time from flask import request, abort, current_app from models import User, Attention, Syslog + def get_config(key): return current_app.config.get(key) + def tmp_token(): - return hash_name(str(int(time.time() / 900)) + User.query.get(1).token)[5:21] + return hash_name(str(int(time.time() / 900)) + + User.query.get(1).token)[5:21] + def require_token(): token = request.headers.get('User-Token') or request.args.get('user_token') - if not token: abort(401) + if not token: + abort(401) if len(token.split('_')) == 2 and get_config('ENABLE_TMP'): tt, suf = token.split('_') - if tt != tmp_token(): abort(401) - return User(name='tmp_'+suf) - + if tt != tmp_token(): + abort(401) + return User(name='tmp_' + suf) u = User.query.filter_by(token=token).first() - if not u or Syslog.query.filter_by(log_type='BANNED', name_hash=hash_name(u.name)).first(): abort(401) + if not u or Syslog.query.filter_by( + log_type='BANNED', name_hash=hash_name(u.name)).first(): + abort(401) return u + def hash_name(name): - return hashlib.sha256((get_config('SALT') + name).encode('utf-8')).hexdigest() + return hashlib.sha256( + (get_config('SALT') + name).encode('utf-8')).hexdigest() + def map_post(p, name, mc=50): return { - 'pid': p.id, - 'likenum': p.likenum, - 'cw': p.cw, - 'text': p.content, - 'timestamp': p.timestamp, - 'type' : p.post_type, - 'url' : p.file_url, - 'reply': len(p.comments), - 'comments': map_comment(p, name) if len(p.comments) < mc else None, - 'attention': check_attention(name, p.id), - 'can_del': check_can_del(name, p.name_hash) - } + 'pid': p.id, + 'likenum': p.likenum, + 'cw': p.cw, + 'text': p.content, + 'timestamp': p.timestamp, + 'type': p.post_type, + 'url': p.file_url, + 'reply': len(p.comments), + 'comments': map_comment(p, name) if len(p.comments) < mc else None, + 'attention': check_attention(name, p.id), + 'can_del': check_can_del(name, p.name_hash), + 'allow_search': bool(p.search_text) + } + def map_comment(p, name): @@ -48,7 +61,7 @@ def map_comment(p, name): if nh not in names: names[nh] = len(names) return names[nh] - + return [{ 'cid': c.id, 'name_id': gen_name_id(c.name_hash), @@ -56,27 +69,37 @@ def map_comment(p, name): 'text': c.content, 'timestamp': c.timestamp, 'can_del': check_can_del(name, c.name_hash) - } for c in p.comments if not (c.deleted and gen_name_id(c.name_hash)>=0) + } for c in p.comments if not (c.deleted and gen_name_id(c.name_hash) >= 0) ] + def map_syslog(s): return { - 'type': s.log_type, - 'detail': s.log_detail, - 'user': look(s.name_hash), - 'timestamp': s.timestamp - } + 'type': s.log_type, + 'detail': s.log_detail, + 'user': look(s.name_hash), + 'timestamp': s.timestamp + } + def check_attention(name, pid): - at = Attention.query.filter_by(name_hash=hash_name(name), pid=pid, disabled=False).first() + at = Attention.query.filter_by( + name_hash=hash_name(name), + pid=pid, + disabled=False).first() return 1 if at else 0 + def check_can_del(name, author_hash): - return 1 if hash_name(name) == author_hash or name in get_config('ADMINS') else 0 + return 1 if hash_name( + name) == author_hash or name in get_config('ADMINS') else 0 + def look(s): return s[:3] + '...' + s[-3:] + def get_num(p): - if not (p and p.isdigit()): abort(422) + if not (p and p.isdigit()): + abort(422) return int(p)