From a9006bf1bcd1ff316725a4c17943eb62a6bd4d9e Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Wed, 23 Oct 2019 21:09:51 +0200 Subject: [PATCH] Unpacking modules --- .gitignore | 1 + CHANGELOG.md | 8 + app.py | 796 ------------------ app/__init__.py | 87 ++ app/__main__.py | 29 + app/ajax.py | 37 + app/filters.py | 66 ++ app/models.py | 198 +++++ {static => app/static}/lib.js | 0 {static => app/static}/style.css | 0 {templates => app/templates}/404.html | 0 {templates => app/templates}/about.html | 3 +- {templates => app/templates}/base.html | 16 +- {templates => app/templates}/create.html | 2 +- {templates => app/templates}/edit.html | 2 +- .../templates}/edit_profile.html | 2 + {templates => app/templates}/explore.html | 0 app/templates/homepage.html | 7 + .../templates}/includes/infobox_profile.html | 7 +- .../includes/location_selector.html | 0 .../templates}/includes/message.html | 4 +- .../templates}/includes/notification.html | 0 .../templates}/includes/pagination.html | 0 {templates => app/templates}/join.html | 2 +- {templates => app/templates}/login.html | 0 .../templates}/notifications.html | 0 {templates => app/templates}/privacy.html | 0 .../templates}/private_messages.html | 0 {templates => app/templates}/terms.html | 0 {templates => app/templates}/user_detail.html | 4 +- app/templates/user_list.html | 10 + app/utils.py | 162 ++++ app/website.py | 332 ++++++++ config.py | 1 - migrate_0_6_to_0_7.py | 10 + templates/homepage.html | 7 - 36 files changed, 971 insertions(+), 822 deletions(-) delete mode 100644 app.py create mode 100644 app/__init__.py create mode 100644 app/__main__.py create mode 100644 app/ajax.py create mode 100644 app/filters.py create mode 100644 app/models.py rename {static => app/static}/lib.js (100%) rename {static => app/static}/style.css (100%) rename {templates => app/templates}/404.html (100%) rename {templates => app/templates}/about.html (92%) rename {templates => app/templates}/base.html (63%) rename {templates => app/templates}/create.html (89%) rename {templates => app/templates}/edit.html (88%) rename {templates => app/templates}/edit_profile.html (92%) rename {templates => app/templates}/explore.html (100%) create mode 100644 app/templates/homepage.html rename {templates => app/templates}/includes/infobox_profile.html (72%) rename {templates => app/templates}/includes/location_selector.html (100%) rename {templates => app/templates}/includes/message.html (84%) rename {templates => app/templates}/includes/notification.html (100%) rename {templates => app/templates}/includes/pagination.html (100%) rename {templates => app/templates}/join.html (94%) rename {templates => app/templates}/login.html (100%) rename {templates => app/templates}/notifications.html (100%) rename {templates => app/templates}/privacy.html (100%) rename {templates => app/templates}/private_messages.html (100%) rename {templates => app/templates}/terms.html (100%) rename {templates => app/templates}/user_detail.html (81%) create mode 100755 app/templates/user_list.html create mode 100644 app/utils.py create mode 100644 app/website.py create mode 100644 migrate_0_6_to_0_7.py delete mode 100644 templates/homepage.html diff --git a/.gitignore b/.gitignore index cbec1df..c9522f1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ uploads/ *.pyc **~ **/.*.swp +**/__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index db5a99a..0398a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.7-dev + +* Biggest change: unpacking modules. The single `app.py` file has become an `app` package, with submodules `models.py`, `utils.py`, `filters.py`, `website.py` and `ajax.py`. +* Now `/about/` shows Python and Flask versions. +* Now the error 404 handler returns HTTP 404. +* Added user followers and following lists, accessible via `/+/followers` and `/+/following` and from the profile info box, linked to the followers/following number. +* Schema changes: added column `telegram` to `UserProfile` table. To update schema, execute the script `migrate_0_6_to_0_7.py` + ## 0.6.0 * Added user adminship. Admins are users with very high privileges. Adminship can be assigned only at script level (not from the web). diff --git a/app.py b/app.py deleted file mode 100644 index cce32b8..0000000 --- a/app.py +++ /dev/null @@ -1,796 +0,0 @@ -from flask import ( - Flask, Markup, abort, flash, g, jsonify, redirect, render_template, request, - send_from_directory, session, url_for) -import hashlib -from peewee import * -import datetime, time, re, os, sys, string, json, html -from functools import wraps -import argparse -from flask_login import LoginManager, login_user, logout_user, login_required - -__version__ = '0.6.0' - -# we want to support Python 3 only. -# Python 2 has too many caveats. -if sys.version_info[0] < 3: - raise RuntimeError('Python 3 required') - -arg_parser = argparse.ArgumentParser() -arg_parser.add_argument('--norun', action='store_true', - help='Don\'t run the app. Useful for debugging.') -arg_parser.add_argument('--debug', action='store_true', - help='Run the app in debug mode.') -arg_parser.add_argument('-p', '--port', type=int, default=5000, - help='The port where to run the app. Defaults to 5000') - -app = Flask(__name__) -app.config.from_pyfile('config.py') - -login_manager = LoginManager(app) - -### DATABASE ### - -database = SqliteDatabase(app.config['DATABASE']) - -class BaseModel(Model): - class Meta: - database = database - -# A user. The user is separated from its page. -class User(BaseModel): - # The unique username. - username = CharField(unique=True) - # The password hash. - password = CharField() - # An email address. - email = CharField() - # The date of birth (required because of Terms of Service) - birthday = DateField() - # The date joined - join_date = DateTimeField() - # A disabled flag. 0 = active, 1 = disabled by user, 2 = banned - is_disabled = IntegerField(default=0) - - # Helpers for flask_login - def get_id(self): - return str(self.id) - @property - def is_active(self): - return not self.is_disabled - @property - def is_anonymous(self): - return False - @property - def is_authenticated(self): - return self == get_current_user() - - # it often makes sense to put convenience methods on model instances, for - # example, "give me all the users this user is following": - def following(self): - # query other users through the "relationship" table - return (User - .select() - .join(Relationship, on=Relationship.to_user) - .where(Relationship.from_user == self) - .order_by(User.username)) - - def followers(self): - return (User - .select() - .join(Relationship, on=Relationship.from_user) - .where(Relationship.to_user == self) - .order_by(User.username)) - - def is_following(self, user): - return (Relationship - .select() - .where( - (Relationship.from_user == self) & - (Relationship.to_user == user)) - .exists()) - - def unseen_notification_count(self): - return len(Notification - .select() - .where( - (Notification.target == self) & (Notification.seen == 0) - )) - # user adminship is stored into a separate table; new in 0.6 - @property - def is_admin(self): - return UserAdminship.select().where(UserAdminship.user == self).exists() - # user profile info; new in 0.6 - @property - def profile(self): - # lazy initialization; I don't want (and don't know how) - # to do schema changes. - try: - return UserProfile.get(UserProfile.user == self) - except UserProfile.DoesNotExist: - return UserProfile.create(user=self, full_name=self.username) - -# User adminship. -# A very high privilege where users can review posts. -# For very few users only; new in 0.6 -class UserAdminship(BaseModel): - user = ForeignKeyField(User, primary_key=True) - -# User profile. -# Additional info for identifying users. -# New in 0.6 -class UserProfile(BaseModel): - user = ForeignKeyField(User, primary_key=True) - full_name = TextField() - biography = TextField(default='') - location = IntegerField(null=True) - year = IntegerField(null=True) - website = TextField(null=True) - instagram = TextField(null=True) - facebook = TextField(null=True) - -# The message privacy values. -MSGPRV_PUBLIC = 0 # everyone -MSGPRV_UNLISTED = 1 # everyone, doesn't show up in public timeline -MSGPRV_FRIENDS = 2 # only accounts which follow each other -MSGPRV_ONLYME = 3 # only the poster - -# A single public message. -# New in v0.5: removed type and info fields; added privacy field. -class Message(BaseModel): - # The user who posted the message. - user = ForeignKeyField(User, backref='messages') - # The text of the message. - text = TextField() - # The posted date. - pub_date = DateTimeField() - # Info about privacy of the message. - privacy = IntegerField(default=MSGPRV_PUBLIC) - - def is_visible(self, is_public_timeline=False): - user = self.user - cur_user = get_current_user() - privacy = self.privacy - if user == cur_user: - # short path - # also: don't show user's messages in public timeline - return not is_public_timeline - elif privacy == MSGPRV_PUBLIC: - return True - elif privacy == MSGPRV_UNLISTED: - # even if unlisted - return not is_public_timeline - elif privacy == MSGPRV_FRIENDS: - if cur_user is None: - return False - return user.is_following(cur_user) and cur_user.is_following(user) - else: - return False - -# this model contains two foreign keys to user -- it essentially allows us to -# model a "many-to-many" relationship between users. by querying and joining -# on different columns we can expose who a user is "related to" and who is -# "related to" a given user -class Relationship(BaseModel): - from_user = ForeignKeyField(User, backref='relationships') - to_user = ForeignKeyField(User, backref='related_to') - created_date = DateTimeField() - - class Meta: - indexes = ( - # Specify a unique multi-column index on from/to-user. - (('from_user', 'to_user'), True), - ) - - -UPLOAD_DIRECTORY = 'uploads/' - -# fixing directory name because of imports from other directory -if __name__ != '__main__': - UPLOAD_DIRECTORY = os.path.join(os.path.dirname(__file__), UPLOAD_DIRECTORY) -class Upload(BaseModel): - # the extension of the media - type = TextField() - # the message bound to this media - message = ForeignKeyField(Message, backref='uploads') - # helper to retrieve contents - def filename(self): - return str(self.id) + '.' + self.type - -class Notification(BaseModel): - type = TextField() - target = ForeignKeyField(User, backref='notifications') - detail = TextField() - pub_date = DateTimeField() - seen = IntegerField(default=0) - -def create_tables(): - with database: - database.create_tables([ - User, UserAdminship, UserProfile, Message, Relationship, - Upload, Notification]) - if not os.path.isdir(UPLOAD_DIRECTORY): - os.makedirs(UPLOAD_DIRECTORY) - -### UTILS ### - -_forbidden_extensions = 'com net org txt'.split() -_username_characters = frozenset(string.ascii_letters + string.digits + '_') - -def is_username(username): - username_splitted = username.split('.') - if username_splitted and username_splitted[-1] in _forbidden_extensions: - return False - return all(x and set(x) < _username_characters for x in username_splitted) - -_mention_re = r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)' - -def validate_birthday(date): - today = datetime.date.today() - if today.year - date.year > 13: - return True - if today.year - date.year < 13: - return False - if today.month > date.month: - return True - if today.month < date.month: - return False - if today.day >= date.day: - return True - return False - -def validate_website(website): - return re.match(r'(?:https?://)?(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*' - r'|\[[A-Fa-f0-9:]+\])(?::\d+)?(?:/[^\s]*)?(?:\?[^\s]*)?(?:#[^\s]*)?$', - website) - -def human_short_date(timestamp): - return '' - -@app.template_filter() -def human_date(date): - timestamp = date.timestamp() - today = int(time.time()) - offset = today - timestamp - if offset <= 1: - return '1 second ago' - elif offset < 60: - return '%d seconds ago' % offset - elif offset < 120: - return '1 minute ago' - elif offset < 3600: - return '%d minutes ago' % (offset // 60) - elif offset < 7200: - return '1 hour ago' - elif offset < 86400: - return '%d hours ago' % (offset // 3600) - elif offset < 172800: - return '1 day ago' - elif offset < 604800: - return '%d days ago' % (offset // 86400) - else: - d = datetime.datetime.fromtimestamp(timestamp) - return d.strftime('%B %e, %Y') - -def int_to_b64(n): - b = int(n).to_bytes(48, 'big') - return base64.b64encode(b).lstrip(b'A').decode() - -def pwdhash(s): - return hashlib.md5((request.form['password']).encode('utf-8')).hexdigest() - -def get_object_or_404(model, *expressions): - try: - return model.get(*expressions) - except model.DoesNotExist: - abort(404) - -class Visibility(object): - ''' - Workaround for the visibility problem for posts. - Cannot be directly resolved with filter(). - - TODO find a better solution, this seems to be too slow. - ''' - def __init__(self, query, is_public_timeline=False): - self.query = query - self.is_public_timeline = is_public_timeline - def __iter__(self): - for i in self.query: - if i.is_visible(self.is_public_timeline): - yield i - def count(self): - counter = 0 - for i in self.query: - if i.is_visible(self.is_public_timeline): - counter += 1 - return counter - def paginate(self, page): - counter = 0 - pages_no = range((page - 1) * 20, page * 20) - for i in self.query: - if i.is_visible(self.is_public_timeline): - if counter in pages_no: - yield i - counter += 1 - -def get_locations(): - data = {} - with open('locations.txt') as f: - for line in f: - line = line.rstrip() - if line.startswith('#'): - continue - try: - key, value = line.split(None, 1) - except ValueError: - continue - data[key] = value - return data - -try: - locations = get_locations() -except OSError: - locations = {} - -# get the user from the session -# changed in 0.5 to comply with flask_login -def get_current_user(): - user_id = session.get('user_id') - if user_id: - return User[user_id] - -login_manager.login_view = 'login' - -def push_notification(type, target, **kwargs): - try: - if isinstance(target, str): - target = User.get(User.username == target) - Notification.create( - type=type, - target=target, - detail=json.dumps(kwargs), - pub_date=datetime.datetime.now() - ) - except Exception: - sys.excepthook(*sys.exc_info()) - -def unpush_notification(type, target, **kwargs): - try: - if isinstance(target, str): - target = User.get(User.username == target) - (Notification - .delete() - .where( - (Notification.type == type) & - (Notification.target == target) & - (Notification.detail == json.dumps(kwargs)) - ) - .execute()) - except Exception: - sys.excepthook(*sys.exc_info()) - -# given a template and a SelectQuery instance, render a paginated list of -# objects from the query inside the template -def object_list(template_name, qr, var_name='object_list', **kwargs): - kwargs.update( - page=int(request.args.get('page', 1)), - pages=qr.count() // 20 + 1) - kwargs[var_name] = qr.paginate(kwargs['page']) - return render_template(template_name, **kwargs) - -### WEB ### - -@app.before_request -def before_request(): - g.db = database - try: - g.db.connect() - except OperationalError: - sys.stderr.write('database connected twice.\n') - -@app.after_request -def after_request(response): - g.db.close() - return response - -@app.context_processor -def _inject_variables(): - return {'site_name': app.config['SITE_NAME'], 'locations': locations} - -@login_manager.user_loader -def _inject_user(userid): - return User[userid] - -@app.errorhandler(404) -def error_404(body): - return render_template('404.html') - -@app.route('/') -def homepage(): - if get_current_user(): - return private_timeline() - else: - return render_template('homepage.html') - -def private_timeline(): - # the private timeline (aka feed) exemplifies the use of a subquery -- we are asking for - # messages where the person who created the message is someone the current - # user is following. these messages are then ordered newest-first. - user = get_current_user() - messages = Visibility(Message - .select() - .where((Message.user << user.following()) - | (Message.user == user)) - .order_by(Message.pub_date.desc())) - # TODO change to "feed.html" - return object_list('private_messages.html', messages, 'message_list') - -@app.route('/explore/') -def public_timeline(): - messages = Visibility(Message - .select() - .order_by(Message.pub_date.desc()), True) - return object_list('explore.html', messages, 'message_list') - -@app.route('/signup/', methods=['GET', 'POST']) -def register(): - if request.method == 'POST' and request.form['username']: - try: - birthday = datetime.datetime.fromisoformat(request.form['birthday']) - except ValueError: - flash('Invalid date format') - return render_template('join.html') - username = request.form['username'].lower() - if not is_username(username): - flash('This username is invalid') - return render_template('join.html') - if username == getattr(get_current_user(), 'username', None) and not request.form.get('confirm_another'): - flash('You are already logged in. Please confirm you want to ' - 'create another account by checking the option.') - return render_template('join.html') - try: - with database.atomic(): - # Attempt to create the user. If the username is taken, due to the - # unique constraint, the database will raise an IntegrityError. - user = User.create( - username=username, - password=pwdhash(request.form['password']), - email=request.form['email'], - birthday=birthday, - join_date=datetime.datetime.now()) - UserProfile.create( - user=user, - full_name=request.form.get('full_name') or username - ) - - # mark the user as being 'authenticated' by setting the session vars - login_user(user) - return redirect(request.args.get('next','/')) - - except IntegrityError: - flash('That username is already taken') - - return render_template('join.html') - -@app.route('/login/', methods=['GET', 'POST']) -def login(): - if request.method == 'POST' and request.form['username']: - try: - username = request.form['username'] - pw_hash = pwdhash(request.form['password']) - if '@' in username: - user = User.get(User.email == username) - else: - user = User.get(User.username == username) - if user.password != pw_hash: - flash('The password entered is incorrect.') - return render_template('login.html') - except User.DoesNotExist: - flash('A user with this username or email does not exist.') - else: - remember_for = int(request.form['remember']) - if remember_for > 0: - login_user(user, remember=True, - duration=datetime.timedelta(days=remember_for)) - else: - login_user(user) - return redirect(request.args.get('next', '/')) - return render_template('login.html') - -@app.route('/logout/') -def logout(): - logout_user() - flash('You were logged out') - return redirect(request.args.get('next','/')) - -@app.route('/+/') -def user_detail(username): - user = get_object_or_404(User, User.username == username) - - # get all the users messages ordered newest-first -- note how we're accessing - # the messages -- user.message_set. could also have written it as: - # Message.select().where(Message.user == user) - messages = Visibility(user.messages.order_by(Message.pub_date.desc())) - # TODO change to "profile.html" - return object_list('user_detail.html', messages, 'message_list', user=user) - -@app.route('/+/follow/', methods=['POST']) -@login_required -def user_follow(username): - cur_user = get_current_user() - user = get_object_or_404(User, User.username == username) - try: - with database.atomic(): - Relationship.create( - from_user=cur_user, - to_user=user, - created_date=datetime.datetime.now()) - except IntegrityError: - pass - - flash('You are following %s' % user.username) - push_notification('follow', user, user=cur_user.id) - return redirect(url_for('user_detail', username=user.username)) - -@app.route('/+/unfollow/', methods=['POST']) -@login_required -def user_unfollow(username): - cur_user = get_current_user() - user = get_object_or_404(User, User.username == username) - (Relationship - .delete() - .where( - (Relationship.from_user == cur_user) & - (Relationship.to_user == user)) - .execute()) - flash('You are no longer following %s' % user.username) - unpush_notification('follow', user, user=cur_user.id) - return redirect(url_for('user_detail', username=user.username)) - - -@app.route('/create/', methods=['GET', 'POST']) -@login_required -def create(): - user = get_current_user() - if request.method == 'POST' and request.form['text']: - text = request.form['text'] - privacy = int(request.form.get('privacy', '0')) - message = Message.create( - user=user, - text=text, - pub_date=datetime.datetime.now(), - privacy=privacy) - file = request.files.get('file') - if file: - print('Uploading', file.filename) - ext = file.filename.split('.')[-1] - upload = Upload.create( - type=ext, - message=message - ) - file.save(UPLOAD_DIRECTORY + str(upload.id) + '.' + ext) - # create mentions - mention_usernames = set() - for mo in re.finditer(_mention_re, text): - mention_usernames.add(mo.group(1)) - # to avoid self mention - mention_usernames.difference_update({user.username}) - for u in mention_usernames: - try: - mention_user = User.get(User.username == u) - if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ - (privacy == MSGPRV_FRIENDS and - mention_user.is_following(user) and - user.is_following(mention_user)): - push_notification('mention', mention_user, user=user.id) - except User.DoesNotExist: - pass - flash('Your message has been posted successfully') - return redirect(url_for('user_detail', username=user.username)) - return render_template('create.html') - -@app.route('/edit/', methods=['GET', 'POST']) -@login_required -def edit(id): - user = get_current_user() - message = get_object_or_404(Message, Message.id == id) - if message.user != user: - abort(404) - if request.method == 'POST' and (request.form['text'] != message.text or - request.form['privacy'] != message.privacy): - text = request.form['text'] - privacy = int(request.form.get('privacy', '0')) - Message.update( - text=text, - privacy=privacy, - pub_date=datetime.datetime.now() - ).where(Message.id == id).execute() - # edit uploads (skipped for now) - # create mentions - mention_usernames = set() - for mo in re.finditer(_mention_re, text): - mention_usernames.add(mo.group(1)) - # to avoid self mention - mention_usernames.difference_update({user.username}) - for u in mention_usernames: - try: - mention_user = User.get(User.username == u) - if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ - (privacy == MSGPRV_FRIENDS and - mention_user.is_following(user) and - user.is_following(mention_user)): - push_notification('mention', mention_user, user=user.id) - except User.DoesNotExist: - pass - flash('Your message has been edited successfully') - return redirect(url_for('user_detail', username=user.username)) - return render_template('edit.html', message=message) - -#@app.route('/delete/', methods=['GET', 'POST']) -#def confirm_delete(id): -# return render_template('confirm_delete.html') - -# Workaround for problems related to invalid data. -# Without that, changes will be lost across requests. -def profile_checkpoint(): - return UserProfile( - user=get_current_user(), - full_name=request.form['full_name'], - biography=request.form['biography'], - location=int(request.form['location']), - year=int(request.form['year'] if request.form.get('has_year') else '0'), - website=request.form['website'] or None, - instagram=request.form['instagram'] or None, - facebook=request.form['facebook'] or None - ) - -@app.route('/edit_profile/', methods=['GET', 'POST']) -def edit_profile(): - if request.method == 'POST': - user = get_current_user() - username = request.form['username'] - if not username: - # prevent username to be set to empty - username = user.username - if username != user.username: - try: - User.update(username=username).where(User.id == user.id).execute() - except IntegrityError: - flash('That username is already taken') - return render_template('edit_profile.html', profile=profile_checkpoint()) - website = request.form['website'].strip().replace(' ', '%20') - if website and not validate_website(website): - flash('You should enter a valid URL.') - return render_template('edit_profile.html', profile=profile_checkpoint()) - location = int(request.form.get('location')) - if location == 0: - location = None - UserProfile.update( - full_name=request.form['full_name'] or username, - biography=request.form['biography'], - year=request.form['year'] if request.form.get('has_year') else None, - location=location, - website=website, - instagram=request.form['instagram'], - facebook=request.form['facebook'] - ).where(UserProfile.user == user).execute() - return redirect(url_for('user_detail', username=username)) - return render_template('edit_profile.html') - -@app.route('/notifications/') -@login_required -def notifications(): - user = get_current_user() - notifications = (Notification - .select() - .where(Notification.target == user) - .order_by(Notification.pub_date.desc())) - - with database.atomic(): - (Notification - .update(seen=1) - .where((Notification.target == user) & (Notification.seen == 0)) - .execute()) - return object_list('notifications.html', notifications, 'notification_list', json=json, User=User) - -@app.route('/about/') -def about(): - return render_template('about.html', version=__version__) - -# The two following routes are mandatory by law. -@app.route('/terms/') -def terms(): - return render_template('terms.html') - -@app.route('/privacy/') -def privacy(): - return render_template('privacy.html') - -@app.route('/robots.txt') -def robots_txt(): - return send_from_directory(os.getcwd(), 'robots.txt') - -@app.route('/uploads/.') -def uploads(id, type='jpg'): - return send_from_directory(UPLOAD_DIRECTORY, id + '.' + type) - -@app.route('/ajax/username_availability/') -def username_availability(username): - current = get_current_user() - if current: - current = current.username - else: - current = None - is_valid = is_username(username) - if is_valid: - try: - user = User.get(User.username == username) - is_available = current == user.username - except User.DoesNotExist: - is_available = True - else: - is_available = False - return jsonify({'is_valid':is_valid, 'is_available':is_available, 'status':'ok'}) - -@app.route('/ajax/location_search/') -def location_search(name): - results = [] - for key, value in locations.items(): - if value.lower().startswith(name.lower()): - results.append({'value': key, 'display': value}) - return jsonify({'results': results}) - -_enrich_symbols = [ - (r'\n', 'NEWLINE'), - (r'https?://(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|\[[A-Fa-f0-9:]+\])' - r'(?::\d+)?(?:/[^\s]*)?(?:\?[^\s]*)?(?:#[^\s]*)?', 'URL'), - (_mention_re, 'MENTION'), - (r'[^h\n+]+', 'TEXT'), - (r'.', 'TEXT') -] - -def _tokenize(characters, table): - pos = 0 - tokens = [] - while pos < len(characters): - mo = None - for pattern, tag in table: - mo = re.compile(pattern).match(characters, pos) - if mo: - if tag: - text = mo.group(0) - tokens.append((text, tag)) - break - pos = mo.end(0) - return tokens - -@app.template_filter() -def enrich(s): - tokens = _tokenize(s, _enrich_symbols) - r = [] - for text, tag in tokens: - if tag == 'TEXT': - r.append(html.escape(text)) - elif tag == 'URL': - r.append('{0}'.format(html.escape(text))) - elif tag == 'MENTION': - r.append('+{1}'.format(text, text.lstrip('+'))) - elif tag == 'NEWLINE': - r.append('
') - return Markup(''.join(r)) - -@app.template_filter('is_following') -def is_following(from_user, to_user): - return from_user.is_following(to_user) - -@app.template_filter('locationdata') -def locationdata(key): - if key > 0: - return locations[str(key)] - -# allow running from the command line -if __name__ == '__main__': - args = arg_parser.parse_args() - create_tables() - if not args.norun: - app.run(port=args.port, debug=args.debug) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..d7cb66d --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,87 @@ +''' +Cori+ +===== + +The root module of the package. +This module also contains very basic web hooks, such as robots.txt. + +For the website hooks, see `app.website`. +For the AJAX hook, see `app.ajax`. +For template filters, see `app.filters`. +For the database models, see `app.models`. +For other, see `app.utils`. +''' + +from flask import ( + Flask, abort, flash, g, jsonify, redirect, render_template, request, + send_from_directory, session, url_for, __version__ as flask_version) +import hashlib +from peewee import * +import datetime, time, re, os, sys, string, json, html +from functools import wraps +from flask_login import LoginManager + +__version__ = '0.7-dev' + +# we want to support Python 3 only. +# Python 2 has too many caveats. +if sys.version_info[0] < 3: + raise RuntimeError('Python 3 required') + +app = Flask(__name__) +app.config.from_pyfile('../config.py') + +login_manager = LoginManager(app) + +from .models import * + +from .utils import * + +from .filters import * + +### WEB ### + +login_manager.login_view = 'website.login' + +@app.before_request +def before_request(): + g.db = database + try: + g.db.connect() + except OperationalError: + sys.stderr.write('database connected twice.\n') + +@app.after_request +def after_request(response): + g.db.close() + return response + +@app.context_processor +def _inject_variables(): + return {'site_name': app.config['SITE_NAME'], 'locations': locations} + +@login_manager.user_loader +def _inject_user(userid): + return User[userid] + +@app.errorhandler(404) +def error_404(body): + return render_template('404.html'), 404 + +@app.route('/robots.txt') +def robots_txt(): + return send_from_directory(os.getcwd(), 'robots.txt') + +@app.route('/uploads/.') +def uploads(id, type='jpg'): + return send_from_directory(UPLOAD_DIRECTORY, id + '.' + type) + +from .website import bp +app.register_blueprint(bp) + +from .ajax import bp +app.register_blueprint(bp) + + + + diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..f09c3fc --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,29 @@ +''' +Run the app as module. + +You can also use `flask run` on the parent directory of the package. + +XXX Using "--debug" argument currently causes an ImportError. +''' + +import argparse +from . import app +from .models import create_tables + +arg_parser = argparse.ArgumentParser() +arg_parser.add_argument('--norun', action='store_true', + help='Don\'t run the app. Useful for debugging.') +arg_parser.add_argument('--no-create-tables', action='store_true', + help='Don\'t create tables.') +arg_parser.add_argument('--debug', action='store_true', + help='Run the app in debug mode.') +arg_parser.add_argument('-p', '--port', type=int, default=5000, + help='The port where to run the app. Defaults to 5000') + +args = arg_parser.parse_args() + +if not args.no_create_tables: + create_tables() + +if not args.norun: + app.run(port=args.port, debug=args.debug) diff --git a/app/ajax.py b/app/ajax.py new file mode 100644 index 0000000..63dc532 --- /dev/null +++ b/app/ajax.py @@ -0,0 +1,37 @@ +''' +AJAX hooks for the website. + +Warning: this is not the public API. +''' + +from flask import Blueprint, jsonify +from .models import User +from .utils import locations, get_current_user, is_username + +bp = Blueprint('ajax', __name__, url_prefix='/ajax') + +@bp.route('/username_availability/') +def username_availability(username): + current = get_current_user() + if current: + current = current.username + else: + current = None + is_valid = is_username(username) + if is_valid: + try: + user = User.get(User.username == username) + is_available = current == user.username + except User.DoesNotExist: + is_available = True + else: + is_available = False + return jsonify({'is_valid':is_valid, 'is_available':is_available, 'status':'ok'}) + +@bp.route('/location_search/') +def location_search(name): + results = [] + for key, value in locations.items(): + if value.lower().startswith(name.lower()): + results.append({'value': key, 'display': value}) + return jsonify({'results': results}) diff --git a/app/filters.py b/app/filters.py new file mode 100644 index 0000000..2735d9d --- /dev/null +++ b/app/filters.py @@ -0,0 +1,66 @@ +''' +Filter functions used in the website templates. +''' + +from flask import Markup +import html, datetime, re, time +from .utils import tokenize +from . import app + +@app.template_filter() +def human_date(date): + timestamp = date.timestamp() + today = int(time.time()) + offset = today - timestamp + if offset <= 1: + return '1 second ago' + elif offset < 60: + return '%d seconds ago' % offset + elif offset < 120: + return '1 minute ago' + elif offset < 3600: + return '%d minutes ago' % (offset // 60) + elif offset < 7200: + return '1 hour ago' + elif offset < 86400: + return '%d hours ago' % (offset // 3600) + elif offset < 172800: + return '1 day ago' + elif offset < 604800: + return '%d days ago' % (offset // 86400) + else: + d = datetime.datetime.fromtimestamp(timestamp) + return d.strftime('%B %e, %Y') + +_enrich_symbols = [ + (r'\n', 'NEWLINE'), + (r'https?://(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|\[[A-Fa-f0-9:]+\])' + r'(?::\d+)?(?:/[^\s]*)?(?:\?[^\s]*)?(?:#[^\s]*)?', 'URL'), + (r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', 'MENTION'), + (r'[^h\n+]+', 'TEXT'), + (r'.', 'TEXT') +] + +@app.template_filter() +def enrich(s): + tokens = tokenize(s, _enrich_symbols) + r = [] + for text, tag in tokens: + if tag == 'TEXT': + r.append(html.escape(text)) + elif tag == 'URL': + r.append('{0}'.format(html.escape(text))) + elif tag == 'MENTION': + r.append('+{1}'.format(text, text.lstrip('+'))) + elif tag == 'NEWLINE': + r.append('
') + return Markup(''.join(r)) + +@app.template_filter('is_following') +def is_following(from_user, to_user): + return from_user.is_following(to_user) + +@app.template_filter('locationdata') +def locationdata(key): + if key > 0: + return locations[str(key)] diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..a03ad7b --- /dev/null +++ b/app/models.py @@ -0,0 +1,198 @@ +''' +Database models for the application. + +The tables are: +* user - the basic account info, such as username and password +* useradminship - relationship which existence determines whether a user is admin or not; new in 0.6 +* userprofile - additional account info for self describing; new in 0.6 +* message - a status update, appearing in profile and feeds +* relationship - a follow relationship between users +* upload - a file upload attached to a message; new in 0.2 +* notification - a in-site notification to a user; new in 0.3 +''' + +from peewee import * +import os +# here should go `from .utils import get_current_user`, but it will cause +# import errors. It's instead imported at function level. + +database = SqliteDatabase(os.path.join(os.getcwd(), 'coriplus.sqlite')) + +class BaseModel(Model): + class Meta: + database = database + +# A user. The user is separated from its page. +class User(BaseModel): + # The unique username. + username = CharField(unique=True) + # The password hash. + password = CharField() + # An email address. + email = CharField() + # The date of birth (required because of Terms of Service) + birthday = DateField() + # The date joined + join_date = DateTimeField() + # A disabled flag. 0 = active, 1 = disabled by user, 2 = banned + is_disabled = IntegerField(default=0) + + # Helpers for flask_login + def get_id(self): + return str(self.id) + @property + def is_active(self): + return not self.is_disabled + @property + def is_anonymous(self): + return False + @property + def is_authenticated(self): + from .utils import get_current_user + return self == get_current_user() + + # it often makes sense to put convenience methods on model instances, for + # example, "give me all the users this user is following": + def following(self): + # query other users through the "relationship" table + return (User + .select() + .join(Relationship, on=Relationship.to_user) + .where(Relationship.from_user == self) + .order_by(User.username)) + + def followers(self): + return (User + .select() + .join(Relationship, on=Relationship.from_user) + .where(Relationship.to_user == self) + .order_by(User.username)) + + def is_following(self, user): + return (Relationship + .select() + .where( + (Relationship.from_user == self) & + (Relationship.to_user == user)) + .exists()) + + def unseen_notification_count(self): + return len(Notification + .select() + .where( + (Notification.target == self) & (Notification.seen == 0) + )) + # user adminship is stored into a separate table; new in 0.6 + @property + def is_admin(self): + return UserAdminship.select().where(UserAdminship.user == self).exists() + # user profile info; new in 0.6 + @property + def profile(self): + # lazy initialization; I don't want (and don't know how) + # to do schema changes. + try: + return UserProfile.get(UserProfile.user == self) + except UserProfile.DoesNotExist: + return UserProfile.create(user=self, full_name=self.username) + +# User adminship. +# A very high privilege where users can review posts. +# For very few users only; new in 0.6 +class UserAdminship(BaseModel): + user = ForeignKeyField(User, primary_key=True) + +# User profile. +# Additional info for identifying users. +# New in 0.6 +class UserProfile(BaseModel): + user = ForeignKeyField(User, primary_key=True) + full_name = TextField() + biography = TextField(default='') + location = IntegerField(null=True) + year = IntegerField(null=True) + website = TextField(null=True) + instagram = TextField(null=True) + facebook = TextField(null=True) + telegram = TextField(null=True) + +# The message privacy values. +MSGPRV_PUBLIC = 0 # everyone +MSGPRV_UNLISTED = 1 # everyone, doesn't show up in public timeline +MSGPRV_FRIENDS = 2 # only accounts which follow each other +MSGPRV_ONLYME = 3 # only the poster + +# A single public message. +# New in v0.5: removed type and info fields; added privacy field. +class Message(BaseModel): + # The user who posted the message. + user = ForeignKeyField(User, backref='messages') + # The text of the message. + text = TextField() + # The posted date. + pub_date = DateTimeField() + # Info about privacy of the message. + privacy = IntegerField(default=MSGPRV_PUBLIC) + + def is_visible(self, is_public_timeline=False): + from .utils import get_current_user + user = self.user + cur_user = get_current_user() + privacy = self.privacy + if user == cur_user: + # short path + # also: don't show user's messages in public timeline + return not is_public_timeline + elif privacy == MSGPRV_PUBLIC: + return True + elif privacy == MSGPRV_UNLISTED: + # even if unlisted + return not is_public_timeline + elif privacy == MSGPRV_FRIENDS: + if cur_user is None: + return False + return user.is_following(cur_user) and cur_user.is_following(user) + else: + return False + +# this model contains two foreign keys to user -- it essentially allows us to +# model a "many-to-many" relationship between users. by querying and joining +# on different columns we can expose who a user is "related to" and who is +# "related to" a given user +class Relationship(BaseModel): + from_user = ForeignKeyField(User, backref='relationships') + to_user = ForeignKeyField(User, backref='related_to') + created_date = DateTimeField() + + class Meta: + indexes = ( + # Specify a unique multi-column index on from/to-user. + (('from_user', 'to_user'), True), + ) + + +UPLOAD_DIRECTORY = os.path.join(os.path.split(os.path.dirname(__file__))[0], 'uploads') + +class Upload(BaseModel): + # the extension of the media + type = TextField() + # the message bound to this media + message = ForeignKeyField(Message, backref='uploads') + # helper to retrieve contents + def filename(self): + return str(self.id) + '.' + self.type + +class Notification(BaseModel): + type = TextField() + target = ForeignKeyField(User, backref='notifications') + detail = TextField() + pub_date = DateTimeField() + seen = IntegerField(default=0) + +def create_tables(): + with database: + database.create_tables([ + User, UserAdminship, UserProfile, Message, Relationship, + Upload, Notification]) + if not os.path.isdir(UPLOAD_DIRECTORY): + os.makedirs(UPLOAD_DIRECTORY) diff --git a/static/lib.js b/app/static/lib.js similarity index 100% rename from static/lib.js rename to app/static/lib.js diff --git a/static/style.css b/app/static/style.css similarity index 100% rename from static/style.css rename to app/static/style.css diff --git a/templates/404.html b/app/templates/404.html similarity index 100% rename from templates/404.html rename to app/templates/404.html diff --git a/templates/about.html b/app/templates/about.html similarity index 92% rename from templates/about.html rename to app/templates/about.html index 29c7a98..e5691a8 100644 --- a/templates/about.html +++ b/app/templates/about.html @@ -3,7 +3,8 @@ {% block body %}

About {{ site_name }}

-

Version: {{ version }}

+

{{ site_name }} {{ version }} – Python {{ python_version }} – + Flask {{ flask_version }}

Copyright © 2019 Sakuragasaki46.

License

diff --git a/templates/base.html b/app/templates/base.html similarity index 63% rename from templates/base.html rename to app/templates/base.html index 783853a..74ee636 100644 --- a/templates/base.html +++ b/app/templates/base.html @@ -9,21 +9,21 @@
-

{{ site_name }}

+

{{ site_name }}

{% if current_user.is_anonymous %} - log in - register + log in + register {% else %} - {{ current_user.username }} + {{ current_user.username }} {% set notification_count = current_user.unseen_notification_count() %} {% if notification_count > 0 %} - ({{ notification_count }}) + ({{ notification_count }}) {% endif %} - - explore - create - log out + explore + create + log out {% endif %}
diff --git a/templates/create.html b/app/templates/create.html similarity index 89% rename from templates/create.html rename to app/templates/create.html index 4d89d6e..8f31263 100644 --- a/templates/create.html +++ b/app/templates/create.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block body %}

Create

-
+
Message:
diff --git a/templates/edit.html b/app/templates/edit.html similarity index 88% rename from templates/edit.html rename to app/templates/edit.html index 7111f33..2de4877 100644 --- a/templates/edit.html +++ b/app/templates/edit.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block body %}

Edit

- +
Message:
diff --git a/templates/edit_profile.html b/app/templates/edit_profile.html similarity index 92% rename from templates/edit_profile.html rename to app/templates/edit_profile.html index 2feaf29..349d6bd 100644 --- a/templates/edit_profile.html +++ b/app/templates/edit_profile.html @@ -27,6 +27,8 @@
Facebook:
+
Telegram:
+
diff --git a/templates/explore.html b/app/templates/explore.html similarity index 100% rename from templates/explore.html rename to app/templates/explore.html diff --git a/app/templates/homepage.html b/app/templates/homepage.html new file mode 100644 index 0000000..106bf9a --- /dev/null +++ b/app/templates/homepage.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block body %} +

Hello

+ +

{{ site_name }} is made by people like you.
+Log in or register to see more.

+{% endblock %} diff --git a/templates/includes/infobox_profile.html b/app/templates/includes/infobox_profile.html similarity index 72% rename from templates/includes/infobox_profile.html rename to app/templates/includes/infobox_profile.html index d268eae..f20df41 100644 --- a/templates/includes/infobox_profile.html +++ b/app/templates/includes/infobox_profile.html @@ -19,12 +19,15 @@ {% if profile.facebook %}

Facebook: {{ profile.facebook }}

{% endif %} + {% if profile.telegram %} +

Telegram: {{ profile.telegram }}

+ {% endif %}

{{ user.messages|count }} messages - - {{ user.followers()|count }} followers + {{ user.followers()|count }} followers - - {{ user.following()|count }} following + {{ user.following()|count }} following

{% if user == current_user %}

Edit profile

diff --git a/templates/includes/location_selector.html b/app/templates/includes/location_selector.html similarity index 100% rename from templates/includes/location_selector.html rename to app/templates/includes/location_selector.html diff --git a/templates/includes/message.html b/app/templates/includes/message.html similarity index 84% rename from templates/includes/message.html rename to app/templates/includes/message.html index 7f63d9b..fc8a91f 100644 --- a/templates/includes/message.html +++ b/app/templates/includes/message.html @@ -1,11 +1,11 @@

{{ message.text|enrich }}

{% if message.uploads %}
- +
{% endif %}

Join {{ site_name }}

-
+
Username:
diff --git a/templates/login.html b/app/templates/login.html similarity index 100% rename from templates/login.html rename to app/templates/login.html diff --git a/templates/notifications.html b/app/templates/notifications.html similarity index 100% rename from templates/notifications.html rename to app/templates/notifications.html diff --git a/templates/privacy.html b/app/templates/privacy.html similarity index 100% rename from templates/privacy.html rename to app/templates/privacy.html diff --git a/templates/private_messages.html b/app/templates/private_messages.html similarity index 100% rename from templates/private_messages.html rename to app/templates/private_messages.html diff --git a/templates/terms.html b/app/templates/terms.html similarity index 100% rename from templates/terms.html rename to app/templates/terms.html diff --git a/templates/user_detail.html b/app/templates/user_detail.html similarity index 81% rename from templates/user_detail.html rename to app/templates/user_detail.html index 9316432..5a5c42f 100644 --- a/templates/user_detail.html +++ b/app/templates/user_detail.html @@ -5,11 +5,11 @@ {% if not current_user.is_anonymous %} {% if user.username != current_user.username %} {% if current_user|is_following(user) %} - + {% else %} -
+
{% endif %} diff --git a/app/templates/user_list.html b/app/templates/user_list.html new file mode 100755 index 0000000..b694e28 --- /dev/null +++ b/app/templates/user_list.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block body %} +

{{ title }}

+ +{% endblock %} diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..f6370a2 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,162 @@ +''' +A list of utilities used across modules. +''' + +import datetime, re, base64, hashlib, string +from .models import User, Notification +from flask import abort, render_template, request, session +import sys, json + +_forbidden_extensions = 'com net org txt'.split() +_username_characters = frozenset(string.ascii_letters + string.digits + '_') + +def is_username(username): + username_splitted = username.split('.') + if username_splitted and username_splitted[-1] in _forbidden_extensions: + return False + return all(x and set(x) < _username_characters for x in username_splitted) + +def validate_birthday(date): + today = datetime.date.today() + if today.year - date.year > 13: + return True + if today.year - date.year < 13: + return False + if today.month > date.month: + return True + if today.month < date.month: + return False + if today.day >= date.day: + return True + return False + +def validate_website(website): + return re.match(r'(?:https?://)?(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*' + r'|\[[A-Fa-f0-9:]+\])(?::\d+)?(?:/[^\s]*)?(?:\?[^\s]*)?(?:#[^\s]*)?$', + website) + +def human_short_date(timestamp): + return '' + +def int_to_b64(n): + b = int(n).to_bytes(48, 'big') + return base64.b64encode(b).lstrip(b'A').decode() + +def pwdhash(s): + return hashlib.md5((request.form['password']).encode('utf-8')).hexdigest() + +def get_object_or_404(model, *expressions): + try: + return model.get(*expressions) + except model.DoesNotExist: + abort(404) + +class Visibility(object): + ''' + Workaround for the visibility problem for posts. + Cannot be directly resolved with filter(). + + TODO find a better solution, this seems to be too slow. + ''' + def __init__(self, query, is_public_timeline=False): + self.query = query + self.is_public_timeline = is_public_timeline + def __iter__(self): + for i in self.query: + if i.is_visible(self.is_public_timeline): + yield i + def count(self): + counter = 0 + for i in self.query: + if i.is_visible(self.is_public_timeline): + counter += 1 + return counter + def paginate(self, page): + counter = 0 + pages_no = range((page - 1) * 20, page * 20) + for i in self.query: + if i.is_visible(self.is_public_timeline): + if counter in pages_no: + yield i + counter += 1 + +def get_locations(): + data = {} + with open('locations.txt') as f: + for line in f: + line = line.rstrip() + if line.startswith('#'): + continue + try: + key, value = line.split(None, 1) + except ValueError: + continue + data[key] = value + return data + +try: + locations = get_locations() +except OSError: + locations = {} + +# get the user from the session +# changed in 0.5 to comply with flask_login +def get_current_user(): + user_id = session.get('user_id') + if user_id: + return User[user_id] + +def push_notification(type, target, **kwargs): + try: + if isinstance(target, str): + target = User.get(User.username == target) + Notification.create( + type=type, + target=target, + detail=json.dumps(kwargs), + pub_date=datetime.datetime.now() + ) + except Exception: + sys.excepthook(*sys.exc_info()) + +def unpush_notification(type, target, **kwargs): + try: + if isinstance(target, str): + target = User.get(User.username == target) + (Notification + .delete() + .where( + (Notification.type == type) & + (Notification.target == target) & + (Notification.detail == json.dumps(kwargs)) + ) + .execute()) + except Exception: + sys.excepthook(*sys.exc_info()) + +# given a template and a SelectQuery instance, render a paginated list of +# objects from the query inside the template +def object_list(template_name, qr, var_name='object_list', **kwargs): + kwargs.update( + page=int(request.args.get('page', 1)), + pages=qr.count() // 20 + 1) + kwargs[var_name] = qr.paginate(kwargs['page']) + return render_template(template_name, **kwargs) + +def tokenize(characters, table): + ''' + A useful tokenizer. + ''' + pos = 0 + tokens = [] + while pos < len(characters): + mo = None + for pattern, tag in table: + mo = re.compile(pattern).match(characters, pos) + if mo: + if tag: + text = mo.group(0) + tokens.append((text, tag)) + break + pos = mo.end(0) + return tokens diff --git a/app/website.py b/app/website.py new file mode 100644 index 0000000..e463d37 --- /dev/null +++ b/app/website.py @@ -0,0 +1,332 @@ +''' +All website hooks, excluding AJAX. +''' + +from .utils import * +from .models import * +from . import __version__ as app_version +from sys import version as python_version +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for, __version__ as flask_version +from flask_login import login_required, login_user, logout_user +import json + +bp = Blueprint('website', __name__) + +@bp.route('/') +def homepage(): + if get_current_user(): + return private_timeline() + else: + return render_template('homepage.html') + +def private_timeline(): + # the private timeline (aka feed) exemplifies the use of a subquery -- we are asking for + # messages where the person who created the message is someone the current + # user is following. these messages are then ordered newest-first. + user = get_current_user() + messages = Visibility(Message + .select() + .where((Message.user << user.following()) + | (Message.user == user)) + .order_by(Message.pub_date.desc())) + # TODO change to "feed.html" + return object_list('private_messages.html', messages, 'message_list') + +@bp.route('/explore/') +def public_timeline(): + messages = Visibility(Message + .select() + .order_by(Message.pub_date.desc()), True) + return object_list('explore.html', messages, 'message_list') + +@bp.route('/signup/', methods=['GET', 'POST']) +def register(): + if request.method == 'POST' and request.form['username']: + try: + birthday = datetime.datetime.fromisoformat(request.form['birthday']) + except ValueError: + flash('Invalid date format') + return render_template('join.html') + username = request.form['username'].lower() + if not is_username(username): + flash('This username is invalid') + return render_template('join.html') + if username == getattr(get_current_user(), 'username', None) and not request.form.get('confirm_another'): + flash('You are already logged in. Please confirm you want to ' + 'create another account by checking the option.') + return render_template('join.html') + try: + with database.atomic(): + # Attempt to create the user. If the username is taken, due to the + # unique constraint, the database will raise an IntegrityError. + user = User.create( + username=username, + password=pwdhash(request.form['password']), + email=request.form['email'], + birthday=birthday, + join_date=datetime.datetime.now()) + UserProfile.create( + user=user, + full_name=request.form.get('full_name') or username + ) + + # mark the user as being 'authenticated' by setting the session vars + login_user(user) + return redirect(request.args.get('next','/')) + + except IntegrityError: + flash('That username is already taken') + + return render_template('join.html') + +@bp.route('/login/', methods=['GET', 'POST']) +def login(): + if request.method == 'POST' and request.form['username']: + try: + username = request.form['username'] + pw_hash = pwdhash(request.form['password']) + if '@' in username: + user = User.get(User.email == username) + else: + user = User.get(User.username == username) + if user.password != pw_hash: + flash('The password entered is incorrect.') + return render_template('login.html') + except User.DoesNotExist: + flash('A user with this username or email does not exist.') + else: + remember_for = int(request.form['remember']) + if remember_for > 0: + login_user(user, remember=True, + duration=datetime.timedelta(days=remember_for)) + else: + login_user(user) + return redirect(request.args.get('next', '/')) + return render_template('login.html') + +@bp.route('/logout/') +def logout(): + logout_user() + flash('You were logged out') + return redirect(request.args.get('next','/')) + +@bp.route('/+/') +def user_detail(username): + user = get_object_or_404(User, User.username == username) + + # get all the users messages ordered newest-first -- note how we're accessing + # the messages -- user.message_set. could also have written it as: + # Message.select().where(Message.user == user) + messages = Visibility(user.messages.order_by(Message.pub_date.desc())) + # TODO change to "profile.html" + return object_list('user_detail.html', messages, 'message_list', user=user) + +@bp.route('/+/follow/', methods=['POST']) +@login_required +def user_follow(username): + cur_user = get_current_user() + user = get_object_or_404(User, User.username == username) + try: + with database.atomic(): + Relationship.create( + from_user=cur_user, + to_user=user, + created_date=datetime.datetime.now()) + except IntegrityError: + pass + + flash('You are following %s' % user.username) + push_notification('follow', user, user=cur_user.id) + return redirect(url_for('website.user_detail', username=user.username)) + +@bp.route('/+/unfollow/', methods=['POST']) +@login_required +def user_unfollow(username): + cur_user = get_current_user() + user = get_object_or_404(User, User.username == username) + (Relationship + .delete() + .where( + (Relationship.from_user == cur_user) & + (Relationship.to_user == user)) + .execute()) + flash('You are no longer following %s' % user.username) + unpush_notification('follow', user, user=cur_user.id) + return redirect(url_for('website.user_detail', username=user.username)) + +@bp.route('/+/followers/') +@login_required +def user_followers(username): + user = get_object_or_404(User, User.username == username) + return object_list('user_list.html', user.followers(), 'user_list', + title='%s\'s followers' % username) + +@bp.route('/+/following/') +@login_required +def user_following(username): + user = get_object_or_404(User, User.username == username) + return object_list('user_list.html', user.following(), 'user_list', + title='Accounts followed by %s' % username) + +@bp.route('/create/', methods=['GET', 'POST']) +@login_required +def create(): + user = get_current_user() + if request.method == 'POST' and request.form['text']: + text = request.form['text'] + privacy = int(request.form.get('privacy', '0')) + message = Message.create( + user=user, + text=text, + pub_date=datetime.datetime.now(), + privacy=privacy) + file = request.files.get('file') + if file: + print('Uploading', file.filename) + ext = file.filename.split('.')[-1] + upload = Upload.create( + type=ext, + message=message + ) + file.save(UPLOAD_DIRECTORY + str(upload.id) + '.' + ext) + # create mentions + mention_usernames = set() + for mo in re.finditer(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', text): + mention_usernames.add(mo.group(1)) + # to avoid self mention + mention_usernames.difference_update({user.username}) + for u in mention_usernames: + try: + mention_user = User.get(User.username == u) + if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ + (privacy == MSGPRV_FRIENDS and + mention_user.is_following(user) and + user.is_following(mention_user)): + push_notification('mention', mention_user, user=user.id) + except User.DoesNotExist: + pass + flash('Your message has been posted successfully') + return redirect(url_for('website.user_detail', username=user.username)) + return render_template('create.html') + +@bp.route('/edit/', methods=['GET', 'POST']) +@login_required +def edit(id): + user = get_current_user() + message = get_object_or_404(Message, Message.id == id) + if message.user != user: + abort(404) + if request.method == 'POST' and (request.form['text'] != message.text or + request.form['privacy'] != message.privacy): + text = request.form['text'] + privacy = int(request.form.get('privacy', '0')) + Message.update( + text=text, + privacy=privacy, + pub_date=datetime.datetime.now() + ).where(Message.id == id).execute() + # edit uploads (skipped for now) + # create mentions + mention_usernames = set() + for mo in re.finditer(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', text): + mention_usernames.add(mo.group(1)) + # to avoid self mention + mention_usernames.difference_update({user.username}) + for u in mention_usernames: + try: + mention_user = User.get(User.username == u) + if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \ + (privacy == MSGPRV_FRIENDS and + mention_user.is_following(user) and + user.is_following(mention_user)): + push_notification('mention', mention_user, user=user.id) + except User.DoesNotExist: + pass + flash('Your message has been edited successfully') + return redirect(url_for('website.user_detail', username=user.username)) + return render_template('edit.html', message=message) + +#@bp.route('/delete/', methods=['GET', 'POST']) +#def confirm_delete(id): +# return render_template('confirm_delete.html') + +# Workaround for problems related to invalid data. +# Without that, changes will be lost across requests. +def profile_checkpoint(): + return UserProfile( + user=get_current_user(), + full_name=request.form['full_name'], + biography=request.form['biography'], + location=int(request.form['location']), + year=int(request.form['year'] if request.form.get('has_year') else '0'), + website=request.form['website'] or None, + instagram=request.form['instagram'] or None, + facebook=request.form['facebook'] or None + ) + +@bp.route('/edit_profile/', methods=['GET', 'POST']) +@login_required +def edit_profile(): + if request.method == 'POST': + user = get_current_user() + username = request.form['username'] + if not username: + # prevent username to be set to empty + username = user.username + if username != user.username: + try: + User.update(username=username).where(User.id == user.id).execute() + except IntegrityError: + flash('That username is already taken') + return render_template('edit_profile.html', profile=profile_checkpoint()) + website = request.form['website'].strip().replace(' ', '%20') + if website and not validate_website(website): + flash('You should enter a valid URL.') + return render_template('edit_profile.html', profile=profile_checkpoint()) + location = int(request.form.get('location')) + if location == 0: + location = None + UserProfile.update( + full_name=request.form['full_name'] or username, + biography=request.form['biography'], + year=request.form['year'] if request.form.get('has_year') else None, + location=location, + website=website, + instagram=request.form['instagram'], + facebook=request.form['facebook'], + telegram=request.form['telegram'] + ).where(UserProfile.user == user).execute() + return redirect(url_for('website.user_detail', username=username)) + return render_template('edit_profile.html') + +@bp.route('/notifications/') +@login_required +def notifications(): + user = get_current_user() + notifications = (Notification + .select() + .where(Notification.target == user) + .order_by(Notification.pub_date.desc())) + + with database.atomic(): + (Notification + .update(seen=1) + .where((Notification.target == user) & (Notification.seen == 0)) + .execute()) + return object_list('notifications.html', notifications, 'notification_list', json=json, User=User) + +@bp.route('/about/') +def about(): + return render_template('about.html', version=app_version, + python_version=python_version, flask_version=flask_version) + +# The two following routes are mandatory by law. +@bp.route('/terms/') +def terms(): + return render_template('terms.html') + +@bp.route('/privacy/') +def privacy(): + return render_template('privacy.html') + + diff --git a/config.py b/config.py index 4a13153..a6e2b64 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,3 @@ -DATABASE = 'coriplus.sqlite' DEBUG = True SECRET_KEY = 'hin6bab8ge25*r=x&+5$0kn=-#log$pt^#@vrqjld!^2ci@g*b' SITE_NAME = 'Cori+' diff --git a/migrate_0_6_to_0_7.py b/migrate_0_6_to_0_7.py new file mode 100644 index 0000000..c65e73c --- /dev/null +++ b/migrate_0_6_to_0_7.py @@ -0,0 +1,10 @@ +import sqlite3 + +conn = sqlite3.connect('coriplus.sqlite') + +if __name__ == '__main__': + conn.executescript(''' +BEGIN TRANSACTION; + ALTER TABLE userprofile ADD COLUMN telegram TEXT; +COMMIT; +''') diff --git a/templates/homepage.html b/templates/homepage.html deleted file mode 100644 index da0aa3d..0000000 --- a/templates/homepage.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

Hello

- -

{{ site_name }} is made by people like you.
-Log in or register to see more.

-{% endblock %}