From a646c96b865a8ef8b82abe8b539abda700207627 Mon Sep 17 00:00:00 2001
From: Mattia Succurro
Date: Sat, 12 Oct 2019 19:22:10 +0200
Subject: [PATCH 01/48] schema change; added flask-login
---
CHANGELOG.md | 7 +-
README.md | 2 +-
app.py | 127 ++++++++++++++++++-------------------
migrate_0_4_to_0_5.py | 15 +++++
requirements.txt | 5 +-
templates/base.html | 4 +-
templates/login.html | 9 ++-
templates/user_detail.html | 5 +-
8 files changed, 100 insertions(+), 74 deletions(-)
create mode 100644 migrate_0_4_to_0_5.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba9a021..d30ccf5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,11 @@
# Changelog
-## 0.4
+## 0.5-dev
+
+* Removed `type` and `info` fields from `Message` table and merged `privacy` field, previously into a separate table, into that table. In order to make the app work, when upgrading you should run the `migrate_0_4_to_0_5.py` script.
+* Added flask-login dependency. Now, user logins can be persistent up to 365 days.
+
+## 0.4.0
* Adding quick mention. You can now create a message mentioning another user in one click.
* Added mention notifications.
diff --git a/README.md b/README.md
index 53f8595..5602072 100644
--- a/README.md
+++ b/README.md
@@ -17,5 +17,5 @@ Based on Tweepee example of [peewee](https://github.com/coleifer/peewee/).
## Requirements
* **Python 3** only. We don't want to support Python 2.
-* **Flask** web framework.
+* **Flask** web framework (also required extension **Flask-Login**).
* **Peewee** ORM.
diff --git a/app.py b/app.py
index e0a484d..855254c 100644
--- a/app.py
+++ b/app.py
@@ -5,17 +5,27 @@ import hashlib
from peewee import *
import datetime, time, re, os, sys, string, json
from functools import wraps
+import argparse
+from flask_login import LoginManager, login_user, logout_user, login_required
-__version__ = '0.4.0'
+__version__ = '0.5-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')
-
+
+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('-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'])
@@ -39,6 +49,19 @@ class User(BaseModel):
# 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):
@@ -71,27 +94,24 @@ class User(BaseModel):
(Notification.target == self) & (Notification.seen == 0)
))
+# 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 type of the message.
- type = TextField()
# The user who posted the message.
user = ForeignKeyField(User, backref='messages')
# The text of the message.
text = TextField()
- # Additional info (in JSON format)
- # TODO: remove because it's dumb.
- info = TextField(default='{}')
# The posted date.
pub_date = DateTimeField()
# Info about privacy of the message.
- @property
- def privacy(self):
- try:
- return MessagePrivacy.get(MessagePrivacy.message == self).value
- except MessagePrivacy.DoesNotExist:
- # default to public
- return MSGPRV_PUBLIC
+ privacy = IntegerField(default=MSGPRV_PUBLIC)
+
def is_visible(self, is_public_timeline=False):
user = self.user
cur_user = get_current_user()
@@ -112,20 +132,6 @@ class Message(BaseModel):
else:
return False
-# 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
-
-# Doing it into a separate table to don't worry about schema change.
-# Added in v0.4.
-class MessagePrivacy(BaseModel):
- # The message.
- message = ForeignKeyField(Message, primary_key=True)
- # The privacy value. Needs to be one of these above.
- value = IntegerField()
-
# 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
@@ -162,7 +168,7 @@ class Notification(BaseModel):
def create_tables():
with database:
database.create_tables([
- User, Message, Relationship, Upload, Notification, MessagePrivacy])
+ User, Message, Relationship, Upload, Notification])
if not os.path.isdir(UPLOAD_DIRECTORY):
os.makedirs(UPLOAD_DIRECTORY)
@@ -263,30 +269,14 @@ class Visibility(object):
yield i
counter += 1
-# flask provides a "session" object, which allows us to store information across
-# requests (stored by default in a secure cookie). this function allows us to
-# mark a user as being logged-in by setting some values in the session data:
-def auth_user(user):
- session['logged_in'] = True
- session['user_id'] = user.id
- session['username'] = user.username
- flash('You are logged in as %s' % (user.username))
-
# get the user from the session
+# changed in 0.5 to comply with flask_login
def get_current_user():
- if session.get('logged_in'):
- return User.get(User.id == session['user_id'])
+ user_id = session.get('user_id')
+ if user_id:
+ return User[user_id]
-# view decorator which indicates that the requesting user must be authenticated
-# before they can access the view. it checks the session to see if they're
-# logged in, and if not redirects them to the login view.
-def login_required(f):
- @wraps(f)
- def inner(*args, **kwargs):
- if not session.get('logged_in'):
- return redirect(url_for('login'))
- return f(*args, **kwargs)
- return inner
+login_manager.login_view = 'login'
def push_notification(type, target, **kwargs):
try:
@@ -337,9 +327,9 @@ def after_request(response):
g.db.close()
return response
-@app.context_processor
-def _inject_user():
- return {'current_user': get_current_user()}
+@login_manager.user_loader
+def _inject_user(userid):
+ return User[userid]
@app.errorhandler(404)
def error_404(body):
@@ -347,7 +337,7 @@ def error_404(body):
@app.route('/')
def homepage():
- if session.get('logged_in'):
+ if get_current_user():
return private_timeline()
else:
return render_template('homepage.html')
@@ -395,7 +385,7 @@ def register():
join_date=datetime.datetime.now())
# mark the user as being 'authenticated' by setting the session vars
- auth_user(user)
+ login_user(user)
return redirect(request.args.get('next','/'))
except IntegrityError:
@@ -419,13 +409,18 @@ def login():
except User.DoesNotExist:
flash('A user with this username or email does not exist.')
else:
- auth_user(user)
+ 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():
- session.pop('logged_in', None)
+ logout_user()
flash('You were logged out')
return redirect(request.args.get('next','/'))
@@ -437,6 +432,7 @@ def user_detail(username):
# 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'])
@@ -455,7 +451,6 @@ def user_follow(username):
flash('You are following %s' % user.username)
push_notification('follow', user, user=cur_user.id)
- # TODO change to "profile.html"
return redirect(url_for('user_detail', username=user.username))
@app.route('/+/unfollow/', methods=['POST'])
@@ -485,11 +480,8 @@ def create():
type='text',
user=user,
text=text,
- pub_date=datetime.datetime.now())
- MessagePrivacy.create(
- message=message,
- value=privacy
- )
+ pub_date=datetime.datetime.now(),
+ privacy=privacy)
file = request.files.get('file')
if file:
print('Uploading', file.filename)
@@ -558,8 +550,9 @@ def uploads(id, type='jpg'):
@app.route('/ajax/username_availability/')
def username_availability(username):
- if session.get('logged_in'):
- current = get_current_user().username
+ current = get_current_user()
+ if current:
+ current = current.username
else:
current = None
is_valid = is_username(username)
@@ -585,5 +578,7 @@ def is_following(from_user, to_user):
# allow running from the command line
if __name__ == '__main__':
+ args = arg_parser.parse_args()
create_tables()
- app.run()
+ if not args.norun:
+ app.run(port=args.port)
diff --git a/migrate_0_4_to_0_5.py b/migrate_0_4_to_0_5.py
new file mode 100644
index 0000000..d64d23c
--- /dev/null
+++ b/migrate_0_4_to_0_5.py
@@ -0,0 +1,15 @@
+import config, sqlite3
+
+conn = sqlite3.connect(config.DATABASE)
+
+if __name__ == '__main__':
+ conn.executescript('''
+BEGIN TRANSACTION;
+ CREATE TABLE new_message ("id" INTEGER NOT NULL PRIMARY KEY, "user_id" INTEGER NOT NULL, "text" TEXT NOT NULL, "pub_date" DATETIME NOT NULL, "privacy" INTEGER DEFAULT 0, FOREIGN KEY ("user_id") REFERENCES "user" ("id"));
+ INSERT INTO new_message (id, user_id, text, pub_date, privacy) SELECT t1.id, t1.user_id, t1.text, t1.pub_date, t2.value FROM message AS t1 LEFT JOIN messageprivacy AS t2 ON t2.message_id = t1.id;
+ UPDATE new_message SET privacy = 0 WHERE privacy IS NULL;
+ DROP TABLE message;
+ DROP TABLE messageprivacy;
+ ALTER TABLE new_message RENAME TO message;
+COMMIT;
+''')
diff --git a/requirements.txt b/requirements.txt
index 1efada2..e21ebfc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
-flask
-peewee
+flask>=1.1.1
+peewee>=3.11.1
+flask-login>=0.4.1
diff --git a/templates/base.html b/templates/base.html
index 99e540a..15f48c0 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -12,14 +12,14 @@
{% include "includes/pagination.html" %}
From 9dfead5e9c2308466f9e0d0f87c86382ed664f38 Mon Sep 17 00:00:00 2001
From: Mattia Succurro
Date: Mon, 14 Oct 2019 21:06:53 +0200
Subject: [PATCH 04/48] Some fixes
---
app.py | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/app.py b/app.py
index ca3892a..4b910d9 100644
--- a/app.py
+++ b/app.py
@@ -320,7 +320,10 @@ def object_list(template_name, qr, var_name='object_list', **kwargs):
@app.before_request
def before_request():
g.db = database
- g.db.connect()
+ try:
+ g.db.connect()
+ except OperationalError:
+ sys.stderr.write('database connected twice.\n')
@app.after_request
def after_request(response):
@@ -584,7 +587,7 @@ def privacy():
def robots_txt():
return send_from_directory(os.getcwd(), 'robots.txt')
-@app.route('/uploads/.jpg')
+@app.route('/uploads/.')
def uploads(id, type='jpg'):
return send_from_directory(UPLOAD_DIRECTORY, id + '.' + type)
From 309009d3a414ac55c392e7748db6408f84322958 Mon Sep 17 00:00:00 2001
From: Mattia Succurro
Date: Mon, 14 Oct 2019 21:24:41 +0200
Subject: [PATCH 05/48] Other fixes
---
CHANGELOG.md | 1 +
app.py | 4 ++++
2 files changed, 5 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e33d07..9d64fc0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
* Added flask-login dependency. Now, user logins can be persistent up to 365 days.
* Rewritten `enrich` filter, correcting a serious security flaw. The new filter uses a tokenizer and escapes all non-markup text. Plus, now the `+` of the mention is visible, but weakened; newlines are now visible in the message.
* Now you can edit or change privacy to messages after they are published. After a message it's edited, the date and time of the message is changed.
+* Fixed a bug when uploading.
## 0.4.0
diff --git a/app.py b/app.py
index 4b910d9..be1068e 100644
--- a/app.py
+++ b/app.py
@@ -149,6 +149,10 @@ class Relationship(BaseModel):
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()
From 01cb4354e02f528e412b4f5d607220a14e8249c6 Mon Sep 17 00:00:00 2001
From: Mattia Succurro
Date: Tue, 15 Oct 2019 14:13:55 +0200
Subject: [PATCH 06/48] Moving site name to config.py
---
app.py | 6 +++++-
config.py | 2 +-
templates/base.html | 1 -
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/app.py b/app.py
index be1068e..04fb230 100644
--- a/app.py
+++ b/app.py
@@ -334,6 +334,10 @@ def after_request(response):
g.db.close()
return response
+@app.context_processor
+def _inject_variables():
+ return {'site_name': app.config['SITE_NAME']}
+
@login_manager.user_loader
def _inject_user(userid):
return User[userid]
@@ -618,7 +622,7 @@ _enrich_symbols = [
(r'https?://(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|\[[A-Fa-f0-9:]+\])'
r'(?::\d+)?(?:/.*)?(?:\?.*)?(?:#.*)?', 'URL'),
(_mention_re, 'MENTION'),
- (r'[^\n+]+', 'TEXT'),
+ (r'[^h\n+]+', 'TEXT'),
(r'.', 'TEXT')
]
diff --git a/config.py b/config.py
index c4ae536..4a13153 100644
--- a/config.py
+++ b/config.py
@@ -1,4 +1,4 @@
DATABASE = 'coriplus.sqlite'
DEBUG = True
SECRET_KEY = 'hin6bab8ge25*r=x&+5$0kn=-#log$pt^#@vrqjld!^2ci@g*b'
-
+SITE_NAME = 'Cori+'
diff --git a/templates/base.html b/templates/base.html
index 15f48c0..58f0c5e 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -1,7 +1,6 @@
- {% set site_name = "Cori+" %}
{{ site_name }}
From 313c001a63e8b157d2e3db827c301c3fc19dc557 Mon Sep 17 00:00:00 2001
From: Mattia Succurro
Date: Tue, 15 Oct 2019 16:32:36 +0200
Subject: [PATCH 07/48] Fixed url regex
---
CHANGELOG.md | 1 +
app.py | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d64fc0..58ee3f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
* Rewritten `enrich` filter, correcting a serious security flaw. The new filter uses a tokenizer and escapes all non-markup text. Plus, now the `+` of the mention is visible, but weakened; newlines are now visible in the message.
* Now you can edit or change privacy to messages after they are published. After a message it's edited, the date and time of the message is changed.
* Fixed a bug when uploading.
+* Moved the site name, previously hard-coded into templates, into `config.py`.
## 0.4.0
diff --git a/app.py b/app.py
index 04fb230..b7c92da 100644
--- a/app.py
+++ b/app.py
@@ -620,7 +620,7 @@ def username_availability(username):
_enrich_symbols = [
(r'\n', 'NEWLINE'),
(r'https?://(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|\[[A-Fa-f0-9:]+\])'
- r'(?::\d+)?(?:/.*)?(?:\?.*)?(?:#.*)?', 'URL'),
+ r'(?::\d+)?(?:/[^\s]*)?(?:\?[^\s]*)?(?:#[^\s]*)?', 'URL'),
(_mention_re, 'MENTION'),
(r'[^h\n+]+', 'TEXT'),
(r'.', 'TEXT')
From 156d58e5499802e0deb6eb56628b9b7dff46616f Mon Sep 17 00:00:00 2001
From: Mattia Succurro
Date: Wed, 16 Oct 2019 19:06:09 +0200
Subject: [PATCH 08/48] Changing version number
---
.gitignore | 1 +
CHANGELOG.md | 4 +++-
app.py | 2 +-
3 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
index dd7c762..0bd8872 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
coriplus.sqlite
+coriplus-*.sqlite
__pycache__/
uploads/
*.pyc
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58ee3f1..b7823b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,8 @@
# Changelog
-## 0.5-dev
+## 0.6-dev
+
+## 0.5.0
* Removed `type` and `info` fields from `Message` table and merged `privacy` field, previously into a separate table, into that table. In order to make the app work, when upgrading you should run the `migrate_0_4_to_0_5.py` script.
* Added flask-login dependency. Now, user logins can be persistent up to 365 days.
diff --git a/app.py b/app.py
index b7c92da..cd5cb29 100644
--- a/app.py
+++ b/app.py
@@ -8,7 +8,7 @@ from functools import wraps
import argparse
from flask_login import LoginManager, login_user, logout_user, login_required
-__version__ = '0.5-dev'
+__version__ = '0.6-dev'
# we want to support Python 3 only.
# Python 2 has too many caveats.
From 32e7c37158fbdf5e125eb80ebe695a26afef4e62 Mon Sep 17 00:00:00 2001
From: Mattia Succurro
Date: Thu, 17 Oct 2019 14:34:55 +0200
Subject: [PATCH 09/48] Adding profiles and adminship
---
CHANGELOG.md | 7 +++
app.py | 57 +++++++++++++++++++++++--
static/style.css | 5 +++
templates/edit_profile.html | 13 ++++++
templates/includes/infobox_profile.html | 30 +++++++++++++
templates/join.html | 26 +++++++++--
templates/user_detail.html | 11 +----
7 files changed, 133 insertions(+), 16 deletions(-)
create mode 100644 templates/edit_profile.html
create mode 100644 templates/includes/infobox_profile.html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7823b4..a63f553 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
## 0.6-dev
+* Added user adminship. Admins are users with very high privileges. Adminship can be assigned only at script level (not from the web).
+* Now one's messages won't show up in public timeline.
+* Added user profile info. Now you can specify your full name, biography, location, birth year, website, Facebook and Instagram. Of course this is totally optional.
+* Added reference to terms of service and privacy policy on signup page.
+* When visiting signup page as logged in, user should confirm he wants to create another account in order to do it.
+* Moved user stats inside profile info.
+
## 0.5.0
* Removed `type` and `info` fields from `Message` table and merged `privacy` field, previously into a separate table, into that table. In order to make the app work, when upgrading you should run the `migrate_0_4_to_0_5.py` script.
diff --git a/app.py b/app.py
index cd5cb29..9c04f8d 100644
--- a/app.py
+++ b/app.py
@@ -93,6 +93,38 @@ class User(BaseModel):
.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
@@ -118,11 +150,11 @@ class Message(BaseModel):
privacy = self.privacy
if user == cur_user:
# short path
- return True
+ # 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:
- # TODO user's posts may appear the same in public timeline,
# even if unlisted
return not is_public_timeline
elif privacy == MSGPRV_FRIENDS:
@@ -172,7 +204,8 @@ class Notification(BaseModel):
def create_tables():
with database:
database.create_tables([
- User, Message, Relationship, Upload, Notification])
+ User, UserAdminship, UserProfile, Message, Relationship,
+ Upload, Notification])
if not os.path.isdir(UPLOAD_DIRECTORY):
os.makedirs(UPLOAD_DIRECTORY)
@@ -384,6 +417,11 @@ def register():
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
@@ -394,6 +432,10 @@ def register():
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)
@@ -562,6 +604,15 @@ def edit(id):
#def confirm_delete(id):
# return render_template('confirm_delete.html')
+@app.route('/edit_profile/', methods=['GET', 'POST'])
+def edit_profile():
+ if request.method == 'POST':
+ user = get_current_user()
+ username = request.form['username']
+ if username != user.username:
+ User.update(username=username).where(User.id == user.id).execute()
+ return render_template('edit_profile.html')
+
@app.route('/notifications/')
@login_required
def notifications():
diff --git a/static/style.css b/static/style.css
index baa794c..650f108 100644
--- a/static/style.css
+++ b/static/style.css
@@ -8,7 +8,12 @@ body{margin:0}
.metanav{float:right}
.header h1{margin:0;display:inline-block}
.flash{background-color:#ff9;border:yellow 1px solid}
+.infobox{padding:12px;border:#ccc 1px solid}
+@media (min-width:640px) {
+ .infobox{float:right;width:320px}
+}
.weak{opacity:.5}
+.field_desc{display:block}
.message-visual img{max-width:100%;max-height:8em}
.message-options-showhide::before{content:'\2026'}
.message-options{display:none}
diff --git a/templates/edit_profile.html b/templates/edit_profile.html
new file mode 100644
index 0000000..7475d4f
--- /dev/null
+++ b/templates/edit_profile.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+
+{% block body %}
+
Edit Profile
+
+
+{% endblock %}
diff --git a/templates/includes/infobox_profile.html b/templates/includes/infobox_profile.html
new file mode 100644
index 0000000..730db0c
--- /dev/null
+++ b/templates/includes/infobox_profile.html
@@ -0,0 +1,30 @@
+{% set profile = user.profile %}
+
{% endif %}
{% if profile.website %}
+ {% set website = profile.website %}
+ {% set website = website if website.startswith(('http://', 'https://')) else 'http://' + website %}
Website: {{ profile.website|urlize }}
{% endif %}
{% if profile.instagram %}
diff --git a/templates/includes/location_selector.html b/templates/includes/location_selector.html
new file mode 100644
index 0000000..86861d4
--- /dev/null
+++ b/templates/includes/location_selector.html
@@ -0,0 +1,6 @@
+
diff --git a/templates/privacy.html b/templates/privacy.html
index 8bdfb55..a7b8570 100644
--- a/templates/privacy.html
+++ b/templates/privacy.html
@@ -3,5 +3,45 @@
{% block body %}
Privacy Policy
+
At {{ site_name }}, accessible from {{ request.host }}, one of our main priorities is the privacy of our visitors. This Privacy Policy document contains types of information that is collected and recorded by {{ site_name }} and how we use it.
+
If you have additional questions or require more information about our Privacy Policy, do not hesitate to contact us through email at sakuragasaki46@gmail.com
+
+
Log Files
+
+
{{ site_name }} follows a standard procedure of using log files. These files log visitors when they visit websites. All hosting companies do this and a part of hosting services' analytics. The information collected by log files include internet protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and time stamp, referring/exit pages, and possibly the number of clicks. These are not linked to any information that is personally identifiable. The purpose of the information is for analyzing trends, administering the site, tracking users' movement on the website, and gathering demographic information.
+
+
Cookies and Web Beacons
+
+
Like any other website, {{ site_name }} uses 'cookies'. These cookies are used to store information including visitors' preferences, and the pages on the website that the visitor accessed or visited. The information is used to optimize the users' experience by customizing our web page content based on visitors' browser type and/or other information.
+
+
+
+
Privacy Policies
+
+
You may consult this list to find the Privacy Policy for each of the advertising partners of {{ site_name }}. Our Privacy Policy was created with the help of the Privacy Policy Generator and the Generate Privacy Policy Generator.
+
+
Third-party ad servers or ad networks uses technologies like cookies, JavaScript, or Web Beacons that are used in their respective advertisements and links that appear on {{ site_name }}, which are sent directly to users' browser. They automatically receive your IP address when this occurs. These technologies are used to measure the effectiveness of their advertising campaigns and/or to personalize the advertising content that you see on websites that you visit.
+
+
Note that {{ site_name }} has no access to or control over these cookies that are used by third-party advertisers.
+
+
Third Party Privacy Policies
+
+
{{ site_name }}'s Privacy Policy does not apply to other advertisers or websites. Thus, we are advising you to consult the respective Privacy Policies of these third-party ad servers for more detailed information. It may include their practices and instructions about how to opt-out of certain options. You may find a complete list of these Privacy Policies and their links here: Privacy Policy Links.
+
+
You can choose to disable cookies through your individual browser options. To know more detailed information about cookie management with specific web browsers, it can be found at the browsers' respective websites. What Are Cookies?
+
+
Children's Information
+
+
Another part of our priority is adding protection for children while using the internet. We encourage parents and guardians to observe, participate in, and/or monitor and guide their online activity.
+
+
{{ site_name }} does not knowingly collect any Personal Identifiable Information from children under the age of 13. If you think that your child provided this kind of information on our website, we strongly encourage you to contact us immediately and we will do our best efforts to promptly remove such information from our records.
+
+
Online Privacy Policy Only
+
+
This Privacy Policy applies only to our online activities and is valid for visitors to our website with regards to the information that they shared and/or collect in {{ site_name }}. This policy is not applicable to any information collected offline or via channels other than this website.
+
+
Consent
+
+
By using our website, you hereby consent to our Privacy Policy and agree to its Terms and Conditions.
{% endblock %}
From d8f7d609aa651131a5b1ebef854950bd88844134 Mon Sep 17 00:00:00 2001
From: Mattia Succurro
Date: Sun, 20 Oct 2019 20:04:58 +0200
Subject: [PATCH 12/48] Fixed problem when entering invalid data while editing
profile
---
app.py | 33 ++++++++++++++++++++++++++++-----
static/.style.css.swp | Bin 0 -> 1024 bytes
static/style.css | 1 +
templates/base.html | 3 ++-
templates/edit_profile.html | 15 +++++++++++++--
5 files changed, 44 insertions(+), 8 deletions(-)
create mode 100644 static/.style.css.swp
diff --git a/app.py b/app.py
index 95529a1..c7fad25 100644
--- a/app.py
+++ b/app.py
@@ -18,6 +18,8 @@ if sys.version_info[0] < 3:
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')
@@ -628,6 +630,20 @@ def edit(id):
#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':
@@ -637,19 +653,26 @@ def edit_profile():
# prevent username to be set to empty
username = user.username
if username != user.username:
- User.update(username=username).where(User.id == user.id).execute()
+ 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')
+ 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,
- location=location
+ 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')
@@ -713,7 +736,7 @@ def username_availability(username):
def location_search(name):
results = []
for key, value in locations.items():
- if value.startswith(name):
+ if value.lower().startswith(name.lower()):
results.append({'value': key, 'display': value})
return jsonify({'results': results})
@@ -770,4 +793,4 @@ if __name__ == '__main__':
args = arg_parser.parse_args()
create_tables()
if not args.norun:
- app.run(port=args.port)
+ app.run(port=args.port, debug=args.debug)
diff --git a/static/.style.css.swp b/static/.style.css.swp
new file mode 100644
index 0000000000000000000000000000000000000000..e371f7802f15303029fdc9f60ce99dc8b238a71e
GIT binary patch
literal 1024
zcmYc?$V<%2S1{HyVn6|2Q49>Zi6teOi73KYIS_TaAsLx@*#U_ux~`^{rq~qfXXNLm
z>O)lPo0Md@7A5K@=NDxb
diff --git a/templates/edit_profile.html b/templates/edit_profile.html
index dad75e7..2feaf29 100644
--- a/templates/edit_profile.html
+++ b/templates/edit_profile.html
@@ -7,15 +7,26 @@
Username:
- {% set profile = current_user.profile %}
+ {% if not profile %}
+ {% set profile = current_user.profile %}
+ {% endif %}
Full name:
Biography:
Location:
{% include "includes/location_selector.html" %}
+
Generation:
+
+
+
+
Website:
-
+
+
Instagram:
+
+
Facebook:
+
From 635e3eaa2da43ae3b110105c718a94a535cfad4e Mon Sep 17 00:00:00 2001
From: Mattia Succurro
Date: Sun, 20 Oct 2019 20:19:20 +0200
Subject: [PATCH 13/48] Update readme and changelog
---
.gitignore | 1 +
CHANGELOG.md | 1 +
README.md | 1 +
static/.style.css.swp | Bin 1024 -> 0 bytes
4 files changed, 3 insertions(+)
delete mode 100644 static/.style.css.swp
diff --git a/.gitignore b/.gitignore
index 0bd8872..cbec1df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ __pycache__/
uploads/
*.pyc
**~
+**/.*.swp
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d5269a..e9bc814 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
* When visiting signup page as logged in, user should confirm he wants to create another account in order to do it.
* Moved user stats inside profile info.
* Adding Privacy Policy.
+* Adding links to Terms and Privacy at the bottom of any page.
## 0.5.0
diff --git a/README.md b/README.md
index 5602072..8139d41 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ Based on Tweepee example of [peewee](https://github.com/coleifer/peewee/).
* Create text statuses, optionally with image
* Follow users
* Timeline feed
+* Add info to your profile
* In-site notifications
* SQLite-based app
diff --git a/static/.style.css.swp b/static/.style.css.swp
deleted file mode 100644
index e371f7802f15303029fdc9f60ce99dc8b238a71e..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1024
zcmYc?$V<%2S1{HyVn6|2Q49>Zi6teOi73KYIS_TaAsLx@*#U_ux~`^{rq~qfXXNLm
z>O)lPo0Md@7A5K@=NDxb
Date: Sun, 20 Oct 2019 20:48:18 +0200
Subject: [PATCH 14/48] Preparing for release
---
CHANGELOG.md | 2 +-
app.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9bc814..db5a99a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
# Changelog
-## 0.6-dev
+## 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).
* Now one's messages won't show up in public timeline.
diff --git a/app.py b/app.py
index c7fad25..cce32b8 100644
--- a/app.py
+++ b/app.py
@@ -8,7 +8,7 @@ from functools import wraps
import argparse
from flask_login import LoginManager, login_user, logout_user, login_required
-__version__ = '0.6-dev'
+__version__ = '0.6.0'
# we want to support Python 3 only.
# Python 2 has too many caveats.
From a9006bf1bcd1ff316725a4c17943eb62a6bd4d9e Mon Sep 17 00:00:00 2001
From: Mattia Succurro
Date: Wed, 23 Oct 2019 21:09:51 +0200
Subject: [PATCH 15/48] 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 %}
+{% 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 %}
From 5536e764e7be92a690b6235adc80234344fb0e93 Mon Sep 17 00:00:00 2001
From: Mattia Succurro
Date: Thu, 24 Oct 2019 18:27:53 +0200
Subject: [PATCH 16/48] Added password change form
---
CHANGELOG.md | 4 ++
app/templates/change_password.html | 17 ++++++++
app/templates/confirm_delete.html | 22 ++++++++++
.../{private_messages.html => feed.html} | 0
app/templates/includes/message.html | 2 +-
app/templates/user_list.html | 0
app/utils.py | 34 +++++++++++++--
app/website.py | 42 ++++++++++++++++---
8 files changed, 111 insertions(+), 10 deletions(-)
create mode 100644 app/templates/change_password.html
create mode 100644 app/templates/confirm_delete.html
rename app/templates/{private_messages.html => feed.html} (100%)
mode change 100755 => 100644 app/templates/user_list.html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0398a37..82fcb82 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@
* 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.
+* Added the page for permanent deletion of messages. Well, you cannot delete them yet. It's missing a function that checks the CSRF-Token.
+* Renamed template `private_messages.html` to `feed.html`.
+* Added the capability to change password.
+* Corrected a bug into `pwdhash`: it accepted an argument, but pulled data from the form instead of processing it. Now it uses the argument.
* Schema changes: added column `telegram` to `UserProfile` table. To update schema, execute the script `migrate_0_6_to_0_7.py`
## 0.6.0
diff --git a/app/templates/change_password.html b/app/templates/change_password.html
new file mode 100644
index 0000000..afd4e28
--- /dev/null
+++ b/app/templates/change_password.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+
+{% block body %}
+
Are you sure you want to permanently delete this post?
+ Neither you nor others will be able to see it;
+ you cannot recover a post after it's deleted.
+
+
If you only want to hide it from the public,
+ you can set its privacy to "Only me".
+
+
Here's the content of the message for reference:
+
+
+
{% include "includes/message.html" %}
+
+
+
+{% endblock %}
diff --git a/app/templates/private_messages.html b/app/templates/feed.html
similarity index 100%
rename from app/templates/private_messages.html
rename to app/templates/feed.html
diff --git a/app/templates/includes/message.html b/app/templates/includes/message.html
index fc8a91f..59369c9 100644
--- a/app/templates/includes/message.html
+++ b/app/templates/includes/message.html
@@ -20,7 +20,7 @@
Permission is hereby granted, free of charge, to any person obtaining
diff --git a/src/coriplus/templates/admin_report_detail.html b/src/coriplus/templates/admin_report_detail.html
index d445d64..8f5d2c6 100644
--- a/src/coriplus/templates/admin_report_detail.html
+++ b/src/coriplus/templates/admin_report_detail.html
@@ -21,6 +21,7 @@
{% include "includes/reported_message.html" %}
{% endif %}