diff --git a/.gitignore b/.gitignore index a3430b3..355b7d0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,5 @@ __pycache__/ venv/ *.pyc -/freepost/static/css/ /database.sqlite /settings.production.yaml diff --git a/README.md b/README similarity index 85% rename from README.md rename to README index 827f059..601ab52 100644 --- a/README.md +++ b/README @@ -18,24 +18,17 @@ users can read and comment. source venv/bin/activate python3 -m bottle --debug --reload --bind 127.0.0.1:8000 freepost -## Build stylesheets - -Build CSS files - - stylus --watch --compress --disable-cache --out freepost/static/css/ freepost/static/stylus/freepost.styl - # Deployment -- Build CSS stylesheets (see `Development` above) - Copy all files to your `public_html` folder -- Make sure `settings.yaml` has restricted permissions, for instance `0600` -- If the SQLite database is located in the same HTML folder, make sure this too has - restricted access - Rename `.htaccess.wsgi` or `.htaccess.cgi` to `.htaccess` (if you use CGI or WSGI) - Change settings in `settings.yaml` if needed - Create Python virtual environment For tuxfamily only: run `newgrp freepost` before creating the virtenv, for quota reasons - Create a new empty SQLite database: `cat database.schema.sql | sqlite3 database.sqlite` +- Make sure `settings.yaml` has restricted permissions, for instance `0600`. + If the SQLite database is located in the same HTML folder, make sure this too has + restricted access Everything should be setup and working. Make sure your CGI or WSGI server is configured correctly. diff --git a/freepost.cgi b/freepost.cgi index 6047e4f..c6d4c2e 100755 --- a/freepost.cgi +++ b/freepost.cgi @@ -1,4 +1,14 @@ #!./venv/bin/python3 +import os from freepost import bottle -bottle.run (server='cgi') + +# freepost uses Bottle's function get_url() extensively (see https://bottlepy.org/docs/dev/_modules/bottle.html#Bottle.get_url +# for a description). This function uses the env variable SCRIPT_NAME internally, +# which is set by Apache to "/freepost.cgi" when redirecting URLs from .htaccess. +# The result is that all the URLs created by get_url() will start with "/freepost.cgi", +# for example "/freepost.cgi/post/" instead of "/post/". +# So, here it's overwritten to an empty string in order to remove the script name from the URLs. +os.environ['SCRIPT_NAME'] = '' + +bottle.run(server='cgi') diff --git a/freepost/__init__.py b/freepost/__init__.py index a0bd7a5..7995288 100644 --- a/freepost/__init__.py +++ b/freepost/__init__.py @@ -33,18 +33,20 @@ template = functools.partial ( template_settings = { 'filters': { 'ago': lambda date: timeago.format(date), - 'datetime': lambda date: date,# date.strftime ('%b %-d, %Y - %H:%M%p%z%Z'), + 'datetime': lambda date: date, #date.strftime('%b %-d, %Y - %H:%M%p%z%Z'), # TODO this should be renamed. It's only a way to pretty print dates 'title': lambda date: dateutil.parser.parse(date).strftime('%b %-d, %Y - %H:%M%z%Z'), # Convert markdown to plain text 'md2txt': lambda text: bleach.clean (markdown.markdown(text), tags=[], attributes={}, strip=True), # Convert markdown to html - 'md2html': lambda text: bleach.clean (bleach.linkify (markdown.markdown ( - text, - # https://python-markdown.github.io/extensions/ - extensions=[ 'extra', 'admonition', 'nl2br', 'smarty' ], - output_format='html5'))), + 'md2html': lambda text: bleach.clean( + markdown.markdown( + text, + # https://python-markdown.github.io/extensions/ + extensions=[ 'extra', 'admonition', 'nl2br', 'smarty' ], + output_format='html5'), + tags = list(bleach.sanitizer.ALLOWED_TAGS) + [ 'br', 'img', 'p', 'pre', 'h1', 'h2', 'h3', 'hr' ]), # Get the domain part of a URL 'netloc': lambda url: urlparse (url).netloc }, @@ -63,11 +65,6 @@ template = functools.partial ( '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. -# The list() casting is required because it's of type "frozenlist" -bleach.sanitizer.ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [ 'br', 'img', 'p', 'pre', 'h1', 'h2', 'h3', 'hr' ] bleach.sanitizer.ALLOWED_ATTRIBUTES.update ({ 'img': [ 'src' ] }) @@ -76,31 +73,31 @@ from freepost import database, mail, session # Decorator. # Make sure user is logged in -def requires_login (controller): - def wrapper (): - session_token = request.get_cookie ( +def requires_login(controller): + def wrapper(*args, **kwargs): + session_token = request.get_cookie( key = settings['session']['name'], secret = settings['cookies']['secret']) - if database.is_valid_session (session_token): - return controller () + if database.is_valid_session(session_token): + return controller(*args, **kwargs) else: - redirect (application.get_url ('login')) + 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 ( +def requires_logout(controller): + def wrapper(*args, **kwargs): + 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_settings')) + if database.is_valid_session(session_token): + redirect(application.get_url('user_settings')) else: - return controller () + return controller(*args, **kwargs) return wrapper @@ -238,6 +235,8 @@ def register_new_account (): Check form for creating new account. """ + abort() + username = request.forms.getunicode ('username') password = request.forms.getunicode ('password') @@ -470,15 +469,165 @@ def user_public_homepage (username): return template ('user_public_homepage.html', account=account) -@get ('/post/', name='post') -def post_thread (hash_id): +@get('/c', name='communities') +def communities(): + """ + List communities. + """ + + communities_list = database.get_communities_list() + + return template('communities.html', communities=communities_list) + +@post('/c') +@requires_login +def community_create(): + name = request.forms.getunicode('community_name').strip().replace(' ', '').lower() + + if len(name) < 3 or len(name) > 100: + redirect(application.get_url('communities')) + + user = session.user() + + community = database.get_community(name) + if community: + redirect(application.get_url('community', cmty=name)) + + database.create_community(name) + + community = database.get_community(name) + + # The community wasn't created for some reasons? + if not community: + redirect(application.get_url('communities')) + else: + database.add_community_member(community['id'], user['id'], True) + redirect(application.get_url('community', cmty=name)) + +@get('/c/', name='community') +def community(cmty): + """ + Show a community. + """ + + # Sort order + sort = request.query.sort or 'hot' + + user = session.user() + cmty = database.get_community(cmty) + + if not cmty: + abort(404, "This community doesn't exist.") + + mods = database.get_community_mods(cmty['id']) + + try: + is_moderator = database.is_community_moderator(cmty['id'], user['id']) + is_member = database.is_community_member(cmty['id'], user['id']) + except: + is_moderator = False + is_member = False + + # Page number + page = int(request.query.page or 0) + + if page < 0: + redirect(application.get_url('homepage')) + + if sort in [ 'hot', 'new' ]: + posts = database.get_posts( + page, user['id'] if user else None, sort, + topic=None, community_id=cmty['id']) + else: + posts = [] + + return template( + 'community.html', + community=cmty['name'], + community_data=cmty, + is_member=is_member, + is_moderator=is_moderator, + moderators=mods, + posts=posts, + sort=sort) + +@get('/c//administration', name='community_administration') +@requires_login +def community_administration(community): + """ + Administration page of community. + """ + + user = session.user() + community = database.get_community(community) + + try: + is_moderator = database.is_community_moderator(community['id'], user['id']) + assert is_moderator + except: + redirect(application.get_url('homepage')) + abort() + + mods = database.get_community_mods(community['id']) + + return template( + 'community_administration.html', + community=community, + moderators=mods) + +@post('/c//administration') +@requires_login +def community_administration_update(community): + """ + Update community settings. + """ + + user = session.user() + community = database.get_community(community) + + try: + is_moderator = database.is_community_moderator(community['id'], user['id']) + assert is_moderator + except: + redirect(application.get_url('homepage')) + abort() + + # Truncate at 1K + description = request.forms.getunicode('description').strip()[:1024] + allow_new_posts = request.forms.getunicode('allow_new_posts') == 'yes' + + database.update_community_settings( + community['id'], description, allow_new_posts) + + redirect(application.get_url('community', cmty=community['name'])) + +@post('/c/') +@requires_login +def community_join(community): + user = session.user() + cmty = database.get_community(community) + + if not cmty: + abort(500, "Community doesn't exist.") + return + + if 'join' in request.forms: + database.add_community_member(cmty['id'], user['id']) + + if 'leave' in request.forms: + database.remove_community_member(cmty['id'], user['id']) + + redirect(application.get_url('community', cmty=community)) + +@get('/post/', name='post') +def post_thread(hash_id): """ Display a single post with all its comments. """ - 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) + 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) topics = database.get_post_topics (post['id']) # Group comments by parent @@ -509,7 +658,7 @@ def post_thread (hash_id): return temp_comments - comments = children () + comments = children() # Show posts/comments Markdown instead of rendered text show_source = 'source' in request.query @@ -522,6 +671,7 @@ def post_thread (hash_id): return template ( 'post.html', + community=post['community_name'], post=post, comments=comments, topics=topics, @@ -531,15 +681,15 @@ def post_thread (hash_id): 'comment': {} }) +@post('/post/') @requires_login -@post ('/post/') -def new_comment (hash_id): +def new_comment(hash_id): # The comment text - comment = request.forms.getunicode ('new_comment').strip () + comment = request.forms.getunicode('new_comment').strip() # Empty comment? - if len (comment) == 0: - redirect (application.get_url ('post', hash_id=hash_id)) + if len(comment) == 0: + redirect(application.get_url('post', hash_id=hash_id)) # Retrieve the post post = database.get_post (hash_id) @@ -560,45 +710,45 @@ def new_comment (hash_id): 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']) +@requires_login +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)) + redirect(application.get_url('post', hash_id=hash_id)) - return template ('edit_post.html', post=post) + return template('edit_post.html', community=post['community_name'], post=post) -@requires_login @post ('/edit/post/') -def edit_post_check (hash_id): - user = session.user () - post = database.get_post (hash_id, user['id']) +@requires_login +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')) + redirect(application.get_url('homepage')) # MUST have a title. If empty, use original title - title = request.forms.getunicode ('title').strip () + title = request.forms.getunicode('title').strip() if len (title) == 0: title = post['title'] - link = request.forms.getunicode ('link').strip () if 'link' in request.forms else '' - text = request.forms.getunicode ('text').strip () if 'text' in request.forms else '' + link = request.forms.getunicode('link').strip() if 'link' in request.forms else '' + text = request.forms.getunicode('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 == '': + if len(link) > 0 and urlparse(link).scheme == '': link = 'http://' + link # Update post - database.update_post (title, link, text, hash_id, user['id']) + database.update_post(title, link, text, hash_id, user['id']) - redirect (application.get_url ('post', hash_id=hash_id)) + redirect(application.get_url ('post', hash_id=hash_id)) -@requires_login @get ('/edit/comment/', name='edit_comment') +@requires_login def edit_comment (hash_id): user = session.user () comment = database.get_comment (hash_id, user['id']) @@ -609,8 +759,8 @@ def edit_comment (hash_id): return template ('edit_comment.html', comment=comment) -@requires_login @post ('/edit/comment/') +@requires_login def edit_comment_check (hash_id): user = session.user () comment = database.get_comment (hash_id, user['id']) @@ -627,37 +777,37 @@ def edit_comment_check (hash_id): redirect (application.get_url ('post', hash_id=comment['postHashId']) + '#comment-' + hash_id) -@get ('/submit') +@get('/c//submit', name='submit') @requires_login -def submit (): +def submit(community): """ Submit a new post. """ - return template ('submit.html') + return template('submit.html', community=community) -@post ('/submit', name='submit') +@post('/c//submit') @requires_login -def submit_check (): +def submit_check(community): """ Check submission of new post. """ # Somebody sent a
without a title??? - if not request.forms.getunicode ('title'): - abort () + if 'title' not in request.forms: + abort() - user = session.user () + user = session.user() # Retrieve title - title = request.forms.getunicode ('title').strip () + title = request.forms.getunicode('title').strip() # Bad title? - if len (title) == 0: - return template ('submit.html', flash='Title is missing.') + if len(title) == 0: + return template('submit.html', community=community, flash='Title is missing.') # Retrieve link - link = request.forms.getunicode ('link').strip () + link = request.forms.getunicode('link').strip() if len (link) > 0: # If there is a URL, make sure it has a "scheme" @@ -669,36 +819,50 @@ def submit_check (): if previous_posts: posts_list = ''.join([ '
  • {title}
  • '.format( - link = application.get_url ('post', hash_id=post['hashId']), + link = application.get_url('post', hash_id=post['hashId']), title = post['title']) for post in previous_posts ]) - return template ('submit.html', - flash='This link was already submitted:
      {posts}
    '.format(posts=posts_list)) + return template('submit.html', + community=community, + flash='This link was already submitted:
      {posts}
    '.format(posts=posts_list)) # Retrieve topics - topics = request.forms.getunicode ('topics') + # topics = request.forms.getunicode ('topics') # Retrieve text - text = request.forms.getunicode ('text') + text = request.forms.getunicode('text') + + # Retrieve the community + community = database.get_community(community) + + if not community: + abort(500, "Community doesn't exist.") + return + + # Does this community allow new posts? + if not community['allow_new_posts']: + return template('submit.html', + community=community['name'], + flash='This community does not allow new posts.') # Add the new post - post_hash_id = database.new_post (title, link, text, user['id']) + post_hash_id = database.new_post(title, link, text, user['id'], community['id']) # Retrieve the new post just created - post = database.get_post (post_hash_id) + post = database.get_post(post_hash_id) # Add topics for this post - database.replace_post_topics (post['id'], topics) + # database.replace_post_topics (post['id'], topics) # Automatically add an upvote for the original poster - database.vote_post (post['id'], user['id'], +1) + 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)) + redirect(application.get_url('post', hash_id=post_hash_id)) -@requires_login @get ('/reply/', name='reply') +@requires_login def reply (hash_id): user = session.user () @@ -711,8 +875,8 @@ def reply (hash_id): return template ('reply.html', comment=comment) -@requires_login @post ('/reply/') +@requires_login def reply_check (hash_id): user = session.user () @@ -739,8 +903,8 @@ def reply_check (hash_id): redirect (application.get_url ('post', hash_id=comment['postHashId']) + '#comment-' + reply_hash_id) -@requires_login @post ('/vote', name='vote') +@requires_login def vote (): """ Handle upvotes and downvotes. diff --git a/freepost/database.py b/freepost/database.py index 83110a7..256dbdb 100644 --- a/freepost/database.py +++ b/freepost/database.py @@ -53,7 +53,7 @@ def delete_session (user_id): ) # Check user login credentials -# +# # @return None if bad credentials, otherwise return the user def check_user_credentials (username, password): with db: @@ -70,19 +70,19 @@ def check_user_credentials (username, password): 'password': password } ) - + return cursor.fetchone () # Check if username exists def username_exists (username, case_sensitive = True): if not username: return None - + if case_sensitive: where = 'WHERE username = :username' else: where = 'WHERE LOWER(username) = LOWER(:username)' - + with db: cursor = db.execute( """ @@ -94,7 +94,7 @@ def username_exists (username, case_sensitive = True): 'username': username } ) - + return cursor.fetchone() is not None # Check if post with same link exists. This is used to check for duplicates. @@ -107,7 +107,7 @@ def link_exists (link): cursor = db.execute( """ SELECT * - FROM post + FROM post WHERE LOWER(link) = LOWER(:link) ORDER BY created DESC """, @@ -115,17 +115,17 @@ def link_exists (link): 'link': link } ) - + return cursor.fetchall() # 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 with db: db.execute ( @@ -158,14 +158,14 @@ def count_unread_messages (user_id): 'user': user_id } ) - + return cursor.fetchone ()['new_messages'] # Retrieve a user def get_user_by_username (username): if not username: return None - + with db: cursor = db.execute( """ @@ -177,7 +177,7 @@ def get_user_by_username (username): 'username': username } ) - + return cursor.fetchone() # Retrieve a user from a session cookie @@ -193,46 +193,54 @@ def get_user_by_session_token(session_token): 'session': session_token } ) - + return cursor.fetchone() # Get posts by date (for homepage) -def get_posts (page = 0, session_user_id = None, sort = 'hot', topic = None): +def get_posts (page=0, session_user_id=None, sort='hot', topic=None, community_id=None): if sort == 'new': sort = 'ORDER BY P.created DESC' else: sort = 'ORDER BY P.dateCreated DESC, P.vote DESC, P.commentsCount DESC' - + if topic: topic_name = 'WHERE T.name = :topic' else: topic_name = '' - + + if community_id: + community_filter = 'AND C.id = :community_id' + else: + community_filter = '' + with db: cursor = db.execute ( - """ + f""" SELECT P.*, U.username, + C.name AS community_name, V.vote AS user_vote, GROUP_CONCAT(T.name, " ") AS topics FROM post AS P JOIN user AS U ON P.userId = U.id + JOIN community AS C ON P.community_id = C.id {community_filter} LEFT JOIN vote_post as V ON V.postId = P.id AND V.userId = :user LEFT JOIN topic as T ON T.post_id = P.id - {topic} + {topic_name} GROUP BY P.id - {order} + {sort} LIMIT :limit OFFSET :offset - """.format (topic=topic_name, order=sort), + """, { 'user': session_user_id, 'limit': settings['defaults']['items_per_page'], 'offset': page * settings['defaults']['items_per_page'], - 'topic': topic + 'topic': topic, + 'community_id': community_id } ) - + return cursor.fetchall () # Retrieve user's own posts @@ -250,7 +258,7 @@ def get_user_posts (user_id): 'user': user_id } ) - + return cursor.fetchall() # Retrieve user's own comments @@ -271,7 +279,7 @@ def get_user_comments (user_id): 'user': user_id } ) - + return cursor.fetchall() # Retrieve user's own replies to other people @@ -294,7 +302,7 @@ def get_user_replies (user_id): 'user': user_id } ) - + return cursor.fetchall() # Update user information @@ -316,7 +324,7 @@ def update_user (user_id, about, email, email_notifications, preferred_feed): 'preferred_feed': preferred_feed } ) - + # Update email address. Convert all addresses to LOWER() case. This # prevents two users from using the same address with different case. # IGNORE update if the email address is already specified. This is @@ -348,46 +356,47 @@ def set_replies_as_read (user_id): ) # Submit a new post/link -def new_post (title, link, text, user_id): +def new_post (title, link, text, user_id, community_id): # Create a hash_id for the new post - hash_id = random.alphanumeric_string (10) - + hash_id = random.alphanumeric_string(10) + with db: db.execute( """ INSERT INTO post (hashId, created, dateCreated, title, - link, text, vote, commentsCount, userId) + link, text, vote, commentsCount, userId, community_id) VALUES (:hash_id, DATETIME(), DATE(), :title, :link, - :text, 0, 0, :user) + :text, 0, 0, :user, :community) """, { 'hash_id': hash_id, 'title': title, 'link': link, 'text': text, - 'user': user_id + 'user': user_id, + 'community': community_id } ) - + return hash_id # Set topics post. Deletes existing ones. def replace_post_topics (post_id, topics = ''): if not topics: return - + # Normalize topics # 1. Split topics by space # 2. Remove empty strings # 3. Lower case topic name topics = [ topic.lower () for topic in topics.split (' ') if topic ] - + if len (topics) == 0: return - + # Remove extra topics if the list is too long topics = topics[:settings['defaults']['topics_per_post']] - + with db: # First we delete the existing topics db.execute ( @@ -400,7 +409,7 @@ def replace_post_topics (post_id, topics = ''): 'post': post_id } ) - + # Now insert the new topics. # IGNORE duplicates that trigger UNIQUE constraint. db.executemany ( @@ -412,14 +421,16 @@ def replace_post_topics (post_id, topics = ''): ) # Retrieve a post -def get_post (hash, session_user_id = None): +def get_post(hash, session_user_id = None): with db: cursor = db.execute ( """ SELECT P.*, U.username, - V.vote AS user_vote + V.vote AS user_vote, + C.name AS community_name FROM post AS P + JOIN community AS C ON P.community_id = C.id JOIN user AS U ON P.userId = U.id LEFT JOIN vote_post as V ON V.postId = P.id AND V.userId = :user WHERE P.hashId = :post @@ -429,7 +440,7 @@ def get_post (hash, session_user_id = None): 'post': hash } ) - + return cursor.fetchone () # Update a post @@ -473,7 +484,7 @@ def get_post_comments (post_id, session_user_id = None): 'post': post_id } ) - + return cursor.fetchall () # Retrieve all topics for a specific post @@ -490,17 +501,17 @@ def get_post_topics (post_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_user_id = None, 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) - + with db: db.execute ( """ @@ -518,7 +529,7 @@ def new_comment (comment_text, post_hash_id, user_id, parent_user_id = None, par 'user': user_id } ) - + # Increase comments count for post db.execute ( """ @@ -530,7 +541,7 @@ def new_comment (comment_text, post_hash_id, user_id, parent_user_id = None, par 'post': post['id'] } ) - + return hash_id # Retrieve a single comment @@ -554,7 +565,7 @@ def get_comment (hash_id, session_user_id = None): 'comment': hash_id } ) - + return cursor.fetchone() # Retrieve last N newest comments @@ -575,7 +586,7 @@ def get_latest_comments (): { } ) - + return cursor.fetchall () # Update a comment @@ -608,7 +619,7 @@ def vote_post (post_id, user_id, vote): 'user': user_id } ) - + # Update user vote (+1 or -1) db.execute( """ @@ -622,7 +633,7 @@ def vote_post (post_id, user_id, vote): 'user': user_id } ) - + # Update post's total db.execute ( """ @@ -650,7 +661,7 @@ def vote_comment (comment_id, user_id, vote): 'user': user_id } ) - + # Update user vote (+1 or -1) db.execute ( """ @@ -664,7 +675,7 @@ def vote_comment (comment_id, user_id, vote): 'user': user_id } ) - + # Update comment's total db.execute ( """ @@ -682,18 +693,18 @@ def vote_comment (comment_id, user_id, vote): def search (query, sort='newest', page=0): if not query: return [] - + # Remove multiple white spaces and replace with '|' (for query REGEXP) query = re.sub (' +', '|', query.strip ()) - + if len (query) == 0: return [] - + if sort == 'newest': sort = 'P.created DESC' if sort == 'points': sort = 'P.vote DESC' - + with db: cursor = db.execute ( """ @@ -713,14 +724,14 @@ def search (query, sort='newest', page=0): 'offset': page * settings['defaults']['search_results_per_page'] } ) - + return cursor.fetchall () # Set reset token for user email def set_password_reset_token (user_id = None, token = None): if not user_id or not token: return - + with db: db.execute ( """ @@ -766,14 +777,14 @@ def is_password_reset_token_valid (user_id = None): 'user': user_id } ) - + return cursor.fetchone()['valid'] == 1 # Reset user password def reset_password (username = None, email = None, new_password = None, secret_token = None): if not new_password: return - + with db: db.execute ( """ @@ -794,11 +805,143 @@ def reset_password (username = None, email = None, new_password = None, secret_t } ) +def get_communities_list(): + with db: + cursor = db.execute( + f""" + SELECT C.name, C.description, COUNT(M.user_id) AS members_count + FROM community AS C + JOIN community_member AS M ON M.community_id = C.id + GROUP BY C.id + """ + ) + return cursor.fetchall() +def get_community(name): + with db: + cursor = db.execute( + """ + SELECT C.* , COUNT(M.user_id) AS members_count + FROM community AS C + LEFT JOIN community_member AS M ON M.community_id = C.id + WHERE name = :name + """, + { + 'name': name + } + ) + + community = cursor.fetchone() + return community if community['name'] else None + +def get_community_mods(community_id): + with db: + cursor = db.execute( + """ + SELECT U.username + FROM community AS C + JOIN community_member AS M ON M.community_id = C.id AND M.moderator = 1 + JOIN user AS U ON U.id = M.user_id + WHERE C.id = :community_id + """, + { + 'community_id': community_id + } + ) + + return cursor.fetchall() + +def create_community(name): + with db: + db.execute( + """ + INSERT OR IGNORE INTO community (name, created, description) + VALUES (:name, DATETIME(), "") + """, + { + 'name': name + } + ) + +def add_community_member(community_id, user_id, is_moderator=False): + with db: + db.execute( + """ + INSERT OR REPLACE INTO community_member (community_id, user_id, moderator) + VALUES (:community_id, :user_id, :moderator) + """, + { + 'community_id': community_id, + 'user_id': user_id, + 'moderator': 1 if is_moderator else 0 + } + ) +def remove_community_member(community_id, user_id, is_moderator=False): + with db: + db.execute( + """ + DELETE FROM community_member + WHERE community_id = :community_id AND user_id = :user_id + """, + { + 'community_id': community_id, + 'user_id': user_id + } + ) + +def is_community_moderator(community_id, user_id): + with db: + cursor = db.execute( + """ + SELECT EXISTS ( + SELECT 1 + FROM community_member AS M + WHERE M.community_id = :community_id AND M.user_id = :user_id AND M.moderator = 1 + ) AS count + """, + { + 'community_id': community_id, + 'user_id': user_id + } + ) + return cursor.fetchone()['count'] > 0 +def is_community_member(community_id, user_id): + with db: + cursor = db.execute( + """ + SELECT EXISTS ( + SELECT 1 + FROM community_member AS M + WHERE M.community_id = :community_id AND M.user_id = :user_id + ) AS count + """, + { + 'community_id': community_id, + 'user_id': user_id + } + ) + + return cursor.fetchone()['count'] > 0 + +def update_community_settings(community_id, description, allow_new_posts): + with db: + db.execute ( + """ + UPDATE community + SET description = :description, + allow_new_posts = :allow_new_posts + WHERE id = :community_id + """, + { + 'community_id': community_id, + 'description': description, + 'allow_new_posts': 1 if allow_new_posts else 0 + } + ) diff --git a/freepost/static/css/freepost.css b/freepost/static/css/freepost.css new file mode 100644 index 0000000..a7a00bb --- /dev/null +++ b/freepost/static/css/freepost.css @@ -0,0 +1,855 @@ +* { + margin: 0; + padding: 0; + font-family: 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: .5em 1em; + text-align: center; +} + +@media only screen and (max-width: 800px) { + .button { + font-size: 1.2rem; + padding: .5em 1em; + width: 100%; + } +} + +.button_transparent, /* Green */ +.button_transparent:hover, +.button_transparent:visited { + background-color: transparent; + border: 0; + color: black; +} + +.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; + padding: .5em 1em; + line-height: 1.42857143; + color: #555; + border: 1px solid #ccc; + border-radius: 4px; + width: 100%; + -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; +} + +.form-control-inline { + display: inline; + width: auto; +} + +textarea.form-control { + height: 8rem; +} + +.pagination {} + + .pagination > form { + display: inline-block; + } + + .pagination > .page_number { + font-size: .7rem; + font-weight: bold; + margin: 0 1rem; + } + +/* When users vote, this diff --git a/freepost/templates/post.html b/freepost/templates/post.html index d31344d..652d23d 100644 --- a/freepost/templates/post.html +++ b/freepost/templates/post.html @@ -19,44 +19,49 @@ {{ post.title }} {% endif %} - + {% if post.link %}
    {{ post.link|netloc }}
    {% endif %} - + + {#
    {% for topic in topics %} {{ topic.name }} {% endfor %}
    - + #} +
    - {{ vote ('post', post, user) }} - - + {{ vote('post', post, user) }} + + c/{{ post['community_name'] }} + + Posted by + {{ post.username }} - + โ€” - + {{ post.vote }} votes, {{ post.commentsCount }} comments - + โ€” - + {% if not show_source %} Source {% endif%} - + {% if user and post.userId == user.id %} Edit {% endif %}
    - +
    {% if show_source %}
    {{ post.text }}
    @@ -64,7 +69,7 @@ {{ post.text|md2html|safe }} {% endif %}
    - +
    - +
    diff --git a/settings.yaml b/settings.yaml index cb39d0f..ae6e1dc 100644 --- a/settings.yaml +++ b/settings.yaml @@ -23,7 +23,7 @@ email: 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.