From ebe84c38966ad541b4e79b1411ad38256f18ea17 Mon Sep 17 00:00:00 2001 From: zPlus Date: Mon, 19 Jun 2023 13:52:30 +0200 Subject: [PATCH] Add basic support for communities. --- README.md => README | 13 +- freepost.cgi | 12 +- freepost/__init__.py | 318 +++++++++++---- freepost/database.py | 265 ++++++++++--- freepost/static/images/libre.exchange.png | Bin 259 -> 0 bytes freepost/static/javascript/freepost.js | 47 +-- freepost/static/stylus/freepost.styl | 375 ------------------ freepost/static/stylus/reset.styl | 261 ------------ freepost/templates/banner.html | 3 + freepost/templates/communities.html | 26 ++ freepost/templates/community.html | 62 +++ .../templates/community_administration.html | 40 ++ freepost/templates/homepage.html | 6 +- freepost/templates/layout.html | 80 ++-- freepost/templates/post.html | 45 ++- freepost/templates/posts.html | 57 ++- freepost/templates/search.html | 6 +- freepost/templates/submit.html | 10 +- settings.yaml | 2 +- 19 files changed, 714 insertions(+), 914 deletions(-) rename README.md => README (83%) delete mode 100644 freepost/static/images/libre.exchange.png delete mode 100644 freepost/static/stylus/freepost.styl delete mode 100644 freepost/static/stylus/reset.styl create mode 100644 freepost/templates/communities.html create mode 100644 freepost/templates/community.html create mode 100644 freepost/templates/community_administration.html diff --git a/README.md b/README similarity index 83% rename from README.md rename to README index 827f0591..601ab52d 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 6047e4f1..c6d4c2ee 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 a0bd7a59..29c4da07 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 @@ -470,15 +467,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 +656,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 +669,7 @@ def post_thread (hash_id): return template ( 'post.html', + community=post['community_name'], post=post, comments=comments, topics=topics, @@ -531,15 +679,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 +708,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 +757,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 +775,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 +817,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 +873,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 +901,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 83110a7b..256dbdb8 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/images/libre.exchange.png b/freepost/static/images/libre.exchange.png deleted file mode 100644 index f09e9e30bd4035f71bce0d6e594d7fb5fc3a68c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 259 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzwj^(N7l!{JxM1({$v_d#0*}aI z1_o|n5N2eUHAey{xY*OhF(l$}a)QLm2EldG x8;uMMObiVS{v24`CbD66W8*`O!x{&e7. 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. */ @@ -35,23 +35,23 @@ function vote (action, vote_dom) var arrow_up = vote_dom.querySelector ('button[title="upvote"]'); var vote_counter = vote_dom.querySelector ('.count') var arrow_down = vote_dom.querySelector ('button[title="downvote"]'); - + // Voted/Upvoted var current_status = 0; - + if (vote_dom.classList.contains('upvoted')) current_status = 1; - + if (vote_dom.classList.contains('downvoted')) current_status = -1; - + // Current vote var current_vote = Number (vote_counter.textContent); - + // Remove any existing upvoted/downvoted class vote_dom.classList.remove ('upvoted'); vote_dom.classList.remove ('downvoted'); - + // Toggle upvote class for arrow if ("up" == action) switch (current_status) @@ -68,7 +68,7 @@ function vote (action, vote_dom) vote_counter.textContent = current_vote - 1; break; } - + // Toggle downvote class for arrow if ("down" == action) switch (current_status) @@ -89,18 +89,18 @@ function vote (action, vote_dom) // 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. @@ -111,21 +111,22 @@ document.addEventListener ('DOMContentLoaded', function() { .addEventListener ('click', function () { vote ('up', this.closest ('.vote')) }); - + vote_sections[i] .querySelector ('button[title="downvote"]') .addEventListener ('click', function () { vote ('down', this.closest ('.vote')) }); } - + // Function to hide/show menu when burger-icon has been clicked - var burger_menus = document.getElementsByClassName ('burger-icon') + var burger_menus = document.getElementsByClassName('burger-icon'); + for (var i = 0; i < burger_menus.length; i++) - burger_menus[i].addEventListener ('click', function (event) { + burger_menus[i].addEventListener('click', function(event) { // Toggle menu visibility - document.getElementById ('menu').classList.toggle ('visible'); - - this.classList.toggle ('open'); + document.getElementById('menu').classList.toggle('visible'); + + this.classList.toggle('open'); }); }); diff --git a/freepost/static/stylus/freepost.styl b/freepost/static/stylus/freepost.styl deleted file mode 100644 index b894a082..00000000 --- a/freepost/static/stylus/freepost.styl +++ /dev/null @@ -1,375 +0,0 @@ -@require 'reset.styl' - -/* A class used for displaying URLs domain (under post tile) */ -.netloc - color #828282 - font-size 1rem - font-style italic - -.monkey - height 1.5em - margin 0 1em - vertical-align middle - -a.topic, -a.topic:hover, -a.topic:visited - color rgba(200,0,100,.8) - font-size 1rem - padding 0 .2rem - text-decoration none - -/* Text icon near the title, to display the post content */ -.text_preview - height .8rem - margin 0 .5rem - 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 - @media only screen and (max-width: 800px) - display grid - grid-template-columns auto - - > .title-large - display none - - > .title-small - display grid - grid-template-columns auto max-content - - @media only screen and (min-width: 800px) - display grid - grid-template-columns max-content auto - - > .title-small - display none - - padding 1rem 0 - - /* Menu under the logo */ - @media only screen and (max-width: 800px) - .burger-icon - display inline-block - - .menu - border-bottom 2px dashed #aaa - display none - padding 1rem 0 - - /* This class is toggled when the burger icon is clicked */ - &.visible - display block - - /* Every menu item */ - > a - border 0 - color #000 - display block - margin 0 - padding .8rem 0 - text-align left - text-decoration none - - /* Highlight menu item of the current active page (Hot/New/Submit/...) */ - > .active_page - font-weight bold - text-decoration underline dotted - text-transform uppercase - - /* Highlight username if there are unread messages */ - .new_messages - background-color rgb(255, 175, 50) - color #fff - font-weight bold - padding-left 1rem - - @media only screen and (min-width: 800px) - .burger-icon - display none - - .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-end - - > .flex-item - flex 0 1 auto - align-self auto - order 0 - - border 0 - border-bottom 1px solid #ccc - color #000 - margin 0 - padding 0 .5rem .5rem .5rem - - /* 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 0 - 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 1rem 0 0 - padding 0 .5rem - - form - display inline-block - margin 0 - padding 0 - - > button - background transparent - border 0 - cursor pointer - display inline-block - margin 0 - overflow hidden - padding 0 - text-decoration none - vertical-align middle - - > img - height .8rem - width .8rem - - /* Votes counter */ - > .count - margin 0 .5rem - - .upvoted - background-color #d1ffd5 - border 1px dashed - border-color #00e313 - border-radius .5rem - color #00a200 - font-weight bolder - - .downvoted - background-color #ffd9d9 - border 1px dashed - border-color #ff7171 - border-radius .5rem - color #f00 - font-weight bolder - - /* Home page */ - .posts - - /* A singe post */ - > .post - display grid - grid-template-columns min-content auto - grid-column-gap 1.5rem - - margin-bottom 2rem - - /* Show numbered position in the list */ - > .position - color #555 - font-style italic - line-height 1.9rem - text-align right - - > .info - > .title > a - color #000 - font-size 1.6rem - - /* Some post info showed below the title */ - > .about - color #666 - margin .5rem 0 0 0 - - > .pagination - > form - width 100% - - /* New submission page */ - > form.submit - width 100% - - /* 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 - > 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 */ - > form > .user - display grid - grid-column-gap 3rem - grid-row-gap 1rem - grid-template-columns max-content auto - - /* User activity */ - > .user_activity - - > * - margin 0 0 2em 0 - - > .info - color #888 - - /* Login page */ - > .login - @media only screen and (min-width: 1024px) - margin auto - max-width 40% - - > form > div - margin 1rem 0 - - /* 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 - - @media only screen and (max-width: 1024px) - float none - margin 1rem 0 diff --git a/freepost/static/stylus/reset.styl b/freepost/static/stylus/reset.styl deleted file mode 100644 index 576df430..00000000 --- a/freepost/static/stylus/reset.styl +++ /dev/null @@ -1,261 +0,0 @@ -* - 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 - -@media only screen and (max-width: 800px) - .button - font-size 1.2rem - padding .5em 1em - width 100% - -.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 - -textarea.form-control - height 8rem - -.pagination - > form - display inline-block - - > .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 d31344dd..652d23d4 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 cb39d0fa..ae6e1dcd 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.