From be631324d777f2e9d15a8ddddc2d03282461db37 Mon Sep 17 00:00:00 2001 From: zPlus Date: Mon, 16 Jul 2018 22:27:20 +0200 Subject: [PATCH] Initial port. --- .gitignore | 9 +- .htaccess.cgi | 29 + .htaccess.wsgi | 22 + README.md | 38 +- freepost.cgi | 4 + freepost/__init__.py | 622 +++++++++++++++++- freepost/database.py | 598 +++++++++++++++++ freepost/random.py | 27 + freepost/session.py | 46 ++ freepost/static/images/downvote.png | Bin 0 -> 1049 bytes freepost/static/images/freepost.png | Bin 0 -> 7557 bytes freepost/static/images/libre.exchange.png | Bin 0 -> 259 bytes freepost/static/images/peers.png | Bin 0 -> 334 bytes freepost/static/images/pulse.gif | Bin 0 -> 14815 bytes freepost/static/images/rss.png | Bin 0 -> 2673 bytes freepost/static/images/source.png | Bin 0 -> 516 bytes freepost/static/images/tuxfamily.png | Bin 0 -> 5712 bytes freepost/static/images/upvote.png | Bin 0 -> 1047 bytes freepost/static/javascript/freepost.js | 147 +++++ freepost/static/stylus/freepost.styl | 313 +++++++++ freepost/static/stylus/reset.styl | 195 ++++++ freepost/templates/about.html | 86 +++ freepost/templates/edit_comment.html | 35 + freepost/templates/edit_post.html | 37 ++ freepost/templates/homepage.html | 98 +++ freepost/templates/layout.html | 91 +++ freepost/templates/login.html | 53 ++ freepost/templates/post.html | 104 +++ freepost/templates/register.html | 43 ++ freepost/templates/reply.html | 33 + freepost/templates/rss.xml | 27 + freepost/templates/search.html | 76 +++ freepost/templates/submit.html | 39 ++ freepost/templates/user_comments.html | 28 + freepost/templates/user_posts.html | 38 ++ freepost/templates/user_private_homepage.html | 70 ++ freepost/templates/user_public_homepage.html | 38 ++ freepost/templates/user_replies.html | 29 + freepost/templates/vote.html | 46 ++ requirements.txt | 13 +- settings.ini | 4 - settings.yaml | 29 + 42 files changed, 3026 insertions(+), 41 deletions(-) create mode 100644 .htaccess.cgi create mode 100644 .htaccess.wsgi create mode 100755 freepost.cgi mode change 100644 => 100755 freepost/__init__.py create mode 100644 freepost/database.py create mode 100644 freepost/random.py create mode 100644 freepost/session.py create mode 100755 freepost/static/images/downvote.png create mode 100755 freepost/static/images/freepost.png create mode 100755 freepost/static/images/libre.exchange.png create mode 100755 freepost/static/images/peers.png create mode 100755 freepost/static/images/pulse.gif create mode 100755 freepost/static/images/rss.png create mode 100755 freepost/static/images/source.png create mode 100755 freepost/static/images/tuxfamily.png create mode 100755 freepost/static/images/upvote.png create mode 100755 freepost/static/javascript/freepost.js create mode 100755 freepost/static/stylus/freepost.styl create mode 100755 freepost/static/stylus/reset.styl create mode 100755 freepost/templates/about.html create mode 100755 freepost/templates/edit_comment.html create mode 100755 freepost/templates/edit_post.html create mode 100755 freepost/templates/homepage.html create mode 100644 freepost/templates/layout.html create mode 100755 freepost/templates/login.html create mode 100755 freepost/templates/post.html create mode 100755 freepost/templates/register.html create mode 100755 freepost/templates/reply.html create mode 100644 freepost/templates/rss.xml create mode 100755 freepost/templates/search.html create mode 100755 freepost/templates/submit.html create mode 100755 freepost/templates/user_comments.html create mode 100755 freepost/templates/user_posts.html create mode 100755 freepost/templates/user_private_homepage.html create mode 100755 freepost/templates/user_public_homepage.html create mode 100755 freepost/templates/user_replies.html create mode 100755 freepost/templates/vote.html delete mode 100644 settings.ini create mode 100644 settings.yaml diff --git a/.gitignore b/.gitignore index 541ca10a..4b9d04d4 100755 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ -__pycache__ -cache/template/* -venv +__pycache__/ +venv/ +*.pyc + +/freepost/static/css/ +/settings.production.yaml diff --git a/.htaccess.cgi b/.htaccess.cgi new file mode 100644 index 00000000..dd590bc5 --- /dev/null +++ b/.htaccess.cgi @@ -0,0 +1,29 @@ +Options +ExecCGI +# AddHandler cgi-script .cgi + +# Other useful info for deploying on TuxFamily +# https://faq.tuxfamily.org/WebArea/En#How_to_play_with_types_and_handlers + +Options +FollowSymLinks +Options -MultiViews +RewriteEngine On +RewriteBase / + +# Redirect everything to HTTPS +RewriteCond %{HTTPS} off + RewriteRule .* https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + +# Remove www +RewriteCond %{HTTP_HOST} ^www\.(.*)$ [NC] + RewriteRule ^(.*)$ http://%1%{REQUEST_URI} [R=301,QSA,NC,L] + +# Remove trailing slashes +# Do we need this rule? It's probably better not to use it, as it +# could interfere with any regex match in some topic. +# RewriteCond %{REQUEST_FILENAME} !-d +# RewriteRule ^(.*)/+$ /$1 [NC,L,R=301,QSA] + +# Redirect all requests to CGI script +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteRule ^(.*)$ freepost.cgi/$1 [L] diff --git a/.htaccess.wsgi b/.htaccess.wsgi new file mode 100644 index 00000000..03bae463 --- /dev/null +++ b/.htaccess.wsgi @@ -0,0 +1,22 @@ +# The Python interpreter option for the webserver. Use virtualenv. +PassengerPython "./venv/bin/python3" + +Options +FollowSymLinks +Options -MultiViews +RewriteEngine On +RewriteBase / + +# Redirect everything to HTTPS +RewriteCond %{HTTPS} off + RewriteRule .* https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + +# Remove www +RewriteCond %{HTTP_HOST} ^www\.(.*)$ [NC] + RewriteRule ^(.*)$ http://%1%{REQUEST_URI} [R=301,QSA,NC,L] + +# Remove trailing slashes +# Do we need this rule? It's probably better not to use it, as it +# could interfere with any regex match in some topic. +# RewriteCond %{REQUEST_FILENAME} !-d +# RewriteRule ^(.*)/+$ /$1 [NC,L,R=301,QSA] + diff --git a/README.md b/README.md index 82f84bdd..ac6ecd17 100755 --- a/README.md +++ b/README.md @@ -1,40 +1,34 @@ -# FreePost +# freepost -This is the code powering [freepost](http://freepo.st). FreePost is a web-based -discussion board that allows users to post text and links which other users may -read and comment on (start a discussion). It also supports upvoting and downvoting -of posts and has some nifty features to display the so-called **Hot** posts (those -that are very popular and have been upvoted a lot) as well as the newest posts, -aptly named **New**. Each user has a profile page which includes some information -on themselves. +This is the code powering [freepost](http://freepost.peers.community), a free +discussion board that allows users to post text and links that other +users can read and comment. -## Development +# Development -### Setup Python3 virtual environment +## Setup Python3 virtual environment mkdir venv virtualenv -p python3 venv -> alternative: python3 -m venv venv - source ./venv/bin/activate - pip3 install --no-binary :all: -r requirements.txt + source venv/bin/activate + pip3 install -r requirements.txt -### Run +## Run dev server - source ./venv/bin/activate + source venv/bin/activate python3 -m bottle --debug --reload --bind 127.0.0.1:8000 freepost +## Build stylesheets -### Stylesheets +Build CSS files -Sources are in `css/`. They are compiled using Stylus. Run this command from -the project's root: + stylus --watch --compress --disable-cache --out freepost/static/css/ freepost/static/stylus/freepost.styl - stylus --watch --compress --disable-cache --out css/ css/ +## Deploy -## Contacts - -If you have any questions please get in contact with us at -[freepost](http://freepo.st). +- Rename `.htaccess.wsgi` or `.htaccess.cgi` to `.htaccess` +- Change settings in `settings.yaml` ## License diff --git a/freepost.cgi b/freepost.cgi new file mode 100755 index 00000000..6047e4f1 --- /dev/null +++ b/freepost.cgi @@ -0,0 +1,4 @@ +#!./venv/bin/python3 + +from freepost import bottle +bottle.run (server='cgi') diff --git a/freepost/__init__.py b/freepost/__init__.py old mode 100644 new mode 100755 index e1e5e225..7319e2ee --- a/freepost/__init__.py +++ b/freepost/__init__.py @@ -1,22 +1,628 @@ +#!./venv/bin/python3 + +import bleach import bottle import configparser import functools import importlib import json -from bottle import abort, get, post, redirect, request +import markdown +import timeago +import urllib3 +import yaml +from datetime import datetime, timezone +from bottle import abort, get, jinja2_template as template, post, redirect, request, response, static_file from urllib.parse import urlparse -# This is used to export the bottle object for the WSGI server +# This is used to export the bottle object for a WSGI server # See passenger_wsgi.py application = bottle.app () -# Load settings for this app -application.config.load_config ('settings.ini') +# Load user settings for this app +with open ('settings.yaml', encoding='UTF-8') as file: + settings = yaml.load (file) -# Default app templates +# Directories to search for app templates bottle.TEMPLATE_PATH = [ './freepost/templates' ] +# Custom settings and functions for templates +template = functools.partial ( + template, + template_settings = { + 'filters': { + 'ago': lambda date: timeago.format (date), + 'datetime': lambda date: date.strftime ('%b %-d, %Y - %H:%M%p%z%Z'), + 'title': lambda date: date.strftime ('%b %-d, %Y - %H:%M%z%Z'), + # Convert markdown to html + 'md2html': lambda text: bleach.clean (markdown.markdown ( + text, + # https://python-markdown.github.io/extensions/ + extensions=[ 'extra', 'admonition', 'nl2br', 'smarty' ], + output_format='html5')), + # Get the domain part of a URL + 'netloc': lambda url: urlparse (url).netloc + }, + 'globals': { + 'new_messages': lambda user_id: database.count_unread_messages (user_id), + 'now': lambda: datetime.now (timezone.utc), + 'url': application.get_url, + 'session_user': lambda: session.user () + }, + 'autoescape': True + }) + +# "bleach" library is used to sanitize the HTML output of jinja2's "md2html" +# filter. The library has only a very restrictive list of white-listed +# tags, so we add some more here. +bleach.sanitizer.ALLOWED_TAGS += [ 'p', 'pre', 'img' ] +bleach.sanitizer.ALLOWED_ATTRIBUTES.update ({ + 'img': [ 'src' ] +}) + +from freepost import database, session + +# Decorator. +# Make sure user is logged in +def requires_login (controller): + def wrapper (): + session_token = request.get_cookie ( + key = settings['session']['name'], + secret = settings['cookies']['secret']) + + if database.is_valid_session (session_token): + return controller () + else: + redirect (application.get_url ('login')) + + return wrapper + +# Decorator. +# Make sure user is logged out +def requires_logout (controller): + def wrapper (): + session_token = request.get_cookie ( + key = settings['session']['name'], + secret = settings['cookies']['secret']) + + if database.is_valid_session (session_token): + redirect (application.get_url ('user')) + else: + return controller () + + return wrapper + +# Routes + +@get ('/', name='homepage') +def homepage (): + # Page number + page = int (request.query.page or 0) + + if page < 0: + redirect (application.get_url ('homepage')) + + user = session.user () + posts = database.get_hot_posts (page, user['id'] if user else None) + + return template ( + 'homepage.html', + page_number=page, + posts=posts, + sorting='hot') + +@get ('/new', name='new') +def new (): + # Page number + page = int (request.query.page or 0) + + if page < 0: + redirect (application.get_url ('homepage')) + + user = session.user () + posts = database.get_new_posts (page, user['id'] if user else None) + + return template ( + 'homepage.html', + page_number=page, + posts=posts, + sorting='new') + +@get ('/about', name='about') +def about (): + return template ('about.html') + +@get ('/login', name='login') +@requires_logout +def login (): + return template ('login.html') + +@post ('/login') +@requires_logout +def login_check (): + username = request.forms.get ('username') + password = request.forms.get ('password') + remember = 'remember' in request.forms + + if not username or not password: + return template ( + 'login.html', + flash = 'Bad login!') + + # Check if user exists + user = database.check_user_credentials (username, password) + + # Username/Password not working + if not user: + return template ( + 'login.html', + flash = 'Bad login!') + + session.start (user['id'], remember) + + redirect (application.get_url ('homepage')) + +@get ('/register', name='register') +@requires_logout +def register (): + return template ('register.html') + +@post ('/register') +@requires_logout +def register_new_account (): + username = request.forms.get ('username') + password = request.forms.get ('password') + + # Normalize username + username = username.strip () + + if len (username) == 0 or database.username_exists (username): + return template ( + 'register.html', + flash='Name taken, please choose another.') + + # Password too short? + if len (password) < 8: + return template ( + 'register.html', + flash = 'Password too short') + + # Username OK, Password OK: create new user + database.new_user (username, password) + + # Retrieve user (check if it was created) + user = database.check_user_credentials (username, password) + + # Something bad happened... + if user is None: + return template ( + 'register.html', + flash = 'An error has occurred, please try again.') + + # Start session... + session.start (user['id']) + + # ... and go to the homepage of the new user + redirect (application.get_url ('user')) + +@get ('/logout', name='logout') +@requires_login +def logout (): + session.close () + + redirect (application.get_url ('homepage')) + +@get ('/user', name='user') +@requires_login +def user_private_homepage (): + return template ('user_private_homepage.html') + +@post ('/user') +@requires_login +def update_user (): + user = session.user () + + about = request.forms.get ('about') + email = request.forms.get ('email') + + if about is None or email is None: + redirect (application.get_url ('user')) + + database.update_user (user['id'], about, email, False) + + redirect (application.get_url ('user')) + +@get ('/user_activity/posts') +@requires_login +def user_posts (): + user = session.user () + posts = database.get_user_posts (user['id']) + + return template ('user_posts.html', posts=posts) + +@get ('/user_activity/comments') +@requires_login +def user_comments (): + user = session.user () + comments = database.get_user_comments (user['id']) + + return template ('user_comments.html', comments=comments) + +@get ('/user_activity/replies') +@requires_login +def user_replies (): + user = session.user () + replies = database.get_user_replies (user['id']) + + database.set_replies_as_read (user['id']) + + return template ('user_replies.html', replies=replies) + +@get ('/user/', name='user_public') +def user_public_homepage (username): + account = database.get_user_by_username (username) + + if account is None: + redirect (application.get_url ('user')) + + return template ('user_public_homepage.html', account=account) + +@get ('/post/', name='post') +def post_thread (hash_id): + user = session.user () + post = database.get_post (hash_id, user['id'] if user else None) + comments = database.get_post_comments (post['id'], user['id'] if user else None) + + # Group comments by parent + comments_tree = {} + + for comment in comments: + if comment['parentId'] is None: + if 0 not in comments_tree: + comments_tree[0] = [] + + comments_tree[0].append (comment) + else: + if comment['parentId'] not in comments_tree: + comments_tree[comment['parentId']] = [] + + comments_tree[comment['parentId']].append (comment) + + # Build ordered list of comments (recourse tree) + def children (parent_id = 0, depth = 0): + temp_comments = [] + + if parent_id in comments_tree: + for comment in comments_tree[parent_id]: + comment['depth'] = depth + temp_comments.append (comment) + + temp_comments.extend (children (comment['id'], depth + 1)) + + return temp_comments + + comments = children () + + return template ( + 'post.html', + post=post, + comments=comments, + votes = { + 'post': {}, + 'comment': {} + }) + +@requires_login +@post ('/post/') +def new_comment (hash_id): + user = session.user () + + # The comment text + comment = request.forms.get ('new_comment').strip () + + # Empty comment + if len (comment) == 0: + redirect (application.get_url ('post', hash_id=hash_id)) + + comment_hash_id = database.new_comment (comment, hash_id, user['id']) + + # Retrieve new comment + comment = database.get_comment (comment_hash_id) + + # Automatically add an upvote for the original poster + database.vote_comment (comment['id'], user['id'], +1) + + redirect (application.get_url ('post', hash_id=hash_id)) + +@requires_login +@get ('/edit/post/', name='edit_post') +def edit_post (hash_id): + user = session.user () + post = database.get_post (hash_id, user['id']) + + # Make sure the session user is the actual poster/commenter + if post['userId'] != user['id']: + redirect (application.get_url ('post', hash_id=hash_id)) + + return template ('edit_post.html', post=post) + +@requires_login +@post ('/edit/post/') +def edit_post_check (hash_id): + user = session.user () + post = database.get_post (hash_id, user['id']) + + # Make sure the session user is the actual poster/commenter + if post['userId'] != user['id']: + redirect (application.get_url ('homepage')) + + # MUST have a title. If empty, use original title + title = request.forms.get ('title').strip () + if len (title) == 0: title = post['title'] + link = request.forms.get ('link').strip () if 'link' in request.forms else '' + text = request.forms.get ('text').strip () if 'text' in request.forms else '' + + # If there is a URL, make sure it has a "scheme" + if len (link) > 0 and urlparse (link).scheme == '': + link = 'http://' + link + + # Update post + database.update_post (title, link, text, hash_id, user['id']) + + redirect (application.get_url ('post', hash_id=hash_id)) + +@requires_login +@get ('/edit/comment/', name='edit_comment') +def edit_comment (hash_id): + user = session.user () + comment = database.get_comment (hash_id, user['id']) + + # Make sure the session user is the actual poster/commenter + if comment['userId'] != user['id']: + redirect (application.get_url ('post', hash_id=comment['postHashId'])) + + return template ('edit_comment.html', comment=comment) + +@requires_login +@post ('/edit/comment/') +def edit_comment_check (hash_id): + user = session.user () + comment = database.get_comment (hash_id, user['id']) + + # Make sure the session user is the actual poster/commenter + if comment['userId'] != user['id']: + redirect (application.get_url ('homepage')) + + text = request.forms.get ('text').strip () if 'text' in request.forms else '' + + # Only update if the text is not empty + if len (text) > 0: + database.update_comment (text, hash_id, user['id']) + + redirect (application.get_url ('post', hash_id=comment['postHashId']) + '#comment-' + hash_id) + +@get ('/submit') +@requires_login +def submit (): + return template ('submit.html') + +@post ('/submit') +@requires_login +def submit_check (): + # Somebody sent a
without a title??? + if 'title' not in request.forms or \ + 'link' not in request.forms or \ + 'text' not in request.forms: + abort () + + user = session.user () + + # Retrieve title + title = request.forms.get ('title').strip () + + # Bad title? + if len (title) == 0: + return template ('submit.html', flash='Title is missing.') + + # Retrieve link + link = request.forms.get ('link').strip () + + # If there is a URL, make sure it has a "scheme" + if len (link) > 0 and urlparse (link).scheme == '': + link = 'http://' + link + + # Retrieve text + text = request.forms.get ('text') + + # Add the new post + post_hash_id = database.new_post (title, link, text, user['id']) + + # Retrieve new post + post = database.get_post (post_hash_id) + + # Automatically add an upvote for the original poster + database.vote_post (post['id'], user['id'], +1) + + # Posted. Now go the new post's page + redirect (application.get_url ('post', hash_id=post_hash_id)) + +@requires_login +@get ('/reply/', name='reply') +def reply (hash_id): + user = session.user () + + # The comment to reply to + comment = database.get_comment (hash_id) + + # Does the comment exist? + if not comment: + redirect (application.get_url ('homepage')) + + return template ('reply.html', comment=comment) + +@requires_login +@post ('/reply/') +def reply_check (hash_id): + user = session.user () + + # The comment to reply to + comment = database.get_comment (hash_id) + + # The text of the reply + text = request.forms.get ('text').strip () + + # Empty comment. Redirect to parent post + if len (text) == 0: + redirect (application.get_url ('post', hash_id=comment['postHashId'])) + + # We have a text, add the reply and redirect to the new reply + reply_hash_id = database.new_comment (text, comment['postHashId'], user['id'], comment['id']) + + # TODO upvote comment + # TODO Increase comments count for post + + redirect (application.get_url ('post', hash_id=comment['postHashId']) + '#comment-' + reply_hash_id) + +@requires_login +@post ('/vote', name='vote') +def vote (): + user = session.user () + + # Vote a post + if request.forms.get ('target') == 'post': + # Retrieve the post + post = database.get_post (request.forms.get ('post'), user['id']) + + if not post: + return + + # If user clicked the "upvote" button... + if request.forms.get ('updown') == 'up': + # If user has upvoted this post before... + if post['user_vote'] == 1: + # Remove +1 + database.vote_post (post['id'], user['id'], -1) + # If user has downvoted this post before... + elif post['user_vote'] == -1: + # Change vote from -1 to +1 + database.vote_post (post['id'], user['id'], +2) + # If user hasn't voted this post... + else: + # Add +1 + database.vote_post (post['id'], user['id'], +1) + + # If user clicked the "downvote" button... + if request.forms.get ('updown') == 'down': + # If user has downvoted this post before... + if post['user_vote'] == -1: + # Remove -1 + database.vote_post (post['id'], user['id'], +1) + # If user has upvoted this post before... + elif post['user_vote'] == 1: + # Change vote from +1 to -1 + database.vote_post (post['id'], user['id'], -2) + # If user hasn't voted this post... + else: + # Add -1 + database.vote_post (post['id'], user['id'], -1) + + # Vote a comment + if request.forms.get ('target') == 'comment': + # Retrieve the comment + comment = database.get_comment (request.forms.get ('comment'), user['id']) + + if not comment: + return + + # If user clicked the "upvote" button... + if request.forms.get ('updown') == 'up': + # If user has upvoted this comment before... + if comment['user_vote'] == 1: + # Remove +1 + database.vote_comment (comment['id'], user['id'], -1) + # If user has downvoted this comment before... + elif comment['user_vote'] == -1: + # Change vote from -1 to +1 + database.vote_comment (comment['id'], user['id'], +2) + # If user hasn't voted this comment... + else: + # Add +1 + database.vote_comment (comment['id'], user['id'], +1) + + # If user clicked the "downvote" button... + if request.forms.get ('updown') == 'down': + # If user has downvoted this comment before... + if comment['user_vote'] == -1: + # Remove -1 + database.vote_comment (comment['id'], user['id'], +1) + # If user has upvoted this comment before... + elif comment['user_vote'] == 1: + # Change vote from +1 to -1 + database.vote_comment (comment['id'], user['id'], -2) + # If user hasn't voted this comment... + else: + # Add -1 + database.vote_comment (comment['id'], user['id'], -1) + +@get ('/search') +def search (): + # Get the search query + query = request.query.get ('q') + + # Results order + order = request.query.get ('order') + if order not in [ 'newest', 'points' ]: + order = 'newest' + + results = database.search (query, order=order) + + if not results: + results = [] + + return template ('search.html', results=results, query=query, order=order) + +@get ('/rss') +def rss_default (): + return redirect (application.get_url ('rss', sorting='hot')) + +# TODO check if is correctly displayed in RSS aggregators +@get ('/rss/', name='rss') +def rss (sorting): + posts = [] + + # Retrieve the hostname from the HTTP request. + # This is used to build absolute URLs in the RSS feed. + base_url = request.urlparts.scheme + '://' + request.urlparts.netloc + + if sorting == 'new': + posts = database.get_new_posts () + + if sorting == 'hot': + posts = database.get_hot_posts () + + # Set correct Content-Type header for this RSS feed + response.content_type = 'application/rss+xml; charset=UTF-8' + + return template ('rss.xml', base_url=base_url, posts=posts) + +@get ('/') +def static (filename): + return static_file (filename, root='freepost/static/') + + + + + + + + + + + + + + + + + + -@get ('/', name='index') -def index (): - return "ll" diff --git a/freepost/database.py b/freepost/database.py new file mode 100644 index 00000000..fa2f00ae --- /dev/null +++ b/freepost/database.py @@ -0,0 +1,598 @@ +import MySQLdb +import re +from freepost import random, settings + +db = MySQLdb.connect ( + host = settings['mysql']['host'], + port = settings['mysql']['port'], + db = settings['mysql']['schema'], + user = settings['mysql']['username'], + passwd = settings['mysql']['password'], + autocommit = True) + +# Store a new session_id for a user that has logged in +# The session token is stored in the user cookies during login, here +# we store the hash value of that token. +def new_session (user_id, session_token): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + UPDATE user + SET session = SHA2(%(session)s, 512) + WHERE id = %(user)s + """, + { + 'user': user_id, + 'session': session_token + } + ) + +# Delete user session token on logout +def delete_session (user_id): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + UPDATE user + SET session = NULL + WHERE id = %(user)s + """, + { + 'user': user_id + } + ) + +# Check user login credentials +# +# @return None if bad credentials, otherwise return the user +def check_user_credentials (username, password): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT * + FROM user + WHERE + username = %(username)s AND + password = SHA2(CONCAT(%(password)s, salt), 512) AND + isActive = 1 + """, + { + 'username': username, + 'password': password + } + ) + + return cursor.fetchone () + +# Check if username exists +def username_exists (username): + return get_user_by_username (username) is not None + +# Create new user account +def new_user (username, password): + # Create a hash_id for the new post + hash_id = random.alphanumeric_string (10) + + # Create a salt for user's password + salt = random.ascii_string (16) + + # Add user to database + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + INSERT INTO user (hashId, isActive, password, registered, salt, username) + VALUES (%(hash_id)s, 1, SHA2(CONCAT(%(password)s, %(salt)s), 512), NOW(), %(salt)s, %(username)s) + """, + { + 'hash_id': hash_id, + 'password': password, + 'salt': salt, + 'username': username + } + ) + +# Check if session token exists +def is_valid_session (token): + return get_user_by_session_token (token) is not None + +# Return the number of unread replies +def count_unread_messages (user_id): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT COUNT(1) as new_messages + FROM comment + WHERE parentUserId = %(user)s AND userId != %(user)s AND `read` = 0 + """, + { + 'user': user_id + } + ) + + return cursor.fetchone ()['new_messages'] + +# Retrieve a user +def get_user_by_username (username): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT * + FROM user + WHERE username = %(username)s + """, + { + 'username': username + } + ) + + return cursor.fetchone () + +# Retrieve a user from a session cookie +def get_user_by_session_token (session_token): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT * + FROM user + WHERE session = SHA2(%(session)s, 512) + """, + { + 'session': session_token + } + ) + + return cursor.fetchone () + +# Get posts by date (for homepage) +def get_new_posts (page = 0, session_user_id = None): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT P.*, U.username, V.vote AS user_vote + FROM post AS P + JOIN user AS U ON P.userId = U.id + LEFT JOIN vote_post as V ON V.postId = P.id AND V.userId = %(user)s + ORDER BY P.created DESC + LIMIT %(limit)s + OFFSET %(offset)s + """, + { + 'user': session_user_id, + 'limit': settings['defaults']['items_per_page'], + 'offset': page * settings['defaults']['items_per_page'] + } + ) + + return cursor.fetchall () + +# Get posts by rating (for homepage) +def get_hot_posts (page = 0, session_user_id = None): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT P.*, U.username, V.vote AS user_vote + FROM post AS P + JOIN user AS U ON P.userId = U.id + LEFT JOIN vote_post as V ON V.postId = P.id AND V.userId = %(user)s + ORDER BY P.dateCreated DESC, P.vote DESC, P.commentsCount DESC + LIMIT %(limit)s + OFFSET %(offset)s + """, + { + 'user': session_user_id, + 'limit': settings['defaults']['items_per_page'], + 'offset': page * settings['defaults']['items_per_page'] + } + ) + + return cursor.fetchall () + +# Retrieve user's own posts +def get_user_posts (user_id): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT * + FROM post + WHERE userId = %(user)s + ORDER BY created DESC + LIMIT 50 + """, + { + 'user': user_id + } + ) + + return cursor.fetchall () + +# Retrieve user's own comments +def get_user_comments (user_id): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT + C.*, + P.title AS postTitle, + P.hashId AS postHashId + FROM comment AS C + JOIN post AS P ON P.id = C.postId + WHERE C.userId = %(user)s + ORDER BY C.created DESC + LIMIT 50 + """, + { + 'user': user_id + } + ) + + return cursor.fetchall () + +# Retrieve user's own replies to other people +def get_user_replies (user_id): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT + C.*, + P.title AS postTitle, + P.hashId AS postHashId, + U.username AS username + FROM comment AS C + JOIN post AS P ON P.id = C.postId + JOIN user AS U ON U.id = C.userId + WHERE C.parentUserId = %(user)s AND C.userId != %(user)s + ORDER BY C.created DESC + LIMIT 50 + """, + { + 'user': user_id + } + ) + + return cursor.fetchall () + +# Update user information +def update_user (user_id, about, email, email_notifications): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + UPDATE user + SET about = %(about)s, + email = %(email)s, + email_notifications = %(notifications)s + WHERE id = %(user)s + """, + { + 'about': about, + 'email': email, + 'notifications': email_notifications, + 'user': user_id + } + ) + +# Set user replies as read +def set_replies_as_read (user_id): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + UPDATE comment + SET `read` = 1 + WHERE parentUserId = %(user)s AND `read` = 0 + """, + { + 'user': user_id + } + ) + +# Submit a new post/link +def new_post (title, link, text, user_id): + # Create a hash_id for the new post + hash_id = random.alphanumeric_string (10) + + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + INSERT INTO post (hashId, created, dateCreated, title, + link, text, vote, commentsCount, userId) + VALUES (%(hash_id)s, NOW(), CURDATE(), %(title)s, %(link)s, + %(text)s, 0, 0, %(user)s) + """, + { + 'hash_id': hash_id, + 'title': title, + 'link': link, + 'text': text, + 'user': user_id + } + ) + + return hash_id + +# Retrieve a post +def get_post (hash, session_user_id = None): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT P.*, U.username, V.vote AS user_vote + FROM post AS P + JOIN user AS U ON P.userId = U.id + LEFT JOIN vote_post as V ON V.postId = P.id AND V.userId = %(user)s + WHERE P.hashId = %(post)s + """, + { + 'user': session_user_id, + 'post': hash + } + ) + + return cursor.fetchone () + +# Update a post +def update_post (title, link, text, post_hash_id, user_id): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + UPDATE post + SET title = %(title)s, + link = %(link)s, + text = %(text)s + WHERE hashId = %(hash_id)s AND userId = %(user)s + """, + { + 'title': title, + 'link': link, + 'text': text, + 'hash_id': post_hash_id, + 'user': user_id + } + ) + +# Retrieve all comments for a specific post +def get_post_comments (post_id, session_user_id = None): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT C.*, U.username, V.vote AS user_vote + FROM comment AS C + JOIN user AS U ON C.userId = U.id + LEFT JOIN vote_comment as V ON V.commentId = C.id AND V.userId = %(user)s + WHERE C.postId = %(post)s + ORDER BY C.vote DESC, C.created ASC + """, + { + 'user': session_user_id, + 'post': post_id + } + ) + + return cursor.fetchall () + +# Submit a new comment to a post +def new_comment (comment_text, post_hash_id, user_id, parent_comment_id = None): + # Create a hash_id for the new comment + hash_id = random.alphanumeric_string (10) + + # Retrieve post + post = get_post (post_hash_id) + + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + INSERT INTO comment (hashId, created, dateCreated, `read`, `text`, `vote`, + parentId, parentUserId, postId, userId) + VALUES (%(hash_id)s, NOW(), CURDATE(), 0, %(text)s, 0, %(parent_id)s, + %(parent_user_id)s, %(post_id)s, %(user)s) + """, + { + 'hash_id': hash_id, + 'text': comment_text, + 'parent_id': parent_comment_id, + 'parent_user_id': post['userId'], + 'post_id': post['id'], + 'user': user_id + } + ) + + # Increase comments count for post + cursor.execute ( + """ + UPDATE post + SET commentsCount = commentsCount + 1 + WHERE id = %(post)s + """, + { + 'post': post['id'] + } + ) + + return hash_id + +# Retrieve a single comment +def get_comment (hash_id, session_user_id = None): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + SELECT + C.*, + P.hashId AS postHashId, + P.title AS postTitle, + U.username, + V.vote AS user_vote + FROM comment AS C + JOIN user AS U ON C.userId = U.id + JOIN post AS P ON P.id = C.postId + LEFT JOIN vote_comment as V ON V.commentId = C.id AND V.userId = %(user)s + WHERE C.hashId = %(comment)s + """, + { + 'user': session_user_id, + 'comment': hash_id + } + ) + + return cursor.fetchone () + +# Update a comment +def update_comment (text, comment_hash_id, user_id): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + cursor.execute ( + """ + UPDATE comment + SET text = %(text)s + WHERE hashId = %(comment)s AND userId = %(user)s + """, + { + 'text': text, + 'comment': comment_hash_id, + 'user': user_id + } + ) + +# Add or update vote to a post +def vote_post (post_id, user_id, vote): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + # Insert or update the user vote + cursor.execute ( + """ + INSERT INTO vote_post (vote, datetime, postId, userId) + VALUES (%(vote)s, NOW(), %(post)s, %(user)s) + ON DUPLICATE KEY UPDATE + vote = vote + %(vote)s, + datetime = NOW() + """, + { + 'vote': vote, + 'post': post_id, + 'user': user_id + } + ) + + # Update vote counter for post + cursor.execute ( + """ + UPDATE post + SET vote = vote + %(vote)s + WHERE id = %(post)s + """, + { + 'vote': vote, + 'post': post_id + } + ) + +# Add or update vote to a comment +def vote_comment (comment_id, user_id, vote): + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + # Insert or update the user vote + cursor.execute ( + """ + INSERT INTO vote_comment (vote, datetime, commentId, userId) + VALUES (%(vote)s, NOW(), %(comment)s, %(user)s) + ON DUPLICATE KEY UPDATE + vote = vote + %(vote)s, + datetime = NOW() + """, + { + 'vote': vote, + 'comment': comment_id, + 'user': user_id + } + ) + + # Update vote counter for comment + cursor.execute ( + """ + UPDATE comment + SET vote = vote + %(vote)s + WHERE id = %(comment)s + """, + { + 'vote': vote, + 'comment': comment_id + } + ) + +# Search posts +def search (query, page = 0, order = 'newest'): + if not query: + return None + + # Remove multiple white spaces and replace with '|' (for query REGEXP) + query = re.sub (' +', '|', query.strip ()) + + if len (query) == 0: + return None + + cursor = db.cursor (MySQLdb.cursors.DictCursor) + + if order == 'newest': + order = 'P.created DESC' + if order == 'points': + order = 'P.vote DESC' + + cursor.execute ( + """ + SELECT P.*, U.username + FROM post AS P + JOIN user AS U ON P.userId = U.id + WHERE P.title REGEXP %(query)s + ORDER BY {order} + LIMIT %(limit)s + OFFSET %(offset)s + """.format (order=order), + { + 'query': query, + 'limit': settings['defaults']['search_results_per_page'], + 'offset': page * settings['defaults']['search_results_per_page'] + } + ) + + return cursor.fetchall () + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/freepost/random.py b/freepost/random.py new file mode 100644 index 00000000..4f3a3fab --- /dev/null +++ b/freepost/random.py @@ -0,0 +1,27 @@ +# The secrets module is used for generating cryptographically strong random +# numbers suitable for managing data such as passwords, account authentication, +# security tokens, and related secrets. +# In particularly, secrets should be used in preference to the default +# pseudo-random number generator in the random module, which is designed for +# modelling and simulation, not security or cryptography. +# +# Requires Python 3.6+ +# import secrets +import random +import string + +def ascii_string ( + length = 16, + alphabet = string.ascii_letters + string.digits + string.punctuation): + + # return ''.join (secrets.choice (alphabet) for i in range (length)) + return ''.join (random.choice (alphabet) for i in range (length)) + +def alphanumeric_string (length = 16): + return ascii_string (length, string.ascii_letters + string.digits) + +def digit_string (length = 16): + return ascii_string (length, alphabet = string.digits) + +def hex_string (length = 16): + return ascii_string (length, alphabet = string.hexdigits) diff --git a/freepost/session.py b/freepost/session.py new file mode 100644 index 00000000..767934e3 --- /dev/null +++ b/freepost/session.py @@ -0,0 +1,46 @@ +from bottle import request, response +from freepost import database, random, settings + +# Start a new session +def start (user_id, remember = False): + # Create a new token for this session. + # The random token is stored as a user cookie, and its hash value is + # stored in the database to match the current user for the future requests. + session_token = random.ascii_string (64) + + # Create session cookie + response.set_cookie ( + name = settings['session']['name'], + value = session_token, + secret = settings['cookies']['secret'], + path = '/', + # When to end the session + max_age = settings['session']['remember_me'] if remember else None, + # HTTPS only + secure = False, + # Do not allow JavaScript to read this cookie + httponly = True) + + # Store session to database + database.new_session (user_id, session_token) + +# Close the current open session +def close (): + session_user = user () + + # Delete user cookie containing session token + response.delete_cookie (settings['session']['name']) + + # Delete session token from database + database.delete_session (session_user['id']) + +# Retrieve user from session token +def user (): + session_token = request.get_cookie ( + key = settings['session']['name'], + secret = settings['cookies']['secret']) + + if session_token is None: + return None + + return database.get_user_by_session_token (session_token) diff --git a/freepost/static/images/downvote.png b/freepost/static/images/downvote.png new file mode 100755 index 0000000000000000000000000000000000000000..3cc8c565861f5fc013f448f685977fd611651ed2 GIT binary patch literal 1049 zcmeAS@N?(olHy`uVBq!ia0vp^4nSl@xwq=GylsM<- z=BDPA0J#hd&iMtEMVaXtB?^aDDpUpJlooTjWEPhcWhRxDq^2m8=B1<-DL5CUCYEHD zr7D1Q=I0eFIF>+EWaj57_+%!h<`t(ZXk?U>6ck(O>nB51CqoqLnBy} zo9G#6>Tz*}WMmdAq-Ex$Dgfz%#G(>~{4|BMqSVxa{NfS?T?LSFK+_;1dc`Hm=4DeD z7?`emx;Tbp+AeodA5_z#`WaU@6rNqdSHb+GVVXpzz2>Yq=GQrH(v8!T zW=zeF<~cH-FQZB?N_>Opi61<+j|(3Jv>cUZRpZ@dwCJb2?tzd6%lEt%*`VC?sGP~T zYuV$2Y?%qyE+q)v5LNtmw(Cda#VnPl#%EZi>X>FJDb$&NFrBbF{`AbFGBGUAn0xpB z7R(jnW#oLY*|8?fBsOBzV!h^y_f|QxT%8Z3J(#Qdd``xx?y{xAah?Y@Y>2&9G5g22 zrxt6OteBHey#HYsAkFk~>w)0YGtPImt(rRJlx^8Bzv^QCQ$IXUc7(-#$~ zU0<3LWOw4E?*!gY_R+huU3#LuPX02xz{y#eroAy=C~%j!Wk96%)7QeOlS^lFY-X!i z((#&IPGjzufEt&1j;R(m=J5&{v+10k^)kOfBIx{~`|`Q-Yu$2^J((mz+YYQq?d%bq z&GdTje!CURojgAM51%vVj`~W2Z>P#Co!)6rGJ7BbPG%M|sSosDc^u#pQCZ9j%9Nh2 KelF{r5}E)sbi^?L literal 0 HcmV?d00001 diff --git a/freepost/static/images/freepost.png b/freepost/static/images/freepost.png new file mode 100755 index 0000000000000000000000000000000000000000..e46e311d2ccb2571b69eb73f0cac78c0deaf551c GIT binary patch literal 7557 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 literal 0 HcmV?d00001 diff --git a/freepost/static/images/libre.exchange.png b/freepost/static/images/libre.exchange.png new file mode 100755 index 0000000000000000000000000000000000000000..f09e9e30bd4035f71bce0d6e594d7fb5fc3a68c9 GIT binary patch literal 259 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzwj^(N7l!{JxM1({$v_d#0*}aI z1_o|n5N2eUHAey{xY*OhF(l$}a)QLm2EldG x8;uMMObiVS{v24`CbD66W8*`O!x{&e7ukzL*e%O?uXC=NN){ZM@gc?b&Q69j2hj_m&uv*G7=`m0F*P|f=wCiAzEHS;;hn@~OUVGX zZ@CSz#`+C~0q-Rv%s$J^5c;C^=by{mMWOrn-|XE|b$z0>`+cVVYcZ_f?%Dl0oE?Db i29T3}L^fE3eiUY?OJ3`Cg84Sk=M0{%elF{r5}E+t*>Ma2 literal 0 HcmV?d00001 diff --git a/freepost/static/images/pulse.gif b/freepost/static/images/pulse.gif new file mode 100755 index 0000000000000000000000000000000000000000..46967c07eda5912d10b942412f244880325c4796 GIT binary patch literal 14815 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 literal 0 HcmV?d00001 diff --git a/freepost/static/images/upvote.png b/freepost/static/images/upvote.png new file mode 100755 index 0000000000000000000000000000000000000000..e7e68a4a191ef18dba226081c5b09d03cb9119f5 GIT binary patch literal 1047 zcmeAS@N?(olHy`uVBq!ia0vp^4nSlxC{)=`303lnduoN3WruIR0ZUe7IV2|7MB!dCY6??rYMx=rKA=qI2WZRmSmQt zDu8t6=M^hBmOxZw=I1H+WG1KP6{jj_WR#Q?6kF-*Cqq>yLlo=f7p3dxKok}0Cspd3 z=ox70adCxYWELx=W#*(R0O^9nq7sGtG=;RH)YO9f;t~a21(0z-(;y;x#U;w-Wm6a! zn67%dIEG~0dpq;IcSxYfaeI@Q0nU@vrY5d7S~#hxC^BTr37*rTx?EY&)x{HAzRHWF zSQjTMUe(J|4Ds1E>4@)wnT`@1j!S#mGR|#z!E@H;oo(;A_22K^;kSAAamV{Pziq$o zt3Geb%-4SSVS$C#RF9c|w=-8a_D_}gla>&+?wObHes&4=prYQ>o4XRU4Kxo;E?W8| zGWW*O;|(u)K5Cx-yJZWf>Q;w43iqU2Dk9PkO>o@uR*Gx(kHo_s>i<9ISnwP&XE;A4 z)1Jd7(5`)XlJbTxT94*2OE+qKaN@l5cR|`nt`|md%UcKQe(;tI1oG*Wy?eDZ_)`<|?n15iR>5ZI;<-TU(N0#ebFr10rwlVTS zgxt1%=Hh9&C2=*=r@AGaH1gWt;LZA>FfwOmjn351615yNHkRZpb$DdlydmkMsdBBR zrgekn=`H?D;$06uFFG2|^+Tmri!<=wwGTdvH}c%kS@b^dW8x=U#aeBxuLo94+H_Cv zAj6%&pf`aD(Hmv*+3snyPm@;t!?Jsd^EK_4*^ZqI%w9>CyfeMu2tH_>RKY8<$v)I! zztTU!4f}X@2Z(-%u(YwAVpi$C%fGXvUUAM7$ri`df?uCza=uQvdX-D+s@(~f)s53n z-MXS173RBq)2)5XuWCxfREi3QjgN6%^wJ*PD_3!WP6fP(Kb#3dsHYG+y zs5SW7t#d!paNf)Dsh1urZ}cw9smYFu@4R8(r;+)q^6i0*88e-V57;#wc3f(=Q6p+| z2CL9CaiIf#O~>_AIXEAbq&D+$yuy%gJ26ZB$DGd@ zRcqfS1wPnolA~%fN7Pq&=bNP71DuDBYbKaOl9|jQ`9HJvD{**MhJv!Br>mdKI;Vst E0DPprq5uE@ literal 0 HcmV?d00001 diff --git a/freepost/static/javascript/freepost.js b/freepost/static/javascript/freepost.js new file mode 100755 index 00000000..8e08258c --- /dev/null +++ b/freepost/static/javascript/freepost.js @@ -0,0 +1,147 @@ +/* + @licstart The following is the entire license notice for the JavaScript code in this page. + + This is the code powering . + Copyright Β© 2014-2016 zPlus + Copyright Β© 2016 Adonay "adfeno" Felipe Nogueira + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + + As additional permission under GNU GPL version 3 section 7, you may + distribute non-source (e.g., minimized or compacted) forms of that code + without the copy of the GNU GPL normally required by section 4, provided + you include this license notice and a URL through which recipients can + access the Corresponding Source. + + @licend The above is the entire license notice for the JavaScript code in this page. +*/ + +/** + * Store which keys have been pressed. + * When a key has been pressed, pressed_key[e.keyCode] will be set + * to TRUE. When a key is released, pressed_key[e.keyCode] will be + * set to FALSE. + */ +var pressed_key = []; + +/** + * Change arrows class when voting. + */ +function vote (action, dom_element) +{ + var arrow_up = dom_element.querySelector ('button[title="upvote"]'); + var vote_counter = dom_element.querySelector ('.count') + var arrow_down = dom_element.querySelector ('button[title="downvote"]'); + + // Voted/Upvoted + var current_status = 0; + + if (arrow_up.classList.contains('upvoted')) + current_status = 1; + + if (arrow_down.classList.contains('downvoted')) + current_status = -1; + + // Current vote + var current_vote = Number (vote_counter.textContent); + + // Remove class from arrows + arrow_up.classList.remove ('upvoted'); + arrow_down.classList.remove ('downvoted'); + + // Toggle upvote class for arrow + if ("up" == action) + switch (current_status) + { + case -1: + vote_counter.textContent = current_vote + 2; + arrow_up.classList.add ('upvoted'); + break; + case 0: + vote_counter.textContent = current_vote + 1; + arrow_up.classList.add ('upvoted'); + break; + case 1: + vote_counter.textContent = current_vote - 1; + break; + } + + // Toggle downvote class for arrow + if ("down" == action) + switch (current_status) + { + case -1: + vote_counter.textContent = current_vote + 1; + break; + case 0: + vote_counter.textContent = current_vote - 1; + arrow_down.classList.add ('downvoted'); + break; + case 1: + vote_counter.textContent = current_vote - 2; + arrow_down.classList.add ('downvoted'); + break; + } +} + +// Wait DOM to be ready... +document.addEventListener ('DOMContentLoaded', function() { + + /** + * A "vote section" is a containing + * - up arrow + * - votes sum + * - down arrow + * + * However, if the user is not logged in, there's only a text + * with the sum of votes, eg. "2 votes" (no children). + */ + var vote_sections = document.querySelectorAll ('.vote '); + + // Bind vote() event to up/down vote arrows + for (var i = 0; i < vote_sections.length; i++) + // See comment above on the "vote_sections" declaration. + if (vote_sections[i].children.length > 0) + { + vote_sections[i] + .querySelector ('button[title="upvote"]') + .addEventListener ('click', function () { + vote ('up', this.closest ('.vote')) + }); + + vote_sections[i] + .querySelector ('button[title="downvote"]') + .addEventListener ('click', function () { + vote ('down', this.closest ('.vote')) + }); + } + + // Bind onkeydown()/onkeyup() event to keys + document.onkeydown = document.onkeyup = function(e) { + // Set the current key code as TRUE/FALSE + pressed_key[e.keyCode] = e.type == 'keydown'; + + // If Ctrl+Enter have been pressed + // Key codes: Ctrl=17, Enter=13 + if (pressed_key[17] && pressed_key[13]) + { + // Select all forms in the current page with class "shortcut-submit" + var forms = document.querySelectorAll ("form.shortcut-submit"); + + for (var i = 0; i < forms.length; i++) + forms[i].submit (); + } + } + +}); diff --git a/freepost/static/stylus/freepost.styl b/freepost/static/stylus/freepost.styl new file mode 100755 index 00000000..df942c7e --- /dev/null +++ b/freepost/static/stylus/freepost.styl @@ -0,0 +1,313 @@ +@require 'reset.styl' + +/* A class used for displaying URLs domain (under post tile) */ +.netloc + color #828282 + font-style italic + +.monkey + height 1.5em + margin 0 1em + vertical-align middle + +/* Logo text */ +a.logo, +a.logo:hover, +a.logo:visited + color #000 + font-weight bold + text-decoration none + +body + > .container + margin auto + max-width 80% + + /* Page header */ + > .header + padding 1rem 0 + text-align center + + /* Menu under the logo */ + > .menu + border-bottom 1px solid transparent + display flex + flex-direction row + flex-wrap wrap + justify-content flex-start + align-content flex-start + align-items flex-start + + margin 1em auto + + > .flex-item + flex 0 1 auto + align-self auto + order 0 + + border 0 + border-bottom 1px solid #ccc + color #000 + margin 0 0 + padding 0 .5rem .5rem .5rem + + &:first-child + border-bottom 2px solid transparent + margin-left 0 + + /* Highlight menu item of the current active page (Hot/New/Submit/...) */ + > .active_page + border-bottom 3px solid #000 + + /* Highlight username if there are unread messages */ + .new_messages + background-color rgb(255, 175, 50) + border-radius 4px + color #fff + font-weight bold + margin 0 + padding .5em .5em + text-decoration none + + + > .content + padding 1em 0 + line-height 1.5em + + .vote + margin 0 + + > form + display inline-block + + > button + background transparent + border 0 + cursor pointer + display inline-block + font-family "Courier New", Courier, monospace + font-size 1rem + margin 0 + overflow hidden + padding 0 + text-decoration none + vertical-align middle + + /* Arrow style if already upvoted (green) */ + &.upvoted + background-color #00e313 + border-radius 999em + color #fff + font-weight bolder + height 1rem + width 1rem + + /* Arrow style if already upvoted (red) */ + &.downvoted + background-color #f00 + border-radius 999em + color #fff + font-weight bolder + height 1rem + width 1rem + + /* Votes counter */ + > .count + margin 0 .5rem + + /* Home page */ + .posts + + /* A singe post */ + .post + margin 0 0 2em 0 + vertical-align top + + > .title + font-size 1.5em + + > a + color #000 + + /* Some post info showed below the title */ + > .info + color #666 + margin .5em 0 + opacity .8 + + > .username + margin-left 1rem + + /* New submission page */ + > form.submit + margin auto + max-width 30em + + /* Page for a post */ + > .post + + /* Style used to format Markdown tables */ + table + background #fff + border-collapse collapse + text-align left + + th + border-bottom 2px solid #6678b1 + color #039 + font-weight normal + padding 0 1em + + td + border-bottom 1px solid #ccc + color #669 + padding .5em 1em + + tbody tr:hover td + color #009 + + /* The post title */ + > .title + font-size 1.5em + + /* Info below post title */ + > .info + margin 1em 0 + + > .username + margin-left 1rem + + /* Post text */ + > .text + margin 2rem 0 + word-wrap break-word + + /* The "new comment" form for this post page */ + .new_comment + > textarea + height 5rem + + > input[type=submit] + margin 1em 0 + + /* Comments tree for the Post page */ + > .comments + margin 5em 0 0 0 + + /* A single comment in the tree */ + > .comment + margin 0 0 1rem 0 + overflow hidden + + /* Some info about this comment */ + > .info + display inline-block + font-size .9rem + + > .username + display inline-block + margin 0 1rem + + > a, a:hover, a:visited + display inline-block + text-decoration none + + > .op + background-color rgb(255, 175, 50) + border-radius 4px + font-weight bold + padding 0 1rem + + > a, a:hover, a:visited + color #fff + + /* The comment text */ + > .text + word-wrap break-word + + /* Give the comment that's linked to in the URL location hash a lightyellow background color */ + .comment:target + background-color lightyellow + + > .search + margin-bottom 3rem + + /* User home page */ + table.user + /* If one length specified: both horizontal and vertical spacing + * If two length specified: first sets the horizontal spacing, and + * the second sets the vertical spacing + */ + border-spacing 2em 1em + border-collapse separate + margin auto + width 80% + + tr + > td:first-child + font-weight bold + text-align right + vertical-align top + width 30% + + > td:last-child + text-align left + + /* User activity */ + > .user_activity + + > * + margin 0 0 2em 0 + + > .info + color #888 + + /* Login page */ + > .login + margin auto + max-width 20em + + input[type=submit] + margin 1em 0 + + .title + line-height 2em + + /* Page to edit a post or a comment */ + > .edit + { + } + + /* Page to reply to a comment */ + > .reply + { + } + + /* About page */ + > .about + + > h3 + margin 1em 0 .5em 0 + + > p + line-height 1.5em + + > footer + border-top 1px solid #ccc + margin 3em 0 0 0 + padding 2em 0 + + img + height 1.2em + margin 0 .5em 0 0 + vertical-align middle + + > ul + list-style none + margin 0 + overflow hidden + padding 0 + + > li + float left + margin 0 2em 0 0 diff --git a/freepost/static/stylus/reset.styl b/freepost/static/stylus/reset.styl new file mode 100755 index 00000000..273c7747 --- /dev/null +++ b/freepost/static/stylus/reset.styl @@ -0,0 +1,195 @@ +* + margin 0 + padding 0 + font-family "Helvetica Neue", Helvetica, Arial, sans-serif + + -moz-box-sizing border-box + -webkit-box-sizing border-box + box-sizing border-box + +a, a:hover, a:visited + background transparent + color #337ab7 + text-decoration none + +blockquote + background-color #f8f8f8 + border-left 5px solid #e9e9e9 + font-size .85em + margin 1em 0 + padding .5em 1em + +blockquote cite + color #999 + display block + font-size .8em + margin-top 1em + +blockquote cite:before + content "\2014 \2009" + +h3 + font-size 1.5em + font-weight normal + margin 1em 0 .5em 0 + +p + margin 0 0 10px 0 + +.bg-green + background-color #d9ffca + border-radius 4px + padding .5em 1em + +.bg-red + background-color #f2dede + border-radius 4px + padding .5em 1em + +.bg-blue + background-color #337ab7 + border-radius 4px + padding .5em 1em + +.bg-light-blue + background-color #d9edf7 + border-radius 4px + padding .5em 1em + +/* Some styles for buttons */ +.button + border 0px + border-radius 4px + cursor pointer + display inline-block + padding .2em 1em + text-align center + +.button_ok /* Green */ +.button_ok:hover, +.button_ok:visited + background-color #4caf50 + color #fff + +.button_info /* Blue */ +.button_info:hover, +.button_info:visited + background-color #008cba + color #fff + +.button_alert /* Red */ +.button_alert:hover, +.button_alert:visited + background-color #f44336 + color #fff + +.button_default /* Gray */ +.button_default:hover, +.button_default:visited + background-color #e7e7e7 + color #000 + +.button_default1, /* Black */ +.button_default1:hover, +.button_default1:visited + background-color #555 + color #fff + +img + /* Prevent images from taking up too much space in comments */ + max-width 100% + +label + cursor pointer + font-weight normal + +/* Add light blue shadow to form controls */ +.form-control:focus + border-color #66afe9 + outline 0 + -webkit-box-shadow inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6) + box-shadow inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6) + +.form-control + display block + width 100% + padding .5em 1em + line-height 1.42857143 + color #555 + border 1px solid #ccc + border-radius 4px + -webkit-box-shadow inset 0 1px 1px rgba(0,0,0,.075) + box-shadow inset 0 1px 1px rgba(0,0,0,.075) + -webkit-transition border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s + -o-transition border-color ease-in-out .15s,box-shadow ease-in-out .15s + transition border-color ease-in-out .15s,box-shadow ease-in-out .15s + +/* When users vote, this + + + + diff --git a/freepost/templates/login.html b/freepost/templates/login.html new file mode 100755 index 00000000..5bf5db21 --- /dev/null +++ b/freepost/templates/login.html @@ -0,0 +1,53 @@ +{% extends 'layout.html' %} + +{# Set variables for base layour #} +{% set active_page = 'login' %} +{% set title = 'Login' %} + +{% block content %} + + +{% endblock %} + + + + diff --git a/freepost/templates/post.html b/freepost/templates/post.html new file mode 100755 index 00000000..fb3ca1b1 --- /dev/null +++ b/freepost/templates/post.html @@ -0,0 +1,104 @@ +{% from 'vote.html' import vote %} + +{% extends 'layout.html' %} + +{# Set variables for base layour #} +{% set active_page = '' %} +{% set title = post.title %} + +{% block content %} + +
+ +
+ {% if post.link and post.link|length > 0 %} + + {{ post.title }} + + {% else %} + {{ post.title }} + {% endif %} +
+ + {% if post.link %} +
+ {{ post.link|netloc }} +
+ {% endif %} + +
+ {{ vote ('post', post, user) }} + + + {{ post.username }} + + + + β€” {{ post.vote }} votes, {{ post.commentsCount }} comments + + {% if user and post.userId == user.id %} + β€” Edit + {% endif %} +
+ +
+ {{ post.text|md2html|safe }} +
+ + {# "shortcut-submit" is a class used exclusively from javascript + # to submit the form when a key (Ctrl+Enter) is pressed. + #} +
+ + +
+ + {# id="" used as anchor #} +
+ {% for comment in comments %} + {# The id="" is used as anchor #} +
+
+ {{ vote ('comment', comment, user) }} + + {# Username #} + + {{ comment.username }} + + + {# DateTime #} + + + {% if user %} + β€” + + {# Reply #} + Reply + + {# Edit #} + {% if comment.userId == user.id %} + Edit + {% endif %} + {% endif %} +
+ +
+ {{ comment.text|md2html|safe }} +
+
+ {% endfor %} +
+
+ +{% endblock %} diff --git a/freepost/templates/register.html b/freepost/templates/register.html new file mode 100755 index 00000000..20008e01 --- /dev/null +++ b/freepost/templates/register.html @@ -0,0 +1,43 @@ +{% extends 'layout.html' %} + +{# Set variables for base layour #} +{% set active_page = 'login' %} +{% set title = 'Register' %} + +{% block content %} + + +{% endblock %} + + + + diff --git a/freepost/templates/reply.html b/freepost/templates/reply.html new file mode 100755 index 00000000..ca07c9ce --- /dev/null +++ b/freepost/templates/reply.html @@ -0,0 +1,33 @@ +{% extends 'layout.html' %} + +{% block content %} + +
+

Reply to {{ comment.username }}

+ + + +
+ {{ comment.text|md2html|safe }} +
+ + {# "shortcut-submit" is a class used exclusively from javascript + # to submit the form when a key (Ctrl+Enter) is pressed. + #} +
+ + +
+ +
+ +
+ +
+
+
+ +{% endblock %} diff --git a/freepost/templates/rss.xml b/freepost/templates/rss.xml new file mode 100644 index 00000000..04eb6829 --- /dev/null +++ b/freepost/templates/rss.xml @@ -0,0 +1,27 @@ + + + + freepost + + {{ base_url }} + {{ now () }} + + {% for post in posts %} + {# freepost URL of this post #} + {% set freepost_url = base_url ~ url ('post', hash_id=post.hashId) %} + + + {{ post.hashId }} + {{ post.title }} + +

by {{ post.username }} β€” {{ post.vote }} votes, {{ post.commentsCount ~ ' comments' if post.commentsCount > 0 else 'discuss' }}

+

{{ post.text }}

+
+ {{ post.link if post.link and post.link|length > 0 else freepost_url }} + {{ freepost_url }} + {{ post.created }} + {{ post.username }} +
+ {% endfor %} +
+
diff --git a/freepost/templates/search.html b/freepost/templates/search.html new file mode 100755 index 00000000..4c972fd2 --- /dev/null +++ b/freepost/templates/search.html @@ -0,0 +1,76 @@ +{% extends 'layout.html' %} + +{# Set variables for base layour #} +{% set active_page = 'search' %} +{% set title = 'Search' %} + +{% block content %} + + + +
+ + {% for post in results %} + +
+
+ {% if post.link and post.link|length > 0 %} + + {{ post.title }} + + {% else %} + + {{ post.title }} + + {% endif %} +
+ + +
+ + {% endfor %} + + {# Add once I'll have fulltext search + + #} +
+ +{% endblock %} diff --git a/freepost/templates/submit.html b/freepost/templates/submit.html new file mode 100755 index 00000000..f75dfbdc --- /dev/null +++ b/freepost/templates/submit.html @@ -0,0 +1,39 @@ +{% extends 'layout.html' %} + +{# Set variables for base layour #} +{% set active_page = 'submit' %} +{% set title = 'Submit' %} + +{% block content %} + + {% if flash %} +
+ {{ flash }} +
+ {% endif %} + + {# "shortcut-submit" is a class used exclusively from javascript + # to submit the form when a key (Ctrl+Enter) is pressed. + #} +
+

Title (required)

+
+ +
+ +

Link

+
+ +
+ +

Text

+
+ +
+ +
+ +
+
+ +{% endblock %} diff --git a/freepost/templates/user_comments.html b/freepost/templates/user_comments.html new file mode 100755 index 00000000..9ffabe40 --- /dev/null +++ b/freepost/templates/user_comments.html @@ -0,0 +1,28 @@ +{% extends 'layout.html' %} + +{# Set variables for base layour #} +{% set active_page = 'user' %} +{% set title = '' %} + +{% block content %} + +
+ + {% for comment in comments %} + +
+ {{ comment.text|md2html|safe }} + + {# Post info #} +
+ read + β€” + on {{ comment.postTitle }} +
+
+ + {% endfor %} + +
+ +{% endblock %} diff --git a/freepost/templates/user_posts.html b/freepost/templates/user_posts.html new file mode 100755 index 00000000..b08347ee --- /dev/null +++ b/freepost/templates/user_posts.html @@ -0,0 +1,38 @@ +{% extends 'layout.html' %} + +{# Set variables for base layour #} +{% set active_page = 'user' %} +{% set title = '' %} + +{% block content %} + +
+ + {% for post in posts %} + +
+ {# Post title #} + {% if post.link|length > 0 %} + + {{ post.title }} + + {% else %} + + {{ post.title }} + + {% endif %} + + {# Post info #} +
+ {{ post.vote }} votes, + + β€” + {{ post.commentsCount }} comments +
+
+ + {% endfor %} + +
+ +{% endblock %} diff --git a/freepost/templates/user_private_homepage.html b/freepost/templates/user_private_homepage.html new file mode 100755 index 00000000..73a6b7e0 --- /dev/null +++ b/freepost/templates/user_private_homepage.html @@ -0,0 +1,70 @@ +{% extends 'layout.html' %} + +{# Set variables for base layour #} +{% set active_page = 'user' %} +{% set title = '' %} + +{% block content %} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ User + + {{ user.username }} + + + + +
+ Since + + {{ user.registered|datetime }} ({{ user.registered|ago }}) +
+ About + + +
+ Email + + + Required if you wish to change your password + {# +

+ +

+ #} +
+ + +
+
+ +{% endblock %} diff --git a/freepost/templates/user_public_homepage.html b/freepost/templates/user_public_homepage.html new file mode 100755 index 00000000..137b5367 --- /dev/null +++ b/freepost/templates/user_public_homepage.html @@ -0,0 +1,38 @@ +{% extends 'layout.html' %} + +{# Set variables for base layour #} +{% set active_page = 'user' %} +{% set title = '' %} + +{% block content %} + + + + + + + + + + + + + + + + +
+ User + + {{ account.username }} +
+ Since + + {{ account.registered|datetime }} ({{ account.registered|ago }}) +
+ About + + {{ account.about|md2html|safe }} +
+ +{% endblock %} diff --git a/freepost/templates/user_replies.html b/freepost/templates/user_replies.html new file mode 100755 index 00000000..494d6499 --- /dev/null +++ b/freepost/templates/user_replies.html @@ -0,0 +1,29 @@ +{% extends 'layout.html' %} + +{# Set variables for base layour #} +{% set active_page = 'user' %} +{% set title = '' %} + +{% block content %} + +
+ + {% for comment in replies %} + +
+ {{ comment.text|md2html|safe }} + + {# Post info #} +
+ read + reply + β€” + by {{ comment.username }} on {{ comment.postTitle }} +
+
+ + {% endfor %} + +
+ +{% endblock %} diff --git a/freepost/templates/vote.html b/freepost/templates/vote.html new file mode 100755 index 00000000..274ad3ae --- /dev/null +++ b/freepost/templates/vote.html @@ -0,0 +1,46 @@ +{# Template for up/down vote arrows. + # This template expects these inputs + # + # - target: ('post', 'comment') + # - item: either a "post" object or a "comment" + # - user: reference to logged in user + #} + +{% macro vote (target, item, user) %} + + + + {% if user %} + +
+ + + + + +
+ + {# Show number of votes #} + {{ item.vote }} + +
+ + + + + +
+ + {% else %} + + {{ item.vote ~ ' vote' ~ ('s' if item.vote != 1 else '') }} + + {% endif %} + +
+ +{% endmacro %} diff --git a/requirements.txt b/requirements.txt index 91e04010..b0bd0ebb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,9 @@ -bottle == 0.12.* -pyld == 1.* -PyMySQL == 0.9.* -requests == 2.* +bleach +bottle +jinja2 +markdown +mysqlclient +pyld +pyyaml +requests +timeago diff --git a/settings.ini b/settings.ini deleted file mode 100644 index 1451b85d..00000000 --- a/settings.ini +++ /dev/null @@ -1,4 +0,0 @@ -# This is a bunch of settings useful for the app - -[name] -key = value diff --git a/settings.yaml b/settings.yaml new file mode 100644 index 00000000..1ccff06a --- /dev/null +++ b/settings.yaml @@ -0,0 +1,29 @@ +# This is a bunch of settings useful for the app + +defaults: + items_per_page: 50 + search_results_per_page: 50 + +session: + # Name to use for the session cookie + name: freepost + + # Timeout in seconds for the "remember me" option. + # By default, if the user doesn't click "remember me" during login the + # session will end when the browser is closed. + # 2592000 = 30 days + remember_me: 2592000 + +cookies: + # A secret key for signing cookies. Must be kept private. + # Used to verify that cookies haven't been tampered with. + secret: "secret random string" + +mysql: + host: localhost + port: 3306 + schema: freepost_freepost + # charset: utf8mb4 + charset: utf8 + username: freepost + password: freepost