Unpacking modules

This commit is contained in:
Yusur 2019-10-23 21:09:51 +02:00
parent 1e7787e24e
commit a9006bf1bc
36 changed files with 971 additions and 822 deletions

87
app/__init__.py Normal file
View file

@ -0,0 +1,87 @@
'''
Cori+
=====
The root module of the package.
This module also contains very basic web hooks, such as robots.txt.
For the website hooks, see `app.website`.
For the AJAX hook, see `app.ajax`.
For template filters, see `app.filters`.
For the database models, see `app.models`.
For other, see `app.utils`.
'''
from flask import (
Flask, abort, flash, g, jsonify, redirect, render_template, request,
send_from_directory, session, url_for, __version__ as flask_version)
import hashlib
from peewee import *
import datetime, time, re, os, sys, string, json, html
from functools import wraps
from flask_login import LoginManager
__version__ = '0.7-dev'
# we want to support Python 3 only.
# Python 2 has too many caveats.
if sys.version_info[0] < 3:
raise RuntimeError('Python 3 required')
app = Flask(__name__)
app.config.from_pyfile('../config.py')
login_manager = LoginManager(app)
from .models import *
from .utils import *
from .filters import *
### WEB ###
login_manager.login_view = 'website.login'
@app.before_request
def before_request():
g.db = database
try:
g.db.connect()
except OperationalError:
sys.stderr.write('database connected twice.\n')
@app.after_request
def after_request(response):
g.db.close()
return response
@app.context_processor
def _inject_variables():
return {'site_name': app.config['SITE_NAME'], 'locations': locations}
@login_manager.user_loader
def _inject_user(userid):
return User[userid]
@app.errorhandler(404)
def error_404(body):
return render_template('404.html'), 404
@app.route('/robots.txt')
def robots_txt():
return send_from_directory(os.getcwd(), 'robots.txt')
@app.route('/uploads/<id>.<type>')
def uploads(id, type='jpg'):
return send_from_directory(UPLOAD_DIRECTORY, id + '.' + type)
from .website import bp
app.register_blueprint(bp)
from .ajax import bp
app.register_blueprint(bp)

29
app/__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)

37
app/ajax.py Normal file
View file

