TalkSpar is an open-source web app that lets you enjoy online communities focused around topics you love, without censorship and moderator abuse.
Just want to use TalkSpar? Visit talkspar.com
Our crack team of developers are committed to protecting online freedom of speech. Your support helps us build the best possible product.
Your support will help the TalkSpar team upgrade server infrastructure, pay for APIs, etc.
Supporting TalkSpar means supporting free speech and digital discourse.
We like to keep things transparent. For this reason, our entire codebase is open-source and available on GitHub. View our algorithms right from your browser. No biased trending or sorting manipulation on TalkSpar.
from flask import g, request from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint from sqlalchemy.orm import relationship from apps.__main__ import Base class Vote(Base): __tablename__ = "votes" id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id")) vote_type = Column(Integer) submission_id = Column(Integer, ForeignKey("submissions.id"), index=True) created_utc = Column(Integer, default=0) creation_ip = Column(String, default=None) app_id = Column(Integer, ForeignKey("oauth_apps.id"), default=None) unique_votes = UniqueConstraint("user_id", "submission_id") user = relationship("User", lazy="subquery", backref="votes") post = relationship("Submission", lazy="subquery") def __init__(self, *args, **kwargs): if "created_utc" not in kwargs: kwargs["created_utc"] = g.timestamp kwargs["creation_ip"]=request.remote_addr super().__init__(*args, **kwargs) def __repr__(self): return f"<Vote(id={self.id})>" def change_to(self, x): """ 1 - upvote 0 - novote -1 - downvote """ if x in ["-1", "0", "1"]: x = int(x) elif x not in [-1, 0, 1]: abort(400) self.vote_type = x self.created_utc = g.timestamp g.db.add(self) @property def json_core(self): data={ "user_id": self.user_id, "submission_id":self.submission_id, "created_utc": self.created_utc, "vote_type":self.vote_type } return data @property def json(self): data=self.json_core data["user"]=self.user.json_core data["post"]=self.post.json_core return data class CommentVote(Base): __tablename__ = "commentvotes" id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id")) vote_type = Column(Integer) comment_id = Column(Integer, ForeignKey("comments.id"), index=True) created_utc = Column(Integer, default=0) creation_ip = Column(String, default=None) app_id = Column(Integer, ForeignKey("oauth_apps.id"), default=None) unique_votes = UniqueConstraint("user_id", "comment_id") user = relationship("User", lazy="subquery", backref="commentvotes") comment = relationship("Comment", lazy="subquery") def __init__(self, *args, **kwargs): if "created_utc" not in kwargs: kwargs["created_utc"] = g.timestamp kwargs["creation_ip"]=request.remote_addr super().__init__(*args, **kwargs) def __repr__(self): return f"<CommentVote(id={self.id})>" def change_to(self, x): """ 1 - upvote 0 - novote -1 - downvote """ if x in ["-1", "0", "1"]: x = int(x) elif x not in [-1, 0, 1]: abort(400) self.vote_type = x self.created_utc = g.timestamp g.db.add(self) @property def json_core(self): data={ "user_id": self.user_id, "comment_id":self.comment_id, "created_utc": self.created_utc, "vote_type":self.vote_type } return data @property def json(self): data=self.json_core data["user"]=self.user.json_core data["comment"]=self.comment.json_core return data
from flask import render_template, request, abort, g import time from sqlalchemy import ( Column, Integer, BigInteger, String, Boolean, ForeignKey, FetchedValue, Index, Float, ) from sqlalchemy.orm import relationship, deferred import math from urllib.parse import urlparse import random from os import environ import requests import re from .mix_ins import * from apps.helpers.base36 import base36encode from apps.helpers.lazy import lazy import apps.helpers.aws as aws from apps.__main__ import Base, cache, app, debug # from .votes import Vote, CommentVote from .domains import Domain from .flags import Flag from .badwords import BadWord from .comment import Comment class SubmissionAux(Base): __tablename__ = "submissions_aux" # we don't care about this ID key_id = Column(BigInteger, primary_key=True) id = Column(BigInteger, ForeignKey("submissions.id"), index=True, unique=True) title = Column(String(500), default=None) url = Column(String(2048), default=None) body = Column(String(10000), default="") body_html = Column(String(20000), default="") ban_reason = Column(String(128), default="") embed_url = Column(String(3000), default="") meta_title = Column(String(512), default="") meta_description = Column(String(1024), default="") __table_args__ = ( Index( "submissions_aux_url_trgm_idx", "url", postgresql_using="gin", postgresql_ops={"url": "gin_trgm_ops"}, ), Index( "submissions_aux_title_trgm_idx", "title", postgresql_using="gin", postgresql_ops={"title": "gin_trgm_ops"}, ), Index( "submissions_aux_body_trgm_idx", "body", postgresql_using="gin", postgresql_ops={"body": "gin_trgm_ops"}, ), ) class Submission(Base, standard_mixin, age_mixin, score_mixin, fuzzing_mixin): __tablename__ = "submissions" id = Column(BigInteger, primary_key=True) submission_aux = relationship( "SubmissionAux", lazy="joined", uselist=False, innerjoin=True, primaryjoin="Submission.id==SubmissionAux.id", ) author_id = Column(BigInteger, ForeignKey("users.id"), index=True) repost_id = Column(BigInteger, ForeignKey("submissions.id"), default=None) edited_utc = Column(BigInteger, default=0) created_utc = Column(BigInteger, default=0) is_banned = Column(Boolean, default=False) deleted_utc = Column(Integer, default=0) purged_utc = Column(Integer, default=0) distinguish_level = Column(Integer, default=0) gm_distinguish = Column(Integer, ForeignKey("boards.id"), default=None) distinguished_board = relationship( "Board", lazy="joined", primaryjoin="Board.id==Submission.gm_distinguish" ) created_str = Column(String(255), default=None) stickied = Column(Boolean, default=False) # _comments = relationship( # "Comment", # lazy="dynamic", # primaryjoin="Comment.parent_submission==Submission.id", # backref="submissions") domain_ref = Column(Integer, ForeignKey("domains.id"), default=None) domain_obj = relationship("Domain") flags = relationship("Flag", backref="submission") is_approved = Column(Integer, ForeignKey("users.id"), default=None) approved_utc = Column(Integer, default=0) board_id = Column(Integer, ForeignKey("boards.id"), default=None, index=True) original_board_id = Column(Integer, ForeignKey("boards.id"), default=None) over_18 = Column(Boolean, default=False) original_board = relationship( "Board", primaryjoin="Board.id==Submission.original_board_id" ) creation_ip = Column(String(64), default="") mod_approved = Column(Integer, default=None) accepted_utc = Column(Integer, default=0) # is_image = Column(Boolean, default=False) has_thumb = Column(Boolean, default=False) post_public = Column(Boolean, default=True) score_hot = Column(Float, default=0) score_disputed = Column(Float, default=0) score_top = Column(Float, default=1) score_activity = Column(Float, default=0) is_offensive = Column(Boolean, default=False) is_nsfl = Column(Boolean, default=False) board = relationship( "Board", lazy="joined", innerjoin=True, primaryjoin="Submission.board_id==Board.id", viewonly=True, ) author = relationship( "User", lazy="joined", innerjoin=True, primaryjoin="Submission.author_id==User.id", viewonly=True, ) is_pinned = Column(Boolean, default=False) score_best = Column(Float, default=0) reports = relationship("Report", backref="submission") is_bot = Column(Boolean, default=False) upvotes = Column(Integer, default=1, nullable=False) downvotes = Column(Integer, default=0, nullable=False) creation_region = Column(String(2), default=None) app_id = Column(Integer, ForeignKey("oauth_apps.id"), default=None) oauth_app = relationship("OauthApp") approved_by = relationship( "User", uselist=False, primaryjoin="Submission.is_approved==User.id" ) # not sure if we need this reposts = relationship("Submission", lazy="joined", remote_side=[id]) # These are virtual properties handled as postgres functions server-side # There is no difference to SQLAlchemy, but they cannot be written to ups = deferred(Column(Integer, server_default=FetchedValue())) downs = deferred(Column(Integer, server_default=FetchedValue())) # age=deferred(Column(Integer, server_default=FetchedValue())) comment_count = Column(Integer, server_default=FetchedValue()) # flag_count=deferred(Column(Integer, server_default=FetchedValue())) # report_count=deferred(Column(Integer, server_default=FetchedValue())) score = deferred(Column(Float, server_default=FetchedValue())) # is_public=deferred(Column(Boolean, server_default=FetchedValue())) awards = relationship("AwardRelationship", lazy="joined") rank_hot = deferred(Column(Float, server_default=FetchedValue())) rank_fiery = deferred(Column(Float, server_default=FetchedValue())) rank_activity = deferred(Column(Float, server_default=FetchedValue())) rank_best = deferred(Column(Float, server_default=FetchedValue())) scores_last_updated_utc = Column(Integer, default=0) # ordered indexes on scores __table_args__ = ( Index( "submissions_score_hot_desc_idx", "score_hot", postgresql_using="btree", postgresql_ops={"score_hot": "DESC"}, ), Index( "submissions_score_disputed_desc_idx", "score_disputed", postgresql_using="btree", postgresql_ops={"score_disputed": "DESC"}, ), Index( "submissions_score_top_desc_idx", "score_top", postgresql_using="btree", postgresql_ops={"score_top": "DESC"}, ), Index( "submissions_score_activity_desc_idx", "score_activity", postgresql_using="btree", postgresql_ops={"score_activity": "DESC"}, ), Index( "submissions_created_utc_desc_idx", "created_utc", postgresql_using="btree", postgresql_ops={"created_utc": "DESC"}, ), ) def __init__(self, *args, **kwargs): if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) kwargs["created_str"] = time.strftime( "%I:%M %p on %d %b %Y", time.gmtime(kwargs["created_utc"]) ) kwargs["creation_ip"] = request.remote_addr super().__init__(*args, **kwargs) def __repr__(self): return f"<Submission(id={self.id})>" @property @lazy def board_base36id(self): return base36encode(self.board_id) @property @lazy def is_deleted(self): return bool(self.deleted_utc) @property def is_repost(self): return bool(self.repost_id) @property def is_archived(self): return int(time.time()) - self.created_utc > 60 * 60 * 24 * 180 @property @lazy def fullname(self): return f"t2_{self.base36id}" @property @lazy def permalink(self): output = self.title.lower() output = re.sub("&\w{2,3};", "", output) output = [re.sub("\W", "", word) for word in output.split()] output = [x for x in output if x][0:6] output = "-".join(output) if not output: output = "-" return f"/+{self.board.name}/post/{self.base36id}/{output}" @property @lazy def votes_permalink(self): return self.permalink.replace("/post/", "/votes/", 1) @property def is_archived(self): now = int(time.time()) cutoff = now - (60 * 60 * 24 * 180) return self.created_utc < cutoff def rendered_page(self, comment=None, comment_info=None): # check for banned if self.deleted_utc > 0: template = "submission_deleted.html" elif g.user and g.user.admin_level >= 3: template = "submission.html" elif self.is_banned: template = "submission_banned.html" else: template = "submission.html" private = ( not self.is_public and not self.is_pinned and not self.board.can_view(g.user) ) if private and (not g.user or not self.author_id == g.user.id): abort(403) elif private: self.__dict__["replies"] = [] else: # load and tree comments # calling this function with a comment object will do a comment # permalink thing if ( "replies" not in self.__dict__ and "_preloaded_comments" in self.__dict__ ): self.tree_comments(comment=comment) # return template is_allowed_to_comment = self.board.can_comment(g.user) and not self.is_archived # if request.args.get("sort", "Hot") != "new": # self.replies = [x for x in self.replies if x.is_pinned] + [x for x in self.replies if not x.is_pinned] return render_template( template, p=self, sort_method=request.args.get("sort", "Hot").capitalize(), linked_comment=comment, comment_info=comment_info, is_allowed_to_comment=is_allowed_to_comment, render_replies=True, b=self.board, ) @property @lazy def domain(self): if not self.url: return "text post" domain = urlparse(self.url).netloc if domain.startswith("www."): domain = domain[4:] return domain def tree_comments(self, comment=None): comments = self.__dict__.get("_preloaded_comments", []) if not comments: return pinned_comment = [] index = {} for c in comments: if c.is_pinned and c.parent_fullname == self.fullname: pinned_comment = [c] continue if c.parent_fullname in index: index[c.parent_fullname].append(c) else: index[c.parent_fullname] = [c] for c in comments: c.__dict__["replies"] = index.get(c.fullname, []) if comment: self.__dict__["replies"] = [comment] else: self.__dict__["replies"] = pinned_comment + index.get(self.fullname, []) @property def active_flags(self): if self.is_approved: return 0 else: return len(self.flags) @property def active_reports(self): if self.mod_approved: return 0 else: return self.reports.filter(Report.created_utc > self.accepted_utc).count() @property # @lazy def thumb_url(self): if self.has_thumb: return ( f"https://{app.config['S3_BUCKET_URL']}/posts/{self.base36id}/thumb.png" ) elif self.is_image: return self.url elif self.embed_url: return "/assets/images/icons/default_thumb_yt.png" else: return None @property def visibility_reason(self): if g.user and self.author_id == g.user.id: return "this is your content." elif self.is_pinned: return "a guildmaster has pinned it." elif self.board.has_mod(g.user): return f"you are a guildmaster of +{self.board.name}." elif self.board.has_contributor(g.user): return f"you are an approved contributor in +{self.board.name}." elif g.user.admin_level >= 4: return f"you are a {app.config['SITE_NAME']} admin." def determine_offensive(self): for x in g.db.query(BadWord).all(): if (self.body and x.check(self.body)) or x.check(self.title): self.is_offensive = True break else: self.is_offensive = False @property @lazy def is_crosspost(self): return bool( (self.domain == app.config["SERVER_NAME"]) and re.match( "^https?://[a-zA-Z0-9_.-]+/\+\w+/post/(\w+)(/[a-zA-Z0-9_-]+/?)?$", self.url, ) ) @property def json_raw(self): data = { "author_name": self.author.username if not self.author.is_deleted else None, "permalink": self.permalink, "is_banned": bool(self.is_banned), "is_deleted": self.is_deleted, "created_utc": self.created_utc, "id": self.base36id, "fullname": self.fullname, "title": self.title, "is_nsfw": self.over_18, "is_nsfl": self.is_nsfl, "is_bot": self.is_bot, "thumb_url": self.thumb_url, "domain": self.domain, "is_archived": self.is_archived, "url": self.url, "body": self.body, "body_html": self.body_html, "created_utc": self.created_utc, "edited_utc": self.edited_utc or 0, "guild_name": self.board.name, "original_guild_name": self.original_board.name if not self.board_id == self.original_board_id else None, "original_guild_id": self.original_board.id if not self.board_id == self.original_board_id else None, "guild_id": base36encode(self.board_id), "comment_count": self.comment_count, "score": self.score_fuzzed, "upvotes": self.upvotes_fuzzed, "downvotes": self.downvotes_fuzzed, "award_count": self.award_count, "is_offensive": self.is_offensive, "meta_title": self.meta_title, "meta_description": self.meta_description, "is_pinned": self.is_pinned, "is_distinguished": bool(self.distinguish_level), "is_heralded": bool(self.gm_distinguish), "is_crosspost": self.is_crosspost, "herald_guild": self.distinguished_board.name if self.gm_distinguish else None, } if self.ban_reason: data["ban_reason"] = self.ban_reason if self.board_id != self.original_board_id and self.original_board: data["original_guild_name"] = self.original_board.name data["original_guild_id"] = base36encode(self.original_board_id) return data @property def json_core(self): if self.is_banned: return { "is_banned": True, "is_deleted": self.is_deleted, "ban_reason": self.ban_reason, "id": self.base36id, "title": self.title, "permalink": self.permalink, "guild_name": self.board.name, "is_pinned": self.is_pinned, } elif self.is_deleted: return { "is_banned": bool(self.is_banned), "is_deleted": True, "id": self.base36id, "title": self.title, "permalink": self.permalink, "guild_name": self.board.name, } return self.json_raw @property def json(self): data = self.json_core if self.deleted_utc > 0 or self.is_banned: return data data["author"] = self.author.json_core data["guild"] = self.board.json_core data["original_guild"] = ( self.original_board.json_core if not self.board_id == self.original_board_id else None ) data["comment_count"]: self.comment_count if "replies" in self.__dict__: data["replies"] = [x.json_core for x in self.replies] if "_voted" in self.__dict__: data["voted"] = self._voted return data @property def voted(self): return self.__dict__.get("_voted", 0) @property def title(self): return self.submission_aux.title @title.setter def title(self, x): self.submission_aux.title = x g.db.add(self.submission_aux) @property def url(self): return self.submission_aux.url @url.setter def url(self, x): self.submission_aux.url = x g.db.add(self.submission_aux) @property def body(self): return self.submission_aux.body @body.setter def body(self, x): self.submission_aux.body = x g.db.add(self.submission_aux) @property def body_html(self): return self.submission_aux.body_html @body_html.setter def body_html(self, x): self.submission_aux.body_html = x g.db.add(self.submission_aux) @property def ban_reason(self): return self.submission_aux.ban_reason @ban_reason.setter def ban_reason(self, x): self.submission_aux.ban_reason = x g.db.add(self.submission_aux) @property def embed_url(self): return self.submission_aux.embed_url @embed_url.setter def embed_url(self, x): self.submission_aux.embed_url = x g.db.add(self.submission_aux) @property def meta_title(self): return self.submission_aux.meta_title @meta_title.setter def meta_title(self, x): self.submission_aux.meta_title = x g.db.add(self.submission_aux) @property def meta_description(self): return self.submission_aux.meta_description @meta_description.setter def meta_description(self, x): self.submission_aux.meta_description = x g.db.add(self.submission_aux) def is_guildmaster(self, perm=None): mod = self.__dict__.get("_is_guildmaster", False) if not mod: return False elif not perm: return True else: return mod.perm_full or mod.__dict__[f"perm_{perm}"] return output @property def is_blocking_guild(self): return self.__dict__.get("_is_blocking_guild", False) @property def is_blocked(self): return self.__dict__.get("_is_blocked", False) @property def is_blocking(self): return self.__dict__.get("_is_blocking", False) @property def is_subscribed(self): return self.__dict__.get("_is_subscribed", False) @property def is_public(self): return self.post_public or not self.board.is_private @property def flag_count(self): return len(self.flags) @property def report_count(self): return len(self.reports) @property def award_count(self): return len(self.awards) @property def embed_template(self): return f"site_embeds/{self.domain_obj.embed_template}.html" @property def self_download_json(self): # This property should never be served to anyone but author and admin if not self.is_banned and self.deleted_utc == 0: return self.json_core data = { "title": self.title, "author": self.author.username, "url": self.url, "body": self.body, "body_html": self.body_html, "is_banned": bool(self.is_banned), "deleted_utc": self.deleted_utc, "created_utc": self.created_utc, "id": self.base36id, "fullname": self.fullname, "guild_name": self.board.name, "comment_count": self.comment_count, "permalink": self.permalink, } if self.original_board_id and (self.original_board_id != self.board_id): data["original_guild_name"] = self.original_board.name return data @property def json_admin(self): data = self.json_raw data["creation_ip"] = self.creation_ip data["creation_region"] = self.creation_region return data @property def is_exiled_for(self): return self.__dict__.get("_is_exiled_for", None) @property def is_image(self): return self.has_thumb and self.domain_obj and self.domain_obj.show_thumbnail @is_image.setter def is_image(self, other): pass @property def shortlink(self): protocol = "https" if app.config["FORCE_HTTPS"] else "http" if app.config["SHORT_DOMAIN"]: return f"{protocol}://{app.config['SHORT_DOMAIN']}/{self.base36id}" else: return f"{protocol}://{app.config['SERVER_NAME']}/post/{self.base36id}" @property def outside_embed_url(self): return f"/embed/post/{self.base36id}" def update_scores(self): self.score_hot = self.rank_hot self.score_disputed = self.rank_fiery self.score_top = self.score self.score_activity = self.rank_activity self.score_best = self.rank_best self.scores_last_updated_utc = g.timestamp # class SaveRelationship(Base, Stndrd): # __tablename__="save_relationship" # id=Column(Integer, primary_key=True) # user_id=Column(Integer, ForeignKey("users.id")) # submission_id=Column(Integer, ForeignKey("submissions.id"))
from werkzeug.security import generate_password_hash, check_password_hash from flask import g, session, abort, request from time import strftime, gmtime from sqlalchemy import ( Column, Integer, BigInteger, String, Boolean, ForeignKey, FetchedValue, Index, and_, or_, select, func, ) from sqlalchemy.orm import ( relationship, deferred, joinedload, lazyload, contains_eager, aliased, Load, load_only, ) from os import environ from secrets import token_hex from pyotp import TOTP from mistletoe import markdown import threading from apps.helpers.base36 import base36encode from apps.helpers.security import generate_hash, validate_hash from apps.helpers.lazy import lazy from apps.helpers.user_imports import send_notif import apps.helpers.aws as aws from apps.helpers.discord import add_role, delete_role, discord_log_event from .alts import Alt from .titles import TITLES from .submission import Submission, SubmissionAux # , SaveRelationship from .comment import Comment, CommentAux, Notification from .boards import Board from .board_relationships import ( ModRelationship, BanRelationship, ContributorRelationship, BoardBlock, ) from .mix_ins import * from .subscriptions import Subscription, Follow from .userblock import UserBlock from .badges import * from .clients import * from .paypal import PayPalTxn from .flags import Report from .votes import Vote from apps.__main__ import Base, cache, app, g, db_session, debug class User(Base, standard_mixin, age_mixin): __tablename__ = "users" # basic stuff id = Column(Integer, primary_key=True) username = Column(String, default=None) email = Column(String, default=None) passhash = deferred(Column(String, default=None)) created_utc = Column(Integer, default=0) admin_level = Column(Integer, default=0) is_activated = Column(Boolean, default=False) over_18 = Column(Boolean, default=False) creation_ip = Column(String, default=None) bio = Column(String, default="") bio_html = Column(String, default="") real_id = Column(String, default=None) referred_by = Column(Integer, default=None) is_deleted = Column(Boolean, default=False) delete_reason = Column(String(500), default="") creation_region = Column(String(2), default=None) # ban & admin actions is_banned = Column(Integer, default=0) unban_utc = Column(Integer, default=0) ban_reason = Column(String, default="") ban_evade = Column(Integer, default=0) # content preferences defaultsorting = Column(String, default="hot") defaulttime = Column(String, default="all") hide_offensive = Column(Boolean, default=True) hide_bot = Column(Boolean, default=False) show_nsfl = Column(Boolean, default=False) custom_filter_list = Column(String(1000), default="") filter_nsfw = Column(Boolean, default=False) per_page_preference = Column(Integer, default=25) # security login_nonce = Column(Integer, default=0) mfa_secret = deferred(Column(String(64), default=None)) is_private = Column(Boolean, default=False) is_nofollow = Column(Boolean, default=False) # profile title_id = Column(Integer) has_profile = Column(Boolean, default=False) has_banner = Column(Boolean, default=False) reserved = Column(String(256), default=None) is_nsfw = Column(Boolean, default=False) profile_nonce = Column(Integer, default=0) banner_nonce = Column(Integer, default=0) profile_upload_ip = deferred(Column(String(255), default=None)) banner_upload_ip = deferred(Column(String(255), default=None)) profile_upload_region = deferred(Column(String(2))) banner_upload_region = deferred(Column(String(2))) original_username = deferred(Column(String(255))) name_changed_utc = deferred(Column(Integer, default=0)) # siege last_siege_utc = Column(Integer, default=0) # stored values from db-side functions stored_karma = Column(Integer, default=0) stored_subscriber_count = Column(Integer, default=0) # premium coin_balance = Column(Integer, default=0) premium_expires_utc = Column(Integer, default=0) negative_balance_cents = Column(Integer, default=0) # discord discord_id = Column(String(64), default=None) # color=Column(String(6), default="805ad5") # secondary_color=Column(String(6), default="ffff00") # signature=Column(String(280), default='') # signature_html=Column(String(512), default="") ## === RELATIONSHIPS === # Content submissions = relationship( "Submission", lazy="dynamic", primaryjoin="Submission.author_id==User.id" ) comments = relationship( "Comment", lazy="dynamic", primaryjoin="Comment.author_id==User.id" ) notifications = relationship("Notification") # profile _badges = relationship("Badge", lazy="dynamic", backref="user") # Guild relationships moderates = relationship("ModRelationship") banned_from = relationship( "BanRelationship", primaryjoin="BanRelationship.user_id==User.id" ) subscriptions = relationship("Subscription") boards_created = relationship("Board", lazy="dynamic") contributes = relationship( "ContributorRelationship", lazy="dynamic", primaryjoin="ContributorRelationship.user_id==User.id", ) board_blocks = relationship("BoardBlock", lazy="dynamic") # Inter-user relationships following = relationship("Follow", primaryjoin="Follow.user_id==User.id") followers = relationship("Follow", primaryjoin="Follow.target_id==User.id") blocking = relationship( "UserBlock", lazy="dynamic", primaryjoin="User.id==UserBlock.user_id" ) blocked = relationship( "UserBlock", lazy="dynamic", primaryjoin="User.id==UserBlock.target_id" ) # apps _applications = relationship("OauthApp", lazy="dynamic") authorizations = relationship("ClientAuth", lazy="dynamic") # properties defined as SQL server-side functions energy = deferred(Column(Integer, server_default=FetchedValue())) comment_energy = deferred(Column(Integer, server_default=FetchedValue())) referral_count = deferred(Column(Integer, server_default=FetchedValue())) follower_count = deferred(Column(Integer, server_default=FetchedValue())) __table_args__ = ( Index( "users_username_trgm_idx", "username", postgresql_using="gin", postgresql_ops={"username": "gin_trgm_ops"}, ), Index( "users_original_username_trgm_idx", "original_username", postgresql_using="gin", postgresql_ops={"original_username": "gin_trgm_ops"}, ), Index( "users_email_trgm_idx", "email", postgresql_using="gin", postgresql_ops={"email": "gin_trgm_ops"}, ), Index( "users_created_utc_idx", "created_utc", postgresql_using="btree", postgresql_ops={"created_utc": "DESC"}, ), ) def __init__(self, **kwargs): if "password" in kwargs: kwargs["passhash"] = self.hash_password(kwargs["password"]) kwargs.pop("password") if "created_utc" not in kwargs: kwargs["created_utc"] = g.timestamp super().__init__(**kwargs) def has_block(self, target): return ( g.db.query(UserBlock) .filter_by(user_id=self.id, target_id=target.id) .first() ) def is_blocked_by(self, user): return ( g.db.query(UserBlock).filter_by(user_id=user.id, target_id=self.id).first() ) def any_block_exists(self, other): return ( g.db.query(UserBlock) .filter( or_( and_(UserBlock.user_id == self.id, UserBlock.target_id == other.id), and_(UserBlock.user_id == other.id, UserBlock.target_id == self.id), ) ) .first() ) def has_blocked_guild(self, board): return ( g.db.query(BoardBlock).filter_by(user_id=self.id, board_id=board.id).first() ) def validate_2fa(self, token): x = TOTP(self.mfa_secret) return x.verify(token, valid_window=1) @property def mfa_removal_code(self): hashstr = f"{self.mfa_secret}+{self.id}+{self.original_username}" hashstr = generate_hash(hashstr) removal_code = base36encode(int(hashstr, 16) % int("z" * 25, 36)) # should be 25char long, left pad if needed while len(removal_code) < 25: removal_code = "0" + removal_code return removal_code @property def boards_subscribed(self): boards = [ x.board for x in self.subscriptions if x.is_active and not x.board.is_banned ] return boards @property def age(self): return g.timestamp - self.created_utc @property def title(self): return TITLES.get(self.title_id) @cache.memoize(300) def recommended_list(self, page=1, per_page=25, filter_words=[], **kwargs): # get N most recent upvotes # get those posts # get a list of all users who also upvoted those things # get their upvotes # get those posts ordered by number of upvotes among those users # eliminate content based on personal filters # hard check to prevent this feature from being used as a "vote stalker" # Recommendations must be based on 4 other co-voting users minimum user_count = ( g.db.query(Vote.user_id) .filter( Vote.vote_type == 1, Vote.user_id.in_( select(Vote.user_id).filter( Vote.vote_type == 1, Vote.submission_id.in_( select(Vote.submission_id) .filter(Vote.vote_type == 1, Vote.user_id == self.id) .order_by(Vote.created_utc.desc()) .limit(50) ), ) ), ) .distinct() .count() ) if user_count < 4: return [] # select post IDs, with global restrictions - no deleted, removed, or front-page-sticky content posts = ( g.db.query( Submission.id, Submission.author_id, Submission.board_id, Submission.created_utc, ) .options( load_only( Submission.id, Submission.author_id, Submission.board_id, Submission.created_utc, ), lazyload("*"), ) .filter_by(is_banned=False, deleted_utc=0, stickied=False) ) # filter out anything that's yours, # or that you've already voted on, # or that's too old posts = posts.filter( Submission.author_id != self.id, Submission.created_utc > g.timestamp - 2592000, Submission.id.notin_( select(Vote.submission_id).filter( Vote.user_id == self.id, Vote.vote_type != 0 ) ), ) # no nsfw content if personal settings dicate if self.filter_nsfw or not self.over_18: posts = posts.filter_by(over_18=False) # no racial slur content if personal settings dictate if self.hide_offensive: posts = posts.filter_by(is_offensive=False) # no bot content if personal settings dictate if self.hide_bot: posts = posts.filter_by(is_bot=False) # no disturbing/gruesome content if personal settings dictate if not self.show_nsfl: posts = posts.filter_by(is_nsfl=False) # no content from guilds on user's personal block list posts = posts.filter( Submission.board_id.notin_( select(BoardBlock.board_id).filter_by(user_id=g.user.id) ) ) # subquery - guilds where user is mod or has mod invite m = select(ModRelationship.board_id).filter_by( user_id=self.id, invite_rescinded=False ) # subquery - guilds where user is added as contributor c = select(ContributorRelationship.board_id).filter_by(user_id=self.id) # no content from private guilds, unless the post was made while guild was public, # or the user is mod, or has mod invite, or is contributor, or is the post author posts = posts.filter( or_( # Submission.author_id == self.id, Submission.post_public == True, Submission.board_id.in_(m), Submission.board_id.in_(c), ) ) # subquery - other users who you are blocking blocking = select(UserBlock.target_id).filter_by(user_id=self.id) # subquery - other users you are blocked by blocked = select(UserBlock.user_id).filter_by(target_id=self.id) # no content where you're blocking the user or they're blocking you posts = posts.filter( Submission.author_id.notin_(blocking), Submission.author_id.notin_(blocked) ) # guild-based restrictions on recommended content # no content from banned guilds, and no content from opt-out guilds unless the user is sub'd board_ids = select(Board.id).filter( Board.is_banned == False, or_( Board.all_opt_out == False, Submission.board_id.in_( select(Subscription.board_id).filter_by( user_id=g.user.id, is_active=True ) ), ), ) posts = posts.filter(Submission.board_id.in_(board_ids)) # personal filter word restrictions # no content whose title contains a personal filter word if filter_words: posts = posts.join(Submission.submission_aux) for word in filter_words: posts = posts.filter(not_(SubmissionAux.title.ilike(f"%{word}%"))) ### Algorithm core part 1 # read this code inside -> outside # select your 50 most recent upvoted posts, # then select the users who also upvoted those posts ("co-voting users") # then select the other stuff they upvoted # filter out any posts not in that list posts = posts.filter( Submission.id.in_( select(Vote.submission_id).filter( Vote.vote_type == 1, Vote.user_id.in_( select(Vote.user_id).filter( Vote.vote_type == 1, Vote.submission_id.in_( select(Vote.submission_id) .filter(Vote.vote_type == 1, Vote.user_id == self.id) .order_by(Vote.created_utc.desc()) .limit(50) ), ) ), ) ) ) # at this point we have an unfinished query representing the pool of all possible For You posts # The next steps involve some heavy lifting on the part of the database # To save computation resources in the next steps, sort by Top and cut off everything below the first 500 # this is now an unsent db query that can be used in subsequent steps posts_subq = posts.order_by(Submission.score_top.desc()).limit(500).subquery() # Votes subquery - the only votes we care about are those from users who co-voted the user's last 100 upvotes # this is similar to the algoritm core part 1, with an added filter of only paying attention to votes on the final pool of 500 votes = ( select(Vote) .options(lazyload("*")) .filter( Vote.vote_type == 1, Vote.user_id.in_( select(Vote.user_id).filter( Vote.vote_type == 1, Vote.submission_id.in_( select(Vote.submission_id) .filter(Vote.vote_type == 1, Vote.user_id == self.id) .order_by(Vote.created_utc.desc()) .limit(50) ), ) ), Vote.submission_id.in_(select(posts_subq.c.id)), ) ) # This assigns posts their initial score - the number of upvotes it has from co-voting users starting_rank = func.count(votes.c.submission_id).label("rank") vote_scores = ( g.db.query(votes.c.submission_id.label("id"), starting_rank) .group_by(votes.c.submission_id) .order_by(starting_rank.desc()) .subquery() ) # create final scoring matrix, starting with post id, author_id, and board_id, # join it with the vote scores above # and add in penalty columns based on age and prior entries of the same author/board (promoting variety) age_penalty = ( (g.timestamp - posts_subq.c.created_utc) // (60 * 60 * 24 * 2) ).label("age_penalty") scores = ( g.db.query( posts_subq.c.id, posts_subq.c.author_id, posts_subq.c.board_id, posts_subq.c.created_utc, vote_scores.c.rank, age_penalty, func.row_number() .over( partition_by=posts_subq.c.author_id, order_by=(vote_scores.c.rank - age_penalty).desc(), ) .label("user_penalty"), func.row_number() .over( partition_by=posts_subq.c.board_id, order_by=(vote_scores.c.rank - age_penalty).desc(), ) .label("board_penalty"), ) .join(vote_scores, posts_subq.c.id == vote_scores.c.id) .subquery() ) # everything so far has been for the purpose of putting all candidate entries # into an ephemeral score table, which has columns: # id | author_id | board_id | created_utc | vote_rank | age_penalty | user_penalty | board_penalty # Last step, get *just* the ids, sorted by (vote_rank - age_penalty*a - user_penalty-b - board_penalty*c) post_ids = g.db.query(scores.c.id).order_by( # Submission.score_best.desc() # scores.c.rank.desc() ( scores.c.rank - scores.c.user_penalty - scores.c.board_penalty * 2 - scores.c.age_penalty ).desc(), scores.c.created_utc.desc(), ) # and paginate post_ids = post_ids.offset(per_page * (page - 1)).limit(per_page + 1).all() return [x.id for x in post_ids] @cache.memoize() def idlist(self, sort=None, page=1, t=None, filter_words=[], per_page=25, **kwargs): posts = ( g.db.query(Submission) .options(load_only(Submission.id), lazyload("*")) .filter_by(is_banned=False, deleted_utc=0, stickied=False) ) if not self.over_18: posts = posts.filter_by(over_18=False) if self.hide_offensive: posts = posts.filter_by(is_offensive=False) if self.hide_bot: posts = posts.filter_by(is_bot=False) if not self.show_nsfl: posts = posts.filter_by(is_nsfl=False) board_ids = select(Subscription.board_id).filter_by( user_id=self.id, is_active=True ) user_ids = ( select(Follow.user_id) .filter_by(user_id=self.id) .join(Follow.target) .filter(User.is_private == False, User.is_nofollow == False) ) posts = posts.filter( or_(Submission.board_id.in_(board_ids), Submission.author_id.in_(user_ids)) ) if self.admin_level < 4: # admins can see everything m = select(ModRelationship.board_id).filter_by( user_id=self.id, invite_rescinded=False ) c = select(ContributorRelationship.board_id).filter_by(user_id=self.id) posts = posts.filter( or_( Submission.author_id == self.id, Submission.post_public == True, Submission.board_id.in_(m), Submission.board_id.in_(c), ) ) blocking = select(UserBlock.target_id).filter_by(user_id=self.id) blocked = select(UserBlock.user_id).filter_by(target_id=self.id) posts = ( posts.filter( Submission.author_id.notin_(blocking) # , # Submission.author_id.notin_(blocked) ) .join(Submission.board) .filter(Board.is_banned == False) ) if filter_words: posts = posts.join(Submission.submission_aux) for word in filter_words: posts = posts.filter(not_(SubmissionAux.title.ilike(f"%{word}%"))) if t: now = g.timestamp if t == "day": cutoff = now - 86400 elif t == "week": cutoff = now - 604800 elif t == "month": cutoff = now - 2592000 elif t == "year": cutoff = now - 31536000 else: cutoff = 0 posts = posts.filter(Submission.created_utc >= cutoff) gt = kwargs.get("gt") lt = kwargs.get("lt") if gt: posts = posts.filter(Submission.created_utc > gt) if lt: posts = posts.filter(Submission.created_utc < lt) if sort == None: sort = self.defaultsorting or "hot" if sort == "hot": posts = posts.order_by(Submission.score_best.desc()) elif sort == "new": posts = posts.order_by(Submission.created_utc.desc()) elif sort == "old": posts = posts.order_by(Submission.created_utc.asc()) elif sort == "disputed": posts = posts.order_by(Submission.score_disputed.desc()) elif sort == "top": posts = posts.order_by(Submission.score_top.desc()) elif sort == "activity": posts = posts.order_by(Submission.score_activity.desc()) else: abort(422) return [ x.id for x in posts.offset(per_page * (page - 1)).limit(per_page + 1).all() ] @cache.memoize() def userpagelisting(self, page=1, sort="new", t="all", per_page=25): submissions = ( g.db.query(Submission) .options(load_only(Submission.id)) .filter_by(author_id=self.id) ) if not (g.user and g.user.over_18): submissions = submissions.filter_by(over_18=False) if g.user and g.user.hide_offensive and g.user.id != self.id: submissions = submissions.filter_by(is_offensive=False) if not (g.user and g.user.admin_level >= 3): submissions = ( submissions.filter_by(deleted_utc=0, is_banned=False) .join(Submission.board) .filter(Board.is_banned == False) ) if g.user and g.user.admin_level >= 4: pass elif g.user: m = select(ModRelationship.board_id).filter_by( user_id=g.user.id, invite_rescinded=False ) c = select(ContributorRelationship.board_id).filter_by(user_id=g.user.id) submissions = submissions.filter( or_( Submission.author_id == g.user.id, Submission.post_public == True, Submission.board_id.in_(m), Submission.board_id.in_(c), ) ) else: submissions = submissions.filter(Submission.post_public == True) if sort == "hot": submissions = submissions.order_by(Submission.score_best.desc()) elif sort == "new": submissions = submissions.order_by(Submission.created_utc.desc()) elif sort == "old": submissions = submissions.order_by(Submission.created_utc.asc()) elif sort == "disputed": submissions = submissions.order_by(Submission.score_disputed.desc()) elif sort == "top": submissions = submissions.order_by(Submission.score_top.desc()) elif sort == "activity": submissions = submissions.order_by(Submission.score_activity.desc()) now = g.timestamp if t == "day": cutoff = now - 86400 elif t == "week": cutoff = now - 604800 elif t == "month": cutoff = now - 2592000 elif t == "year": cutoff = now - 31536000 else: cutoff = 0 submissions = submissions.filter(Submission.created_utc >= cutoff) listing = [ x.id for x in submissions.offset(per_page * (page - 1)).limit(per_page + 1) ] return listing @cache.memoize() def commentlisting(self, page=1, sort="new", t="all", per_page=25): comments = ( self.comments.options(load_only(Comment.id)) .filter(Comment.parent_submission is not None) .join(Comment.post) ) if not (g.user and g.user.over_18): comments = comments.filter(Submission.over_18 == False) if g.user and g.user.hide_offensive and g.user.id != self.id: comments = comments.filter(Comment.is_offensive == False) if g.user and not g.user.show_nsfl: comments = comments.filter(Submission.is_nsfl == False) if not (g.user and g.user.admin_level >= 3): comments = comments.filter( Comment.deleted_utc == 0, Comment.is_banned == False ) if g.user and g.user.admin_level >= 4: pass elif g.user: m = select(ModRelationship).filter_by( user_id=g.user.id, invite_rescinded=False ) c = select(ContributorRelationship).filter_by(user_id=g.user.id) comments = ( comments.join(m, m.c.board_id == Submission.board_id, isouter=True) .join(c, c.c.board_id == Submission.board_id, isouter=True) .join(Board, Board.id == Submission.board_id) ) comments = comments.filter( or_( Comment.author_id == g.user.id, Submission.post_public == True, Board.is_private == False, m.c.board_id != None, c.c.board_id != None, ), Board.is_banned == False, ) else: comments = comments.join(Board, Board.id == Submission.board_id).filter( or_(Submission.post_public == True, Board.is_private == False), Board.is_banned == False, ) comments = comments.options(contains_eager(Comment.post)) if sort == "hot": comments = comments.order_by(Comment.score_hot.desc()) elif sort == "new": comments = comments.order_by(Comment.created_utc.desc()) elif sort == "old": comments = comments.order_by(Comment.created_utc.asc()) elif sort == "disputed": comments = comments.order_by(Comment.score_disputed.desc()) elif sort == "top": comments = comments.order_by(Comment.score_top.desc()) now = g.timestamp if t == "day": cutoff = now - 86400 elif t == "week": cutoff = now - 604800 elif t == "month": cutoff = now - 2592000 elif t == "year": cutoff = now - 31536000 else: cutoff = 0 comments = comments.filter(Comment.created_utc >= cutoff) comments = comments.offset(per_page * (page - 1)).limit(per_page + 1) listing = [c.id for c in comments] return listing @property @lazy def mods_anything(self): return bool([i for i in self.moderates if i.accepted]) @property @lazy def subscribed_to_anything(self): return bool([i for i in self.subscriptions if i.is_active]) @property def boards_modded(self): z = [ x.board for x in self.moderates if x and x.board and x.accepted and not x.board.is_banned ] z = sorted(z, key=lambda x: x.name) return z @property @cache.memoize() # 1hr cache time for user rep def karma(self): energy = self.energy if self.energy is not None else 0 post_count = self.post_count if self.post_count is not None else 0 return energy - post_count @property @cache.memoize() def comment_karma(self): if self.comment_energy is None or self.comment_count is None: comment_energy = ( self.comment_energy if self.comment_energy is not None else 0 ) comment_count = self.comment_count if self.comment_count is not None else 0 return comment_energy - comment_count @property @cache.memoize() def true_score(self): return max((self.karma + self.comment_karma), -5) @property def fullname(self): return f"t1_{self.base36id}" @property @cache.memoize() @lazy def has_report_queue(self): board_ids = ( select(ModRelationship.board_id) .options(lazyload("*")) .filter( ModRelationship.user_id == self.id, ModRelationship.accepted == True, or_( ModRelationship.perm_full == True, ModRelationship.perm_content == True, ), ) ) posts = ( g.db.query(Submission) .options(lazyload("*")) .filter( Submission.board_id.in_(board_ids), Submission.mod_approved == None, Submission.is_banned == False, Submission.deleted_utc == 0, ) .join(Report, Report.post_id == Submission.id) ) if not self.over_18: posts = posts.filter(Submission.over_18 == False) return bool(posts.first()) @property def banned_by(self): if not self.is_banned: return None return g.db.query(User).filter_by(id=self.is_banned).first() def has_badge(self, badgedef_id): return self._badges.filter_by(badge_id=badgedef_id).first() def vote_status_on_post(self, post): return post.voted def vote_status_on_comment(self, comment): return comment.voted def hash_password(self, password): return generate_password_hash(password, method="pbkdf2:sha512", salt_length=8) def verifyPass(self, password): return check_password_hash(self.passhash, password) @property def feedkey(self): return generate_hash( f"{self.username}{self.id}{self.feed_nonce}{self.created_utc}" ) @property def formkey(self): if "session_id" not in session: session["session_id"] = token_hex(16) msg = f"{session['session_id']}+{self.id}+{self.login_nonce}" return generate_hash(msg) def validate_formkey(self, formkey): return validate_hash( f"{session['session_id']}+{self.id}+{self.login_nonce}", formkey ) @property def url(self): return f"/@{self.username}" @property def permalink(self): return self.url @property def uid_permalink(self): return f"/uid/{self.base36id}" @property def original_link(self): return f"/@{self.original_username}" def __repr__(self): return f"<User(username={self.username})>" def notification_commentlisting( self, page=1, all_=False, replies_only=False, mentions_only=False, system_only=False, ): notifications = ( g.db.query(Notification) .options( lazyload("*"), ) .join(Notification.comment) .filter( Notification.user_id == self.id, Comment.is_banned == False, Comment.deleted_utc == 0, ) ) if replies_only: cs = select(Comment.id).filter(Comment.author_id == self.id) ps = select(Submission.id).filter(Submission.author_id == self.id) notifications = notifications.filter( or_( Comment.parent_comment_id.in_(cs), and_(Comment.level == 1, Comment.parent_submission.in_(ps)), ) ) elif mentions_only: cs = select(Comment.id).filter(Comment.author_id == self.id) ps = select(Submission.id).filter(Submission.author_id == self.id) notifications = notifications.filter( and_( Comment.parent_comment_id.notin_(cs), or_(Comment.level > 1, Comment.parent_submission.notin_(ps)), ) ) elif system_only: notifications = notifications.filter(Comment.author_id == 1) elif not all_: notifications = notifications.filter(Notification.read == False) notifications = notifications.options(contains_eager(Notification.comment)) notifications = ( notifications.order_by( # staying at 25 for performance reasons Notification.id.desc() ) .offset(25 * (page - 1)) .limit(26) .all() ) mark_as_read = False for x in notifications[0:25]: if x.read: continue x.read = True g.db.add(x) mark_as_read = True if mark_as_read: g.db.commit() return [x.comment_id for x in notifications] def notification_postlisting(self, all_=False, page=1): notifications = ( g.db.query(Notification) .options(lazyload("*")) .join(Notification.post) .filter( Notification.user_id == self.id, Submission.is_banned == False, Submission.deleted_utc == 0, ) ) if not all_: notifications = notifications.filter(Notification.read == False) notifications = ( notifications.options(contains_eager(Notification.post)) .order_by(Notification.id.desc()) .offset(25 * (page - 1)) .limit(26) ) mark_as_read = False for x in notifications[0:25]: if x.read: continue x.read = True g.db.add(x) mark_as_ready = True if mark_as_read: g.db.commit() g.db.commit() return [x.submission_id for x in notifications] @property @lazy def mentions_count(self): cs = select(Comment.id).filter(Comment.author_id == self.id) ps = select(Submission.id).filter(Submission.author_id == self.id) return ( self.notifications.options(lazyload("*")) .join(Notification.comment) .filter( Notification.read == False, Comment.is_banned == False, Comment.deleted_utc == 0, ) .filter( and_( Comment.parent_comment_id.notin_(cs), or_(Comment.level > 1, Comment.parent_submission.notin_(ps)), ) ) .count() ) @property @lazy def replies_count(self): cs = select(Comment.id).filter(Comment.author_id == self.id) ps = select(Submission.id).filter(Submission.author_id == self.id) return ( self.notifications.options(lazyload("*")) .join(Notification.comment) .filter(Comment.is_banned == False, Comment.deleted_utc == 0) .filter( Notification.read == False, or_( Comment.parent_comment_id.in_(cs), and_(Comment.level == 1, Comment.parent_submission.in_(ps)), ), ) .count() ) @property @lazy def post_notifications_count(self): return ( g.db.query(Notification) .filter(Notification.user_id == self.id, Notification.read == False) .join(Submission, Submission.id == Notification.submission_id) .filter( Submission.is_banned == False, Submission.deleted_utc == 0, ) .count() ) @property @lazy def system_notif_count(self): return ( g.db.query(Notification) .options(lazyload("*")) .join(Notification.comment) .filter( Notification.user_id == self.id, Notification.read == False, Comment.author_id == 1, ) .count() ) @property @lazy def notifications_count(self): return ( g.db.query(Notification) .options(lazyload("*")) .filter(Notification.user_id == self.id, Notification.read == False) .join(Notification.comment, isouter=True) .join(Notification.post, isouter=True) .filter( or_( and_(Comment.is_banned == False, Comment.deleted_utc == 0), and_(Submission.is_banned == False, Submission.deleted_utc == 0), ) ) .count() ) @property def post_count(self): return self.submissions.filter_by(is_banned=False).count() @property def comment_count(self): return ( self.comments.filter(Comment.parent_submission != None) .filter_by(is_banned=False, deleted_utc=0) .count() ) @property @lazy def alts(self): subq = ( g.db.query(Alt) .filter(or_(Alt.user1 == self.id, Alt.user2 == self.id)) .subquery() ) data = ( g.db.query(User, aliased(Alt, alias=subq)) .join(subq, or_(subq.c.user1 == User.id, subq.c.user2 == User.id)) .filter(User.id != self.id) .order_by(User.username.asc()) .all() ) data = [x for x in data] output = [] for x in data: user = x[0] user._is_manual = x[1].is_manual output.append(user) return output def alts_subquery(self): returnselect(User.id).filter( or_( User.id.in_(select(Alt.user1).filter(Alt.user2 == self.id)), User.id.in_(select(Alt.user2).filter(Alt.user1 == self.id)), ) ) def alts_threaded(self, db): subq = select(Alt).filter(or_(Alt.user1 == self.id, Alt.user2 == self.id)) data = ( db.query(User, aliased(Alt, alias=subq)) .join(subq, or_(subq.c.user1 == User.id, subq.c.user2 == User.id)) .filter(User.id != self.id) .order_by(User.username.asc()) .all() ) data = [x for x in data] output = [] for x in data: user = x[0] user._is_manual = x[1].is_manual output.append(user) return output def has_follower(self, user): return g.db.query(Follow).filter_by(target_id=self.id, user_id=user.id).first() def set_profile(self, file): self.del_profile() self.profile_nonce += 1 aws.upload_file( name=f"uid/{self.base36id}/profile-{self.profile_nonce}.png", file=file, resize=(100, 100), ) self.has_profile = True self.profile_upload_ip = request.remote_addr self.profile_set_utc = g.timestamp self.profile_upload_region = request.headers.get("cf-ipcountry") g.db.add(self) g.db.commit() def set_banner(self, file): self.del_banner() self.banner_nonce += 1 aws.upload_file( name=f"uid/{self.base36id}/banner-{self.banner_nonce}.png", file=file ) self.has_banner = True self.banner_upload_ip = request.remote_addr self.banner_upload_region = request.headers.get("cf-ipcountry") g.db.add(self) g.db.commit() def del_profile(self, db=None): aws.delete_file(name=f"uid/{self.base36id}/profile-{self.profile_nonce}.png") self.has_profile = False self.profile_nonce += 1 if db: db.add(self) db.commit() db.close() else: g.db.add(self) g.db.commit() def del_banner(self, db=None): aws.delete_file(name=f"uid/{self.base36id}/banner-{self.banner_nonce}.png") self.has_banner = False self.banner_nonce += 1 if db: db.add(self) db.commit() db.close() else: g.db.add(self) g.db.commit() @property def banner_url(self): if self.has_banner: return f"https://{app.config['S3_BUCKET_URL']}/uid/{self.base36id}/banner-{self.banner_nonce}.png" else: return app.config["IMG_URL_JUMBOTRON"] @property def dynamic_profile_url(self): return f"/uid/{self.base36id}/pic/profile/{self.profile_nonce}" @property def profile_url(self): if self.has_profile and not self.is_deleted: return f"https://{app.config['S3_BUCKET_URL']}/uid/{self.base36id}/profile-{self.profile_nonce}.png" else: return f"http{'s' if app.config['FORCE_HTTPS'] else ''}://{app.config['SERVER_NAME']}/logo/fontawesome/solid//{app.config['COLOR_PRIMARY']}/150" @property def can_make_guild(self): if app.config["GUILD_CREATION_REQ"] == -1: return self.admin_level >= 3 elif app.config["GUILD_CREATION_REQ"] == 0: return self.can_join_gms return ( self.has_premium or self.admin_level >= 3 or self.true_score >= app.config["GUILD_CREATION_REQ"] ) and self.can_join_gms @property def can_join_gms(self): if app.config["MAX_GUILD_COUNT"] == 0: return True return ( len([x for x in self.boards_modded if x.is_siegable]) < app.config["MAX_GUILD_COUNT"] ) @property def can_siege(self): if self.is_suspended: return False now = g.timestamp return now - max(self.last_siege_utc, self.created_utc) > 60 * 60 * 24 * 7 @property def can_submit_image(self): # Has premium return ( self.has_premium or self.true_score >= app.config["UPLOAD_IMAGE_REP"] ) and not g.is_tor @property def can_upload_avatar(self): return ( self.has_premium or self.true_score >= app.config["PROFILE_UPLOAD_REP"] ) and not g.is_tor @property def can_upload_banner(self): return ( self.has_premium or self.true_score >= app.config["BANNER_UPLOAD_REP"] ) and not g.is_tor @property def json_raw(self): data = { "username": self.username, "permalink": self.permalink, "is_banned": self.is_suspended, "is_premium": self.has_premium_no_renew, "created_utc": self.created_utc, "id": self.base36id, "is_private": self.is_private, "profile_url": self.profile_url, "banner_url": self.banner_url, "title": self.title.json if self.title else None, "bio": self.bio, "bio_html": self.bio_html, } if self.real_id: data["real_id"] = self.real_id return data @property def json_core(self): now = g.timestamp if self.is_suspended: return { "username": self.username, "permalink": self.permalink, "is_banned": True, "is_permanent_ban": not bool(self.unban_utc), "ban_reason": self.ban_reason, "id": self.base36id, } elif self.is_deleted: return { "username": self.username, "permalink": self.permalink, "is_deleted": True, "id": self.base36id, } return self.json_raw @property def json(self): data = self.json_core if self.is_suspended or self.is_deleted: return data data["badges"] = [x.json_core for x in self.badges] data["post_rep"] = int(self.karma) data["comment_rep"] = int(self.comment_karma) data["post_count"] = self.post_count data["comment_count"] = self.comment_count return data @property def total_karma(self): return 503 if self.id == 1 else max(self.karma + self.comment_karma, -5) @property def is_valid(self): if self.is_banned and self.unban_utc == 0: return False elif self.is_deleted: return False else: return True def ban(self, admin=None, reason=None, message=None, days=0): self.is_banned = admin.id if admin else 1 if reason: self.ban_reason = reason g.db.add(self) g.db.flush() # send message text = "Your account has been" if not admin: text += " automatically" if days: text += f" suspended for {days} day{'s' if days>1 else ''}" else: text += " terminated" if reason: text += f" for the following reason:\n\n> {reason}" else: text += "." if not admin: text += f"\n\nBecause this ban was performed automatically, it may be appealed. If your ban was applied in error, [join the {app.config['SITE_NAME']} discord server](/discord), and you will be automatically added to the ban appeals channel." if message: text += f"\n\nAdditional private message from the admins:\n\n{message}" send_notif(self, text) if days > 0: ban_time = g.timestamp + (days * 86400) self.unban_utc = ban_time else: # Takes care of all functions needed for account termination self.unban_utc = 0 if self.has_banner: thread1 = threading.Thread( target=self.del_banner, kwargs={"db": db_session()} ) thread1.start() if self.has_profile: thread2 = threading.Thread( target=self.del_profile, kwargs={"db": db_session()} ) thread2.start() add_role(self, "banned") delete_role(self, "member") # unprivate guilds if no mods remaining for b in self.boards_modded: if b.mods_count == 0: b.is_private = False b.restricted_posting = False # b.all_opt_out = False g.db.add(b) # ban api applications for application in self.applications: application.is_banned = True g.db.add(application) self.has_profile = False self.profile_nonce += 1 self.has_banner = False self.banner_nonce += 1 g.db.add(self) g.db.commit() discord_ban_action = f"{days} Day Ban" if days else "Perm Ban" discord_log_event( discord_ban_action, self, admin, reason=reason, admin_action=True ) def unban(self): # Takes care of all functions needed for account reinstatement. self.is_banned = 0 self.unban_utc = 0 delete_role(self, "banned") add_role(self, "member") g.db.add(self) discord_log_event( "Unban", self, g.user, reason=self.ban_reason, admin_action=True ) text = f'Your {app.config["SITE_NAME"]} account has been reinstated. Please review the [Terms of Service](/help/terms) and [Rules](/help/rules), and avoid breaking them in the future.' send_notif(self, text) @property def is_suspended(self): return self.is_banned and (self.unban_utc == 0 or self.unban_utc > g.timestamp) @property def is_permbanned(self): return self.is_banned and not self.unban_utc @property def is_blocking(self): return self.__dict__.get("_is_blocking", 0) @property def is_blocked(self): return self.__dict__.get("_is_blocked", 0) def refresh_selfset_badges(self): for badge in BADGE_DEFS.values(): if not badge.__dict__.get("expr"): continue should_have = badge.evaluate(self) if should_have: if not self.has_badge(badge.id): new_badge = Badge( user_id=self.id, badge_id=badge.id, created_utc=g.timestamp ) g.db.add(new_badge) elif should_have == False: bad_badge = self.has_badge(badge.id) if bad_badge: g.db.delete(bad_badge) g.db.commit() @property def applications(self): return [x for x in self._applications.order_by(OauthApp.id.asc()).all()] # def saved_idlist(self, page=1, per_page=25): # posts = g.db.query(Submission.id).options(lazyload('*')).filter_by(is_banned=False, # deleted_utc=0 # ) # if not self.over_18: # posts = posts.filter_by(over_18=False) # saved=select(SaveRelationship.submission_id).filter(SaveRelationship.user_id==self.id) # posts=posts.filter(Submission.id.in_(saved)) # if self.admin_level < 4: # # admins can see everything # m = select( # ModRelationship.board_id).filter_by( # user_id=self.id, # invite_rescinded=False) # c = select( # ContributorRelationship.board_id).filter_by( # user_id=self.id) # posts = posts.filter( # or_( # Submission.author_id == self.id, # Submission.post_public == True, # Submission.board_id.in_(m), # Submission.board_id.in_(c) # ) # ) # blocking = select( # UserBlock.target_id).filter_by( # user_id=self.id) # blocked = select( # UserBlock.user_id).filter_by( # target_id=self.id) # posts = posts.filter( # Submission.author_id.notin_(blocking), # Submission.author_id.notin_(blocked) # ) # posts=posts.order_by(Submission.created_utc.desc()) # return [x[0] for x in posts.offset(per_page * (page - 1)).limit(per_page+1).all()] def guild_rep(self, guild, recent=0): posts = g.db.query(Submission.score_top).filter_by( is_banned=False, original_board_id=guild.id, is_bot=False ) if recent: cutoff = g.timestamp - 60 * 60 * 24 * recent posts = posts.filter(Submission.created_utc > cutoff) posts = posts.all() post_rep = sum([x[0] for x in posts]) - len(posts) comments = g.db.query(Comment.score_top).filter_by( is_banned=False, original_board_id=guild.id, is_bot=False ) if recent: cutoff = g.timestamp - 60 * 60 * 24 * recent comments = comments.filter(Comment.created_utc > cutoff) comments = comments.all() comment_rep = sum([x[0] for x in comments]) - len(comments) return int(post_rep + comment_rep) @property def has_premium(self): now = g.timestamp if self.negative_balance_cents: return False if self.is_permbanned: return False elif self.premium_expires_utc > now: return True elif self.coin_balance >= 1: self.coin_balance -= 1 self.premium_expires_utc = now + 60 * 60 * 24 * 7 add_role(self, "premium") g.db.add(self) return True else: if self.premium_expires_utc: delete_role(self, "premium") self.premium_expires_utc = 0 g.db.add(self) return False @property def has_premium_no_renew(self): now = g.timestamp if self.negative_balance_cents: return False elif self.premium_expires_utc > now: return True elif self.coin_balance >= 1: return True else: return False @property def renew_premium_time(self): return strftime("%d %b %Y at %H:%M:%S", gmtime(self.premium_expires_utc)) @property def filter_words(self): l = ( [i.lstrip().rstrip() for i in self.custom_filter_list.split("\n")] if self.custom_filter_list else [] ) l = [i for i in l if i] return l @property def boards_modded_ids(self): return [x.id for x in self.boards_modded] @property def txn_history(self): return ( self._transactions.filter(PayPalTxn.status != 1) .order_by(PayPalTxn.created_utc.desc()) .all() ) @property def json_admin(self): data = self.json_raw data["creation_ip"] = self.creation_ip data["creation_region"] = self.creation_region data["email"] = self.email data["email_verified"] = self.is_activated return data @property def can_upload_comment_image(self): return self.has_premium and not g.is_tor @property def can_change_name(self): return ( self.name_changed_utc < g.timestamp - 60 * 60 * 24 * app.config["COOLDOWN_DAYS_CHANGE_USERNAME"] and self.coin_balance >= app.config["COINS_REQUIRED_CHANGE_USERNAME"] ) @cache.memoize(60 * 60 * 24) def badges_function(self): self.refresh_selfset_badges() return self._badges.all() badges = property(badges_function) @property def is_following(self): return self.__dict__.get("_is_following", None) @property def unban_string(self): if self.unban_utc == 0: return "Permanent Ban" wait = self.unban_utc - g.timestamp if wait < 60: text = "just a moment" else: days = wait // (60 * 60 * 24) wait -= days * 60 * 60 * 24 hours = wait // (60 * 60) wait -= hours * 60 * 60 minutes = wait // 60 text = f"{days}d {hours:02d}h {minutes:02d}m" return f"Unban in {text}"
from flask import g, render_template, request from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, FetchedValue, Index, Float from sqlalchemy.orm import relationship, deferred from sqlalchemy.ext.associationproxy import association_proxy from random import randint import math from apps.helpers.base36 import base36encode from apps.helpers.lazy import lazy from .mix_ins import * from .votes import CommentVote from .flags import CommentFlag from .badwords import BadWord from apps.__main__ import Base, cache, debug class CommentAux(Base): __tablename__ = "comments_aux" key_id = Column(Integer, primary_key=True) id = Column(Integer, ForeignKey("comments.id"), index=True, unique=True) body = Column(String(10000), default=None) body_html = Column(String(20000)) ban_reason = Column(String(256), default='') __table_args__=( Index( "comments_aux_body_trgm_idx", "body", postgresql_using="gin", postgresql_ops={ 'body':'gin_trgm_ops' } ), ) class Comment(Base, standard_mixin, age_mixin, score_mixin, fuzzing_mixin): __tablename__ = "comments" id = Column(Integer, primary_key=True) comment_aux = relationship( "CommentAux", lazy="joined", uselist=False, innerjoin=True, primaryjoin="Comment.id==CommentAux.id") author_id = Column(Integer, ForeignKey("users.id"), index=True) parent_submission = Column(Integer, ForeignKey("submissions.id"), index=True) # this column is foreignkeyed to comment(id) but we can't do that yet as # "comment" class isn't yet defined #parent_fullname = Column(Integer) created_utc = Column(Integer, default=0) edited_utc = Column(Integer, default=0) is_banned = Column(Boolean, default=False) distinguish_level = Column(Integer, default=0) gm_distinguish = Column(Integer, ForeignKey("boards.id"), default=None) distinguished_board = relationship("Board", lazy="joined", primaryjoin="Comment.gm_distinguish==Board.id") deleted_utc = Column(Integer, default=0) purged_utc = Column(Integer, default=0) is_approved = Column(Integer, default=0) approved_utc = Column(Integer, default=0) creation_ip = Column(String(64), default='') score_disputed = Column(Float, default=0) score_hot = Column(Float, default=0) score_top = Column(Integer, default=1) level = Column(Integer, default=0) parent_comment_id = Column(Integer, ForeignKey("comments.id"), index=True) original_board_id = Column(Integer, ForeignKey("boards.id"), index=True) over_18 = Column(Boolean, default=False) is_offensive = Column(Boolean, default=False) is_nsfl = Column(Boolean, default=False) is_bot = Column(Boolean, default=False) is_pinned = Column(Boolean, default=False) creation_region=Column(String(2), default=None) app_id = Column(Integer, ForeignKey("oauth_apps.id"), default=None) oauth_app=relationship("OauthApp") post = relationship("Submission", backref="comments") flags = relationship("CommentFlag", backref="comment") author = relationship( "User", lazy="joined", innerjoin=True, primaryjoin="User.id==Comment.author_id", viewonly=True) board = association_proxy("post", "board") original_board = relationship( "Board", primaryjoin="Board.id==Comment.original_board_id") upvotes = Column(Integer, default=1, nullable=False) downvotes = Column(Integer, default=0, nullable=False) parent_comment = relationship("Comment", remote_side=[id], viewonly=True) child_comments = relationship("Comment", remote_side=[parent_comment_id], viewonly=True) awards = relationship("AwardRelationship", lazy="joined") # These are virtual properties handled as postgres functions server-side # There is no difference to SQLAlchemy, but they cannot be written to ups = deferred(Column(Integer, server_default=FetchedValue())) downs = deferred(Column(Integer, server_default=FetchedValue())) is_public = deferred(Column(Boolean, server_default=FetchedValue())) score = deferred(Column(Integer, server_default=FetchedValue())) rank_fiery = deferred(Column(Float, server_default=FetchedValue())) rank_hot = deferred(Column(Float, server_default=FetchedValue())) __table_args__=( Index( "comments_score_hot_desc_idx", "score_hot", postgresql_using="btree", postgresql_ops={ 'score_hot':'DESC' } ), Index( "comments_score_top_desc_idx", "score_top", postgresql_using="btree", postgresql_ops={ 'score_top':'DESC' } ), Index( "comments_score_disputed_desc_idx", "score_disputed", postgresql_using="btree", postgresql_ops={ 'score_disputed':'DESC' } ), Index( "comments_created_utc_desc_idx", "created_utc", postgresql_using="btree", postgresql_ops={ 'created_utc':'DESC' } ) ) #flag_count=deferred(Column(Integer, server_default=FetchedValue())) def __init__(self, *args, **kwargs): if "created_utc" not in kwargs: kwargs["created_utc"] = g.timestamp kwargs["creation_ip"] = request.remote_addr super().__init__(*args, **kwargs) def __repr__(self): return f"<Comment(id={self.id})>" @property @lazy def fullname(self): return f"t3_{self.base36id}" @property @lazy def is_deleted(self): return bool(self.deleted_utc) @property @lazy def is_top_level(self): return self.parent_fullname and self.parent_fullname.startswith("t2_") @property def is_archived(self): return self.post.is_archived @property @lazy def parent(self): if not self.parent_submission: return None if self.is_top_level: return self.post else: return g.db.query(Comment).get(self.parent_comment_id) @property def children(self): return g.db.query(Comment).filter_by(parent_comment_id=self.id).all() @property def replies(self): r = self.__dict__.get("replies", None) if r is None: r = self.child_comments return r @replies.setter def replies(self, value): self.__dict__["replies"] = value @property @lazy def permalink(self): return f"{self.post.permalink}/{self.base36id}" @property @lazy def votes_permalink(self): return self.permalink.replace('/post/', '/votes/', 1) @property def any_descendants_live(self): if self.replies == []: return False if any([not x.is_banned and not x.is_deleted for x in self.replies]): return True else: return any([x.any_descendants_live for x in self.replies]) @property def active_flags(self): if self.is_approved: return 0 else: return self.flag_count def visibility_reason(self, v): if not v or self.author_id == v.id: return "this is your content." elif not self.board: return None elif self.board.has_mod(v): return f"you are a guildmaster of +{self.board.name}." elif self.board.has_contributor(v): return f"you are an approved contributor in +{self.board.name}." elif self.parent.author_id == v.id: return "this is a reply to your content." elif v.admin_level >= 4: return "you are a Ruqqus admin." def determine_offensive(self): for x in g.db.query(BadWord).all(): if x.check(self.body): self.is_offensive = True break else: self.is_offensive = False @property def json_raw(self): data= { 'id': self.base36id, 'fullname': self.fullname, 'level': self.level, 'author_name': self.author.username if not self.author.is_deleted else None, 'body': self.body, 'body_html': self.body_html, 'is_archived': self.is_archived, 'is_bot': self.is_bot, 'created_utc': self.created_utc, 'edited_utc': self.edited_utc or 0, 'is_banned': bool(self.is_banned), 'is_deleted': self.is_deleted, 'is_nsfw': self.over_18, 'is_offensive': self.is_offensive, 'is_nsfl': self.is_nsfl, 'is_distinguished': bool(self.distinguish_level), 'is_heralded': bool(self.gm_distinguish), 'herald_guild': self.distinguished_board.name if self.gm_distinguish else None, 'permalink': self.permalink, 'post_id': self.post.base36id, 'score': self.score_fuzzed, 'upvotes': self.upvotes_fuzzed, 'downvotes': self.downvotes_fuzzed, 'award_count': self.award_count, 'is_bot': self.is_bot, 'guild_id': base36encode(self.post.board_id), 'voted': self.voted } if self.ban_reason: data["ban_reason"]=self.ban_reason return data @property def json_core(self): if self.is_banned: data= {'is_banned': True, 'ban_reason': self.ban_reason, 'id': self.base36id, 'post': self.post.base36id, 'level': self.level, 'parent': self.parent_fullname } elif self.deleted_utc > 0: data= {'deleted_utc': self.deleted_utc, 'id': self.base36id, 'post': self.post.base36id, 'level': self.level, 'parent': self.parent_fullname } else: data=self.json_raw if self.level>=2: data['parent_comment_id']= base36encode(self.parent_comment_id), if "replies" in self.__dict__: data['replies']=[x.json_core for x in self.replies] return data @property def json(self): data=self.json_core if self.deleted_utc > 0 or self.is_banned: return data data["author"]=self.author.json_core data["post"]=self.post.json_core data["guild"]=self.post.board.json_core data["voted"]=self.voted if self.level >= 2: data["parent"]=self.parent.json_core if self.parent else [] return data @property def voted(self): return self.__dict__.get("_voted") @property def title(self): return self.__dict__.get("_title", self.author.title) @property def is_blocking(self): return self.__dict__.get('_is_blocking', 0) @property def is_blocked(self): return self.__dict__.get('_is_blocked', 0) @property def body(self): return self.comment_aux.body @body.setter def body(self, x): self.comment_aux.body = x g.db.add(self.comment_aux) @property def body_html(self): return self.comment_aux.body_html @body_html.setter def body_html(self, x): self.comment_aux.body_html = x g.db.add(self.comment_aux) @property def ban_reason(self): return self.comment_aux.ban_reason @ban_reason.setter def ban_reason(self, x): self.comment_aux.ban_reason = x g.db.add(self.comment_aux) @property def flag_count(self): return len(self.flags) @property def award_count(self): return len(self.awards) def collapse_for_user(self, v): if not v: return False if self.is_offensive and v.hide_offensive: return True if self.is_bot and v.hide_bot: return True if any([x in self.body for x in v.filter_words]): return True return False @property def self_download_json(self): #This property should never be served to anyone but author and admin if not self.is_banned and not self.is_banned: return self.json_core data= { "author": self.author.name, "body": self.body, "body_html": self.body_html, "is_banned": bool(self.is_banned), "deleted_utc": self.deleted_utc, 'created_utc': self.created_utc, 'id': self.base36id, 'fullname': self.fullname, 'permalink': self.permalink, 'post_id': self.post.base36id, 'level': self.level } if self.level>=2: data['parent_comment_id']= base36encode(self.parent_comment_id) return data @property def json_admin(self): data= self.json_raw data["creation_ip"] = self.creation_ip data["creation_region"] = self.creation_region return data def is_guildmaster(self, perm=None): mod=self.__dict__.get('_is_guildmaster', False) if not mod: return False elif not perm: return True else: return mod.perm_full or mod.__dict__[f"perm_{perm}"] return output @property def is_exiled_for(self): return self.__dict__.get('_is_exiled_for', None) @property @lazy def is_op(self): return self.author_id==self.post.author_id and not self.author.is_deleted and not self.post.author.is_deleted and not self.post.is_deleted @property @lazy def board(self): return self.post.board @property def parent_fullname(self): if self.parent_comment_id: return f't3_{base36encode(self.parent_comment_id)}' elif self.parent_submission: return f't2_{base36encode(self.parent_submission)}' else: return None @property def notif_type(self): #system notif if self.author_id==1: return 1 #reply if g.v and self.parent.author_id==g.v.id: return 2 #mention else: return 3 @property def board_id(self): return self.post.board_id @property def outside_embed_url(self): return f"/embed/comment/{self.base36id}" class Notification(Base): __tablename__ = "notifications" id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), index=True) comment_id = Column(Integer, ForeignKey("comments.id"), index=True, default=None) submission_id = Column(Integer, ForeignKey("submissions.id"), default=None) read = Column(Boolean, default=False) comment = relationship("Comment") post = relationship("Submission") user=relationship("User", innerjoin=True, viewonly=True) def __repr__(self): return f"<Notification(id={self.id})>" @property def voted(self): return 0 @property def target(self): return self.comment if self.comment_id else self.post @property def created_utc(self): return self.target.created_utc
Now accepting Bitcoin, Litecoin, and Ethereum. Every donation counts :)