diff --git a/.gitignore b/.gitignore index 541ca10..4b9d04d 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 0000000..dd590bc --- /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 0000000..03bae46 --- /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 82f84bd..ac6ecd1 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 0000000..6047e4f --- /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 e1e5e22..7319e2e --- 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 0000000..fa2f00a --- /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 0000000..4f3a3fa --- /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 0000000..767934e --- /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 0000000..3cc8c56 Binary files /dev/null and b/freepost/static/images/downvote.png differ diff --git a/freepost/static/images/freepost.png b/freepost/static/images/freepost.png new file mode 100755 index 0000000..e46e311 Binary files /dev/null and b/freepost/static/images/freepost.png differ diff --git a/freepost/static/images/libre.exchange.png b/freepost/static/images/libre.exchange.png new file mode 100755 index 0000000..f09e9e3 Binary files /dev/null and b/freepost/static/images/libre.exchange.png differ diff --git a/freepost/static/images/peers.png b/freepost/static/images/peers.png new file mode 100755 index 0000000..a67f4bd Binary files /dev/null and b/freepost/static/images/peers.png differ diff --git a/freepost/static/images/pulse.gif b/freepost/static/images/pulse.gif new file mode 100755 index 0000000..46967c0 Binary files /dev/null and b/freepost/static/images/pulse.gif differ diff --git a/freepost/static/images/rss.png b/freepost/static/images/rss.png new file mode 100755 index 0000000..6ad1448 Binary files /dev/null and b/freepost/static/images/rss.png differ diff --git a/freepost/static/images/source.png b/freepost/static/images/source.png new file mode 100755 index 0000000..5936ed5 Binary files /dev/null and b/freepost/static/images/source.png differ diff --git a/freepost/static/images/tuxfamily.png b/freepost/static/images/tuxfamily.png new file mode 100755 index 0000000..a7fbaa9 Binary files /dev/null and b/freepost/static/images/tuxfamily.png differ diff --git a/freepost/static/images/upvote.png b/freepost/static/images/upvote.png new file mode 100755 index 0000000..e7e68a4 Binary files /dev/null and b/freepost/static/images/upvote.png differ diff --git a/freepost/static/javascript/freepost.js b/freepost/static/javascript/freepost.js new file mode 100755 index 0000000..8e08258 --- /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 0000000..df942c7 --- /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 0000000..273c774 --- /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 0000000..5bf5db2 --- /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 0000000..fb3ca1b --- /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 0000000..20008e0 --- /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 0000000..ca07c9c --- /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 0000000..04eb682 --- /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 0000000..4c972fd --- /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 0000000..f75dfbd --- /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 0000000..9ffabe4 --- /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 0000000..b08347e --- /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 0000000..73a6b7e --- /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 0000000..137b536 --- /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 0000000..494d649 --- /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 0000000..274ad3a --- /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 91e0401..b0bd0eb 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 1451b85..0000000 --- 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 0000000..1ccff06 --- /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