@ -0,0 +1,37 @@
'''
AJAX hooks for the website.
Warning: this is not the public API.
'''
from flask import Blueprint, jsonify
from .models import User
from .utils import locations, get_current_user, is_username
bp = Blueprint('ajax', __name__, url_prefix='/ajax')
@bp.route('/username_availability/<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})

66
app/filters.py Normal file
View file

@ -0,0 +1,66 @@
'''
Filter functions used in the website templates.
'''
from flask import Markup
import html, datetime, re, time
from .utils import tokenize
from . import app
@app.template_filter()
def human_date(date):
timestamp = date.timestamp()
today = int(time.time())
offset = today - timestamp
if offset <= 1:
return '1 second ago'
elif offset < 60:
return '%d seconds ago' % offset
elif offset < 120:
return '1 minute ago'
elif offset < 3600:
return '%d minutes ago' % (offset // 60)
elif offset < 7200:
return '1 hour ago'
elif offset < 86400:
return '%d hours ago' % (offset // 3600)
elif offset < 172800:
return '1 day ago'
elif offset < 604800:
return '%d days ago' % (offset // 86400)
else:
d = datetime.datetime.fromtimestamp(timestamp)
return d.strftime('%B %e, %Y')
_enrich_symbols = [
(r'\n', 'NEWLINE'),
(r'https?://(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|\[[A-Fa-f0-9:]+\])'
r'(?::\d+)?(?:/[^\s]*)?(?:\?[^\s]*)?(?:#[^\s]*)?', 'URL'),
(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', 'MENTION'),
(r'[^h\n+]+', 'TEXT'),
(r'.', 'TEXT')
]
@app.template_filter()
def enrich(s):
tokens = tokenize(s, _enrich_symbols)
r = []
for text, tag in tokens:
if tag == 'TEXT':
r.append(html.escape(text))
elif tag == 'URL':
r.append('<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)]

198
app/models.py Normal file
View file

@ -0,0 +1,198 @@
'''
Database models for the application.
The tables are:
* user - the basic account info, such as username and password
* useradminship - relationship which existence determines whether a user is admin or not; new in 0.6
* userprofile - additional account info for self describing; new in 0.6
* message - a status update, appearing in profile and feeds
* relationship - a follow relationship between users
* upload - a file upload attached to a message; new in 0.2
* notification - a in-site notification to a user; new in 0.3
'''
from peewee import *
import os
# here should go `from .utils import get_current_user`, but it will cause
# import errors. It's instead imported at function level.
database = SqliteDatabase(os.path.join(os.getcwd(), 'coriplus.sqlite'))
class BaseModel(Model):
class Meta:
database = database
# A user. The user is separated from its page.
class User(BaseModel):
# The unique username.
username = CharField(unique=True)
# The password hash.
password = CharField()
# An email address.
email = CharField()
# The date of birth (required because of Terms of Service)
birthday = DateField()
# The date joined
join_date = DateTimeField()
# A disabled flag. 0 = active, 1 = disabled by user, 2 = banned
is_disabled = IntegerField(default=0)
# Helpers for flask_login
def get_id(self):
return str(self.id)
@property
def is_active(self):
return not self.is_disabled
@property
def is_anonymous(self):
return False
@property
def is_authenticated(self):
from .utils import get_current_user
return self == get_current_user()
# it often makes sense to put convenience methods on model instances, for
# example, "give me all the users this user is following":
def following(self):
# query other users through the "relationship" table
return (User
.select()
.join(Relationship, on=Relationship.to_user)
.where(Relationship.from_user == self)
.order_by(User.username))
def followers(self):
return (User
.select()
.join(Relationship, on=Relationship.from_user)
.where(Relationship.to_user == self)
.order_by(User.username))
def is_following(self, user):
return (Relationship
.select()
.where(
(Relationship.from_user == self) &
(Relationship.to_user == user))
.exists())
def unseen_notification_count(self):
return len(Notification
.select()
.where(
(Notification.target == self) & (Notification.seen == 0)
))
# user adminship is stored into a separate table; new in 0.6
@property
def is_admin(self):
return UserAdminship.select().where(UserAdminship.user == self).exists()
# user profile info; new in 0.6
@property
def profile(self):
# lazy initialization; I don't want (and don't know how)
# to do schema changes.
try:
return UserProfile.get(UserProfile.user == self)
except UserProfile.DoesNotExist:
return UserProfile.create(user=self, full_name=self.username)
# User adminship.
# A very high privilege where users can review posts.
# For very few users only; new in 0.6
class UserAdminship(BaseModel):
user = ForeignKeyField(User, primary_key=True)
# User profile.
# Additional info for identifying users.
# New in 0.6
class UserProfile(BaseModel):
user = ForeignKeyField(User, primary_key=True)
full_name = TextField()
biography = TextField(default='')
location = IntegerField(null=True)
year = IntegerField(null=True)
website = TextField(null=True)
instagram = TextField(null=True)
facebook = TextField(null=True)
telegram = TextField(null=True)
# The message privacy values.
MSGPRV_PUBLIC = 0 # everyone
MSGPRV_UNLISTED = 1 # everyone, doesn't show up in public timeline
MSGPRV_FRIENDS = 2 # only accounts which follow each other
MSGPRV_ONLYME = 3 # only the poster
# A single public message.
# New in v0.5: removed type and info fields; added privacy field.
class Message(BaseModel):
# The user who posted the message.
user = ForeignKeyField(User, backref='messages')
# The text of the message.
text = TextField()
# The posted date.
pub_date = DateTimeField()
# Info about privacy of the message.
privacy = IntegerField(default=MSGPRV_PUBLIC)
def is_visible(self, is_public_timeline=False):
from .utils import get_current_user
user = self.user
cur_user = get_current_user()
privacy = self.privacy
if user == cur_user:
# short path
# also: don't show user's messages in public timeline
return not is_public_timeline
elif privacy == MSGPRV_PUBLIC:
return True
elif privacy == MSGPRV_UNLISTED:
# even if unlisted
return not is_public_timeline
elif privacy == MSGPRV_FRIENDS:
if cur_user is None:
return False
return user.is_following(cur_user) and cur_user.is_following(user)
else:
return False
# this model contains two foreign keys to user -- it essentially allows us to
# model a "many-to-many" relationship between users. by querying and joining
# on different columns we can expose who a user is "related to" and who is
# "related to" a given user
class Relationship(BaseModel):
from_user = ForeignKeyField(User, backref='relationships')
to_user = ForeignKeyField(User, backref='related_to')
created_date = DateTimeField()
class Meta:
indexes = (
# Specify a unique multi-column index on from/to-user.
(('from_user', 'to_user'), True),
)
UPLOAD_DIRECTORY = os.path.join(os.path.split(os.path.dirname(__file__))[0], 'uploads')
class Upload(BaseModel):
# the extension of the media
type = TextField()
# the message bound to this media
message = ForeignKeyField(Message, backref='uploads')
# helper to retrieve contents
def filename(self):
return str(self.id) + '.' + self.type
class Notification(BaseModel):
type = TextField()
target = ForeignKeyField(User, backref='notifications')
detail = TextField()
pub_date = DateTimeField()
seen = IntegerField(default=0)
def create_tables():
with database:
database.create_tables([
User, UserAdminship, UserProfile, Message, Relationship,
Upload, Notification])
if not os.path.isdir(UPLOAD_DIRECTORY):
os.makedirs(UPLOAD_DIRECTORY)

100
app/static/lib.js Normal file
View file

@ -0,0 +1,100 @@
function checkUsername(u){
var starts_with_period = /^\./.test(u);
var ends_with_period = /\.$/.test(u);
var two_periods = /\.\./.test(u);
var forbidden_extensions = u.match(/\.(com|net|org|txt)$/);
return (
starts_with_period? 'You cannot start username with a period.':
ends_with_period? 'You cannot end username with a period.':
two_periods? 'You cannot have more than one period in a row.':
forbidden_extensions? 'Your username cannot end with .' + forbidden_extensions[1]:
'ok'
);
}
function attachUsernameInput(){
var usernameInputs = document.getElementsByClassName('username-input');
for(var i=0;i<usernameInputs.length;i++)(function(usernameInput){
var lastValue = '';
var usernameInputMessage = document.createElement('div');
usernameInput.oninput = function(event){
var value = usernameInput.value;
if (value != lastValue){
if(!/^[a-z0-9_. ]*$/i.test(value)){
usernameInputMessage.innerHTML = 'Usernames can only contain letters, numbers, underscores, and periods.';
usernameInputMessage.className = 'username-input-message error';
event.preventDefault();
return;
}
if(/ /.test(value)){
value = value.replace(/ /g,'_');
}
usernameInput.value = lastValue = value.toLowerCase();
if(!value){
usernameInputMessage.innerHTML = 'You cannot have an empty username.';
usernameInputMessage.className = 'username-input-message error';
return;
}
var message = checkUsername(value);
if (message != 'ok'){
usernameInputMessage.innerHTML = message;
usernameInputMessage.className = 'username-input-message error';
return;
}
usernameInputMessage.innerHTML = 'Checking username...';
usernameInputMessage.className = 'username-input-message checking';
requestUsernameAvailability(value, function(resp){
if (resp.status != 'ok'){
usernameInputMessage.innerHTML = 'Sorry, there was an unknown error.';
usernameInputMessage.className = 'username-input-message error';
return;
}
if (resp.is_available){
usernameInputMessage.innerHTML = "The username '" + value + "' is available.";
usernameInputMessage.className = 'username-input-message success';
return;
} else {
usernameInputMessage.innerHTML = "The username '" + value + "' is not available.";
usernameInputMessage.className = 'username-input-message error';
return;
}
});
}
};
usernameInputMessage.className = 'username-input-message';
usernameInput.parentNode.appendChild(usernameInputMessage);
})(usernameInputs[i]);
}
attachUsernameInput();
function requestUsernameAvailability(u, callback){
var xhr = new XMLHttpRequest();
xhr.open('GET', '/ajax/username_availability/' + u, true);
xhr.onreadystatechange = function(){
if (xhr.readyState == XMLHttpRequest.DONE){
try {
var data = JSON.parse(xhr.responseText);
callback(data);
} catch(ex) {
}
}
}
xhr.send();
}
function attachFileInput(){
var fileInput = document.getElementById('fileInputContainer');
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';
}
}

28
app/static/style.css Normal file
View file

@ -0,0 +1,28 @@
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}
.infobox{padding:12px;border:#ccc 1px solid}
@media (min-width:640px) {
.infobox{float:right;width:320px}
}
.weak{opacity:.5}
.field_desc{display:block}
.message-visual img{max-width:100%;max-height:8em}
.message-options-showhide::before{content:'\2026'}
.message-options{display:none}
.create_text{width:100%;height:8em}
.biography_text{height:4em}
.before-toggle:not(:checked) + input{display:none}
.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}

7
app/templates/404.html Normal file
View file

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

34
app/templates/about.html Normal file
View file

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block body %}
<h1>About {{ site_name }}</h1>
<p>{{ site_name }} {{ version }} &ndash; Python {{ python_version }} &ndash;
Flask {{ flask_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 %}

43
app/templates/base.html Normal file
View file

@ -0,0 +1,43 @@
<!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.">
</head>
<body>
<div class="header">
<h1><a href="{{ url_for('website.homepage') }}">{{ site_name }}</a></h1>
<div class="metanav">
{% if current_user.is_anonymous %}
<a href="{{ url_for('website.login', next=request.full_path) }}">log in</a>
<a href="{{ url_for('website.register', next=request.full_path) }}">register</a>
{% else %}
<a href="{{ url_for('website.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('website.notifications') }}">(<strong>{{ notification_count }}</strong>)</a>
{% endif %}
-
<a href="{{ url_for('website.public_timeline') }}">explore</a>
<a href="{{ url_for('website.create') }}">create</a>
<a href="{{ url_for('website.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> - <a href="/terms/">Terms</a> -
<a href="/privacy/">Privacy</a></p>
</div>
<script src="/static/lib.js"></script>
</body>
</html>

18
app/templates/create.html Normal file
View file

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block body %}
<h2>Create</h2>
<form action="{{ url_for('website.create') }}" method="POST" enctype="multipart/form-data">
<dl>
<dt>Message:</dt>
<dd><textarea name="text" placeholder="What's happening?" class="create_text">{{ request.args['preload'] }}</textarea></dd>
<dd id="fileInputContainer"><a href="javascript:attachFileInput();">Add a file...</a>
<dd><select name="privacy">
<option value="0">Public - everyone in your profile or public timeline</option>
<option value="1">Unlisted - everyone in your profile, hide from public timeline</option>
<option value="2">Friends - only people you follow each other</option>
<option value="3">Only you</option>
</select></dd>
<dd><input type="submit" value="Create" /></dd>
</dl>
</form>
{% endblock %}

17
app/templates/edit.html Normal file
View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block body %}
<h2>Edit</h2>
<form action="{{ url_for('website.edit', id=message.id) }}" method="POST" enctype="multipart/form-data">
<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>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block body %}
<h2>Edit Profile</h2>
<form method="POST">
<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>
{% endblock %}

View file

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block body %}
<h2>Explore</h2>
<ul>
{% for message in message_list %}
<li id="{{ message.id }}">{% include "includes/message.html" %}</li>
{% endfor %}
</ul>
{% include "includes/pagination.html" %}
{% endblock %}

