diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..3758a58 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a7da10e --- /dev/null +++ b/LICENSE @@ -0,0 +1,33 @@ +Copyright (c) 2015 by Armin Ronacher and contributors. See AUTHORS +for more details. + +Some rights reserved. + +Redistribution and use in source and binary forms of the software as well +as documentation, with or without modification, are permitted provided +that the following conditions are met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +* The names of the contributors may not be used to endorse or + promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND +CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT +NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..9bde38a --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn csitwit:app --log-file=- diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4b47c3 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +export MINITWIT_SETTINGS env var or edit minitwit.py + +python3 -m flask -a csitwit initdb +python3 csitwit.py + +This is a modification of the MiniTwit example from Flask. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controllers/.DS_Store b/controllers/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/controllers/.DS_Store differ diff --git a/controllers/__init__.py b/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controllers/follow.py b/controllers/follow.py new file mode 100644 index 0000000..8f8b3a9 --- /dev/null +++ b/controllers/follow.py @@ -0,0 +1,33 @@ +from helpers import functions +from flask import Flask, request, session, url_for, redirect, \ + render_template, abort, g, flash, _app_ctx_stack + +def follow_user(username): + """Adds the current user as follower of the given user.""" + if not g.user: + abort(401) + whom_id = functions.get_user_id(username) + if whom_id is None: + abort(404) + db = functions.get_db() + db.execute('insert into follower (who_id, whom_id) values (?, ?)', + [session['user_id'], whom_id]) + db.commit() + flash('You are now following "%s"' % username) + return redirect(functions.url_for('/%(username)s', {'username':username})) + +def unfollow_user(username): + """Removes the current user as follower of the given user.""" + if not g.user: + abort(401) + whom_id = functions.get_user_id(username) + if whom_id is None: + abort(404) + db = functions.get_db() + db.execute('delete from follower where who_id=? and whom_id=?', + [session['user_id'], whom_id]) + db.commit() + flash('You are no longer following "%s"' % username) + return redirect(functions.url_for('/%(username)s', {'username':username})) + + diff --git a/controllers/timeline.py b/controllers/timeline.py new file mode 100644 index 0000000..2f0f3b3 --- /dev/null +++ b/controllers/timeline.py @@ -0,0 +1,48 @@ +from helpers import functions +from flask import Flask, request, session, url_for, redirect, \ + render_template, abort, g, flash, _app_ctx_stack + +PER_PAGE = 30 + +def index(): + """Shows a users timeline or if no user is logged in it will + redirect to the public timeline. This timeline shows the user's + messages as well as all the messages of followed users. + """ + if not g.user: + return redirect(functions.url_for('/public')) + query_messages = functions.query_db(''' + select message.*, user.* from message, user + where message.author_id = user.user_id and ( + user.user_id = ? or + user.user_id in (select whom_id from follower + where who_id = ?)) + order by message.pub_date desc limit ?''', + [session['user_id'], session['user_id'], PER_PAGE]) + return render_template('timeline.html', messages=query_messages) + +def public(): + """Displays the latest messages of all users.""" + return render_template('timeline.html', messages=functions.query_db(''' + select message.*, user.* from message, user + where message.author_id = user.user_id + order by message.pub_date desc limit ?''', [PER_PAGE])) + +def user(username): + """Display's a users tweets.""" + profile_user = functions.query_db('select * from user where username = ?', + [username], one=True) + if profile_user is None: + abort(404) + followed = False + if g.user: + followed = functions.query_db('''select 1 from follower where + follower.who_id = ? and follower.whom_id = ?''', + [session['user_id'], profile_user['user_id']], + one=True) is not None + return render_template('timeline.html', messages=functions.query_db(''' + select message.*, user.* from message, user where + user.user_id = message.author_id and user.user_id = ? + order by message.pub_date desc limit ?''', + [profile_user['user_id'], PER_PAGE]), followed=followed, + profile_user=profile_user) diff --git a/controllers/tweet.py b/controllers/tweet.py new file mode 100644 index 0000000..ac26fe1 --- /dev/null +++ b/controllers/tweet.py @@ -0,0 +1,17 @@ +from helpers import functions +from flask import Flask, request, session, url_for, redirect, \ + render_template, abort, g, flash, _app_ctx_stack +import time + +def add_message(): + """Registers a new message for the user.""" + if 'user_id' not in session: + abort(401) + if request.form['text']: + db = functions.get_db() + db.execute('''insert into message (author_id, text, pub_date) + values (?, ?, ?)''', (session['user_id'], request.form['text'], + int(time.time()))) + db.commit() + flash('Your message was recorded') + return redirect(functions.url_for('/')) diff --git a/controllers/user.py b/controllers/user.py new file mode 100644 index 0000000..c16ae5d --- /dev/null +++ b/controllers/user.py @@ -0,0 +1,57 @@ +from helpers import functions +from flask import Flask, request, session, url_for, redirect, \ + render_template, abort, g, flash, _app_ctx_stack +from werkzeug import check_password_hash, generate_password_hash + +def login(): + """Logs the user in.""" + if g.user: + return redirect(functions.url_for('/')) + error = None + if request.method == 'POST': + user = functions.query_db('''select * from user where + username = ?''', [request.form['username']], one=True) + if user is None: + error = 'Invalid username' + elif not check_password_hash(user['pw_hash'], + request.form['password']): + error = 'Invalid password' + else: + flash('You were logged in') + session['user_id'] = user['user_id'] + return redirect(functions.url_for('/')) + return render_template('login.html', error=error) + +def register(): + """Registers the user.""" + if g.user: + return redirect(functions.url_for('/')) + error = None + if request.method == 'POST': + if not request.form['username']: + error = 'You have to enter a username' + elif not request.form['email'] or \ + '@' not in request.form['email']: + error = 'You have to enter a valid email address' + elif not request.form['password']: + error = 'You have to enter a password' + elif request.form['password'] != request.form['password1']: + error = 'The two passwords do not match' + #request.form['password'] is not request.form['password1'] + elif functions.get_user_id(request.form['username']) is not None: + error = 'The username is already taken' + else: + db = functions.get_db() + db.execute('''insert into user (username, email, pw_hash) values (?, ?, ?)''', + [request.form['username'], request.form['email'], + generate_password_hash(request.form['password'])]) + db.commit() + flash('You were successfully registered and can login now') + return redirect(functions.url_for('login')) + return render_template('register.html', error=error) + +def logout(): + """Logs the user out.""" + flash('You were logged out') + session.pop('user_id', None) + return redirect(functions.url_for('/public')) diff --git a/csitwit.py b/csitwit.py new file mode 100644 index 0000000..4470079 --- /dev/null +++ b/csitwit.py @@ -0,0 +1,22 @@ +from flask import Flask +from helpers import functions, init +import router + +# create our application +app = Flask(__name__) + +# configuration +app.config.update( + DEBUG = True, + DATABASE = 'minitwit.db', + SECRET_KEY = 'a unique key' +) + +# some initialization functions +init.start(app) + +# set our router +router.set_router(app) + +if __name__ == "__main__": + app.run(port=5001) diff --git a/helpers/.DS_Store b/helpers/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/helpers/.DS_Store differ diff --git a/helpers/__init__.py b/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/helpers/functions.py b/helpers/functions.py new file mode 100644 index 0000000..e2b3a92 --- /dev/null +++ b/helpers/functions.py @@ -0,0 +1,64 @@ +from flask import _app_ctx_stack +from flask import current_app as app +from flask import Flask, request, session, url_for, redirect, \ + render_template, abort, g, flash, _app_ctx_stack +from sqlite3 import dbapi2 as sqlite3 +from hashlib import md5 +from datetime import datetime + +def get_db(): + """Opens a new database connection if there is none yet for the + current application context. + """ + top = _app_ctx_stack.top + if not hasattr(top, 'sqlite_db'): + top.sqlite_db = sqlite3.connect(app.config['DATABASE']) + top.sqlite_db.row_factory = sqlite3.Row + return top.sqlite_db + +def close_database(exception): + """Closes the database again at the end of the request.""" + top = _app_ctx_stack.top + if hasattr(top, 'sqlite_db'): + top.sqlite_db.close() + +def init_db(): + """Initializes the database.""" + db = get_db() + with app.open_resource('schema.sql', mode='r') as f: + db.cursor().executescript(f.read()) + db.commit() + +def initdb_command(): + """Creates the database tables.""" + init_db() + print('Initialized the database.') + +def query_db(query, args=(), one=False): + """Queries the database and returns a list of dictionaries.""" + cur = get_db().execute(query, args) + rv = cur.fetchall() + return (rv[0] if rv else None) if one else rv + +def get_user_id(username): + """Convenience method to look up the id for a username.""" + rv = query_db('select user_id from user where username = ?', + [username], one=True) + return rv[0] if rv else None + + +def format_datetime(timestamp): + """Format a timestamp for display.""" + return datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%d @ %H:%M') + + +def gravatar_url(email, size=80): + """Return the gravatar image for the given email address.""" + return 'http://www.gravatar.com/avatar/%s?d=identicon&s=%d' % \ + (md5(email.strip().lower().encode('utf-8')).hexdigest(), size) + +def url_for(string, data=None): + if data is not None: + return (string) % data + else: + return string diff --git a/helpers/init.py b/helpers/init.py new file mode 100644 index 0000000..8f01a88 --- /dev/null +++ b/helpers/init.py @@ -0,0 +1,14 @@ +from helpers import functions + +def start(app): + # add some filters to jinja, to enable us to use it on our template files + app.jinja_env.filters['datetimeformat'] = functions.format_datetime + app.jinja_env.filters['gravatar'] = functions.gravatar_url + app.jinja_env.filters['url_for'] = functions.url_for + + # teardown_appcontext closes the database + app.teardown_appcontext(functions.close_database) + + # we're adding the cli command to initialize the database + app.cli.command('initdb')(functions.initdb_command) + diff --git a/minitwit.db b/minitwit.db new file mode 100644 index 0000000..8f9cc11 Binary files /dev/null and b/minitwit.db differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fa15648 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +click==5.1 +-e git://github.com/mitsuhiko/flask.git#egg=Flask +gunicorn==19.3.0 +itsdangerous==0.24 +Jinja2==2.8 +MarkupSafe==0.23 +Werkzeug==0.10.4 +wheel==0.24.0 diff --git a/router.py b/router.py new file mode 100644 index 0000000..35f830b --- /dev/null +++ b/router.py @@ -0,0 +1,33 @@ +from flask import g, session +from helpers import functions, init +from controllers import follow, timeline, tweet, user + +def set_router(app): + # we're setting g.user as the user in the current session + @app.before_request + def before_request(): + g.user = None + if 'user_id' in session: + # if there is a user_id in session + # select it and set g.user to it + g.user = functions.query_db('select * from user where user_id = ?', + [session['user_id']], one=True) + + ### Our Routes ### + + # timeline routes + app.route('/')(timeline.index) + app.route('/public')(timeline.public) + app.route('/')(timeline.user) + + # follow routes + app.route('//follow')(follow.follow_user) + app.route('//unfollow')(follow.unfollow_user) + + # tweet routes + app.route('/add_message', methods=['POST'])(tweet.add_message) + + # user routes + app.route('/login', methods=['GET', 'POST'])(user.login) + app.route('/register', methods=['GET', 'POST'])(user.register) + app.route('/logout')(user.logout) diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..d2fdba2 --- /dev/null +++ b/schema.sql @@ -0,0 +1,23 @@ +drop table if exists user; +create table user ( + user_id integer primary key autoincrement, + username text not null, + email text not null, + pw_hash text not null +); + +drop table if exists follower; +create table follower ( + who_id integer, + whom_id integer +); + +drop table if exists message; +create table message ( + message_id integer primary key autoincrement, + author_id integer not null, + text text not null, + pub_date integer +); + +INSERT INTO user (username, email, pw_hash) VALUES ('person', 'person@gmail.com', 'pbkdf2:sha1:1000$L1UfsRgW$d780bf74e8432301bbdd361ee5ade12e842820e3') diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..49a39d8 --- /dev/null +++ b/static/style.css @@ -0,0 +1,178 @@ +body { + background: #BEE8E4; + font-family: 'Trebuchet MS', sans-serif; + font-size: 14px; +} + +a { + color: #26776F; +} + +a:hover { + color: #333; +} + +input[type="text"], +input[type="password"] { + background: white; + border: 1px solid #BFE6E2; + padding: 2px; + font-family: 'Trebuchet MS', sans-serif; + font-size: 14px; + -moz-border-radius: 2px; + -webkit-border-radius: 2px; + color: #105751; +} + +input[type="submit"] { + background: #105751; + border: 1px solid #073B36; + padding: 1px 3px; + font-family: 'Trebuchet MS', sans-serif; + font-size: 14px; + font-weight: bold; + -moz-border-radius: 2px; + -webkit-border-radius: 2px; + color: white; +} + +div.page { + background: white; + border: 1px solid #6ECCC4; + width: 700px; + margin: 30px auto; +} + +div.page h1 { + background: #6ECCC4; + margin: 0; + padding: 10px 14px; + color: white; + letter-spacing: 1px; + text-shadow: 0 0 3px #24776F; + font-weight: normal; +} + +div.page div.navigation { + background: #DEE9E8; + padding: 4px 10px; + border-top: 1px solid #ccc; + border-bottom: 1px solid #eee; + color: #888; + font-size: 12px; + letter-spacing: 0.5px; +} + +div.page div.navigation a { + color: #444; + font-weight: bold; +} + +div.page h2 { + margin: 0 0 15px 0; + color: #105751; + text-shadow: 0 1px 2px #ccc; +} + +div.page div.body { + padding: 10px; +} + +div.page div.footer { + background: #eee; + color: #888; + padding: 5px 10px; + font-size: 12px; +} + +div.page div.followstatus { + border: 1px solid #ccc; + background: #E3EBEA; + -moz-border-radius: 2px; + -webkit-border-radius: 2px; + padding: 3px; + font-size: 13px; +} + +div.page ul.messages { + list-style: none; + margin: 0; + padding: 0; +} + +div.page ul.messages li { + margin: 10px 0; + padding: 5px; + background: #F0FAF9; + border: 1px solid #DBF3F1; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + min-height: 48px; +} + +div.page ul.messages p { + margin: 0; +} + +div.page ul.messages li img { + float: left; + padding: 0 10px 0 0; +} + +div.page ul.messages li small { + font-size: 0.9em; + color: #888; +} + +div.page div.twitbox { + margin: 10px 0; + padding: 5px; + background: #F0FAF9; + border: 1px solid #94E2DA; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; +} + +div.page div.twitbox h3 { + margin: 0; + font-size: 1em; + color: #2C7E76; +} + +div.page div.twitbox p { + margin: 0; +} + +div.page div.twitbox input[type="text"] { + width: 585px; +} + +div.page div.twitbox input[type="submit"] { + width: 70px; + margin-left: 5px; +} + +ul.flashes { + list-style: none; + margin: 10px 10px 0 10px; + padding: 0; +} + +ul.flashes li { + background: #B9F3ED; + border: 1px solid #81CEC6; + -moz-border-radius: 2px; + -webkit-border-radius: 2px; + padding: 4px; + font-size: 13px; +} + +div.error { + margin: 10px 0; + background: #FAE4E4; + border: 1px solid #DD6F6F; + -moz-border-radius: 2px; + -webkit-border-radius: 2px; + padding: 4px; + font-size: 13px; +} diff --git a/templates/.DS_Store b/templates/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/templates/.DS_Store differ diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..c6d7aa5 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,32 @@ + +{% block title %}Welcome{% endblock %} | CSITwit + +
+

