Compare commits

...

48 commits

Author SHA1 Message Date
b29fa75226 add csrf_token to JavaScript actions 2025-12-19 11:10:32 +01:00
8369035693 implement permanent deletion, make user profile migration reversible 2025-11-26 16:50:42 +01:00
536e49d1b9 schema changes 2025-11-12 11:02:53 +01:00
9071f5ff7a change credential access for /admin/, style changes, fix and deprecate get_current_user() 2025-11-12 10:34:57 +01:00
c834424836 style changes + regularize admin platform access + add link to material icons font 2025-11-08 20:02:52 +01:00
a5695707b0 add migrations 2025-11-06 17:19:00 +01:00
c46dce5e3b add CSRF token 2025-11-06 07:25:07 +01:00
be24a37f5c move old migrations away from project root 2025-11-06 06:36:02 +01:00
71f7bd1a3b refactor code layout, move config to .env, add pyproject.toml 2025-11-06 06:33:31 +01:00
b874b989bf 0.9.0 2025-11-06 05:48:17 +01:00
71619dba2b Fix imports 2024-06-16 11:31:56 +02:00
5ba9f1d7d5 CSS changes 2024-06-16 11:22:54 +02:00
8b5e2ed41b Add inline_svg 2021-10-17 10:20:47 +02:00
baed59ea39 Adding notifications and +1's to messages 2019-11-25 09:39:33 +01:00
29cf1532f7 Adding explore endpoint and fixing bugs 2019-11-22 18:20:32 +01:00
d40a8b9b6b Preparing for release 2019-11-21 20:11:14 +01:00
d115e80e41 Fixing taberror 2019-11-20 12:49:24 +01:00
3e1c3bfebe Fixing edit_profile endpoint 2019-11-20 12:46:33 +01:00
42552f12be Commenting out some entries on edit_profile endpoint 2019-11-20 12:30:40 +01:00
621d8cf2c8 Adding message edit support for API 2019-11-18 19:19:06 +01:00
6c128d0567 Adding admin and report endpoints 2019-11-11 19:15:55 +01:00
af299a53c7 Added create2 endpoint 2019-11-09 15:00:06 +01:00
7fb5c47e4d Added new API endpoints 2019-11-08 16:51:32 +01:00
a70b4f2eae Schema and version number changes 2019-11-06 11:12:11 +01:00
ef8d5343e9 Added profile stats to API 2019-11-05 22:15:16 +01:00
c57088c6c3 Preparing for release 2019-11-05 17:03:58 +01:00
7ede351b11 Adding profile_feed and profile_search endpoints 2019-11-04 22:09:04 +01:00
8d97d1fbf7 Added profile_info endpoint 2019-10-31 17:03:14 +01:00
09172d9c1e Adding create API endpoint 2019-10-31 16:38:43 +01:00
0b7711fe26 Changing login mechanism 2019-10-28 09:03:44 +01:00
09a809192a Some fixes 2019-10-28 08:29:07 +01:00
dc33b5567a Adding feed to public API 2019-10-27 11:30:14 +01:00
5536e764e7 Added password change form 2019-10-24 18:27:53 +02:00
a9006bf1bc Unpacking modules 2019-10-23 21:09:51 +02:00
1e7787e24e Preparing for release 2019-10-20 20:48:18 +02:00
635e3eaa2d Update readme and changelog 2019-10-20 20:19:20 +02:00
d8f7d609aa Fixed problem when entering invalid data while editing profile 2019-10-20 20:04:58 +02:00
b9467583b7 Adding location and privacy policy 2019-10-20 13:14:16 +02:00
bfc44c9362 Now you can edit username, full name, biography and website 2019-10-17 15:21:33 +02:00
32e7c37158 Adding profiles and adminship 2019-10-17 14:34:55 +02:00
156d58e549 Changing version number 2019-10-16 19:06:09 +02:00
313c001a63 Fixed url regex 2019-10-15 16:32:36 +02:00
01cb4354e0 Moving site name to config.py 2019-10-15 14:13:55 +02:00
309009d3a4 Other fixes 2019-10-14 21:24:41 +02:00
9dfead5e9c Some fixes 2019-10-14 21:06:53 +02:00
755e7b70be Added the capability to edit messages 2019-10-14 19:53:33 +02:00
5e7c6097d4 Changed enrich filter 2019-10-14 14:30:22 +02:00
a646c96b86 schema change; added flask-login 2019-10-12 19:22:10 +02:00
80 changed files with 3267 additions and 799 deletions

16
.gitignore vendored
View file

@ -1,5 +1,21 @@
coriplus.sqlite coriplus.sqlite
coriplus-*.sqlite
__pycache__/ __pycache__/
uploads/ uploads/
*.pyc *.pyc
**~ **~
.*.swp
__pycache__/
venv
.env
.venv
env
data/
conf/
config/
\#*\#
.\#*
node_modules/
alembic.ini
**.egg-info
.vscode

View file