View file

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block body %}
<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>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% set profile = user.profile %}
<div class="infobox">
<h3>{{ profile.full_name }}</h3>
<p>{{ profile.biography|enrich }}</p>
{% if profile.location %}
<p><span class="weak">Location:</span> {{ profile.location|locationdata }}</p>
{% endif %}
{% if profile.year %}
<p><span class="weak">Year:</span> {{ profile.year }}</p>
{% endif %}
{% if profile.website %}
{% set website = profile.website %}
{% set website = website if website.startswith(('http://', 'https://')) else 'http://' + website %}
<p><span class="weak">Website:</span> {{ profile.website|urlize }}</p>
{% endif %}
{% if profile.instagram %}
<p><span class="weak">Instagram:</span> <a href="https://www.instagram.com/{{ profile.instagram }}">{{ profile.instagram }}</a></p>
{% endif %}
{% if profile.facebook %}
<p><span class="weak">Facebook:</span> <a href="https://facebook.com/{{ profile.facebook }}">{{ profile.facebook }}</a></p>
{% endif %}
{% if profile.telegram %}
<p><span class="weak">Telegram:</span> <a href="https://t.me/{{ profile.facebook }}">{{ profile.telegram }}</a></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/">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,27 @@
<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('website.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>
-
<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="/confirm_delete/{{ message.id }}">Delete</a></li-->
{% else %}
<!--li><a href="/report/{{ message.id }}">Report</a></li-->
{% endif %}
</ul>

View file

@ -0,0 +1,13 @@
{% set detail = json.loads(notification.detail) %}
{% if notification.type == 'follow' %}
{% set user = User[detail['user']] %}
<p><a href="/+{{ user.username }}">{{ user.username }}</a> started following you.</p>
{% elif notification.type == 'mention' %}
{% set user = User[detail['user']] %}
<p><a href="/+{{ user.username }}">{{ user.username }}</a> mentioned you in a message.</p>
{% else %}
<p>Unknown Notification</p>
{% endif %}
<small>{{ notification.pub_date | human_date }}</small>

View file

@ -0,0 +1,6 @@
{% if page > 1 %}
<a class="prev" href="?page={{ page - 1 }}">Previous</a>
{% endif %}
{% if page < pages %}
<a class="next" href="?page={{ page + 1 }}">Next</a>
{% endif %}

35
app/templates/join.html Normal file
View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block body %}
<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>
{% endblock %}