CSITwit

+ + {% with flashes = get_flashed_messages() %} + {% if flashes %} +
    + {% for message in flashes %} +
  • {{ message }} + {% endfor %} +
+ {% endif %} + {% endwith %} +
+ {% block body %}{% endblock %} +
+ +
diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..f15bf10 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,16 @@ +{% extends "layout.html" %} +{% block title %}Sign In{% endblock %} +{% block body %} +

Sign In

+ {% if error %}
Error: {{ error }}
{% endif %} +
+
+
Username: +
+
Password: +
+
+
+
+{% endblock %} + diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..446133c --- /dev/null +++ b/templates/register.html @@ -0,0 +1,20 @@ +{% extends "layout.html" %} +{% block title %}Sign Up{% endblock %} +{% block body %} +

Sign Up

+ {% if error %}
Error: {{ error }}
{% endif %} +
+
+
Username: +
+
E-Mail: +
+
Password: +
+ +
Confirm Password: +
+
+
+
+{% endblock %} diff --git a/templates/timeline.html b/templates/timeline.html new file mode 100644 index 0000000..efc2315 --- /dev/null +++ b/templates/timeline.html @@ -0,0 +1,48 @@ +{% extends "layout.html" %} +{% block title %} + {% if request.endpoint == 'index' %} + Public Timeline + {% elif request.endpoint == 'user' %} + {{ profile_user.username }}'s Timeline + {% else %} + My Timeline + {% endif %} +{% endblock %} +{% block body %} +

{{ self.title() }}

+ {% if g.user %} + {% if request.endpoint == 'user' %} +
+ {% if g.user.user_id == profile_user.user_id %} + This is you! + {% elif followed %} + You are currently following this user. + Unfollow user. + {% else %} + You are not yet following this user. + . + {% endif %} +
+ {% elif request.endpoint == 'index' %} +
+

What's on your mind {{ g.user.username }}?

+ +
+ +
+
+
+ {% endif %} + {% endif %} +
    + {% for message in messages %} +
  • + {{ message.username }} + {{message.text}} + — {{ message.pub_date|datetimeformat }} + {% else %} +

  • There's no message so far. + {% endfor %} + +
+{% endblock %}