@ -1,6 +1,81 @@
# Changelog # Changelog
## 0.4 ## 0.10.0
+ Codebase refactor (with breaking changes!)
+ Dropped support for Python<=3.9
+ Switched database to PostgreSQL
+ Move ALL config to .env (config.py is NO MORE supported)
+ Config SITE_NAME replaced with APP_NAME
+ Add CSRF token and flask_WTF
+ Schema changes: biography and website moved to `User`; `UserProfile` table deprecated (and useless fields removed)
+ Posts can now be permanently deleted
+ Miscellaneous style changes
## 0.9.0
* Website redesign: added some material icons, implemented via a `inline_svg` function, injected by default in templates and defined in `utils.py`.
* Added positive feedback mechanism: now you can +1 a message. So, `score_message_add` and `score_message_remove` API endpoints were added, and `MessageUpvote` table was created.
* Added notifications support for API.
* Added `create_account` endpoint to API. This endpoint does not require an access token.
* Added `explore`, `notifications_count`, `notifications` and `notifications_seen` endpoints.
* Added `has_more` field to feed endpoints (`feed`, `explore` and `profile_feed`).
* Added `join_date` field into `user` object of `profile_info` endpoint, for more profile transparency.
* Added `/favicon.ico`.
* Fixed some bugs when creating mentions and using offsets in feeds.
## 0.8.0
* Added the admin dashboard, accessible from `/admin/` via basic auth. Only users with admin right can access it. Added endpoints `admin.reports` and `admin.reports_detail`.
* Safety is our top priority: added the ability to report someone other's post for everything violating the site's Terms of Service. The current reasons for reporting are: spam, impersonation, pornography, violence, harassment or bullying, hate speech or symbols, self injury, sale or promotion of firearms or drugs, and underage use.
* Schema changes: moved `full_name` field from table `userprofile` to table `user` for search improvement reasons. Added `Report` model.
* Now `profile_search` API endpoint searches by full name too.
* Adding `messages_count`, `followers_count` and `following_count` to `profile_info` API endpoint (what I've done to 0.7.1 too).
* Adding `create2` API endpoint that accepts media, due to an issue with the `create` endpoint that would make it incompatible.
* Adding media URLs to messages in API.
* Added `relationships_follow`, `relationships_unfollow`, `username_availability`, `edit_profile`, `request_edit` and `confirm_edit` endpoints to API.
* Added `url` utility to model `Upload`.
* Changed default `robots.txt`, adding report and admin-related lines.
* Released official [Android client](https://github.com/sakuragasaki46/coriplusapp/releases/tag/v0.8.0).
## 0.7.1
* Adding `messages_count`, `followers_count` and `following_count` to `profile_info` API endpoint (forgot to release).
## 0.7.0
* 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`. There is also a new module `api.py`.
* Now `/about/` shows Python and Flask versions.
* Now the error 404 handler returns HTTP 404.
* Added user followers and following lists, accessible via `/+<username>/followers` and `/+<username>/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`
* Adding public API. Each of the API endpoints take a mandatory query string argument: the access token, generated by a separate endpoint at `/get_access_token` and stored into the client. All API routes start with `/api/V1`. Added endpoints `feed`, `create`, `profile_info`, `profile_feed` and `profile_search`.
* Planning to release mobile app for Android.
## 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.
* 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.
* Adding Privacy Policy.
* Adding links to Terms and Privacy at the bottom of any page.
## 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.
* 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
* Adding quick mention. You can now create a message mentioning another user in one click. * Adding quick mention. You can now create a message mentioning another user in one click.
* Added mention notifications. * Added mention notifications.

View file

@ -2,20 +2,33 @@
A simple social network, inspired by the now dead Google-Plus. A simple social network, inspired by the now dead Google-Plus.
To run the app, run the file "run_example.py" To run the app, do "flask run" in the package's parent directory.
Based on Tweepee example of [peewee](https://github.com/coleifer/peewee/). Based on Tweepee example of [peewee](https://github.com/coleifer/peewee/).
This is the server. For the client, see [coriplusapp](https://github.com/sakuragasaki46/coriplusapp/).
## Features ## Features
* Create text statuses, optionally with image * Create text statuses, optionally with image
* Follow users * Follow users
* Timeline feed * Timeline feed
* Add info to your profile
* In-site notifications * In-site notifications
* SQLite-based app * Public API
* SQLite (or PostgreSQL)-based app
## Requirements ## Requirements
* **Python 3** only. We don't want to support Python 2. * **Python 3.10+** with **pip**.
* **Flask** web framework. * **Flask** web framework.
* **Peewee** ORM. * **Peewee** ORM.
* A \*nix-based OS.
## Installation
* Install dependencies: `pip install .`
* Set the `DATABASE_URL` (must be SQLite or PostgreSQL)
* Run the migrations: `sh ./genmig.sh @`
* i forgor

589
app.py
View file

@ -1,589 +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
from functools import wraps
__version__ = '0.4.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')
app = Flask(__name__)
app.config.from_pyfile('config.py')
### 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)
# 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)
))
# A single public message.
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
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
return True
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:
if cur_user is None:
return False
return user.is_following(cur_user) and cur_user.is_following(user)
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
# "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/'
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, Message, Relationship, Upload, Notification, MessagePrivacy])
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 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
# 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
def get_current_user():
if session.get('logged_in'):
return User.get(User.id == session['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
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
g.db.connect()
@app.after_request
def after_request(response):
g.db.close()
return response
@app.context_processor
def _inject_user():
return {'current_user': get_current_user()}
@app.errorhandler(404)
def error_404(body):
return render_template('404.html')
@app.route('/')
def homepage():
if session.get('logged_in'):
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')
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())
# mark the user as being 'authenticated' by setting the session vars
auth_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:
auth_user(user)
return redirect(request.args.get('next', '/'))
return render_template('login.html')
@app.route('/logout/')
def logout():
session.pop('logged_in', None)
flash('You were logged out')
return redirect(request.args.get('next','/'))
@app.route('/+<username>/')
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()))
return object_list('user_detail.html', messages, 'message_list', user=user)
@app.route('/+<username>/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)
# TODO change to "profile.html"
return redirect(url_for('user_detail', username=user.username))
@app.route('/+<username>/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(
type='text',
user=user,
text=text,
pub_date=datetime.datetime.now())
MessagePrivacy.create(
message=message,
value=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('/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/<id>.jpg')
def uploads(id, type='jpg'):
return send_from_directory(UPLOAD_DIRECTORY, id + '.' + type)
@app.route('/ajax/username_availability/<username>')
def username_availability(username):
if session.get('logged_in'):
current = get_current_user().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.template_filter()
def enrich(s):
'''Filter for mentioning users.'''
return Markup(re.sub(_mention_re, r'<a href="/+\1">\1</a>', s))
@app.template_filter('is_following')
def is_following(from_user, to_user):
return from_user.is_following(to_user)
# allow running from the command line
if __name__ == '__main__':
create_tables()
app.run()

View file

@ -1,4 +0,0 @@
DATABASE = 'coriplus.sqlite'
DEBUG = True
SECRET_KEY = 'hin6bab8ge25*r=x&amp;+5$0kn=-#log$pt^#@vrqjld!^2ci@g*b'

10
genmig.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/bash
# GENERATE MIGRATIONS
source venv/bin/activate && \
source .env && \
case "$1" in
("+") pw_migrate create --auto --auto-source=coriplus.models --directory=src/migrations --database="$DATABASE_URL" "${@:2}" ;;
("@") pw_migrate migrate --directory=src/migrations --database="$DATABASE_URL" "${@:2}" ;;
(\\) pw_migrate rollback --directory=src/migrations --database="$DATABASE_URL" "${@:2}" ;;
esac

1
icons/edit-24px.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 287 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M10.09 15.59L11.5 17l5-5-5-5-1.41 1.41L12.67 11H3v2h9.67l-2.58 2.59zM19 3H5c-1.11 0-2 .9-2 2v4h2V5h14v14H5v-4H3v4c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>

After

Width:  |  Height:  |  Size: 302 B

1
icons/explore-24px.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M12 10.9c-.61 0-1.1.49-1.1 1.1s.49 1.1 1.1 1.1c.61 0 1.1-.49 1.1-1.1s-.49-1.1-1.1-1.1zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm2.19 12.19L6 18l3.81-8.19L18 6l-3.81 8.19z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 333 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>

After

Width:  |  Height:  |  Size: 261 B

1
icons/person-24px.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 247 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>

After

Width:  |  Height:  |  Size: 279 B

1
icons/shuffle-24px.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>

After

Width:  |  Height:  |  Size: 311 B

356
locations.txt Normal file
View file

@ -0,0 +1,356 @@
004 Afghanistan
008 Albania
010 Antarctica
012 Algeria
016 American Samoa
020 Andorra
024 Angola
028 Antigua and Barbuda
031 Azerbaijan
032 Argentina
036 Australia
040 Austria
044 Bahamas
048 Bahrain
050 Bangladesh
051 Armenia
052 Barbados
056 Belgium
060 Bermuda
064 Bhutan
068 Bolivia (Plurinational State of)
070 Bosnia and Herzegovina
072 Botswana
074 Bouvet Island
076 Brazil
084 Belize
086 British Indian Ocean Territory
090 Solomon Islands
092 Virgin Islands (British)
096 Brunei Darussalam
100 Bulgaria
104 Myanmar
108 Burundi
112 Belarus
116 Cambodia
120 Cameroon
124 Canada
132 Cabo Verde
136 Cayman Islands
140 Central African Republic
144 Sri Lanka
148 Chad
152 Chile
156 China
158 Taiwan, Province of China
162 Christmas Island
166 Cocos (Keeling) Islands
170 Colombia
174 Comoros
175 Mayotte
178 Congo
180 Congo, Democratic Republic of the
184 Cook Islands
188 Costa Rica
191 Croatia
192 Cuba
196 Cyprus
203 Czechia
204 Benin
208 Denmark
212 Dominica
214 Dominican Republic
218 Ecuador
222 El Salvador
226 Equatorial Guinea
231 Ethiopia
232 Eritrea
233 Estonia
234 Faroe Islands
238 Falkland Islands (Malvinas)
239 South Georgia and the South Sandwich Islands
242 Fiji
246 Finland
248 Åland Islands
250 France
254 French Guiana
258 French Polynesia
260 French Southern Territories
262 Djibouti
266 Gabon
268 Georgia
270 Gambia
275 Palestine, State of
276 Germany
288 Ghana
292 Gibraltar
296 Kiribati
300 Greece
304 Greenland
308 Grenada
312 Guadeloupe
316 Guam
320 Guatemala
324 Guinea
328 Guyana
332 Haiti
334 Heard Island and McDonald Islands
336 Holy See
340 Honduras
344 Hong Kong
348 Hungary
352 Iceland
356 India
360 Indonesia
364 Iran (Islamic Republic of)
368 Iraq
372 Ireland
376 Israel
380 Italy
384 Côte d'Ivoire
388 Jamaica
392 Japan
398 Kazakhstan
400 Jordan
404 Kenya
408 Korea (Democratic People's Republic of)
410 Korea, Republic of
414 Kuwait
417 Kyrgyzstan
418 Lao People's Democratic Republic
422 Lebanon
426 Lesotho
428 Latvia
430 Liberia
434 Libya
438 Liechtenstein
440 Lithuania
442 Luxembourg
446 Macao
450 Madagascar
454 Malawi
458 Malaysia
462 Maldives
466 Mali
470 Malta
474 Martinique
478 Mauritania
480 Mauritius
484 Mexico
492 Monaco
496 Mongolia
498 Moldova, Republic of
499 Montenegro
500 Montserrat
504 Morocco
508 Mozambique
512 Oman
516 Namibia
520 Nauru
524 Nepal
528 Netherlands
531 Curaçao
533 Aruba
534 Sint Maarten (Dutch part)
535 Bonaire, Sint Eustatius and Saba
540 New Caledonia
548 Vanuatu
554 New Zealand
558 Nicaragua
562 Niger
566 Nigeria
570 Niue
574 Norfolk Island
578 Norway
580 Northern Mariana Islands
581 United States Minor Outlying Islands
583 Micronesia (Federated States of)
584 Marshall Islands
585 Palau
586 Pakistan
591 Panama
598 Papua New Guinea
600 Paraguay
604 Peru
608 Philippines
612 Pitcairn
616 Poland
620 Portugal
624 Guinea-Bissau
626 Timor-Leste
630 Puerto Rico
634 Qatar
638 Réunion
642 Romania
643 Russian Federation
646 Rwanda
652 Saint Barthélemy
654 Saint Helena, Ascension and Tristan da Cunha
659 Saint Kitts and Nevis
660 Anguilla
662 Saint Lucia
663 Saint Martin (French part)
666 Saint Pierre and Miquelon
670 Saint Vincent and the Grenadines
674 San Marino
678 Sao Tome and Principe
682 Saudi Arabia
686 Senegal
688 Serbia
690 Seychelles
694 Sierra Leone
702 Singapore
703 Slovakia
704 Viet Nam
705 Slovenia
706 Somalia
710 South Africa
716 Zimbabwe
724 Spain
728 South Sudan
729 Sudan
732 Western Sahara
740 Suriname
744 Svalbard and Jan Mayen
748 Eswatini
752 Sweden
756 Switzerland
760 Syrian Arab Republic
762 Tajikistan
764 Thailand
768 Togo
772 Tokelau
776 Tonga
780 Trinidad and Tobago
784 United Arab Emirates
788 Tunisia
792 Turkey
795 Turkmenistan
796 Turks and Caicos Islands
798 Tuvalu
800 Uganda
804 Ukraine
807 North Macedonia
818 Egypt
826 United Kingdom of Great Britain and Northern Ireland
831 Guernsey
832 Jersey
833 Isle of Man
834 Tanzania, United Republic of
840 United States of America
850 Virgin Islands (U.S.)
854 Burkina Faso
858 Uruguay
860 Uzbekistan
862 Venezuela (Bolivarian Republic of)
876 Wallis and Futuna
882 Samoa
887 Yemen
894 Zambia
1001 Torino
1002 Vercelli
1003 Novara
1004 Cuneo
1005 Asti
1006 Alessandria
1007 Aosta
1008 Imperia
1009 Savona
1010 Genova
1011 La Spezia
1012 Varese
1013 Como
1014 Sondrio
1015 Milano
1016 Bergamo
1017 Brescia
1018 Pavia
1019 Cremona
1020 Mantova
1021 Bolzano
1022 Trento
1023 Verona
1024 Vicenza
1025 Belluno
1026 Treviso
1027 Venezia
1028 Padova
1029 Rovigo
1030 Udine
1031 Gorizia
1032 Trieste
1033 Piacenza
1034 Parma
1035 Reggio nell'Emilia
1036 Modena
1037 Bologna
1038 Ferrara
1039 Ravenna
1040 Forlì-Cesena
1041 Pesaro e Urbino
1042 Ancona
1043 Macerata
1044 Ascoli Piceno
1045 Massa-Carrara
1046 Lucca
1047 Pistoia
1048 Firenze
1049 Livorno
1050 Pisa
1051 Arezzo
1052 Siena
1053 Grosseto
1054 Perugia
1055 Terni
1056 Viterbo
1057 Rieti
1058 Roma
1059 Latina
1060 Frosinone
1061 Caserta
1062 Benevento
1063 Napoli
1064 Avellino
1065 Salerno
1066 L'Aquila
1067 Teramo
1068 Pescara
1069 Chieti
1070 Campobasso
1071 Foggia
1072 Bari
1073 Taranto
1074 Brindisi
1075 Lecce
1076 Potenza
1077 Matera
1078 Cosenza
1079 Catanzaro
1080 Reggio Calabria
1081 Trapani
1082 Palermo
1083 Messina
1084 Agrigento
1085 Caltanissetta
1086 Enna
1087 Catania
1088 Ragusa
1089 Siracusa
1090 Sassari
1091 Nuoro
1092 Cagliari
1093 Pordenone
1094 Isernia
1095 Oristano
1096 Biella
1097 Lecco
1098 Lodi
1099 Rimini
1100 Prato
1101 Crotone
1102 Vibo Valentia
1103 Verbano-Cusio-Ossola
1108 Monza e della Brianza
1109 Fermo
1110 Barletta-Andria-Trani
1111 Sud Sardegna

24
pyproject.toml Normal file
View file

@ -0,0 +1,24 @@
[project]
name = "sakuragasaki46_coriplus"
authors = [
{ name = "Sakuragasaki46" }
]
dynamic = ["version"]
dependencies = [
"Python-Dotenv>=1.0.0",
"Flask",
"Flask-Login",
"Peewee",
"Flask-WTF",
"peewee-migrate",
"PsycoPG2"
]
requires-python = ">=3.10"
classifiers = [
"Private :: X"
]
[tool.setuptools.dynamic]
version = { attr = "coriplus.__version__" }

View file

@ -1,2 +1,3 @@
flask flask>=1.1.1
peewee peewee>=3.11.1
flask-login>=0.4.1

View file

@ -1 +0,0 @@

View file

@ -1,16 +0,0 @@
#!/usr/bin/env python
import sys
sys.path.insert(0, '../..')
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('-p', '--port', type=int, default=5000,
help='An alternative port where to run the server.')
from app import app, create_tables
if __name__ == '__main__':
args = parser.parse_args()
create_tables()
app.run(port=args.port)

155
src/coriplus/__init__.py Normal file
View file

@ -0,0 +1,155 @@
'''
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 public API, see `app.api`.
For report pages, see `app.reports`.
For site administration, see `app.admin`.
For template filters, see `app.filters`.
For the database models, see `app.models`.
For other, see `app.utils`.
'''
from flask import (
Flask, g, jsonify, render_template, request,
send_from_directory, __version__ as flask_version)
import os, sys
from flask_login import LoginManager
from flask_wtf import CSRFProtect
import dotenv
import logging
__version__ = '0.10.0-dev50'
# we want to support Python 3.10+ only.
# Python 2 has too many caveats.
# Python <=3.9 has harder type support.
if sys.version_info[0:2] < (3, 10):
raise RuntimeError('Python 3.10+ required')
BASEDIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
os.chdir(BASEDIR)
dotenv.load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = Flask(__name__)
app.secret_key = os.environ['SECRET_KEY']
login_manager = LoginManager(app)
CSRFProtect(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:
logger.error('database connected twice')
@app.after_request
def after_request(response):
try:
g.db.close()
except Exception:
logger.error('database closed twice')
return response
@app.context_processor
def _inject_variables():
return {
'site_name': os.environ.get('APP_NAME', 'Cori+'),
'locations': locations,
'inline_svg': inline_svg
}
@login_manager.user_loader
def _inject_user(userid):
return User[userid]
@app.errorhandler(403)
def error_403(body):
return render_template('403.html'), 403
@app.errorhandler(404)
def error_404(body):
return render_template('404.html'), 404
@app.route('/favicon.ico')
def favicon_ico():
return send_from_directory(BASEDIR, 'src/favicon.ico')
@app.route('/robots.txt')
def robots_txt():
return send_from_directory(BASEDIR, 'src/robots.txt')
@app.route('/uploads/<id>.<type>')
def uploads(id, type='jpg'):
return send_from_directory(UPLOAD_DIRECTORY, id + '.' + type)
@app.route('/get_access_token', methods=['POST'])
def send_access_token():
try:
data = request.get_json(True)
try:
user = User.get(
(User.username == data['username']) &
(User.password == pwdhash(data['password'])))
except User.DoesNotExist:
return jsonify({
'message': 'Invalid username or password',
'login_correct': False,
'status': 'ok'
})
if user.is_disabled == 1:
user.is_disabled = 0
elif user.is_disabled == 2:
return jsonify({
'message': 'Your account has been disabled by violating our Terms.',
'login_correct': False,
'status': 'ok'
})
return jsonify({
'token': generate_access_token(user),
'login_correct': True,
'status': 'ok'
})
except Exception:
sys.excepthook(*sys.exc_info())
return jsonify({
'message': 'An unknown error has occurred.',
'status': 'fail'
})
from .website import bp
app.register_blueprint(bp)
from .ajax import bp
app.register_blueprint(bp)
from .api import bp
app.register_blueprint(bp)
from .reports import bp
app.register_blueprint(bp)
from .admin import bp
app.register_blueprint(bp)

29
src/coriplus/__main__.py Normal file
View file

@ -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)

63
src/coriplus/admin.py Normal file
View file

@ -0,0 +1,63 @@
'''
Management of reports and the entire site.
New in 0.8.
'''
from flask import Blueprint, abort, redirect, render_template, request, url_for
from flask_login import current_user
from .models import User, Message, Report, report_reasons, REPORT_STATUS_ACCEPTED, \
REPORT_MEDIA_USER, REPORT_MEDIA_MESSAGE
from .utils import pwdhash, object_list
from functools import wraps
bp = Blueprint('admin', __name__, url_prefix='/admin')
def _check_auth(username) -> bool:
try:
return User.get((User.username == username)).is_admin
except User.DoesNotExist:
return False
def admin_required(f):
@wraps(f)
def wrapped_view(**kwargs):
if not _check_auth(current_user.username):
abort(403)
return f(**kwargs)
return wrapped_view
def review_reports(status, media_type, media_id):
(Report
.update(status=status)
.where((Report.media_type == media_type) & (Report.media_id == media_id))
.execute())
if status == REPORT_STATUS_ACCEPTED:
if media_type == REPORT_MEDIA_USER:
user = User[media_id]
user.is_disabled = 2
user.save()
elif media_type == REPORT_MEDIA_MESSAGE:
Message.delete().where(Message.id == media_id).execute()
@bp.route('/')
@admin_required
def homepage():
return render_template('admin_home.html')
@bp.route('/reports')
@admin_required
def reports():
return object_list('admin_reports.html', Report.select().order_by(Report.created_date.desc()), 'report_list', report_reasons=dict(report_reasons))
@bp.route('/reports/<int:id>', methods=['GET', 'POST'])
@admin_required
def reports_detail(id):
report = Report[id]
if request.method == 'POST':
if request.form.get('take_down'):
review_reports(REPORT_STATUS_ACCEPTED, report.media_type, report.media_id)
elif request.form.get('discard'):
review_reports(REPORT_STATUS_DECLINED, report.media_type, report.media_id)
return redirect(url_for('admin.reports'))
return render_template('admin_report_detail.html', report=report, report_reasons=dict(report_reasons))

66
src/coriplus/ajax.py Normal file
View file

@ -0,0 +1,66 @@
'''
AJAX hooks for the website.
Warning: this is not the public API.
'''
from flask import Blueprint, jsonify
from flask_login import current_user
from .models import User, Message, MessageUpvote
from .utils import locations, is_username
import datetime
bp = Blueprint('ajax', __name__, url_prefix='/ajax')
@bp.route('/username_availability/<username>')
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/<name>')
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})
@bp.route('/score/<int:id>/toggle', methods=['POST'])
def score_toggle(id):
user = current_user
message = Message[id]
upvoted_by_self = (MessageUpvote
.select()
.where((MessageUpvote.message == message) & (MessageUpvote.user == user))
.exists())
if upvoted_by_self:
(MessageUpvote
.delete()
.where(
(MessageUpvote.message == message) &
(MessageUpvote.user == user))
.execute()
)
else:
MessageUpvote.create(
message=message,
user=user,
created_date=datetime.datetime.now()
)
return jsonify({
"score": message.score,
"status": "ok"
})

436
src/coriplus/api.py Normal file
View file

@ -0,0 +1,436 @@
from flask import Blueprint, jsonify, request
import sys, os, datetime, re, uuid
from functools import wraps
from peewee import IntegrityError
from .models import User, UserProfile, Message, Upload, Relationship, Notification, \
MessageUpvote, database, \
MSGPRV_PUBLIC, MSGPRV_UNLISTED, MSGPRV_FRIENDS, MSGPRV_ONLYME, UPLOAD_DIRECTORY
from .utils import check_access_token, Visibility, push_notification, unpush_notification, \
create_mentions, is_username, generate_access_token, pwdhash
import logging
logger = logging.getLogger(__name__)
bp = Blueprint('api', __name__, url_prefix='/api/V1')
def get_message_info(message):
try:
media = message.uploads[0].url()
except IndexError:
media = None
if media:
logger.debug(media)
return {
'id': message.id,
'user': {
'id': message.user.id,
'username': message.user.username,
},
'text': message.text,
'privacy': message.privacy,
'pub_date': message.pub_date.timestamp(),
'media': media,
'score': len(message.upvotes),
'upvoted_by_self': message.upvoted_by_self(),
}
def validate_access(func):
@wraps(func)
def wrapper(*args, **kwargs):
access_token = request.args.get('access_token')
if access_token is None:
return jsonify({
'message': 'missing access_token',
'status': 'fail'
})
user = check_access_token(access_token)
if user is None:
return jsonify({
'message': 'invalid access_token',
'status': 'fail'
})
try:
result = func(user, *args, **kwargs)
assert isinstance(result, dict)
except Exception:
import traceback; traceback.print_exc()
return jsonify({
'message': str(sys.exc_info()[1]),
'status': 'fail'
})
result['status'] = 'ok'
return jsonify(result)
return wrapper
@bp.route('/feed')
@validate_access
def feed(self):
timeline_media = []
date = request.args.get('offset')
if date is None:
date = datetime.datetime.now()
else:
date = datetime.datetime.fromtimestamp(float(date))
query = Visibility(Message
.select()
.where(((Message.user << self.following())
| (Message.user == self))
& (Message.pub_date < date))
.order_by(Message.pub_date.desc()))
for message in query.paginate(1):
timeline_media.append(get_message_info(message))
return {'timeline_media': timeline_media, 'has_more': query.count() > len(timeline_media)}
@bp.route('/explore')
@validate_access
def explore(self):
timeline_media = []
date = request.args.get('offset')
if date is None:
date = datetime.datetime.now()
else:
date = datetime.datetime.fromtimestamp(float(date))
query = Visibility(Message
.select()
.where(Message.pub_date < date)
.order_by(Message.pub_date.desc()), True)
for message in query.paginate(1):
timeline_media.append(get_message_info(message))
return {'timeline_media': timeline_media, 'has_more': query.count() > len(timeline_media)}
@bp.route('/create', methods=['POST'])
@validate_access
def create(self):
data = request.get_json(True)
text = data['text']
privacy = int(data.get('privacy', 0))
message = Message.create(
user=self,
text=text,
pub_date=datetime.datetime.now(),
privacy=privacy)
# This API does not support files. Use create2 instead.
create_mentions(self, text, privacy)
return {}
@bp.route('/create2', methods=['POST'])
@validate_access
def create2(self):
text = request.form['text']
privacy = int(request.form.get('privacy', 0))
message = Message.create(
user=self,
text=text,
pub_date=datetime.datetime.now(),
privacy=privacy)
file = request.files.get('file')
if file:
logger.info('Uploading', file.filename)
ext = file.filename.split('.')[-1]
upload = Upload.create(
type=ext,
message=message
)
file.save(os.path.join(UPLOAD_DIRECTORY, str(upload.id) + '.' + ext))
create_mentions(self, text, privacy)
return {}
def get_relationship_info(self, other):
if self == other:
return
return {
"following": self.is_following(other),
"followed_by": other.is_following(self)
}
@bp.route('/profile_info/<userid>', methods=['GET'])
@validate_access
def profile_info(self, userid):
if userid == 'self':
user = self
elif userid.startswith('+'):
user = User.get(User.username == userid[1:])
elif userid.isdigit():
try:
user = User[userid]
except User.DoesNotExist:
return {'user': None}
else:
raise ValueError('userid should be an integer or "self"')
profile = user.profile
return {
"user": {
"id": user.id,
"username": user.username,
"full_name": profile.full_name,
"biography": profile.biography,
"website": profile.website,
"generation": profile.year,
"instagram": profile.instagram,
"facebook": profile.facebook,
"join_date": user.join_date.timestamp(),
"relationships": get_relationship_info(self, user),
"messages_count": len(user.messages),
"followers_count": len(user.followers()),
"following_count": len(user.following())
}
}
@bp.route('/profile_info/feed/<userid>', methods=['GET'])
@validate_access
def profile_feed(self, userid):
if userid == 'self':
user = self
elif userid.startswith('+'):
user = User.get(User.username == userid[1:])
elif userid.isdigit():
user = User[userid]
else:
raise ValueError('userid should be an integer or "self"')
timeline_media = []
date = request.args.get('offset')
if date is None:
date = datetime.datetime.now()
else:
date = datetime.datetime.fromtimestamp(float(date))
query = Visibility(Message
.select()
.where((Message.user == user)
& (Message.pub_date < date))
.order_by(Message.pub_date.desc()))
for message in query.paginate(1):
timeline_media.append(get_message_info(message))
return {'timeline_media': timeline_media, 'has_more': query.count() > len(timeline_media)}
@bp.route('/relationships/<int:userid>/follow', methods=['POST'])
@validate_access
def relationships_follow(self, userid):
user = User[userid]
try:
with database.atomic():
Relationship.create(
from_user=self,
to_user=user,
created_date=datetime.datetime.now())
except IntegrityError:
pass
push_notification('follow', user, user=self.id)
return get_relationship_info(self, user)
@bp.route('/relationships/<int:userid>/unfollow', methods=['POST'])
@validate_access
def relationships_unfollow(self, userid):
user = User[userid]
(Relationship
.delete()
.where(
(Relationship.from_user == self) &
(Relationship.to_user == user))
.execute())
unpush_notification('follow', user, user=self.id)
return get_relationship_info(self, user)
@bp.route('/profile_search', methods=['POST'])
@validate_access
def profile_search(self):
data = request.get_json(True)
query = User.select().where((User.username ** ('%' + data['q'] + '%')) |
(User.full_name ** ('%' + data['q'] + '%'))).limit(20)
results = []
for result in query:
profile = result.profile
results.append({
"id": result.id,
"username": result.username,
"full_name": result.full_name,
"followers_count": len(result.followers())
})
return {
"users": results
}
@bp.route('/username_availability/<username>')
@validate_access
def username_availability(self, username):
current = self.username
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 {
'is_valid': is_valid,
'is_available': is_available
}
@bp.route('/edit_profile', methods=['POST'])
@validate_access
def edit_profile(user):
data = request.get_json(True)
username = data['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:
raise ValueError('that username is already taken')
full_name = data['full_name'] or username
if full_name != user.full_name:
User.update(full_name=full_name).where(User.id == user.id).execute()
kwargs = {}
if 'website' in data:
website = data['website'].strip().replace(' ', '%20')
if website and not validate_website(website):
raise ValueError('You should enter a valid URL.')
kwargs['website'] = website
if 'location' in data:
location = int(request.form.get('location'))
if location == 0:
location = None
kwargs['location'] = location
if 'year' in data:
if data.get('has_year'):
kwargs['year'] = data['year']
else:
kwargs['year'] = None
if 'instagram' in data: kwargs['instagram'] = data['instagram']
if 'facebook' in data: kwargs['facebook'] = data['facebook']
if 'telegram' in data: kwargs['telegram'] = data['telegram']
UserProfile.update(
biography=data['biography'],
**kwargs
).where(UserProfile.user == user).execute()
return {}
@bp.route('/request_edit/<int:id>')
@validate_access
def request_edit(self, id):
message = Message[id]
if message.user != self:
raise ValueError('Attempt to edit a message from another')
return {
'message_info': get_message_info(message)
}
@bp.route('/save_edit/<int:id>', methods=['POST'])
@validate_access
def save_edit(self, id):
message = Message[id]
if message.user != self:
raise ValueError('Attempt to edit a message from another')
data = request.get_json(True)
Message.update(text=data['text'], privacy=data['privacy']).where(Message.id == id).execute()
return {}
# no validate access for this endpoint!
@bp.route('/create_account', methods=['POST'])
def create_account():
try:
data = request.get_json(True)
try:
birthday = datetime.datetime.fromisoformat(data['birthday'])
except ValueError:
raise ValueError('invalid date format')
username = data['username'].lower()
if not is_username(username):
raise ValueError('invalid username')
with database.atomic():
user = User.create(
username=username,
full_name=data.get('full_name') or username,
password=pwdhash(data['password']),
email=data['email'],
birthday=birthday,
join_date=datetime.datetime.now())
UserProfile.create(
user=user
)
return jsonify({'access_token': generate_access_token(user), 'status': 'ok'})
except Exception as e:
return jsonify({'message': str(e), 'status': 'fail'})
def get_notification_info(notification):
obj = {
"id": notification.id,
"type": notification.type,
"timestamp": notification.pub_date.timestamp(),
"seen": notification.seen
}
obj.update(json.loads(notification.detail))
return obj
@bp.route('/notifications/count')
@validate_access
def notifications_count(self):
count = len(Notification
.select()
.where((Notification.target == self) & (Notification.seen == 0)))
return {
'count': count
}
@bp.route('/notifications')
@validate_access
def notifications(self):
items = []
query = (Notification
.select()
.where(Notification.target == self)
.order_by(Notification.pub_date.desc())
.limit(100))
unseen_count = len(Notification
.select()
.where((Notification.target == self) & (Notification.seen == 0)))
for notification in query:
items.append(get_notification_info(query))
return {
"notifications": {
"items": items,
"unseen_count": unseen_count
}
}
@bp.route('/notifications/seen', methods=['POST'])
@validate_access
def notifications_seen(self):
data = request.get_json(True)
(Notification
.update(seen=1)
.where((Notification.target == self) & (Notification.pub_date < data['offset']))
.execute())
return {}
@bp.route('/score/message/<int:id>/add', methods=['POST'])
@validate_access
def score_message_add(self, id):
message = Message[id]
MessageUpvote.create(
message=message,
user=self,
created_date=datetime.datetime.now()
)
return {
'score': len(message.upvotes)
}
@bp.route('/score/message/<int:id>/remove', methods=['POST'])
@validate_access
def score_message_remove(self, id):
message = Message[id]
(MessageUpvote
.delete()
.where(
(MessageUpvote.message == message) &
(MessageUpvote.user == self))
.execute()
)
return {
'score': len(message.upvotes)
}

67
src/coriplus/filters.py Normal file
View file

@ -0,0 +1,67 @@
'''
Filter functions used in the website templates.
'''
from markupsafe import Markup
import html, datetime, re, time
from .utils import tokenize, inline_svg as _inline_svg
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('<a href="{0}">{0}</a>'.format(html.escape(text)))
elif tag == 'MENTION':
r.append('<span class="weak">+</span><a href="/{0}">{1}</a>'.format(text, text.lstrip('+')))
elif tag == 'NEWLINE':
r.append('<br>')
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)]

291
src/coriplus/models.py Normal file
View file

@ -0,0 +1,291 @@
'''
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 flask import request
from peewee import *
from playhouse.db_url import connect
import os
from . import BASEDIR
# here should go `from .utils import get_current_user`, but it will cause
# import errors. It's instead imported at function level.
database = connect(os.environ['DATABASE_URL'])
class BaseModel(Model):
id = AutoField(primary_key=True)
class Meta:
database = database
# A user. The user is separated from its page.
class User(BaseModel):
# The unique username.
username = CharField(30, unique=True)
# The user's full name (here for better search since 0.8)
full_name = CharField(80)
# The password hash.
password = CharField(256)
# An email address.
email = CharField(256)
# 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)
# Short description of user.
biography = CharField(256, default='')
# Personal website.
website = TextField(null=True)
# 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 True
# 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
# Deprecated in 0.10 and merged with User
class UserProfile(BaseModel):
user = ForeignKeyField(User, primary_key=True)
biography = TextField(default='')
location = IntegerField(null=True)
website = TextField(null=True)
@property
def full_name(self):
'''
Moved to User in 0.8 for search improvement reasons.
'''
return self.user.full_name
# 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 not cur_user or cur_user.is_anonymous:
return False
return user.is_following(cur_user) and cur_user.is_following(user)
else:
return False
@property
def score(self):
return self.upvotes.count()
def upvoted_by_self(self):
from .utils import get_current_user
user = get_current_user()
return (MessageUpvote
.select()
.where((MessageUpvote.message == self) & (MessageUpvote.user == user))
.exists()
)
# 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(BASEDIR, '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
def url(self):
return request.host_url + 'uploads/' + self.filename()
class Notification(BaseModel):
type = TextField()
target = ForeignKeyField(User, backref='notifications')
detail = TextField()
pub_date = DateTimeField()
seen = IntegerField(default=0)
REPORT_MEDIA_USER = 1
REPORT_MEDIA_MESSAGE = 2
REPORT_REASON_SPAM = 1
REPORT_REASON_IMPERSONATION = 2
REPORT_REASON_PORN = 3
REPORT_REASON_VIOLENCE = 4
REPORT_REASON_HATE = 5
REPORT_REASON_BULLYING = 6
REPORT_REASON_SELFINJURY = 7
REPORT_REASON_FIREARMS = 8
REPORT_REASON_DRUGS = 9
REPORT_REASON_UNDERAGE = 10
REPORT_REASON_LEAK = 11
REPORT_REASON_DMCA = 12
report_reasons = [
(REPORT_REASON_SPAM, "It's spam"),
(REPORT_REASON_IMPERSONATION, "This profile is pretending to be someone else"),
(REPORT_REASON_PORN, "Nudity or pornography"),
(REPORT_REASON_VIOLENCE, "Violence or dangerous organization"),
(REPORT_REASON_HATE, "Hate speech or symbols"),
(REPORT_REASON_BULLYING, "Harassment or bullying"),
(REPORT_REASON_SELFINJURY, "Self injury"),
(REPORT_REASON_FIREARMS, "Sale or promotion of firearms"),
(REPORT_REASON_DRUGS, "Sale or promotion of drugs"),
(REPORT_REASON_UNDERAGE, "This user is less than 13 years old"),
(REPORT_REASON_LEAK, "Leak of sensitive information"),
(REPORT_REASON_DMCA, "Copyright violation")
]
REPORT_STATUS_DELIVERED = 0
REPORT_STATUS_ACCEPTED = 1
REPORT_STATUS_DECLINED = 2
# New in 0.8.
class Report(BaseModel):
media_type = IntegerField()
media_id = IntegerField()
sender = ForeignKeyField(User, null=True)
reason = IntegerField()
status = IntegerField(default=REPORT_STATUS_DELIVERED)
created_date = DateTimeField()
@property
def media(self):
try:
if self.media_type == REPORT_MEDIA_USER:
return User[self.media_id]
elif self.media_type == REPORT_MEDIA_MESSAGE:
return Message[self.media_id]
except DoesNotExist:
return
# New in 0.9.
class MessageUpvote(BaseModel):
message = ForeignKeyField(Message, backref='upvotes')
user = ForeignKeyField(User)
created_date = DateTimeField()
class Meta:
indexes = (
(('message', 'user'), True),
)
def create_tables():
with database:
database.create_tables([
User, UserAdminship, UserProfile, Message, Relationship,
Upload, Notification, Report, MessageUpvote])
if not os.path.isdir(UPLOAD_DIRECTORY):
os.makedirs(UPLOAD_DIRECTORY)

42
src/coriplus/reports.py Normal file
View file

@ -0,0 +1,42 @@
'''
Module for user and message reports.
New in 0.8.
'''
from flask import Blueprint, redirect, request, render_template, url_for
from .models import Report, REPORT_MEDIA_USER, REPORT_MEDIA_MESSAGE, report_reasons
from .utils import get_current_user
import datetime
bp = Blueprint('reports', __name__, url_prefix='/report')
@bp.route('/user/<int:userid>', methods=['GET', 'POST'])
def report_user(userid):
if request.method == "POST":
Report.create(
media_type=REPORT_MEDIA_USER,
media_id=userid,
sender=get_current_user(),
reason=request.form['reason'],
created_date=datetime.datetime.now()
)
return redirect(url_for('reports.report_done'))
return render_template('report_user.html', report_reasons=report_reasons)
@bp.route('/message/<int:userid>', methods=['GET', 'POST'])
def report_message(userid):
if request.method == "POST":
Report.create(
media_type=REPORT_MEDIA_MESSAGE,
media_id=userid,
sender=get_current_user(),
reason=request.form['reason'],
created_date=datetime.datetime.now()
)
return redirect(url_for('reports.report_done'))
return render_template('report_message.html', report_reasons=report_reasons)
@bp.route('/done', methods=['GET', 'POST'])
def report_done():
return render_template('report_done.html')

View file

@ -88,3 +88,39 @@ function attachFileInput(){
var fileInput = document.getElementById('fileInputContainer'); var fileInput = document.getElementById('fileInputContainer');
fileInput.innerHTML = '<input type="file" accept="image/*" name="file">'; fileInput.innerHTML = '<input type="file" accept="image/*" name="file">';
} }
function showHideMessageOptions(id){
var msgElem = document.getElementById(id);
var options = msgElem.getElementsByClassName('message-options')[0];
if(options.style.display == 'block'){
options.style.display = 'none';
} else {
options.style.display = 'block';
}
}
function getCsrfToken () {
var csrf_token = document.querySelector('meta[name="csrf_token"]');
return csrf_token?.getAttribute('content');
}
function toggleUpvote(id){
var msgElem = document.getElementById(id);
//var upvoteLink = msgElem.getElementsByClassName('message-upvote')[0];
var scoreCounter = msgElem.getElementsByClassName('message-score')[0];
var body = "csrf_token=" + getCsrfToken();
var xhr = new XMLHttpRequest();
xhr.open("POST", "/ajax/score/" + id + "/toggle", true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
// TODO add csrf token somewhere
xhr.onreadystatechange = function(){
if(xhr.readyState == XMLHttpRequest.DONE){
if(xhr.status == 200){
console.log('liked #' + id);
var data = JSON.parse(xhr.responseText);
scoreCounter.innerHTML = data.score;
}
}
};
xhr.send(body);
}

View file

@ -0,0 +1,63 @@
:root {
--accent: #f0372e;
--link: #3399ff;
}
* {
box-sizing: border-box;
}
body, button, input, select, textarea {
font-family: Inter, Roboto, sans-serif;
line-height: 1.6;
background-color: #eeeeee;
}
#site-name, h1, h2, h3, h4, h5, h6 {
font-family: 'Funnel Sans', Roboto, sans-serif;
line-height: 1.2;
}
body{margin:0}
main{margin:auto; max-width: 960px;}
a{text-decoration:none}
a:hover{text-decoration:underline}
.mobile-collapse {font-size: smaller;}
@media (max-width:640px){
.mobile-collapse{display:none}
}
.header{
padding:12px;color:white; background-color:var(--accent); box-shadow:0 0 3px 3px #ccc; display: flex; position: relative;
}
.centered{
text-align: center;
}
.content{padding:12px}
.header a{color:white; }
.header a svg{fill:white}
.content a{color:var(--link)}
.content a svg{fill:var(--link)}
.content a.plus{color:var(--accent)}
.leftnav, .rightnav{list-style: none; display: flex; padding: 0; margin: 0;position: absolute;}
.leftnav {left: 0}
.rightnav {right: 0}
.card {background-color: #fafafa; border-radius: 12px; padding: 12px; margin-bottom: 12px; border-bottom: 2px solid #999999;}
#site-name {text-align: center;flex: 1}
.header h1{margin:0;display:inline-block}
.flash{background-color:#ff9;border:yellow 1px solid}
.infobox{width: 50%; float: right;}
@media (max-width:639px) {
.infobox{width: 100%;}
}
.weak{opacity:.5}
.field_desc{display:block}
ul.timeline{padding:0;margin:auto;max-width:960px;clear: both}
ul.timeline > li{list-style:none;}
.message-visual img{max-width:100%;margin:auto}
.message-options-showhide::before{content:'\2026'}
.message-options{display:none}
.create_text{width:100%;height:8em}
.biography_text{height:4em}
.before-toggle:not(:checked) + input{display:none}
.follow_button,input[type="submit"]{background-color:var(--accent);color:white;border-radius:3px;border:1px solid var(--accent)}
.follow_button.following{background-color:transparent;color:var(--accent);border-color:var(--accent)}
.copyright{font-size:smaller;text-align:center;color:#808080}
.copyright a:link,.copyright a:visited{color:#31559e}
.copyright ul{list-style:none;padding:0}
.copyright ul > li{padding:0 3px}

View file

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block body %}
<div class="centered">
<h2>Forbidden</h2>
<p><a href="/">Back to homepage.</a></p>
</div>
{% endblock %}

View file

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block body %}
<div class="centered">
<h2>Not Found</h2>
<p><a href="/">Back to homepage.</a></p>
</div>
{% endblock %}

View file

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block body %}
<div class="card">
<h1>About {{ site_name }}</h1>
<ul>
<li>{{ site_name }} {{ version }}</li>
<li> Python {{ python_version }}</li>
<li>Flask {{ flask_version }}</li>
</ul>
<p>Copyright &copy; 2019, 2025 Sakuragasaki46.</p>
<h2>License</h2>
<p>Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files
(the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:</p>
<p>The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.</p>
<p>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
<p>Source code for this site: <a
href="https://github.com/sakuragasaki46/coriplus/">
https://github.com/sakuragasaki46/coriplus/</a>
</div>
{% endblock %}

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ site_name }}</title>
<link rel="stylesheet" type="text/css" href="/static/style.css">
<style>.done{opacity:.5}</style>
</head>
<body>
<div class="header">
<h1><a href="{{ url_for('admin.homepage') }}">{{ site_name }}: Admin</a></h1>
<div class="rightnav">
<!-- what does it go here? -->
</div>
</div>
<div class="content">
{% for message in get_flashed_messages() %}
<div class="flash">{{ message }}</div>
{% endfor %}
<main>
{% block body %}{% endblock %}
</main>
</div>
<div class="footer">
<p class="copyright">&copy; 2019 Sakuragasaki46.
<a href="/about/">About</a> - <a href="/terms/">Terms</a> -
<a href="/privacy/">Privacy</a></p>
</div>
<script src="/static/lib.js"></script>
</body>
</html>

View file

@ -0,0 +1,9 @@
{% extends "admin_base.html" %}
{% block body %}
<ul>
<li>
<a href="{{ url_for('admin.reports') }}">Reports</a>
</li>
</ul>
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends "admin_base.html" %}
{% block body %}
<h2>Report detail #{{ report.id }}</h2>
<div class="card">
<p>Type: {{ [None, 'user', 'message'][report.media_type] }}</p>
<p>Reason: <strong>{{ report_reasons[report.reason] }}</strong></p>
<p>Status: <strong>{{ ['Unreviewed', 'Accepted', 'Declined'][report.status] }}</strong></p>
<h3>Detail</h3>
{% if report.media is none %}
<p><em>The media is unavailable.</em></p>
{% elif report.media_type == 1 %}
<p><em>Showing first 20 messages of the reported user.</em></p>
<ul>
{% for message in report.media.messages %}
{% include "includes/reported_message.html" %}
{% endfor %}
</ul>
{% elif report.media_type == 2 %}
{% set message = report.media %}
{% include "includes/reported_message.html" %}
{% endif %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="submit" name="take_down" value="Take down">
<input type="submit" name="discard" value="Discard">
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends "admin_base.html" %}
{% block body %}
<ul>
{% for report in report_list %}
<li class="card {% if report.status > 0 %}done{% endif %}">
<p><strong>#{{ report.id }}</strong>
(<a href="{{ url_for('admin.reports_detail', id=report.id) }}">detail</a>)</p>
<p>Type: {{ [None, 'user', 'message'][report.media_type] }}</p>
<p>Reason: <strong>{{ report_reasons[report.reason] }}</strong></p>
<p>Status: <strong>{{ ['Unreviewed', 'Accepted', 'Declined'][report.status] }}</strong></p>
</li>
{% endfor %}
</ul>
{% include "includes/pagination.html" %}
{% endblock %}

View file

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ site_name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="/static/style.css">
<meta name="og:title" content="Cori+">
<meta name="og:description" content="A simple social network. Post text statuses, optionally with image.">
<meta name="csrf_token" content="{{ csrf_token() }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Funnel+Sans:ital,wght@0,300..800;1,300..800&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<div class="header">
<ul class="leftnav">
</ul>
<h1 id="site-name"><a href="{{ url_for('website.homepage') }}">{{ site_name }}</a></h1>
<ul class="rightnav">
{% if current_user.is_anonymous %}
<li><a href="{{ url_for('website.login', next=request.full_path) }}">{{ inline_svg('exit_to_app') }} <span class="mobile-collapse">log in</span></a></li>
<li><a href="{{ url_for('website.register', next=request.full_path) }}">{{ inline_svg('person_add') }} <span class="mobile-collapse">register</span></a></li>
{% else %}
<li><a href="{{ url_for('website.user_detail', username=current_user.username) }}">{{ inline_svg('person') }} {{ current_user.username }}</a></li>
{% set notification_count = current_user.unseen_notification_count() %}
<li><a href="{{ url_for('website.notifications') }}">{{ inline_svg('notifications') }} (<strong>{{ notification_count }}</strong>)</a></li>
<li><a href="{{ url_for('website.public_timeline') }}">{{ inline_svg('explore') }} <span class="mobile-collapse">explore</span></a></li>
<li><a href="{{ url_for('website.create') }}">{{ inline_svg('edit') }} <span class="mobile-collapse">create</span></a></li>
<li><a href="{{ url_for('website.logout') }}">{{ inline_svg('exit_to_app') }} <span class="mobile-collapse">log out</span></a></li>
{% endif %}
</ul>
</div>
<div class="content">
{% for message in get_flashed_messages() %}
<div class="flash">{{ message }}</div>
{% endfor %}
<main>
{% block body %}{% endblock %}
</main>
</div>
<div class="footer">
<p class="copyright">&copy; 2019, 2025 Sakuragasaki46.
<a href="/about/">About</a> - <a href="/terms/">Terms</a> -
<a href="/privacy/">Privacy</a> -
<a href="https://github.com/sakuragasaki46/coriplus">GitHub</a></p>
</div>
<script src="/static/lib.js"></script>
</body>
</html>

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block body %}
<div class="card">
<h2>Change Password</h2>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<dl>
<dt>Old password:</dt>
<dd><input type="password" name="old_password"></dd>
<dt>New password:</dt>
<dd><input type="password" name="new_password"></dd>
<dt>New password, again:</dt>
<dd><input type="password" name="confirm_password"></dd>
<dd><input type="submit" value="Save"></dd>
</dl>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block body %}
<div class="card">
<h2>Confirm Deletion</h2>
<p>Are you sure you want to <u>permanently delete</u> this post?
Neither you nor others will be able to see it;
you cannot recover a post after it's deleted.</p>
<p><small>Tip: If you only want to hide it from the public,
you can <a href="/edit/{{ message.id }}">set its privacy</a> to "Only me".</small></p>
<ul>
<li>{% include "includes/message.html" %}</li>
</ul>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="submit" value="Delete">
</form>
</div>
{% endblock %}

View file

@ -1,7 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<div class="card">
<h2>Create</h2> <h2>Create</h2>
<form action="{{ url_for('create') }}" method="POST" enctype="multipart/form-data"> <form action="{{ url_for('website.create') }}" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<dl> <dl>
<dt>Message:</dt> <dt>Message:</dt>
<dd><textarea name="text" placeholder="What's happening?" class="create_text">{{ request.args['preload'] }}</textarea></dd> <dd><textarea name="text" placeholder="What's happening?" class="create_text">{{ request.args['preload'] }}</textarea></dd>
@ -15,4 +17,5 @@
<dd><input type="submit" value="Create" /></dd> <dd><input type="submit" value="Create" /></dd>
</dl> </dl>
</form> </form>
</div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block body %}
<div class="card">
<h2>Edit</h2>
<form action="{{ url_for('website.edit', id=message.id) }}" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<dl>
<dt>Message:</dt>
<dd><textarea name="text" required="" class="create_text">{{ message.text }}</textarea></dd>
<dd><select name="privacy">
<option value="0"{% if message.privacy == '0' %} selected{% endif %}>Public - everyone in your profile or public timeline</option>
<option value="1"{% if message.privacy == '1' %} selected{% endif %}>Unlisted - everyone in your profile, hide from public timeline</option>
<option value="2"{% if message.privacy == '2' %} selected{% endif %}>Friends - only people you follow each other</option>
<option value="3"{% if message.privacy == '3' %} selected{% endif %}>Only you</option>
</select></dd>
<dd><input type="submit" value="Save" /></dd>
</dl>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block body %}
<div class="card">
<h2>Edit Profile</h2>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<dl>
<dt>Username:</dt>
<dd><input type="text" class="username-input" name="username" required value="{{ current_user.username }}" autocomplete="off"></dd>
{% if not profile %}
{% set profile = current_user.profile %}
{% endif %}
<dt>Full name:</dt>
<dd><input type="text" name="full_name" value="{{ profile.full_name }}"></dd>
<dt>Biography:</dt>
<dd><textarea class="biography_text" name="biography">{{ profile.biography }}</textarea></dd>
<dt>Location:</dt>
<dd>{% include "includes/location_selector.html" %}</dd>
<dt>Generation:</dt>
<dd>
<input type="checkbox" class="before-toggle" name="has_year" value="1" {% if profile.year %}checked{% endif %}>
<input type="number" name="year" value="{{ profile.year or 2000 }}">
</dd>
<dt>Website:</dt>
<dd><input type="text" name="website" value="{{ profile.website or '' }}"></dd>
<dt>Instagram:</dt>
<dd><input type="text" name="instagram" value="{{ profile.instagram or '' }}"></dd>
<dt>Facebook:</dt>
<dd><input type="text" name="facebook" value="{{ profile.facebook or '' }}"></dd>
<dt>Telegram:</dt>
<dd><input type="text" name="telegram" value="{{ profile.telegram or '' }}"></dd>
<dd><input type="submit" value="Save"></dd>
</dl>
</form>
</div>
{% endblock %}

View file

@ -1,9 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros/message.html" import feed_message with context %}
{% block body %} {% block body %}
<h2>Explore</h2> <h2>Explore</h2>
<ul> <ul class="timeline">
{% for message in message_list %} {% for message in message_list %}
<li>{% include "includes/message.html" %}</li> {{ feed_message(message) }}
{% endfor %} {% endfor %}
</ul> </ul>
{% include "includes/pagination.html" %} {% include "includes/pagination.html" %}

View file

@ -1,9 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros/message.html" import feed_message with context %}
{% block body %} {% block body %}
<h2>Your Timeline</h2> <h2>Your Timeline</h2>
<ul> <ul class="timeline">
{% for message in message_list %} {% for message in message_list %}
<li>{% include "includes/message.html" %}</li> {{ feed_message(message) }}
{% endfor %} {% endfor %}
</ul> </ul>
{% include "includes/pagination.html" %} {% include "includes/pagination.html" %}

View file

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block body %}
<div class="card">
<h2>Hello</h2>
<p>{{ site_name }} is made by people like you. <br/>
<a href="{{ url_for('website.login') }}">Log in</a> or <a href="{{ url_for('website.register') }}">register</a> to see more.</p>
</div>
{% endblock %}

View file

@ -0,0 +1,20 @@
<div class="card infobox">
<h3>{{ user.full_name }}</h3>
<p>{{ user.biography|enrich }}</p>
{% if user.website %}
{% set website = user.website %}
{% set website = website if website.startswith(('http://', 'https://')) else 'http://' + website %}
<p><span class="weak">Website:</span> {{ website|urlize }}</p>
{% endif %}
<p>
<strong>{{ user.messages|count }}</strong> messages
-
<a href="{{ url_for('website.user_followers', username=user.username) }}"><strong>{{ user.followers()|count }}</strong></a> followers
-
<a href="{{ url_for('website.user_following', username=user.username) }}"><strong>{{ user.following()|count }}</strong></a> following
</p>
{% if user == current_user %}
<p><a href="/edit_profile/">{{ inline_svg('edit') }} Edit profile</a></p>
{% endif %}
</div>

View file

@ -0,0 +1,6 @@
<select name="location">
<option value="0">Not Applicable</option>
{% for k, v in locations.items() %}
<option value="{{ k }}">{{ v }}</option>
{% endfor %}
</select>

View file

@ -0,0 +1,31 @@
<p class="message-content">{{ message.text|enrich }}</p>
{% if message.uploads %}
<div class="message-visual">
<img src="/uploads/{{ message.uploads[0].filename() }}">
</div>
{% endif %}
<p class="message-footer">
<a href="javascript:void(0);" class="message-upvote" onclick="toggleUpvote({{ message.id }});">+</a>
<span class="message-score">{{ message.score }}</span>
-
<a href="{{ url_for('website.user_detail', username=message.user.username) }}">{{ message.user.username }}</a>
-
{% set message_privacy = message.privacy %}
{% if message_privacy == 0 %} Public
{% elif message_privacy == 1 %} Unlisted
{% elif message_privacy == 2 %} Friends
{% elif message_privacy == 3 %} Only me
{% endif %}
-
<time datetime="{{ message.pub_date.isoformat() }}" title="{{ message.pub_date.ctime() }}">{{ message.pub_date | human_date }}</time>
-
<a href="javascript:void(0);" onclick="showHideMessageOptions({{ message.id }});" class="message-options-showhide"></a>
</p>
<ul class="message-options">
{% if message.user == current_user %}
<li><a href="/edit/{{ message.id }}">Edit or change privacy</a></li>
<li><a href="/delete/{{ message.id }}">Delete permanently</a></li>
{% else %}
<li><a href="/report/message/{{ message.id }}" target="_blank">Report</a></li>
{% endif %}
</ul>

View file

@ -0,0 +1,15 @@
<div>
<p><strong>Message #{{ message.id }}</strong> (<a href="{# url_for('admin.message_info', id=message.id) #}">detail</a>)</p>
<p>Author: <a href="{# url_for('admin.user_detail', id=message.user_id #}">{{ message.user.username }}</a></p>
<p>Text:</p>
<div style="border:1px solid gray;padding:12px">
{{ message.text|enrich }}
{% if message.uploads %}
<div>
<img src="/uploads/{{ message.uploads[0].filename() }}">
</div>
{% endif %}
</div>
<p>Privacy: {{ ['public', 'unlisted', 'friends', 'only me'][message.privacy] }}</p>
<p>Date: {{ message.pub_date.strftime('%B %-d, %Y %H:%M:%S') }}</p>
</div>

View file

@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block body %}
<div class="card">
<h2>Join {{ site_name }}</h2>
<form action="{{ url_for('website.register') }}" method="POST">
<dl>
<dt>Username:</dt>
<dd><input type="text" class="username-input" name="username" autocomplete="off"></dd>
<dt>Full name:</dt>
<dd>
<small class="field_desc">If not given, defaults to your username.</small>
<input type="text" name="full_name">
</dd>
<dt>Password:</dt>
<dd><input type="password" name="password"></dd>
<dt>Email:</dt>
<dd><input type="text" name="email"></dd>
<dt>Birthday:</dt>
<dd>
<small class="field_desc">Your birthday won't be shown to anyone.</small>
<input type="text" name="birthday" placeholder="yyyy-mm-dd">
</dd>
{% if not current_user.is_anonymous %}
<dd>
<input type="checkbox" name="confirm_another" value="1">
<label for="confirm_another">I want to create another account</label>
</dd>
{% endif %}
<dd>
<input type="checkbox" name="legal" value="1">
<label for="legal">I've read the <a href="/terms/">Terms of Service</a> and <a href="/privacy/">Privacy Policy</a>.</label>
</dd>
<dd><input type="submit" value="Join">
</dl>
</form>
<div>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block body %}
<h2>Login</h2>
{% if error %}<p class="error"><strong>Error:</strong> {{ error }}</p>{% endif %}
<div class="card">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<dl>
<dt>Username or email:
<dd><input type="text" name="username">
<dt>Password:
<dd><input type="password" name="password">
<dt>Remember me for:
<dd><select name="remember">
<option value="0">This session only</option>
<option value="7">A week</option>
<option value="30">A month</option>
<option value="365">A year</option>
</select></dd>
<dd><input type="submit" value="Login">
</dl>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% macro feed_message(message) %}
<li class="card" id="{{ message.id }}">
<p class="message-content">{{ message.text|enrich }}</p>
{% if message.uploads %}
<div class="message-visual">
<img src="/uploads/{{ message.uploads[0].filename() }}">
</div>
{% endif %}
<p class="message-footer">
<a href="javascript:void(0);" class="message-upvote" onclick="toggleUpvote({{ message.id }});">+</a>
<span class="message-score">{{ message.score }}</span>
-
<a href="{{ url_for('website.user_detail', username=message.user.username) }}">{{ message.user.username }}</a>
-
{% set message_privacy = message.privacy %}
{% if message_privacy == 0 %} Public
{% elif message_privacy == 1 %} Unlisted
{% elif message_privacy == 2 %} Friends
{% elif message_privacy == 3 %} Only me
{% endif %}
-
<time datetime="{{ message.pub_date.isoformat() }}" title="{{ message.pub_date.ctime() }}">{{ message.pub_date | human_date }}</time>
-
<a href="javascript:void(0);" onclick="showHideMessageOptions({{ message.id }});" class="message-options-showhide"></a>
</p>
<ul class="message-options">
{% if message.user == current_user %}
<li><a href="/edit/{{ message.id }}">Edit or change privacy</a></li>
<li><a href="/delete/{{ message.id }}">Delete permanently</a></li>
{% else %}
<li><a href="/report/message/{{ message.id }}" target="_blank">Report</a></li>
{% endif %}
</ul>
</li>
{% endmacro %}

View file

@ -3,7 +3,7 @@
<h2>Notifications</h2> <h2>Notifications</h2>
<ul> <ul>
{% for notification in notification_list %} {% for notification in notification_list %}
<li>{% include "includes/notification.html" %}</li> <li class="card">{% include "includes/notification.html" %}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% include "includes/pagination.html" %} {% include "includes/pagination.html" %}

View file

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block body %}
<div class="card">
<h1>Privacy Policy</h1>
<p>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.</p>
<p>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</p>
<h2>Log Files</h2>
<p>{{ 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.</p>
<h2>Cookies and Web Beacons</h2>
<p>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.</p>
<p>You can choose to disable cookies through your individual browser options. This, however, can and will hurt Your usage of {{ site_name }}</p>
<h2>Privacy Policies</h2>
<P>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 <a href="https://www.privacypolicygenerator.info">Privacy Policy Generator</a> and the <a href="https://www.generateprivacypolicy.com">Generate Privacy Policy Generator</a>.</p>
<p>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.</p>
<p>Note that {{ site_name }} has no access to or control over these cookies that are used by third-party advertisers.</p>
<h2>Third Party Privacy Policies</h2>
<p>{{ 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.</p>
<h2>Legal Basis</h2>
<p>Legal Basis for treatment is Legitimate Interest, except:</p>
<ul>
<li>Transactional information, such as username, email and essential cookies, are treated according to Providing a Service.</li>
</ul>
<h2>Children's Information</h2>
<p>Another part of our priority is adding protection for children while using the internet. We encourage parents and guardians to observe, participate in, monitor, guide and/or exercise total control on their online activity.</p>
<p>{{ 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.</p>
<h2>Online Privacy Policy Only</h2>
<p>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 via channels other than this website.</p>
<h2>Consent</h2>
<p>By using our website, you hereby consent <u>irrevocably</u> to our Privacy Policy and agree to its Terms and Conditions.</p>
</div>
{% endblock %}

View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<title>Report &ndash; Cori+</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body{margin:0;font-family:sans-serif}
.item{padding:12px;border-bottom:1px solid gray}
.item h2{font-size:1em;margin:6px 0}
</style>
</head>
<body>
<div class="content">
{% block body %}{% endblock %}
</div>
<form id="reportForm" method="POST">
<input id="reportFormValue" name="reason" type="hidden" value="">
<input type="submit" style="display:none">
</form>
<script>
function submitReport(value){
reportFormValue.value = value;
reportForm.submit();
}
</script>
</body>
</html>

View file

@ -0,0 +1,11 @@
{% extends "report_base.html" %}
{% block body %}
<div class="item">
<h1>Done</h1>
<p>Your report has been sent.<br />
We'll review the user or message, and, if against our Community
Guidelines, we'll remove it.</p>
</div>
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "report_base.html" %}
{% block body %}
{% for reason in report_reasons %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="item" onclick="submitReport({{ reason[0] }})">
<h2>{{ reason[1] }}</h2>
</div>
</form>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "report_base.html" %}
{% block body %}
{% for reason in report_reasons %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="item" onclick="submitReport({{ reason[0] }})">
<h2>{{ reason[1] }}</h2>
</div>
</form>
{% endfor %}
{% endblock %}

View file

@ -1,7 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<div class="card">
<h1>Terms of Service</h1> <h1>Terms of Service</h1>
<p>[decline to state]</p>
</div>
{% endblock %} {% endblock %}

View file

@ -1,30 +1,29 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros/message.html" import feed_message with context %}
{% block body %} {% block body %}
{% include "includes/infobox_profile.html" %}
<h2>Messages from {{ user.username }}</h2> <h2>Messages from {{ user.username }}</h2>
<p> {% if not current_user.is_anonymous %}
<strong>{{ user.messages|count }}</strong> messages
-
<strong>{{ user.followers()|count }}</strong> followers
-
<strong>{{ user.following()|count }}</strong> following
</p>
{% if current_user %}
{% if user.username != current_user.username %} {% if user.username != current_user.username %}
{% if current_user|is_following(user) %} {% if current_user|is_following(user) %}
<form action="{{ url_for('user_unfollow', username=user.username) }}" method="post"> <form action="{{ url_for('website.user_unfollow', username=user.username) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="submit" class="follow_button following" value="- Un-follow" /> <input type="submit" class="follow_button following" value="- Un-follow" />
</form> </form>
{% else %} {% else %}
<form action="{{ url_for('user_follow', username=user.username) }}" method="post"> <form action="{{ url_for('website.user_follow', username=user.username) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="submit" class="follow_button" value="+ Follow" /> <input type="submit" class="follow_button" value="+ Follow" />
</form> </form>
{% endif %} {% endif %}
<p><a href="/create/?preload=%2B{{ user.username }}">Mention this user in a message</a></p> <p><a href="/create/?preload=%2B{{ user.username }}">Mention this user in a message</a></p>
{% else %}
<a href="/create/">Create a message</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
<ul> <ul class="timeline">
{% for message in message_list %} {% for message in message_list %}
<li>{% include "includes/message.html" %}</li> {{ feed_message(message) }}
{% endfor %} {% endfor %}
</ul> </ul>
{% include "includes/pagination.html" %} {% include "includes/pagination.html" %}

View file

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block body %}
<h1>{{ title }}</h1>
<ul>
{% for user in user_list %}
<li><a href="/+{{ user.username }}">{{ user.username }}</a></li>
{% endfor %}
</ul>
{% endblock %}

224
src/coriplus/utils.py Normal file
View file

@ -0,0 +1,224 @@
'''
A list of utilities used across modules.
'''
import datetime, re, base64, hashlib, string, sys, json
from flask_login import current_user
from .models import User, Message, Notification, MSGPRV_PUBLIC, MSGPRV_UNLISTED, \
MSGPRV_FRIENDS, MSGPRV_ONLYME
from flask import abort, render_template, request, session
from markupsafe import Markup
_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(s.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', encoding='utf-8') 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
# DEPRECATED in 0.10; use current_user instead
def get_current_user():
# new in 0.7; need a different method to get current user id
if request.path.startswith('/api/'):
# assume token validation is already done
return User[request.args['access_token'].split(':')[0]]
elif current_user.is_authenticated:
return current_user
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
def get_secret_key():
from . import app
secret_key = app.config['SECRET_KEY']
if isinstance(secret_key, str):
secret_key = secret_key.encode('utf-8')
return secret_key
def generate_access_token(user):
'''
Generate access token for public API.
'''
h = hashlib.sha256(get_secret_key())
h.update(b':')
h.update(str(user.id).encode('utf-8'))
h.update(b':')
h.update(str(user.password).encode('utf-8'))
return str(user.id) + ':' + h.hexdigest()[:32]
def check_access_token(token):
uid, hh = token.split(':')
try:
user = User[uid]
except User.DoesNotExist:
return
h = hashlib.sha256(get_secret_key())
h.update(b':')
h.update(str(user.id).encode('utf-8'))
h.update(b':')
h.update(str(user.password).encode('utf-8'))
if h.hexdigest()[:32] == hh:
return user
def create_mentions(cur_user, text, privacy):
# 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({cur_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(cur_user) and
cur_user.is_following(mention_user)):
push_notification('mention', mention_user, user=user.id)
except User.DoesNotExist:
pass
# New in 0.9
# changed in 0.10
def inline_svg(name):
return Markup('<span class="material-icons">{}</span>').format(name)

358
src/coriplus/website.py Normal file
View file

@ -0,0 +1,358 @@
'''
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 current_user, login_required, login_user, logout_user
import json
import logging
logger = logging.getLogger(__name__)
bp = Blueprint('website', __name__)
@bp.route('/')
def homepage():
if current_user and current_user.is_authenticated:
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 = current_user
messages = Visibility(Message
.select()
.where((Message.user << user.following())
| (Message.user == user))
.order_by(Message.pub_date.desc()))
return object_list('feed.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,
full_name=request.form.get('full_name') or username,
password=pwdhash(request.form['password']),
email=request.form['email'],
birthday=birthday,
join_date=datetime.datetime.now())
UserProfile.create(
user=user
)
# 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 current_user and current_user.is_authenticated:
flash('You are already logged in')
return redirect(request.args.get('next', '/'))
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('/+<username>/')
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('/+<username>/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())
push_notification('follow', user, user=cur_user.id)
flash('You are now following %s' % user.username)
except IntegrityError:
flash(f'Error following {user.username}')
return redirect(url_for('website.user_detail', username=user.username))
@bp.route('/+<username>/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('/+<username>/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('/+<username>/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:
logger.info('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(user, text, privacy)
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/<int:id>', 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/<int:id>', methods=['GET', 'POST'])
def confirm_delete(id):
user: User = current_user
message: Message = get_object_or_404(Message, Message.id == id)
if message.user != user:
abort(404)
if request.method == 'POST':
if message.user == user:
message.delete_instance()
flash('Your message has been deleted forever')
return redirect(request.args.get('next', '/'))
return render_template('confirm_delete.html', message=message)
# Workaround for problems related to invalid data.
# Without that, changes will be lost across requests.
def profile_checkpoint():
return UserProfile(
user=get_current_user(),
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,
telegram=request.form['telegram'] 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())
full_name = request.form['full_name'] or username
if full_name != user.full_name:
User.update(full_name=full_name).where(User.id == user.id).execute()
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(
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('/change_password/', methods=['GET', 'POST'])
def change_password():
user = get_current_user()
if request.method == 'POST':
old_password = request.form['old_password']
new_password = request.form['new_password']
confirm_password = request.form['confirm_password']
errors = False
if not new_password:
flash('Password cannot be empty')
errors = True
if new_password != confirm_password:
flash('Password mismatch')
errors = True
if pwdhash(old_password) != user.password:
flash('The old password is incorrect')
errors = True
if not errors:
user.update(
password=pwdhash(new_password)
)
return redirect(url_for('website.edit_profile'))
return render_template('change_password.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')

BIN
src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,171 @@
"""Peewee migrations -- 001_0_9_to_0_10.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class BaseModel(pw.Model):
id = pw.AutoField()
class Meta:
table_name = "basemodel"
@migrator.create_model
class User(pw.Model):
id = pw.AutoField()
username = pw.CharField(max_length=255, unique=True)
full_name = pw.TextField()
password = pw.CharField(max_length=255)
email = pw.CharField(max_length=255)
birthday = pw.DateField()
join_date = pw.DateTimeField()
is_disabled = pw.IntegerField()
class Meta:
table_name = "user"
@migrator.create_model
class Message(pw.Model):
id = pw.AutoField()
user = pw.ForeignKeyField(column_name='user_id', field='id', model=migrator.orm['user'])
text = pw.TextField()
pub_date = pw.DateTimeField()
privacy = pw.IntegerField()
class Meta:
table_name = "message"
@migrator.create_model
class MessageUpvote(pw.Model):
id = pw.AutoField()
message = pw.ForeignKeyField(column_name='message_id', field='id', model=migrator.orm['message'])
user = pw.ForeignKeyField(column_name='user_id', field='id', model=migrator.orm['user'])
created_date = pw.DateTimeField()
class Meta:
table_name = "messageupvote"
indexes = [(('message', 'user'), True)]
@migrator.create_model
class Notification(pw.Model):
id = pw.AutoField()
type = pw.TextField()
target = pw.ForeignKeyField(column_name='target_id', field='id', model=migrator.orm['user'])
detail = pw.TextField()
pub_date = pw.DateTimeField()
seen = pw.IntegerField()
class Meta:
table_name = "notification"
@migrator.create_model
class Relationship(pw.Model):
id = pw.AutoField()
from_user = pw.ForeignKeyField(column_name='from_user_id', field='id', model=migrator.orm['user'])
to_user = pw.ForeignKeyField(column_name='to_user_id', field='id', model=migrator.orm['user'])
created_date = pw.DateTimeField()
class Meta:
table_name = "relationship"
indexes = [(('from_user', 'to_user'), True)]
@migrator.create_model
class Report(pw.Model):
id = pw.AutoField()
media_type = pw.IntegerField()
media_id = pw.IntegerField()
sender = pw.ForeignKeyField(column_name='sender_id', field='id', model=migrator.orm['user'], null=True)
reason = pw.IntegerField()
status = pw.IntegerField()
created_date = pw.DateTimeField()
class Meta:
table_name = "report"
@migrator.create_model
class Upload(pw.Model):
id = pw.AutoField()
type = pw.TextField()
message = pw.ForeignKeyField(column_name='message_id', field='id', model=migrator.orm['message'])
class Meta:
table_name = "upload"
@migrator.create_model
class UserAdminship(pw.Model):
user = pw.ForeignKeyField(column_name='user_id', field='id', model=migrator.orm['user'], primary_key=True)
class Meta:
table_name = "useradminship"
@migrator.create_model
class UserProfile(pw.Model):
user = pw.ForeignKeyField(column_name='user_id', field='id', model=migrator.orm['user'], primary_key=True)
biography = pw.TextField()
location = pw.IntegerField(null=True)
year = pw.IntegerField(null=True)
website = pw.TextField(null=True)
instagram = pw.TextField(null=True)
facebook = pw.TextField(null=True)
telegram = pw.TextField(null=True)
class Meta:
table_name = "userprofile"
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('userprofile')
migrator.remove_model('useradminship')
migrator.remove_model('upload')
migrator.remove_model('report')
migrator.remove_model('relationship')
migrator.remove_model('notification')
migrator.remove_model('messageupvote')
migrator.remove_model('message')
migrator.remove_model('user')
migrator.remove_model('basemodel')

View file

@ -0,0 +1,86 @@
"""Peewee migrations -- 002_move_columns_from_userprofile.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields(
'user',
biography=pw.CharField(max_length=256, default=""),
website=pw.TextField(null=True))
migrator.change_fields('user', username=pw.CharField(max_length=30, unique=True))
migrator.change_fields('user', full_name=pw.CharField(max_length=80))
migrator.change_fields('user', password=pw.CharField(max_length=256))
migrator.change_fields('user', email=pw.CharField(max_length=256))
migrator.sql("""
UPDATE "user" SET biography = (SELECT p.biography FROM userprofile p WHERE p.user_id = id LIMIT 1),
website = (SELECT p.website FROM userprofile p WHERE p.user_id = id LIMIT 1);
""")
migrator.remove_fields('userprofile', 'year', 'instagram', 'facebook', 'telegram')
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.add_fields(
'userprofile',
year=pw.IntegerField(null=True),
instagram=pw.TextField(null=True),
facebook=pw.TextField(null=True),
telegram=pw.TextField(null=True))
migrator.sql("""
UPDATE "userprofile" SET biography = (SELECT p.biography FROM user p WHERE p.user_id = id LIMIT 1),
website = (SELECT p.website FROM user p WHERE p.user_id = id LIMIT 1);
""")
migrator.remove_fields('user', 'biography', 'website')
migrator.change_fields('user', username=pw.CharField(max_length=255, unique=True))
migrator.change_fields('user', full_name=pw.TextField())
migrator.change_fields('user', password=pw.CharField(max_length=255))
migrator.change_fields('user', email=pw.CharField(max_length=255))

View file

@ -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;
''')

View file

@ -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;
''')

View file

@ -0,0 +1,20 @@
import sqlite3
import sqlite3
conn = sqlite3.connect('coriplus.sqlite')
if __name__ == '__main__':
conn.executescript('''
BEGIN TRANSACTION;
CREATE TABLE "new_userprofile" ("user_id" INTEGER NOT NULL PRIMARY KEY, "biography" TEXT NOT NULL, "location" INTEGER, "year" INTEGER, "website" TEXT, "instagram" TEXT, "facebook" TEXT, telegram TEXT, FOREIGN KEY ("user_id") REFERENCES "user" ("id"));
CREATE TABLE "new_user" ("id" INTEGER NOT NULL PRIMARY KEY, "username" VARCHAR(30) NOT NULL, "full_name" VARCHAR(30), "password" VARCHAR(255) NOT NULL, "email" VARCHAR(255) NOT NULL, "birthday" DATE NOT NULL, "join_date" DATETIME NOT NULL, "is_disabled" INTEGER NOT NULL);
INSERT INTO new_user (id, username, full_name, password, email, birthday, join_date, is_disabled) SELECT t1.id, t1.username, t2.full_name, t1.password, t1.email, t1.birthday, t1.join_date, t1.is_disabled FROM user AS t1 LEFT JOIN userprofile AS t2 ON t1.id = t2.user_id;
INSERT INTO new_userprofile (user_id, biography, location, year, website, instagram, facebook, telegram) SELECT user_id, biography, location, year, website, instagram, facebook, telegram FROM userprofile;
UPDATE new_user SET full_name = username WHERE username IS NULL;
DROP TABLE user;
DROP TABLE userprofile;
ALTER TABLE new_user RENAME TO user;
ALTER TABLE new_userprofile RENAME TO userprofile;
COMMIT;
''')

3
src/robots.txt Normal file
View file

@ -0,0 +1,3 @@
User-Agent: *
Disallow: /report/
Noindex: /admin/

View file

@ -1,18 +0,0 @@
body,button,input,select,textarea{font-family:'Segoe UI',Arial,Helvetica,sans-serif}
body{margin:0}
.header{padding:12px;color:white;background-color:#ff3018}
.content{padding:12px}
.header a{color:white}
.content a{color:#3399ff}
.content a.plus{color:#ff3018}
.metanav{float:right}
.header h1{margin:0;display:inline-block}
.flash{background-color:#ff9;border:yellow 1px solid}
.message-visual img{max-width:100%;max-height:8em}
.create_text{width:100%;height:8em}
.follow_button,input[type="submit"]{background-color:#ff3018;color:white;border-radius:3px;border:1px solid #ff3018}
.follow_button.following{background-color:transparent;color:#ff3018;border-color:#ff3018}
.copyright{font-size:smaller;text-align:center;color:#808080}
.copyright a:link,.copyright a:visited{color:#31559e}
.copyright ul{list-style:none;padding:0}
.copyright ul > li{padding:0 3px}

View file

@ -1,7 +0,0 @@
{% extends "base.html" %}
{% block body %}
<h2>Not Found</h2>
<p><a href="/">Back to homepage.</a></p>
{% endblock %}

View file

@ -1,33 +0,0 @@
{% extends "base.html" %}
{% block body %}
<h1>About {{ site_name }}</h1>
<p>Version: {{ version }}</p>
<p>Copyright &copy; 2019 Sakuragasaki46.</p>
<h2>License</h2>
<p>Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files
(the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:</p>
<p>The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.</p>
<p>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
<p>Source code for this site: <a
href="https://github.com/sakuragasaki46/coriplus/">
https://github.com/sakuragasaki46/coriplus/</a>
{% endblock %}

View file

@ -1,43 +0,0 @@
<!DOCTYPE html>
<html>
<head>
{% set site_name = "Cori+" %}
<title>{{ site_name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="/static/style.css">
<meta name="og:title" content="Cori+">
<meta name="og:description" content="A simple social network. Post text statuses, optionally with image.">
</head>
<body>
<div class="header">
<h1><a href="{{ url_for('homepage') }}">{{ site_name }}</a></h1>
<div class="metanav">
{% if not session.logged_in %}
<a href="{{ url_for('login') }}">log in</a>
<a href="{{ url_for('register') }}">register</a>
{% else %}
<a href="{{ url_for('user_detail', username=current_user.username) }}">{{ current_user.username }}</a>
{% set notification_count = current_user.unseen_notification_count() %}
{% if notification_count > 0 %}
<a href="{{ url_for('notifications') }}">({{ notification_count }})</a>
{% endif %}
-
<a href="{{ url_for('public_timeline') }}">explore</a>
<a href="{{ url_for('create') }}">create</a>
<a href="{{ url_for('logout') }}">log out</a>
{% endif %}
</div>
</div>
<div class="content">
{% for message in get_flashed_messages() %}
<div class="flash">{{ message }}</div>
{% endfor %}
{% block body %}{% endblock %}
</div>
<div class="footer">
<p class="copyright">&copy; 2019 Sakuragasaki46.
<a href="/about/">About</a></p>
</div>
<script src="/static/lib.js"></script>
</body>
</html>

View file

@ -1,7 +0,0 @@
{% extends "base.html" %}
{% block body %}
<h2>Hello</h2>
<p>{{ site_name }} is made by people like you. <br/>
<a href="{{url_for('login')}}">Log in</a> or <a href="{{url_for('register')}}">register</a> to see more.</p>
{% endblock %}

View file

@ -1,17 +0,0 @@
<p class="message-content">{{ message.text|enrich }}</p>
{% if message.uploads %}
<div class="message-visual">
<img src="/uploads/{{message.uploads[0].filename()}}">
</div>
{% endif %}
<p class="message-footer">
<a href="{{ url_for('user_detail', username=message.user.username) }}">{{ message.user.username }}</a>
-
{% set message_privacy = message.privacy %}
{% if message.privacy in (0, 1) %} Public
{% elif message.privacy == 2 %} Friends
{% elif message.privacy == 3 %} Only me
{% endif %}
-
<time datetime="{{ message.pub_date.isoformat() }}" title="{{ message.pub_date.ctime() }}">{{ message.pub_date | human_date }}</time>
</p>

View file

@ -1,17 +0,0 @@
{% extends "base.html" %}
{% block body %}
<h2>Join {{ site_name }}</h2>
<form action="{{ url_for('register') }}" method="post">
<dl>
<dt>Username:</dt>
<dd><input type="text" class="username-input" name="username"></dd>
<dt>Password:</dt>
<dd><input type="password" name="password"></dd>
<dt>Email:</dt>
<dd><input type="text" name="email"></dd>
<dt>Birthday:
<dd><input type="text" name="birthday" placeholder="yyyy-mm-dd">
<dd><input type="submit" value="Join">
</dl>
</form>
{% endblock %}

View file

@ -1,14 +0,0 @@
{% extends "base.html" %}
{% block body %}
<h2>Login</h2>
{% if error %}<p class=error><strong>Error:</strong> {{ error }}{% endif %}
<form action="{{ url_for('login') }}" method="POST">
<dl>
<dt>Username or email:
<dd><input type="text" name="username">
<dt>Password:
<dd><input type="password" name="password">
<dd><input type="submit" value="Login">
</dl>
</form>
{% endblock %}

View file

@ -1,7 +0,0 @@
{% extends "base.html" %}
{% block body %}
<h1>Privacy Policy</h1>
{% endblock %}