21
app/templates/login.html Normal file
View file

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block body %}
<h2>Login</h2>
{% if error %}<p class=error><strong>Error:</strong> {{ error }}{% endif %}
<form method="POST">
<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>
{% endblock %}

View file

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block body %}
<h2>Notifications</h2>
<ul>
{% for notification in notification_list %}
<li>{% include "includes/notification.html" %}</li>
{% endfor %}
</ul>
{% include "includes/pagination.html" %}
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block body %}
<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>
<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>
<p>You can choose to disable cookies through your individual browser options. To know more detailed information about cookie management with specific web browsers, it can be found at the browsers' respective websites. What Are Cookies?</p>
<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, and/or monitor and guide 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 offline or via channels other than this website.</p>
<h2>Consent</h2>
<p>By using our website, you hereby consent to our Privacy Policy and agree to its Terms and Conditions.</p>
{% endblock %}

View file

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block body %}
<h2>Your Timeline</h2>
<ul>
{% for message in message_list %}
<li id="{{ message.id }}">{% include "includes/message.html" %}</li>
{% endfor %}
</ul>
{% include "includes/pagination.html" %}
{% endblock %}

7
app/templates/terms.html Normal file
View file

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

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block body %}
{% include "includes/infobox_profile.html" %}
<h2>Messages from {{ user.username }}</h2>
{% if not current_user.is_anonymous %}
{% if user.username != current_user.username %}
{% if current_user|is_following(user) %}
<form action="{{ url_for('website.user_unfollow', username=user.username) }}" method="post">
<input type="submit" class="follow_button following" value="- Un-follow" />
</form>
{% else %}
<form action="{{ url_for('website.user_follow', username=user.username) }}" method="post">
<input type="submit" class="follow_button" value="+ Follow" />
</form>
{% endif %}
<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 %}
<ul>
{% for message in message_list %}
<li id="{{ message.id }}">{% include "includes/message.html" %}</li>
{% endfor %}
</ul>
{% include "includes/pagination.html" %}
{% endblock %}

