def mod_take_pid(pid, v): bid = request.form.get("board_id",None) if not bid: abort(400) board=get_board(bid) if board.is_banned: abort(403) if not board.has_mod(v): abort(403) post = get_post(pid) if not post.board_id==1: abort(422) if board.has_ban(post.author): abort(403) if not board.can_take(post): abort(403) post.board_id=board.id post.guild_name=board.name db.add(post) db.commit() #clear board's listing caches cache.delete_memoized(Board.idlist, board) return redirect(post.permalink)
def mod_toggle_post_pin(bid, pid, x, board, v): post=get_post(pid) if post.board_id != board.id: abort(422) try: x=bool(int(x)) except: abort(422) if x and not board.can_pin_another: return jsonify({"error":f"+{board.name} already has the maximum number of pinned posts."}), 409 post.is_pinned=x cache.delete_memoized(Board.idlist, post.board) db.add(post) db.commit() return "", 204
def user_kick_pid(pid, v): #allows a user to yank their content back to +general if it was there previously post=get_post(pid) current_board=post.board if not post.author_id==v.id: abort(403) if post.board_id==post.original_board_id: abort(403) if post.board_id==1: abort(400) #block further yanks to the same board new_rel=PostRelationship(post_id=post.id, board_id=post.board.id) db.add(new_rel) post.board_id=1 post.guild_name="general" post.is_pinned=False db.add(post) db.commit() #clear board's listing caches cache.delete_memoized(Board.idlist, current_board) return "", 204
def ban_post(post_id, v): post = g.db.query(Submission).filter_by(id=base36decode(post_id)).first() if not post: abort(400) post.is_banned = True post.is_approved = 0 post.approved_utc = 0 post.stickied = False post.is_pinned = False ban_reason=request.form.get("reason", "") with CustomRenderer() as renderer: ban_reason = renderer.render(mistletoe.Document(ban_reason)) ban_reason = sanitize(ban_reason, linkgen=True) post.ban_reason = ban_reason g.db.add(post) cache.delete_memoized(Board.idlist, post.board) ma=ModAction( kind="ban_post", user_id=v.id, target_submission_id=post.id, board_id=post.board_id, note="admin action" ) g.db.add(ma) return (redirect(post.permalink), post)
def settings_block_user(v): user = get_user(request.values.get("username"), graceful=True) if not user: return jsonify({"error": "That user doesn't exist."}), 404 if user.id == v.id: return jsonify({"error": "You can't block yourself."}), 409 if v.has_block(user): return jsonify( {"error": f"You have already blocked @{user.username}."}), 409 if user.id == 1: return jsonify({"error": "You can't block @ruqqus."}), 409 new_block = UserBlock(user_id=v.id, target_id=user.id, created_utc=int(time.time())) g.db.add(new_block) cache.delete_memoized(v.idlist) #cache.delete_memoized(Board.idlist, v=v) cache.delete_memoized(frontlist, v=v) return jsonify({"message": f"@{user.username} blocked."})
def subscribe_board(boardname, v): board = get_guild(boardname) #check for existing subscription, canceled or otherwise sub = g.db.query(Subscription).filter_by(user_id=v.id, board_id=board.id).first() if sub: if sub.is_active: return jsonify( {"error": f"You are already a member of +{board.name}"}), 409 else: #reactivate canceled sub sub.is_active = True g.db.add(sub) return jsonify({"message": f"Joined +{board.name}"}), 200 new_sub = Subscription(user_id=v.id, board_id=board.id) g.db.add(new_sub) g.db.flush() #clear your cached guild listings cache.delete_memoized(User.idlist, v, kind="board") #update board trending rank board.rank_trending = board.trending_rank board.stored_subscriber_count = board.subscriber_count g.db.add(board) return jsonify({"message": f"Joined +{board.name}"}), 200
def mod_board_color(bid, board, v): color = str(request.form.get("color", "")) if len(color) != 6: color = "603abb" red = color[0:1] green = color[2:3] blue = color[4:5] try: if any([int(x, 16) > 255 for x in [red, green, blue]]): color = "603abb" except ValueError: color = "603abb" board.color = color board.color_nonce += 1 g.db.add(board) try: cache.delete_memoized(board_css, board.name) cache.delete_memoized(board_dark_css, board.name) except: pass return redirect(f"/+{board.name}/mod/appearance?msg=Success")
def subscribe_board(boardname, v): board=get_guild(boardname) #check for existing subscription, canceled or otherwise sub= g.db.query(Subscription).filter_by(user_id=v.id, board_id=board.id).first() if sub: if sub.is_active: abort(409) else: #reactivate canceled sub sub.is_active=True g.db.add(sub) return "", 204 new_sub=Subscription(user_id=v.id, board_id=board.id) g.db.add(new_sub) #clear your cached guild listings cache.delete_memoized(User.idlist, v, kind="board") return "", 204
def mod_take_pid(pid, v): bid = request.form.get("board_id",None) if not bid: abort(400) board=get_board(bid) post = get_post(pid) if board.is_banned: return jsonify({'error':f"+{board.name} is banned. You can't yank anything there."}), 403 if not post.board_id==1: return jsonify({'error':f"This post is no longer in +general"}), 403 if not board.has_mod(v): return jsonify({'error':f"You are no longer a guildmaster of +{board.name}"}), 403 if board.has_ban(post.author): return jsonify({'error':f"@{post.author.username} is exiled from +{board.name}, so you can't yank their post there."}), 403 if not board.can_take(post): return jsonify({'error':f"You can't yank this particular post to +{board.name}."}), 403 post.board_id=board.id post.guild_name=board.name g.db.add(post) #clear board's listing caches cache.delete_memoized(Board.idlist, board) return "", 204
def unsubscribe_board(boardname, v): board = get_guild(boardname) #check for existing subscription sub = g.db.query(Subscription).filter_by(user_id=v.id, board_id=board.id).first() if not sub: abort(409) elif not sub.is_active: abort(409) sub.is_active = False g.db.add(sub) g.db.flush() #clear your cached guild listings cache.delete_memoized(User.idlist, v, kind="board") board.rank_trending = board.trending_rank g.db.add(board) return "", 204
def mod_board_color(bid, board, v): color=str(request.form.get("color","")).strip() #Remove the '#' from the beginning in case it was entered. if color.startswith('#'): color = color[1:] if len(color) !=6: return render_template("guild/appearance.html", v=v, b=board, error="Invalid color code."), 400 red=color[0:1] green=color[2:3] blue=color[4:5] try: if any([int(x,16)>255 for x in [red,green,blue]]): return render_template("guild/appearance.html", v=v, b=board, error="Invalid color code."), 400 except ValueError: return render_template("guild/appearance.html", v=v, b=board, error="Invalid color code."), 400 board.color=color board.color_nonce+=1 g.db.add(board) try: cache.delete_memoized(board_css, board.name) cache.delete_memoized(board_dark_css, board.name) except: pass return redirect(f"/+{board.name}/mod/appearance?msg=Success")
def mod_take_pid(pid, v): bid = request.form.get("board_id", request.form.get("guild", None)) if not bid: abort(400) board = get_board(bid) post = get_post(pid) #check cooldowns now=int(time.time()) if post.original_board_id != board.id and post.author_id != v.id: if now < v.last_yank_utc + 3600: return jsonify({'error':f"You've yanked a post recently. You need to wait 1 hour between yanks."}), 401 elif now < board.last_yank_utc + 3600: return jsonify({'error':f"+{board.name} has yanked a post recently. The Guild needs to wait 1 hour between yanks."}), 401 if board.is_banned: return jsonify({'error': f"+{board.name} is banned. You can't yank anything there."}), 403 if not post.board_id == 1: return jsonify({'error': f"This post is no longer in +general"}), 403 if not board.has_mod(v): return jsonify({'error': f"You are no longer a guildmaster of +{board.name}"}), 403 if board.has_ban(post.author): return jsonify({'error': f"@{post.author.username} is exiled from +{board.name}, so you can't yank their post there."}), 403 if post.author.any_block_exists(v): return jsonify({'error': f"You can't yank @{post.author.username}'s content."}), 403 if not board.can_take(post): return jsonify({'error': f"You can't yank this particular post to +{board.name}."}), 403 if board.is_private and post.original_board_id != board.id: return jsonify({'error': f"+{board.name} is private, so you can only yank content that started there."}), 403 post.board_id = board.id post.guild_name = board.name g.db.add(post) if post.original_board_id != board.id and post.author_id != v.id: board.last_yank_utc=now v.last_yank_utc=now g.db.add(board) g.db.add(v) notif_text=f"Your post [{post.title}]({post.permalink}) has been Yanked from +general to +{board.name}.\n\nIf you don't want it there, just click `Remove from +{board.name}` on the post." send_notification(post.author, notif_text) g.db.commit() # clear board's listing caches cache.delete_memoized(Board.idlist, board) return "", 204
def delete_post_pid(pid, v): post = get_post(pid) if not post.author_id == v.id: abort(403) post.is_deleted = True db.add(post) #clear cache cache.delete_memoized(User.userpagelisting, v, sort="new") cache.delete_memoized(Board.idlist, post.board) if post.age >= 3600 * 6: cache.delete_memoized(Board.idlist, post.board, sort="new") cache.delete_memoized(frontlist, sort="new") #delete i.ruqqus.com if post.domain == "i.ruqqus.com": segments = post.url.split("/") pid = segments[4] rand = segments[5] if pid == post.base36id: key = f"post/{pid}/{rand}" delete_file(key) post.is_image = False db.add(post) db.commit() return "", 204
def settings_unblock_guild(v): board = get_guild(request.values.get("board"), graceful=True) x = v.has_blocked_guild(board) if not x: abort(409) g.db.delete(x) cache.delete_memoized(v.idlist) #cache.delete_memoized(Board.idlist, v=v) cache.delete_memoized(frontlist, v=v) return jsonify({"message": f"+{board.name} removed from filter"})
def mod_kick_bid_pid(bid, pid, board, v): post = get_post(pid) if not post.board_id == board.id: abort(400) post.board_id = 1 post.guild_name = "general" post.is_pinned = False g.db.add(post) cache.delete_memoized(Board.idlist, board) return "", 204
def settings_unblock_user(v): user = get_user(request.values.get("username")) x = v.has_block(user) if not x: abort(409) g.db.delete(x) cache.delete_memoized(v.idlist) #cache.delete_memoized(Board.idlist, v=v) cache.delete_memoized(frontlist, v=v) return jsonify({"message": f"@{user.username} unblocked."})
def settings_unblock_guild(v): board = get_guild(request.values.get("board"), graceful=True) x = v.has_blocked_guild(board) if not x: abort(409) g.db.delete(x) cache.delete_memoized(v.idlist) #cache.delete_memoized(Board.idlist, v=v) cache.delete_memoized(frontlist, v=v) return "", 204
def settings_unblock_user(v): user = get_user(request.values.get("username")) x = v.has_block(user) if not x: abort(409) g.db.delete(x) cache.delete_memoized(v.idlist) #cache.delete_memoized(Board.idlist, v=v) cache.delete_memoized(frontlist, v=v) return "", 204
def follow_user(username, v): target = get_user(username) #check for existing follow if g.db.query(Follow).filter_by(user_id=v.id, target_id=target.id).first(): abort(409) new_follow = Follow(user_id=v.id, target_id=target.id) g.db.add(new_follow) cache.delete_memoized(User.idlist, v, kind="user") return "", 204
def unfollow_user(username, v): target = get_user(username) # check for existing follow follow = g.db.query(Follow).filter_by(user_id=v.id, target_id=target.id).first() if not follow: abort(409) g.db.delete(follow) cache.delete_memoized(User.idlist, v, kind="user") return "", 204
def ban_post(post_id, v): post = g.db.query(Submission).filter_by(id=base36decode(post_id)).first() if not post: abort(400) post.is_banned = True post.is_approved = 0 post.approved_utc = 0 post.stickied = False post.ban_reason = request.form.get("reason", None) g.db.add(post) cache.delete_memoized(Board.idlist, post.board) return (redirect(post.permalink), post)
def settings_block_guild(v): board = get_guild(request.values.get("board"), graceful=True) if not board: return jsonify({"error": "That guild doesn't exist."}), 404 if v.has_blocked_guild(board): return jsonify({"error": f"You have already blocked +{board.name}."}), 409 new_block = BoardBlock(user_id=v.id, board_id=board.id, created_utc=int(time.time())) g.db.add(new_block) cache.delete_memoized(v.idlist) #cache.delete_memoized(Board.idlist, v=v) cache.delete_memoized(frontlist, v=v) return jsonify({"message": f"+{board.name} added to filter"})
def follow_user(username, v): target = get_user(username) if target.id == v.id: return jsonify({"error": "You can't follow yourself!"}), 400 # check for existing follow if g.db.query(Follow).filter_by(user_id=v.id, target_id=target.id).first(): abort(409) new_follow = Follow(user_id=v.id, target_id=target.id) g.db.add(new_follow) g.db.flush() target.stored_subscriber_count = target.follower_count g.db.add(target) g.db.commit() cache.delete_memoized(User.idlist, v, kind="user") return "", 204
def unsubscribe_board(boardname, v): board = get_guild(boardname) # check for existing subscription sub = g.db.query(Subscription).filter_by(user_id=v.id, board_id=board.id).first() if not sub or not sub.is_active: return jsonify({"error": f"You aren't a member of +{board.name}"}), 409 sub.is_active = False g.db.add(sub) g.db.flush() # clear your cached guild listings cache.delete_memoized(User.idlist, v, kind="board") board.rank_trending = board.trending_rank board.stored_subscriber_count = board.subscriber_count g.db.add(board) return jsonify({"message": f"Left +{board.name}"}), 200
def create_board_post(v): if not v.can_make_guild: return render_template("make_board.html", title="Unable to make board", error="You need more Reputation before you can make a Guild." ) board_name=request.form.get("name") board_name=board_name.lstrip("+") description = request.form.get("description") if not re.match(valid_board_regex, board_name): return render_template("make_board.html", v=v, error="Guild names must be 3-25 letters or numbers.", description=description ) #check name if g.db.query(Board).filter(Board.name.ilike(board_name)).first(): return render_template("make_board.html", v=v, error="That Guild already exists.", description=description ) #check # recent boards made by user cutoff=int(time.time())-60*60*24 recent=g.db.query(Board).filter(Board.creator_id==v.id, Board.created_utc >= cutoff).all() if len([x for x in recent])>=2: return render_template("message.html", title="You need to wait a bit.", message="You can only create up to 2 guilds per day. Try again later." ), 429 with CustomRenderer() as renderer: description_md=renderer.render(mistletoe.Document(description)) description_html=sanitize(description_md, linkgen=True) #make the board new_board=Board(name=board_name, description=description, description_html=description_html, over_18=bool(request.form.get("over_18","")), creator_id=v.id ) g.db.add(new_board) g.db.commit() #add user as mod mod=ModRelationship(user_id=v.id, board_id=new_board.id, accepted=True) g.db.add(mod) #add subscription for user sub=Subscription(user_id=v.id, board_id=new_board.id) g.db.add(sub) #clear cache cache.delete_memoized(guild_ids, sort="new") return redirect(new_board.permalink)
def submit_post(v): title = request.form.get("title", "") url = request.form.get("url", "") board = get_guild(request.form.get('board', 'general'), graceful=True) if not board: board = get_guild('general') if re.match('^\s*$', title): return render_template("submit.html", v=v, error="Please enter a better title.", title=title, url=url, body=request.form.get("body", ""), b=board) # if len(title)<10: # return render_template("submit.html", # v=v, # error="Please enter a better title.", # title=title, # url=url, # body=request.form.get("body",""), # b=board # ) elif len(title) > 500: return render_template("submit.html", v=v, error="500 character limit for titles.", title=title[0:500], url=url, body=request.form.get("body", ""), b=board) parsed_url = urlparse(url) if not (parsed_url.scheme and parsed_url.netloc) and not request.form.get( "body") and not request.files.get("file", None): return render_template("submit.html", v=v, error="Please enter a URL or some text.", title=title, url=url, body=request.form.get("body", ""), b=board) #sanitize title title = sanitize(title, linkgen=False) #check for duplicate dup = db.query(Submission).filter_by(title=title, author_id=v.id, url=url, is_deleted=False, board_id=board.id).first() if dup: return redirect(dup.permalink) #check for domain specific rules parsed_url = urlparse(url) domain = parsed_url.netloc # check ban status domain_obj = get_domain(domain) if domain_obj: if not domain_obj.can_submit: return render_template("submit.html", v=v, error=BAN_REASONS[domain_obj.reason], title=title, url=url, body=request.form.get("body", ""), b=get_guild(request.form.get( "board", "general"), graceful=True)) #check for embeds if domain_obj.embed_function: try: embed = eval(domain_obj.embed_function)(url) except: embed = "" else: embed = "" else: embed = "" #board board_name = request.form.get("board", "general") board_name = board_name.lstrip("+") board_name = board_name.rstrip() board = get_guild(board_name, graceful=True) if not board: board = get_guild('general') if board.is_banned: return render_template("submit.html", v=v, error=f"+{board.name} has been demolished.", title=title, url=url, body=request.form.get("body", ""), b=get_guild("general", graceful=True)), 403 if board.has_ban(v): return render_template("submit.html", v=v, error=f"You are exiled from +{board.name}.", title=title, url=url, body=request.form.get("body", ""), b=get_guild("general")), 403 if (board.restricted_posting or board.is_private) and not (board.can_submit(v)): return render_template( "submit.html", v=v, error=f"You are not an approved contributor for +{board.name}.", title=title, url=url, body=request.form.get("body", ""), b=get_guild(request.form.get("board", "general"), graceful=True)) user_id = v.id user_name = v.username #Force https for submitted urls if request.form.get("url"): new_url = ParseResult(scheme="https", netloc=parsed_url.netloc, path=parsed_url.path, params=parsed_url.params, query=parsed_url.query, fragment=parsed_url.fragment) url = urlunparse(new_url) else: url = "" #now make new post body = request.form.get("body", "") #catch too-long body if len(str(body)) > 10000: return render_template("submit.html", v=v, error="10000 character limit for text body", title=title, text=str(body)[0:10000], url=url, b=get_guild(request.form.get( "board", "general"), graceful=True)), 400 if len(url) > 2048: return render_template("submit.html", v=v, error="URLs cannot be over 2048 characters", title=title, text=body[0:2000], b=get_guild(request.form.get( "board", "general"), graceful=True)), 400 with CustomRenderer() as renderer: body_md = renderer.render(mistletoe.Document(body)) body_html = sanitize(body_md, linkgen=True) #check for embeddable video domain = parsed_url.netloc if url: repost = db.query(Submission).filter( Submission.url.ilike(url)).filter_by( board_id=board.id, is_deleted=False, is_banned=False).order_by(Submission.id.asc()).first() else: repost = None if request.files.get('file') and not v.can_submit_image: abort(403) new_post = Submission( title=title, url=url, author_id=user_id, body=body, body_html=body_html, embed_url=embed, domain_ref=domain_obj.id if domain_obj else None, board_id=board.id, original_board_id=board.id, over_18=(bool(request.form.get("over_18", "")) or board.over_18), post_public=not board.is_private, #author_name=user_name, #guild_name=board.name, repost_id=repost.id if repost else None) db.add(new_post) db.commit() new_post.determine_offensive() vote = Vote(user_id=user_id, vote_type=1, submission_id=new_post.id) db.add(vote) db.commit() #check for uploaded image if request.files.get('file'): file = request.files['file'] name = f'post/{new_post.base36id}/{secrets.token_urlsafe(8)}' upload_file(name, file) #update post data new_post.url = f'https://{BUCKET}/{name}' new_post.is_image = True new_post.domain_ref = 1 #id of i.ruqqus.com domain db.add(new_post) db.commit() #spin off thumbnail generation and csam detection as new threads elif new_post.url: new_thread = threading.Thread(target=thumbnail_thread, args=(new_post.base36id, )) new_thread.start() csam_thread = threading.Thread(target=check_csam, args=(new_post, )) csam_thread.start() #expire the relevant caches: front page new, board new cache.delete_memoized(frontlist, sort="new") cache.delete_memoized(Board.idlist, board, sort="new") #print(f"Content Event: @{new_post.author.username} post {new_post.base36id}") return redirect(new_post.permalink)
def settings_profile_post(v): updated = False if request.values.get("over18", v.over_18) != v.over_18: updated = True v.over_18 = request.values.get("over18", None) == 'true' cache.delete_memoized(User.idlist, v) if request.values.get("hide_offensive", v.hide_offensive) != v.hide_offensive: updated = True v.hide_offensive = request.values.get("hide_offensive", None) == 'true' cache.delete_memoized(User.idlist, v) if request.values.get("show_nsfl", v.show_nsfl) != v.show_nsfl: updated = True v.show_nsfl = request.values.get("show_nsfl", None) == 'true' cache.delete_memoized(User.idlist, v) if request.values.get("filter_nsfw", v.filter_nsfw) != v.filter_nsfw: updated = True v.filter_nsfw = not request.values.get("filter_nsfw", None) == 'true' cache.delete_memoized(User.idlist, v) if request.values.get("private", v.is_private) != v.is_private: updated = True v.is_private = request.values.get("private", None) == 'true' if request.values.get("nofollow", v.is_nofollow) != v.is_nofollow: updated = True v.is_nofollow = request.values.get("nofollow", None) == 'true' if request.values.get("bio") is not None: bio = request.values.get("bio")[0:256] if bio == v.bio: return render_template("settings_profile.html", v=v, error="You didn't change anything") v.bio = bio with CustomRenderer() as renderer: v.bio_html = renderer.render(mistletoe.Document(bio)) v.bio_html = sanitize(v.bio_html, linkgen=True) g.db.add(v) return render_template("settings_profile.html", v=v, msg="Your bio has been updated.") x = request.values.get("title_id", None) if x: x = int(x) if x == 0: v.title_id = None updated = True elif x > 0: title = get_title(x) if bool(eval(title.qualification_expr)): v.title_id = title.id updated = True else: return jsonify({ "error": f"You don't meet the requirements for title `{title.text}`." }), 403 else: abort(400) if updated: g.db.add(v) return jsonify({"message": "Your settings have been updated."}) else: return jsonify({"error": "You didn't change anything."}), 400
def submit_post(v): title = request.form.get("title", "") title = title.lstrip().rstrip() title = title.replace("\n", "") title = title.replace("\r", "") title = title.replace("\t", "") url = request.form.get("url", "") board = get_guild(request.form.get('board', 'general'), graceful=True) if not board: board = get_guild('general') if not title: return render_template("submit.html", v=v, error="Please enter a better title.", title=title, url=url, body=request.form.get("body", ""), b=board) # if len(title)<10: # return render_template("submit.html", # v=v, # error="Please enter a better title.", # title=title, # url=url, # body=request.form.get("body",""), # b=board # ) elif len(title) > 500: return render_template("submit.html", v=v, error="500 character limit for titles.", title=title[0:500], url=url, body=request.form.get("body", ""), b=board) parsed_url = urlparse(url) if not (parsed_url.scheme and parsed_url.netloc) and not request.form.get( "body") and not request.files.get("file", None): return render_template("submit.html", v=v, error="Please enter a URL or some text.", title=title, url=url, body=request.form.get("body", ""), b=board) #sanitize title title = bleach.clean(title) #Force https for submitted urls if request.form.get("url"): new_url = ParseResult(scheme="https", netloc=parsed_url.netloc, path=parsed_url.path, params=parsed_url.params, query=parsed_url.query, fragment=parsed_url.fragment) url = urlunparse(new_url) else: url = "" body = request.form.get("body", "") #check for duplicate dup = g.db.query(Submission).join(Submission.submission_aux).filter( Submission.author_id == v.id, Submission.is_deleted == False, Submission.board_id == board.id, SubmissionAux.title == title, SubmissionAux.url == url, SubmissionAux.body == body).first() if dup: return redirect(dup.permalink) #check for domain specific rules parsed_url = urlparse(url) domain = parsed_url.netloc # check ban status domain_obj = get_domain(domain) if domain_obj: if not domain_obj.can_submit: return render_template("submit.html", v=v, error=BAN_REASONS[domain_obj.reason], title=title, url=url, body=request.form.get("body", ""), b=get_guild(request.form.get( "board", "general"), graceful=True)) #check for embeds if domain_obj.embed_function: try: embed = eval(domain_obj.embed_function)(url) except: embed = "" else: embed = "" else: embed = "" #board board_name = request.form.get("board", "general") board_name = board_name.lstrip("+") board_name = board_name.rstrip() board = get_guild(board_name, graceful=True) if not board: board = get_guild('general') if board.is_banned: return render_template("submit.html", v=v, error=f"+{board.name} has been demolished.", title=title, url=url, body=request.form.get("body", ""), b=get_guild("general", graceful=True)), 403 if board.has_ban(v): return render_template("submit.html", v=v, error=f"You are exiled from +{board.name}.", title=title, url=url, body=request.form.get("body", ""), b=get_guild("general")), 403 if (board.restricted_posting or board.is_private) and not (board.can_submit(v)): return render_template( "submit.html", v=v, error=f"You are not an approved contributor for +{board.name}.", title=title, url=url, body=request.form.get("body", ""), b=get_guild(request.form.get("board", "general"), graceful=True)) #similarity check now = int(time.time()) cutoff = now - 60 * 60 * 24 similar_posts = g.db.query(Submission).options(lazyload('*')).join( Submission.submission_aux).filter( Submission.author_id == v.id, SubmissionAux.title.op('<->')(title) < app.config["SPAM_SIMILARITY_THRESHOLD"], Submission.created_utc > cutoff).all() if url: similar_urls = g.db.query(Submission).options(lazyload('*')).join( Submission.submission_aux).filter( Submission.author_id == v.id, SubmissionAux.url.op('<->')(url) < app.config["SPAM_URL_SIMILARITY_THRESHOLD"], Submission.created_utc > cutoff).all() else: similar_urls = [] threshold = app.config["SPAM_SIMILAR_COUNT_THRESHOLD"] if v.age >= (60 * 60 * 24 * 30): threshold *= 4 elif v.age >= (60 * 60 * 24 * 7): threshold *= 3 elif v.age >= (60 * 60 * 24): threshold *= 2 if max(len(similar_urls), len(similar_posts)) >= threshold: text = "Your Ruqqus account has been suspended for 1 day for the following reason:\n\n> Too much spam!" send_notification(v, text) v.ban(reason="Spamming.", include_alts=True, days=1) for post in similar_posts + similar_urls: post.is_banned = True post.ban_reason = "Automatic spam removal. This happened because the post's creator submitted too much similar content too quickly." g.db.add(post) g.db.commit() return redirect("/notifications") #catch too-long body if len(str(body)) > 10000: return render_template("submit.html", v=v, error="10000 character limit for text body", title=title, text=str(body)[0:10000], url=url, b=get_guild(request.form.get( "board", "general"), graceful=True)), 400 if len(url) > 2048: return render_template("submit.html", v=v, error="URLs cannot be over 2048 characters", title=title, text=body[0:2000], b=get_guild(request.form.get( "board", "general"), graceful=True)), 400 #render text with CustomRenderer() as renderer: body_md = renderer.render(mistletoe.Document(body)) body_html = sanitize(body_md, linkgen=True) ##check spam soup = BeautifulSoup(body_html, features="html.parser") links = [x['href'] for x in soup.find_all('a') if x.get('href')] if url: links = [url] + links for link in links: parse_link = urlparse(link) check_url = ParseResult(scheme="https", netloc=parse_link.netloc, path=parse_link.path, params=parse_link.params, query=parse_link.query, fragment='') check_url = urlunparse(check_url) badlink = g.db.query(BadLink).filter( literal(check_url).contains(BadLink.link)).first() if badlink: if badlink.autoban: text = "Your Ruqqus account has been suspended for 1 day for the following reason:\n\n> Too much spam!" send_notification(v, text) v.ban(days=1, reason="spam") return redirect('/notifications') else: return render_template( "submit.html", v=v, error= f"The link `{badlink.link}` is not allowed. Reason: {badlink.reason}", title=title, text=body[0:2000], b=get_guild(request.form.get("board", "general"), graceful=True)), 400 #check for embeddable video domain = parsed_url.netloc if url: repost = g.db.query(Submission).join(Submission.submission_aux).filter( SubmissionAux.url.ilike(url), Submission.board_id == board.id, Submission.is_deleted == False, Submission.is_banned == False).order_by( Submission.id.asc()).first() else: repost = None if request.files.get('file') and not v.can_submit_image: abort(403) #offensive is_offensive = False for x in g.db.query(BadWord).all(): if (body and x.check(body)) or x.check(title): is_offensive = True break new_post = Submission(author_id=v.id, domain_ref=domain_obj.id if domain_obj else None, board_id=board.id, original_board_id=board.id, over_18=(bool(request.form.get("over_18", "")) or board.over_18), post_public=not board.is_private, repost_id=repost.id if repost else None, is_offensive=is_offensive) g.db.add(new_post) g.db.flush() new_post_aux = SubmissionAux(id=new_post.id, url=url, body=body, body_html=body_html, embed_url=embed, title=title) g.db.add(new_post_aux) g.db.flush() vote = Vote(user_id=v.id, vote_type=1, submission_id=new_post.id) g.db.add(vote) g.db.flush() g.db.commit() g.db.refresh(new_post) #check for uploaded image if request.files.get('file'): file = request.files['file'] name = f'post/{new_post.base36id}/{secrets.token_urlsafe(8)}' upload_file(name, file) #thumb_name=f'posts/{new_post.base36id}/thumb.png' #upload_file(name, file, resize=(375,227)) #update post data new_post.url = f'https://{BUCKET}/{name}' new_post.is_image = True new_post.domain_ref = 1 #id of i.ruqqus.com domain g.db.add(new_post) g.db.commit() #spin off thumbnail generation and csam detection as new threads if new_post.url or request.files.get('file'): new_thread = threading.Thread(target=thumbnail_thread, args=(new_post.base36id, )) new_thread.start() csam_thread = threading.Thread(target=check_csam, args=(new_post, )) csam_thread.start() #expire the relevant caches: front page new, board new #cache.delete_memoized(frontlist, sort="new") g.db.commit() cache.delete_memoized(Board.idlist, board, sort="new") #print(f"Content Event: @{new_post.author.username} post {new_post.base36id}") return { "html": lambda: redirect(new_post.permalink), "api": lambda: jsonify(new_post.json) }
def submit_post(v): title = request.form.get("title", "").lstrip().rstrip() title = title.lstrip().rstrip() title = title.replace("\n", "") title = title.replace("\r", "") title = title.replace("\t", "") url = request.form.get("url", "") board = get_guild(request.form.get('board', 'general'), graceful=True) if not board: board = get_guild('general') if not title: return { "html": lambda: (render_template("submit.html", v=v, error="Please enter a better title.", title=title, url=url, body=request.form.get("body", ""), b=board), 400), "api": lambda: ({ "error": "Please enter a better title" }, 400) } # if len(title)<10: # return render_template("submit.html", # v=v, # error="Please enter a better title.", # title=title, # url=url, # body=request.form.get("body",""), # b=board # ) elif len(title) > 500: return { "html": lambda: (render_template("submit.html", v=v, error="500 character limit for titles.", title=title[0:500], url=url, body=request.form.get("body", ""), b=board), 400), "api": lambda: ({ "error": "500 character limit for titles" }, 400) } parsed_url = urlparse(url) if not (parsed_url.scheme and parsed_url.netloc) and not request.form.get( "body") and not request.files.get("file", None): return { "html": lambda: (render_template("submit.html", v=v, error="Please enter a url or some text.", title=title, url=url, body=request.form.get("body", ""), b=board), 400), "api": lambda: ({ "error": "`url` or `body` parameter required." }, 400) } # sanitize title title = bleach.clean(title, tags=[]) # Force https for submitted urls if request.form.get("url"): new_url = ParseResult(scheme="https", netloc=parsed_url.netloc, path=parsed_url.path, params=parsed_url.params, query=parsed_url.query, fragment=parsed_url.fragment) url = urlunparse(new_url) else: url = "" body = request.form.get("body", "") # check for duplicate dup = g.db.query(Submission).join(Submission.submission_aux).filter( Submission.author_id == v.id, Submission.deleted_utc == 0, Submission.board_id == board.id, SubmissionAux.title == title, SubmissionAux.url == url, SubmissionAux.body == body).first() if dup: return redirect(dup.permalink) # check for domain specific rules parsed_url = urlparse(url) domain = parsed_url.netloc # check ban status domain_obj = get_domain(domain) if domain_obj: if not domain_obj.can_submit: if domain_obj.reason == 4: v.ban(days=30, reason="Digitally malicious content") elif domain_obj.reason == 7: v.ban(reason="Sexualizing minors") return { "html": lambda: (render_template("submit.html", v=v, error=BAN_REASONS[domain_obj.reason], title=title, url=url, body=request.form.get("body", ""), b=board), 400), "api": lambda: ({ "error": BAN_REASONS[domain_obj.reason] }, 400) } # check for embeds if domain_obj.embed_function: try: embed = eval(domain_obj.embed_function)(url) except BaseException: embed = "" else: embed = "" else: embed = "" # board board_name = request.form.get("board", "general") board_name = board_name.lstrip("+") board_name = board_name.rstrip() board = get_guild(board_name, graceful=True) if not board: return { "html": lambda: (render_template("submit.html", v=v, error=f"Please enter a Guild to submit to.", title=title, url=url, body=request.form.get("body", ""), b=None), 403), "api": lambda: (jsonify( {"error": f"403 Forbidden - +{board.name} has been banned."})) } if board.is_banned: return { "html": lambda: (render_template("submit.html", v=v, error=f"+{board.name} has been banned.", title=title, url=url, body=request.form.get("body", ""), b=None), 403), "api": lambda: (jsonify( {"error": f"403 Forbidden - +{board.name} has been banned."})) } if board.has_ban(v): return { "html": lambda: (render_template("submit.html", v=v, error=f"You are exiled from +{board.name}.", title=title, url=url, body=request.form.get("body", ""), b=None), 403), "api": lambda: (jsonify({ "error": f"403 Not Authorized - You are exiled from +{board.name}" }), 403) } if (board.restricted_posting or board.is_private) and not (board.can_submit(v)): return { "html": lambda: (render_template( "submit.html", v=v, error= f"You are not an approved contributor for +{board.name}.", title=title, url=url, body=request.form.get("body", ""), b=None), 403), "api": lambda: (jsonify({ "error": f"403 Not Authorized - You are not an approved contributor for +{board.name}" }), 403) } if board.disallowbots and request.headers.get("X-User-Type") == "Bot": return { "api": lambda: (jsonify({ "error": f"403 Not Authorized - +{board.name} disallows bots from posting and commenting!" }), 403) } # similarity check now = int(time.time()) cutoff = now - 60 * 60 * 24 similar_posts = g.db.query(Submission).options(lazyload('*')).join( Submission.submission_aux ).filter( #or_( # and_( Submission.author_id == v.id, SubmissionAux.title.op('<->')(title) < app.config["SPAM_SIMILARITY_THRESHOLD"], Submission.created_utc > cutoff # ), # and_( # SubmissionAux.title.op('<->')(title) < app.config["SPAM_SIMILARITY_THRESHOLD"]/2, # Submission.created_utc > cutoff # ) #) ).all() if url: similar_urls = g.db.query(Submission).options(lazyload('*')).join( Submission.submission_aux ).filter( #or_( # and_( Submission.author_id == v.id, SubmissionAux.url.op('<->')(url) < app.config["SPAM_URL_SIMILARITY_THRESHOLD"], Submission.created_utc > cutoff # ), # and_( # SubmissionAux.url.op('<->')(url) < app.config["SPAM_URL_SIMILARITY_THRESHOLD"]/2, # Submission.created_utc > cutoff # ) #) ).all() else: similar_urls = [] threshold = app.config["SPAM_SIMILAR_COUNT_THRESHOLD"] if v.age >= (60 * 60 * 24 * 7): threshold *= 3 elif v.age >= (60 * 60 * 24): threshold *= 2 if max(len(similar_urls), len(similar_posts)) >= threshold: text = "Your Ruqqus account has been suspended for 1 day for the following reason:\n\n> Too much spam!" send_notification(v, text) v.ban(reason="Spamming.", days=1) for alt in v.alts: if not alt.is_suspended: alt.ban(reason="Spamming.", days=1) for post in similar_posts + similar_urls: post.is_banned = True post.is_pinned = False post.ban_reason = "Automatic spam removal. This happened because the post's creator submitted too much similar content too quickly." g.db.add(post) ma = ModAction(user_id=1, target_submission_id=post.id, kind="ban_post", board_id=post.board_id, note="spam") g.db.add(ma) g.db.commit() return redirect("/notifications") # catch too-long body if len(str(body)) > 10000: return { "html": lambda: (render_template("submit.html", v=v, error="10000 character limit for text body.", title=title, url=url, body=request.form.get("body", ""), b=board), 400), "api": lambda: ({ "error": "10000 character limit for text body." }, 400) } if len(url) > 2048: return { "html": lambda: (render_template("submit.html", v=v, error="2048 character limit for URLs.", title=title, url=url, body=request.form.get("body", ""), b=board), 400), "api": lambda: ({ "error": "2048 character limit for URLs." }, 400) } # render text body = preprocess(body) with CustomRenderer() as renderer: body_md = renderer.render(mistletoe.Document(body)) body_html = sanitize(body_md, linkgen=True) # Run safety filter bans = filter_comment_html(body_html) if bans: ban = bans[0] reason = f"Remove the {ban.domain} link from your post and try again." if ban.reason: reason += f" {ban.reason_text}" #auto ban for digitally malicious content if any([x.reason == 4 for x in bans]): v.ban(days=30, reason="Digitally malicious content is not allowed.") abort(403) return { "html": lambda: (render_template("submit.html", v=v, error=reason, title=title, url=url, body=request.form.get("body", ""), b=board), 403), "api": lambda: ({ "error": reason }, 403) } # check spam soup = BeautifulSoup(body_html, features="html.parser") links = [x['href'] for x in soup.find_all('a') if x.get('href')] if url: links = [url] + links for link in links: parse_link = urlparse(link) check_url = ParseResult(scheme="https", netloc=parse_link.netloc, path=parse_link.path, params=parse_link.params, query=parse_link.query, fragment='') check_url = urlunparse(check_url) badlink = g.db.query(BadLink).filter( literal(check_url).contains(BadLink.link)).first() if badlink: if badlink.autoban: text = "Your Ruqqus account has been suspended for 1 day for the following reason:\n\n> Too much spam!" send_notification(v, text) v.ban(days=1, reason="spam") return redirect('/notifications') else: return { "html": lambda: (render_template( "submit.html", v=v, error= f"The link `{badlink.link}` is not allowed. Reason: {badlink.reason}.", title=title, url=url, body=request.form.get("body", ""), b=board), 400), "api": lambda: ({ "error": f"The link `{badlink.link}` is not allowed. Reason: {badlink.reason}" }, 400) } # check for embeddable video domain = parsed_url.netloc if url: repost = g.db.query(Submission).join(Submission.submission_aux).filter( SubmissionAux.url.ilike(url), Submission.board_id == board.id, Submission.deleted_utc == 0, Submission.is_banned == False).order_by( Submission.id.asc()).first() else: repost = None if repost and request.values.get("no_repost"): return redirect(repost.permalink) if request.files.get('file') and not v.can_submit_image: abort(403) # offensive is_offensive = False for x in g.db.query(BadWord).all(): if (body and x.check(body)) or x.check(title): is_offensive = True break new_post = Submission(author_id=v.id, domain_ref=domain_obj.id if domain_obj else None, board_id=board.id, original_board_id=board.id, over_18=(bool(request.form.get("over_18", "")) or board.over_18), post_public=not board.is_private, repost_id=repost.id if repost else None, is_offensive=is_offensive, app_id=v.client.application.id if v.client else None, creation_region=request.headers.get("cf-ipcountry"), is_bot=request.headers.get("X-User-Type", "").lower() == "bot") g.db.add(new_post) g.db.flush() new_post_aux = SubmissionAux(id=new_post.id, url=url, body=body, body_html=body_html, embed_url=embed, title=title) g.db.add(new_post_aux) g.db.flush() vote = Vote(user_id=v.id, vote_type=1, submission_id=new_post.id) g.db.add(vote) g.db.flush() g.db.refresh(new_post) # check for uploaded image if request.files.get('file'): #check file size if request.content_length > 16 * 1024 * 1024 and not v.has_premium: g.db.rollback() abort(413) file = request.files['file'] if not file.content_type.startswith('image/'): return { "html": lambda: (render_template("submit.html", v=v, error=f"Image files only.", title=title, body=request.form.get("body", ""), b=board), 400), "api": lambda: ({ "error": f"Image files only" }, 400) } name = f'post/{new_post.base36id}/{secrets.token_urlsafe(8)}' upload_file(name, file) # thumb_name=f'posts/{new_post.base36id}/thumb.png' #upload_file(name, file, resize=(375,227)) # update post data new_post.url = f'https://{BUCKET}/{name}' new_post.is_image = True new_post.domain_ref = 1 # id of i.ruqqus.com domain g.db.add(new_post) g.db.add(new_post.submission_aux) g.db.commit() #csam detection def del_function(): db = db_session() delete_file(name) new_post.is_banned = True db.add(new_post) db.commit() ma = ModAction(kind="ban_post", user_id=1, note="banned image", target_submission_id=new_post.id) db.add(ma) db.commit() db.close() csam_thread = threading.Thread(target=check_csam_url, args=(f"https://{BUCKET}/{name}", v, del_function)) csam_thread.start() g.db.commit() # spin off thumbnail generation and csam detection as new threads if (new_post.url or request.files.get('file')) and ( v.is_activated or request.headers.get('cf-ipcountry') != "T1"): new_thread = gevent.spawn(thumbnail_thread, new_post.base36id) # expire the relevant caches: front page new, board new cache.delete_memoized(frontlist) g.db.commit() cache.delete_memoized(Board.idlist, board, sort="new") # queue up notifications for username mentions notify_users = set() soup = BeautifulSoup(body_html, features="html.parser") for mention in soup.find_all("a", href=re.compile("^/@(\w+)"), limit=3): username = mention["href"].split("@")[1] user = g.db.query(User).filter_by(username=username).first() if user and not v.any_block_exists(user) and user.id != v.id: notify_users.add(user.id) for x in notify_users: send_notification( x, f"@{v.username} has mentioned you: https://ruqqus.com{new_post.permalink}" ) # print(f"Content Event: @{new_post.author.username} post # {new_post.base36id}") #Bell notifs board_uids = g.db.query(Subscription.user_id).options( lazyload('*')).filter( Subscription.board_id == new_post.board_id, Subscription.is_active == True, Subscription.get_notifs == True, Subscription.user_id != v.id, Subscription.user_id.notin_( g.db.query( UserBlock.user_id).filter_by(target_id=v.id).subquery())) follow_uids = g.db.query(Follow.user_id).options(lazyload('*')).filter( Follow.target_id == v.id, Follow.get_notifs == True, Follow.user_id != v.id, Follow.user_id.notin_( g.db.query( UserBlock.user_id).filter_by(target_id=v.id).subquery()), Follow.user_id.notin_( g.db.query(UserBlock.target_id).filter_by( user_id=v.id).subquery())).join(Follow.target).filter( User.is_private == False, User.is_nofollow == False, ) if not new_post.is_public: contribs = g.db.query(ContributorRelationship).filter_by( board_id=new_post.board_id, is_active=True).subquery() mods = g.db.query(ModRelationship).filter_by( board_id=new_post.board_id, accepted=True).subquery() board_uids = board.uids.join( contribs, contribs.c.user_id == Subscription.user_id, isouter=True).join(mods, mods.c.user_id == Subscription.user_id, isouter=True).filter( or_(mods.c.id != None, contribs.c.id != None)) follow_uids = follow_uids.join(contribs, contribs.c.user_id == Follow.user_id, isouter=True).join( mods, mods.c.user_id == Follow.user_id, isouter=True).filter( or_(mods.c.id != None, contribs.c.id != None)) uids = list( set([x[0] for x in board_uids.all()] + [x[0] for x in follow_uids.all()])) for uid in uids: new_notif = Notification(user_id=uid, submission_id=new_post.id) g.db.add(new_notif) g.db.commit() return { "html": lambda: redirect(new_post.permalink), "api": lambda: jsonify(new_post.json) }
def settings_profile_post(v): updated = False if request.form.get("new_password"): if request.form.get("new_password") != request.form.get( "cnf_password"): return render_template("settings.html", v=v, error="Passwords do not match.") if not v.verifyPass(request.form.get("old_password")): return render_template("settings.html", v=v, error="Incorrect password") v.passhash = v.hash_password(request.form.get("new_password")) updated = True if request.form.get("over18") != v.over_18: updated = True v.over_18 = bool(request.form.get("over18", None)) cache.delete_memoized(User.idlist, v) if request.form.get("hide_offensive") != v.hide_offensive: updated = True v.hide_offensive = bool(request.form.get("hide_offensive", None)) cache.delete_memoized(User.idlist, v) if request.form.get("show_nsfl") != v.show_nsfl: updated = True v.show_nsfl = bool(request.form.get("show_nsfl", None)) cache.delete_memoized(User.idlist, v) if request.form.get("private") != v.is_private: updated = True v.is_private = bool(request.form.get("private", None)) if request.form.get("bio") != v.bio: updated = True bio = request.form.get("bio")[0:256] v.bio = bio with CustomRenderer() as renderer: v.bio_html = renderer.render(mistletoe.Document(bio)) v.bio_html = sanitize(v.bio_html, linkgen=True) x = int(request.form.get("title_id", 0)) if x == 0: v.title_id = None updated = True elif x > 0: title = get_title(x) if bool(eval(title.qualification_expr)): v.title_id = title.id updated = True else: return render_template( "settings_profile.html", v=v, error= f"Unable to set title {title.text} - {title.requirement_string}" ) else: abort(400) if updated: g.db.add(v) return render_template("settings_profile.html", v=v, msg="Your settings have been saved.") else: return render_template("settings_profile.html", v=v, error="You didn't change anything.")