10
app/templates/user_list.html Executable file
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 %}

162
app/utils.py Normal file
View file

@ -0,0 +1,162 @@
'''
A list of utilities used across modules.
'''
import datetime, re, base64, hashlib, string
from .models import User, Notification
from flask import abort, render_template, request, session
import sys, json
_forbidden_extensions = 'com net org txt'.split()
_username_characters = frozenset(string.ascii_letters + string.digits + '_')
def is_username(username):
username_splitted = username.split('.')
if username_splitted and username_splitted[-1] in _forbidden_extensions:
return False
return all(x and set(x) < _username_characters for x in username_splitted)
def validate_birthday(date):
today = datetime.date.today()
if today.year - date.year > 13:
return True
if today.year - date.year < 13:
return False
if today.month > date.month:
return True
if today.month < date.month:
return False
if today.day >= date.day:
return True
return False
def validate_website(website):
return re.match(r'(?:https?://)?(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*'
r'|\[[A-Fa-f0-9:]+\])(?::\d+)?(?:/[^\s]*)?(?:\?[^\s]*)?(?:#[^\s]*)?$',
website)
def human_short_date(timestamp):
return ''
def int_to_b64(n):
b = int(n).to_bytes(48, 'big')
return base64.b64encode(b).lstrip(b'A').decode()
def pwdhash(s):
return hashlib.md5((request.form['password']).encode('utf-8')).hexdigest()
def get_object_or_404(model, *expressions):
try:
return model.get(*expressions)
except model.DoesNotExist:
abort(404)
class Visibility(object):
'''
Workaround for the visibility problem for posts.
Cannot be directly resolved with filter().
TODO find a better solution, this seems to be too slow.
'''
def __init__(self, query, is_public_timeline=False):
self.query = query
self.is_public_timeline = is_public_timeline
def __iter__(self):
for i in self.query:
if i.is_visible(self.is_public_timeline):
yield i
def count(self):
counter = 0
for i in self.query:
if i.is_visible(self.is_public_timeline):
counter += 1
return counter
def paginate(self, page):
counter = 0
pages_no = range((page - 1) * 20, page * 20)
for i in self.query:
if i.is_visible(self.is_public_timeline):
if counter in pages_no:
yield i
counter += 1
def get_locations():
data = {}
with open('locations.txt') as f:
for line in f:
line = line.rstrip()
if line.startswith('#'):
continue
try:
key, value = line.split(None, 1)
except ValueError:
continue
data[key] = value
return data
try:
locations = get_locations()
except OSError:
locations = {}
# get the user from the session
# changed in 0.5 to comply with flask_login
def get_current_user():
user_id = session.get('user_id')
if user_id:
return User[user_id]
def push_notification(type, target, **kwargs):
try:
if isinstance(target, str):
target = User.get(User.username == target)
Notification.create(
type=type,
target=target,
detail=json.dumps(kwargs),
pub_date=datetime.datetime.now()
)
except Exception:
sys.excepthook(*sys.exc_info())
def unpush_notification(type, target, **kwargs):
try:
if isinstance(target, str):
target = User.get(User.username == target)
(Notification
.delete()
.where(
(Notification.type == type) &
(Notification.target == target) &
(Notification.detail == json.dumps(kwargs))
)
.execute())
except Exception:
sys.excepthook(*sys.exc_info())
# given a template and a SelectQuery instance, render a paginated list of
# objects from the query inside the template
def object_list(template_name, qr, var_name='object_list', **kwargs):
kwargs.update(
page=int(request.args.get('page', 1)),
pages=qr.count() // 20 + 1)
kwargs[var_name] = qr.paginate(kwargs['page'])
return render_template(template_name, **kwargs)
def tokenize(characters, table):
'''
A useful tokenizer.
'''
pos = 0
tokens = []
while pos < len(characters):
mo = None
for pattern, tag in table:
mo = re.compile(pattern).match(characters, pos)
if mo:
if tag:
text = mo.group(0)
tokens.append((text, tag))
break
pos = mo.end(0)
return tokens

332
app/website.py Normal file
View file

@ -0,0 +1,332 @@
'''
All website hooks, excluding AJAX.
'''
from .utils import *
from .models import *
from . import __version__ as app_version
from sys import version as python_version
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for, __version__ as flask_version
from flask_login import login_required, login_user, logout_user
import json
bp = Blueprint('website', __name__)
@bp.route('/')
def homepage():
if get_current_user():
return private_timeline()
else:
return render_template('homepage.html')
def private_timeline():
# the private timeline (aka feed) exemplifies the use of a subquery -- we are asking for
# messages where the person who created the message is someone the current
# user is following. these messages are then ordered newest-first.
user = get_current_user()
messages = Visibility(Message
.select()
.where((Message.user << user.following())
| (Message.user == user))
.order_by(Message.pub_date.desc()))
# TODO change to "feed.html"
return object_list('private_messages.html', messages, 'message_list')
@bp.route('/explore/')
def public_timeline():
messages = Visibility(Message
.select()
.order_by(Message.pub_date.desc()), True)
return object_list('explore.html', messages, 'message_list')
@bp.route('/signup/', methods=['GET', 'POST'])
def register():
if request.method == 'POST' and request.form['username']:
try:
birthday = datetime.datetime.fromisoformat(request.form['birthday'])
except ValueError:
flash('Invalid date format')
return render_template('join.html')
username = request.form['username'].lower()
if not is_username(username):
flash('This username is invalid')
return render_template('join.html')
if username == getattr(get_current_user(), 'username', None) and not request.form.get('confirm_another'):
flash('You are already logged in. Please confirm you want to '
'create another account by checking the option.')
return render_template('join.html')
try:
with database.atomic():
# Attempt to create the user. If the username is taken, due to the
# unique constraint, the database will raise an IntegrityError.
user = User.create(
username=username,
password=pwdhash(request.form['password']),
email=request.form['email'],
birthday=birthday,
join_date=datetime.datetime.now())
UserProfile.create(
user=user,
full_name=request.form.get('full_name') or username
)
# mark the user as being 'authenticated' by setting the session vars
login_user(user)
return redirect(request.args.get('next','/'))
except IntegrityError:
flash('That username is already taken')
return render_template('join.html')
@bp.route('/login/', methods=['GET', 'POST'])
def login():
if request.method == 'POST' and request.form['username']:
try:
username = request.form['username']
pw_hash = pwdhash(request.form['password'])
if '@' in username:
user = User.get(User.email == username)
else:
user = User.get(User.username == username)
if user.password != pw_hash:
flash('The password entered is incorrect.')
return render_template('login.html')
except User.DoesNotExist:
flash('A user with this username or email does not exist.')
else:
remember_for = int(request.form['remember'])
if remember_for > 0:
login_user(user, remember=True,
duration=datetime.timedelta(days=remember_for))
else:
login_user(user)
return redirect(request.args.get('next', '/'))
return render_template('login.html')
@bp.route('/logout/')
def logout():
logout_user()
flash('You were logged out')
return redirect(request.args.get('next','/'))
@bp.route('/+<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())
except IntegrityError:
pass
flash('You are following %s' % user.username)
push_notification('follow', user, user=cur_user.id)
return redirect(url_for('website.user_detail', username=user.username))
@bp.route('/+<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:
print('Uploading', file.filename)
ext = file.filename.split('.')[-1]
upload = Upload.create(
type=ext,
message=message
)
file.save(UPLOAD_DIRECTORY + str(upload.id) + '.' + ext)
# create mentions
mention_usernames = set()
for mo in re.finditer(r'\+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)', text):
mention_usernames.add(mo.group(1))
# to avoid self mention
mention_usernames.difference_update({user.username})
for u in mention_usernames:
try:
mention_user = User.get(User.username == u)
if privacy in (MSGPRV_PUBLIC, MSGPRV_UNLISTED) or \
(privacy == MSGPRV_FRIENDS and
mention_user.is_following(user) and
user.is_following(mention_user)):
push_notification('mention', mention_user, user=user.id)
except User.DoesNotExist:
pass
flash('Your message has been posted successfully')
return redirect(url_for('website.user_detail', username=user.username))
return render_template('create.html')
@bp.route('/edit/<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):
# return render_template('confirm_delete.html')
# Workaround for problems related to invalid data.
# Without that, changes will be lost across requests.
def profile_checkpoint():
return UserProfile(
user=get_current_user(),
full_name=request.form['full_name'],
biography=request.form['biography'],
location=int(request.form['location']),
year=int(request.form['year'] if request.form.get('has_year') else '0'),
website=request.form['website'] or None,
instagram=request.form['instagram'] or None,
facebook=request.form['facebook'] or None
)
@bp.route('/edit_profile/', methods=['GET', 'POST'])
@login_required
def edit_profile():
if request.method == 'POST':
user = get_current_user()
username = request.form['username']
if not username:
# prevent username to be set to empty
username = user.username
if username != user.username:
try:
User.update(username=username).where(User.id == user.id).execute()
except IntegrityError:
flash('That username is already taken')
return render_template('edit_profile.html', profile=profile_checkpoint())
website = request.form['website'].strip().replace(' ', '%20')
if website and not validate_website(website):
flash('You should enter a valid URL.')
return render_template('edit_profile.html', profile=profile_checkpoint())
location = int(request.form.get('location'))
if location == 0:
location = None
UserProfile.update(
full_name=request.form['full_name'] or username,
biography=request.form['biography'],
year=request.form['year'] if request.form.get('has_year') else None,
location=location,
website=website,
instagram=request.form['instagram'],
facebook=request.form['facebook'],
telegram=request.form['telegram']
).where(UserProfile.user == user).execute()
return redirect(url_for('website.user_detail', username=username))
return render_template('edit_profile.html')
@bp.route('/notifications/')
@login_required
def notifications():
user = get_current_user()
notifications = (Notification
.select()
.where(Notification.target == user)
.order_by(Notification.pub_date.desc()))
with database.atomic():
(Notification
.update(seen=1)
.where((Notification.target == user) & (Notification.seen == 0))
.execute())
return object_list('notifications.html', notifications, 'notification_list', json=json, User=User)
@bp.route('/about/')
def about():
return render_template('about.html', version=app_version,
python_version=python_version, flask_version=flask_version)
# The two following routes are mandatory by law.
@bp.route('/terms/')
def terms():
return render_template('terms.html')
@bp.route('/privacy/')
def privacy():
return render_template('privacy.html')