diff --git a/.gitignore b/.gitignore index 5b41b0b2d..717f57b48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ -image.* -video.mp4 -video.webm +# Python, Flask, IDEs cache/ __pycache__/ .idea/ @@ -12,3 +10,14 @@ flask_session/ .DS_Store .venv *.pyc + +# rdrama Media Runtime Artifacts +image.* +video.mp4 +video.webm + +# Optional env file for some environments +env + +# Diagnostic output +output.svg diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..c8cfe3959 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/Dockerfile b/Dockerfile index 67649f5ae..b06f765e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,14 +4,14 @@ FROM python:3.10 AS base ARG DEBIAN_FRONTEND=noninteractive -RUN apt update && apt -y upgrade && apt install -y supervisor +RUN apt update && apt -y upgrade # we'll end up blowing away this directory via docker-compose WORKDIR /service COPY pyproject.toml . COPY poetry.lock . RUN pip install 'poetry==1.2.2' -RUN poetry config virtualenvs.create false && poetry install +RUN poetry config virtualenvs.create false RUN mkdir /images @@ -19,30 +19,34 @@ EXPOSE 80/tcp ENV FLASK_APP=files/cli:app +CMD [ "bootstrap/init.sh" ] + ################################################################### # Release container FROM base AS release +RUN poetry install --without dev + COPY bootstrap/supervisord.conf.release /etc/supervisord.conf -CMD [ "/usr/bin/supervisord", "-c", "/etc/supervisord.conf" ] ################################################################### # Dev container FROM release AS dev +RUN poetry install --with dev + # Install our tweaked sqlalchemy-easy-profile COPY thirdparty/sqlalchemy-easy-profile sqlalchemy-easy-profile RUN cd sqlalchemy-easy-profile && python3 setup.py install COPY bootstrap/supervisord.conf.dev /etc/supervisord.conf -CMD [ "/usr/bin/supervisord", "-c", "/etc/supervisord.conf" ] ################################################################### # Utility container for running commands (tests, most notably) -FROM release AS operation +FROM dev AS operation # don't run the server itself, just start up the environment and assume we'll exec things from the outside CMD sleep infinity diff --git a/bootstrap/init.sh b/bootstrap/init.sh new file mode 100755 index 000000000..6948b22d7 --- /dev/null +++ b/bootstrap/init.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euxo pipefail + +python3 -m flask db upgrade # this does not actually return error codes properly! +python3 -m flask cron_setup + +/usr/local/bin/supervisord -c /etc/supervisord.conf diff --git a/bootstrap/supervisord.conf.dev b/bootstrap/supervisord.conf.dev index c0b122e79..60a51bb6e 100644 --- a/bootstrap/supervisord.conf.dev +++ b/bootstrap/supervisord.conf.dev @@ -3,10 +3,29 @@ nodaemon=true pidfile=/tmp/supervisord.pid logfile=/tmp/supervisord.log +[unix_http_server] +file = /run/supervisor.sock +# complains about no authentication on startup, but it's 0700 root:root by default + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + [program:service] directory=/service -command=sh -c 'python3 -m flask db upgrade && WERKZEUG_DEBUG_PIN=off ENABLE_SERVICES=true python3 -m flask --debug run --host=0.0.0.0 --port=80' +command=sh -c 'WERKZEUG_DEBUG_PIN=off ENABLE_SERVICES=true python3 -m flask --debug run --host=0.0.0.0 --port=80' stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 + +[program:cron] +directory=/service +command=sh -c 'python3 -m flask cron' +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[eventlistener:memmon] +command=memmon -p cron=300MB +events=TICK_60 diff --git a/bootstrap/supervisord.conf.release b/bootstrap/supervisord.conf.release index 30d978f67..ab3843d6f 100644 --- a/bootstrap/supervisord.conf.release +++ b/bootstrap/supervisord.conf.release @@ -3,10 +3,29 @@ nodaemon=true pidfile=/tmp/supervisord.pid logfile=/tmp/supervisord.log +[unix_http_server] +file = /run/supervisor.sock +# complains about no authentication on startup, but it's 0700 root:root by default + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + [program:service] directory=/service -command=sh -c 'python3 -m flask db upgrade && ENABLE_SERVICES=true gunicorn files.__main__:app -k gevent -w ${CORE_OVERRIDE:-$(( `nproc` * 2 ))} --reload -b 0.0.0.0:80 --max-requests 1000 --max-requests-jitter 500' +command=sh -c 'ENABLE_SERVICES=true gunicorn files.__main__:app -k gevent -w ${CORE_OVERRIDE:-$(( `nproc` * 2 ))} --reload -b 0.0.0.0:80 --max-requests 1000 --max-requests-jitter 500' stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 + +[program:cron] +directory=/service +command=sh -c 'python3 -m flask cron' +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[eventlistener:memmon] +command=memmon -p cron=200MB +events=TICK_60 diff --git a/docker-compose.yml b/docker-compose.yml index 64388e811..91fd884c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: - "6379:6379" postgres: - image: postgres:12.3 + image: postgres:12.14 # command: ["postgres", "-c", "log_statement=all"] # uncomment this if u wanna output all SQL queries to the console volumes: diff --git a/files/__main__.py b/files/__main__.py index 32a7c0ba2..8f22f00c4 100644 --- a/files/__main__.py +++ b/files/__main__.py @@ -1,39 +1,59 @@ +''' +Main entry point for the application. Global state among other things are +stored here. +''' -from pathlib import Path import gevent.monkey + gevent.monkey.patch_all() -from os import environ, path -import secrets -from files.helpers.strings import bool_from_string -from flask import * -from flask_caching import Cache -from flask_limiter import Limiter -from flask_compress import Compress -from flask_mail import Mail + +# ^ special case: in general imports should go +# stdlib - externals - internals, but gevent does monkey patching for stdlib +# functions so we want to monkey patch before importing other things + +import faulthandler +from os import environ +from pathlib import Path + +import flask +import flask_caching +import flask_compress +import flask_limiter +import flask_mail import flask_profiler -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy import * import gevent import redis -import time -from sys import stdout, argv -import faulthandler -import json +from sqlalchemy.engine import Engine, create_engine +from sqlalchemy.orm import scoped_session, sessionmaker +from files.helpers.config.const import Service +from files.helpers.strings import bool_from_string -app = Flask(__name__, template_folder='templates') +# first, let's parse arguments to find out what type of instance this is... + +service:Service = Service.from_argv() + +# ...and then let's create our flask app... + +app = flask.app.Flask(__name__, template_folder='templates') app.url_map.strict_slashes = False app.jinja_env.cache = {} app.jinja_env.auto_reload = True faulthandler.enable() +# ...then check that debug mode was not accidentally enabled... + if bool_from_string(environ.get("ENFORCE_PRODUCTION", True)) and app.debug: raise ValueError("Debug mode is not allowed! If this is a dev environment, please set ENFORCE_PRODUCTION to false") +# ...and then attempt to load a .env file if the environment is not configured... + if environ.get("SITE_ID") is None: from dotenv import load_dotenv - load_dotenv(dotenv_path=Path("env")) + load_dotenv(dotenv_path=Path("bootstrap/site_env")) + load_dotenv(dotenv_path=Path("env"), override=True) + +# ...and let's add the flask profiler if it's enabled... if environ.get("FLASK_PROFILER_ENDPOINT"): app.config["flask_profiler"] = { @@ -52,12 +72,14 @@ if environ.get("FLASK_PROFILER_ENDPOINT"): profiler = flask_profiler.Profiler() profiler.init_app(app) -try: - from easy_profile import EasyProfileMiddleware - from jinja2.utils import internal_code +# ...and then let's install code to unmangle jinja2 stacktraces for easy_profile... +try: import inspect as inspectlib import linecache + + from easy_profile import EasyProfileMiddleware + from jinja2.utils import internal_code def jinja_unmangle_stacktrace(): rewritten_frames = [] @@ -88,122 +110,111 @@ except ModuleNotFoundError: # failed to import, just keep on going pass -app.config["SITE_ID"]=environ.get("SITE_ID").strip() -app.config["SITE_TITLE"]=environ.get("SITE_TITLE").strip() -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -app.config['DATABASE_URL'] = environ.get("DATABASE_URL", "postgresql://postgres@localhost:5432") -app.config['SECRET_KEY'] = environ.get('MASTER_KEY') -app.config["SERVER_NAME"] = environ.get("DOMAIN").strip() -app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 if app.debug else 3153600 -app.config["SESSION_COOKIE_NAME"] = "session_" + environ.get("SITE_ID").strip().lower() -app.config["VERSION"] = "1.0.0" -app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 -app.config["SESSION_COOKIE_SECURE"] = bool(environ.get('SESSION_COOKIE_SECURE', "localhost" not in environ.get("DOMAIN"))) -app.config["SESSION_COOKIE_SAMESITE"] = "Lax" -app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * 365 -app.config["DEFAULT_COLOR"] = environ.get("DEFAULT_COLOR", "ffffff").strip() -app.config["DEFAULT_THEME"] = "TheMotte" -app.config["FORCE_HTTPS"] = 1 -app.config["UserAgent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36" -app.config["HCAPTCHA_SITEKEY"] = environ.get("HCAPTCHA_SITEKEY","").strip() -app.config["HCAPTCHA_SECRET"] = environ.get("HCAPTCHA_SECRET","").strip() -app.config["SPAM_SIMILARITY_THRESHOLD"] = float(environ.get("SPAM_SIMILARITY_THRESHOLD", 0.5)) -app.config["SPAM_URL_SIMILARITY_THRESHOLD"] = float(environ.get("SPAM_URL_SIMILARITY_THRESHOLD", 0.1)) -app.config["SPAM_SIMILAR_COUNT_THRESHOLD"] = int(environ.get("SPAM_SIMILAR_COUNT_THRESHOLD", 10)) -app.config["COMMENT_SPAM_SIMILAR_THRESHOLD"] = float(environ.get("COMMENT_SPAM_SIMILAR_THRESHOLD", 0.5)) -app.config["COMMENT_SPAM_COUNT_THRESHOLD"] = int(environ.get("COMMENT_SPAM_COUNT_THRESHOLD", 10)) -app.config["CACHE_TYPE"] = "RedisCache" -app.config["CACHE_REDIS_URL"] = environ.get("REDIS_URL", "redis://localhost") -app.config['MAIL_SERVER'] = environ.get("MAIL_SERVER", "").strip() -app.config['MAIL_PORT'] = 587 -app.config['MAIL_USE_TLS'] = True -app.config['MAIL_USERNAME'] = environ.get("MAIL_USERNAME", "").strip() -app.config['MAIL_PASSWORD'] = environ.get("MAIL_PASSWORD", "").strip() -app.config['DESCRIPTION'] = environ.get("DESCRIPTION", "DESCRIPTION GOES HERE").strip() -app.config['SETTINGS'] = {} -app.config['SQLALCHEMY_DATABASE_URI'] = app.config['DATABASE_URL'] -app.config['MENTION_LIMIT'] = int(environ.get('MENTION_LIMIT', 100)) -app.config['MULTIMEDIA_EMBEDDING_ENABLED'] = environ.get('MULTIMEDIA_EMBEDDING_ENABLED', "false").lower() == "true" -app.config['RESULTS_PER_PAGE_COMMENTS'] = int(environ.get('RESULTS_PER_PAGE_COMMENTS',50)) -app.config['SCORE_HIDING_TIME_HOURS'] = int(environ.get('SCORE_HIDING_TIME_HOURS')) -app.config['ENABLE_SERVICES'] = bool_from_string(environ.get('ENABLE_SERVICES', False)) +# ...and let's load up app config... -app.config['DBG_VOLUNTEER_PERMISSIVE'] = bool_from_string(environ.get('DBG_VOLUNTEER_PERMISSIVE', False)) -app.config['VOLUNTEER_JANITOR_ENABLE'] = bool_from_string(environ.get('VOLUNTEER_JANITOR_ENABLE', True)) +from files.helpers.config.const import (DEFAULT_THEME, MAX_CONTENT_LENGTH, + PERMANENT_SESSION_LIFETIME, + SESSION_COOKIE_SAMESITE) +from files.helpers.config.environment import * -r=redis.Redis(host=environ.get("REDIS_URL", "redis://localhost"), decode_responses=True, ssl_cert_reqs=None) +app.config.update({ + "SITE_ID": SITE_ID, + "SITE_TITLE": SITE_TITLE, + "SQLALCHEMY_TRACK_MODIFICATIONS": SQLALCHEMY_TRACK_MODIFICATIONS, + "DATABASE_URL": DATABASE_URL, + "SECRET_KEY": SECRET_KEY, + "SERVER_NAME": SERVER_NAME, + "SEND_FILE_MAX_AGE_DEFAULT": 0 if app.debug else 3153600, + "SESSION_COOKIE_NAME": f'session_{SITE_ID.lower()}', + "VERSION": "1.0.0", + "MAX_CONTENT_LENGTH": MAX_CONTENT_LENGTH, + "SESSION_COOKIE_SECURE": SESSION_COOKIE_SECURE, + "SESSION_COOKIE_SAMESITE": SESSION_COOKIE_SAMESITE, + "PERMANENT_SESSION_LIFETIME": PERMANENT_SESSION_LIFETIME, + "DEFAULT_COLOR": DEFAULT_COLOR, + "DEFAULT_THEME": DEFAULT_THEME, + "FORCE_HTTPS": 1, + "UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "HCAPTCHA_SITEKEY": HCAPTCHA_SITEKEY, + "HCAPTCHA_SECRET": HCAPTCHA_SECRET, + "SPAM_SIMILARITY_THRESHOLD": SPAM_SIMILARITY_THRESHOLD, + "SPAM_URL_SIMILARITY_THRESHOLD": SPAM_URL_SIMILARITY_THRESHOLD, + "SPAM_SIMILAR_COUNT_THRESHOLD": SPAM_SIMILAR_COUNT_THRESHOLD, + "COMMENT_SPAM_SIMILAR_THRESHOLD": COMMENT_SPAM_SIMILAR_THRESHOLD, + "COMMENT_SPAM_COUNT_THRESHOLD": COMMENT_SPAM_COUNT_THRESHOLD, + "CACHE_TYPE": "RedisCache", + "CACHE_REDIS_URL": CACHE_REDIS_URL, + "MAIL_SERVER": MAIL_SERVER, + "MAIL_PORT": MAIL_PORT, + "MAIL_USE_TLS": MAIL_USE_TLS, + "DESCRIPTION": DESCRIPTION, + "MAIL_USERNAME": MAIL_USERNAME, + "MAIL_PASSWORD": MAIL_PASSWORD, + "DESCRIPTION": DESCRIPTION, + "SETTINGS": {}, + "SQLALCHEMY_DATABASE_URI": DATABASE_URL, + "MENTION_LIMIT": MENTION_LIMIT, + "MULTIMEDIA_EMBEDDING_ENABLED": MULTIMEDIA_EMBEDDING_ENABLED, + "RESULTS_PER_PAGE_COMMENTS": RESULTS_PER_PAGE_COMMENTS, + "SCORE_HIDING_TIME_HOURS": SCORE_HIDING_TIME_HOURS, + "ENABLE_SERVICES": ENABLE_SERVICES, + "RATE_LIMITER_ENABLED": RATE_LIMITER_ENABLED, + + "DBG_VOLUNTEER_PERMISSIVE": DBG_VOLUNTEER_PERMISSIVE, + "VOLUNTEER_JANITOR_ENABLE": VOLUNTEER_JANITOR_ENABLE, +}) + +# ...and then let's load redis so that... + +r = redis.Redis( + host=CACHE_REDIS_URL, + decode_responses=True, + ssl_cert_reqs=None +) + +# ...we can configure our ratelimiter... def get_remote_addr(): with app.app_context(): return request.headers.get('X-Real-IP', default='127.0.0.1') -app.config['RATE_LIMITER_ENABLED'] = not bool_from_string(environ.get('DBG_LIMITER_DISABLED', False)) -if not app.config['RATE_LIMITER_ENABLED']: +if service.enable_services and not RATE_LIMITER_ENABLED: print("Rate limiter disabled in debug mode!") -limiter = Limiter( + +limiter = flask_limiter.Limiter( key_func=get_remote_addr, app=app, default_limits=["3/second;30/minute;200/hour;1000/day"], application_limits=["10/second;200/minute;5000/hour;10000/day"], - storage_uri=environ.get("REDIS_URL", "redis://localhost"), + storage_uri=CACHE_REDIS_URL, auto_check=False, - enabled=app.config['RATE_LIMITER_ENABLED'], + enabled=RATE_LIMITER_ENABLED, ) -Base = declarative_base() +# ...and then after that we can load the database. -engine = create_engine(app.config['DATABASE_URL']) +engine: Engine = create_engine(DATABASE_URL) +db_session_factory: sessionmaker = sessionmaker( + bind=engine, + autoflush=False, + future=True, +) +db_session: scoped_session = scoped_session(db_session_factory) -db_session = scoped_session(sessionmaker(bind=engine, autoflush=False)) +# now that we've that, let's add the cache, compression, and mail extensions to our app... -cache = Cache(app) -Compress(app) -mail = Mail(app) +cache = flask_caching.Cache(app) +flask_compress.Compress(app) +mail = flask_mail.Mail(app) -@app.before_request -def before_request(): - with open('site_settings.json', 'r') as f: - app.config['SETTINGS'] = json.load(f) +# ...and then import the before and after request handlers if this we will import routes. - if request.host != app.config["SERVER_NAME"]: - return {"error": "Unauthorized host provided."}, 403 +if service.enable_services: + from files.routes.allroutes import * - if not app.config['SETTINGS']['Bots'] and request.headers.get("Authorization"): - abort(403, "Bots are currently not allowed") +# setup is done. let's conditionally import the rest of the routes. - g.agent = request.headers.get("User-Agent") - if not g.agent: - return 'Please use a "User-Agent" header!', 403 - - ua = g.agent.lower() - g.debug = app.debug - g.webview = ('; wv) ' in ua) - g.inferior_browser = ( - 'iphone' in ua or - 'ipad' in ua or - 'ipod' in ua or - 'mac os' in ua or - ' firefox/' in ua) - g.timestamp = int(time.time()) - - limiter.check() - - g.db = db_session() - - -@app.teardown_appcontext -def teardown_request(error): - if hasattr(g, 'db') and g.db: - g.db.close() - stdout.flush() - -@app.after_request -def after_request(response): - response.headers.add("Strict-Transport-Security", "max-age=31536000") - response.headers.add("X-Frame-Options", "deny") - return response - -if "load_chat" in argv: - from files.routes.chat import * -else: +if service == Service.THEMOTTE: from files.routes import * +elif service == Service.CHAT: + from files.routes.chat import * diff --git a/files/assets/css/TheMotte.css b/files/assets/css/TheMotte.css index 4f58f7325..3a4e7e07d 100644 --- a/files/assets/css/TheMotte.css +++ b/files/assets/css/TheMotte.css @@ -174,8 +174,11 @@ div.deleted { div.deleted.banned { background-color: var(--gray) !important; } +div.deleted.removed { + background-color: var(--gray) !important; +} .comment-anchor:target, .unread { - background: #2280B310 !important; + background: #2280B310; } #frontpage .posts .card, #userpage .posts .card, #search .posts .card { border: none; diff --git a/files/assets/css/dramblr.css b/files/assets/css/dramblr.css index e715cdf72..eacc150da 100644 --- a/files/assets/css/dramblr.css +++ b/files/assets/css/dramblr.css @@ -13,6 +13,7 @@ --gray-800: #1e293b; --gray-900: #0f172a; --background: #2d3c50; + --white: #fff; } body, .container.transparent, .card { @@ -141,3 +142,11 @@ color: var(--gray-700); #frontpage .post-title a:visited, .visited { color: #6e6e6e !important; } + +.custom-switch .custom-control-label::after { + background-color: var(--white); +} + +.bg-white { + background-color: var(--background) !important; +} diff --git a/files/assets/css/main.css b/files/assets/css/main.css index c4a29e5dd..3039c0079 100644 --- a/files/assets/css/main.css +++ b/files/assets/css/main.css @@ -108,7 +108,6 @@ pre, code, kbd, samp { pre { margin-top: 0; margin-bottom: 1rem; - overflow: auto; } figure { margin: 0 0 1rem; @@ -2786,6 +2785,15 @@ ol > li::before { #logo { margin-left: 0.5rem; } +.logo-text { + font-size: 1.5rem; + font-weight: 700; + color: var(--black) +} +.logo-text:hover { + text-decoration: none; + color: var(--black) +} .navbar-brand, .navbar-light .navbar-brand { color: var(--primary); font-weight: 600; @@ -4337,6 +4345,9 @@ div.deleted { div.deleted.banned { background-color: #964000 !important; } +div.deleted.removed { + background-color: #964000 !important; +} .text-admin { color: var(--primary); } @@ -4922,7 +4933,7 @@ html { display: block; } .comment-anchor:target, .unread { - background: #ffffff22 !important; + background: #ffffff22; padding: 12px; padding-bottom: 4px; } @@ -5123,7 +5134,7 @@ th, td { } #account-menu-header, #account-menu { - min-width: 10em; + min-width: 13em; } .navigation-secondary-link { @@ -5171,6 +5182,12 @@ div[id^="reply-edit-"] li > p:first-child { display: inline; } +/* settings, etc */ +.bordered-section { + border: 1px black solid; + padding: 1em; +} + /*********************** Volunteer Teaser @@ -5319,3 +5336,45 @@ div[id^="reply-edit-"] li > p:first-child { .volunteer_janitor .choices { margin-top: 1rem; } + +.volunteer_janitor_result { + border-radius: 1rem; + padding: 0.2rem; + color: var(--primary-dark1); +} + +.volunteer_janitor_result_notbad_0 { + background-color: hsl(240, 100%, 99%); +} + +.volunteer_janitor_result_notbad_1 { + background-color: hsl(240, 100%, 98%); +} + +.volunteer_janitor_result_notbad_2 { + background-color: hsl(240, 100%, 94%); +} + +.volunteer_janitor_result_notbad_3 { + background-color: hsl(240, 100%, 90%); +} + +.volunteer_janitor_result_bad_0 { + background-color: hsl(0, 100%, 98%); +} + +.volunteer_janitor_result_bad_1 { + background-color: hsl(0, 100%, 94%); +} + +.volunteer_janitor_result_bad_2 { + background-color: hsl(0, 100%, 88%); +} + +.volunteer_janitor_result_bad_3 { + background-color: hsl(0, 100%, 80%); +} + +pre { + line-height: .5rem; +} diff --git a/files/assets/css/transparent.css b/files/assets/css/transparent.css deleted file mode 100644 index cc2b09f31..000000000 --- a/files/assets/css/transparent.css +++ /dev/null @@ -1,76 +0,0 @@ -@charset "UTF-8"; - -:root { - --dark: #383838; - --secondary: #383838; - --white: #E1E1E1; - --black: #CFCFCF; - --light: transparent; - --muted: #E1E1E1; - --gray: #383838; - --gray-100: #E1E1E1; - --gray-200: #E1E1E1; - --gray-300: #383838; - --gray-400: #303030; - --gray-500: #21262d; - --gray-600: transparent; - --gray-700: transparent; - --gray-800: transparent; - --gray-900: transparent; - --background: #21262d; -} - - - -* { - border-color: var(--primary); -} - -.border { - border-color: var(--primary) !important; -} - -.form-control { - border-color: var(--primary) !important; -} - -.btn { - border-color: var(--primary) !important; -} - -.form-control:disabled, .form-control[readonly] { - border-color: var(--primary) !important; -} - -.btn-success { - border-color: #38A169 !important; -} - -.btn-danger { - border-color: #E53E3E !important; -} - -pre { - color: #CFCFCF; -} - -.container { - background: rgba(28, 34, 41, 0.90) !important; - border-radius: 0 !important; -} - -.dropdown-menu { - background-color: var(--gray-500); -} - -.form-inline.search .form-control:active, .form-inline.search .form-control:focus { - background-color: var(--gray-500); -} - -.form-control:focus, .form-control:active { - background-color: var(--gray-500); -} - -#frontpage .post-title a:visited, .visited { - color: #7a7a7a !important; -} diff --git a/files/assets/images/backgrounds/space/1.webp b/files/assets/images/backgrounds/space/1.webp deleted file mode 100644 index 13e8ba834..000000000 Binary files a/files/assets/images/backgrounds/space/1.webp and /dev/null differ diff --git a/files/assets/js/comments.js b/files/assets/js/comments.js index 966f2c02b..4e7c0a903 100644 --- a/files/assets/js/comments.js +++ b/files/assets/js/comments.js @@ -56,4 +56,22 @@ function expandMarkdown(t,id) { let val = t.getElementsByTagName('span')[0] if (val.innerHTML == 'View source') val.innerHTML = 'Hide source' else val.innerHTML = 'View source' -}; \ No newline at end of file +}; + +function commentsAddUnreadIndicator(commentIds) { + commentIds.forEach(element => { + const commentOnly = document.getElementById(`comment-${element}-only`); + if (!commentOnly) { + console.warn(`Couldn't find comment (comment ID ${element}) in page while attempting to add an unread indicator.`); + return; + } + if (commentOnly.classList.contains("unread")) return; + commentOnly.classList.add("unread"); + const commentUserInfo = document.getElementById(`comment-${element}`)?.querySelector(".comment-user-info"); + if (!commentUserInfo) { + console.warn(`Couldn't find comment user info (comment ID ${element}) in page while attempting to add an unread indicator.`); + return; + } + commentUserInfo.innerHTML += "~new~"; + }); +} diff --git a/files/assets/js/comments_admin.js b/files/assets/js/comments_admin.js index 2ece24db8..0f20ce187 100644 --- a/files/assets/js/comments_admin.js +++ b/files/assets/js/comments_admin.js @@ -1,74 +1,23 @@ -function removeComment(post_id,button1,button2) { - url="/ban_comment/"+post_id - - post(url) - - try { - document.getElementById("comment-"+post_id+"-only").classList.add("banned"); - } catch(e) { - document.getElementById("context").classList.add("banned"); +function moderate(isPost, id, removing, removeButtonDesktopId, removeButtonMobileId, approveButtonDesktopId, approveButtonMobileId) { + const filterState = removing ? "removed" : "normal"; + if (isPost) { + filter_new_status(id, filterState); + } else { + filter_new_comment_status(id, filterState); } - - var button=document.getElementById("remove-"+post_id); - button.onclick=function(){approveComment(post_id)}; - button.innerHTML='Approve' - - if (typeof button1 !== 'undefined') { - document.getElementById(button1).classList.toggle("d-md-inline-block"); - document.getElementById(button2).classList.toggle("d-md-inline-block"); - } -}; - -function approveComment(post_id,button1,button2) { - url="/unban_comment/"+post_id - - post(url) - - try { - document.getElementById("comment-"+post_id+"-only").classList.remove("banned"); - } catch(e) { - document.getElementById("context").classList.remove("banned"); - } - - var button=document.getElementById("remove-"+post_id); - button.onclick=function(){removeComment(post_id)}; - button.innerHTML='Remove' - - if (typeof button1 !== 'undefined') { - document.getElementById(button1).classList.toggle("d-md-inline-block"); - document.getElementById(button2).classList.toggle("d-md-inline-block"); - } -} - - -function removeComment2(post_id,button1,button2) { - url="/ban_comment/"+post_id - - post(url) - - document.getElementById("comment-"+post_id+"-only").classList.add("banned"); - var button=document.getElementById("remove-"+post_id); - button.onclick=function(){approveComment(post_id)}; - button.innerHTML='Approve' - - if (typeof button1 !== 'undefined') { - document.getElementById(button1).classList.toggle("d-none"); - document.getElementById(button2).classList.toggle("d-none"); - } -}; - -function approveComment2(post_id,button1,button2) { - url="/unban_comment/"+post_id - - post(url) - - document.getElementById("comment-"+post_id+"-only").classList.remove("banned"); - var button=document.getElementById("remove-"+post_id); - button.onclick=function(){removeComment(post_id)}; - button.innerHTML='Remove' - - if (typeof button1 !== 'undefined') { - document.getElementById(button1).classList.toggle("d-none"); - document.getElementById(button2).classList.toggle("d-none"); + const removeButtonDesktop = document.getElementById(removeButtonDesktopId); + const removeButtonMobile = document.getElementById(removeButtonMobileId); + const approveButtonDesktop = document.getElementById(approveButtonDesktopId); + const approveButtonMobile = document.getElementById(approveButtonMobileId); + if (removing) { + removeButtonDesktop.classList.add("d-none"); + removeButtonMobile.classList.add("d-none"); + approveButtonDesktop.classList.remove("d-none"); + approveButtonMobile.classList.remove("d-none"); + } else { + removeButtonDesktop.classList.remove("d-none"); + removeButtonMobile.classList.remove("d-none"); + approveButtonDesktop.classList.add("d-none"); + approveButtonMobile.classList.add("d-none"); } } diff --git a/files/assets/js/comments_v.js b/files/assets/js/comments_v.js index 0ceb03dab..5e11bdcbe 100644 --- a/files/assets/js/comments_v.js +++ b/files/assets/js/comments_v.js @@ -290,9 +290,9 @@ function post_comment(fullname,id,level = 1){ let comments = document.getElementById('replies-of-' + id); let comment = data["comment"].replace(/data-src/g, 'src').replace(/data-cfsrc/g, 'src').replace(/style="display:none;visibility:hidden;"/g, ''); - comments.innerHTML = comment + comments.innerHTML; + comments.insertAdjacentHTML('afterbegin', comment); - bs_trigger(commentForm); + bs_trigger(comments); // remove the placeholder if it exists let placeholder = document.getElementById("placeholder-comment"); diff --git a/files/assets/js/gif_modal.js b/files/assets/js/gif_modal.js deleted file mode 100644 index 94746bf09..000000000 --- a/files/assets/js/gif_modal.js +++ /dev/null @@ -1,85 +0,0 @@ -let commentFormID; - -function commentForm(form) { - commentFormID = form; -}; - -async function getGif(searchTerm) { - - if (searchTerm !== undefined) { - document.getElementById('gifSearch').value = searchTerm; - } - else { - document.getElementById('gifSearch').value = null; - } - - var loadGIFs = document.getElementById('gifs-load-more'); - - var noGIFs = document.getElementById('no-gifs-found'); - - var container = document.getElementById('GIFs'); - - var backBtn = document.getElementById('gifs-back-btn'); - - var cancelBtn = document.getElementById('gifs-cancel-btn'); - - container.innerHTML = ''; - - if (searchTerm == undefined) { - container.innerHTML = '
Agree
Laugh
Confused
Sad
Happy
Awesome
Yes
No
Love
Please
Scared
Angry
Awkward
Cringe
OMG
Why
Gross
Meh
' - - backBtn.innerHTML = null; - - cancelBtn.innerHTML = null; - - noGIFs.innerHTML = null; - - loadGIFs.innerHTML = null; - } - else { - backBtn.innerHTML = ''; - - cancelBtn.innerHTML = ''; - - let response = await fetch("/giphy?searchTerm=" + searchTerm + "&limit=48"); - let data = await response.json() - var max = data.data?.length === undefined ? 0 : data.data.length - 1 - data = data.data - var gifURL = []; - - if (max <= 0) { - noGIFs.innerHTML = '

Aw shucks. No GIFs found...

'; - container.innerHTML = null; - loadGIFs.innerHTML = null; - } - else { - for (var i = 0; i < 48; i++) { - gifURL[i] = "https://media.giphy.com/media/" + data[i].id + "/giphy.webp"; - if (data[i].username==''){ - container.innerHTML += ('
'); - } - else { - container.innerHTML += ('
'); - } - noGIFs.innerHTML = null; - loadGIFs.innerHTML = '

Thou've reached the end of the list!

'; - } - } - } -} - -function insertGIF(url,form) { - - // https://github.com/themotte/rDrama/issues/139 - // when MULTIMEDIA_EMBEDDING_ENABLED == False, we want to insert an anchor, NOT an img - //var gif = "\n\n![](" + url +")"; - var gif = '\n\n[' + url + '](' + url + ')'; - - var commentBox = document.getElementById(form); - - var old = commentBox.value; - - commentBox.value = old + gif; - - if (typeof checkForRequired === "function") checkForRequired(); -} diff --git a/files/assets/js/submit.js b/files/assets/js/submit.js index 52f5250b4..f67a756b9 100644 --- a/files/assets/js/submit.js +++ b/files/assets/js/submit.js @@ -94,8 +94,6 @@ function savetext() { localStorage.setItem("post_title", document.getElementById('post-title').value) localStorage.setItem("post_text", document.getElementById('post-text').value) localStorage.setItem("post_url", document.getElementById('post-url').value) - let sub = document.getElementById('sub') - if (sub) localStorage.setItem("sub", sub.value) } diff --git a/files/classes/__init__.py b/files/classes/__init__.py index 2225bfd15..b4f3ef007 100644 --- a/files/classes/__init__.py +++ b/files/classes/__init__.py @@ -35,7 +35,6 @@ # First the import * from places which don't go circular from sqlalchemy import * -from flask import * # Then everything except what's in files.* import pyotp @@ -44,19 +43,14 @@ import re import time from copy import deepcopy from datetime import datetime -from flask import g -from flask import render_template +from flask import g, render_template from json import loads from math import floor -from os import environ -from os import environ, remove, path +from os import remove, path from random import randint from secrets import token_hex -from sqlalchemy.orm import deferred, aliased -from sqlalchemy.orm import relationship -from sqlalchemy.orm import relationship, deferred +from sqlalchemy.orm import aliased, deferred, relationship from urllib.parse import urlencode, urlparse, parse_qs -from urllib.parse import urlparse # It is now safe to define the models from .alts import Alt @@ -65,16 +59,12 @@ from .badges import BadgeDef, Badge from .clients import OauthApp, ClientAuth from .comment import Comment from .domains import BannedDomain -from .exiles import Exile from .flags import Flag, CommentFlag from .follows import Follow from .marsey import Marsey -from .mod import Mod from .mod_logs import ModAction from .notifications import Notification from .saves import SaveRelationship, CommentSaveRelationship -from .sub import Sub -from .sub_block import SubBlock from .submission import Submission from .subscriptions import Subscription from .user import User @@ -83,13 +73,15 @@ from .usernotes import UserTag, UserNote from .views import ViewerRelationship from .votes import Vote, CommentVote from .volunteer_janitor import VolunteerJanitorRecord +from .cron.tasks import RepeatableTask +from .cron.submission import ScheduledSubmissionTask +from .cron.pycallable import PythonCodeTask # Then the import * from files.* -from files.helpers.const import * +from files.helpers.config.const import * from files.helpers.media import * -from files.helpers.lazy import * +from files.helpers.lazy import lazy from files.helpers.security import * # Then the specific stuff we don't want stomped on -from files.helpers.lazy import lazy -from files.__main__ import Base, app, cache +from files.classes.base import Base, CreatedBase diff --git a/files/classes/alts.py b/files/classes/alts.py index d84a45403..ee02dd229 100644 --- a/files/classes/alts.py +++ b/files/classes/alts.py @@ -1,5 +1,5 @@ from sqlalchemy import * -from files.__main__ import Base +from files.classes.base import Base class Alt(Base): @@ -12,5 +12,4 @@ class Alt(Base): Index('alts_user2_idx', user2) def __repr__(self): - - return f"" + return f"<{self.__class__.__name__}(id={self.id})>" diff --git a/files/classes/award.py b/files/classes/award.py index b3c9a660e..4538d91f0 100644 --- a/files/classes/award.py +++ b/files/classes/award.py @@ -1,9 +1,8 @@ from sqlalchemy import * from sqlalchemy.orm import relationship -from files.__main__ import Base -from os import environ +from files.classes.base import Base +from files.helpers.config.const import AWARDS from files.helpers.lazy import lazy -from files.helpers.const import * class AwardRelationship(Base): diff --git a/files/classes/badges.py b/files/classes/badges.py index 41c76d3dc..7befde335 100644 --- a/files/classes/badges.py +++ b/files/classes/badges.py @@ -1,12 +1,9 @@ from sqlalchemy import * from sqlalchemy.orm import relationship -from files.__main__ import Base, app -from os import environ +from files.classes.base import Base from files.helpers.lazy import lazy -from files.helpers.const import * +from files.helpers.config.const import * from files.helpers.assetcache import assetcache_path -from datetime import datetime -from json import loads class BadgeDef(Base): __tablename__ = "badge_defs" diff --git a/files/classes/base.py b/files/classes/base.py new file mode 100644 index 000000000..edf4a7375 --- /dev/null +++ b/files/classes/base.py @@ -0,0 +1,128 @@ +import time +from datetime import datetime, timedelta, timezone + +from sqlalchemy import text +from sqlalchemy.orm import declarative_base, declared_attr +from sqlalchemy.schema import Column +from sqlalchemy.sql.functions import now +from sqlalchemy.sql.sqltypes import Integer, DateTime + +from files.helpers.time import format_age, format_datetime + +Base = declarative_base() + + +class CreatedBase(Base): + __abstract__ = True + + def __init__(self, *args, **kwargs): + if "created_utc" not in kwargs: + kwargs["created_utc"] = int(time.time()) + super().__init__(*args, **kwargs) + + @declared_attr + def created_utc(self): + return Column(Integer, nullable=False) + + @property + def created_date(self) -> str: + return self.created_datetime + + @property + def created_datetime(self) -> str: + return format_datetime(self.created_utc) + + @property + def created_datetime_py(self) -> datetime: + return datetime.fromtimestamp(self.created_utc, tz=timezone.utc) + + @property + def age_seconds(self) -> int: + return time.time() - self.created_utc + + @property + def age_timedelta(self) -> timedelta: + return datetime.now(tz=timezone.utc) - self.created_datetime_py + + @property + def age_string(self) -> str: + return format_age(self.created_utc) + + +class CreatedDateTimeBase(Base): + """ + An abstract class extending our default SQLAlchemy's `Base`. + + All classes inherit from this class automatically maps a `created_datetimez` column + for the corresponding SQL table. This column will automatically record the created + timestamp of rows. Retrieving `created_datetimez` will return a `datetime` object with + `tzinfo` of UTC. + + This class holds various convenience properties to get `created_datetimez` in different + formats. + """ + __abstract__ = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @declared_attr + def created_datetimez(self): + """ + Declare default column for classes/tables inheriting `CreatedDateTimeBase`. + + Retrieving `created_datetimez` will return a `datetime` object with `tzinfo` for UTC. + """ + return Column(DateTime(timezone=True), nullable=False, server_default=now()) + + @property + def created_utc(self): + """ + the created date in UTC seconds. Milliseconds are truncated/rounded down. + """ + return int(self.created_datetimez.timestamp()) + + @property + def created_date(self) -> str: + """ + the created date in string. + See `file.helpers.time.DATETIME_FORMAT` for the exact format + Note: should this be using `format_date` and not `format_datetime`? + """ + return self.created_datetime + + @property + def created_datetime(self) -> str: + """ + the created datetime in string. + See `file.helpers.time.DATETIME_FORMAT` for the exact format. + """ + return format_datetime(self.created_datetimez) + + @property + def created_datetime_py(self) -> datetime: + """ + the created datetime as a `datetime` object with `tzinfo` of UTC. + """ + return self.created_datetimez + + @property + def age_seconds(self) -> int: + """ + number of seconds since created. + """ + return time.time() - self.created_utc + + @property + def age_timedelta(self) -> timedelta: + """ + a `timedelta` object representing time since created. + """ + return datetime.now(tz=timezone.utc) - self.created_datetimez + + @property + def age_string(self) -> str: + """ + a string representing time since created. Example: "1h ago", "2d ago". + """ + return format_age(self.created_datetimez) diff --git a/files/classes/clients.py b/files/classes/clients.py index 731c4b785..6fe3a2b2b 100644 --- a/files/classes/clients.py +++ b/files/classes/clients.py @@ -1,12 +1,11 @@ -from flask import * +from flask import g from sqlalchemy import * from sqlalchemy.orm import relationship from .submission import Submission from .comment import Comment -from files.__main__ import Base +from files.classes.base import Base from files.helpers.lazy import lazy -from files.helpers.const import * -import time +from files.helpers.config.const import * class OauthApp(Base): @@ -24,18 +23,8 @@ class OauthApp(Base): author = relationship("User", viewonly=True) - def __repr__(self): return f"" - - - @property - @lazy - def created_date(self): - return time.strftime("%d %B %Y", time.gmtime(self.created_utc)) - - @property - @lazy - def created_datetime(self): - return time.strftime("%d/%B/%Y %H:%M:%S UTC", time.gmtime(self.created_utc)) + def __repr__(self): + return f"<{self.__class__.__name__}(id={self.id})>" @property @lazy @@ -43,30 +32,20 @@ class OauthApp(Base): @lazy def idlist(self, page=1): - posts = g.db.query(Submission.id).filter_by(app_id=self.id) - posts=posts.order_by(Submission.created_utc.desc()) - posts=posts.offset(100*(page-1)).limit(101) - return [x[0] for x in posts.all()] @lazy def comments_idlist(self, page=1): - posts = g.db.query(Comment.id).filter_by(app_id=self.id) - posts=posts.order_by(Comment.created_utc.desc()) - posts=posts.offset(100*(page-1)).limit(101) - return [x[0] for x in posts.all()] - class ClientAuth(Base): - __tablename__ = "client_auths" __table_args__ = ( UniqueConstraint('access_token', name='unique_access'), @@ -78,13 +57,3 @@ class ClientAuth(Base): user = relationship("User", viewonly=True) application = relationship("OauthApp", viewonly=True) - - @property - @lazy - def created_date(self): - return time.strftime("%d %B %Y", time.gmtime(self.created_utc)) - - @property - @lazy - def created_datetime(self): - return time.strftime("%d/%B/%Y %H:%M:%S UTC", time.gmtime(self.created_utc)) diff --git a/files/classes/comment.py b/files/classes/comment.py index 5ff6716e2..3e6e88082 100644 --- a/files/classes/comment.py +++ b/files/classes/comment.py @@ -1,34 +1,36 @@ -from os import environ -import re -import time -from urllib.parse import urlencode, urlparse, parse_qs -from flask import * +import math +from typing import TYPE_CHECKING, Literal, Optional +from urllib.parse import parse_qs, urlencode, urlparse + +from flask import g from sqlalchemy import * from sqlalchemy.orm import relationship -from files.__main__ import Base, app -from files.classes.votes import CommentVote -from files.helpers.const import * + +from files.classes.base import CreatedBase +from files.classes.visstate import StateMod, StateReport, VisibilityState +from files.helpers.config.const import * +from files.helpers.config.environment import SCORE_HIDING_TIME_HOURS, SITE_FULL +from files.helpers.content import (body_displayed, + execute_shadowbanned_fake_votes) from files.helpers.lazy import lazy -from .flags import CommentFlag -from random import randint -from .votes import CommentVote -from math import floor +from files.helpers.math import clamp +from files.helpers.time import format_age -class Comment(Base): +if TYPE_CHECKING: + from files.classes.user import User +CommentRenderContext = Literal['comments', 'volunteer'] + +class Comment(CreatedBase): __tablename__ = "comments" id = Column(Integer, primary_key=True) author_id = Column(Integer, ForeignKey("users.id"), nullable=False) parent_submission = Column(Integer, ForeignKey("submissions.id")) - created_utc = Column(Integer, nullable=False) edited_utc = Column(Integer, default=0, nullable=False) - is_banned = Column(Boolean, default=False, nullable=False) ghost = Column(Boolean, default=False, nullable=False) bannedfor = Column(Boolean) distinguish_level = Column(Integer, default=0, nullable=False) - deleted_utc = Column(Integer, default=0, nullable=False) - is_approved = Column(Integer, ForeignKey("users.id")) level = Column(Integer, default=1, nullable=False) parent_comment_id = Column(Integer, ForeignKey("comments.id")) top_comment_id = Column(Integer) @@ -44,13 +46,17 @@ class Comment(Base): descendant_count = Column(Integer, default=0, nullable=False) body = Column(Text) body_html = Column(Text, nullable=False) - ban_reason = Column(String) - filter_state = Column(String, nullable=False) + volunteer_janitor_badness = Column(Float, default=0.5, nullable=False) + + # Visibility states here + state_user_deleted_utc = Column(DateTime(timezone=True), nullable=True) # null if it hasn't been deleted by the user + state_mod = Column(Enum(StateMod), default=StateMod.FILTERED, nullable=False) # default to Filtered just to partially neuter possible exploits + state_mod_set_by = Column(String, nullable=True) # This should *really* be a User.id, but I don't want to mess with the required refactoring at the moment - it's extra hard because it could potentially be a lot of extra either data or queries + state_report = Column(Enum(StateReport), default=StateReport.UNREPORTED, nullable=False) Index('comment_parent_index', parent_comment_id) Index('comment_post_id_index', parent_submission) Index('comments_user_index', author_id) - Index('fki_comment_approver_fkey', is_approved) Index('fki_comment_sentto_fkey', sentto) oauth_app = relationship("OauthApp", viewonly=True) @@ -65,36 +71,50 @@ class Comment(Base): viewonly=True) reports = relationship("CommentFlag", primaryjoin="CommentFlag.comment_id == Comment.id", - order_by="CommentFlag.created_utc", + order_by="CommentFlag.created_datetimez", viewonly=True) notes = relationship("UserNote", back_populates="comment") - - def __init__(self, *args, **kwargs): - if "created_utc" not in kwargs: - kwargs["created_utc"] = int(time.time()) - if 'filter_state' not in kwargs: - kwargs['filter_state'] = 'normal' - super().__init__(*args, **kwargs) def __repr__(self): return f"<{self.__class__.__name__}(id={self.id})>" @property @lazy - def should_hide_score(self): - comment_age_seconds = int(time.time()) - self.created_utc - comment_age_hours = comment_age_seconds / (60*60) - return comment_age_hours < app.config['SCORE_HIDING_TIME_HOURS'] + def should_hide_score(self) -> bool: + comment_age_hours = self.age_seconds / (60*60) + return comment_age_hours < SCORE_HIDING_TIME_HOURS + + def _score_context_str(self, score_type:Literal['score', 'upvotes', 'downvotes'], + context:CommentRenderContext) -> str: + if self.is_message: return '' # don't show scores for messages + if context == 'volunteer': return '' # volunteer: hide scores + if self.should_hide_score: return '' # hide scores for new comments + + if score_type == 'upvotes': return str(self.upvotes) + if score_type == 'score': return str(self.score) + if score_type == 'downvotes': return str(self.downvotes) + + @lazy + def upvotes_str(self, context:CommentRenderContext) -> str: + return self._score_context_str('upvotes', context) + + @lazy + def score_str(self, context:CommentRenderContext) -> str: + return self._score_context_str('score', context) + + @lazy + def downvotes_str(self, context:CommentRenderContext) -> str: + return self._score_context_str('downvotes', context) @property @lazy - def top_comment(self): + def top_comment(self) -> Optional["Comment"]: return g.db.query(Comment).filter_by(id=self.top_comment_id).one_or_none() @lazy def flags(self, v): flags = self.reports - if not (v and (v.shadowbanned or v.admin_level > 2)): + if not (v and (v.shadowbanned or v.admin_level >= 3)): for flag in flags: if flag.user.shadowbanned: flags.remove(flag) @@ -107,79 +127,9 @@ class Comment(Base): return False @property - @lazy - def created_datetime(self): - return time.strftime("%d/%B/%Y %H:%M:%S UTC", time.gmtime(self.created_utc)) - - @property - @lazy - def age_string(self): - notif_utc = self.__dict__.get("notif_utc") - - if notif_utc: - timestamp = notif_utc - elif self.created_utc: - timestamp = self.created_utc - else: - return None - - age = int(time.time()) - timestamp - - if age < 60: - return "just now" - elif age < 3600: - minutes = int(age / 60) - return f"{minutes}m ago" - elif age < 86400: - hours = int(age / 3600) - return f"{hours}hr ago" - elif age < 2678400: - days = int(age / 86400) - return f"{days}d ago" - - now = time.gmtime() - ctd = time.gmtime(timestamp) - - months = now.tm_mon - ctd.tm_mon + 12 * (now.tm_year - ctd.tm_year) - if now.tm_mday < ctd.tm_mday: - months -= 1 - - if months < 12: - return f"{months}mo ago" - else: - years = int(months / 12) - return f"{years}yr ago" - - @property - @lazy def edited_string(self): - - age = int(time.time()) - self.edited_utc - - if age < 60: - return "just now" - elif age < 3600: - minutes = int(age / 60) - return f"{minutes}m ago" - elif age < 86400: - hours = int(age / 3600) - return f"{hours}hr ago" - elif age < 2678400: - days = int(age / 86400) - return f"{days}d ago" - - now = time.gmtime() - ctd = time.gmtime(self.edited_utc) - - months = now.tm_mon - ctd.tm_mon + 12 * (now.tm_year - ctd.tm_year) - if now.tm_mday < ctd.tm_mday: - months -= 1 - - if months < 12: - return f"{months}mo ago" - else: - years = int(months / 12) - return f"{years}yr ago" + if not self.edited_utc: return "never" + return format_age(self.edited_utc) @property @lazy @@ -189,7 +139,7 @@ class Comment(Base): @property @lazy def fullname(self): - return f"t3_{self.id}" + return f"comment_{self.id}" @property @lazy @@ -201,8 +151,8 @@ class Comment(Base): @property @lazy def parent_fullname(self): - if self.parent_comment_id: return f"t3_{self.parent_comment_id}" - elif self.parent_submission: return f"t2_{self.parent_submission}" + if self.parent_comment_id: return f"comment_{self.parent_comment_id}" + elif self.parent_submission: return f"post_{self.parent_submission}" def replies(self, user): if self.replies2 != None: return [x for x in self.replies2 if not x.author.shadowbanned] @@ -212,13 +162,13 @@ class Comment(Base): if not self.parent_submission: return sorted((x for x in self.child_comments if x.author - and (x.filter_state not in ('filtered', 'removed') or x.author_id == author_id) + and (x.state_mod == StateMod.VISIBLE or x.author_id == author_id) and not x.author.shadowbanned), key=lambda x: x.created_utc) return sorted((x for x in self.child_comments if x.author and not x.author.shadowbanned - and (x.filter_state not in ('filtered', 'removed') or x.author_id == author_id)), + and (x.state_mod == StateMod.VISIBLE or x.author_id == author_id)), key=lambda x: x.created_utc, reverse=True) @property @@ -239,7 +189,10 @@ class Comment(Base): @property @lazy def shortlink(self): - return f"{self.post.shortlink}/{self.id}?context=8#context" + if self.post: + return f"{self.post.shortlink}/{self.id}?context=8#context" + else: + return f"/comment/{self.id}?context=8#context" @property @lazy @@ -272,8 +225,6 @@ class Comment(Base): 'is_bot': self.is_bot, 'created_utc': self.created_utc, 'edited_utc': self.edited_utc or 0, - 'is_banned': bool(self.is_banned), - 'deleted_utc': self.deleted_utc, 'is_nsfw': self.over_18, 'permalink': f'/comment/{self.id}', 'is_pinned': self.is_pinned, @@ -286,9 +237,6 @@ class Comment(Base): 'flags': flags, } - if self.ban_reason: - data["ban_reason"]=self.ban_reason - return data def award_count(self, kind): @@ -298,21 +246,22 @@ class Comment(Base): @property @lazy def json_core(self): - if self.is_banned: - data = {'is_banned': True, - 'ban_reason': self.ban_reason, + if self.state_mod != StateMod.VISIBLE: + data = { + 'state_mod_set_by': self.state_mod_set_by, 'id': self.id, 'post': self.post.id if self.post else 0, 'level': self.level, 'parent': self.parent_fullname - } - elif self.deleted_utc: - data = {'deleted_utc': self.deleted_utc, + } + elif self.state_user_deleted_utc: + data = { + 'state_user_deleted_utc': self.state_user_deleted_utc, 'id': self.id, 'post': self.post.id if self.post else 0, 'level': self.level, 'parent': self.parent_fullname - } + } else: data = self.json_raw if self.level >= 2: data['parent_comment_id']= self.parent_comment_id @@ -325,74 +274,193 @@ class Comment(Base): @lazy def json(self): data = self.json_core - if self.deleted_utc or self.is_banned: return data + if self.state_user_deleted_utc or self.state_mod != StateMod.VISIBLE: return data data["author"] = '👻' if self.ghost else self.author.json_core data["post"] = self.post.json_core if self.post else '' return data def realbody(self, v): - if self.post and self.post.club and not (v and (v.paid_dues or v.id in [self.author_id, self.post.author_id])): return f"

{CC} ONLY

" - - body = self.body_html or "" - - if body: - - if v: - body = body.replace("old.reddit.com", v.reddit) - - if v.nitter and not '/i/' in body and '/retweets' not in body: body = body.replace("www.twitter.com", "nitter.net").replace("twitter.com", "nitter.net") - - if v and v.controversial: - captured = [] - for i in controversial_regex.finditer(body): - if i.group(0) in captured: continue - captured.append(i.group(0)) - - url = i.group(1) - p = urlparse(url).query - p = parse_qs(p) - - if 'sort' not in p: p['sort'] = ['controversial'] - - url_noquery = url.split('?')[0] - body = body.replace(url, f"{url_noquery}?{urlencode(p, True)}") - - if v and v.shadowbanned and v.id == self.author_id and 86400 > time.time() - self.created_utc > 60: - ti = max(int((time.time() - self.created_utc)/60), 1) - maxupvotes = min(ti, 13) - rand = randint(0, maxupvotes) - if self.upvotes < rand: - amount = randint(0, 3) - if amount == 1: - self.upvotes += amount - g.db.add(self) - g.db.commit() + body = body_displayed(self, v, is_html=True) + if v and v.controversial: + captured = [] + for i in controversial_regex.finditer(body): + if i.group(0) in captured: continue + captured.append(i.group(0)) + url = i.group(1) + p = urlparse(url).query + p = parse_qs(p) + if 'sort' not in p: p['sort'] = ['controversial'] + url_noquery = url.split('?')[0] + body = body.replace(url, f"{url_noquery}?{urlencode(p, True)}") + execute_shadowbanned_fake_votes(g.db, self, v) # TODO: put in route handler? return body def plainbody(self, v): - if self.post and self.post.club and not (v and (v.paid_dues or v.id in [self.author_id, self.post.author_id])): return f"

{CC} ONLY

" - body = self.body - if not body: return "" - return body + return body_displayed(self, v, is_html=False) @lazy def collapse_for_user(self, v, path): if v and self.author_id == v.id: return False - if path == '/admin/removed/comments': return False - if self.over_18 and not (v and v.over_18) and not (self.post and self.post.over_18): return True - - if self.is_banned: return True - + # we no longer collapse removed things; the mods want to see them, non-mods see a placeholder anyway if v and v.filter_words and self.body and any(x in self.body for x in v.filter_words): return True - return False @property @lazy - def is_op(self): return self.author_id==self.post.author_id + def is_op(self): + return self.author_id == self.post.author_id + @property @lazy - def active_flags(self, v): return len(self.flags(v)) + def is_comment(self) -> bool: + ''' + Returns whether this is an actual comment (i.e. not a private message) + ''' + return bool(self.parent_submission) + + @property + @lazy + def is_message(self) -> bool: + ''' + Returns whether this is a private message or modmail + ''' + return not self.is_comment + + @property + @lazy + def is_strict_message(self) -> bool: + ''' + Returns whether this is a private message or modmail + but is not a notification + ''' + return self.is_message and not self.is_notification + + @property + @lazy + def is_modmail(self) -> bool: + ''' + Returns whether this is a modmail message + ''' + if not self.is_message: return False + if self.sentto == MODMAIL_ID: return True + + top_comment: Optional["Comment"] = self.top_comment + return bool(top_comment.sentto == MODMAIL_ID) + + @property + @lazy + def is_notification(self) -> bool: + ''' + Returns whether this is a notification + ''' + return self.is_message and not self.sentto + + @lazy + def header_msg(self, v, is_notification_page: bool) -> str: + ''' + Returns a message that is in the header for a comment, usually for + display on a notification page. + ''' + if self.post: + post_html:str = f"{self.post.realtitle(v)}" + if v: + if self.level > 1 and v.id == self.parent_comment.author_id: + text = "Comment Reply" + elif self.level == 1 and v.id == self.post.author_id: + text = "Post Reply" + elif self.parent_submission in v.subscribed_idlist(): + text = "Subscribed Thread" + else: + text = "Username Mention" + if is_notification_page: + return f"{text}: {post_html}" + return post_html + elif self.author_id in {AUTOJANNY_ID, NOTIFICATIONS_ID}: + return "Notification" + elif self.sentto == MODMAIL_ID: + return "Sent to admins" + else: + return f"Sent to @{self.senttouser.username}" + + @lazy + def voted_display(self, v) -> int: + ''' + Returns data used to modify how to show the vote buttons. + + :returns: A number between `-2` and `1`. `-2` is returned if `v` is + `None`. `1` is returned if the user is the comment author. + Otherwise, a value of `-1` (downvote),` 0` (no vote or no data), or `1` + (upvote) is returned. + ''' + if not v: return -2 + if v.id == self.author_id: return 1 + return getattr(self, 'voted', 0) + + def sticky_api_url(self, v) -> str | None: + ''' + Returns the API URL used to sticky this comment. + :returns: Currently `None` always. Stickying comments was disabled + UI-side on TheMotte. + ''' + return None + if not self.is_comment: return None + if not v: return None + if v.admin_level >= 2: + return 'sticky_comment' + elif v.id == self.post.author_id: + return 'pin_comment' + return None + + @lazy + def active_flags(self, v): + return len(self.flags(v)) + + @lazy + def show_descendants(self, v:"User | None") -> bool: + if self.visibility_state.is_visible_to(v, getattr(self, 'is_blocking', False)): + return True + return bool(self.descendant_count) + + @lazy + def visibility_and_message(self, v:"User | None") -> tuple[bool, str]: + ''' + Returns a tuple of whether this content is visible and a publicly + visible message to accompany it. The visibility state machine is + a slight mess but... this should at least unify the state checks. + ''' + return self.visibility_state.visibility_and_message( + v, getattr(self, 'is_blocking', False)) + + @property + def visibility_state(self) -> VisibilityState: + return VisibilityState.from_submittable(self) + + def volunteer_janitor_is_unknown(self): + return self.volunteer_janitor_badness > 0.4 and self.volunteer_janitor_badness < 0.6 + + def volunteer_janitor_is_bad(self): + return self.volunteer_janitor_badness >= 0.6 + + def volunteer_janitor_is_notbad(self): + return self.volunteer_janitor_badness <= 0.4 + + def volunteer_janitor_confidence(self): + unitconfidence = (abs(self.volunteer_janitor_badness - 0.5) * 2) + unitanticonfidence = 1 - unitconfidence + logconfidence = -math.log(unitanticonfidence, 2) + return round(logconfidence * 10) + + def volunteer_janitor_css(self): + if self.volunteer_janitor_is_unknown(): + category = "unknown" + elif self.volunteer_janitor_is_bad(): + category = "bad" + elif self.volunteer_janitor_is_notbad(): + category = "notbad" + + strength = clamp(math.trunc(self.volunteer_janitor_confidence() / 10), 0, 3) + + return f"{category}_{strength}" diff --git a/files/classes/cron/pycallable.py b/files/classes/cron/pycallable.py new file mode 100644 index 000000000..5be567d01 --- /dev/null +++ b/files/classes/cron/pycallable.py @@ -0,0 +1,55 @@ +import importlib +from types import ModuleType +from typing import Callable + +from sqlalchemy.schema import Column, ForeignKey +from sqlalchemy.sql.sqltypes import Integer, String + +from files.classes.cron.tasks import (RepeatableTask, ScheduledTaskType, + TaskRunContext) + +__all__ = ('PythonCodeTask',) + +class PythonCodeTask(RepeatableTask): + ''' + A repeatable task that is naively calls a Python callable. It can access + all of the app state as that is provided to the callee. An example task + that sets the fictional `hams` variable on a `User` to `eggs` is shown + below. + + ```py + from files.classes.user import User + from files.classes.cron.tasks import TaskRunContext + from files.helpers.get import get_account + + def spam_task(ctx:TaskRunContext): + user:Optional[User] = get_account(1784, graceful=True, db=ctx.db) + if not user: raise Exception("User not found!") + user.hams = "eggs" + ctx.db.commit() + ``` + + The `import_path` and `callable` properties are passed to `importlib` + which then imports the module and then calls the callable with the current + task context. + ''' + + __tablename__ = "tasks_repeatable_python" + + __mapper_args__ = { + "polymorphic_identity": int(ScheduledTaskType.PYTHON_CALLABLE), + } + + id = Column(Integer, ForeignKey(RepeatableTask.id), primary_key=True) + import_path = Column(String, nullable=False) + callable = Column(String, nullable=False) + + def run_task(self, ctx:TaskRunContext): + self.get_function()(ctx) + + def get_function(self) -> Callable[[TaskRunContext], None]: + module:ModuleType = importlib.import_module(self.import_path) + fn = getattr(module, self.callable, None) + if not callable(fn): + raise TypeError("Name either does not exist or is not callable") + return fn diff --git a/files/classes/cron/submission.py b/files/classes/cron/submission.py new file mode 100644 index 000000000..89155b35c --- /dev/null +++ b/files/classes/cron/submission.py @@ -0,0 +1,185 @@ +import functools +from datetime import datetime, timezone + +from sqlalchemy.orm import relationship +from sqlalchemy.schema import Column, ForeignKey +from sqlalchemy.sql.sqltypes import Boolean, Integer, String, Text + +from files.classes.cron.tasks import (RepeatableTask, ScheduledTaskType, + TaskRunContext) +from files.classes.submission import Submission +from files.classes.visstate import StateMod, StateReport, VisibilityState +from files.helpers.config.const import SUBMISSION_TITLE_LENGTH_MAXIMUM +from files.helpers.content import body_displayed +from files.helpers.lazy import lazy +from files.helpers.sanitize import filter_emojis_only + +__all__ = ('ScheduledSubmissionTask',) + + +class ScheduledSubmissionTask(RepeatableTask): + __tablename__ = "tasks_repeatable_scheduled_submissions" + + __mapper_args__ = { + "polymorphic_identity": int(ScheduledTaskType.SCHEDULED_SUBMISSION), + } + + id = Column(Integer, ForeignKey(RepeatableTask.id), primary_key=True) + author_id_submission = Column(Integer, ForeignKey("users.id"), nullable=False) + ghost = Column(Boolean, default=False, nullable=False) + private = Column(Boolean, default=False, nullable=False) + over_18 = Column(Boolean, default=False, nullable=False) + is_bot = Column(Boolean, default=False, nullable=False) + title = Column(String(SUBMISSION_TITLE_LENGTH_MAXIMUM), nullable=False) + url = Column(String) + body = Column(Text) + body_html = Column(Text) + flair = Column(String) + embed_url = Column(String) + + author = relationship("User", foreign_keys=author_id_submission) + task = relationship(RepeatableTask) + submissions = relationship(Submission, + back_populates="task", order_by="Submission.id.desc()") + + def run_task(self, ctx:TaskRunContext) -> None: + submission:Submission = self.make_submission(ctx) + with ctx.app_context(): + # TODO: stop using app context (currently required for sanitize and + # username pings) + submission.submit(ctx.db) # TODO: thumbnails + submission.publish() + + def make_submission(self, ctx:TaskRunContext) -> Submission: + title:str = self.make_title(ctx.trigger_time) + title_html:str = filter_emojis_only(title, graceful=True) + if len(title_html) > 1500: raise ValueError("Rendered title too large") + + return Submission( + created_utc=int(ctx.trigger_time.timestamp()), + private=self.private, + author_id=self.author_id_submission, + over_18=self.over_18, + app_id=None, + is_bot =self.is_bot, + title=title, + title_html=title_html, + url=self.url, + body=self.body, + body_html=self.body_html, + flair=self.flair, + ghost=self.ghost, + state_mod=StateMod.VISIBLE, + embed_url=self.embed_url, + task_id=self.id, + ) + + def make_title(self, trigger_time:datetime) -> str: + return trigger_time.strftime(self.title) + + # properties below here are mocked in order to reuse part of the submission + # HTML template for previewing a submitted task + + @property + def state_user_deleted_utc(self) -> datetime | None: + return datetime.now(tz=timezone.utc) if not self.task.enabled else None + + @functools.cached_property + def title_html(self) -> str: + ''' + This is used as a mock property for display in submission listings that + contain scheduled posts. + + .. warning:: + This property should not be used for generating the HTML for an actual + submission as this will be missing the special formatting that may be + applies to titles. Instead call + `ScheduledSubmissionContext.make_title()` with the `datetime` that the + event was triggered at. + ''' + return filter_emojis_only(self.title) + + @property + def author_name(self) -> str: + return self.author.username + + @property + def upvotes(self) -> int: + return 1 + + @property + def score(self) -> int: + return 1 + + @property + def downvotes(self) -> int: + return 0 + + @property + def realupvotes(self) -> int: + return 1 + + @property + def comment_count(self) -> int: + return 0 + + @property + def views(self) -> int: + return 0 + + @property + def state_mod(self) -> StateMod: + return StateMod.VISIBLE + + def award_count(self, kind): + return 0 + + @lazy + def realurl(self, v): + return Submission.realurl(self, v) + + def realbody(self, v): + return body_displayed(self, v, is_html=True) + + def plainbody(self, v): + return body_displayed(self, v, is_html=False) + + @lazy + def realtitle(self, v): + return self.title_html if self.title_html else self.title + + @lazy + def plaintitle(self, v): + return self.title + + @property + def permalink(self): + return f"/tasks/scheduled_posts/{self.id}" + + @property + def shortlink(self): + return self.permalink + + @property + def is_real_submission(self) -> bool: + return False + + @property + def should_hide_score(self) -> bool: + return True + + @property + def edit_url(self) -> str: + return f"/tasks/scheduled_posts/{self.id}/content" + + @property + def visibility_state(self) -> VisibilityState: + return VisibilityState( + state_mod=StateMod.VISIBLE, + state_mod_set_by=None, + state_report=StateReport.UNREPORTED, + deleted=False, # we only want to show deleted UI color if disabled + op_shadowbanned=False, + op_id=self.author_id_submission, + op_name_safe=self.author_name + ) diff --git a/files/classes/cron/tasks.py b/files/classes/cron/tasks.py new file mode 100644 index 000000000..52a45bb6f --- /dev/null +++ b/files/classes/cron/tasks.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +import contextlib +import dataclasses +from datetime import date, datetime, timedelta, timezone +from enum import IntEnum, IntFlag +from typing import TYPE_CHECKING, Final, Optional, Union + +import flask +import flask_caching +import flask_mail +import redis +from sqlalchemy.orm import relationship, Session +from sqlalchemy.schema import Column, ForeignKey +from sqlalchemy.sql.sqltypes import (Boolean, DateTime, Integer, SmallInteger, + Text, Time, String) + +from files.classes.base import CreatedBase +from files.helpers.time import format_age, format_datetime + +if TYPE_CHECKING: + from files.classes.user import User + +class ScheduledTaskType(IntEnum): + PYTHON_CALLABLE = 1 + SCHEDULED_SUBMISSION = 2 + + def __str__(self): + if not self.name: return super().__str__() + return self.name.replace('_', ' ').title() + + +class ScheduledTaskState(IntEnum): + WAITING = 1 + ''' + A task waiting to be triggered + ''' + RUNNING = 2 + ''' + A task that is currently running + ''' + + +class DayOfWeek(IntFlag): + SUNDAY = 1 << 1 + MONDAY = 1 << 2 + TUESDAY = 1 << 3 + WEDNESDAY = 1 << 4 + THURSDAY = 1 << 5 + FRIDAY = 1 << 6 + SATURDAY = 1 << 7 + + WEEKDAYS = MONDAY | TUESDAY | WEDNESDAY | THURSDAY | FRIDAY + WEEKENDS = SATURDAY | SUNDAY + + NONE = 0 << 0 + ALL = WEEKDAYS | WEEKENDS + + @classmethod + @property + def all_days(cls) -> list["DayOfWeek"]: + return [ + cls.SUNDAY, cls.MONDAY, cls.TUESDAY, cls.WEDNESDAY, + cls.THURSDAY, cls.FRIDAY, cls.SATURDAY + ] + + @property + def empty(self) -> bool: + return self not in self.ALL + + def __contains__(self, other:Union[date, "DayOfWeek"]) -> bool: + _days:dict[int, "DayOfWeek"] = { + 0: self.MONDAY, + 1: self.TUESDAY, + 2: self.WEDNESDAY, + 3: self.THURSDAY, + 4: self.FRIDAY, + 5: self.SATURDAY, + 6: self.SUNDAY + } + if not isinstance(other, date): + return super().__contains__(other) + weekday:int = other.weekday() + if not 0 <= weekday <= 6: + raise Exception( + f"Unexpected weekday value (got {weekday}, expected 0-6)") + return _days[weekday] in self + + +_UserConvertible = Union["User", str, int] + +@dataclasses.dataclass(frozen=True, kw_only=True, slots=True) +class TaskRunContext: + ''' + A full task run context, with references to all app globals embedded. + This is the entirety of the application's global state at this point. + + This is explicit state. This is useful so scheduled tasks do not have + to import from `files.__main__` and so they can use all of the features + of the application without being in a request context. + ''' + app:flask.app.Flask + ''' + The application. Many of the app functions use the app context globals and + do not have their state explicitly passed. This is a convenience get out of + jail free card so that most features (excepting those that require a + `request` context can be used.) + ''' + cache:flask_caching.Cache + ''' + A cache extension. This is useful for situations where a scheduled task + might want to interact with the cache in some way (for example invalidating + or adding something to the cache.) + ''' + db:Session + ''' + A database session. Useful for when a task needs to modify something in the + database (for example creating a submission) + ''' + mail:flask_mail.Mail + ''' + The mail extension. Needed for sending emails. + ''' + redis:redis.Redis + ''' + A direct reference to our redis connection. Normally most operations that + involve the redis datastore use flask_caching's Cache object (accessed via + the `cache` property), however this is provided as a convenience for more + granular redis operations. + ''' + task:RepeatableTask + ''' + A reference to the task that is being ran. + ''' + task_run:RepeatableTaskRun + ''' + A reference to this current run of the task. + ''' + trigger_time:datetime + ''' + The date and time (UTC) that this task was triggered + ''' + + @property + def run_time(self) -> datetime: + ''' + The date and time (UTC) that this task was actually ran + ''' + return self.task_run.created_datetime_py + + @contextlib.contextmanager + def app_context(self, *, v:Optional[_UserConvertible]=None): + ''' + Context manager that uses `self.app` to generate an app context and set + up the application with expected globals. This assigns `g.db`, `g.v`, + and `g.debug`. + + This is intended for use with legacy code that does not pass state + explicitly and instead relies on the use of `g` for state passing. If + at all possible, state should be passed explicitly to functions that + require it. + + Usage is simple: + ```py + with ctx.app_context() as app_ctx: + # code that requires g + ``` + + Any code that uses `g` can be ran here. As this is intended for + scenarios that may be outside of a request context code that uses the + request context may still raise `RuntimeException`s. + + An example + + ```py + from flask import g, request # import works ok + + def legacy_function(): + u:Optional[User] = g.db.get(User, 1784) # works ok! :) + u.admin_level = \\ + request.values.get("admin_level", default=9001, type=int) + # raises a RuntimeError :( + g.db.commit() + ``` + + This is because there is no actual request being made. Creating a + mock request context is doable but outside of the scope of this + function as this is often not needed outside of route handlers (where + this function is out of scope anyway). + + :param v: A `User`, an `int`, a `str`, or `None`. `g.v` will be set + using the following rules: + + 1. If `v` is an `int`, `files.helpers.get_account` is called and the + result of that is stored in `g.v`. + + 2. If `v` is an `str`, `files.helpers.get_user` is called and the + result of that is stored in `g.v`. + + 3. If `v` is a `User`, it is stored in `g.v`. + + It is expected that callees will provide a valid user ID or username. + If an invalid one is provided, *no* exception will be raised and `g.v` + will be set to `None`. + + This is mainly provided as an optional feature so that tasks can be + somewhat "sudo"ed as a particular user. Note that `g.v` is always + assigned (even if to `None`) in order to prevent code that depends on + its existence from raising an exception. + ''' + with self.app.app_context() as app_ctx: + app_ctx.g.db = self.db + + from files.helpers.get import get_account, get_user + + if isinstance(v, str): + v = get_user(v, graceful=True) + elif isinstance(v, int): + v = get_account(v, graceful=True, db=self.db) + + app_ctx.g.v = v + app_ctx.g.debug = self.app.debug + yield app_ctx + + @contextlib.contextmanager + def db_transaction(self): + try: + yield + self.db.commit() + except: + self.db.rollback() + + +_TABLE_NAME: Final[str] = "tasks_repeatable" + + +class RepeatableTask(CreatedBase): + __tablename__ = _TABLE_NAME + + id = Column(Integer, primary_key=True, nullable=False) + author_id = Column(Integer, ForeignKey("users.id"), nullable=False) + type_id = Column(SmallInteger, nullable=False) + enabled = Column(Boolean, default=True, nullable=False) + run_state = Column(SmallInteger, default=int(ScheduledTaskState.WAITING), nullable=False) + run_time_last = Column(DateTime, default=None) + + frequency_day = Column(SmallInteger, nullable=False) + time_of_day_utc = Column(Time, nullable=False) + + # used for the cron hardcoding system + label = Column(String, nullable=True, unique=True) + + runs = relationship("RepeatableTaskRun", back_populates="task") + + @property + def type(self) -> ScheduledTaskType: + return ScheduledTaskType(self.type_id) + + @type.setter + def type(self, value:ScheduledTaskType): + self.type_id = value + + @property + def frequency_day_flags(self) -> DayOfWeek: + return DayOfWeek(self.frequency_day) + + @frequency_day_flags.setter + def frequency_day_flags(self, value:DayOfWeek): + self.frequency_day = int(value) + + @property + def run_state_enum(self) -> ScheduledTaskState: + return ScheduledTaskState(self.run_state) + + @run_state_enum.setter + def run_state_enum(self, value:ScheduledTaskState): + self.run_state = int(value) + + @property + def run_time_last_or_created_utc(self) -> datetime: + return self.run_time_last or self.created_datetime_py + + @property + def run_time_last_str(self) -> str: + if not self.run_time_last: return 'Never' + return (f'{format_datetime(self.run_time_last)} ' + f'({format_age(self.run_time_last)})') + + @property + def trigger_time(self) -> datetime | None: + return self.next_trigger(self.run_time_last_or_created_utc) + + def can_run(self, now: datetime) -> bool: + return not ( + self.trigger_time is None + or now < self.trigger_time + or self.run_state_enum != ScheduledTaskState.WAITING) + + def next_trigger(self, anchor: datetime) -> datetime | None: + if not self.enabled: return None + if self.frequency_day_flags.empty: return None + + day:timedelta = timedelta(1.0) + target_date:datetime = anchor - day # incremented at start of for loop + + for i in range(8): + target_date = target_date + day + if i == 0 and target_date.time() > self.time_of_day_utc: continue + if target_date in self.frequency_day_flags: break + else: + raise Exception("Could not find suitable timestamp to run next task") + + return datetime.combine(target_date, self.time_of_day_utc, tzinfo=timezone.utc) # type: ignore + + def run(self, db: Session, trigger_time: datetime) -> RepeatableTaskRun: + run:RepeatableTaskRun = RepeatableTaskRun(task_id=self.id) + try: + from files.__main__ import app, cache, mail, r # i know + ctx: TaskRunContext = TaskRunContext( + app=app, + cache=cache, + db=db, + mail=mail, + redis=r, + task=self, + task_run=run, + trigger_time=trigger_time, + ) + self.run_task(ctx) + except Exception as e: + run.exception = e + run.completed_utc = datetime.now(tz=timezone.utc) + db.add(run) + return run + + def run_task(self, ctx:TaskRunContext): + raise NotImplementedError() + + def contains_day_str(self, day_str:str) -> bool: + return (bool(day_str) + and DayOfWeek[day_str.upper()] in self.frequency_day_flags) + + def __repr__(self) -> str: + return f'<{self.__class__.__name__}(id={self.id}, created_utc={self.created_date}, author_id={self.author_id})>' + + __mapper_args__ = { + "polymorphic_on": type_id, + } + + +class RepeatableTaskRun(CreatedBase): + __tablename__ = "tasks_repeatable_runs" + + id = Column(Integer, primary_key=True) + task_id = Column(Integer, ForeignKey(RepeatableTask.id), nullable=False) + manual = Column(Boolean, default=False, nullable=False) + traceback_str = Column(Text, nullable=True) + + completed_utc = Column(DateTime) + + task = relationship(RepeatableTask, back_populates="runs") + + _exception: Optional[Exception] = None # not part of the db model + + @property + def completed_datetime_py(self) -> datetime | None: + if self.completed_utc is None: + return None + return datetime.combine( + self.completed_utc.date(), + self.completed_utc.time(), + timezone.utc) + + @property + def completed_datetime_str(self) -> str: + return format_datetime(self.completed_utc) + + @property + def status_text(self) -> str: + if not self.completed_utc: return "Running" + return "Failed" if self.traceback_str else "Completed" + + @property + def time_elapsed(self) -> Optional[timedelta]: + if self.completed_datetime_py is None: return None + return self.completed_datetime_py - self.created_datetime_py + + @property + def time_elapsed_str(self) -> str: + elapsed:Optional[timedelta] = self.time_elapsed + if not elapsed: return '' + return str(elapsed) + + @property + def exception(self) -> Optional[Exception]: + return self._exception + + @exception.setter + def exception(self, value: Optional[Exception]) -> None: + self._exception = value + self.traceback_str = str(value) if value else None diff --git a/files/classes/domains.py b/files/classes/domains.py index f9acb7b32..10da5862e 100644 --- a/files/classes/domains.py +++ b/files/classes/domains.py @@ -1,8 +1,7 @@ from sqlalchemy import * -from files.__main__ import Base +from files.classes.base import Base class BannedDomain(Base): - __tablename__ = "banneddomains" domain = Column(String, primary_key=True) reason = Column(String, nullable=False) diff --git a/files/classes/exiles.py b/files/classes/exiles.py deleted file mode 100644 index f02da7133..000000000 --- a/files/classes/exiles.py +++ /dev/null @@ -1,18 +0,0 @@ -from sqlalchemy import * -from sqlalchemy.orm import relationship -from files.__main__ import Base - -class Exile(Base): - - __tablename__ = "exiles" - user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) - sub = Column(String, ForeignKey("subs.name"), primary_key=True) - exiler_id = Column(Integer, ForeignKey("users.id"), nullable=False) - - Index('fki_exile_exiler_fkey', exiler_id) - Index('fki_exile_sub_fkey', sub) - - exiler = relationship("User", primaryjoin="User.id==Exile.exiler_id", viewonly=True) - - def __repr__(self): - return f"<{self.__class__.__name__}(user_id={self.user_id}, sub={self.sub})>" diff --git a/files/classes/flags.py b/files/classes/flags.py index 12804d7a0..3f74c720a 100644 --- a/files/classes/flags.py +++ b/files/classes/flags.py @@ -1,77 +1,46 @@ from sqlalchemy import * from sqlalchemy.orm import relationship -from files.__main__ import Base +from files.classes.base import CreatedBase, CreatedDateTimeBase, Base from files.helpers.lazy import lazy -from files.helpers.const import * -import time +from files.helpers.config.const import * -class Flag(Base): + +class Flag(CreatedBase): __tablename__ = "flags" id = Column(Integer, primary_key=True) post_id = Column(Integer, ForeignKey("submissions.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) reason = Column(String) - created_utc = Column(Integer, nullable=False) Index('flag_user_idx', user_id) user = relationship("User", primaryjoin = "Flag.user_id == User.id", uselist = False, viewonly=True) - def __init__(self, *args, **kwargs): - if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) - super().__init__(*args, **kwargs) - def __repr__(self): return f"<{self.__class__.__name__}(id={self.id})>" - @property - @lazy - def created_date(self): - return time.strftime("%d %B %Y", time.gmtime(self.created_utc)) - - @property - @lazy - def created_datetime(self): - return time.strftime("%d/%B/%Y %H:%M:%S UTC", time.gmtime(self.created_utc)) - @lazy def realreason(self, v): return self.reason -class CommentFlag(Base): - +class CommentFlag(CreatedDateTimeBase): __tablename__ = "commentflags" id = Column(Integer, primary_key=True) comment_id = Column(Integer, ForeignKey("comments.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) reason = Column(String) - created_utc = Column(Integer, nullable=False) Index('cflag_user_idx', user_id) user = relationship("User", primaryjoin = "CommentFlag.user_id == User.id", uselist = False, viewonly=True) - def __init__(self, *args, **kwargs): - if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) - super().__init__(*args, **kwargs) - def __repr__(self): return f"<{self.__class__.__name__}(id={self.id})>" - @property - @lazy - def created_date(self): - return time.strftime("%d %B %Y", time.gmtime(self.created_utc)) - - @property - @lazy - def created_datetime(self): - return time.strftime("%d/%B/%Y %H:%M:%S UTC", time.gmtime(self.created_utc)) - @lazy def realreason(self, v): return self.reason diff --git a/files/classes/follows.py b/files/classes/follows.py index c372adafb..8bffa1e9d 100644 --- a/files/classes/follows.py +++ b/files/classes/follows.py @@ -1,22 +1,19 @@ from sqlalchemy import * from sqlalchemy.orm import relationship -from files.__main__ import Base -import time +from files.classes.base import CreatedBase -class Follow(Base): +class Follow(CreatedBase): __tablename__ = "follows" target_id = Column(Integer, ForeignKey("users.id"), primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) - created_utc = Column(Integer, nullable=False) Index('follow_user_id_index', user_id) user = relationship("User", uselist=False, primaryjoin="User.id==Follow.user_id", viewonly=True) target = relationship("User", primaryjoin="User.id==Follow.target_id", viewonly=True) - def __init__(self, *args, **kwargs): - if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) - super().__init__(*args, **kwargs) - def __repr__(self): - return f"<{self.__class__.__name__}(id={self.id})>" + return ( + f"<{self.__class__.__name__}(" + f"target_id={self.target_id}, user_id={self.user_id})>" + ) diff --git a/files/classes/leaderboard.py b/files/classes/leaderboard.py index 4697d39bf..b505901b8 100644 --- a/files/classes/leaderboard.py +++ b/files/classes/leaderboard.py @@ -2,9 +2,9 @@ from dataclasses import dataclass from typing import Any, Callable, Final, Optional from sqlalchemy import Column, func -from sqlalchemy.orm import scoped_session, Query +from sqlalchemy.orm import Session, Query -from files.helpers.const import LEADERBOARD_LIMIT +from files.helpers.config.const import LEADERBOARD_LIMIT from files.classes.badges import Badge from files.classes.marsey import Marsey @@ -51,9 +51,9 @@ class Leaderboard: raise NotImplementedError() class SimpleLeaderboard(Leaderboard): - def __init__(self, v:User, meta:LeaderboardMeta, db:scoped_session, users_query:Query, column:Column): + def __init__(self, v:User, meta:LeaderboardMeta, db:Session, users_query:Query, column:Column): super().__init__(v, meta) - self.db:scoped_session = db + self.db:Session = db self.users_query:Query = users_query self.column:Column = column self._calculate() @@ -62,7 +62,7 @@ class SimpleLeaderboard(Leaderboard): self._all_users = self.users_query.order_by(self.column.desc()).limit(self.meta.limit).all() if self.v not in self._all_users: sq = self.db.query(User.id, self.column, func.rank().over(order_by=self.column.desc()).label("rank")).subquery() - sq_data = self.db.query(sq.c.id, sq.c.column, sq.c.rank).filter(sq.c.id == self.v.id).limit(1).one() + sq_data = self.db.query(sq.c.id, sq.c[self.column.name], sq.c.rank).filter(sq.c.id == self.v.id).limit(1).one() self._v_value:int = sq_data[1] self._v_position:int = sq_data[2] @@ -92,9 +92,9 @@ class _CountedAndRankedLeaderboard(Leaderboard): return func.rank().over(order_by=func.count(criteria).desc()).label("rank") class BadgeMarseyLeaderboard(_CountedAndRankedLeaderboard): - def __init__(self, v:User, meta:LeaderboardMeta, db:scoped_session, column:Column): + def __init__(self, v:User, meta:LeaderboardMeta, db:Session, column:Column): super().__init__(v, meta) - self.db:scoped_session = db + self.db:Session = db self.column = column self._calculate() @@ -132,12 +132,12 @@ class BadgeMarseyLeaderboard(_CountedAndRankedLeaderboard): @property def value_func(self) -> Callable[[User], int]: - return lambda u:self._all_users[u] + return lambda u: self._all_users[u] class UserBlockLeaderboard(_CountedAndRankedLeaderboard): - def __init__(self, v:User, meta:LeaderboardMeta, db:scoped_session, column:Column): + def __init__(self, v:User, meta:LeaderboardMeta, db:Session, column:Column): super().__init__(v, meta) - self.db:scoped_session = db + self.db:Session = db self.column = column self._calculate() @@ -168,8 +168,12 @@ class UserBlockLeaderboard(_CountedAndRankedLeaderboard): def v_value(self) -> int: return self._v_value + @property + def value_func(self) -> Callable[[User], int]: + return lambda u: self._all_users[u] + class RawSqlLeaderboard(Leaderboard): - def __init__(self, meta:LeaderboardMeta, db:scoped_session, query:str) -> None: # should be LiteralString on py3.11+ + def __init__(self, meta:LeaderboardMeta, db:Session, query:str) -> None: # should be LiteralString on py3.11+ super().__init__(None, meta) self.db = db self._calculate(query) @@ -234,7 +238,7 @@ FROM cv_for_user cvfu ORDER BY count DESC LIMIT 25 """ - def __init__(self, meta:LeaderboardMeta, db:scoped_session) -> None: + def __init__(self, meta:LeaderboardMeta, db:Session) -> None: super().__init__(meta, db, self._query) class GivenUpvotesLeaderboard(RawSqlLeaderboard): @@ -248,5 +252,5 @@ FULL OUTER JOIN (SELECT user_id, COUNT(*) FROM commentvotes WHERE vote_type = 1 ORDER BY count DESC LIMIT 25 """ - def __init__(self, meta:LeaderboardMeta, db:scoped_session) -> None: + def __init__(self, meta:LeaderboardMeta, db:Session) -> None: super().__init__(meta, db, self._query) diff --git a/files/classes/marsey.py b/files/classes/marsey.py index 4aba397f9..06edcc208 100644 --- a/files/classes/marsey.py +++ b/files/classes/marsey.py @@ -1,5 +1,5 @@ from sqlalchemy import * -from files.__main__ import Base +from files.classes.base import Base class Marsey(Base): __tablename__ = "marseys" diff --git a/files/classes/mod.py b/files/classes/mod.py deleted file mode 100644 index fb8812a95..000000000 --- a/files/classes/mod.py +++ /dev/null @@ -1,26 +0,0 @@ -from sqlalchemy import * -from sqlalchemy.orm import relationship -from files.__main__ import Base -from files.helpers.lazy import * -import time - -class Mod(Base): - - __tablename__ = "mods" - user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) - sub = Column(String, ForeignKey("subs.name"), primary_key=True) - created_utc = Column(Integer, nullable=False) - - Index('fki_mod_sub_fkey', sub) - - def __init__(self, *args, **kwargs): - if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) - super().__init__(*args, **kwargs) - - def __repr__(self): - return f"<{self.__class__.__name__}(user_id={self.user_id}, sub={self.sub})>" - - @property - @lazy - def created_datetime(self): - return time.strftime("%d/%B/%Y %H:%M:%S UTC", time.gmtime(self.created_utc)) diff --git a/files/classes/mod_logs.py b/files/classes/mod_logs.py index 278a1a5ed..245bb98fc 100644 --- a/files/classes/mod_logs.py +++ b/files/classes/mod_logs.py @@ -1,14 +1,15 @@ +import logging +from copy import deepcopy + from sqlalchemy import * from sqlalchemy.orm import relationship -from files.__main__ import Base -import time -from files.helpers.lazy import lazy -from os import environ -from copy import deepcopy -from files.helpers.const import * -import logging -class ModAction(Base): +from files.classes.base import CreatedBase +from files.helpers.config.const import * +from files.helpers.lazy import lazy + + +class ModAction(CreatedBase): __tablename__ = "modactions" id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id")) @@ -17,7 +18,6 @@ class ModAction(Base): target_submission_id = Column(Integer, ForeignKey("submissions.id")) target_comment_id = Column(Integer, ForeignKey("comments.id")) _note=Column(String) - created_utc = Column(Integer, nullable=False) Index('fki_modactions_user_fkey', target_user_id) Index('modaction_action_idx', kind) @@ -29,49 +29,12 @@ class ModAction(Base): target_user = relationship("User", primaryjoin="User.id==ModAction.target_user_id", viewonly=True) target_post = relationship("Submission", viewonly=True) - def __init__(self, *args, **kwargs): - if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) - super().__init__(*args, **kwargs) - def __repr__(self): return f"<{self.__class__.__name__}(id={self.id})>" - @property - @lazy - def age_string(self): - - age = int(time.time()) - self.created_utc - - if age < 60: - return "just now" - elif age < 3600: - minutes = int(age / 60) - return f"{minutes}m ago" - elif age < 86400: - hours = int(age / 3600) - return f"{hours}hr ago" - elif age < 2678400: - days = int(age / 86400) - return f"{days}d ago" - - now = time.gmtime() - ctd = time.gmtime(self.created_utc) - - months = now.tm_mon - ctd.tm_mon + 12 * (now.tm_year - ctd.tm_year) - if now.tm_mday < ctd.tm_mday: - months -= 1 - - if months < 12: - return f"{months}mo ago" - else: - years = int(months / 12) - return f"{years}yr ago" - - @property def note(self): - - if self.kind=="ban_user": + if self.kind == "ban_user": if self.target_post: return f'for post' elif self.target_comment_id: return f'for comment' else: return self._note @@ -92,11 +55,9 @@ class ModAction(Base): @property @lazy def string(self): - output = self.lookup_action_type()["str"].format(self=self, cc=CC_TITLE) - - if self.note: output += f" ({self.note})" - + if not self.note: return output + output += f" ({self.note})" return output @property @@ -124,6 +85,26 @@ class ModAction(Base): return f"/log/{self.id}" ACTIONTYPES = { + 'approve_post': { + "str": 'approved post {self.target_link}', + "icon": 'fa-feather-alt', + "color": 'bg-success' + }, + 'approve_comment': { + "str": 'approved {self.target_link}', + "icon": 'fa-comment', + "color": 'bg-success' + }, + 'remove_post': { + "str": 'removed post {self.target_link}', + "icon": 'fa-feather-alt', + "color": 'bg-danger' + }, + 'remove_comment': { + "str": 'removed {self.target_link}', + "icon": 'fa-comment', + "color": 'bg-danger' + }, 'approve_app': { "str": 'approved an application by {self.target_link}', "icon": 'fa-robot', @@ -139,21 +120,11 @@ ACTIONTYPES = { "icon": 'fa-badge', "color": 'bg-danger' }, - 'ban_comment': { - "str": 'removed {self.target_link}', - "icon": 'fa-comment', - "color": 'bg-danger' - }, 'ban_domain': { "str": 'banned a domain', "icon": 'fa-globe', "color": 'bg-danger' }, - 'ban_post': { - "str": 'removed post {self.target_link}', - "icon": 'fa-feather-alt', - "color": 'bg-danger' - }, 'ban_user': { "str": 'banned user {self.target_link}', "icon": 'fa-user-slash', @@ -279,11 +250,6 @@ ACTIONTYPES = { "icon": 'fa-sack-dollar', "color": 'bg-success' }, - 'move_hole': { - "str": 'moved {self.target_link} to /h/{self.target_post.sub}', - "icon": 'fa-manhole', - "color": 'bg-primary' - }, 'nuke_user': { "str": 'removed all content of {self.target_link}', "icon": 'fa-radiation-alt', @@ -344,21 +310,11 @@ ACTIONTYPES = { "icon": 'fa-eye-slash', "color": 'bg-danger' }, - 'unban_comment': { - "str": 'reinstated {self.target_link}', - "icon": 'fa-comment', - "color": 'bg-success' - }, 'unban_domain': { "str": 'unbanned a domain', "icon": 'fa-globe', "color": 'bg-success' }, - 'unban_post': { - "str": 'reinstated post {self.target_link}', - "icon": 'fa-feather-alt', - "color": 'bg-success' - }, 'unban_user': { "str": 'unbanned user {self.target_link}', "icon": 'fa-user', diff --git a/files/classes/notifications.py b/files/classes/notifications.py index a8a190513..b4b87510b 100644 --- a/files/classes/notifications.py +++ b/files/classes/notifications.py @@ -1,16 +1,13 @@ from sqlalchemy import * from sqlalchemy.orm import relationship -from files.__main__ import Base -import time - -class Notification(Base): +from files.classes.base import CreatedBase +class Notification(CreatedBase): __tablename__ = "notifications" user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) comment_id = Column(Integer, ForeignKey("comments.id"), primary_key=True) read = Column(Boolean, default=False, nullable=False) - created_utc = Column(Integer, nullable=False) Index('notification_read_idx', read) Index('notifications_comment_idx', comment_id) @@ -19,9 +16,5 @@ class Notification(Base): comment = relationship("Comment", viewonly=True) user = relationship("User", viewonly=True) - def __init__(self, *args, **kwargs): - if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) - super().__init__(*args, **kwargs) - def __repr__(self): - return f"<{self.__class__.__name__}(id={self.id})>" + return f"<{self.__class__.__name__}(user_id={self.user_id}, comment_id={self.comment_id})>" diff --git a/files/classes/saves.py b/files/classes/saves.py index 1e89898b6..3d753864d 100644 --- a/files/classes/saves.py +++ b/files/classes/saves.py @@ -1,24 +1,21 @@ from sqlalchemy import * from sqlalchemy.orm import relationship -from files.__main__ import Base +from files.classes.base import Base class SaveRelationship(Base): + __tablename__ = "save_relationship" - __tablename__="save_relationship" - - user_id=Column(Integer, ForeignKey("users.id"), primary_key=True) - submission_id=Column(Integer, ForeignKey("submissions.id"), primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + submission_id = Column(Integer, ForeignKey("submissions.id"), primary_key=True) Index('fki_save_relationship_submission_fkey', submission_id) - class CommentSaveRelationship(Base): + __tablename__ = "comment_save_relationship" - __tablename__="comment_save_relationship" - - user_id=Column(Integer, ForeignKey("users.id"), primary_key=True) - comment_id=Column(Integer, ForeignKey("comments.id"), primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + comment_id = Column(Integer, ForeignKey("comments.id"), primary_key=True) Index('fki_comment_save_relationship_comment_fkey', comment_id) diff --git a/files/classes/sub.py b/files/classes/sub.py deleted file mode 100644 index 04780cfda..000000000 --- a/files/classes/sub.py +++ /dev/null @@ -1,50 +0,0 @@ -from sqlalchemy import * -from sqlalchemy.orm import relationship -from files.__main__ import Base -from files.helpers.lazy import lazy -from os import environ -from .sub_block import * - -SITE = environ.get("DOMAIN", '').strip() -if SITE == "localhost": SITE_FULL = 'http://' + SITE -else: SITE_FULL = 'https://' + SITE - -class Sub(Base): - - __tablename__ = "subs" - name = Column(String, primary_key=True) - sidebar = Column(String) - sidebar_html = Column(String) - sidebarurl = Column(String) - bannerurl = Column(String) - css = Column(String) - - Index('subs_idx', name) - - blocks = relationship("SubBlock", lazy="dynamic", primaryjoin="SubBlock.sub==Sub.name", viewonly=True) - - - def __repr__(self): - return f"<{self.__class__.__name__}(name={self.name})>" - - @property - @lazy - def sidebar_url(self): - if self.sidebarurl: return SITE_FULL + self.sidebarurl - return None # Add default sidebar for subs if subs are used again - - @property - @lazy - def banner_url(self): - if self.bannerurl: return SITE_FULL + self.bannerurl - return None # Add default banner for subs if subs are used again - - @property - @lazy - def subscription_num(self): - return self.subscriptions.count() - - @property - @lazy - def block_num(self): - return self.blocks.count() diff --git a/files/classes/sub_block.py b/files/classes/sub_block.py deleted file mode 100644 index 9a41b0664..000000000 --- a/files/classes/sub_block.py +++ /dev/null @@ -1,14 +0,0 @@ -from sqlalchemy import * -from sqlalchemy.orm import relationship -from files.__main__ import Base - -class SubBlock(Base): - - __tablename__ = "sub_blocks" - user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) - sub = Column(String, ForeignKey("subs.name"), primary_key=True) - - Index('fki_sub_blocks_sub_fkey', sub) - - def __repr__(self): - return f"<{self.__class__.__name__}(user_id={self.user_id}, sub={self.sub})>" diff --git a/files/classes/submission.py b/files/classes/submission.py index ca74f594f..1857d3a88 100644 --- a/files/classes/submission.py +++ b/files/classes/submission.py @@ -1,43 +1,39 @@ -from os import environ -import random -import re -import time from urllib.parse import urlparse -from flask import render_template -from sqlalchemy import * -from sqlalchemy.orm import relationship, deferred -from files.__main__ import Base, app -from files.helpers.const import * -from files.helpers.lazy import lazy -from files.helpers.assetcache import assetcache_path -from .flags import Flag -from .comment import Comment -from flask import g -from .sub import Sub -from .votes import CommentVote -class Submission(Base): +from flask import g +from sqlalchemy import * +from sqlalchemy.orm import Session, declared_attr, deferred, relationship + +from files.classes.base import CreatedBase +from files.classes.flags import Flag +from files.classes.visstate import StateMod, StateReport, VisibilityState +from files.classes.votes import Vote +from files.helpers.assetcache import assetcache_path +from files.helpers.config.const import * +from files.helpers.config.environment import (SCORE_HIDING_TIME_HOURS, SITE, + SITE_FULL, SITE_ID) +from files.helpers.content import body_displayed +from files.helpers.lazy import lazy +from files.helpers.time import format_age, format_datetime + + +class Submission(CreatedBase): __tablename__ = "submissions" id = Column(Integer, primary_key=True) author_id = Column(Integer, ForeignKey("users.id"), nullable=False) edited_utc = Column(Integer, default=0, nullable=False) - created_utc = Column(Integer, nullable=False) thumburl = Column(String) - is_banned = Column(Boolean, default=False, nullable=False) bannedfor = Column(Boolean) ghost = Column(Boolean, default=False, nullable=False) views = Column(Integer, default=0, nullable=False) - deleted_utc = Column(Integer, default=0, nullable=False) distinguish_level = Column(Integer, default=0, nullable=False) stickied = Column(String) stickied_utc = Column(Integer) - sub = Column(String, ForeignKey("subs.name")) is_pinned = Column(Boolean, default=False, nullable=False) private = Column(Boolean, default=False, nullable=False) club = Column(Boolean, default=False, nullable=False) comment_count = Column(Integer, default=0, nullable=False) - is_approved = Column(Integer, ForeignKey("users.id")) over_18 = Column(Boolean, default=False, nullable=False) is_bot = Column(Boolean, default=False, nullable=False) upvotes = Column(Integer, default=1, nullable=False) @@ -50,36 +46,83 @@ class Submission(Base): body = Column(Text) body_html = Column(Text) flair = Column(String) - ban_reason = Column(String) embed_url = Column(String) - filter_state = Column(String, nullable=False) + task_id = Column(Integer, ForeignKey("tasks_repeatable_scheduled_submissions.id")) + + # Visibility states here + state_user_deleted_utc = Column(DateTime(timezone=True), nullable=True) # null if it hasn't been deleted by the user + state_mod = Column(Enum(StateMod), default=StateMod.FILTERED, nullable=False) # default to Filtered just to partially neuter possible exploits + state_mod_set_by = Column(String, nullable=True) # This should *really* be a User.id, but I don't want to mess with the required refactoring at the moment - it's extra hard because it could potentially be a lot of extra either data or queries + state_report = Column(Enum(StateReport), default=StateReport.UNREPORTED, nullable=False) - Index('fki_submissions_approver_fkey', is_approved) Index('post_app_id_idx', app_id) - Index('subimssion_binary_group_idx', is_banned, deleted_utc, over_18) - Index('submission_isbanned_idx', is_banned) - Index('submission_isdeleted_idx', deleted_utc) - Index('submission_new_sort_idx', is_banned, deleted_utc, created_utc.desc(), over_18) + Index('subimssion_binary_group_idx', state_mod, state_user_deleted_utc, over_18) + Index('submission_state_mod_idx', state_mod) + Index('submission_isdeleted_idx', state_user_deleted_utc) + + @declared_attr + def submission_new_sort_idx(self): + return Index('submission_new_sort_idx', self.state_mod, self.state_user_deleted_utc, self.created_utc.desc(), self.over_18) + Index('submission_pinned_idx', is_pinned) Index('submissions_author_index', author_id) - Index('submissions_created_utc_asc_idx', created_utc.nullsfirst()) - Index('submissions_created_utc_desc_idx', created_utc.desc()) + + @declared_attr + def submisission_created_utc_asc_idx(self): + return Index('submissions_created_utc_asc_idx', self.created_utc.nulls_first()) + + @declared_attr + def submissions_created_utc_desc_idx(self): + return Index('submissions_created_utc_desc_idx', self.created_utc.desc()) + Index('submissions_over18_index', over_18) author = relationship("User", primaryjoin="Submission.author_id==User.id") oauth_app = relationship("OauthApp", viewonly=True) - approved_by = relationship("User", uselist=False, primaryjoin="Submission.is_approved==User.id", viewonly=True) awards = relationship("AwardRelationship", viewonly=True) reports = relationship("Flag", viewonly=True) comments = relationship("Comment", primaryjoin="Comment.parent_submission==Submission.id") - subr = relationship("Sub", primaryjoin="foreign(Submission.sub)==remote(Sub.name)", viewonly=True) notes = relationship("UserNote", back_populates="post") + task = relationship("ScheduledSubmissionTask", back_populates="submissions") bump_utc = deferred(Column(Integer, server_default=FetchedValue())) - def __init__(self, *args, **kwargs): - if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) - super().__init__(*args, **kwargs) + def submit(self, db: Session): + # create submission... + db.add(self) + db.flush() + + # then create vote... + vote = Vote( + user_id=self.author_id, + vote_type=1, + submission_id=self.id + ) + db.add(vote) + author = self.author + author.post_count = db.query(Submission.id).filter_by( + author_id=self.author_id, + state_mod=StateMod.VISIBLE, + state_user_deleted_utc=None).count() + db.add(author) + + def publish(self): + # this is absolutely horrifying. imports are very very tangled and the + # spider web of imports is hard to maintain. we defer loading these + # imports until as late as possible. otherwise there are import loops + # that would require much more work to untangle + from files.helpers.alerts import notify_submission_publish + from files.helpers.caching import invalidate_cache + + if self.private: return + if not self.ghost: + notify_submission_publish(self) + invalidate_cache( + frontlist=True, + userpagelisting=True, + changeloglist=("[changelog]" in self.title.lower() + or "(changelog)" in self.title.lower()), + ) def __repr__(self): return f"<{self.__class__.__name__}(id={self.id})>" @@ -87,9 +130,8 @@ class Submission(Base): @property @lazy def should_hide_score(self): - submission_age_seconds = int(time.time()) - self.created_utc - submission_age_hours = submission_age_seconds / (60*60) - return submission_age_hours < app.config['SCORE_HIDING_TIME_HOURS'] + submission_age_hours = self.age_seconds / (60*60) + return submission_age_hours < SCORE_HIDING_TIME_HOURS @property @lazy @@ -100,89 +142,19 @@ class Submission(Base): @lazy def flags(self, v): flags = g.db.query(Flag).filter_by(post_id=self.id).order_by(Flag.created_utc).all() - if not (v and (v.shadowbanned or v.admin_level > 2)): + if not (v and (v.shadowbanned or v.admin_level >= 3)): for flag in flags: if flag.user.shadowbanned: flags.remove(flag) return flags @property - @lazy - def created_datetime(self): - return time.strftime("%d/%B/%Y %H:%M:%S UTC", time.gmtime(self.created_utc)) - - @property - @lazy - def created_datetime(self): - return time.strftime("%d/%B/%Y %H:%M:%S UTC", time.gmtime(self.created_utc)) - - @property - @lazy - def age_string(self): - - age = int(time.time()) - self.created_utc - - if age < 60: - return "just now" - elif age < 3600: - minutes = int(age / 60) - return f"{minutes}m ago" - elif age < 86400: - hours = int(age / 3600) - return f"{hours}hr ago" - elif age < 2678400: - days = int(age / 86400) - return f"{days}d ago" - - now = time.gmtime() - ctd = time.gmtime(self.created_utc) - - months = now.tm_mon - ctd.tm_mon + 12 * (now.tm_year - ctd.tm_year) - if now.tm_mday < ctd.tm_mday: - months -= 1 - - if months < 12: - return f"{months}mo ago" - else: - years = int(months / 12) - return f"{years}yr ago" - - @property - @lazy def edited_string(self): - - if not self.edited_utc: return "never" - - age = int(time.time()) - self.edited_utc - - if age < 60: - return "just now" - elif age < 3600: - minutes = int(age / 60) - return f"{minutes}m ago" - elif age < 86400: - hours = int(age / 3600) - return f"{hours}hr ago" - elif age < 2678400: - days = int(age / 86400) - return f"{days}d ago" - - now = time.gmtime() - ctd = time.gmtime(self.edited_utc) - months = now.tm_mon - ctd.tm_mon + 12 * (now.tm_year - ctd.tm_year) - - if months < 12: - return f"{months}mo ago" - else: - years = now.tm_year - ctd.tm_year - return f"{years}yr ago" - + return format_age(self.edited_utc) if self.edited_utc else "never" @property - @lazy def edited_datetime(self): - return time.strftime("%d/%B/%Y %H:%M:%S UTC", time.gmtime(self.edited_utc)) - + return format_datetime(self.edited_utc) if self.edited_utc else "" @property @lazy @@ -192,15 +164,12 @@ class Submission(Base): @property @lazy def fullname(self): - return f"t2_{self.id}" - + return f"post_{self.id}" @property @lazy def shortlink(self): link = f"/post/{self.id}" - if self.sub: link = f"/h/{self.sub}{link}" - if self.club: return link + '/-' output = title_regex.sub('', self.title.lower()) @@ -261,8 +230,6 @@ class Submission(Base): data = {'author_name': self.author_name if self.author else '', 'permalink': self.permalink, 'shortlink': self.shortlink, - 'is_banned': bool(self.is_banned), - 'deleted_utc': self.deleted_utc, 'created_utc': self.created_utc, 'id': self.id, 'title': self.title, @@ -287,26 +254,22 @@ class Submission(Base): 'club': self.club, } - if self.ban_reason: - data["ban_reason"]=self.ban_reason - return data @property @lazy def json_core(self): - - if self.is_banned: - return {'is_banned': True, - 'deleted_utc': self.deleted_utc, - 'ban_reason': self.ban_reason, + if self.state_mod != StateMod.VISIBLE: + return { + 'state_user_deleted_utc': self.state_user_deleted_utc, + 'state_mod_set_by': self.state_mod_set_by, 'id': self.id, 'title': self.title, 'permalink': self.permalink, } - elif self.deleted_utc: - return {'is_banned': bool(self.is_banned), - 'deleted_utc': True, + elif self.state_user_deleted_utc: + return { + 'state_user_deleted_utc': self.state_user_deleted_utc, 'id': self.id, 'title': self.title, 'permalink': self.permalink, @@ -317,10 +280,9 @@ class Submission(Base): @property @lazy def json(self): - data=self.json_core - if self.deleted_utc or self.is_banned: + if self.state_user_deleted_utc or self.state_mod != StateMod.VISIBLE: return data data["author"]='👻' if self.ghost else self.author.json_core @@ -357,44 +319,14 @@ class Submission(Base): else: return "" def realbody(self, v): - if self.club and not (v and (v.paid_dues or v.id == self.author_id)): return f"

{CC} ONLY

" - - body = self.body_html or "" - - if v: - body = body.replace("old.reddit.com", v.reddit) - - if v.nitter and '/i/' not in body and '/retweets' not in body: body = body.replace("www.twitter.com", "nitter.net").replace("twitter.com", "nitter.net") - - if v and v.shadowbanned and v.id == self.author_id and 86400 > time.time() - self.created_utc > 20: - ti = max(int((time.time() - self.created_utc)/60), 1) - maxupvotes = min(ti, 11) - rand = random.randint(0, maxupvotes) - if self.upvotes < rand: - amount = random.randint(0, 3) - if amount == 1: - self.views += amount*random.randint(3, 5) - self.upvotes += amount - g.db.add(self) - g.db.commit() - - return body + return body_displayed(self, v, is_html=True) def plainbody(self, v): - if self.club and not (v and (v.paid_dues or v.id == self.author_id)): return f"

{CC} ONLY

" - body = self.body - if not body: return "" - if v: - body = body.replace("old.reddit.com", v.reddit) - if v.nitter and '/i/' not in body and '/retweets' not in body: body = body.replace("www.twitter.com", "nitter.net").replace("twitter.com", "nitter.net") - return body + return body_displayed(self, v, is_html=False) @lazy def realtitle(self, v): - if self.title_html: - return self.title_html - else: - return self.title + return self.title_html if self.title_html else self.title @lazy def plaintitle(self, v): @@ -413,4 +345,17 @@ class Submission(Base): return False @lazy - def active_flags(self, v): return len(self.flags(v)) + def active_flags(self, v): + return len(self.flags(v)) + + @property + def is_real_submission(self) -> bool: + return True + + @property + def edit_url(self) -> str: + return f"/edit_post/{self.id}" + + @property + def visibility_state(self) -> VisibilityState: + return VisibilityState.from_submittable(self) diff --git a/files/classes/subscriptions.py b/files/classes/subscriptions.py index 72a58a104..d25091a3e 100644 --- a/files/classes/subscriptions.py +++ b/files/classes/subscriptions.py @@ -1,6 +1,6 @@ from sqlalchemy import * from sqlalchemy.orm import relationship -from files.__main__ import Base +from files.classes.base import Base class Subscription(Base): __tablename__ = "subscriptions" diff --git a/files/classes/user.py b/files/classes/user.py index d7d7bf835..0050144df 100644 --- a/files/classes/user.py +++ b/files/classes/user.py @@ -1,35 +1,39 @@ -from sqlalchemy.orm import deferred, aliased -from secrets import token_hex -import pyotp -from files.helpers.media import * -from files.helpers.const import * -from .alts import Alt -from .saves import * -from .notifications import Notification -from .award import AwardRelationship -from .follows import * -from .subscriptions import * -from .userblock import * -from .badges import * -from .clients import * -from .mod_logs import * -from .mod import * -from .exiles import * -from .sub_block import * -from files.__main__ import app, Base, cache -from files.helpers.security import * -from files.helpers.assetcache import assetcache_path -from files.helpers.contentsorting import apply_time_filter, sort_objects -import random -from datetime import datetime -from os import environ, remove, path +from __future__ import annotations +import time +from typing import TYPE_CHECKING, Union + +import pyotp +from flask import g, session +from sqlalchemy.orm import aliased, declared_attr, deferred, relationship + +from files.classes.alts import Alt +from files.classes.award import AwardRelationship +from files.classes.badges import Badge +from files.classes.base import CreatedBase +from files.classes.clients import * # note: imports Comment and Submission +from files.classes.follows import Follow +from files.classes.mod_logs import ModAction +from files.classes.notifications import Notification +from files.classes.saves import CommentSaveRelationship, SaveRelationship +from files.classes.subscriptions import Subscription +from files.classes.userblock import UserBlock +from files.classes.visstate import StateMod +from files.helpers.assetcache import assetcache_path +from files.helpers.config.const import * +from files.helpers.config.environment import (CARD_VIEW, + CLUB_TRUESCORE_MINIMUM, + DEFAULT_COLOR, + DEFAULT_TIME_FILTER, SITE_FULL, + SITE_ID) +from files.helpers.security import * + +if TYPE_CHECKING: + from files.classes.cron.submission import ScheduledSubmissionTask defaulttheme = "TheMotte" -defaulttimefilter = environ.get("DEFAULT_TIME_FILTER", "all").strip() -cardview = bool(int(environ.get("CARD_VIEW", 1))) -class User(Base): +class User(CreatedBase): __tablename__ = "users" __table_args__ = ( UniqueConstraint('bannerurl', name='one_banner'), @@ -40,13 +44,12 @@ class User(Base): id = Column(Integer, primary_key=True) username = Column(String(length=255), nullable=False) namecolor = Column(String(length=6), default=DEFAULT_COLOR, nullable=False) - background = Column(String) customtitle = Column(String) customtitleplain = deferred(Column(String)) titlecolor = Column(String(length=6), default=DEFAULT_COLOR, nullable=False) theme = Column(String, default=defaulttheme, nullable=False) themecolor = Column(String, default=DEFAULT_COLOR, nullable=False) - cardview = Column(Boolean, default=cardview, nullable=False) + cardview = Column(Boolean, default=CARD_VIEW, nullable=False) highres = Column(String) profileurl = Column(String) bannerurl = Column(String) @@ -63,7 +66,6 @@ class User(Base): post_count = Column(Integer, default=0, nullable=False) comment_count = Column(Integer, default=0, nullable=False) received_award_count = Column(Integer, default=0, nullable=False) - created_utc = Column(Integer, nullable=False) admin_level = Column(Integer, default=0, nullable=False) coins_spent = Column(Integer, default=0, nullable=False) lootboxes_bought = Column(Integer, default=0, nullable=False) @@ -103,14 +105,14 @@ class User(Base): stored_subscriber_count = Column(Integer, default=0, nullable=False) defaultsortingcomments = Column(String, default="new", nullable=False) defaultsorting = Column(String, default="new", nullable=False) - defaulttime = Column(String, default=defaulttimefilter, nullable=False) + defaulttime = Column(String, default=DEFAULT_TIME_FILTER, nullable=False) is_nofollow = Column(Boolean, default=False, nullable=False) custom_filter_list = Column(String) ban_evade = Column(Integer, default=0, nullable=False) original_username = deferred(Column(String)) referred_by = Column(Integer, ForeignKey("users.id")) - subs_created = Column(Integer, default=0, nullable=False) volunteer_last_started_utc = Column(DateTime, nullable=True) + volunteer_janitor_correctness = Column(Float, default=0, nullable=False) Index( 'users_original_username_trgm_idx', @@ -128,7 +130,11 @@ class User(Base): Index('fki_user_referrer_fkey', referred_by) Index('user_banned_idx', is_banned) Index('user_private_idx', is_private) - Index('users_created_utc_index', created_utc) + + @declared_attr + def users_created_utc_index(self): + return Index('users_created_utc_index', self.created_utc) + Index('users_subs_idx', stored_subscriber_count) Index('users_unbanutc_idx', unban_utc.desc()) @@ -148,12 +154,8 @@ class User(Base): if "password" in kwargs: kwargs["passhash"] = self.hash_password(kwargs["password"]) kwargs.pop("password") - - if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) - super().__init__(**kwargs) - def can_manage_reports(self): return self.admin_level > 1 @@ -163,8 +165,10 @@ class User(Base): @property def should_comments_be_filtered(self): + from files.__main__ import app # avoiding import loop if self.admin_level > 0: return False + # TODO: move settings out of app.config site_settings = app.config['SETTINGS'] min_comments = site_settings.get('FilterCommentsMinComments', 0) min_karma = site_settings.get('FilterCommentsMinKarma', 0) @@ -173,58 +177,30 @@ class User(Base): or self.age_days < min_age \ or self.truescore < min_karma - @lazy - def mods(self, sub): - return self.admin_level == 3 or bool(g.db.query(Mod.user_id).filter_by(user_id=self.id, sub=sub).one_or_none()) - @lazy - def exiled_from(self, sub): - return self.admin_level < 2 and bool(g.db.query(Exile.user_id).filter_by(user_id=self.id, sub=sub).one_or_none()) + def can_change_user_privacy(self, v: "User") -> bool: + from files.__main__ import app # avoiding import loop + if v.admin_level >= PERMS['USER_SET_PROFILE_PRIVACY']: return True + if self.id != v.id: return False # non-admin changing someone else's things, hmm... - @property - @lazy - def all_blocks(self): - return [x[0] for x in g.db.query(SubBlock.sub).filter_by(user_id=self.id).all()] + # TODO: move settings out of app.config + site_settings = app.config['SETTINGS'] + min_comments: int = site_settings.get('min_comments_private_profile', 0) + min_truescore: int = site_settings.get('min_truescore_private_profile', 0) + min_age_days: int = site_settings.get('min_age_days_private_profile', 0) + user_age_days: int = self.age_timedelta.days - @lazy - def blocks(self, sub): - return g.db.query(SubBlock).filter_by(user_id=self.id, sub=sub).one_or_none() - - @lazy - def mod_date(self, sub): - if self.id == OWNER_ID: return 1 - mod = g.db.query(Mod).filter_by(user_id=self.id, sub=sub).one_or_none() - if not mod: return None - return mod.created_utc + return ( + self.comment_count >= min_comments + and self.truecoins >= min_truescore + and user_age_days >= min_age_days) + @property @lazy def csslazy(self): return self.css - @property - @lazy - def created_date(self): - - return time.strftime("%d %b %Y", time.gmtime(self.created_utc)) - - @property - @lazy - def discount(self): - if self.patron == 1: discount = 0.90 - elif self.patron == 2: discount = 0.85 - elif self.patron == 3: discount = 0.80 - elif self.patron == 4: discount = 0.75 - elif self.patron == 5: discount = 0.70 - elif self.patron == 6: discount = 0.65 - else: discount = 1 - - for badge in [69,70,71,72,73]: - if self.has_badge(badge): discount -= discounts[badge] - - return discount - - @property @lazy def user_awards(self): @@ -244,25 +220,18 @@ class User(Base): @property @lazy def paid_dues(self): - return not self.shadowbanned and not (self.is_banned and not self.unban_utc) and (self.admin_level or self.club_allowed or (self.club_allowed != False and self.truescore > dues)) + return not self.shadowbanned and not (self.is_banned and not self.unban_utc) and (self.admin_level or self.club_allowed or (self.club_allowed != False and self.truescore > CLUB_TRUESCORE_MINIMUM)) @lazy 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 validate_2fa(self, token): - x = pyotp.TOTP(self.mfa_secret) return x.verify(token, valid_window=1) - @property - @lazy - def age(self): - return int(time.time()) - self.created_utc - @property @lazy def ban_reason_link(self): @@ -285,22 +254,6 @@ class User(Base): if u.patron: return True return False - @cache.memoize(timeout=86400) - def userpagelisting(self, site=None, v=None, page=1, sort="new", t="all"): - if self.shadowbanned and not (v and (v.admin_level > 1 or v.id == self.id)): return [] - - posts = g.db.query(Submission.id).filter_by(author_id=self.id, is_pinned=False) - - if not (v and (v.admin_level > 1 or v.id == self.id)): - posts = posts.filter_by(deleted_utc=0, is_banned=False, private=False, ghost=False) - - posts = apply_time_filter(posts, t, Submission) - posts = sort_objects(posts, sort, Submission) - - posts = posts.offset(25 * (page - 1)).limit(26).all() - - return [x[0] for x in posts] - @property @lazy def follow_count(self): @@ -315,7 +268,7 @@ class User(Base): @property @lazy def fullname(self): - return f"t1_{self.id}" + return f"user_{self.id}" @property @lazy @@ -336,7 +289,6 @@ class User(Base): @property @lazy def formkey(self): - msg = f"{session['session_id']}+{self.id}+{self.login_nonce}" return generate_hash(msg) @@ -409,7 +361,7 @@ class User(Base): @property @lazy def notifications_count(self): - notifs = g.db.query(Notification.user_id).join(Comment).filter(Notification.user_id == self.id, Notification.read == False, Comment.is_banned == False, Comment.deleted_utc == 0) + notifs = g.db.query(Notification.user_id).join(Comment).filter(Notification.user_id == self.id, Notification.read == False, Comment.state_mod == StateMod.VISIBLE, Comment.state_user_deleted_utc == None) if not self.shadowbanned and self.admin_level < 3: notifs = notifs.join(User, User.id == Comment.author_id).filter(User.shadowbanned == None) @@ -421,30 +373,19 @@ class User(Base): def post_notifications_count(self): return g.db.query(Notification.user_id).join(Comment).filter(Notification.user_id == self.id, Notification.read == False, Comment.author_id == AUTOJANNY_ID).count() - @property - @lazy - def reddit_notifications_count(self): - return g.db.query(Notification.user_id).join(Comment).filter(Notification.user_id == self.id, Notification.read == False, Comment.is_banned == False, Comment.deleted_utc == 0, Comment.body_html.like('%

New site mention: = PERMS['POST_EDITING'] + if target.__class__.__name__ == "ScheduledSubmissionTask": + # XXX: avoiding import loop. should be fixed someday. + return self.admin_level >= PERMS['SCHEDULER_POSTS'] + @property def can_see_shadowbanned(self): return self.admin_level >= PERMS['USER_SHADOWBAN'] or self.shadowbanned diff --git a/files/classes/userblock.py b/files/classes/userblock.py index f9e3cb8b6..435062392 100644 --- a/files/classes/userblock.py +++ b/files/classes/userblock.py @@ -1,6 +1,6 @@ from sqlalchemy import * from sqlalchemy.orm import relationship -from files.__main__ import Base +from files.classes.base import Base class UserBlock(Base): diff --git a/files/classes/usernotes.py b/files/classes/usernotes.py index 665691d72..ee9f7937c 100644 --- a/files/classes/usernotes.py +++ b/files/classes/usernotes.py @@ -1,9 +1,7 @@ -import time -from flask import * from sqlalchemy import * from sqlalchemy.orm import relationship -from files.__main__ import Base -from files.helpers.const import * +from files.classes.base import CreatedBase +from files.helpers.config.const import * from enum import Enum from sqlalchemy import Enum as EnumType @@ -17,13 +15,11 @@ class UserTag(Enum): Spam = 6 Bot = 7 -class UserNote(Base): - +class UserNote(CreatedBase): __tablename__ = "usernotes" id = Column(Integer, primary_key=True) author_id = Column(Integer, ForeignKey("users.id"), nullable=False) - created_utc = Column(Integer, nullable=False) reference_user = Column(Integer, ForeignKey("users.id", ondelete='CASCADE'), nullable=False) reference_comment = Column(Integer, ForeignKey("comments.id", ondelete='SET NULL')) reference_post = Column(Integer, ForeignKey("submissions.id", ondelete='SET NULL')) @@ -35,11 +31,6 @@ class UserNote(Base): comment = relationship("Comment", back_populates="notes") post = relationship("Submission", back_populates="notes") - - def __init__(self, *args, **kwargs): - if "created_utc" not in kwargs: - kwargs["created_utc"] = int(time.time()) - super().__init__(*args, **kwargs) def __repr__(self): return f"<{self.__class__.__name__}(id={self.id})>" @@ -63,4 +54,4 @@ class UserNote(Base): 'tag': self.tag.value } - return data \ No newline at end of file + return data diff --git a/files/classes/views.py b/files/classes/views.py index 29a206577..18ad428d7 100644 --- a/files/classes/views.py +++ b/files/classes/views.py @@ -1,11 +1,14 @@ -from sqlalchemy import * -from sqlalchemy.orm import relationship -from files.__main__ import Base -from files.helpers.lazy import * import time -class ViewerRelationship(Base): +from sqlalchemy import * +from sqlalchemy.orm import relationship +from files.classes.base import Base +from files.helpers.lazy import lazy +from files.helpers.time import format_age + + +class ViewerRelationship(Base): __tablename__ = "viewers" user_id = Column(Integer, ForeignKey('users.id'), primary_key=True) @@ -17,7 +20,6 @@ class ViewerRelationship(Base): viewer = relationship("User", primaryjoin="ViewerRelationship.viewer_id == User.id", viewonly=True) def __init__(self, **kwargs): - if 'last_view_utc' not in kwargs: kwargs['last_view_utc'] = int(time.time()) @@ -26,36 +28,9 @@ class ViewerRelationship(Base): @property @lazy def last_view_since(self): - return int(time.time()) - self.last_view_utc @property @lazy def last_view_string(self): - - age = self.last_view_since - - if age < 60: - return "just now" - elif age < 3600: - minutes = int(age / 60) - return f"{minutes}m ago" - elif age < 86400: - hours = int(age / 3600) - return f"{hours}hr ago" - elif age < 2678400: - days = int(age / 86400) - return f"{days}d ago" - - now = time.gmtime() - ctd = time.gmtime(self.created_utc) - - months = now.tm_mon - ctd.tm_mon + 12 * (now.tm_year - ctd.tm_year) - if now.tm_mday < ctd.tm_mday: - months -= 1 - - if months < 12: - return f"{months}mo ago" - else: - years = int(months / 12) - return f"{years}yr ago" + return format_age(self.last_view_utc) diff --git a/files/classes/visstate.py b/files/classes/visstate.py new file mode 100644 index 000000000..a0bfd9fc0 --- /dev/null +++ b/files/classes/visstate.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from files.helpers.config.const import PERMS + +if TYPE_CHECKING: + from files.classes.user import User + from files.helpers.content import Submittable + + +class StateMod(enum.Enum): + VISIBLE = 0 + FILTERED = 1 + REMOVED = 2 + +class StateReport(enum.Enum): + UNREPORTED = 0 + RESOLVED = 1 + REPORTED = 2 + IGNORED = 3 + + +@dataclass(frozen=True, kw_only=True, slots=True) +class VisibilityState: + ''' + The full moderation state machine. It holds the moderation state, report + state, deleted information, and shadowban information. A decision to show + or hide a post or comment should be able to be done with information from + this alone. + ''' + state_mod: StateMod + state_mod_set_by: str | None + state_report: StateReport + state_mod_set_by: str | None + deleted: bool + op_shadowbanned: bool + op_id: int + op_name_safe: str + + @property + def removed(self) -> bool: + return self.state_mod == StateMod.REMOVED + + @property + def filtered(self) -> bool: + return self.state_mod == StateMod.FILTERED + + @property + def reports_ignored(self) -> bool: + return self.state_report == StateReport.IGNORED + + @classmethod + def from_submittable(cls, target: Submittable) -> "VisibilityState": + return cls( + state_mod=target.state_mod, + state_mod_set_by=target.state_mod_set_by, # type: ignore + state_report=target.state_report, + deleted=bool(target.state_user_deleted_utc != None), + op_shadowbanned=bool(target.author.shadowbanned), + op_id=target.author_id, # type: ignore + op_name_safe=target.author_name + ) + + def moderated_body(self, v: User | None, is_blocking: bool=False) -> str | None: + if v and (v.admin_level >= PERMS['POST_COMMENT_MODERATION'] \ + or v.id == self.op_id): + return None + if self.deleted: return 'Deleted' + if self.appear_removed(v): return 'Removed' + if self.filtered: return 'Filtered' + if is_blocking: return f'You are blocking @{self.op_name_safe}' + return None + + def visibility_and_message(self, v: User | None, is_blocking: bool) -> tuple[bool, str]: + ''' + Returns a tuple of whether this content is visible and a publicly + visible message to accompany it. The visibility state machine is + a slight mess but... this should at least unify the state checks. + ''' + def can(v: User | None, perm_level: int) -> bool: + return v and v.admin_level >= perm_level + + can_moderate: bool = can(v, PERMS['POST_COMMENT_MODERATION']) + can_shadowban: bool = can(v, PERMS['USER_SHADOWBAN']) + + if v and v.id == self.op_id: + return True, "This shouldn't be here, please report it!" + if (self.removed and not can_moderate) or \ + (self.op_shadowbanned and not can_shadowban): + msg: str = 'Removed' + if self.state_mod_set_by: + msg = f'Removed by @{self.state_mod_set_by}' + return False, msg + if self.filtered and not can_moderate: + return False, 'Filtered' + if self.deleted and not can_moderate: + return False, 'Deleted by author' + if is_blocking: + return False, f'You are blocking @{self.op_name_safe}' + return True, "This shouldn't be here, please report it!" + + def is_visible_to(self, v: User | None, is_blocking: bool) -> bool: + return self.visibility_and_message(v, is_blocking)[0] + + def replacement_message(self, v: User | None, is_blocking: bool) -> str: + return self.visibility_and_message(v, is_blocking)[1] + + def appear_removed(self, v: User | None) -> bool: + if self.removed: return True + if not self.op_shadowbanned: return False + return (not v) or bool(v.admin_level < PERMS['USER_SHADOWBAN']) + + @property + def publicly_visible(self) -> bool: + return all( + not state for state in + [self.deleted, self.removed, self.filtered, self.op_shadowbanned] + ) + + @property + def explicitly_moderated(self) -> bool: + ''' + Whether this was removed or filtered and not as the result of a shadowban + ''' + return self.removed or self.filtered diff --git a/files/classes/volunteer_janitor.py b/files/classes/volunteer_janitor.py index aa2e07417..19dc2df74 100644 --- a/files/classes/volunteer_janitor.py +++ b/files/classes/volunteer_janitor.py @@ -1,6 +1,6 @@ import enum -from files.__main__ import Base +from files.classes.base import Base from sqlalchemy import * from sqlalchemy.orm import relationship @@ -14,7 +14,6 @@ class VolunteerJanitorResult(enum.Enum): Ban = 6 class VolunteerJanitorRecord(Base): - __tablename__ = "volunteer_janitor" id = Column(Integer, primary_key=True) diff --git a/files/classes/votes.py b/files/classes/votes.py index 1112d01b6..4163dc46f 100644 --- a/files/classes/votes.py +++ b/files/classes/votes.py @@ -1,12 +1,11 @@ -from flask import * from sqlalchemy import * from sqlalchemy.orm import relationship -from files.__main__ import Base + +from files.classes.base import CreatedBase from files.helpers.lazy import lazy -import time -class Vote(Base): +class Vote(CreatedBase): __tablename__ = "votes" submission_id = Column(Integer, ForeignKey("submissions.id"), primary_key=True) @@ -14,7 +13,6 @@ class Vote(Base): vote_type = Column(Integer, nullable=False) app_id = Column(Integer, ForeignKey("oauth_apps.id")) real = Column(Boolean, default=True, nullable=False) - created_utc = Column(Integer, nullable=False) Index('votes_type_index', vote_type) Index('vote_user_index', user_id) @@ -22,10 +20,6 @@ class Vote(Base): user = relationship("User", lazy="subquery", viewonly=True) post = relationship("Submission", lazy="subquery", viewonly=True) - def __init__(self, *args, **kwargs): - if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) - super().__init__(*args, **kwargs) - def __repr__(self): return f"<{self.__class__.__name__}(id={self.id})>" @@ -45,12 +39,10 @@ class Vote(Base): data=self.json_core data["user"]=self.user.json_core data["post"]=self.post.json_core - return data -class CommentVote(Base): - +class CommentVote(CreatedBase): __tablename__ = "commentvotes" comment_id = Column(Integer, ForeignKey("comments.id"), primary_key=True) @@ -58,7 +50,6 @@ class CommentVote(Base): vote_type = Column(Integer, nullable=False) app_id = Column(Integer, ForeignKey("oauth_apps.id")) real = Column(Boolean, default=True, nullable=False) - created_utc = Column(Integer, nullable=False) Index('cvote_user_index', user_id) Index('commentvotes_comments_type_index', vote_type) @@ -66,10 +57,6 @@ class CommentVote(Base): user = relationship("User", lazy="subquery") comment = relationship("Comment", lazy="subquery", viewonly=True) - def __init__(self, *args, **kwargs): - if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) - super().__init__(*args, **kwargs) - def __repr__(self): return f"<{self.__class__.__name__}(id={self.id})>" @@ -89,5 +76,4 @@ class CommentVote(Base): data=self.json_core data["user"]=self.user.json_core data["comment"]=self.comment.json_core - return data diff --git a/files/cli.py b/files/cli.py index a8461d700..a2f9ed6a6 100644 --- a/files/cli.py +++ b/files/cli.py @@ -1,7 +1,12 @@ from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy -from .__main__ import app -from .commands.seed_db import seed_db + +from files.__main__ import app +from files.commands.cron import cron_app_worker +from files.commands.seed_db import seed_db +from files.commands.volunteer_janitor_recalc import volunteer_janitor_recalc +from files.commands.volunteer_janitor_histogram import volunteer_janitor_histogram_cmd +from files.commands.cron_setup import cron_setup import files.classes db = SQLAlchemy(app) diff --git a/files/commands/cron.py b/files/commands/cron.py new file mode 100644 index 000000000..4f672f332 --- /dev/null +++ b/files/commands/cron.py @@ -0,0 +1,139 @@ +import contextlib +import logging +import time +from datetime import datetime, timezone +from typing import Final + +from sqlalchemy.orm import sessionmaker, Session + +from files.__main__ import app, db_session_factory +from files.classes.cron.tasks import (DayOfWeek, RepeatableTask, + RepeatableTaskRun, ScheduledTaskState) + +CRON_SLEEP_SECONDS: Final[int] = 15 +''' +How long the app will sleep for between runs. Lower values give better +resolution, but will hit the database more. + +The cost of a lower value is potentially higher lock contention. A value below +`0` will raise a `ValueError` (on call to `time.sleep`). A value of `0` is +possible but not recommended. + +The sleep time is not guaranteed to be exactly this value (notably, it may be +slightly longer if the system is very busy) + +This value is passed to `time.sleep()`. For more information on that, see +the Python documentation: https://docs.python.org/3/library/time.html +''' + +_CRON_COMMAND_NAME = "cron" + + +@app.cli.command(_CRON_COMMAND_NAME) +def cron_app_worker(): + ''' + The "worker" process task. This actually executes tasks. + ''' + + # someday we'll clean this up further, for now I need debug info + logging.basicConfig(level=logging.INFO) + + logging.info("Starting scheduler worker process") + while True: + try: + _run_tasks(db_session_factory) + except Exception as e: + logging.exception( + "An unhandled exception occurred while running tasks", + exc_info=e + ) + time.sleep(CRON_SLEEP_SECONDS) + + +@contextlib.contextmanager +def _acquire_lock_exclusive(db: Session, table: str): + ''' + Acquires an exclusive lock on the table provided by the `table` parameter. + This can be used for synchronizing the state of the specified table and + making sure no readers can access it while in the critical section. + ''' + # TODO: make `table` the type LiteralString once we upgrade to python 3.11 + db.begin() # we want to raise an exception if there's a txn in progress + db.execute(f"LOCK TABLE {table} IN ACCESS EXCLUSIVE MODE") + try: + yield + db.commit() + except Exception: + logging.error( + "An exception occurred during an operation in a critical section. " + "A task might not occur or might be duplicated." + ) + try: + db.rollback() + except: + logging.warning( + f"Failed to rollback database. The table {table} might still " + "be locked.") + raise + + +def _run_tasks(db_session_factory: sessionmaker): + ''' + Runs tasks, attempting to guarantee that a task is ran once and only once. + This uses postgres to lock the table containing our tasks at key points in + in the process (reading the tasks and writing the last updated time). + + The task itself is ran outside of this context; this is so that a long + running task does not lock the entire table for its entire run, which would + for example, prevent any statistics about status from being gathered. + ''' + db: Session = db_session_factory() + + with _acquire_lock_exclusive(db, RepeatableTask.__tablename__): + now: datetime = datetime.now(tz=timezone.utc) + + tasks: list[RepeatableTask] = db.query(RepeatableTask).filter( + RepeatableTask.enabled == True, + RepeatableTask.frequency_day != int(DayOfWeek.NONE), + RepeatableTask.run_state != int(ScheduledTaskState.RUNNING), + (RepeatableTask.run_time_last <= now) + | (RepeatableTask.run_time_last == None), + ).all() + + # SQLA needs to query again for the inherited object info anyway + # so it's fine that objects in the list get expired on txn end. + # Prefer more queries to risk of task run duplication. + tasks_to_run: list[RepeatableTask] = list(filter( + lambda task: task.can_run(now), tasks)) + + for task in tasks_to_run: + now = datetime.now(tz=timezone.utc) + with _acquire_lock_exclusive(db, RepeatableTask.__tablename__): + # We need to check for runnability again because we don't mutex + # the RepeatableTask.run_state until now. + if not task.can_run(now): + continue + task.run_time_last = now + task.run_state_enum = ScheduledTaskState.RUNNING + + # This *must* happen before we start doing db queries, including sqlalchemy db queries + db.begin() + task_debug_identifier = f"(ID {task.id}:{task.label})" + logging.info(f"Running task {task_debug_identifier}") + + run: RepeatableTaskRun = task.run(db, task.run_time_last_or_created_utc) + + if run.exception: + # TODO: collect errors somewhere other than just here and in the + # task run object itself (see #220). + logging.exception( + f"Exception running task {task_debug_identifier}", + exc_info=run.exception + ) + db.rollback() + else: + db.commit() + logging.info(f"Finished task {task_debug_identifier}") + + with _acquire_lock_exclusive(db, RepeatableTask.__tablename__): + task.run_state_enum = ScheduledTaskState.WAITING diff --git a/files/commands/cron_setup.py b/files/commands/cron_setup.py new file mode 100644 index 000000000..e44edf7b2 --- /dev/null +++ b/files/commands/cron_setup.py @@ -0,0 +1,53 @@ + +from typing import Optional +import datetime + +import sqlalchemy +from sqlalchemy.orm import Session + +from files.__main__ import app, db_session +from files.classes.cron.pycallable import PythonCodeTask +from files.classes.cron.tasks import DayOfWeek +from files.helpers.config.const import AUTOJANNY_ID + + +@app.cli.command('cron_setup') +def cron_setup(): + db: Session = db_session() + + tasklist = db.query(PythonCodeTask) + + # I guess in theory we should load this from a file or something, but, ehhhh + hardcoded_cron_jobs = { + 'volunteer_janitor_recalc': { + 'frequency_day': DayOfWeek.ALL, + 'time_of_day_utc': datetime.time(0, 0), + 'import_path': 'files.commands.volunteer_janitor_recalc', + 'callable': 'volunteer_janitor_recalc_cron', + }, + } + + print(f"{tasklist.count()} tasks") + for task in tasklist: + if task.label and task.label in hardcoded_cron_jobs: + print(f"Cron: Updating {task.label}") + ref = hardcoded_cron_jobs[task.label] + task.frequency_day = ref["frequency_day"] + task.time_of_day_utc = ref["time_of_day_utc"] + task.import_path = ref["import_path"] + task.callable = ref["callable"] + del hardcoded_cron_jobs[task.label] + + for label, ref in hardcoded_cron_jobs.items(): + print(f"Cron: Creating {label}") + task: PythonCodeTask = PythonCodeTask( + label = label, + author_id = AUTOJANNY_ID, + frequency_day = ref["frequency_day"], + time_of_day_utc = ref["time_of_day_utc"], + import_path = ref["import_path"], + callable = ref["callable"], + ) + db.add(task) + + db.commit() diff --git a/files/commands/seed_db.py b/files/commands/seed_db.py index 08329866d..23e306047 100644 --- a/files/commands/seed_db.py +++ b/files/commands/seed_db.py @@ -1,15 +1,17 @@ import hashlib import math from typing import Optional -import sqlalchemy -from sqlalchemy.orm import scoped_session +import sqlalchemy +from sqlalchemy.orm import Session from werkzeug.security import generate_password_hash from files.__main__ import app, db_session -from files.classes import User, Submission, Comment, Vote, CommentVote +from files.classes import Comment, CommentVote, Submission, User, Vote +from files.classes.visstate import StateMod from files.helpers.comments import bulk_recompute_descendant_counts + @app.cli.command('seed_db') def seed_db(): seed_db_worker() @@ -23,7 +25,7 @@ def seed_db_worker(num_users = 900, num_posts = 40, num_toplevel_comments = 1000 COMMENT_UPVOTE_PROB = 0.0008 COMMENT_DOWNVOTE_PROB = 0.0003 - db: scoped_session = db_session() + db: Session = db_session() def detrand(): detrand.randstate = bytes(hashlib.sha256(detrand.randstate).hexdigest(), 'utf-8') @@ -102,9 +104,8 @@ def seed_db_worker(num_users = 900, num_posts = 40, num_toplevel_comments = 1000 embed_url=None, title=f'Clever unique post title number {i}', title_html=f'Clever unique post title number {i}', - sub=None, ghost=False, - filter_state='normal' + state_mod=StateMod.VISIBLE, ) db.add(post) posts.append(post) @@ -127,7 +128,8 @@ def seed_db_worker(num_users = 900, num_posts = 40, num_toplevel_comments = 1000 app_id=None, body_html=f'toplevel {i}', body=f'toplevel {i}', - ghost=False + ghost=False, + state_mod=StateMod.VISIBLE, ) db.add(comment) comments.append(comment) @@ -156,7 +158,8 @@ def seed_db_worker(num_users = 900, num_posts = 40, num_toplevel_comments = 1000 app_id=None, body_html=f'reply {i}', body=f'reply {i}', - ghost=False + ghost=False, + state_mod=StateMod.VISIBLE, ) db.add(comment) comments.append(comment) diff --git a/files/commands/volunteer_janitor_histogram.py b/files/commands/volunteer_janitor_histogram.py new file mode 100644 index 000000000..c68a57bc5 --- /dev/null +++ b/files/commands/volunteer_janitor_histogram.py @@ -0,0 +1,35 @@ + +import pprint + +from files.classes.volunteer_janitor import VolunteerJanitorRecord + +from files.__main__ import app, db_session + +@app.cli.command('volunteer_janitor_histogram') +def volunteer_janitor_histogram_cmd(): + import pandas as pd + import matplotlib.pyplot as plt + + result_set = db_session().query(VolunteerJanitorRecord.recorded_utc).all() + + # convert the result into a pandas DataFrame + df = pd.DataFrame(result_set, columns=['recorded_utc']) + + # convert the date column to datetime + df['recorded_utc'] = pd.to_datetime(df['recorded_utc']) + + # set 'recorded_utc' as the index of the DataFrame + df.set_index('recorded_utc', inplace=True) + + # resample the data to daily frequency + df_resampled = df.resample('D').size() + + # plot the resampled DataFrame + df_resampled.plot(kind='line') + plt.title('Density of Dates over Time') + plt.xlabel('Date') + plt.ylabel('Count') + + # save the figure in SVG format + plt.savefig('output.svg', format='svg') + print(len(result_set)) diff --git a/files/commands/volunteer_janitor_recalc.py b/files/commands/volunteer_janitor_recalc.py new file mode 100644 index 000000000..5941c6efb --- /dev/null +++ b/files/commands/volunteer_janitor_recalc.py @@ -0,0 +1,379 @@ + +import pprint + +import sqlalchemy +from sqlalchemy.orm import Session + +from alive_progress import alive_it +from collections import defaultdict +from files.classes import User, Comment, UserNote, UserTag +from files.classes.cron.tasks import TaskRunContext +from files.classes.volunteer_janitor import VolunteerJanitorRecord, VolunteerJanitorResult +from files.helpers.volunteer_janitor import evaluate_badness_of, userweight_from_user_accuracy, calculate_final_comment_badness, update_comment_badness +from files.helpers.math import saturate, remap, lerp + +import logging +import random + +from files.__main__ import app, db_session + +CONFIG_modhat_weight = 4 +CONFIG_admin_volunteer_weight = 4 +CONFIG_new_user_damping = 2 +CONFIG_default_user_accuracy = 0.2 +CONFIG_user_correctness_lerp = 0.2 + +def _compile_records(db): + vrecords = db.query(VolunteerJanitorRecord).order_by(VolunteerJanitorRecord.recorded_utc).all() + + # get the info we need for all mentioned posts + reported_comment_ids = {record.comment_id for record in vrecords} + reported_comments = db.query(Comment).where(Comment.id.in_(reported_comment_ids)).options(sqlalchemy.orm.load_only('id', 'state_user_deleted_utc')) + reported_comments = {comment.id: comment for comment in reported_comments} + + # get our compiled data + records_compiled = {} + for record in vrecords: + # we're just going to ignore deleted comments entirely + if reported_comments[record.comment_id].state_user_deleted_utc != None: + continue + + # unique identifier for user/comment report pair + uic = (record.user_id, record.comment_id) + + if record.result == VolunteerJanitorResult.Pending: + if uic in records_compiled: + # something wonky happened, we went back to pending after getting a result? + records_compiled[uic]["status"] = "wonky" + else: + # fill out the pending field + records_compiled[uic] = {"status": "pending"} + else: + if not uic in records_compiled: + # something wonky happened, we never asked them for the info to begin with + records_compiled[uic] = {"status": "wonky"} + elif records_compiled[uic]["status"] != "pending": + # received two submissions; practically we'll just use their first submission + records_compiled[uic]["status"] = "resubmit" + else: + # actually got a result, yay + records_compiled[uic]["status"] = "submit" + records_compiled[uic]["result"] = record.result + + # todo: + # filter out anything submitted *after* a mod chimed in + # filter out anything submitted too long after the request + + users_compiled = defaultdict(lambda: { + "pending": 0, + "wonky": 0, + "submit": 0, + "resubmit": 0, + }) + for key, result in records_compiled.items(): + #pprint.pprint(key) + userid = key[0] + + users_compiled[key[0]][result["status"]] += 1 + + #pprint.pprint(records_compiled) + #pprint.pprint(users_compiled) + + # strip out invalid records + random_removal = -1 # this is sometimes useful for testing that our algorithm is somewhat stable; removing a random half of all responses shouldn't drastically invert people's quality scores, for example + records_compiled = {key: value for key, value in records_compiled.items() if "result" in value and random.random() > random_removal} + + return records_compiled, users_compiled + +def dbg_commentdump(cid, records_compiled, users, user_accuracy): + print(f"Dump for comment {cid}") + + from tabulate import tabulate + + dats = [] + for key, value in [(key, value) for key, value in records_compiled.items() if key[1] == cid]: + uid = key[0] + dats.append({ + "vote": value["result"], + "username": users[uid]["username"], + "accuracy": user_accuracy[uid], + }) + print(tabulate(dats, headers = "keys")) + +def dbg_userdump(uid, records_compiled, users, comment_calculated_badness_user): + print(f"Dump for user {users[uid]['username']}") + + from tabulate import tabulate + + dats = [] + for key, value in [(key, value) for key, value in records_compiled.items() if key[0] == uid]: + cid = key[1] + bad, weight = evaluate_badness_of(value["result"]) + dats.append({ + "cid": cid, + "vote": value["result"], + "calculated": evaluate_badness_of(value["result"]), + "badness": comment_calculated_badness_user[cid], + "correctness": evaluate_correctness_single(bad, weight, comment_calculated_badness_user[cid]), + }) + print(tabulate(dats, headers = "keys")) + +# Calculates how correct a user is, based on whether they thought it was bad, how confident they were, and how bad we think it is +# Returns (IsCorrect, Confidence) +def evaluate_correctness_single(bad, user_weight, calculated_badness): + # Boolean for whether this comment is bad + calculated_badbool = calculated_badness > 0.5 + + # Boolean for whether the user was correct + correctness_result = (bad == calculated_badbool) and 1 or 0 + + # "how confident are we that this is bad/notbad", range [0, 0.5] + calculated_badness_confidence = abs(calculated_badness - 0.5) + + # "how much do we want this to influence the user's correctness" + # there's a deadzone around not-confident where we just push it to 0 and don't make it relevant + calculated_badness_weight = saturate(remap(calculated_badness_confidence, 0.1, 0.5, 0, 1)) + + # see how correct we think the user is + user_correctness = user_weight * calculated_badness_weight + + return correctness_result, user_correctness + +def volunteer_janitor_recalc(db: Session, diagnostics: bool = False): + logging.info("Starting full janitor recalculation") + + # Get our full list of data + records_compiled, users_compiled = _compile_records(db) + + reporting_user_ids = {record[0] for record in records_compiled} + reported_comment_ids = {record[1] for record in records_compiled} + + # Get some metadata for all reported comments + comments = db.query(Comment) \ + .where(Comment.id.in_(reported_comment_ids)) \ + .options(sqlalchemy.orm.load_only('id', 'created_utc', 'author_id')) + comments = {comment.id: comment for comment in comments} + + reported_user_ids = {comment.author_id for comment in comments.values()} + + # Get mod intervention data + modhats_raw = db.query(Comment) \ + .where(Comment.parent_comment_id.in_(reported_comment_ids)) \ + .where(Comment.distinguish_level > 0) \ + .options(sqlalchemy.orm.load_only('parent_comment_id', 'created_utc')) + + modhats = {} + # we jump through some hoops to deduplicate this; I guess we just pick the last one in our list for now + for modhat in modhats_raw: + modhats[modhat.parent_comment_id] = modhat + + usernotes_raw = db.query(UserNote) \ + .where(UserNote.tag.in_([UserTag.Warning, UserTag.Tempban, UserTag.Permban, UserTag.Spam, UserTag.Bot])) \ + .options(sqlalchemy.orm.load_only('reference_user', 'created_utc', 'tag')) + + # Here we're trying to figure out whether modhats are actually warnings/bans + # We don't have a formal connection between "a comment is bad" and "the user got a warning", so we're kind of awkwardly trying to derive it from our database + # In addition, sometimes someone posts a lot of bad comments and only gets modhatted for one of them + # That doesn't mean the other comments weren't bad + # It just means we picked the worst one + # So we ignore comments near the actual modhat time + + commentresults = {} + for uid in reported_user_ids: + # For each user, figure out when modhats happened + # this is slow but whatever + modhat_times = [] + for modhat in modhats.values(): + if comments[modhat.parent_comment_id].author_id != uid: + continue + + modhat_times.append(modhat.created_utc) + + usernote_times = [] + for usernote in usernotes_raw: + if usernote.reference_user != uid: + continue + + usernote_times.append(usernote.created_utc) + + # For each comment . . . + for comment in comments.values(): + if comment.author_id != uid: + continue + + if comment.id in modhats: + modhat_comment = modhats[comment.id] + else: + modhat_comment = None + + # if the comment was modhatted *and* resulted in a negative usernote near the modhat time, it's bad + if modhat_comment is not None and next((time for time in usernote_times if abs(modhat_comment.created_utc - time) < 60 * 15), None) is not None: + commentresults[comment.id] = "bad" + # otherwise, if the comment was posted less than 48 hours before a negative usernote, we ignore it for processing on the assumption that it may just have been part of a larger warning + elif next((time for time in usernote_times if comment.created_utc < time and comment.created_utc + 48 * 60 * 60 > time), None) is not None: + commentresults[comment.id] = "ignored" + # otherwise, we call it not-bad + else: + commentresults[comment.id] = "notbad" + + # get per-user metadata + users = db.query(User) \ + .where(User.id.in_(reporting_user_ids)) \ + .options(sqlalchemy.orm.load_only('id', 'username', 'admin_level')) + users = {user.id: {"username": user.username, "admin": user.admin_level != 0} for user in users} + + user_accuracy = defaultdict(lambda: CONFIG_default_user_accuracy) + + # Do an update loop! + for lid in range(0, 100): + + # Accumulated weight/badness, taking admin flags into account + # This is used for training + comment_weight_admin = defaultdict(lambda: 0) + comment_badness_admin = defaultdict(lambda: 0) + + # Accumulated weight/badness, not taking admin flags into account + # This is used for output and display + comment_weight_user = defaultdict(lambda: 0) + comment_badness_user = defaultdict(lambda: 0) + + # accumulate modhat weights + for cid in reported_comment_ids: + result = commentresults[cid] + + if result == "ignored": + # I guess we'll just let the users decide? + continue + + if result == "bad": + comment_weight_admin[cid] += CONFIG_modhat_weight + comment_badness_admin[cid] += CONFIG_modhat_weight + + if result == "notbad": + comment_weight_admin[cid] += CONFIG_modhat_weight + + # accumulate volunteer weights + for key, value in records_compiled.items(): + uid, cid = key + + # Calculate how much to weight a user; highly inaccurate users are not inverted! They just don't get contribution + # (losers) + userweight_user = userweight_from_user_accuracy(user_accuracy[uid]); + + if users[uid]["admin"]: + userweight_admin = CONFIG_admin_volunteer_weight + else: + userweight_admin = userweight_user + + bad, weight = evaluate_badness_of(value["result"]) + + # Accumulate these to our buffers + comment_weight_admin[cid] += userweight_admin * weight + comment_weight_user[cid] += userweight_user * weight + + if bad: + comment_badness_admin[cid] += userweight_admin * weight + comment_badness_user[cid] += userweight_user * weight + + # Calculated badnesses, both taking admins into account and not doing so, and "theoretical idea" versus a conversative view designed to be more skeptical of low-weighted comments + comment_calculated_badness_admin = {cid: calculate_final_comment_badness(comment_badness_admin[cid], comment_weight_admin[cid], False) for cid in reported_comment_ids} + comment_calculated_badness_admin_conservative = {cid: calculate_final_comment_badness(comment_badness_admin[cid], comment_weight_admin[cid], True) for cid in reported_comment_ids} + comment_calculated_badness_user = {cid: calculate_final_comment_badness(comment_badness_user[cid], comment_weight_user[cid], False) for cid in reported_comment_ids} + comment_calculated_badness_user_conservative = {cid: calculate_final_comment_badness(comment_badness_user[cid], comment_weight_user[cid], True) for cid in reported_comment_ids} + + # go through user submissions and count up how good users seem to be at this + user_correctness_weight = defaultdict(lambda: CONFIG_new_user_damping) + user_correctness_value = defaultdict(lambda: CONFIG_default_user_accuracy * user_correctness_weight[0]) + + for key, value in records_compiled.items(): + uid, cid = key + + # if this is "ignored", I don't trust that we have a real answer, so we just skip it for training purposes + if commentresults[cid] == "ignored": + continue + + bad, weight = evaluate_badness_of(value["result"]) + + correctness, weight = evaluate_correctness_single(bad, weight, comment_calculated_badness_admin[cid]) + + user_correctness_weight[uid] += weight + user_correctness_value[uid] += correctness * weight + + # calculate new correctnesses + for uid in reporting_user_ids: + target_user_correctness = user_correctness_value[uid] / user_correctness_weight[uid] + + # lerp slowly to the new values + user_accuracy[uid] = lerp(user_accuracy[uid], target_user_correctness, CONFIG_user_correctness_lerp) + + if diagnostics: + # debug print + + from tabulate import tabulate + + commentscores = [{ + "link": f"https://themotte.org/comment/{cid}", + "badness": comment_calculated_badness_admin[cid], + "badnessuser": comment_calculated_badness_user[cid], + "badnessusercons": comment_calculated_badness_user_conservative[cid], + "participation": comment_weight_user[cid], + "mh": commentresults[cid]} for cid in reported_comment_ids] + commentscores.sort(key = lambda item: item["badnessusercons"] + item["badnessuser"] / 100) + print(tabulate(commentscores, headers = "keys")) + + results = [{ + "user": f"https://themotte.org/@{users[uid]['username']}", + "accuracy": user_accuracy[uid], + "submit": users_compiled[uid]["submit"], + "nonsubmit": sum(users_compiled[uid].values()) - users_compiled[uid]["submit"], + "admin": users[uid]["admin"] and "Admin" or "", + } for uid in reporting_user_ids] + results.sort(key = lambda k: k["accuracy"]) + print(tabulate(results, headers = "keys")) + + dbg_commentdump(89681, records_compiled, users, user_accuracy) + print(calculate_final_comment_badness(comment_badness_user[89681], comment_weight_user[89681], True)) + + #dbg_userdump(131, records_compiled, users, comment_calculated_badness_user) + + # Shove all this in the database, yaaay + # Conditional needed because sqlalchemy breaks if you try passing it zero data + if len(user_accuracy) > 0: + db.query(User) \ + .where(User.id.in_([id for id in user_accuracy.keys()])) \ + .update({ + User.volunteer_janitor_correctness: sqlalchemy.sql.case( + user_accuracy, + value = User.id, + ) + }) + db.commit() + + # We don't bother recalculating comment confidences here; it's a pain to do it and they shouldn't change much + + logging.info("Finished full janitor recalculation") + +@app.cli.command('volunteer_janitor_recalc') +def volunteer_janitor_recalc_cmd(): + volunteer_janitor_recalc(db_session(), diagnostics = True) + +def volunteer_janitor_recalc_cron(ctx:TaskRunContext): + volunteer_janitor_recalc(ctx.db) + +def volunteer_janitor_recalc_all_comments(db: Session): + # may as well do this first + volunteer_janitor_recalc(db) + + # I'm not sure of the details here, but there seems to be some session-related caching cruft left around + # so let's just nuke that + db.expire_all() + + # going through all the comments piecemeal like this is hilariously efficient, but this entire system gets run exactly once ever, so, okay + for comment in alive_it(db.query(Comment).join(Comment.reports)): + update_comment_badness(db, comment.id) + + db.commit() + +@app.cli.command('volunteer_janitor_recalc_all_comments') +def volunteer_janitor_recalc_all_comments_cmd(): + volunteer_janitor_recalc_all_comments(db_session()) diff --git a/files/helpers/alerts.py b/files/helpers/alerts.py index cea6d52d9..75927e6e3 100644 --- a/files/helpers/alerts.py +++ b/files/helpers/alerts.py @@ -1,7 +1,9 @@ from files.classes import * from flask import g + from .sanitize import * -from .const import * +from .config.const import * +from files.classes.visstate import StateMod def create_comment(text_html, autojanny=False): if autojanny: author_id = AUTOJANNY_ID @@ -10,7 +12,8 @@ def create_comment(text_html, autojanny=False): new_comment = Comment(author_id=author_id, parent_submission=None, body_html=text_html, - distinguish_level=6) + distinguish_level=6, + state_mod=StateMod.VISIBLE,) g.db.add(new_comment) g.db.flush() @@ -19,7 +22,6 @@ def create_comment(text_html, autojanny=False): return new_comment.id def send_repeatable_notification(uid, text, autojanny=False): - if autojanny: author_id = AUTOJANNY_ID else: author_id = NOTIFICATIONS_ID @@ -38,13 +40,11 @@ def send_repeatable_notification(uid, text, autojanny=False): def send_notification(uid, text, autojanny=False): - cid = notif_comment(text, autojanny) add_notif(cid, uid) def notif_comment(text, autojanny=False): - if autojanny: author_id = AUTOJANNY_ID alert = True @@ -61,7 +61,6 @@ def notif_comment(text, autojanny=False): def notif_comment2(p): - search_html = f'% has mentioned you: %' existing = g.db.query(Comment.id).filter(Comment.author_id == NOTIFICATIONS_ID, Comment.parent_submission == None, Comment.body_html.like(search_html)).first() @@ -69,7 +68,6 @@ def notif_comment2(p): if existing: return existing[0] else: text = f"@{p.author.username} has mentioned you: [{p.title}](/post/{p.id})" - if p.sub: text += f" in /h/{p.sub}" text_html = sanitize(text, alert=True) return create_comment(text_html) @@ -81,11 +79,12 @@ def add_notif(cid, uid): g.db.add(notif) -def NOTIFY_USERS(text, v): +def NOTIFY_USERS(text, v) -> set[int]: notify_users = set() for word, id in NOTIFIED_USERS.items(): if id == 0 or v.id == id: continue - if word in text.lower() and id not in notify_users: notify_users.add(id) + if word in text.lower() and id not in notify_users: + notify_users.add(id) captured = [] for i in mention_regex.finditer(text): @@ -95,6 +94,26 @@ def NOTIFY_USERS(text, v): captured.append(i.group(0)) user = get_user(i.group(2), graceful=True) - if user and v.id != user.id and not v.any_block_exists(user): notify_users.add(user.id) + if user and v.id != user.id and not v.any_block_exists(user): + notify_users.add(user.id) return notify_users + +def notify_submission_publish(target: Submission): + # Username mentions in title & body + text: str = f'{target.title} {target.body}' + notify_users = NOTIFY_USERS(text, target.author) + if notify_users: + comment_id = notif_comment2(target) + for user_id in notify_users: + add_notif(comment_id, user_id) + + # Submission author followers + if target.author.followers: + message: str = ( + f"@{target.author.username} has made a new post: " + f"[{target.title}]({target.shortlink})" + ) + cid = notif_comment(message, autojanny=True) + for follow in target.author.followers: + add_notif(cid, follow.user_id) diff --git a/files/helpers/caching.py b/files/helpers/caching.py new file mode 100644 index 000000000..16094568d --- /dev/null +++ b/files/helpers/caching.py @@ -0,0 +1,25 @@ +from files.__main__ import cache +import files.helpers.listing as listing + +# i hate this. +# +# we should probably come up with a better way for this in the future. +# flask_caching is kinda weird in that it requires you to use a function +# reference to deleted a memoized function, which basically means fitting your +# code to flask_caching's worldview. it's very much not ideal and ideally would +# be less coupled in the future. +# +# the question is whether it's worth it. + +def invalidate_cache(*, frontlist=False, userpagelisting=False, changeloglist=False): + ''' + Invalidates the caches for the front page listing, user page listings, + and optionally, the changelog listing. + + :param frontlist: Whether to invalidate the `frontlist` cache. + :param userpagelisting: Whether to invalidate the `userpagelisting` cache. + :param changeloglist: Whether to invalidate the `changeloglist` cache. + ''' + if frontlist: cache.delete_memoized(listing.frontlist) + if userpagelisting: cache.delete_memoized(listing.userpagelisting) + if changeloglist: cache.delete_memoized(listing.changeloglist) diff --git a/files/helpers/comments.py b/files/helpers/comments.py index 5e04a336e..2f409d983 100644 --- a/files/helpers/comments.py +++ b/files/helpers/comments.py @@ -1,16 +1,20 @@ -from pusher_push_notifications import PushNotifications -from files.classes import Comment, Notification, Subscription, User -from files.helpers.alerts import NOTIFY_USERS -from files.helpers.const import PUSHER_ID, PUSHER_KEY, SITE_ID, SITE_FULL -from files.helpers.assetcache import assetcache_path -from flask import g -from sqlalchemy import select, update -from sqlalchemy.sql.expression import func, text, alias -from sqlalchemy.orm import Query, aliased from sys import stdout -import gevent from typing import Optional +import gevent +from flask import g, request +from pusher_push_notifications import PushNotifications +from sqlalchemy import select, update +from sqlalchemy.orm import Query, aliased +from sqlalchemy.sql.expression import alias, func, text + +from files.classes import Comment, Notification, Subscription, User +from files.classes.visstate import StateMod +from files.helpers.alerts import NOTIFY_USERS +from files.helpers.assetcache import assetcache_path +from files.helpers.config.environment import (PUSHER_ID, PUSHER_KEY, SITE_FULL, + SITE_ID) + if PUSHER_ID != 'blahblahblah': beams_client = PushNotifications(instance_id=PUSHER_ID, secret_key=PUSHER_KEY) @@ -62,8 +66,8 @@ def update_author_comment_count(comment, delta): comment.author.comment_count = g.db.query(Comment).filter( Comment.author_id == comment.author_id, Comment.parent_submission != None, - Comment.is_banned == False, - Comment.deleted_utc == 0, + Comment.state_mod == StateMod.VISIBLE, + Comment.state_user_deleted_utc == None, ).count() g.db.add(comment.author) @@ -197,8 +201,11 @@ def comment_on_publish(comment:Comment): to_notify.add(parent.author_id) for uid in to_notify: - notif = Notification(comment_id=comment.id, user_id=uid) - g.db.add(notif) + notif = g.db.query(Notification) \ + .filter_by(comment_id=comment.id, user_id=uid).one_or_none() + if not notif: + notif = Notification(comment_id=comment.id, user_id=uid) + g.db.add(notif) update_stateful_counters(comment, +1) @@ -212,7 +219,7 @@ def comment_on_publish(comment:Comment): def comment_on_unpublish(comment:Comment): """ Run when a comment becomes invisible: when a moderator makes the comment non-visible - by changing the filter_state to "removed", or when the user deletes the comment. + by changing the state_mod to "removed", or when the user deletes the comment. Should be used to update stateful counters, notifications, etc. that reflect the comments users will actually see. """ @@ -220,13 +227,12 @@ def comment_on_unpublish(comment:Comment): def comment_filter_moderated(q: Query, v: Optional[User]) -> Query: - if not (v and v.shadowbanned) and not (v and v.admin_level > 2): + if not (v and v.shadowbanned) and not (v and v.admin_level >= 3): q = q.join(User, User.id == Comment.author_id) \ .filter(User.shadowbanned == None) if not v or v.admin_level < 2: q = q.filter( - ((Comment.filter_state != 'filtered') - & (Comment.filter_state != 'removed')) + (Comment.state_mod == StateMod.VISIBLE) | (Comment.author_id == ((v and v.id) or 0)) ) return q diff --git a/files/helpers/const.py b/files/helpers/config/const.py similarity index 52% rename from files/helpers/const.py rename to files/helpers/config/const.py index 09a38af34..1305f7b62 100644 --- a/files/helpers/const.py +++ b/files/helpers/config/const.py @@ -1,19 +1,47 @@ import re +import sys from copy import deepcopy -from os import environ +from enum import IntEnum from typing import Final from flask import request -from files.__main__ import db_session -from files.classes.sub import Sub -from files.classes.marsey import Marsey -SITE = environ.get("DOMAIN", '').strip() -SITE_ID = environ.get("SITE_ID", '').strip() -SITE_TITLE = environ.get("SITE_TITLE", '').strip() -SCHEME = environ.get('SCHEME', 'http' if 'localhost' in SITE else 'https') -SITE_FULL = SCHEME + '://' + SITE +class Service(IntEnum): + ''' + An enumeration of services provided by this application + ''' + THEMOTTE = 0 + ''' + TheMotte web application. Handles most routes and tasks performed, + including all non-chat web requests. + ''' + CRON = 1 + ''' + Runs tasks periodicially on a set schedule + ''' + CHAT = 2 + ''' + Chat application. + ''' + MIGRATION = 3 + ''' + Migration mode. Used for performing database migrations + ''' + + @classmethod + def from_argv(cls): + if "db" in sys.argv: + return cls.MIGRATION + if "cron" in sys.argv: + return cls.CRON + if "load_chat" in sys.argv: + return cls.CHAT + return cls.THEMOTTE + + @property + def enable_services(self) -> bool: + return self not in {self.CRON, self.MIGRATION} CC = "COUNTRY CLUB" CC_TITLE = CC.title() @@ -32,13 +60,12 @@ BASEDBOT_ID = 0 GIFT_NOTIF_ID = 9 OWNER_ID = 9 BUG_THREAD = 0 -WELCOME_MSG = f"Welcome to {SITE_TITLE}! Please read [the rules](/rules) first. Then [read some of our current conversations](/) and feel free to comment or post!\n\nWe encourage people to comment even if they aren't sure they fit in; as long as your comment follows [community rules](/rules), we are happy to have posters from all backgrounds, education levels, and specialties." ROLES={} LEADERBOARD_LIMIT: Final[int] = 25 -THEMES = {"TheMotte", "dramblr", "reddit", "transparent", "win98", "dark", - "light", "coffee", "tron", "4chan", "midnight"} +THEMES = ["TheMotte", "dramblr", "reddit", "win98", "dark", + "light", "coffee", "tron", "4chan", "midnight"] SORTS_COMMON = { "top": 'fa-arrow-alt-circle-up', "bottom": 'fa-arrow-alt-circle-down', @@ -54,11 +81,16 @@ SORTS_POSTS = { SORTS_POSTS.update(SORTS_COMMON) SORTS_COMMENTS = SORTS_COMMON -PUSHER_ID = environ.get("PUSHER_ID", "").strip() -PUSHER_KEY = environ.get("PUSHER_KEY", "").strip() -DEFAULT_COLOR = environ.get("DEFAULT_COLOR", "fff").strip() -COLORS = {'ff66ac','805ad5','62ca56','38a169','80ffff','2a96f3','eb4963','ff0000','f39731','30409f','3e98a7','e4432d','7b9ae4','ec72de','7f8fa6', 'f8db58','8cdbe6', DEFAULT_COLOR} +MAX_CONTENT_LENGTH = 16 * 1024 * 1024 +SESSION_COOKIE_SAMESITE = "Lax" +PERMANENT_SESSION_LIFETIME = 60 * 60 * 24 * 365 +DEFAULT_THEME = "TheMotte" +FORCE_HTTPS = 1 +COLORS = {'ff66ac','805ad5','62ca56','38a169','80ffff','2a96f3','eb4963','ff0000','f39731','30409f','3e98a7','e4432d','7b9ae4','ec72de','7f8fa6', 'f8db58','8cdbe6', 'fff'} +SUBMISSION_FLAIR_LENGTH_MAXIMUM: Final[int] = 350 +SUBMISSION_TITLE_LENGTH_MAXIMUM: Final[int] = 500 +SUBMISSION_URL_LENGTH_MAXIMUM: Final[int] = 2048 SUBMISSION_BODY_LENGTH_MAXIMUM: Final[int] = 20000 COMMENT_BODY_LENGTH_MAXIMUM: Final[int] = 10000 MESSAGE_BODY_LENGTH_MAXIMUM: Final[int] = 10000 @@ -72,6 +104,7 @@ ERROR_MESSAGES = { 405: "Something went wrong and it's probably my fault. If you can do it reliably, or it's causing problems for you, please report it!", 409: "There's a conflict between what you're trying to do and what you or someone else has done and because of that you can't do what you're trying to do.", 413: "Max file size is 8 MB", + 415: "That file type isn't allowed to be uploaded here", 422: "Something is wrong about your request. If you keep getting this unexpectedly, please report it!", 429: "Are you hammering the site? Stop that, yo.", 500: "Something went wrong and it's probably my fault. If you can do it reliably, or it's causing problems for you, please report it!", @@ -98,6 +131,7 @@ WERKZEUG_ERROR_DESCRIPTIONS = { } IMAGE_FORMATS = ['png','gif','jpg','jpeg','webp'] +IMAGE_URL_ENDINGS = IMAGE_FORMATS + ['.webp', '.jpg', '.png', '.gif', '.jpeg', '?maxwidth=9999', '&fidelity=high'] VIDEO_FORMATS = ['mp4','webm','mov','avi','mkv','flv','m4v','3gp'] AUDIO_FORMATS = ['mp3','wav','ogg','aac','m4a','flac'] NO_TITLE_EXTENSIONS = IMAGE_FORMATS + VIDEO_FORMATS + AUDIO_FORMATS @@ -108,7 +142,17 @@ FEATURES = { PERMS = { "DEBUG_LOGIN_TO_OTHERS": 3, + "PERFORMANCE_KILL_PROCESS": 3, + "PERFORMANCE_SCALE_UP_DOWN": 3, + "PERFORMANCE_RELOAD": 3, + "PERFORMANCE_STATS": 3, + "POST_COMMENT_MODERATION": 2, + "POST_EDITING": 3, + "SCHEDULER": 2, + "SCHEDULER_POSTS": 2, + "SCHEDULER_TASK_TRACEBACK": 3, "USER_SHADOWBAN": 2, + 'USER_SET_PROFILE_PRIVACY': 2, } AWARDS = {} @@ -132,82 +176,6 @@ NOTIFIED_USERS = { patron = 'Patron' -discounts = { - 69: 0.02, - 70: 0.04, - 71: 0.06, - 72: 0.08, - 73: 0.10, -} - -CF_KEY = environ.get("CF_KEY", "").strip() -CF_ZONE = environ.get("CF_ZONE", "").strip() -CF_HEADERS = {"Authorization": f"Bearer {CF_KEY}", "Content-Type": "application/json"} - -dues = int(environ.get("DUES").strip()) - -christian_emojis = (':#marseyjesus:',':#marseyimmaculate:',':#marseymothermary:',':#marseyfatherjoseph:',':#gigachadorthodox:',':#marseyorthodox:',':#marseyorthodoxpat:') - -db = db_session() -marseys_const = [x[0] for x in db.query(Marsey.name).filter(Marsey.name!='chudsey').all()] -marseys_const2 = marseys_const + ['chudsey','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','0','1','2','3','4','5','6','7','8','9','exclamationpoint','period','questionmark'] -db.close() - -valid_username_chars = 'a-zA-Z0-9_\\-' -valid_username_regex = re.compile("^[a-zA-Z0-9_\\-]{3,25}$", flags=re.A) -mention_regex = re.compile('(^|\\s|

)@(([a-zA-Z0-9_\\-]){1,25})', flags=re.A) -mention_regex2 = re.compile('

@(([a-zA-Z0-9_\\-]){1,25})', flags=re.A) - -valid_password_regex = re.compile("^.{8,100}$", flags=re.A) - -marsey_regex = re.compile("[a-z0-9]{1,30}", flags=re.A) - -tags_regex = re.compile("[a-z0-9: ]{1,200}", flags=re.A) - -valid_sub_regex = re.compile("^[a-zA-Z0-9_\\-]{3,20}$", flags=re.A) - -query_regex = re.compile("(\\w+):(\\S+)", flags=re.A) - -title_regex = re.compile("[^\\w ]", flags=re.A) - -based_regex = re.compile("based and (.{1,20}?)(-| )pilled", flags=re.I|re.A) - -controversial_regex = re.compile('["> ](https:\\/\\/old\\.reddit\\.com/r/[a-zA-Z0-9_]{3,20}\\/comments\\/[\\w\\-.#&/=\\?@%+]{5,250})["< ]', flags=re.A) - -fishylinks_regex = re.compile("https?://\\S+", flags=re.A) - -spoiler_regex = re.compile('''\\|\\|(.+)\\|\\|''', flags=re.A) -reddit_regex = re.compile('(^|\\s|

)\\/?((r|u)\\/(\\w|-){3,25})(?![^<]*<\\/(code|pre|a)>)', flags=re.A) -sub_regex = re.compile('(^|\\s|

)\\/?(h\\/(\\w|-){3,25})', flags=re.A) - -# Bytes that shouldn't be allowed in user-submitted text -# U+200E is LTR toggle, U+200F is RTL toggle, U+200B and U+FEFF are Zero-Width Spaces, -# and U+1242A is a massive and terrifying cuneiform numeral -unwanted_bytes_regex = re.compile("\u200e|\u200f|\u200b|\ufeff|\U0001242a") - -whitespace_regex = re.compile('\\s+') - -strikethrough_regex = re.compile('''~{1,2}([^~]+)~{1,2}''', flags=re.A) - -mute_regex = re.compile("/mute @([a-z0-9_\\-]{3,25}) ([0-9])+", flags=re.A) - -emoji_regex = re.compile(f"[^a]>\\s*(:[!#@]{{0,3}}[{valid_username_chars}]+:\\s*)+<\\/", flags=re.A) -emoji_regex2 = re.compile(f"(?([\\w:~,()\\-.#&\\/=?@%;+]{5,250})<\\/a>', flags=re.A) - -# Technically this allows stuff that is not a valid email address, but realistically -# we care "does this email go to the correct person" rather than "is this email -# address syntactically valid", so if we care we should be sending a confirmation -# link, and otherwise should be pretty liberal in what we accept here. -email_regex = re.compile('[^@]+@[^@]+\\.[^@]+', flags=re.A) - -utm_regex = re.compile('utm_[a-z]+=[a-z0-9_]+&', flags=re.A) -utm_regex2 = re.compile('[?&]utm_[a-z]+=[a-z0-9_]+', flags=re.A) - -YOUTUBE_KEY = environ.get("YOUTUBE_KEY", "").strip() - proxies = {} approved_embed_hosts = [ @@ -265,23 +233,12 @@ approved_embed_hosts = [ ] hosts = "|".join(approved_embed_hosts).replace('.','\\.') - image_check_regex = re.compile(f'!\\[\\]\\(((?!(https:\\/\\/([a-z0-9-]+\\.)*({hosts})\\/|\\/)).*?)\\)', flags=re.A) - embed_fullmatch_regex = re.compile(f'https:\\/\\/([a-z0-9-]+\\.)*({hosts})\\/[\\w:~,()\\-.#&\\/=?@%;+]*', flags=re.A) - video_sub_regex = re.compile(f'(

[^<]*)(https:\\/\\/([a-z0-9-]+\\.)*({hosts})\\/[\\w:~,()\\-.#&\\/=?@%;+]*?\\.(mp4|webm|mov))', flags=re.A) -youtube_regex = re.compile('(

[^<]*)(https:\\/\\/youtube\\.com\\/watch\\?v\\=([a-z0-9-_]{5,20})[\\w\\-.#&/=\\?@%+]*)', flags=re.I|re.A) - -yt_id_regex = re.compile('[a-z0-9-_]{5,20}', flags=re.I|re.A) - -image_regex = re.compile("(^|\\s)(https:\\/\\/[\\w\\-.#&/=\\?@%;+]{5,250}(\\.png|\\.jpg|\\.jpeg|\\.gif|\\.webp|maxwidth=9999|fidelity=high))($|\\s)", flags=re.I|re.A) - procoins_li = (0,2500,5000,10000,25000,50000,125000,250000) -linefeeds_regex = re.compile("([^\\n])\\n([^\\n])", flags=re.A) - -html_title_regex = re.compile("(.{1,200})", flags=re.I) +from files.helpers.config.regex import * def make_name(*args, **kwargs): return request.base_url diff --git a/files/helpers/config/environment.py b/files/helpers/config/environment.py new file mode 100644 index 000000000..e152664da --- /dev/null +++ b/files/helpers/config/environment.py @@ -0,0 +1,109 @@ +''' +Environment data. Please don't use `files.helpers.config.const` for things that +aren't constants. If it's an environment configuration, it should go in here. +''' + +from os import environ + +from files.helpers.strings import bool_from_string + +SITE = environ.get("DOMAIN", '').strip() +SITE_ID = environ.get("SITE_ID").strip() +SITE_TITLE = environ.get("SITE_TITLE").strip() +SCHEME = environ.get('SCHEME', 'http' if 'localhost' in SITE else 'https') + +if "localhost" in SITE: + SITE_FULL = 'http://' + SITE +else: + SITE_FULL = 'https://' + SITE + +WELCOME_MSG = ( + f"Welcome to {SITE_TITLE}! Please read [the rules](/rules) first. " + "Then [read some of our current conversations](/) and feel free to comment " + "or post!\n" + "We encourage people to comment even if they aren't sure they fit in; as " + "long as your comment follows [community rules](/rules), we are happy to " + "have posters from all backgrounds, education levels, and specialties." +) + +SQLALCHEMY_TRACK_MODIFICATIONS = False +DATABASE_URL = environ.get("DATABASE_URL", "postgresql://postgres@localhost:5432") +SECRET_KEY = environ.get('MASTER_KEY', '') +SERVER_NAME = environ.get("DOMAIN").strip() +SESSION_COOKIE_SECURE = "localhost" not in SERVER_NAME +DEFAULT_COLOR = environ.get("DEFAULT_COLOR", "fff").strip() +DEFAULT_TIME_FILTER = environ.get("DEFAULT_TIME_FILTER", "all").strip() +HCAPTCHA_SITEKEY = environ.get("HCAPTCHA_SITEKEY","").strip() +HCAPTCHA_SECRET = environ.get("HCAPTCHA_SECRET","").strip() + +if not SECRET_KEY: + raise Exception("Secret key not set!") + +# spam filter + +SPAM_SIMILARITY_THRESHOLD = float(environ.get("SPAM_SIMILARITY_THRESHOLD", 0.5)) +''' Spam filter similarity threshold (posts) ''' +SPAM_URL_SIMILARITY_THRESHOLD = float(environ.get("SPAM_URL_SIMILARITY_THRESHOLD", 0.1)) +''' Spam filter similarity threshold for URLs (posts) ''' +SPAM_SIMILAR_COUNT_THRESHOLD = int(environ.get("SPAM_SIMILAR_COUNT_THRESHOLD", 10)) +''' Spam filter similarity count (posts) ''' +COMMENT_SPAM_SIMILAR_THRESHOLD = float(environ.get("COMMENT_SPAM_SIMILAR_THRESHOLD", 0.5)) +''' Spam filter similarity threshold (comments)''' +COMMENT_SPAM_COUNT_THRESHOLD = int(environ.get("COMMENT_SPAM_COUNT_THRESHOLD", 10)) +''' Spam filter similarity count (comments) ''' + + +CACHE_REDIS_URL = environ.get("REDIS_URL", "redis://localhost") +MAIL_SERVER = environ.get("MAIL_SERVER", "").strip() +MAIL_PORT = 587 +MAIL_USE_TLS = True +MAIL_USERNAME = environ.get("MAIL_USERNAME", "").strip() +MAIL_PASSWORD = environ.get("MAIL_PASSWORD", "").strip() +DESCRIPTION = environ.get("DESCRIPTION", "DESCRIPTION GOES HERE").strip() +SQLALCHEMY_DATABASE_URI = DATABASE_URL + +MENTION_LIMIT = int(environ.get('MENTION_LIMIT', 100)) +''' Maximum amount of username mentions ''' + +MULTIMEDIA_EMBEDDING_ENABLED = bool_from_string(environ.get('MULTIMEDIA_EMBEDDING_ENABLED', False)) +''' +Whether multimedia will be embedded into a page. Note that this does not +affect posts or comments retroactively. +''' + +RESULTS_PER_PAGE_COMMENTS = int(environ.get('RESULTS_PER_PAGE_COMMENTS', 50)) +SCORE_HIDING_TIME_HOURS = int(environ.get('SCORE_HIDING_TIME_HOURS', 0)) + + +ENABLE_SERVICES = bool_from_string(environ.get('ENABLE_SERVICES', False)) +''' +Whether to start up deferred tasks. Usually `True` when running as an app and +`False` when running as a script (for example to perform migrations). + +See https://github.com/themotte/rDrama/pull/427 for more info. +''' + +DBG_VOLUNTEER_PERMISSIVE = bool_from_string(environ.get('DBG_VOLUNTEER_PERMISSIVE', False)) +VOLUNTEER_JANITOR_ENABLE = bool_from_string(environ.get('VOLUNTEER_JANITOR_ENABLE', True)) + +RATE_LIMITER_ENABLED = not bool_from_string(environ.get('DBG_LIMITER_DISABLED', False)) + +ENABLE_DOWNVOTES = not bool_from_string(environ.get('DISABLE_DOWNVOTES', False)) +CARD_VIEW = bool_from_string(environ.get("CARD_VIEW", True)) +FINGERPRINT_TOKEN = environ.get("FP", None) + +# other stuff from const.py that aren't constants +CLUB_TRUESCORE_MINIMUM = int(environ.get("DUES").strip()) + +IMGUR_KEY = environ.get("IMGUR_KEY", "").strip() +PUSHER_ID = environ.get("PUSHER_ID", "").strip() +PUSHER_KEY = environ.get("PUSHER_KEY", "").strip() + +YOUTUBE_KEY = environ.get("YOUTUBE_KEY", "").strip() + +CF_KEY = environ.get("CF_KEY", "").strip() +CF_ZONE = environ.get("CF_ZONE", "").strip() +CF_HEADERS = { + "Authorization": f"Bearer {CF_KEY}", + "Content-Type": "application/json" +} diff --git a/files/helpers/config/regex.py b/files/helpers/config/regex.py new file mode 100644 index 000000000..2ca85395b --- /dev/null +++ b/files/helpers/config/regex.py @@ -0,0 +1,80 @@ +import re + +# usernames + +valid_username_chars = 'a-zA-Z0-9_\\-' +valid_username_regex = re.compile("^[a-zA-Z0-9_\\-]{3,25}$", flags=re.A) +mention_regex = re.compile('(^|\\s|

)@(([a-zA-Z0-9_\\-]){1,25})', flags=re.A) +mention_regex2 = re.compile('

@(([a-zA-Z0-9_\\-]){1,25})', flags=re.A) + +valid_password_regex = re.compile("^.{8,100}$", flags=re.A) + +marsey_regex = re.compile("[a-z0-9]{1,30}", flags=re.A) + +tags_regex = re.compile("[a-z0-9: ]{1,200}", flags=re.A) + +valid_sub_regex = re.compile("^[a-zA-Z0-9_\\-]{3,20}$", flags=re.A) + +query_regex = re.compile("(\\w+):(\\S+)", flags=re.A) + +title_regex = re.compile("[^\\w ]", flags=re.A) + +based_regex = re.compile("based and (.{1,20}?)(-| )pilled", flags=re.I|re.A) + +controversial_regex = re.compile('["> ](https:\\/\\/old\\.reddit\\.com/r/[a-zA-Z0-9_]{3,20}\\/comments\\/[\\w\\-.#&/=\\?@%+]{5,250})["< ]', flags=re.A) + +fishylinks_regex = re.compile("https?://\\S+", flags=re.A) + +spoiler_regex = re.compile('''\\|\\|(.+)\\|\\|''', flags=re.A) +reddit_regex = re.compile('(^|\\s|

)\\/?((r|u)\\/(\\w|-){3,25})(?![^<]*<\\/(code|pre|a)>)', flags=re.A) +sub_regex = re.compile('(^|\\s|

)\\/?(h\\/(\\w|-){3,25})', flags=re.A) + + +unwanted_bytes_regex = re.compile("\u200e|\u200f|\u200b|\ufeff|\U0001242a") +''' +Bytes that shouldn't be allowed in user-submitted text +U+200E is LTR toggle, U+200F is RTL toggle, U+200B and U+FEFF are Zero-Width +Spaces, and U+1242A is a massive and terrifying cuneiform numeral +''' + +whitespace_regex = re.compile('\\s+') + +strikethrough_regex = re.compile('''~{1,2}([^~]+)~{1,2}''', flags=re.A) + +mute_regex = re.compile("/mute @([a-z0-9_\\-]{3,25}) ([0-9])+", flags=re.A) + +emoji_regex = re.compile(f"[^a]>\\s*(:[!#@]{{0,3}}[{valid_username_chars}]+:\\s*)+<\\/", flags=re.A) +emoji_regex2 = re.compile(f"(?([\\w:~,()\\-.#&\\/=?@%;+]{5,250})<\\/a>', flags=re.A) + +email_regex = re.compile('[^@]+@[^@]+\\.[^@]+', flags=re.A) +''' +Regex to use for email addresses. + +.. note:: + Technically this allows stuff that is not a valid email address, but + realistically we care "does this email go to the correct person" rather + than "is this email address syntactically valid", so if we care we should + be sending a confirmation link, and otherwise should be pretty liberal in + what we accept here. +''' + +utm_regex = re.compile('utm_[a-z]+=[a-z0-9_]+&', flags=re.A) +utm_regex2 = re.compile('[?&]utm_[a-z]+=[a-z0-9_]+', flags=re.A) + + +# urls + +youtube_regex = re.compile('(

[^<]*)(https:\\/\\/youtube\\.com\\/watch\\?v\\=([a-z0-9-_]{5,20})[\\w\\-.#&/=\\?@%+]*)', flags=re.I|re.A) + +yt_id_regex = re.compile('[a-z0-9-_]{5,20}', flags=re.I|re.A) + +image_regex = re.compile("(^|\\s)(https:\\/\\/[\\w\\-.#&/=\\?@%;+]{5,250}(\\.png|\\.jpg|\\.jpeg|\\.gif|\\.webp|maxwidth=9999|fidelity=high))($|\\s)", flags=re.I|re.A) + +linefeeds_regex = re.compile("([^\\n])\\n([^\\n])", flags=re.A) + +html_title_regex = re.compile(r"(.{1,200})", flags=re.I) + +css_url_regex = re.compile(r'url\(\s*[\'"]?(.*?)[\'"]?\s*\)', flags=re.I|re.A) diff --git a/files/helpers/content.py b/files/helpers/content.py new file mode 100644 index 000000000..f6e913c1d --- /dev/null +++ b/files/helpers/content.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import random +import urllib.parse +from typing import TYPE_CHECKING, Any, Optional + +from sqlalchemy.orm import Session + +if TYPE_CHECKING: + from files.classes import Comment, Submission, User + Submittable = Comment | Submission +else: + Submittable = Any + + +def _replace_urls(url:str) -> str: + def _replace_extensions(url:str, exts:list[str]) -> str: + for ext in exts: + url = url.replace(f'.{ext}', '.webp') + return url + + for rd in ("://reddit.com", "://new.reddit.com", "://www.reddit.com", "://redd.it", "://libredd.it", "://teddit.net"): + url = url.replace(rd, "://old.reddit.com") + + url = url.replace("nitter.net", "twitter.com") \ + .replace("old.reddit.com/gallery", "reddit.com/gallery") \ + .replace("https://youtu.be/", "https://youtube.com/watch?v=") \ + .replace("https://music.youtube.com/watch?v=", "https://youtube.com/watch?v=") \ + .replace("https://streamable.com/", "https://streamable.com/e/") \ + .replace("https://youtube.com/shorts/", "https://youtube.com/watch?v=") \ + .replace("https://mobile.twitter", "https://twitter") \ + .replace("https://m.facebook", "https://facebook") \ + .replace("m.wikipedia.org", "wikipedia.org") \ + .replace("https://m.youtube", "https://youtube") \ + .replace("https://www.youtube", "https://youtube") \ + .replace("https://www.twitter", "https://twitter") \ + .replace("https://www.instagram", "https://instagram") \ + .replace("https://www.tiktok", "https://tiktok") + + if "/i.imgur.com/" in url: + url = _replace_extensions(url, ['png', 'jpg', 'jpeg']) + elif "/media.giphy.com/" in url or "/c.tenor.com/" in url: + url = _replace_extensions(url, ['gif']) + elif "/i.ibb.com/" in url: + url = _replace_extensions(url, ['png', 'jpg', 'jpeg', 'gif']) + + if url.startswith("https://streamable.com/") and not url.startswith("https://streamable.com/e/"): + url = url.replace("https://streamable.com/", "https://streamable.com/e/") + return url + + +def _httpsify_and_remove_tracking_urls(url:str) -> urllib.parse.ParseResult: + parsed_url = urllib.parse.urlparse(url) + domain = parsed_url.netloc + is_reddit_twitter_instagram_tiktok:bool = domain in \ + ('old.reddit.com','twitter.com','instagram.com','tiktok.com') + + if is_reddit_twitter_instagram_tiktok: + query = "" + else: + qd = urllib.parse.parse_qs(parsed_url.query) + filtered = {k: val for k, val in qd.items() if not k.startswith('utm_') and not k.startswith('ref_')} + query = urllib.parse.urlencode(filtered, doseq=True) + + new_url = urllib.parse.ParseResult( + scheme="https", + netloc=parsed_url.netloc, + path=parsed_url.path, + params=parsed_url.params, + query=query, + fragment=parsed_url.fragment, + ) + return new_url + + +def canonicalize_url(url:str) -> str: + return _replace_urls(url) + + +def canonicalize_url2(url:str, *, httpsify:bool=False) -> urllib.parse.ParseResult: + url_parsed = _replace_urls(url) + if httpsify: + url_parsed = _httpsify_and_remove_tracking_urls(url) + else: + url_parsed = urllib.parse.urlparse(url) + return url_parsed + + +def body_displayed(target:Submittable, v:Optional[User], is_html:bool) -> str: + moderated:Optional[str] = target.visibility_state.moderated_body( + v=v, + is_blocking=getattr(target, 'is_blocking', False) + ) + if moderated: return moderated + + body = target.body_html if is_html else target.body + if not body: return "" + if not v: return body + + body = body.replace("old.reddit.com", v.reddit) + if v.nitter and '/i/' not in body and '/retweets' not in body: + body = body.replace("www.twitter.com", "nitter.net").replace("twitter.com", "nitter.net") + return body + + +def execute_shadowbanned_fake_votes(db:Session, target:Submittable, v:Optional[User]): + if not target or not v: return + if not v.shadowbanned: return + if v.id != target.author_id: return + if not (86400 > target.age_seconds > 20): return + + ti = max(target.age_seconds // 60, 1) + maxupvotes = min(ti, 11) + rand = random.randint(0, maxupvotes) + if target.upvotes >= rand: return + + amount = random.randint(0, 3) + if amount != 1: return + + if hasattr(target, 'views'): + target.views += amount*random.randint(3, 5) + + target.upvotes += amount + db.add(target) + db.commit() diff --git a/files/helpers/contentsorting.py b/files/helpers/contentsorting.py index 4d60d2fa5..139ea495b 100644 --- a/files/helpers/contentsorting.py +++ b/files/helpers/contentsorting.py @@ -5,7 +5,7 @@ from typing import Any, TYPE_CHECKING from sqlalchemy.sql import func from sqlalchemy.orm import Query -from files.helpers.const import * +from files.helpers.config.const import * if TYPE_CHECKING: from files.classes.comment import Comment diff --git a/files/helpers/embeds.py b/files/helpers/embeds.py new file mode 100644 index 000000000..86b0243da --- /dev/null +++ b/files/helpers/embeds.py @@ -0,0 +1,59 @@ +''' +Assists with adding embeds to submissions. + +This module is not intended to be imported using the `from X import Y` syntax. + +Example usage: + +```py +import files.helpers.embeds as embeds +embeds.youtube("https://www.youtube.com/watch?v=dQw4w9WgXcQ") +``` +''' + +import urllib.parse +from typing import Optional + +import requests + +from files.helpers.config.environment import YOUTUBE_KEY +from files.helpers.config.regex import yt_id_regex + +__all__ = ('twitter', 'youtube',) + +def twitter(url:str) -> Optional[str]: + try: + return requests.get( + url="https://publish.twitter.com/oembed", + params={"url":url, "omit_script":"t"}, timeout=5).json()["html"] + except: + return None + +def youtube(url:str) -> Optional[str]: + if not YOUTUBE_KEY: return None + url = urllib.parse.unquote(url).replace('?t', '&t') + yt_id = url.split('https://youtube.com/watch?v=')[1].split('&')[0].split('%')[0] + + if not yt_id_regex.fullmatch(yt_id): return None + + try: + req = requests.get( + url=f"https://www.googleapis.com/youtube/v3/videos?id={yt_id}&key={YOUTUBE_KEY}&part=contentDetails", + timeout=5).json() + except: + return None + + if not req.get('items'): return None + + params = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) + t = params.get('t', params.get('start', [0]))[0] + if isinstance(t, str): t = t.replace('s','') + + embed = f'' + return embed diff --git a/files/helpers/get.py b/files/helpers/get.py index 68e15792d..bce165f3a 100644 --- a/files/helpers/get.py +++ b/files/helpers/get.py @@ -1,12 +1,14 @@ +from __future__ import annotations + from collections import defaultdict from typing import Callable, Iterable, List, Optional, Type, Union -from flask import g +from flask import abort, g from sqlalchemy import and_, or_, func from sqlalchemy.orm import Query, scoped_session, selectinload from files.classes import * -from files.helpers.const import AUTOJANNY_ID +from files.helpers.config.const import AUTOJANNY_ID from files.helpers.contentsorting import sort_comment_results @@ -78,20 +80,22 @@ def get_account( id:Union[str,int], v:Optional[User]=None, graceful:bool=False, - include_blocks:bool=False) -> Optional[User]: + include_blocks:bool=False, + db:Optional[scoped_session]=None) -> Optional[User]: try: id = int(id) except: if graceful: return None abort(404) - user = g.db.get(User, id) + if not db: db = g.db + user = db.get(User, id) if not user: if graceful: return None abort(404) if v and include_blocks: - user = _add_block_props(user, v) + user = _add_block_props(user, v, db) return user @@ -258,7 +262,7 @@ def get_comments( blocked.c.target_id, ).filter(Comment.id.in_(cids)) - if not (v and (v.shadowbanned or v.admin_level > 1)): + if not (v and (v.shadowbanned or v.admin_level >= 2)): comments = comments.join(User, User.id == Comment.author_id) \ .filter(User.shadowbanned == None) @@ -298,7 +302,6 @@ def get_comment_trees_eager( query_filter_callable: Callable[[Query], Query], sort: str="old", v: Optional[User]=None) -> tuple[list[Comment], defaultdict[Comment, list[Comment]]]: - if v: votes = g.db.query(CommentVote).filter_by(user_id=v.id).subquery() blocking = v.blocking.subquery() @@ -387,8 +390,10 @@ def get_domain(s:str) -> Optional[BannedDomain]: def _add_block_props( target:Union[Submission, Comment, User], - v:Optional[User]): + v:Optional[User], + db:Optional[scoped_session]=None): if not v: return target + if not db: db = g.db id = None if any(isinstance(target, cls) for cls in [Submission, Comment]): @@ -408,7 +413,7 @@ def _add_block_props( target.is_blocked = False return target - block = g.db.query(UserBlock).filter( + block = db.query(UserBlock).filter( or_( and_( UserBlock.user_id == v.id, diff --git a/files/helpers/jinja2.py b/files/helpers/jinja2.py index ce427f2d0..a8f999ef2 100644 --- a/files/helpers/jinja2.py +++ b/files/helpers/jinja2.py @@ -1,14 +1,24 @@ -from os import listdir, environ import random -import time +from os import listdir from jinja2 import pass_context from files.__main__ import app -from .get import * -from .const import * +from files.classes.cron.tasks import ScheduledTaskType +from files.classes.visstate import StateMod, StateReport from files.helpers.assetcache import assetcache_path +from files.helpers.config.environment import (CARD_VIEW, DEFAULT_COLOR, + ENABLE_DOWNVOTES, FINGERPRINT_TOKEN, PUSHER_ID, SITE, SITE_FULL, SITE_ID, + SITE_TITLE) +from files.helpers.time import format_age, format_datetime +from .config.const import * +from .get import * + + +@app.template_filter("computer_size") +def computer_size(size_bytes:int) -> str: + return f'{size_bytes // 1024 // 1024} MiB' @app.template_filter("shuffle") @pass_context @@ -28,36 +38,15 @@ def post_embed(id, v): if p: return render_template("submission_listing.html", listing=[p], v=v) return '' - @app.template_filter("timestamp") def timestamp(timestamp): + if not timestamp: return '' + return format_datetime(timestamp) - age = int(time.time()) - timestamp - - if age < 60: - return "just now" - elif age < 3600: - minutes = int(age / 60) - return f"{minutes}m ago" - elif age < 86400: - hours = int(age / 3600) - return f"{hours}hr ago" - elif age < 2678400: - days = int(age / 86400) - return f"{days}d ago" - - now = time.gmtime() - ctd = time.gmtime(timestamp) - - months = now.tm_mon - ctd.tm_mon + 12 * (now.tm_year - ctd.tm_year) - if now.tm_mday < ctd.tm_mday: - months -= 1 - - if months < 12: - return f"{months}mo ago" - else: - years = int(months / 12) - return f"{years}yr ago" +@app.template_filter("agestamp") +def agestamp(timestamp): + if not timestamp: return '' + return format_age(timestamp) @app.template_filter("asset") @@ -68,7 +57,6 @@ def template_asset(asset_path): @app.context_processor def inject_constants(): return { - "environ":environ, "SITE":SITE, "SITE_ID":SITE_ID, "SITE_TITLE":SITE_TITLE, @@ -81,8 +69,12 @@ def inject_constants(): "CC_TITLE":CC_TITLE, "listdir":listdir, "config":app.config.get, + "ENABLE_DOWNVOTES": ENABLE_DOWNVOTES, + "CARD_VIEW": CARD_VIEW, + "FINGERPRINT_TOKEN": FINGERPRINT_TOKEN, "COMMENT_BODY_LENGTH_MAXIMUM":COMMENT_BODY_LENGTH_MAXIMUM, "SUBMISSION_BODY_LENGTH_MAXIMUM":SUBMISSION_BODY_LENGTH_MAXIMUM, + "SUBMISSION_TITLE_LENGTH_MAXIMUM":SUBMISSION_TITLE_LENGTH_MAXIMUM, "DEFAULT_COLOR":DEFAULT_COLOR, "COLORS":COLORS, "THEMES":THEMES, @@ -92,6 +84,9 @@ def inject_constants(): "SORTS_COMMENTS":SORTS_COMMENTS, "SORTS_POSTS":SORTS_POSTS, "CSS_LENGTH_MAXIMUM":CSS_LENGTH_MAXIMUM, + "ScheduledTaskType":ScheduledTaskType, + "StateMod": StateMod, + "StateReport": StateReport, } diff --git a/files/helpers/lazy.py b/files/helpers/lazy.py index e91cf2b63..0893c27c7 100644 --- a/files/helpers/lazy.py +++ b/files/helpers/lazy.py @@ -1,18 +1,13 @@ -# Prevents certain properties from having to be recomputed each time they -# are referenced - - def lazy(f): - + ''' + Prevents certain properties from having to be recomputed each time they are + referenced + ''' def wrapper(*args, **kwargs): - o = args[0] - if "_lazy" not in o.__dict__: o.__dict__["_lazy"] = {} - - if f.__name__ not in o.__dict__["_lazy"]: o.__dict__["_lazy"][f.__name__] = f(*args, **kwargs) - + if f.__name__ not in o.__dict__["_lazy"]: + o.__dict__["_lazy"][f.__name__] = f(*args, **kwargs) return o.__dict__["_lazy"][f.__name__] - wrapper.__name__ = f.__name__ return wrapper diff --git a/files/helpers/listing.py b/files/helpers/listing.py new file mode 100644 index 000000000..70a1f7960 --- /dev/null +++ b/files/helpers/listing.py @@ -0,0 +1,137 @@ +""" +Module for listings. +""" + +import time +from typing import Final + +from flask import g +from sqlalchemy.sql.expression import not_ +from sqlalchemy import func + +from files.__main__ import cache +from files.classes.submission import Submission +from files.classes.user import User +from files.classes.visstate import StateMod +from files.classes.votes import Vote +from files.helpers.contentsorting import apply_time_filter, sort_objects +from files.helpers.strings import sql_ilike_clean + + +FRONTLIST_TIMEOUT_SECS: Final[int] = 86400 +USERPAGELISTING_TIMEOUT_SECS: Final[int] = 86400 +CHANGELOGLIST_TIMEOUT_SECS: Final[int] = 86400 + +@cache.memoize(timeout=FRONTLIST_TIMEOUT_SECS) +def frontlist(v=None, sort='new', page=1, t="all", ids_only=True, ccmode="false", filter_words='', gt=0, lt=0): + posts = g.db.query(Submission) + + if v and v.hidevotedon: + voted = [x[0] for x in g.db.query(Vote.submission_id).filter_by(user_id=v.id).all()] + posts = posts.filter(Submission.id.notin_(voted)) + + if not v or v.admin_level < 2: + filter_clause = Submission.state_mod == StateMod.VISIBLE + if v: + filter_clause = filter_clause | (Submission.author_id == v.id) + posts = posts.filter(filter_clause) + + if gt: posts = posts.filter(Submission.created_utc > gt) + if lt: posts = posts.filter(Submission.created_utc < lt) + + if not gt and not lt: + posts = apply_time_filter(posts, t, Submission) + + if (ccmode == "true"): + posts = posts.filter(Submission.club == True) + + posts = posts.filter_by(private=False, state_user_deleted_utc=None) + + if ccmode == "false" and not gt and not lt: + posts = posts.filter_by(stickied=None) + + if v and v.admin_level < 2: + posts = posts.filter(Submission.author_id.notin_(v.userblocks)) + + if not (v and v.changelogsub): + posts = posts.filter(not_(Submission.title.ilike('[changelog]%'))) + + if v and filter_words: + for word in filter_words: + word = sql_ilike_clean(word).strip() + posts = posts.filter(not_(Submission.title.ilike(f'%{word}%'))) + + if not (v and v.shadowbanned): + posts = posts.join(User, User.id == Submission.author_id).filter(User.shadowbanned == None) + + posts = sort_objects(posts, sort, Submission) + + if v: + size = v.frontsize or 25 + else: + size = 25 + + posts = posts.offset(size * (page - 1)).limit(size+1).all() + + next_exists = (len(posts) > size) + + posts = posts[:size] + + if page == 1 and ccmode == "false" and not gt and not lt: + pins = g.db.query(Submission).filter(Submission.stickied != None, Submission.state_mod == StateMod.VISIBLE) + if v: + if v.admin_level < 2: + pins = pins.filter(Submission.author_id.notin_(v.userblocks)) + + pins = pins.all() + + for pin in pins: + if pin.stickied_utc and int(time.time()) > pin.stickied_utc: + pin.stickied = None + pin.stickied_utc = None + g.db.add(pin) + pins.remove(pin) + + posts = pins + posts + + if ids_only: posts = [x.id for x in posts] + + g.db.commit() + + return posts, next_exists + + +@cache.memoize(timeout=USERPAGELISTING_TIMEOUT_SECS) +def userpagelisting(u:User, v=None, page=1, sort="new", t="all"): + if u.shadowbanned and not (v and (v.admin_level >= 2 or v.id == u.id)): return [] + + posts = g.db.query(Submission.id).filter_by(author_id=u.id, is_pinned=False) + + if not (v and (v.admin_level >= 2 or v.id == u.id)): + posts = posts.filter_by(state_user_deleted_utc=None, state_mod=StateMod.VISIBLE, private=False, ghost=False) + + posts = apply_time_filter(posts, t, Submission) + posts = sort_objects(posts, sort, Submission) + + posts = posts.offset(25 * (page - 1)).limit(26).all() + + return [x[0] for x in posts] + + +@cache.memoize(timeout=CHANGELOGLIST_TIMEOUT_SECS) +def changeloglist(v=None, sort="new", page=1, t="all"): + posts = g.db.query(Submission.id).filter_by(state_mod=StateMod.VISIBLE, private=False,).filter(Submission.state_user_deleted_utc == None) + + if v.admin_level < 2: + posts = posts.filter(Submission.author_id.notin_(v.userblocks)) + + admins = [x[0] for x in g.db.query(User.id).filter(User.admin_level > 0).all()] + posts = posts.filter(Submission.title.ilike('_changelog%'), Submission.author_id.in_(admins)) + + if t != 'all': + posts = apply_time_filter(posts, t, Submission) + posts = sort_objects(posts, sort, Submission) + + posts = posts.offset(25 * (page - 1)).limit(26).all() + + return [x[0] for x in posts] diff --git a/files/helpers/math.py b/files/helpers/math.py new file mode 100644 index 000000000..85d95a8ef --- /dev/null +++ b/files/helpers/math.py @@ -0,0 +1,15 @@ + +def remap(input: float, smin: float, smax: float, emin: float, emax: float) -> float: + t = (input - smin) / (smax - smin) + return (1 - t) * emin + t * emax + +def clamp(input: float, min: float, max: float) -> float: + if input < min: return min + if input > max: return max + return input + +def saturate(input: float) -> float: + return clamp(input, 0, 1) + +def lerp(a: float, b: float, t: float) -> float: + return (1 - t) * a + t * b diff --git a/files/helpers/sanitize.py b/files/helpers/sanitize.py index 8b6035a9f..110f7a8e7 100644 --- a/files/helpers/sanitize.py +++ b/files/helpers/sanitize.py @@ -1,19 +1,27 @@ import functools import html -import bleach -from bs4 import BeautifulSoup -from bleach.linkifier import LinkifyFilter, build_url_re -from functools import partial -from .get import * -from os import path, environ import re -from mistletoe import markdown -from json import loads, dump -from random import random, choice +import urllib.parse +from functools import partial +from os import path +from typing import Optional + +import bleach import gevent -import time -import requests -from files.__main__ import app +from bleach.linkifier import LinkifyFilter, build_url_re +from bs4 import BeautifulSoup +from flask import abort, g +from mistletoe import markdown + +from files.classes.domains import BannedDomain +from files.classes.marsey import Marsey +from files.helpers.config.const import (embed_fullmatch_regex, + image_check_regex, video_sub_regex) +from files.helpers.config.environment import (MENTION_LIMIT, + MULTIMEDIA_EMBEDDING_ENABLED, + SITE_FULL) +from files.helpers.config.regex import * +from files.helpers.get import get_user, get_users TLDS = ('ac','ad','ae','aero','af','ag','ai','al','am','an','ao','aq','ar', 'arpa','as','asia','at','au','aw','ax','az','ba','bb','bd','be','bf','bg', @@ -42,7 +50,7 @@ allowed_tags = ('b','blockquote','br','code','del','em','h1','h2','h3','h4', 'tbody','th','thead','td','tr','ul','a','span','ruby','rp','rt', 'spoiler',) -if app.config['MULTIMEDIA_EMBEDDING_ENABLED']: +if MULTIMEDIA_EMBEDDING_ENABLED: allowed_tags += ('img', 'lite-youtube', 'video', 'source',) @@ -118,11 +126,9 @@ def render_emoji(html, regexp, edit, marseys_used=set(), b=False): emoji = i.group(1).lower() attrs = '' if b: attrs += ' b' - if not edit and len(emojis) <= 20 and random() < 0.0025 and ('marsey' in emoji or emoji in marseys_const2): attrs += ' g' old = emoji emoji = emoji.replace('!','').replace('#','') - if emoji == 'marseyrandom': emoji = choice(marseys_const2) emoji_partial_pat = ':{0}:' emoji_partial = ':{0}:' @@ -178,10 +184,7 @@ def sanitize_raw(sanitized:Optional[str], allow_newlines:bool, length_limit:Opti @with_gevent_timeout(2) def sanitize(sanitized, alert=False, comment=False, edit=False): - # double newlines, eg. hello\nworld becomes hello\n\nworld, which later becomes

hello

world

- sanitized = linefeeds_regex.sub(r'\1\n\n\2', sanitized) - - if app.config['MULTIMEDIA_EMBEDDING_ENABLED']: + if MULTIMEDIA_EMBEDDING_ENABLED: # turn eg. https://wikipedia.org/someimage.jpg into ![](https://wikipedia.org/someimage.jpg) sanitized = image_regex.sub(r'\1![](\2)\4', sanitized) @@ -219,19 +222,19 @@ def sanitize(sanitized, alert=False, comment=False, edit=False): names = set(m.group(2) for m in matches) users = get_users(names,graceful=True) - if len(users) > app.config['MENTION_LIMIT']: - abort(400, f'Mentioned {len(users)} users but limit is {app.config["MENTION_LIMIT"]}') + if len(users) > MENTION_LIMIT: + abort(400, f'Mentioned {len(users)} users but limit is {MENTION_LIMIT}') for u in users: if not u: continue - m = [ m for m in matches if u.username == m.group(2) or u.original_username == m.group(2) ] - for i in m: - if not (g.v and g.v.any_block_exists(u)) or g.v.admin_level > 1: + mention_is_u = lambda m: m.group(2).lower() in (u.username.lower(), u.original_username.lower()) + for i in filter(mention_is_u, matches): + if not (g.v and g.v.any_block_exists(u)) or g.v.admin_level >= 2: sanitized = sanitized.replace(i.group(0), f'''{i.group(1)}@{u.username}''', 1) soup = BeautifulSoup(sanitized, 'lxml') - if app.config['MULTIMEDIA_EMBEDDING_ENABLED']: + if MULTIMEDIA_EMBEDDING_ENABLED: for tag in soup.find_all("img"): if tag.get("src") and not tag["src"].startswith('/pp/'): tag["loading"] = "lazy" @@ -280,13 +283,13 @@ def sanitize(sanitized, alert=False, comment=False, edit=False): if "https://youtube.com/watch?v=" in sanitized: sanitized = sanitized.replace("?t=", "&t=") - if app.config['MULTIMEDIA_EMBEDDING_ENABLED']: + if MULTIMEDIA_EMBEDDING_ENABLED: captured = [] for i in youtube_regex.finditer(sanitized): if i.group(0) in captured: continue captured.append(i.group(0)) - params = parse_qs(urlparse(i.group(2).replace('&','&')).query) + params = urllib.parse.parse_qs(urllib.parse.urlparse(i.group(2).replace('&','&')).query) t = params.get('t', params.get('start', [0]))[0] if isinstance(t, str): t = t.replace('s','') @@ -296,7 +299,7 @@ def sanitize(sanitized, alert=False, comment=False, edit=False): sanitized = sanitized.replace(i.group(0), htmlsource) - if app.config['MULTIMEDIA_EMBEDDING_ENABLED']: + if MULTIMEDIA_EMBEDDING_ENABLED: sanitized = video_sub_regex.sub(r'\1', sanitized) if comment: @@ -317,8 +320,6 @@ def sanitize(sanitized, alert=False, comment=False, edit=False): strip=True, ).clean(sanitized) - - soup = BeautifulSoup(sanitized, 'lxml') links = soup.find_all("a") @@ -330,7 +331,7 @@ def sanitize(sanitized, alert=False, comment=False, edit=False): href = link.get("href") if not href: continue - url = urlparse(href) + url = urllib.parse.urlparse(href) domain = url.netloc url_path = url.path domain_list.add(domain+url_path) @@ -377,4 +378,6 @@ def validate_css(css:str) -> tuple[bool, str]: practical concern) or causing styling issues with the rest of the page. ''' if ' bool: +def bool_from_string(input: typing.Union[str, int, bool]) -> bool: if isinstance(input, bool): return input + elif isinstance(input, int): + return bool(input) if input.lower() in ("yes", "true", "t", "on", "1"): return True if input.lower() in ("no", "false", "f", "off", "0"): diff --git a/files/helpers/time.py b/files/helpers/time.py new file mode 100644 index 000000000..3257fd1e2 --- /dev/null +++ b/files/helpers/time.py @@ -0,0 +1,71 @@ +import calendar +import time +from datetime import datetime, timedelta +from typing import Final, Union + +DATE_FORMAT: Final[str] = '%Y %B %d' +DATETIME_FORMAT: Final[str] = '%Y %B %d %H:%M:%S UTC' + +AgeFormattable = Union[int, timedelta] +TimestampFormattable = Union[int, float, datetime, time.struct_time] + +def format_datetime(timestamp: TimestampFormattable | None) -> str: + return _format_timestamp(timestamp, DATETIME_FORMAT) + + +def format_date(timestamp: TimestampFormattable | None) -> str: + return _format_timestamp(timestamp, DATE_FORMAT) + + +def format_age(timestamp: TimestampFormattable | None) -> str: + if timestamp is None: + return "" + + timestamp = _make_timestamp(timestamp) + age:int = int(time.time()) - timestamp + + if age < 60: return "just now" + if age < 3600: + minutes = int(age / 60) + return f"{minutes}m ago" + if age < 86400: + hours = int(age / 3600) + return f"{hours}hr ago" + if age < 2678400: + days = int(age / 86400) + return f"{days}d ago" + + now = time.gmtime() + ctd = time.gmtime(timestamp) + + months = now.tm_mon - ctd.tm_mon + 12 * (now.tm_year - ctd.tm_year) + if now.tm_mday < ctd.tm_mday: + months -= 1 + + if months < 12: + return f"{months}mo ago" + else: + years = int(months / 12) + return f"{years}yr ago" + + +def _format_timestamp(timestamp: TimestampFormattable | None, format: str) -> str: + if timestamp is None: + return "" + elif isinstance(timestamp, datetime): + return timestamp.strftime(format) + elif isinstance(timestamp, (int, float)): + timestamp = time.gmtime(timestamp) + elif not isinstance(timestamp, time.struct_time): + raise TypeError("Invalid argument type (must be one of int, float, " + "datettime, or struct_time)") + return time.strftime(format, timestamp) + + +def _make_timestamp(timestamp: TimestampFormattable) -> int: + if isinstance(timestamp, (int, float)): + return int(timestamp) + if isinstance(timestamp, datetime): + return int(timestamp.timestamp()) + if isinstance(timestamp, time.struct_time): + return calendar.timegm(timestamp) diff --git a/files/helpers/validators.py b/files/helpers/validators.py new file mode 100644 index 000000000..d5bf8fbf5 --- /dev/null +++ b/files/helpers/validators.py @@ -0,0 +1,192 @@ +import shutil +import time +import urllib.parse +from dataclasses import dataclass +from typing import Optional + +from flask import Request, abort, request +from werkzeug.datastructures import FileStorage + +import files.helpers.embeds as embeds +import files.helpers.sanitize as sanitize +from files.helpers.config.environment import SITE_FULL, YOUTUBE_KEY +from files.helpers.config.const import (SUBMISSION_BODY_LENGTH_MAXIMUM, + SUBMISSION_TITLE_LENGTH_MAXIMUM, + SUBMISSION_URL_LENGTH_MAXIMUM) +from files.helpers.content import canonicalize_url2 +from files.helpers.media import process_image + + +def guarded_value(val:str, min_len:int, max_len:int) -> str: + ''' + Get request value `val` and ensure it is within length constraints + Requires a request context and either aborts early or returns a good value + ''' + raw = request.values.get(val, '').strip() + raw = raw.replace('\u200e', '') + + if len(raw) < min_len: abort(400, f"Minimum length for {val} is {min_len}") + if len(raw) > max_len: abort(400, f"Maximum length for {val} is {max_len}") + # TODO: it may make sense to do more sanitisation here + return raw + + +def int_ranged(val:str, min:int, max:int) -> int: + raw:Optional[int] = request.values.get(val, default=None, type=int) + if raw is None or raw < min or raw > max: + abort(400, + f"Invalid input ('{val}' must be an integer and be between {min} and {max})") + return raw + +@dataclass(frozen=True, kw_only=True, slots=True) +class ValidatedSubmissionLike: + title: str + title_html: str + body: str + body_raw: Optional[str] + body_html: str + url: Optional[str] + thumburl: Optional[str] + + @property + def embed_slow(self) -> Optional[str]: + url:Optional[str] = self.url + url_canonical: Optional[urllib.parse.ParseResult] = self.url_canonical + if not url or not url_canonical: return None + + embed:Optional[str] = None + domain:str = url_canonical.netloc + + if domain == "twitter.com": + embed = embeds.twitter(url) + + if url.startswith('https://youtube.com/watch?v=') and YOUTUBE_KEY: + embed = embeds.youtube(url) + + if SITE_FULL in domain and "/post/" in url and "context" not in url: + id = url.split("/post/")[1] + if "/" in id: id = id.split("/")[0] + embed = str(int(id)) + + return embed if embed and len(embed) <= 1500 else None + + @property + def repost_search_url(self) -> Optional[str]: + search_url = self.url_canonical_str + if not search_url: return None + + if search_url.endswith('/'): + search_url = search_url[:-1] + return search_url + + @property + def url_canonical(self) -> Optional[urllib.parse.ParseResult]: + if not self.url: return None + return canonicalize_url2(self.url, httpsify=True) + + @property + def url_canonical_str(self) -> Optional[str]: + url_canonical:Optional[urllib.parse.ParseResult] = self.url_canonical + if not url_canonical: return None + return url_canonical.geturl() + + @classmethod + def from_flask_request(cls, + request:Request, + *, + allow_embedding:bool, + allow_media_url_upload:bool=True, + embed_url_file_key:str="file2", + edit:bool=False) -> "ValidatedSubmissionLike": + ''' + Creates the basic structure for a submission and validating it. The + normal submission API has a lot of duplicate code and while this is not + a pretty solution, this essentially forces all submission-likes through + a central interface. + + :param request: The Flask Request object. + :param allow_embedding: Whether to allow embedding. This should usually + be the value from the environment. + :param allow_media_url_upload: Whether to allow media URL upload. This + should generally be `True` for submission submitting if file uploads + are allowed and `False` in other contexts (such as editing) + :param embed_url_file_key: The key to use for inline file uploads. + :param edit: The value of `edit` to pass to `sanitize` + ''' + + def _process_media(file:Optional[FileStorage]) -> tuple[bool, Optional[str], Optional[str]]: + if request.headers.get("cf-ipcountry") == "T1": # forbid Tor uploads + return False, None, None + elif not file: + # We actually care about falseyness, not just `is not None` because + # no attachment is + # (at least from Firefox 111). + return False, None, None + elif not file.content_type.startswith('image/'): + abort(415, "Image files only") + + name = f'/images/{time.time()}'.replace('.','') + '.webp' + file.save(name) + url:Optional[str] = process_image(name) + if not url: return False, None, None + + name2 = name.replace('.webp', 'r.webp') + shutil.copyfile(name, name2) + thumburl:Optional[str] = process_image(name2, resize=100) + return True, url, thumburl + + def _process_media2(body:str, file2:Optional[list[FileStorage]]) -> tuple[bool, str]: + if request.headers.get("cf-ipcountry") == "T1": # forbid Tor uploads + return False, body + elif not file2: # empty list or None + return False, body + file2 = file2[:4] + if not all(file for file in file2): + # Falseyness check to handle <'' ('application/octet-stream')> + return False, body + + for file in file2: + if not file.content_type.startswith('image/'): + abort(415, "Image files only") + + name = f'/images/{time.time()}'.replace('.','') + '.webp' + file.save(name) + image = process_image(name) + if allow_embedding: + body += f"\n\n![]({image})" + else: + body += f'\n\n{image}' + return True, body + + title = guarded_value("title", 1, SUBMISSION_TITLE_LENGTH_MAXIMUM) + title = sanitize.sanitize_raw(title, allow_newlines=False, length_limit=SUBMISSION_TITLE_LENGTH_MAXIMUM) + + url = guarded_value("url", 0, SUBMISSION_URL_LENGTH_MAXIMUM) + + body_raw = guarded_value("body", 0, SUBMISSION_BODY_LENGTH_MAXIMUM) + body_raw = sanitize.sanitize_raw(body_raw, allow_newlines=True, length_limit=SUBMISSION_BODY_LENGTH_MAXIMUM) + + if not url and allow_media_url_upload: + has_file, url, thumburl = _process_media(request.files.get("file")) + else: + has_file = False + thumburl = None + + has_file2, body = _process_media2(body_raw, request.files.getlist(embed_url_file_key)) + + if not body_raw and not url and not has_file and not has_file2: + raise ValueError("Please enter a URL or some text") + + title_html = sanitize.filter_emojis_only(title, graceful=True) + if len(title_html) > 1500: + raise ValueError("Rendered title is too big!") + + return ValidatedSubmissionLike( + title=title, + title_html=sanitize.filter_emojis_only(title, graceful=True), + body=body, + body_raw=body_raw, + body_html=sanitize.sanitize(body, edit=edit), + url=url, + thumburl=thumburl, + ) diff --git a/files/helpers/volunteer_janitor.py b/files/helpers/volunteer_janitor.py new file mode 100644 index 000000000..5f91fc59b --- /dev/null +++ b/files/helpers/volunteer_janitor.py @@ -0,0 +1,87 @@ + +from files.classes.comment import Comment +from files.classes.volunteer_janitor import VolunteerJanitorRecord, VolunteerJanitorResult +from files.helpers.math import saturate, remap + +# Returns (IsBad, Confidence) +def evaluate_badness_of(choice): + if choice == VolunteerJanitorResult.Warning: + return True, 1 + if choice == VolunteerJanitorResult.Ban: + return True, 1 + + # treating this like a low-weight bad response + if choice == VolunteerJanitorResult.Bad: + return True, 0.5 + + # treating this like a low-weight not-bad response + if choice == VolunteerJanitorResult.Neutral: + return False, 0.5 + + return False, 1 + +def userweight_from_user_accuracy(accuracy): + return saturate(remap(accuracy, 0.5, 1, 0, 1)) + +def calculate_final_comment_badness(comment_total, comment_weight, conservative): + if not conservative: + # insert an artificial 50%-badness confident vote, to prevent us from ever reaching 1.0 or 0.0 + return (comment_total + 0.5) / (comment_weight + 1.0) + + if comment_weight == 0: + # INSUFFICIENT DATA FOR A MEANINGFUL ANSWER + return 0.5 + + original_badness = calculate_final_comment_badness(comment_total, comment_weight, False) + if original_badness > 0.5: + # fake a not-bad vote, with the confidence being the opposite of our confidence in it being bad + # don't let it invert though + forged_weight = 1.0 - original_badness + calculated_badness = max(comment_total / (comment_weight + forged_weight), 0.5) + else: + # fake a bad vote, with the confidence being the opposite of our confidence in it being not-bad + # don't let it invert though + forged_weight = original_badness + calculated_badness = min((comment_total + forged_weight) / (comment_weight + forged_weight), 0.5) + + return calculated_badness + +def update_comment_badness(db, cid, diagnostics: bool = False): + # Recalculate the comment's confidence values + # This probably does more SQL queries than it should + records = db.query(VolunteerJanitorRecord) \ + .where(VolunteerJanitorRecord.comment_id == cid) \ + .order_by(VolunteerJanitorRecord.recorded_utc) + + user_has_pending = {} + earliest_submission = {} + + for rec in records: + if rec.result == VolunteerJanitorResult.Pending: + user_has_pending[rec.user_id] = True + else: + if rec.user_id in user_has_pending: + if rec.user_id not in earliest_submission or earliest_submission[rec.user_id].recorded_utc > rec.recorded_utc: + earliest_submission[rec.user_id] = rec + + badness = 0 + weight = 0 + + for submission in earliest_submission.values(): + userweight_user = userweight_from_user_accuracy(submission.user.volunteer_janitor_correctness); + submission_bad, submission_weight = evaluate_badness_of(submission.result) + + additive_weight = submission_weight * userweight_user + + weight += additive_weight + if submission_bad: + badness += additive_weight + + comment_badness = calculate_final_comment_badness(badness, weight, True) + + db.query(Comment) \ + .where(Comment.id == cid) \ + .update({Comment.volunteer_janitor_badness: comment_badness}) + + if diagnostics: + print(f"Updated comment {cid} to {comment_badness}") diff --git a/files/helpers/wrappers.py b/files/helpers/wrappers.py index f61bb0b42..0899a4c16 100644 --- a/files/helpers/wrappers.py +++ b/files/helpers/wrappers.py @@ -1,11 +1,16 @@ -from .get import * -from .alerts import * -from files.helpers.const import * -from files.__main__ import db_session -from random import randint -import user_agents +import functools import time +import user_agents + +from files.helpers.alerts import * +from files.helpers.config.const import * +from files.helpers.config.environment import SITE +from files.helpers.get import * +from files.routes.importstar import * +from files.__main__ import app, cache, db_session + + def get_logged_in_user(): if hasattr(g, 'v'): return g.v @@ -80,68 +85,52 @@ def get_logged_in_user(): g.v = v return v + def check_ban_evade(v): if v and not v.patron and v.admin_level < 2 and v.ban_evade and not v.unban_utc: v.shadowbanned = "AutoJanny" g.db.add(v) g.db.commit() + def auth_desired(f): + @functools.wraps(f) def wrapper(*args, **kwargs): v = get_logged_in_user() - check_ban_evade(v) - return make_response(f(*args, v=v, **kwargs)) - - wrapper.__name__ = f.__name__ return wrapper def auth_required(f): - + @functools.wraps(f) def wrapper(*args, **kwargs): v = get_logged_in_user() if not v: abort(401) - check_ban_evade(v) - return make_response(f(*args, v=v, **kwargs)) - - wrapper.__name__ = f.__name__ return wrapper def is_not_permabanned(f): - + @functools.wraps(f) def wrapper(*args, **kwargs): v = get_logged_in_user() if not v: abort(401) - check_ban_evade(v) - if v.is_suspended_permanently: abort(403, "You are permanently banned") - return make_response(f(*args, v=v, **kwargs)) - - wrapper.__name__ = f.__name__ return wrapper def admin_level_required(x): - def wrapper_maker(f): - + @functools.wraps(f) def wrapper(*args, **kwargs): v = get_logged_in_user() if not v: abort(401) - if v.admin_level < x: abort(403) - return make_response(f(*args, v=v, **kwargs)) - - wrapper.__name__ = f.__name__ return wrapper - return wrapper_maker diff --git a/files/mail/__init__.py b/files/mail/__init__.py index 0bd51b228..51a3a0f9c 100644 --- a/files/mail/__init__.py +++ b/files/mail/__init__.py @@ -1,30 +1,28 @@ -from os import environ import time -from flask import * from urllib.parse import quote -from files.helpers.security import * -from files.helpers.wrappers import * -from files.helpers.const import * -from files.classes import * -from files.__main__ import app, mail, limiter from flask_mail import Message -SITE_ID = environ.get("SITE_ID").strip() -SITE_TITLE = environ.get("SITE_TITLE").strip() +from files.__main__ import app, limiter, mail +from files.classes.badges import Badge +from files.classes.user import User +from files.helpers.config.const import * +from files.helpers.config.environment import SERVER_NAME, SITE_ID, SITE_TITLE +from files.helpers.security import * +from files.helpers.wrappers import * +from files.routes.importstar import * + def send_mail(to_address, subject, html): - msg = Message(html=html, subject=subject, sender=f"{SITE_ID}@{SITE}", recipients=[to_address]) mail.send(msg) def send_verification_email(user, email=None): - if not email: email = user.email - url = f"https://{app.config['SERVER_NAME']}/activate" + url = f"https://{SERVER_NAME}/activate" now = int(time.time()) token = generate_hash(f"{email}+{user.id}+{now}") @@ -44,16 +42,13 @@ def send_verification_email(user, email=None): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def api_verify_email(v): - send_verification_email(v) - return {"message": "Email has been sent (ETA ~5 minutes)"} @app.get("/activate") @auth_required def activate(v): - email = request.values.get("email", "").strip().lower() if not email_regex.fullmatch(email): diff --git a/files/routes/__init__.py b/files/routes/__init__.py index c6208092b..7c8ae3700 100644 --- a/files/routes/__init__.py +++ b/files/routes/__init__.py @@ -1,4 +1,5 @@ from files.__main__ import app +from files.helpers.config.const import FEATURES from .admin import * from .comments import * @@ -19,4 +20,3 @@ if FEATURES['AWARDS']: from .volunteer import * if app.debug: from .dev import * -# from .subs import * diff --git a/files/routes/admin/__init__.py b/files/routes/admin/__init__.py new file mode 100644 index 000000000..c7fef8c5a --- /dev/null +++ b/files/routes/admin/__init__.py @@ -0,0 +1,3 @@ +from .admin import * +from .performance import * +from .tasks import * diff --git a/files/routes/admin.py b/files/routes/admin/admin.py similarity index 87% rename from files/routes/admin.py rename to files/routes/admin/admin.py index 1767462ad..8fc7d3074 100644 --- a/files/routes/admin.py +++ b/files/routes/admin/admin.py @@ -1,19 +1,23 @@ +import json import time +from datetime import datetime -from files.helpers.wrappers import * +import requests + +from files.classes import * +from files.classes.visstate import StateMod, StateReport from files.helpers.alerts import * -from files.helpers.sanitize import * -from files.helpers.security import * +from files.helpers.caching import invalidate_cache +from files.helpers.comments import comment_on_publish, comment_on_unpublish +from files.helpers.config.const import * +from files.helpers.config.environment import CF_HEADERS, CF_ZONE from files.helpers.get import * from files.helpers.media import * -from files.helpers.const import * -from files.classes import * -from flask import * +from files.helpers.sanitize import * +from files.helpers.security import * +from files.helpers.wrappers import * from files.__main__ import app, cache, limiter -from .front import frontlist -from files.helpers.comments import comment_on_publish, comment_on_unpublish -from datetime import datetime -import requests +from files.routes.importstar import * month = datetime.now().strftime('%B') @@ -56,6 +60,7 @@ def remove_admin(v, username): g.db.commit() return {"message": "Admin removed!"} + @app.post("/@/delete_note/") @limiter.exempt @admin_level_required(2) @@ -67,11 +72,11 @@ def delete_note(v,username,id): 'success':True, 'message': 'Note deleted', 'note': id }), 200) + @app.post("/@/create_note") @limiter.exempt @admin_level_required(2) def create_note(v,username): - def result(msg,succ,note): return make_response(jsonify({ 'success':succ, 'message': msg, 'note': note @@ -108,6 +113,7 @@ def create_note(v,username): return result('Note saved',True,note.json()) + @app.post("/@/revert_actions") @limiter.exempt @admin_level_required(3) @@ -124,15 +130,15 @@ def revert_actions(v, username): cutoff = int(time.time()) - 86400 - posts = [x[0] for x in g.db.query(ModAction.target_submission_id).filter(ModAction.user_id == user.id, ModAction.created_utc > cutoff, ModAction.kind == 'ban_post').all()] + posts = [x[0] for x in g.db.query(ModAction.target_submission_id).filter(ModAction.user_id == user.id, ModAction.created_utc > cutoff, ModAction.kind == 'remove_post').all()] posts = g.db.query(Submission).filter(Submission.id.in_(posts)).all() - comments = [x[0] for x in g.db.query(ModAction.target_comment_id).filter(ModAction.user_id == user.id, ModAction.created_utc > cutoff, ModAction.kind == 'ban_comment').all()] + comments = [x[0] for x in g.db.query(ModAction.target_comment_id).filter(ModAction.user_id == user.id, ModAction.created_utc > cutoff, ModAction.kind == 'remove_comment').all()] comments = g.db.query(Comment).filter(Comment.id.in_(comments)).all() for item in posts + comments: - item.is_banned = False - item.ban_reason = None + item.state_mod = StateMod.VISIBLE + item.state_mod_set_by = v.username g.db.add(item) users = (x[0] for x in g.db.query(ModAction.target_user_id).filter(ModAction.user_id == user.id, ModAction.created_utc > cutoff, ModAction.kind.in_(('shadowban', 'ban_user'))).all()) @@ -156,11 +162,11 @@ def revert_actions(v, username): g.db.commit() return {"message": "Admin actions reverted!"} + @app.post("/@/club_allow") @limiter.exempt @admin_level_required(2) def club_allow(v, username): - u = get_user(username, v=v) if not u: abort(404) @@ -185,11 +191,11 @@ def club_allow(v, username): g.db.commit() return {"message": f"@{username} has been allowed into the {CC_TITLE}!"} + @app.post("/@/club_ban") @limiter.exempt @admin_level_required(2) def club_ban(v, username): - u = get_user(username, v=v) if not u: abort(404) @@ -217,7 +223,7 @@ def club_ban(v, username): @limiter.exempt @auth_required def shadowbanned(v): - if not (v and v.admin_level > 1): abort(404) + if not (v and v.admin_level >= 2): abort(404) users = [x for x in g.db.query(User).filter(User.shadowbanned != None).order_by(User.shadowbanned).all()] return render_template("shadowbanned.html", v=v, users=users) @@ -230,7 +236,7 @@ def filtered_submissions(v): posts_just_ids = g.db.query(Submission) \ .order_by(Submission.id.desc()) \ - .filter(Submission.filter_state == 'filtered') \ + .filter(Submission.state_mod == StateMod.FILTERED) \ .limit(26) \ .offset(25 * (page - 1)) \ .with_entities(Submission.id) @@ -250,7 +256,7 @@ def filtered_comments(v): comments_just_ids = g.db.query(Comment) \ .order_by(Comment.id.desc()) \ - .filter(Comment.filter_state == 'filtered') \ + .filter(Comment.state_mod == StateMod.FILTERED) \ .limit(26) \ .offset(25 * (page - 1)) \ .with_entities(Comment.id) @@ -261,52 +267,75 @@ def filtered_comments(v): return render_template("admin/filtered_comments.html", v=v, listing=comments, next_exists=next_exists, page=page, sort="new") +# NOTE: +# This function is pretty grimy and should be rolled into the Remove/Unremove functions. +# (also rename Unremove to Approve, sigh) @app.post("/admin/update_filter_status") @limiter.exempt -@admin_level_required(2) +@admin_level_required(PERMS['POST_COMMENT_MODERATION']) def update_filter_status(v): update_body = request.get_json() new_status = update_body.get('new_status') post_id = update_body.get('post_id') comment_id = update_body.get('comment_id') if new_status not in ['normal', 'removed', 'ignored']: - return { 'result': f'Status of {new_status} is not permitted' } + return {'result': f'Status of {new_status} is not permitted'}, 403 + + if new_status == 'normal': + state_mod_new = StateMod.VISIBLE + state_report_new = StateReport.RESOLVED + elif new_status == 'removed': + state_mod_new = StateMod.REMOVED + state_report_new = StateReport.RESOLVED + elif new_status == 'ignored': + state_mod_new = None # we just leave this as-is + state_report_new = StateReport.IGNORED if post_id: - p = g.db.get(Submission, post_id) - old_status = p.filter_state - rows_updated = g.db.query(Submission).where(Submission.id == post_id) \ - .update({Submission.filter_state: new_status}) + target: Submission = get_post(post_id, graceful=True) + modlog_target_type: str = 'post' elif comment_id: - c = g.db.get(Comment, comment_id) - old_status = c.filter_state - rows_updated = g.db.query(Comment).where(Comment.id == comment_id) \ - .update({Comment.filter_state: new_status}) + target: Comment = get_comment(comment_id, graceful=True) + modlog_target_type: str = 'comment' else: - return { 'result': f'No valid item ID provided' } + return {"result": "No valid item ID provided"}, 404 + + if not target: + return {"result": "Item ID does not exist"}, 404 - if rows_updated == 1: - # If comment now visible, update state to reflect publication. - if (comment_id - and old_status in ['filtered', 'removed'] - and new_status in ['normal', 'ignored']): - comment_on_publish(c) + old_status = target.state_mod - if (comment_id - and old_status in ['normal', 'ignored'] - and new_status in ['filtered', 'removed']): - comment_on_unpublish(c) + if state_mod_new is not None: + target.state_mod = state_mod_new + target.state_mod_set_by = v.username + target.state_report = state_report_new - g.db.commit() - return { 'result': 'Update successful' } - else: - return { 'result': 'Item ID does not exist' } + making_visible: bool = old_status != StateMod.VISIBLE and state_mod_new == StateMod.VISIBLE + making_invisible: bool = old_status == StateMod.VISIBLE and state_mod_new != StateMod.VISIBLE and state_mod_new is not None + + if making_visible: + modlog_action: str = "approve" + if isinstance(target, Comment): comment_on_publish(target) + elif making_invisible: + modlog_action: str = "remove" + if isinstance(target, Comment): comment_on_unpublish(target) + + if making_visible or making_invisible: + g.db.add(ModAction( + kind=f"{modlog_action}_{modlog_target_type}", + user_id=v.id, + target_submission_id=target.id if isinstance(target, Submission) else None, + target_comment_id=target.id if isinstance(target, Comment) else None + )) + + g.db.commit() + invalidate_cache(frontlist=True) + return { 'result': 'Update successful' } @app.get("/admin/image_posts") @limiter.exempt @admin_level_required(2) def image_posts_listing(v): - try: page = int(request.values.get('page', 1)) except: page = 1 @@ -328,7 +357,7 @@ def reported_posts(v): page = max(1, int(request.values.get("page", 1))) subs_just_ids = g.db.query(Submission) \ - .filter(Submission.filter_state == 'reported') \ + .filter(Submission.state_report == StateReport.REPORTED) \ .order_by(Submission.id.desc()) \ .offset(25 * (page - 1)) \ .limit(26) \ @@ -345,16 +374,10 @@ def reported_posts(v): @limiter.exempt @admin_level_required(2) def reported_comments(v): - page = max(1, int(request.values.get("page", 1))) - listing = g.db.query(Comment - ).filter_by( - is_approved=None, - is_banned=False - ).join(Comment.reports).order_by(Comment.id.desc()).offset(25 * (page - 1)).limit(26).all() comments_just_ids = g.db.query(Comment) \ - .filter(Comment.filter_state == 'reported') \ + .filter(Comment.state_report == StateReport.REPORTED) \ .order_by(Comment.id.desc()) \ .offset(25 * (page - 1)) \ .limit(26) \ @@ -412,7 +435,8 @@ def change_settings(v, setting): level=1, body_html=body_html, sentto=MODMAIL_ID, - distinguish_level=6 + distinguish_level=6, + state_mod=StateMod.VISIBLE, ) g.db.add(new_comment) g.db.flush() @@ -580,7 +604,6 @@ def badge_remove_post(v): @limiter.exempt @admin_level_required(2) def users_list(v): - try: page = int(request.values.get("page", 1)) except: page = 1 @@ -625,7 +648,6 @@ def loggedout_list(v): @limiter.exempt @admin_level_required(2) def alt_votes_get(v): - u1 = request.values.get("u1") u2 = request.values.get("u2") @@ -759,13 +781,12 @@ def admin_link_accounts(v): @limiter.exempt @admin_level_required(2) def admin_removed(v): - try: page = int(request.values.get("page", 1)) except: page = 1 if page < 1: abort(400) - ids = g.db.query(Submission.id).join(User, User.id == Submission.author_id).filter(or_(Submission.is_banned==True, User.shadowbanned != None)).order_by(Submission.id.desc()).offset(25 * (page - 1)).limit(26).all() + ids = g.db.query(Submission.id).join(User, User.id == Submission.author_id).filter(or_(Submission.state_mod == StateMod.REMOVED, User.shadowbanned != None)).order_by(Submission.id.desc()).offset(25 * (page - 1)).limit(26).all() ids=[x[0] for x in ids] @@ -787,11 +808,10 @@ def admin_removed(v): @limiter.exempt @admin_level_required(2) def admin_removed_comments(v): - try: page = int(request.values.get("page", 1)) except: page = 1 - ids = g.db.query(Comment.id).join(User, User.id == Comment.author_id).filter(or_(Comment.is_banned==True, User.shadowbanned != None)).order_by(Comment.id.desc()).offset(25 * (page - 1)).limit(26).all() + ids = g.db.query(Comment.id).join(User, User.id == Comment.author_id).filter(or_(Comment.state_mod == StateMod.REMOVED, User.shadowbanned != None)).order_by(Comment.id.desc()).offset(25 * (page - 1)).limit(26).all() ids=[x[0] for x in ids] @@ -830,7 +850,7 @@ def shadowban(user_id, v): ) g.db.add(ma) - cache.delete_memoized(frontlist) + invalidate_cache(frontlist=True) body = f"@{v.username} has shadowbanned @{user.username}" @@ -841,7 +861,8 @@ def shadowban(user_id, v): parent_submission=None, level=1, body_html=body_html, - distinguish_level=6 + distinguish_level=6, + state_mod=StateMod.VISIBLE, ) g.db.add(new_comment) g.db.flush() @@ -878,7 +899,7 @@ def unshadowban(user_id, v): ) g.db.add(ma) - cache.delete_memoized(frontlist) + invalidate_cache(frontlist=True) g.db.commit() return {"message": "User unshadowbanned!"} @@ -924,7 +945,6 @@ def unverify(user_id, v): @limiter.exempt @admin_level_required(2) def admin_title_change(user_id, v): - user = g.db.query(User).filter_by(id=user_id).one_or_none() new_name=request.values.get("title").strip()[:256] @@ -1027,7 +1047,8 @@ def ban_user(user_id, v): parent_submission=None, level=1, body_html=body_html, - distinguish_level=6 + distinguish_level=6, + state_mod=StateMod.VISIBLE, ) g.db.add(new_comment) g.db.flush() @@ -1049,7 +1070,6 @@ def ban_user(user_id, v): @limiter.exempt @admin_level_required(2) def unban_user(user_id, v): - user = g.db.query(User).filter_by(id=user_id).one_or_none() if not user or not user.is_banned: abort(400) @@ -1082,83 +1102,10 @@ def unban_user(user_id, v): else: return {"message": f"@{user.username} was unbanned!"} -@app.post("/ban_post/") -@limiter.exempt -@admin_level_required(2) -def ban_post(post_id, v): - - post = g.db.query(Submission).filter_by(id=post_id).one_or_none() - - if not post: - abort(400) - - post.is_banned = True - post.is_approved = None - post.stickied = None - post.is_pinned = False - post.ban_reason = v.username - g.db.add(post) - - - - ma=ModAction( - kind="ban_post", - user_id=v.id, - target_submission_id=post.id, - ) - g.db.add(ma) - - cache.delete_memoized(frontlist) - - v.coins += 1 - g.db.add(v) - - requests.post(f'https://api.cloudflare.com/client/v4/zones/{CF_ZONE}/purge_cache', headers=CF_HEADERS, json={'files': [f"{SITE_FULL}/logged_out/"]}, timeout=5) - - g.db.commit() - - return {"message": "Post removed!"} - - -@app.post("/unban_post/") -@limiter.exempt -@admin_level_required(2) -def unban_post(post_id, v): - - post = g.db.query(Submission).filter_by(id=post_id).one_or_none() - - if not post: - abort(400) - - if post.is_banned: - ma=ModAction( - kind="unban_post", - user_id=v.id, - target_submission_id=post.id, - ) - g.db.add(ma) - - post.is_banned = False - post.ban_reason = None - post.is_approved = v.id - - g.db.add(post) - - cache.delete_memoized(frontlist) - - v.coins -= 1 - g.db.add(v) - - g.db.commit() - - return {"message": "Post approved!"} - - @app.post("/distinguish/") @limiter.exempt @admin_level_required(1) def api_distinguish_post(post_id, v): - post = g.db.query(Submission).filter_by(id=post_id).one_or_none() if not post: abort(404) @@ -1191,12 +1138,11 @@ def api_distinguish_post(post_id, v): @limiter.exempt @admin_level_required(2) def sticky_post(post_id, v): - post = g.db.query(Submission).filter_by(id=post_id).one_or_none() if post and not post.stickied: - pins = g.db.query(Submission.id).filter(Submission.stickied != None, Submission.is_banned == False).count() + pins = g.db.query(Submission.id).filter(Submission.stickied != None, Submission.state_mod == StateMod.VISIBLE).count() if pins > 2: - if v.admin_level > 1: + if v.admin_level >= 2: post.stickied = v.username post.stickied_utc = int(time.time()) + 3600 else: abort(403, "Can't exceed 3 pinned posts limit!") @@ -1213,7 +1159,7 @@ def sticky_post(post_id, v): if v.id != post.author_id: send_repeatable_notification(post.author_id, f"@{v.username} has pinned your [post](/post/{post_id})!") - cache.delete_memoized(frontlist) + invalidate_cache(frontlist=True) g.db.commit() return {"message": "Post pinned!"} @@ -1239,7 +1185,7 @@ def unsticky_post(post_id, v): if v.id != post.author_id: send_repeatable_notification(post.author_id, f"@{v.username} has unpinned your [post](/post/{post_id})!") - cache.delete_memoized(frontlist) + invalidate_cache(frontlist=True) g.db.commit() return {"message": "Post unpinned!"} @@ -1295,62 +1241,10 @@ def unsticky_comment(cid, v): return {"message": "Comment unpinned!"} -@app.post("/ban_comment/") -@limiter.exempt -@admin_level_required(2) -def api_ban_comment(c_id, v): - - comment = g.db.query(Comment).filter_by(id=c_id).one_or_none() - if not comment: - abort(404) - - comment.is_banned = True - comment.is_approved = None - comment.ban_reason = v.username - g.db.add(comment) - ma=ModAction( - kind="ban_comment", - user_id=v.id, - target_comment_id=comment.id, - ) - g.db.add(ma) - g.db.commit() - return {"message": "Comment removed!"} - - -@app.post("/unban_comment/") -@limiter.exempt -@admin_level_required(2) -def api_unban_comment(c_id, v): - - comment = g.db.query(Comment).filter_by(id=c_id).one_or_none() - if not comment: abort(404) - - if comment.is_banned: - ma=ModAction( - kind="unban_comment", - user_id=v.id, - target_comment_id=comment.id, - ) - g.db.add(ma) - - comment.is_banned = False - comment.ban_reason = None - comment.is_approved = v.id - - g.db.add(comment) - - g.db.commit() - - return {"message": "Comment approved!"} - - @app.post("/distinguish_comment/") @limiter.exempt @admin_level_required(1) def admin_distinguish_comment(c_id, v): - - comment = get_comment(c_id, v=v) if comment.author_id != v.id: abort(403) @@ -1395,7 +1289,6 @@ def admin_dump_cache(v): @limiter.exempt @admin_level_required(3) def admin_banned_domains(v): - banned_domains = g.db.query(BannedDomain).all() return render_template("admin/banned_domains.html", v=v, banned_domains=banned_domains) @@ -1403,7 +1296,6 @@ def admin_banned_domains(v): @limiter.exempt @admin_level_required(3) def admin_toggle_ban_domain(v): - domain=request.values.get("domain", "").strip() if not domain: abort(400) @@ -1436,25 +1328,24 @@ def admin_toggle_ban_domain(v): @app.post("/admin/nuke_user") @limiter.exempt -@admin_level_required(2) +@admin_level_required(3) def admin_nuke_user(v): - user=get_user(request.values.get("user")) for post in g.db.query(Submission).filter_by(author_id=user.id).all(): - if post.is_banned: + if post.state_mod != StateMod.REMOVED: continue - post.is_banned = True - post.ban_reason = v.username + post.state_mod = StateMod.REMOVED + post.state_mod_set_by = v.username g.db.add(post) for comment in g.db.query(Comment).filter_by(author_id=user.id).all(): - if comment.is_banned: + if comment.state_mod != StateMod.REMOVED: continue - comment.is_banned = True - comment.ban_reason = v.username + comment.state_mod = StateMod.REMOVED + comment.state_mod_set_by = v.username g.db.add(comment) ma=ModAction( @@ -1471,25 +1362,24 @@ def admin_nuke_user(v): @app.post("/admin/unnuke_user") @limiter.exempt -@admin_level_required(2) -def admin_nunuke_user(v): - +@admin_level_required(3) +def admin_unnuke_user(v): user=get_user(request.values.get("user")) for post in g.db.query(Submission).filter_by(author_id=user.id).all(): - if not post.is_banned: + if post.state_mod == StateMod.VISIBLE: continue - post.is_banned = False - post.ban_reason = None + post.state_mod = StateMod.VISIBLE + post.state_mod_set_by = v.username g.db.add(post) for comment in g.db.query(Comment).filter_by(author_id=user.id).all(): - if not comment.is_banned: + if comment.state_mod == StateMod.VISIBLE: continue - comment.is_banned = False - comment.ban_reason = None + comment.state_mod = StateMod.VISIBLE + comment.state_mod_set_by = v.username g.db.add(comment) ma=ModAction( diff --git a/files/routes/admin/performance.py b/files/routes/admin/performance.py new file mode 100644 index 000000000..d50771bfa --- /dev/null +++ b/files/routes/admin/performance.py @@ -0,0 +1,131 @@ +import os +from dataclasses import dataclass +from signal import Signals +from typing import Final + +import psutil +from flask import abort, render_template, request + +from files.helpers.config.const import PERMS +from files.helpers.time import format_datetime +from files.helpers.wrappers import admin_level_required +from files.__main__ import app + +PROCESS_NAME: Final[str] = "gunicorn" +''' +The name of the master and worker processes +''' + +INIT_PID: Final[int] = 1 +''' +The PID of the init process. Used to check an edge case for orphaned workers. +''' + +MEMORY_RSS_WARN_LEVELS_MASTER: dict[int, str] = { + 0: '', + 50 * 1024 * 1024: 'text-warn', + 75 * 1024 * 1024: 'text-danger', +} +''' +Levels to warn for in RAM memory usage for the master process. The master +process shouldn't be using much RAM at all since all it basically does is +orchestrate workers. +''' + +MEMORY_RSS_WARN_LEVELS_WORKER: dict[int, str] = { + 0: '', + 200 * 1024 * 1024: 'text-warn', + 300 * 1024 * 1024: 'text-danger', +} +''' +Levels to warn for in RAM memory usage. There are no warning levels for VM +usage because Python seems to intentionally overallocate (presumably to make +the interpreter faster) and doesn't tend to touch many of its owned pages. +''' + +@dataclass(frozen=True, slots=True) +class RenderedPerfInfo: + pid:int + started_at_utc:float + memory_rss:int + memory_vms:int + + @classmethod + def from_process(cls, p:psutil.Process) -> "RenderedPerfInfo": + with p.oneshot(): + mem = p.memory_info() + return cls(pid=p.pid, started_at_utc=p.create_time(), + memory_rss=mem.rss, memory_vms=mem.vms) + + @property + def is_master(self) -> bool: + return self.pid == os.getppid() and self.pid != INIT_PID + + @property + def is_current(self) -> bool: + return self.pid == os.getpid() + + @property + def memory_rss_css_class(self) -> str: + last = '' + levels: dict[int, str] = MEMORY_RSS_WARN_LEVELS_MASTER \ + if self.is_master else MEMORY_RSS_WARN_LEVELS_WORKER + for mem, css in levels.items(): + if self.memory_rss < mem: return last + last = css + return last + + @property + def started_at_utc_str(self) -> str: + return format_datetime(self.started_at_utc) + +@app.get('/performance/') +@admin_level_required(PERMS['PERFORMANCE_STATS']) +def performance_get_stats(v): + system_vm = psutil.virtual_memory() + processes = {p.pid:RenderedPerfInfo.from_process(p) + for p in psutil.process_iter() + if p.name() == PROCESS_NAME} + return render_template('admin/performance/memory.html', v=v, processes=processes, system_vm=system_vm) + +def _signal_master_process(signal:int) -> None: + ppid:int = os.getppid() + if ppid == INIT_PID: # shouldn't happen but handle the orphaned worker case just in case + abort(500, "This worker is an orphan!") + os.kill(ppid, signal) + +def _signal_worker_process(pid:int, signal:int) -> None: + workers:set[int] = {p.pid + for p in psutil.process_iter() + if p.name() == PROCESS_NAME} + workers.discard(os.getppid()) # don't allow killing the master process + + if not pid in workers: + abort(404, "Worker process not found") + os.kill(pid, signal) + +@app.post('/performance/workers/reload') +@admin_level_required(PERMS['PERFORMANCE_RELOAD']) +def performance_reload_workers(v): + _signal_master_process(Signals.SIGHUP) + return {'message': 'Sent reload signal successfully'} + +@app.post('/performance/workers//terminate') +@admin_level_required(PERMS['PERFORMANCE_KILL_PROCESS']) +def performance_terminate_worker_process(v, pid:int): + _signal_worker_process(pid, Signals.SIGTERM) + return {"message": f"Gracefully shut down worker PID {pid} successfully"} + +@app.post('/performance/workers//kill') +@admin_level_required(PERMS['PERFORMANCE_KILL_PROCESS']) +def performance_kill_worker_process(v, pid:int): + _signal_worker_process(pid, Signals.SIGKILL) + return {"message": f"Killed worker with PID {pid} successfully"} + +@app.post('/performance/workers/+1') +@app.post('/performance/workers/-1') +@admin_level_required(PERMS['PERFORMANCE_SCALE_UP_DOWN']) +def performance_scale_up_down(v): + scale_up:bool = '+1' in request.url + _signal_master_process(Signals.SIGTTIN if scale_up else Signals.SIGTTOU) + return {"message": "Sent signal to master to scale " + ("up" if scale_up else "down")} diff --git a/files/routes/admin/tasks.py b/files/routes/admin/tasks.py new file mode 100644 index 000000000..f5999832f --- /dev/null +++ b/files/routes/admin/tasks.py @@ -0,0 +1,179 @@ +from datetime import time +from typing import Optional + +from flask import abort, g, redirect, render_template, request + +import files.helpers.validators as validators +from files.__main__ import app +from files.classes.cron.submission import ScheduledSubmissionTask +from files.classes.cron.tasks import (DayOfWeek, RepeatableTask, + RepeatableTaskRun, ScheduledTaskType) +from files.classes.user import User +from files.helpers.config.const import PERMS, SUBMISSION_FLAIR_LENGTH_MAXIMUM +from files.helpers.config.environment import MULTIMEDIA_EMBEDDING_ENABLED +from files.helpers.wrappers import admin_level_required + + +def _modify_task_schedule(pid:int): + task: Optional[RepeatableTask] = g.db.get(RepeatableTask, pid) + if not task: abort(404) + + # rebuild the schedule + task.enabled = _get_request_bool('enabled') + task.frequency_day_flags = _get_request_dayofweek() + hour:int = validators.int_ranged('hour', 0, 23) + minute:int = validators.int_ranged('minute', 0, 59) + second:int = 0 # TODO: seconds? + + time_of_day_utc:time = time(hour, minute, second) + task.time_of_day_utc = time_of_day_utc + g.db.commit() + +@app.get('/tasks/') +@admin_level_required(PERMS['SCHEDULER']) +def tasks_get(v:User): + tasks:list[RepeatableTask] = \ + g.db.query(RepeatableTask).all() + return render_template("admin/tasks/tasks.html", v=v, listing=tasks) + + +@app.get('/tasks//') +@admin_level_required(PERMS['SCHEDULER']) +def tasks_get_task(v:User, task_id:int): + task:RepeatableTask = g.db.get(RepeatableTask, task_id) + if not task: abort(404) + return render_template("admin/tasks/single_task.html", v=v, task=task) + + +@app.get('/tasks//runs/') +@admin_level_required(PERMS['SCHEDULER']) +def tasks_get_task_redirect(v:User, task_id:int): # pyright: ignore + return redirect(f'/tasks/{task_id}/') + + +@app.get('/tasks//runs/') +@admin_level_required(PERMS['SCHEDULER']) +def tasks_get_task_run(v:User, task_id:int, run_id:int): + run:RepeatableTaskRun = g.db.get(RepeatableTaskRun, run_id) + if not run: abort(404) + if run.task_id != task_id: + return redirect(f'/tasks/{run.task_id}/runs/{run.id}') + return render_template("admin/tasks/single_run.html", v=v, run=run) + + +@app.post('/tasks//schedule') +@admin_level_required(PERMS['SCHEDULER']) +def task_schedule_post(v:User, task_id:int): # pyright: ignore + _modify_task_schedule(task_id) + return redirect(f'/tasks/{task_id}') + + +@app.get('/tasks/scheduled_posts/') +@admin_level_required(PERMS['SCHEDULER_POSTS']) +def tasks_scheduled_posts_get(v:User): + submissions:list[ScheduledSubmissionTask] = \ + g.db.query(ScheduledSubmissionTask).all() + return render_template("admin/tasks/scheduled_posts.html", v=v, listing=submissions) + + +def _get_request_bool(name:str) -> bool: + return bool(request.values.get(name, default=False, type=bool)) + + +def _get_request_dayofweek() -> DayOfWeek: + days:DayOfWeek = DayOfWeek.NONE + for day in DayOfWeek.all_days: + name:str = day.name.lower() + if _get_request_bool(f'schedule_day_{name}'): days |= day + return days + + +@app.post('/tasks/scheduled_posts/') +@admin_level_required(PERMS['SCHEDULER_POSTS']) +def tasks_scheduled_posts_post(v:User): + validated_post:validators.ValidatedSubmissionLike = \ + validators.ValidatedSubmissionLike.from_flask_request(request, + allow_embedding=MULTIMEDIA_EMBEDDING_ENABLED, + ) + + # first build the template + flair:str = validators.guarded_value("flair", min_len=0, max_len=SUBMISSION_FLAIR_LENGTH_MAXIMUM) + + # and then build the schedule + enabled:bool = _get_request_bool('enabled') + frequency_day:DayOfWeek = _get_request_dayofweek() + hour:int = validators.int_ranged('hour', 0, 23) + minute:int = validators.int_ranged('minute', 0, 59) + second:int = 0 # TODO: seconds? + + time_of_day_utc:time = time(hour, minute, second) + + # and then build the scheduled task + task:ScheduledSubmissionTask = ScheduledSubmissionTask( + author_id=v.id, + author_id_submission=v.id, # TODO: allow customization + enabled=enabled, + ghost=_get_request_bool("ghost"), + private=_get_request_bool("private"), + over_18=_get_request_bool("over_18"), + is_bot=False, # TODO: do we need this? + title=validated_post.title, + url=validated_post.url, + body=validated_post.body, + body_html=validated_post.body_html, + flair=flair, + embed_url=validated_post.embed_slow, + frequency_day=int(frequency_day), + time_of_day_utc=time_of_day_utc, + type_id=int(ScheduledTaskType.SCHEDULED_SUBMISSION), + ) + g.db.add(task) + g.db.commit() + return redirect(f'/tasks/scheduled_posts/{task.id}') + + +@app.get('/tasks/scheduled_posts/') +@admin_level_required(PERMS['SCHEDULER_POSTS']) +def tasks_scheduled_post_get(v:User, pid:int): + submission: Optional[ScheduledSubmissionTask] = \ + g.db.get(ScheduledSubmissionTask, pid) + if not submission: abort(404) + return render_template("admin/tasks/scheduled_post.html", v=v, p=submission) + + +@app.post('/tasks/scheduled_posts//content') +@admin_level_required(PERMS['SCHEDULER_POSTS']) +def task_scheduled_post_content_post(v:User, pid:int): # pyright: ignore + submission: Optional[ScheduledSubmissionTask] = \ + g.db.get(ScheduledSubmissionTask, pid) + if not submission: abort(404) + if not v.can_edit(submission): abort(403) + + validated_post:validators.ValidatedSubmissionLike = \ + validators.ValidatedSubmissionLike.from_flask_request(request, + allow_embedding=MULTIMEDIA_EMBEDDING_ENABLED, + ) + + edited:bool = False + if submission.body != validated_post.body: + submission.body = validated_post.body + submission.body_html = validated_post.body_html + edited = True + + if submission.title != validated_post.title: + submission.title = validated_post.title + edited = True + + if not edited: + abort(400, "Title or body must be edited") + + g.db.commit() + return redirect(f'/tasks/scheduled_posts/{pid}') + +@app.post('/tasks/scheduled_posts//schedule') +@admin_level_required(PERMS['SCHEDULER']) +def task_scheduled_post_post(v:User, task_id:int): # pyright: ignore + # permission being SCHEDULER is intentional as SCHEDULER_POSTS is for + # creating or editing post content + _modify_task_schedule(task_id) + return redirect(f'/tasks/scheduled_posts/{task_id}') diff --git a/files/routes/allroutes.py b/files/routes/allroutes.py new file mode 100644 index 000000000..bf6cb63e3 --- /dev/null +++ b/files/routes/allroutes.py @@ -0,0 +1,51 @@ +import json +import sys +import time + +from flask import abort, g, request + +from files.__main__ import app, db_session, limiter + + +@app.before_request +def before_request(): + with open('site_settings.json', 'r') as f: + app.config['SETTINGS'] = json.load(f) + + if request.host != app.config["SERVER_NAME"]: + return {"error": "Unauthorized host provided."}, 403 + + if not app.config['SETTINGS']['Bots'] and request.headers.get("Authorization"): + abort(403, "Bots are currently not allowed") + + g.agent = request.headers.get("User-Agent") + if not g.agent: + return 'Please use a "User-Agent" header!', 403 + + ua = g.agent.lower() + g.debug = app.debug + g.webview = ('; wv) ' in ua) + g.inferior_browser = ( + 'iphone' in ua or + 'ipad' in ua or + 'ipod' in ua or + 'mac os' in ua or + ' firefox/' in ua) + g.timestamp = int(time.time()) + + limiter.check() + + g.db = db_session() + + +@app.teardown_appcontext +def teardown_request(error): + if hasattr(g, 'db') and g.db: + g.db.close() + sys.stdout.flush() + +@app.after_request +def after_request(response): + response.headers.add("Strict-Transport-Security", "max-age=31536000") + response.headers.add("X-Frame-Options", "deny") + return response diff --git a/files/routes/awards.py b/files/routes/awards.py index 817198754..e1add71d9 100644 --- a/files/routes/awards.py +++ b/files/routes/awards.py @@ -2,9 +2,8 @@ from files.__main__ import app, limiter from files.helpers.wrappers import * from files.helpers.alerts import * from files.helpers.get import * -from files.helpers.const import * +from files.helpers.config.const import * from files.classes.award import * -from .front import frontlist from flask import g, request from files.helpers.sanitize import filter_emojis_only from copy import deepcopy @@ -24,7 +23,7 @@ def shop(v): for val in AWARDS.values(): val["baseprice"] = int(val["price"]) - val["price"] = int(val["price"] * v.discount) + val["price"] = val["baseprice"] sales = g.db.query(func.sum(User.coins_spent)).scalar() return render_template("shop.html", awards=list(AWARDS.values()), v=v, sales=sales) @@ -46,7 +45,7 @@ def buy(v, award): if award not in AWARDS: abort(400) og_price = AWARDS[award]["price"] - price = int(og_price * v.discount) + price = int(og_price) if request.values.get("mb"): if v.procoins < price: abort(400, "Not enough marseybux.") diff --git a/files/routes/chat.py b/files/routes/chat.py index 4e46f8bac..5983e2a4d 100644 --- a/files/routes/chat.py +++ b/files/routes/chat.py @@ -1,7 +1,8 @@ import time +from files.helpers.config.environment import SITE, SITE_FULL from files.helpers.wrappers import auth_required from files.helpers.sanitize import sanitize -from files.helpers.const import * +from files.helpers.config.const import * from datetime import datetime from flask_socketio import SocketIO, emit from files.__main__ import app, limiter, cache @@ -69,7 +70,7 @@ def speak(data, v): total += 1 - if v.admin_level > 1: + if v.admin_level >= 2: text = text.lower() for i in mute_regex.finditer(text): username = i.group(1) @@ -102,7 +103,6 @@ def disconnect(v): @socketio.on('typing') @auth_required def typing_indicator(data, v): - if data and v.username not in typing: typing.append(v.username) elif not data and v.username in typing: typing.remove(v.username) diff --git a/files/routes/comments.py b/files/routes/comments.py index 023467b16..559167c8e 100644 --- a/files/routes/comments.py +++ b/files/routes/comments.py @@ -1,23 +1,19 @@ -from files.helpers.wrappers import * -from files.helpers.alerts import * -from files.helpers.media import process_image -from files.helpers.const import * -from files.helpers.comments import comment_on_publish -from files.classes import * -from flask import * from files.__main__ import app, limiter -from files.helpers.sanitize import filter_emojis_only -import requests -from shutil import copyfile -from json import loads -from collections import Counter +from files.classes import * +from files.classes.visstate import StateMod +from files.helpers.alerts import * +from files.helpers.comments import comment_on_publish +from files.helpers.config.const import * +from files.helpers.media import process_image +from files.helpers.wrappers import * +from files.routes.importstar import * + +from datetime import datetime, timezone @app.get("/comment/") @app.get("/post///") -# @app.get("/h//comment/") -# @app.get("/h//post///") @auth_desired -def post_pid_comment_cid(cid, pid=None, anything=None, v=None, sub=None): +def post_pid_comment_cid(cid, pid=None, anything=None, v=None): comment = get_comment(cid, v=v) if v and request.values.get("read"): @@ -29,9 +25,9 @@ def post_pid_comment_cid(cid, pid=None, anything=None, v=None, sub=None): if comment.post and comment.post.club and not (v and (v.paid_dues or v.id in [comment.author_id, comment.post.author_id])): abort(403) - if comment.post and comment.post.private and not (v and (v.admin_level > 1 or v.id == comment.post.author.id)): abort(403) + if comment.post and comment.post.private and not (v and (v.admin_level >= 2 or v.id == comment.post.author.id)): abort(403) - if not comment.parent_submission and not (v and (comment.author.id == v.id or comment.sentto == v.id)) and not (v and v.admin_level > 1) : abort(403) + if not comment.parent_submission and not (v and (comment.author.id == v.id or comment.sentto == v.id)) and not (v and v.admin_level >= 2) : abort(403) if not pid: if comment.parent_submission: pid = comment.parent_submission @@ -71,7 +67,7 @@ def post_pid_comment_cid(cid, pid=None, anything=None, v=None, sub=None): blocked.c.target_id, ) - if not (v and v.shadowbanned) and not (v and v.admin_level > 2): + if not (v and v.shadowbanned) and not (v and v.admin_level >= 3): comments = comments.join(User, User.id == Comment.author_id).filter(User.shadowbanned == None) comments=comments.filter( @@ -102,9 +98,9 @@ def post_pid_comment_cid(cid, pid=None, anything=None, v=None, sub=None): if request.headers.get("Authorization"): return top_comment.json else: - if post.is_banned and not (v and (v.admin_level > 1 or post.author_id == v.id)): template = "submission_banned.html" + if post.state_mod != StateMod.VISIBLE and not (v and (v.admin_level >= 2 or post.author_id == v.id)): template = "submission_banned.html" else: template = "submission.html" - return render_template(template, v=v, p=post, sort=sort, comment_info=comment_info, render_replies=True, sub=post.subr) + return render_template(template, v=v, p=post, sort=sort, comment_info=comment_info, render_replies=True) @app.post("/comment") @limiter.limit("1/second;20/minute;200/hour;1000/day") @@ -115,24 +111,20 @@ def api_comment(v): parent_fullname = request.values.get("parent_fullname", "").strip() if len(parent_fullname) < 4: abort(400) - id = parent_fullname[3:] parent = None parent_post = None parent_comment_id = None - sub = None - if parent_fullname.startswith("t2_"): - parent = get_post(id, v=v) + if parent_fullname.startswith("post_"): + parent = get_post(parent_fullname.split("post_")[1], v=v) parent_post = parent - elif parent_fullname.startswith("t3_"): - parent = get_comment(id, v=v) + elif parent_fullname.startswith("comment_"): + parent = get_comment(parent_fullname.split("comment_")[1], v=v) parent_post = get_post(parent.parent_submission, v=v) if parent.parent_submission else None parent_comment_id = parent.id else: abort(400) if not parent_post: abort(404) # don't allow sending comments to the ether level = 1 if isinstance(parent, Submission) else parent.level + 1 - sub = parent_post.sub - if sub and v.exiled_from(sub): abort(403, f"You're exiled from /h/{sub}") body = sanitize_raw(request.values.get("body"), allow_newlines=True, length_limit=COMMENT_BODY_LENGTH_MAXIMUM) if not body and not request.files.get('file'): @@ -158,7 +150,7 @@ def api_comment(v): existing = g.db.query(Comment.id).filter( Comment.author_id == v.id, - Comment.deleted_utc == 0, + Comment.state_user_deleted_utc == None, Comment.parent_comment_id == parent_comment_id, Comment.parent_submission == parent_post.id, Comment.body_html == body_html @@ -183,9 +175,9 @@ def api_comment(v): ).all() threshold = app.config["COMMENT_SPAM_COUNT_THRESHOLD"] - if v.age >= (60 * 60 * 24 * 7): + if v.age_seconds >= (60 * 60 * 24 * 7): threshold *= 3 - elif v.age >= (60 * 60 * 24): + elif v.age_seconds >= (60 * 60 * 24): threshold *= 2 if len(similar_comments) > threshold: @@ -196,13 +188,13 @@ def api_comment(v): days=1) for comment in similar_comments: - comment.is_banned = True - comment.ban_reason = "AutoJanny" + comment.state_mod = StateMod.REMOVED + comment.state_mod_set_by = "AutoJanny" g.db.add(comment) ma=ModAction( user_id=AUTOJANNY_ID, target_comment_id=comment.id, - kind="ban_comment", + kind="remove_comment", _note="spam" ) g.db.add(ma) @@ -221,7 +213,7 @@ def api_comment(v): body_html=body_html, body=body[:COMMENT_BODY_LENGTH_MAXIMUM], ghost=parent_post.ghost, - filter_state='filtered' if is_filtered else 'normal' + state_mod=StateMod.FILTERED if is_filtered else StateMod.VISIBLE, ) c.upvotes = 1 @@ -280,11 +272,11 @@ def edit_comment(cid, v): ).all() threshold = app.config["SPAM_SIMILAR_COUNT_THRESHOLD"] - if v.age >= (60 * 60 * 24 * 30): + if v.age_seconds >= (60 * 60 * 24 * 30): threshold *= 4 - elif v.age >= (60 * 60 * 24 * 7): + elif v.age_seconds >= (60 * 60 * 24 * 7): threshold *= 3 - elif v.age >= (60 * 60 * 24): + elif v.age_seconds >= (60 * 60 * 24): threshold *= 2 if len(similar_comments) > threshold: @@ -295,8 +287,8 @@ def edit_comment(cid, v): days=1) for comment in similar_comments: - comment.is_banned = True - comment.ban_reason = "AutoJanny" + comment.state_mod = StateMod.REMOVED + comment.state_mod_set_by = "AutoJanny" g.db.add(comment) abort(403, "Too much spam!") @@ -321,7 +313,7 @@ def edit_comment(cid, v): g.db.add(c) - if c.filter_state != 'filtered': + if c.state_mod == StateMod.VISIBLE: notify_users = NOTIFY_USERS(body, v) for x in notify_users: @@ -340,17 +332,13 @@ def edit_comment(cid, v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def delete_comment(cid, v): - c = get_comment(cid, v=v) - - if not c.deleted_utc: - - if c.author_id != v.id: abort(403) - - c.deleted_utc = int(time.time()) - - g.db.add(c) - g.db.commit() + if c.state_user_deleted_utc: abort(409) + if c.author_id != v.id: abort(403) + c.state_user_deleted_utc = datetime.now(tz=timezone.utc) + # TODO: update stateful counters + g.db.add(c) + g.db.commit() return {"message": "Comment deleted!"} @@ -358,16 +346,13 @@ def delete_comment(cid, v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def undelete_comment(cid, v): - c = get_comment(cid, v=v) - - if c.deleted_utc: - if c.author_id != v.id: abort(403) - - c.deleted_utc = 0 - - g.db.add(c) - g.db.commit() + if not c.state_user_deleted_utc: abort(409) + if c.author_id != v.id: abort(403) + c.state_user_deleted_utc = None + # TODO: update stateful counters + g.db.add(c) + g.db.commit() return {"message": "Comment undeleted!"} @@ -375,7 +360,6 @@ def undelete_comment(cid, v): @app.post("/pin_comment/") @auth_required def pin_comment(cid, v): - comment = get_comment(cid, v=v) if not comment.is_pinned: @@ -417,51 +401,10 @@ def unpin_comment(cid, v): return {"message": "Comment unpinned!"} -@app.post("/mod_pin/") -@auth_required -def mod_pin(cid, v): - - comment = get_comment(cid, v=v) - - if not comment.is_pinned: - if not (comment.post.sub and v.mods(comment.post.sub)): abort(403) - - comment.is_pinned = v.username + " (Mod)" - - g.db.add(comment) - - if v.id != comment.author_id: - message = f"@{v.username} (Mod) has pinned your [comment]({comment.shortlink})!" - send_repeatable_notification(comment.author_id, message) - - g.db.commit() - return {"message": "Comment pinned!"} - - -@app.post("/unmod_pin/") -@auth_required -def mod_unpin(cid, v): - - comment = get_comment(cid, v=v) - - if comment.is_pinned: - if not (comment.post.sub and v.mods(comment.post.sub)): abort(403) - - comment.is_pinned = None - g.db.add(comment) - - if v.id != comment.author_id: - message = f"@{v.username} (Mod) has unpinned your [comment]({comment.shortlink})!" - send_repeatable_notification(comment.author_id, message) - g.db.commit() - return {"message": "Comment unpinned!"} - - @app.post("/save_comment/") @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def save_comment(cid, v): - comment=get_comment(cid) save=g.db.query(CommentSaveRelationship).filter_by(user_id=v.id, comment_id=comment.id).one_or_none() @@ -478,7 +421,6 @@ def save_comment(cid, v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def unsave_comment(cid, v): - comment=get_comment(cid) save=g.db.query(CommentSaveRelationship).filter_by(user_id=v.id, comment_id=comment.id).one_or_none() diff --git a/files/routes/dev.py b/files/routes/dev.py index eeeee4451..814530484 100644 --- a/files/routes/dev.py +++ b/files/routes/dev.py @@ -1,7 +1,7 @@ from secrets import token_hex from flask import session, redirect, request -from files.helpers.const import PERMS +from files.helpers.config.const import PERMS from files.helpers.get import get_user from files.helpers.wrappers import admin_level_required from files.__main__ import app diff --git a/files/routes/errors.py b/files/routes/errors.py index 76f37842d..a494bd332 100644 --- a/files/routes/errors.py +++ b/files/routes/errors.py @@ -4,8 +4,11 @@ from urllib.parse import quote, urlencode from flask import g, redirect, render_template, request, session -from files.helpers.const import ERROR_MESSAGES, SITE_FULL, WERKZEUG_ERROR_DESCRIPTIONS from files.__main__ import app +from files.helpers.config.const import (ERROR_MESSAGES, + WERKZEUG_ERROR_DESCRIPTIONS) +from files.helpers.config.environment import SITE_FULL + @app.errorhandler(400) @app.errorhandler(401) @@ -14,6 +17,7 @@ from files.__main__ import app @app.errorhandler(405) @app.errorhandler(409) @app.errorhandler(413) +@app.errorhandler(415) @app.errorhandler(422) @app.errorhandler(429) def error(e): diff --git a/files/routes/feeds.py b/files/routes/feeds.py index 9d5269c39..682052ed8 100644 --- a/files/routes/feeds.py +++ b/files/routes/feeds.py @@ -1,13 +1,14 @@ -import html -from .front import frontlist from datetime import datetime -from files.helpers.get import * -from yattag import Doc -from files.helpers.const import * -from files.helpers.wrappers import * -from files.helpers.jinja2 import * +from yattag import Doc + +import files.helpers.listing as listing from files.__main__ import app +from files.helpers.config.const import * +from files.helpers.get import * +from files.helpers.jinja2 import * +from files.helpers.wrappers import * + @app.get('/rss') @app.get('/feed') @@ -16,7 +17,7 @@ def feeds_front(sort='new', t='all'): try: page = max(int(request.values.get("page", 1)), 1) except: page = 1 - ids, next_exists = frontlist( + ids, next_exists = listing.frontlist( sort=sort, page=page, t=t, diff --git a/files/routes/front.py b/files/routes/front.py index 1b7301a07..ce488f9af 100644 --- a/files/routes/front.py +++ b/files/routes/front.py @@ -1,15 +1,15 @@ from sqlalchemy.orm import Query -from files.helpers.wrappers import * -from files.helpers.get import * -from files.helpers.strings import sql_ilike_clean -from files.__main__ import app, cache, limiter +import files.helpers.listing as listing +from files.__main__ import app, limiter from files.classes.submission import Submission +from files.classes.visstate import StateMod from files.helpers.comments import comment_filter_moderated -from files.helpers.contentsorting import \ - apply_time_filter, sort_objects, sort_comment_results - -defaulttimefilter = environ.get("DEFAULT_TIME_FILTER", "all").strip() +from files.helpers.contentsorting import (apply_time_filter, + sort_comment_results, sort_objects) +from files.helpers.config.environment import DEFAULT_TIME_FILTER +from files.helpers.get import * +from files.helpers.wrappers import * @app.post("/clear") @auth_required @@ -27,8 +27,8 @@ def unread(v): listing = g.db.query(Notification, Comment).join(Comment, Notification.comment_id == Comment.id).filter( Notification.read == False, Notification.user_id == v.id, - Comment.is_banned == False, - Comment.deleted_utc == 0, + Comment.state_mod == StateMod.VISIBLE, + Comment.state_user_deleted_utc == None, Comment.author_id != AUTOJANNY_ID, ).order_by(Notification.created_utc.desc()).all() @@ -42,137 +42,156 @@ def unread(v): @app.get("/notifications") @auth_required -def notifications(v): - try: page = max(int(request.values.get("page", 1)), 1) - except: page = 1 +def notifications_main(v: User): + page: int = max(request.values.get("page", 1, int) or 1, 1) - messages = request.values.get('messages') - modmail = request.values.get('modmail') - posts = request.values.get('posts') - reddit = request.values.get('reddit') - if modmail and v.admin_level > 1: - comments = g.db.query(Comment).filter(Comment.sentto == MODMAIL_ID).order_by(Comment.id.desc()).offset(25*(page-1)).limit(26).all() - next_exists = (len(comments) > 25) - listing = comments[:25] - elif messages: - if v and (v.shadowbanned or v.admin_level > 2): - comments = g.db.query(Comment).filter(Comment.sentto != None, or_(Comment.author_id==v.id, Comment.sentto==v.id), Comment.parent_submission == None, Comment.level == 1).order_by(Comment.id.desc()).offset(25*(page-1)).limit(26).all() - else: - comments = g.db.query(Comment).join(User, User.id == Comment.author_id).filter(User.shadowbanned == None, Comment.sentto != None, or_(Comment.author_id==v.id, Comment.sentto==v.id), Comment.parent_submission == None, Comment.level == 1).order_by(Comment.id.desc()).offset(25*(page-1)).limit(26).all() - - next_exists = (len(comments) > 25) - listing = comments[:25] - elif posts: - notifications = g.db.query(Notification, Comment).join(Comment, Notification.comment_id == Comment.id).filter(Notification.user_id == v.id, Comment.author_id == AUTOJANNY_ID).order_by(Notification.created_utc.desc()).offset(25 * (page - 1)).limit(101).all() - - listing = [] - - for index, x in enumerate(notifications[:100]): - n, c = x - if n.read and index > 24: break - elif not n.read: - n.read = True - c.unread = True - g.db.add(n) - if n.created_utc > 1620391248: c.notif_utc = n.created_utc - listing.append(c) - - g.db.commit() - - next_exists = (len(notifications) > len(listing)) - elif reddit: - notifications = g.db.query(Notification, Comment).join(Comment, Notification.comment_id == Comment.id).filter(Notification.user_id == v.id, Comment.body_html.like('%

New site mention: 2)): - comments = comments.join(User, User.id == Comment.author_id).filter(User.shadowbanned == None) + if not v.shadowbanned and v.admin_level < 3: + comments = comments.join(Comment.author).filter(User.shadowbanned == None) - comments = comments.offset(25 * (page - 1)).limit(26).all() + comments = comments.offset(25 * (page - 1)).limit(26).all() - next_exists = (len(comments) > 25) - comments = comments[:25] + next_exists = (len(comments) > 25) + comments = comments[:25] - cids = [x[0].id for x in comments] + for c, n in comments: + c.notif_utc = n.created_utc + c.unread = not n.read + n.read = True - comms = get_comments(cids, v=v) + listing: list[Comment] = [c for c, _ in comments] - listing = [] - for c, n in comments: - if n.created_utc > 1620391248: c.notif_utc = n.created_utc - if not n.read: - n.read = True - c.unread = True - g.db.add(n) + # TODO: commit after request rendered, then default session expiry is fine + g.db.expire_on_commit = False + g.db.commit() + g.db.expire_on_commit = True - if c.parent_submission: - if c.replies2 == None: - c.replies2 = c.child_comments.filter(or_(Comment.author_id == v.id, Comment.id.in_(cids))).all() - for x in c.replies2: - if x.replies2 == None: x.replies2 = [] - count = 0 - while count < 50 and c.parent_comment and (c.parent_comment.author_id == v.id or c.parent_comment.id in cids): - count += 1 - c = c.parent_comment - if c.replies2 == None: - c.replies2 = c.child_comments.filter(or_(Comment.author_id == v.id, Comment.id.in_(cids))).all() - for x in c.replies2: - if x.replies2 == None: - x.replies2 = x.child_comments.filter(or_(Comment.author_id == v.id, Comment.id.in_(cids))).all() - else: - while c.parent_comment: - c = c.parent_comment - c.replies2 = g.db.query(Comment).filter_by(parent_comment_id=c.id).order_by(Comment.id).all() + if request.headers.get("Authorization"): + return {"data": [x.json for x in listing]} - if c not in listing: listing.append(c) + return render_template("notifications.html", + v=v, + notifications=listing, + next_exists=next_exists, + page=page, + standalone=True, + render_replies=False, + is_notification_page=True, + ) + + +@app.get("/notifications/posts") +@auth_required +def notifications_posts(v: User): + page: int = max(request.values.get("page", 1, int) or 1, 1) + + notifications = (g.db.query(Notification, Comment) + .join(Comment, Notification.comment_id == Comment.id) + .filter(Notification.user_id == v.id, Comment.author_id == AUTOJANNY_ID) + .order_by(Notification.created_utc.desc()).offset(25 * (page - 1)).limit(101).all()) + + listing = [] + + for index, x in enumerate(notifications[:100]): + n, c = x + if n.read and index > 24: break + elif not n.read: + n.read = True + c.unread = True + g.db.add(n) + if n.created_utc > 1620391248: c.notif_utc = n.created_utc + listing.append(c) + + next_exists = (len(notifications) > len(listing)) g.db.commit() - if request.headers.get("Authorization"): return {"data":[x.json for x in listing]} + if request.headers.get("Authorization"): + return {"data": [x.json for x in listing]} return render_template("notifications.html", - v=v, - notifications=listing, - next_exists=next_exists, - page=page, - standalone=True, - render_replies=True - ) + v=v, + notifications=listing, + next_exists=next_exists, + page=page, + standalone=True, + render_replies=True, + is_notification_page=True, + ) + + +@app.get("/notifications/modmail") +@admin_level_required(2) +def notifications_modmail(v: User): + page: int = max(request.values.get("page", 1, int) or 1, 1) + + comments = (g.db.query(Comment) + .filter(Comment.sentto == MODMAIL_ID) + .order_by(Comment.id.desc()).offset(25 * (page - 1)).limit(26).all()) + next_exists = (len(comments) > 25) + listing = comments[:25] + + if request.headers.get("Authorization"): + return {"data": [x.json for x in listing]} + + return render_template("notifications.html", + v=v, + notifications=listing, + next_exists=next_exists, + page=page, + standalone=True, + render_replies=True, + is_notification_page=True, + ) + + +@app.get("/notifications/messages") +@auth_required +def notifications_messages(v: User): + page: int = max(request.values.get("page", 1, int) or 1, 1) + + comments = g.db.query(Comment).filter( + Comment.sentto != None, + or_(Comment.author_id==v.id, Comment.sentto==v.id), + Comment.parent_submission == None, + Comment.level == 1, + ) + + if not v.shadowbanned and v.admin_level < 3: + comments = comments.join(Comment.author).filter(User.shadowbanned == None) + + comments = comments.order_by(Comment.id.desc()).offset(25 * (page - 1)).limit(26).all() + + next_exists = (len(comments) > 25) + listing = comments[:25] + + if request.headers.get("Authorization"): + return {"data": [x.json for x in listing]} + + return render_template("notifications.html", + v=v, + notifications=listing, + next_exists=next_exists, + page=page, + standalone=True, + render_replies=True, + is_notification_page=True, + ) @app.get("/") @app.get("/catalog") -# @app.get("/h/") -# @app.get("/s/") @limiter.limit("3/second;30/minute;1000/hour;5000/day") @auth_desired -def front_all(v, sub=None, subdomain=None): - if sub: sub = g.db.query(Sub).filter_by(name=sub.strip().lower()).one_or_none() - - if (request.path.startswith('/h/') or request.path.startswith('/s/')) and not sub: abort(404) - +def front_all(v, subdomain=None): if g.webview and not session.get("session_id"): session["session_id"] = secrets.token_hex(49) @@ -198,7 +217,7 @@ def front_all(v, sub=None, subdomain=None): try: lt=int(request.values.get("before", 0)) except: lt=0 - ids, next_exists = frontlist(sort=sort, + ids, next_exists = listing.frontlist(sort=sort, page=page, t=t, v=v, @@ -206,8 +225,6 @@ def front_all(v, sub=None, subdomain=None): filter_words=v.filter_words if v else [], gt=gt, lt=lt, - sub=sub, - site=SITE ) posts = get_posts(ids, v=v, eager=True) @@ -230,91 +247,7 @@ def front_all(v, sub=None, subdomain=None): g.db.commit() if request.headers.get("Authorization"): return {"data": [x.json for x in posts], "next_exists": next_exists} - return render_template("home.html", v=v, listing=posts, next_exists=next_exists, sort=sort, t=t, page=page, ccmode=ccmode, sub=sub, home=True) - - - -@cache.memoize(timeout=86400) -def frontlist(v=None, sort='new', page=1, t="all", ids_only=True, ccmode="false", filter_words='', gt=0, lt=0, sub=None, site=None): - - posts = g.db.query(Submission) - - if v and v.hidevotedon: - voted = [x[0] for x in g.db.query(Vote.submission_id).filter_by(user_id=v.id).all()] - posts = posts.filter(Submission.id.notin_(voted)) - - if not v or v.admin_level < 2: - filter_clause = (Submission.filter_state != 'filtered') & (Submission.filter_state != 'removed') - if v: - filter_clause = filter_clause | (Submission.author_id == v.id) - posts = posts.filter(filter_clause) - - if sub: posts = posts.filter_by(sub=sub.name) - elif v: posts = posts.filter(or_(Submission.sub == None, Submission.sub.notin_(v.all_blocks))) - - if gt: posts = posts.filter(Submission.created_utc > gt) - if lt: posts = posts.filter(Submission.created_utc < lt) - - if not gt and not lt: - posts = apply_time_filter(posts, t, Submission) - - if (ccmode == "true"): - posts = posts.filter(Submission.club == True) - - posts = posts.filter_by(is_banned=False, private=False, deleted_utc = 0) - - if ccmode == "false" and not gt and not lt: - posts = posts.filter_by(stickied=None) - - if v and v.admin_level < 2: - posts = posts.filter(Submission.author_id.notin_(v.userblocks)) - - if not (v and v.changelogsub): - posts=posts.filter(not_(Submission.title.ilike('[changelog]%'))) - - if v and filter_words: - for word in filter_words: - word = sql_ilike_clean(word).strip() - posts=posts.filter(not_(Submission.title.ilike(f'%{word}%'))) - - if not (v and v.shadowbanned): - posts = posts.join(User, User.id == Submission.author_id).filter(User.shadowbanned == None) - - posts = sort_objects(posts, sort, Submission) - - if v: size = v.frontsize or 0 - else: size = 25 - - posts = posts.offset(size * (page - 1)).limit(size+1).all() - - next_exists = (len(posts) > size) - - posts = posts[:size] - - if page == 1 and ccmode == "false" and not gt and not lt: - pins = g.db.query(Submission).filter(Submission.stickied != None, Submission.is_banned == False) - if sub: pins = pins.filter_by(sub=sub.name) - elif v: - pins = pins.filter(or_(Submission.sub == None, Submission.sub.notin_(v.all_blocks))) - if v.admin_level < 2: - pins = pins.filter(Submission.author_id.notin_(v.userblocks)) - - pins = pins.all() - - for pin in pins: - if pin.stickied_utc and int(time.time()) > pin.stickied_utc: - pin.stickied = None - pin.stickied_utc = None - g.db.add(pin) - pins.remove(pin) - - posts = pins + posts - - if ids_only: posts = [x.id for x in posts] - - g.db.commit() - - return posts, next_exists + return render_template("home.html", v=v, listing=posts, next_exists=next_exists, sort=sort, t=t, page=page, ccmode=ccmode, home=True) @app.get("/changelog") @@ -326,11 +259,10 @@ def changelog(v): sort=request.values.get("sort", "new") t=request.values.get('t', "all") - ids = changeloglist(sort=sort, + ids = listing.changeloglist(sort=sort, page=page, t=t, v=v, - site=SITE ) next_exists = (len(ids) > 25) @@ -342,29 +274,9 @@ def changelog(v): return render_template("changelog.html", v=v, listing=posts, next_exists=next_exists, sort=sort, t=t, page=page) -@cache.memoize(timeout=86400) -def changeloglist(v=None, sort="new", page=1, t="all", site=None): - - posts = g.db.query(Submission.id).filter_by(is_banned=False, private=False,).filter(Submission.deleted_utc == 0) - - if v.admin_level < 2: - posts = posts.filter(Submission.author_id.notin_(v.userblocks)) - - admins = [x[0] for x in g.db.query(User.id).filter(User.admin_level > 0).all()] - posts = posts.filter(Submission.title.ilike('_changelog%'), Submission.author_id.in_(admins)) - - if t != 'all': - posts = apply_time_filter(posts, t, Submission) - posts = sort_objects(posts, sort, Submission) - - posts = posts.offset(25 * (page - 1)).limit(26).all() - - return [x[0] for x in posts] - - @app.get("/random_post") def random_post(): - p = g.db.query(Submission.id).filter(Submission.deleted_utc == 0, Submission.is_banned == False, Submission.private == False).order_by(func.random()).first() + p = g.db.query(Submission.id).filter(Submission.state_user_deleted_utc == None, Submission.state_mod == StateMod.VISIBLE, Submission.private == False).order_by(func.random()).first() if p: p = p[0] else: abort(404) @@ -387,7 +299,7 @@ def random_user(): def all_comments(v): page = max(request.values.get("page", 1, int), 1) sort = request.values.get("sort", "new") - time_filter = request.values.get("t", defaulttimefilter) + time_filter = request.values.get("t", DEFAULT_TIME_FILTER) time_gt = request.values.get("after", 0, int) time_lt = request.values.get("before", 0, int) @@ -421,11 +333,10 @@ def get_comments_idlist(page=1, v=None, sort="new", t="all", gt=0, lt=0): if v.admin_level < 2: comments = comments.filter( Comment.author_id.notin_(v.userblocks), - Comment.is_banned == False, - Comment.deleted_utc == 0, + Comment.state_mod == StateMod.VISIBLE, + Comment.state_user_deleted_utc == None, Submission.private == False, # comment parent post not private User.shadowbanned == None, # comment author not shadowbanned - Comment.filter_state.notin_(('filtered', 'removed')), ) if gt: comments = comments.filter(Comment.created_utc > gt) diff --git a/files/routes/importstar.py b/files/routes/importstar.py new file mode 100644 index 000000000..b232bc286 --- /dev/null +++ b/files/routes/importstar.py @@ -0,0 +1,17 @@ +''' +Module that can be safely imported with the syntax +`from files.routes.importstar import *`. This essentially +contains flask stuff for routes that are used by pretty much +all routes. + +This should only be used from the route handlers. Flask imports +are used in pretty much every place, but they shouldn't be used +from the models if at all possible. + +Ideally we'd import only what we need but this is just for ease +of development. Feel free to remove. +''' + +from flask import (Response, abort, g, jsonify, make_response, redirect, + render_template, request, send_file, send_from_directory, + session) diff --git a/files/routes/login.py b/files/routes/login.py index 81f9a32b4..d8111f739 100644 --- a/files/routes/login.py +++ b/files/routes/login.py @@ -1,7 +1,8 @@ from urllib.parse import urlencode +from files.helpers.config.environment import HCAPTCHA_SECRET, HCAPTCHA_SITEKEY, WELCOME_MSG from files.mail import * from files.__main__ import app, limiter -from files.helpers.const import * +from files.helpers.config.const import * from files.helpers.captcha import validate_captcha @app.get("/login") @@ -16,7 +17,7 @@ def login_get(v): if redir.startswith(f'{SITE_FULL}/'): return redirect(redir) elif redir.startswith('/'): return redirect(f'{SITE_FULL}{redir}') - return render_template("login.html", failed=False, redirect=redir) + return render_template("login/login.html", failed=False, redirect=redir) def check_for_alts(current_id): @@ -94,18 +95,18 @@ def login_post(): if not account: time.sleep(random.uniform(0, 2)) - return render_template("login.html", failed=True) + return render_template("login/login.html", failed=True) if request.values.get("password"): if not account.verifyPass(request.values.get("password")): time.sleep(random.uniform(0, 2)) - return render_template("login.html", failed=True) + return render_template("login/login.html", failed=True) if account.mfa_secret: now = int(time.time()) hash = generate_hash(f"{account.id}+{now}+2fachallenge") - return render_template("login_2fa.html", + return render_template("login/login_2fa.html", v=account, time=now, hash=hash, @@ -123,7 +124,7 @@ def login_post(): if not account.validate_2fa(request.values.get("2fa_token", "").strip()): hash = generate_hash(f"{account.id}+{time}+2fachallenge") - return render_template("login_2fa.html", + return render_template("login/login_2fa.html", v=account, time=now, hash=hash, @@ -166,7 +167,6 @@ def me(v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def logout(v): - session.pop("session_id", None) session.pop("lo_user", None) @@ -193,7 +193,7 @@ def sign_up_get(v): ref_user = None if ref_user and (ref_user.id in session.get("history", [])): - return render_template("sign_up_failed_ref.html") + return render_template("login/sign_up_failed_ref.html") now = int(time.time()) token = token_hex(16) @@ -201,20 +201,21 @@ def sign_up_get(v): formkey_hashstr = str(now) + token + agent - formkey = hmac.new(key=bytes(environ.get("MASTER_KEY"), "utf-16"), + formkey = hmac.new(key=bytes(SECRET_KEY, "utf-16"), msg=bytes(formkey_hashstr, "utf-16"), digestmod='md5' ).hexdigest() error = request.values.get("error") - return render_template("sign_up.html", - formkey=formkey, - now=now, - ref_user=ref_user, - hcaptcha=app.config["HCAPTCHA_SITEKEY"], - error=error - ) + return render_template( + "login/sign_up.html", + formkey=formkey, + now=now, + ref_user=ref_user, + hcaptcha=HCAPTCHA_SITEKEY, + error=error + ) @app.post("/signup") @@ -237,7 +238,7 @@ def sign_up_post(v): correct_formkey_hashstr = form_timestamp + submitted_token + agent - correct_formkey = hmac.new(key=bytes(environ.get("MASTER_KEY"), "utf-16"), + correct_formkey = hmac.new(key=bytes(SECRET_KEY, "utf-16"), msg=bytes(correct_formkey_hashstr, "utf-16"), digestmod='md5' ).hexdigest() @@ -289,8 +290,7 @@ def sign_up_post(v): if existing_account: return signup_error("An account with that username already exists.") - if not validate_captcha(app.config.get("HCAPTCHA_SECRET", ""), - app.config.get("HCAPTCHA_SITEKEY", ""), + if not validate_captcha(HCAPTCHA_SECRET, HCAPTCHA_SITEKEY, request.values.get("h-captcha-response", "")): return signup_error("Unable to verify CAPTCHA") @@ -358,20 +358,19 @@ def sign_up_post(v): @app.get("/forgot") def get_forgot(): - return render_template("forgot_password.html") + return render_template("login/forgot_password.html") @app.post("/forgot") @limiter.limit("1/second;30/minute;200/hour;1000/day") def post_forgot(): - username = request.values.get("username") if not username: abort(400) email = request.values.get("email",'').strip().lower() if not email_regex.fullmatch(email): - return render_template("forgot_password.html", error="Invalid email.") + return render_template("login/forgot_password.html", error="Invalid email.") username = username.lstrip('@') @@ -391,13 +390,12 @@ def post_forgot(): v=user) ) - return render_template("forgot_password.html", + return render_template("login/forgot_password.html", msg="If the username and email matches an account, you will be sent a password reset email. You have ten minutes to complete the password reset process.") @app.get("/reset") def get_reset(): - user_id = request.values.get("id") timestamp = int(request.values.get("time",0)) @@ -422,7 +420,7 @@ def get_reset(): reset_token = generate_hash(f"{user.id}+{timestamp}+reset+{user.login_nonce}") - return render_template("reset_password.html", + return render_template("login/reset_password.html", v=user, token=reset_token, time=timestamp, @@ -458,7 +456,7 @@ def post_reset(v): abort(404) if password != confirm_password: - return render_template("reset_password.html", + return render_template("login/reset_password.html", v=user, token=token, time=timestamp, @@ -476,16 +474,14 @@ def post_reset(v): @app.get("/lost_2fa") @auth_desired def lost_2fa(v): - return render_template( - "lost_2fa.html", + "login/lost_2fa.html", v=v ) @app.post("/request_2fa_disable") @limiter.limit("1/second;6/minute;200/hour;1000/day") def request_2fa_disable(): - username=request.values.get("username") user=get_user(username, graceful=True) if not user or not user.email or not user.mfa_secret: @@ -523,7 +519,6 @@ def request_2fa_disable(): @app.get("/reset_2fa") def reset_2fa(): - now=int(time.time()) t = request.values.get("t") if not t: abort(400) diff --git a/files/routes/oauth.py b/files/routes/oauth.py index e684bc0cf..b8cc73a90 100644 --- a/files/routes/oauth.py +++ b/files/routes/oauth.py @@ -1,12 +1,14 @@ -from files.helpers.wrappers import * -from files.helpers.alerts import * -from files.helpers.get import * -from files.helpers.const import * -from files.classes import * -from flask import * -from files.__main__ import app, limiter import sqlalchemy.exc +from files.__main__ import app, limiter +from files.classes import * +from files.helpers.alerts import * +from files.helpers.config.const import * +from files.helpers.get import * +from files.helpers.wrappers import * +from files.routes.importstar import * + + @app.get("/authorize") @auth_required def authorize_prompt(v): @@ -20,7 +22,6 @@ def authorize_prompt(v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def authorize(v): - client_id = request.values.get("client_id") application = g.db.query(OauthApp).filter_by(client_id=client_id).one_or_none() if not application: return {"oauth_error": "Invalid `client_id`"}, 401 @@ -42,7 +43,6 @@ def authorize(v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @is_not_permabanned def request_api_keys(v): - new_app = OauthApp( app_name=request.values.get('name').replace('<','').replace('>',''), redirect_uri=request.values.get('redirect_uri'), @@ -62,7 +62,8 @@ def request_api_keys(v): level=1, body_html=body_html, sentto=MODMAIL_ID, - distinguish_level=6 + distinguish_level=6, + state_mod=StateMod.VISIBLE, ) g.db.add(new_comment) g.db.flush() @@ -83,7 +84,6 @@ def request_api_keys(v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def delete_oauth_app(v, aid): - aid = int(aid) app = g.db.query(OauthApp).filter_by(id=aid).one_or_none() @@ -103,7 +103,6 @@ def delete_oauth_app(v, aid): @limiter.limit("1/second;30/minute;200/hour;1000/day") @is_not_permabanned def edit_oauth_app(v, aid): - aid = int(aid) app = g.db.query(OauthApp).filter_by(id=aid).one_or_none() @@ -124,7 +123,6 @@ def edit_oauth_app(v, aid): @limiter.limit("1/second;30/minute;200/hour;1000/day") @admin_level_required(3) def admin_app_approve(v, aid): - app = g.db.query(OauthApp).filter_by(id=aid).one_or_none() user = app.author @@ -158,7 +156,6 @@ def admin_app_approve(v, aid): @limiter.limit("1/second;30/minute;200/hour;1000/day") @admin_level_required(2) def admin_app_revoke(v, aid): - app = g.db.query(OauthApp).filter_by(id=aid).one_or_none() if app: for auth in g.db.query(ClientAuth).filter_by(oauth_client=app.id).all(): g.db.delete(auth) @@ -183,7 +180,6 @@ def admin_app_revoke(v, aid): @limiter.limit("1/second;30/minute;200/hour;1000/day") @admin_level_required(2) def admin_app_reject(v, aid): - app = g.db.query(OauthApp).filter_by(id=aid).one_or_none() if app: @@ -208,9 +204,6 @@ def admin_app_reject(v, aid): @app.get("/admin/app/") @admin_level_required(2) def admin_app_id(v, aid): - - aid=aid - oauth = g.db.query(OauthApp).filter_by(id=aid).one_or_none() pids=oauth.idlist(page=int(request.values.get("page",1))) @@ -230,13 +223,9 @@ def admin_app_id(v, aid): @app.get("/admin/app//comments") @admin_level_required(2) def admin_app_id_comments(v, aid): - - aid=aid - oauth = g.db.query(OauthApp).filter_by(id=aid).one_or_none() - cids=oauth.comments_idlist(page=int(request.values.get("page",1)), - ) + cids=oauth.comments_idlist(page=int(request.values.get("page",1))) next_exists=len(cids)==101 cids=cids[:100] @@ -256,7 +245,6 @@ def admin_app_id_comments(v, aid): @app.get("/admin/apps") @admin_level_required(2) def admin_apps_list(v): - apps = g.db.query(OauthApp).order_by(OauthApp.id.desc()).all() return render_template("admin/apps.html", v=v, apps=apps) @@ -266,7 +254,6 @@ def admin_apps_list(v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def reroll_oauth_tokens(aid, v): - aid = aid a = g.db.query(OauthApp).filter_by(id=aid).one_or_none() diff --git a/files/routes/posts.py b/files/routes/posts.py index 057cb67a8..f22389de5 100644 --- a/files/routes/posts.py +++ b/files/routes/posts.py @@ -1,65 +1,43 @@ +import sys import time -import gevent -from files.helpers.wrappers import * -from files.helpers.sanitize import * -from files.helpers.alerts import * -from files.helpers.comments import comment_filter_moderated -from files.helpers.contentsorting import sort_objects -from files.helpers.const import * -from files.helpers.media import process_image -from files.helpers.strings import sql_ilike_clean -from files.classes import * -from flask import * +import urllib.parse from io import BytesIO -from files.__main__ import app, limiter, cache, db_session -from PIL import Image as PILimage -from .front import frontlist, changeloglist -from urllib.parse import ParseResult, urlunparse, urlparse, quote, unquote -from os import path +from urllib.parse import ParseResult, urlparse + +from datetime import datetime, timezone + +import gevent import requests -from shutil import copyfile -from sys import stdout +import werkzeug.wrappers +from PIL import Image as PILimage from sqlalchemy.orm import Query +import files.helpers.validators as validators +from files.__main__ import app, db_session, limiter +from files.classes import * +from files.classes.visstate import StateMod +from files.helpers.alerts import * +from files.helpers.caching import invalidate_cache +from files.helpers.config.const import * +from files.helpers.content import canonicalize_url2 +from files.helpers.contentsorting import sort_objects +from files.helpers.media import process_image +from files.helpers.sanitize import * +from files.helpers.strings import sql_ilike_clean +from files.helpers.wrappers import * +from files.routes.importstar import * -snappyquotes = [f':#{x}:' for x in marseys_const2] - -if path.exists(f'snappy_{SITE_ID}.txt'): - with open(f'snappy_{SITE_ID}.txt', "r", encoding="utf-8") as f: - snappyquotes += f.read().split("\n{[para]}\n") - -discounts = { - 69: 0.02, - 70: 0.04, - 71: 0.06, - 72: 0.08, - 73: 0.10, +titleheaders = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36" } -titleheaders = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36"} - MAX_TITLE_LENGTH = 500 MAX_URL_LENGTH = 2048 -MAX_BODY_LENGTH = SUBMISSION_BODY_LENGTH_MAXIMUM -def guarded_value(val, min_len, max_len) -> str: - ''' - Get request value `val` and ensure it is within length constraints - Requires a request context and either aborts early or returns a good value - ''' - raw = request.values.get(val, '').strip() - raw = raw.replace('\u200e', '') - - if len(raw) < min_len: abort(400, f"Minimum length for {val} is {min_len}") - if len(raw) > max_len: abort(400, f"Maximum length for {val} is {max_len}") - # TODO: it may make sense to do more sanitisation here - return raw - @app.post("/toggle_club/") @auth_required def toggle_club(pid, v): - post = get_post(pid) if post.author_id != v.id and v.admin_level < 2: abort(403) @@ -84,52 +62,19 @@ def publish(pid, v): post.created_utc = int(time.time()) g.db.add(post) - if not post.ghost: - notify_users = NOTIFY_USERS(f'{post.title} {post.body}', v) - - if notify_users: - cid = notif_comment2(post) - for x in notify_users: - add_notif(cid, x) - - if v.followers: - text = f"@{v.username} has made a new post: [{post.title}]({post.shortlink})" - if post.sub: text += f" in /h/{post.sub}" - - cid = notif_comment(text, autojanny=True) - for follow in v.followers: - user = get_account(follow.user_id) - if post.club and not user.paid_dues: continue - add_notif(cid, user.id) - + post.publish() g.db.commit() - - cache.delete_memoized(frontlist) - cache.delete_memoized(User.userpagelisting) - - if v.admin_level > 0 and ("[changelog]" in post.title.lower() or "(changelog)" in post.title.lower()): - cache.delete_memoized(changeloglist) - return redirect(post.permalink) @app.get("/submit") -# @app.get("/h//submit") @auth_required -def submit_get(v, sub=None): - if sub: sub = g.db.query(Sub.name).filter_by(name=sub.strip().lower()).one_or_none() - - if request.path.startswith('/h/') and not sub: abort(404) - - SUBS = [x[0] for x in g.db.query(Sub.name).order_by(Sub.name).all()] - - return render_template("submit.html", SUBS=SUBS, v=v, sub=sub) +def submit_get(v): + return render_template("submit.html", v=v) @app.get("/post/") @app.get("/post//") -# @app.get("/h//post/") -# @app.get("/h//post//") @auth_desired -def post_id(pid, anything=None, v=None, sub=None): +def post_id(pid, anything=None, v=None): post = get_post(pid, v=v) if post.over_18 and not (v and v.over_18) and session.get('over_18', 0) < int(time.time()): @@ -149,7 +94,6 @@ def post_id(pid, anything=None, v=None, sub=None): Comment.parent_submission == post.id, Comment.level == 1, ).order_by(Comment.is_pinned.desc().nulls_last()) - top_comments = comment_filter_moderated(top_comments, v) top_comments = sort_objects(top_comments, sort, Comment) pg_top_comment_ids = [] @@ -163,7 +107,6 @@ def post_id(pid, anything=None, v=None, sub=None): def comment_tree_filter(q: Query) -> Query: q = q.filter(Comment.top_comment_id.in_(pg_top_comment_ids)) - q = comment_filter_moderated(q, v) return q comments, comment_tree = get_comment_trees_eager(comment_tree_filter, sort, v) @@ -178,9 +121,9 @@ def post_id(pid, anything=None, v=None, sub=None): if request.headers.get("Authorization"): return post.json else: - if post.is_banned and not (v and (v.admin_level > 1 or post.author_id == v.id)): template = "submission_banned.html" + if post.state_mod != StateMod.VISIBLE and not (v and (v.admin_level >= 2 or post.author_id == v.id)): template = "submission_banned.html" else: template = "submission.html" - return render_template(template, v=v, p=post, ids=list(ids), sort=sort, render_replies=True, offset=offset, sub=post.subr) + return render_template(template, v=v, p=post, ids=list(ids), sort=sort, render_replies=True, offset=offset) @app.get("/viewmore///") @limiter.limit("1/second;30/minute;200/hour;1000/day") @@ -215,7 +158,6 @@ def viewmore(v, pid, sort, offset): # `NOT IN :ids` in top_comments. top_comments = top_comments.filter(Comment.created_utc <= newest_created_utc) - top_comments = comment_filter_moderated(top_comments, v) top_comments = sort_objects(top_comments, sort, Comment) pg_top_comment_ids = [] @@ -229,7 +171,6 @@ def viewmore(v, pid, sort, offset): def comment_tree_filter(q: Query) -> Query: q = q.filter(Comment.top_comment_id.in_(pg_top_comment_ids)) - q = comment_filter_moderated(q, v) return q _, comment_tree = get_comment_trees_eager(comment_tree_filter, sort, v) @@ -294,49 +235,37 @@ def morecomments(v, cid): return render_template("comments.html", v=v, comments=comments, p=p, render_replies=True, ajax=True) -@app.post("/edit_post/") +@app.post("/edit_post/") @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def edit_post(pid, v): p = get_post(pid) + if not v.can_edit(p): abort(403) - if p.author_id != v.id and not (v.admin_level > 1 and v.admin_level > 2): abort(403) + validated_post: validators.ValidatedSubmissionLike = \ + validators.ValidatedSubmissionLike.from_flask_request( + request, + allow_embedding=MULTIMEDIA_EMBEDDING_ENABLED, + allow_media_url_upload=False, + embed_url_file_key="file", + edit=True + ) + changed:bool=False - title = guarded_value("title", 1, MAX_TITLE_LENGTH) - title = sanitize_raw(title, allow_newlines=False, length_limit=MAX_TITLE_LENGTH) + if validated_post.title != p.title: + p.title = validated_post.title + p.title_html = validated_post.title_html + changed = True - body = guarded_value("body", 0, MAX_BODY_LENGTH) - body = sanitize_raw(body, allow_newlines=True, length_limit=MAX_BODY_LENGTH) + if validated_post.body != p.body: + p.body = validated_post.body + p.body_html = validated_post.body_html + changed = True - if title != p.title: - p.title = title - title_html = filter_emojis_only(title, edit=True) - p.title_html = title_html + if not changed: + abort(400, "You need to change something") - if request.files.get("file") and request.headers.get("cf-ipcountry") != "T1": - files = request.files.getlist('file')[:4] - for file in files: - if file.content_type.startswith('image/'): - name = f'/images/{time.time()}'.replace('.','') + '.webp' - file.save(name) - url = process_image(name) - if app.config['MULTIMEDIA_EMBEDDING_ENABLED']: - body += f"\n\n![]({url})" - else: - body += f'\n\n{url}' - else: abort(400, "Image files only") - - body_html = sanitize(body, edit=True) - - p.body = body - p.body_html = body_html - - if not p.private and not p.ghost: - notify_users = NOTIFY_USERS(f'{p.title} {p.body}', v) - if notify_users: - cid = notif_comment2(p) - for x in notify_users: - add_notif(cid, x) + p.publish() if v.id == p.author_id: if int(time.time()) - p.created_utc > 60 * 3: p.edited_utc = int(time.time()) @@ -353,17 +282,16 @@ def edit_post(pid, v): return redirect(p.permalink) + def archiveorg(url): try: requests.get(f'https://web.archive.org/save/{url}', headers={'User-Agent': 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)'}, timeout=100) except: pass def thumbnail_thread(pid): - db = db_session() def expand_url(post_url, fragment_url): - if fragment_url.startswith("https://"): return fragment_url elif fragment_url.startswith("https://"): @@ -399,8 +327,6 @@ def thumbnail_thread(pid): if x.status_code != 200: db.close() return - - if x.headers.get("Content-Type","").startswith("text/html"): soup=BeautifulSoup(x.content, 'lxml') @@ -408,15 +334,13 @@ def thumbnail_thread(pid): thumb_candidate_urls=[] meta_tags = [ - "drama:thumbnail", + "themotte:thumbnail", "twitter:image", "og:image", "thumbnail" ] for tag_name in meta_tags: - - tag = soup.find( 'meta', attrs={ @@ -492,7 +416,7 @@ def thumbnail_thread(pid): db.commit() db.close() - stdout.flush() + sys.stdout.flush() return @@ -501,248 +425,144 @@ def api_is_repost(): url = request.values.get('url') if not url: abort(400) - for rd in ("://reddit.com", "://new.reddit.com", "://www.reddit.com", "://redd.it", "://libredd.it", "://teddit.net"): - url = url.replace(rd, "://old.reddit.com") - - url = url.replace("nitter.net", "twitter.com").replace("old.reddit.com/gallery", "reddit.com/gallery").replace("https://youtu.be/", "https://youtube.com/watch?v=").replace("https://music.youtube.com/watch?v=", "https://youtube.com/watch?v=").replace("https://streamable.com/", "https://streamable.com/e/").replace("https://youtube.com/shorts/", "https://youtube.com/watch?v=").replace("https://mobile.twitter", "https://twitter").replace("https://m.facebook", "https://facebook").replace("m.wikipedia.org", "wikipedia.org").replace("https://m.youtube", "https://youtube").replace("https://www.youtube", "https://youtube").replace("https://www.twitter", "https://twitter").replace("https://www.instagram", "https://instagram").replace("https://www.tiktok", "https://tiktok") - - if "/i.imgur.com/" in url: url = url.replace(".png", ".webp").replace(".jpg", ".webp").replace(".jpeg", ".webp") - elif "/media.giphy.com/" in url or "/c.tenor.com/" in url: url = url.replace(".gif", ".webp") - elif "/i.ibb.com/" in url: url = url.replace(".png", ".webp").replace(".jpg", ".webp").replace(".jpeg", ".webp").replace(".gif", ".webp") - - if url.startswith("https://streamable.com/") and not url.startswith("https://streamable.com/e/"): url = url.replace("https://streamable.com/", "https://streamable.com/e/") - - parsed_url = urlparse(url) - - domain = parsed_url.netloc - if domain in ('old.reddit.com','twitter.com','instagram.com','tiktok.com'): - new_url = ParseResult(scheme="https", - netloc=parsed_url.netloc, - path=parsed_url.path, - params=parsed_url.params, - query=None, - fragment=parsed_url.fragment) - else: - qd = parse_qs(parsed_url.query) - filtered = {k: val for k, val in qd.items() if not k.startswith('utm_') and not k.startswith('ref_')} - - new_url = ParseResult(scheme="https", - netloc=parsed_url.netloc, - path=parsed_url.path, - params=parsed_url.params, - query=urlencode(filtered, doseq=True), - fragment=parsed_url.fragment) - - url = urlunparse(new_url) - + url = canonicalize_url2(url, httpsify=True).geturl() if url.endswith('/'): url = url[:-1] search_url = sql_ilike_clean(url) repost = g.db.query(Submission).filter( Submission.url.ilike(search_url), - Submission.deleted_utc == 0, - Submission.is_banned == False + Submission.state_user_deleted_utc == None, + Submission.state_mod == StateMod.VISIBLE ).first() if repost: return {'permalink': repost.permalink} else: return {'permalink': ''} -@app.post("/submit") -# @app.post("/h//submit") -@limiter.limit("1/second;2/minute;10/hour;50/day") -@auth_required -def submit_post(v, sub=None): - - def error(error): - if request.headers.get("Authorization") or request.headers.get("xhr"): abort(400, error) - - SUBS = [x[0] for x in g.db.query(Sub.name).order_by(Sub.name).all()] - return render_template("submit.html", SUBS=SUBS, v=v, error=error, title=title, url=url, body=body), 400 - - if v.is_suspended: return error("You can't perform this action while banned.") - - title = guarded_value("title", 1, MAX_TITLE_LENGTH) - title = sanitize_raw(title, allow_newlines=False, length_limit=MAX_TITLE_LENGTH) - - url = guarded_value("url", 0, MAX_URL_LENGTH) - - body = guarded_value("body", 0, MAX_BODY_LENGTH) - body = sanitize_raw(body, allow_newlines=True, length_limit=MAX_BODY_LENGTH) - - sub = request.values.get("sub") - if sub: sub = sub.replace('/h/','').replace('s/','') - - if sub and sub != 'none': - sname = sub.strip().lower() - sub = g.db.query(Sub.name).filter_by(name=sname).one_or_none() - if not sub: return error(f"/h/{sname} not found!") - sub = sub[0] - if v.exiled_from(sub): return error(f"You're exiled from /h/{sub}") - else: sub = None - - title_html = filter_emojis_only(title, graceful=True) - - if len(title_html) > 1500: return error("Rendered title is too big!") - - embed = None - - if url: - for rd in ("://reddit.com", "://new.reddit.com", "://www.reddit.com", "://redd.it", "://libredd.it", "://teddit.net"): - url = url.replace(rd, "://old.reddit.com") - - url = url.replace("nitter.net", "twitter.com").replace("old.reddit.com/gallery", "reddit.com/gallery").replace("https://youtu.be/", "https://youtube.com/watch?v=").replace("https://music.youtube.com/watch?v=", "https://youtube.com/watch?v=").replace("https://streamable.com/", "https://streamable.com/e/").replace("https://youtube.com/shorts/", "https://youtube.com/watch?v=").replace("https://mobile.twitter", "https://twitter").replace("https://m.facebook", "https://facebook").replace("m.wikipedia.org", "wikipedia.org").replace("https://m.youtube", "https://youtube").replace("https://www.youtube", "https://youtube").replace("https://www.twitter", "https://twitter").replace("https://www.instagram", "https://instagram").replace("https://www.tiktok", "https://tiktok") - - if "/i.imgur.com/" in url: url = url.replace(".png", ".webp").replace(".jpg", ".webp").replace(".jpeg", ".webp") - elif "/media.giphy.com/" in url or "/c.tenor.com/" in url: url = url.replace(".gif", ".webp") - elif "/i.ibb.com/" in url: url = url.replace(".png", ".webp").replace(".jpg", ".webp").replace(".jpeg", ".webp").replace(".gif", ".webp") - - if url.startswith("https://streamable.com/") and not url.startswith("https://streamable.com/e/"): url = url.replace("https://streamable.com/", "https://streamable.com/e/") - - parsed_url = urlparse(url) - - domain = parsed_url.netloc - if domain in ('old.reddit.com','twitter.com','instagram.com','tiktok.com'): - new_url = ParseResult(scheme="https", - netloc=parsed_url.netloc, - path=parsed_url.path, - params=parsed_url.params, - query=None, - fragment=parsed_url.fragment) - else: - qd = parse_qs(parsed_url.query) - filtered = {k: val for k, val in qd.items() if not k.startswith('utm_') and not k.startswith('ref_')} - - new_url = ParseResult(scheme="https", - netloc=parsed_url.netloc, - path=parsed_url.path, - params=parsed_url.params, - query=urlencode(filtered, doseq=True), - fragment=parsed_url.fragment) - - search_url = urlunparse(new_url) - - if search_url.endswith('/'): url = url[:-1] - - repost = g.db.query(Submission).filter( - func.lower(Submission.url) == search_url.lower(), - Submission.deleted_utc == 0, - Submission.is_banned == False - ).first() - if repost and SITE != 'localhost': return redirect(repost.permalink) - - domain_obj = get_domain(domain) - if not domain_obj: domain_obj = get_domain(domain+parsed_url.path) - - if domain_obj: - reason = f"Remove the {domain_obj.domain} link from your post and try again. {domain_obj.reason}" - return error(reason) - elif "twitter.com" == domain: - try: embed = requests.get("https://publish.twitter.com/oembed", params={"url":url, "omit_script":"t"}, timeout=5).json()["html"] - except: pass - elif url.startswith('https://youtube.com/watch?v='): - url = unquote(url).replace('?t', '&t') - yt_id = url.split('https://youtube.com/watch?v=')[1].split('&')[0].split('%')[0] - - if yt_id_regex.fullmatch(yt_id): - req = requests.get(f"https://www.googleapis.com/youtube/v3/videos?id={yt_id}&key={YOUTUBE_KEY}&part=contentDetails", timeout=5).json() - if req.get('items'): - params = parse_qs(urlparse(url).query) - t = params.get('t', params.get('start', [0]))[0] - if isinstance(t, str): t = t.replace('s','') - - embed = f'' - - elif app.config['SERVER_NAME'] in domain and "/post/" in url and "context" not in url: - id = url.split("/post/")[1] - if "/" in id: id = id.split("/")[0] - embed = str(int(id)) - - - if not url and not body and not request.files.get("file") and not request.files.get("file2"): - return error("Please enter a url or some text.") - - dup = g.db.query(Submission).filter( - Submission.author_id == v.id, - Submission.deleted_utc == 0, - Submission.title == title, - Submission.url == url, - Submission.body == body - ).one_or_none() - - if dup and SITE != 'localhost': return redirect(dup.permalink) +def _do_antispam_submission_check(v:User, validated:validators.ValidatedSubmissionLike): now = int(time.time()) cutoff = now - 60 * 60 * 24 - similar_posts = g.db.query(Submission).filter( - Submission.author_id == v.id, - Submission.title.op('<->')(title) < app.config["SPAM_SIMILARITY_THRESHOLD"], - Submission.created_utc > cutoff + Submission.author_id == v.id, + Submission.title.op('<->')(validated.title) < app.config["SPAM_SIMILARITY_THRESHOLD"], + Submission.created_utc > cutoff ).all() - if url: + if validated.url: similar_urls = g.db.query(Submission).filter( - Submission.author_id == v.id, - Submission.url.op('<->')(url) < app.config["SPAM_URL_SIMILARITY_THRESHOLD"], - Submission.created_utc > cutoff + Submission.author_id == v.id, + Submission.url.op('<->')(validated.url) < app.config["SPAM_URL_SIMILARITY_THRESHOLD"], + Submission.created_utc > cutoff ).all() - else: similar_urls = [] + 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 v.age_seconds >= (60 * 60 * 24 * 7): threshold *= 3 + elif v.age_seconds >= (60 * 60 * 24): threshold *= 2 - if max(len(similar_urls), len(similar_posts)) >= threshold: + if max(len(similar_urls), len(similar_posts)) < threshold: + return - text = "Your account has been banned for **1 day** for the following reason:\n\n> Too much spam!" - send_repeatable_notification(v.id, text) + text = "Your account has been banned for **1 day** for the following reason:\n\n> Too much spam!" + send_repeatable_notification(v.id, text) - v.ban(reason="Spamming.", - days=1) + v.ban(reason="Spamming.", days=1) + for post in similar_posts + similar_urls: + post.state_mod = StateMod.REMOVED + post.state_mod_set_by = "AutoJanny" + post.is_pinned = False + g.db.add(post) + ma=ModAction( + user_id=AUTOJANNY_ID, + target_submission_id=post.id, + kind="remove_post", + _note="spam" + ) + g.db.add(ma) + g.db.commit() + abort(403) - for post in similar_posts + similar_urls: - post.is_banned = True - post.is_pinned = False - post.ban_reason = "AutoJanny" - g.db.add(post) - ma=ModAction( - user_id=AUTOJANNY_ID, - target_submission_id=post.id, - kind="ban_post", - _note="spam" - ) - g.db.add(ma) - return redirect("/notifications") - if request.files.get("file2") and request.headers.get("cf-ipcountry") != "T1": - files = request.files.getlist('file2')[:4] - for file in files: - if file.content_type.startswith('image/'): - name = f'/images/{time.time()}'.replace('.','') + '.webp' - file.save(name) - image = process_image(name) - if app.config['MULTIMEDIA_EMBEDDING_ENABLED']: - body += f"\n\n![]({image})" - else: - body += f'\n\n{image}' - else: - return error("Image files only") +def _execute_domain_ban_check(parsed_url:ParseResult): + domain:str = parsed_url.netloc + domain_obj = get_domain(domain) + if not domain_obj: + domain_obj = get_domain(domain+parsed_url.path) + if not domain_obj: return + abort(403, f"Remove the {domain_obj.domain} link from your post and try again. {domain_obj.reason}") - body_html = sanitize(body) + +def _duplicate_check(search_url:Optional[str]) -> Optional[werkzeug.wrappers.Response]: + if not search_url: return None + repost = g.db.query(Submission).filter( + func.lower(Submission.url) == search_url.lower(), + Submission.state_user_deleted_utc == None, + Submission.state_mod == StateMod.VISIBLE + ).first() + if repost and SITE != 'localhost': + return redirect(repost.permalink) + return None + + +def _duplicate_check2( + user_id:int, + validated_post:validators.ValidatedSubmissionLike) -> Optional[werkzeug.wrappers.Response]: + dup = g.db.query(Submission).filter( + Submission.author_id == user_id, + Submission.state_user_deleted_utc == None, + Submission.title == validated_post.title, + Submission.url == validated_post.url, + Submission.body == validated_post.body + ).one_or_none() + + if dup and SITE != 'localhost': + return redirect(dup.permalink) + return None + + +@app.post("/submit") +@limiter.limit("1/second;2/minute;10/hour;50/day") +@auth_required +def submit_post(v): + def error(error): + title:str = request.values.get("title", "") + body:str = request.values.get("body", "") + url:str = request.values.get("url", "") + + if request.headers.get("Authorization") or request.headers.get("xhr"): abort(400, error) + return render_template("submit.html", v=v, error=error, title=title, url=url, body=body), 400 + + if v.is_suspended: return error("You can't perform this action while banned.") + + try: + validated_post: validators.ValidatedSubmissionLike = \ + validators.ValidatedSubmissionLike.from_flask_request(request, + allow_embedding=MULTIMEDIA_EMBEDDING_ENABLED, + ) + except ValueError as e: + return error(str(e)) + + duplicate:Optional[werkzeug.wrappers.Response] = \ + _duplicate_check(validated_post.repost_search_url) + if duplicate: return duplicate + + parsed_url:Optional[ParseResult] = validated_post.url_canonical + if parsed_url: + _execute_domain_ban_check(parsed_url) + + duplicate:Optional[werkzeug.wrappers.Response] = \ + _duplicate_check2(v.id, validated_post) + if duplicate: return duplicate + + _do_antispam_submission_check(v, validated_post) club = bool(request.values.get("club","")) - - if embed and len(embed) > 1500: embed = None - is_bot = bool(request.headers.get("Authorization")) # Invariant: these values are guarded and obey the length bound - assert len(title) <= MAX_TITLE_LENGTH - assert len(body) <= MAX_BODY_LENGTH + assert len(validated_post.title) <= MAX_TITLE_LENGTH + assert len(validated_post.body) <= SUBMISSION_BODY_LENGTH_MAXIMUM post = Submission( private=bool(request.values.get("private","")), @@ -750,98 +570,49 @@ def submit_post(v, sub=None): author_id=v.id, over_18=bool(request.values.get("over_18","")), app_id=v.client.application.id if v.client else None, - is_bot = is_bot, - url=url, - body=body, - body_html=body_html, - embed_url=embed, - title=title, - title_html=title_html, - sub=sub, + is_bot=is_bot, + url=validated_post.url, + body=validated_post.body, + body_html=validated_post.body_html, + embed_url=validated_post.embed_slow, + title=validated_post.title, + title_html=validated_post.title_html, ghost=False, - filter_state='filtered' if v.admin_level == 0 and app.config['SETTINGS']['FilterNewPosts'] else 'normal' + state_mod=StateMod.FILTERED if v.admin_level == 0 and app.config['SETTINGS']['FilterNewPosts'] else StateMod.VISIBLE, + thumburl=validated_post.thumburl ) - - g.db.add(post) - g.db.flush() - - vote = Vote(user_id=v.id, - vote_type=1, - submission_id=post.id - ) - g.db.add(vote) - - if request.files.get('file') and request.headers.get("cf-ipcountry") != "T1": - - file = request.files['file'] - - if file.content_type.startswith('image/'): - name = f'/images/{time.time()}'.replace('.','') + '.webp' - file.save(name) - post.url = process_image(name) - - name2 = name.replace('.webp', 'r.webp') - copyfile(name, name2) - post.thumburl = process_image(name2, resize=100) - else: - return error("Image files only") + post.submit(g.db) if not post.thumburl and post.url: gevent.spawn(thumbnail_thread, post.id) - if not post.private and not post.ghost: - - notify_users = NOTIFY_USERS(f'{title} {body}', v) - - if notify_users: - cid = notif_comment2(post) - for x in notify_users: - add_notif(cid, x) - - if (request.values.get('followers') or is_bot) and v.followers: - text = f"@{v.username} has made a new post: [{post.title}]({post.shortlink})" - if post.sub: text += f" in /h/{post.sub}" - - cid = notif_comment(text, autojanny=True) - for follow in v.followers: - user = get_account(follow.user_id) - if post.club and not user.paid_dues: continue - add_notif(cid, user.id) - - v.post_count = g.db.query(Submission.id).filter_by(author_id=v.id, is_banned=False, deleted_utc=0).count() - g.db.add(v) + post.publish() g.db.commit() - cache.delete_memoized(frontlist) - cache.delete_memoized(User.userpagelisting) - - if v.admin_level > 0 and ("[changelog]" in post.title.lower() or "(changelog)" in post.title.lower()) and not post.private: - cache.delete_memoized(changeloglist) - - if request.headers.get("Authorization"): return post.json + if request.headers.get("Authorization"): + return post.json else: post.voted = 1 if 'megathread' in post.title.lower(): sort = 'new' else: sort = v.defaultsortingcomments - return render_template('submission.html', v=v, p=post, sort=sort, render_replies=True, offset=0, success=True, sub=post.subr) + return render_template('submission.html', v=v, p=post, sort=sort, render_replies=True, offset=0, success=True) @app.post("/delete_post/") @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def delete_post_pid(pid, v): - post = get_post(pid) if post.author_id != v.id: abort(403) - post.deleted_utc = int(time.time()) + post.state_user_deleted_utc = datetime.now(tz=timezone.utc) post.is_pinned = False post.stickied = None g.db.add(post) - cache.delete_memoized(frontlist) + invalidate_cache(frontlist=True) g.db.commit() @@ -853,10 +624,11 @@ def delete_post_pid(pid, v): def undelete_post_pid(pid, v): post = get_post(pid) if post.author_id != v.id: abort(403) - post.deleted_utc =0 + post.state_user_deleted_utc = None + g.db.add(post) - cache.delete_memoized(frontlist) + invalidate_cache(frontlist=True) g.db.commit() @@ -866,9 +638,8 @@ def undelete_post_pid(pid, v): @app.post("/toggle_comment_nsfw/") @auth_required def toggle_comment_nsfw(cid, v): - comment = g.db.query(Comment).filter_by(id=cid).one_or_none() - if comment.author_id != v.id and not v.admin_level > 1: abort(403) + if comment.author_id != v.id and not v.admin_level >= 2: abort(403) comment.over_18 = not comment.over_18 g.db.add(comment) @@ -880,10 +651,9 @@ def toggle_comment_nsfw(cid, v): @app.post("/toggle_post_nsfw/") @auth_required def toggle_post_nsfw(pid, v): - post = get_post(pid) - if post.author_id != v.id and not v.admin_level > 1: + if post.author_id != v.id and not v.admin_level >= 2: abort(403) post.over_18 = not post.over_18 @@ -906,7 +676,6 @@ def toggle_post_nsfw(pid, v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def save_post(pid, v): - post=get_post(pid) save = g.db.query(SaveRelationship).filter_by(user_id=v.id, submission_id=post.id).one_or_none() @@ -922,7 +691,6 @@ def save_post(pid, v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def unsave_post(pid, v): - post=get_post(pid) save = g.db.query(SaveRelationship).filter_by(user_id=v.id, submission_id=post.id).one_or_none() @@ -941,7 +709,7 @@ def api_pin_post(post_id, v): post.is_pinned = not post.is_pinned g.db.add(post) - cache.delete_memoized(User.userpagelisting) + invalidate_cache(userpagelisting=True) g.db.commit() if post.is_pinned: return {"message": "Post pinned!"} diff --git a/files/routes/reporting.py b/files/routes/reporting.py index 1101fba56..bcf0e608c 100644 --- a/files/routes/reporting.py +++ b/files/routes/reporting.py @@ -1,20 +1,21 @@ -from files.helpers.wrappers import * -from files.helpers.get import * from flask import g + from files.__main__ import app, limiter -from os import path +from files.classes.visstate import StateReport +from files.helpers.get import * from files.helpers.sanitize import filter_emojis_only +from files.helpers.wrappers import * + @app.post("/report/post/") @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def api_flag_post(pid, v): - post = get_post(pid) reason = request.values.get("reason", "").strip()[:100] reason = filter_emojis_only(reason) - if reason.startswith('!') and v.admin_level > 1: + if reason.startswith('!') and v.admin_level >= 2: post.flair = reason[1:] g.db.add(post) ma=ModAction( @@ -27,9 +28,12 @@ def api_flag_post(pid, v): else: flag = Flag(post_id=post.id, user_id=v.id, reason=reason) g.db.add(flag) - g.db.query(Submission) \ - .where(Submission.id == post.id, Submission.filter_state != 'ignored') \ - .update({Submission.filter_state: 'reported'}) + + # We only want to notify if the user is not permabanned + if not v.is_suspended_permanently: + g.db.query(Submission) \ + .where(Submission.id == post.id, Submission.state_report != StateReport.IGNORED) \ + .update({Submission.state_report: StateReport.REPORTED}) g.db.commit() @@ -40,16 +44,20 @@ def api_flag_post(pid, v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def api_flag_comment(cid, v): - comment = get_comment(cid) reason = request.values.get("reason", "").strip()[:100] reason = filter_emojis_only(reason) flag = CommentFlag(comment_id=comment.id, user_id=v.id, reason=reason) g.db.add(flag) - g.db.query(Comment) \ - .where(Comment.id == comment.id, Comment.filter_state != 'ignored') \ - .update({Comment.filter_state: 'reported'}) + + # We only want to notify if the user is not permabanned + if not v.is_suspended_permanently: + g.db.query(Comment) \ + .where(Comment.id == comment.id, Comment.state_report != StateReport.IGNORED) \ + .update({Comment.state_report: StateReport.REPORTED}) + + g.db.commit() return {"message": "Comment reported!"} diff --git a/files/routes/search.py b/files/routes/search.py index da436f388..7830c3865 100644 --- a/files/routes/search.py +++ b/files/routes/search.py @@ -1,11 +1,11 @@ -from files.helpers.wrappers import * -import re from sqlalchemy import * -from flask import * + from files.__main__ import app +from files.classes.visstate import StateMod from files.helpers.contentsorting import apply_time_filter, sort_objects from files.helpers.strings import sql_ilike_clean - +from files.helpers.wrappers import * +from files.routes.importstar import * valid_params=[ 'author', @@ -45,9 +45,9 @@ def searchposts(v): if not (v and v.paid_dues): posts = posts.filter_by(club=False) if v and v.admin_level < 2: - posts = posts.filter(Submission.deleted_utc == 0, Submission.is_banned == False, Submission.private == False, Submission.author_id.notin_(v.userblocks)) + posts = posts.filter(Submission.state_user_deleted_utc == None, Submission.state_mod == StateMod.VISIBLE, Submission.private == False, Submission.author_id.notin_(v.userblocks)) elif not v: - posts = posts.filter(Submission.deleted_utc == 0, Submission.is_banned == False, Submission.private == False) + posts = posts.filter(Submission.state_user_deleted_utc == None, Submission.state_mod == StateMod.VISIBLE, Submission.private == False) if 'author' in criteria: @@ -169,10 +169,10 @@ def searchcomments(v): if v and v.admin_level < 2: private = [x[0] for x in g.db.query(Submission.id).filter(Submission.private == True).all()] - comments = comments.filter(Comment.author_id.notin_(v.userblocks), Comment.is_banned==False, Comment.deleted_utc == 0, Comment.parent_submission.notin_(private)) + comments = comments.filter(Comment.author_id.notin_(v.userblocks), Comment.state_mod == StateMod.VISIBLE, Comment.state_user_deleted_utc == None, Comment.parent_submission.notin_(private)) elif not v: private = [x[0] for x in g.db.query(Submission.id).filter(Submission.private == True).all()] - comments = comments.filter(Comment.is_banned==False, Comment.deleted_utc == 0, Comment.parent_submission.notin_(private)) + comments = comments.filter(Comment.state_mod == StateMod.VISIBLE, Comment.state_user_deleted_utc == None, Comment.parent_submission.notin_(private)) if not (v and v.paid_dues): diff --git a/files/routes/settings.py b/files/routes/settings.py index af5b45a3e..effb407fb 100644 --- a/files/routes/settings.py +++ b/files/routes/settings.py @@ -1,15 +1,14 @@ +import os +from shutil import copyfile + +from files.__main__ import app, limiter from files.helpers.alerts import * +from files.helpers.caching import invalidate_cache +from files.helpers.config.const import * from files.helpers.media import process_image from files.helpers.sanitize import * -from files.helpers.const import * -from files.mail import * -from files.__main__ import app, cache, limiter -from .front import frontlist -import os from files.helpers.sanitize import filter_emojis_only -from files.helpers.strings import sql_ilike_clean -from shutil import copyfile -import requests +from files.mail import * tiers={ "(Paypig)": 1, @@ -22,26 +21,13 @@ tiers={ "(LlamaBean)": 1, } -@app.post("/settings/removebackground") -@limiter.limit("1/second;30/minute;200/hour;1000/day") -@auth_required -def removebackground(v): - v.background = None - g.db.add(v) - g.db.commit() - return {"message": "Background removed!"} - @app.post("/settings/profile") @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def settings_profile_post(v): updated = False - if request.values.get("background", v.background) != v.background: - updated = True - v.background = request.values.get("background") - - elif request.values.get("reddit", v.reddit) != v.reddit: + if request.values.get("reddit", v.reddit) != v.reddit: reddit = request.values.get("reddit") if reddit in {'old.reddit.com', 'reddit.com', 'i.reddit.com', 'teddit.net', 'libredd.it', 'unddit.com'}: updated = True @@ -78,10 +64,6 @@ def settings_profile_post(v): elif request.values.get("over18", v.over_18) != v.over_18: updated = True v.over_18 = request.values.get("over18") == 'true' - - elif request.values.get("private", v.is_private) != v.is_private: - updated = True - v.is_private = request.values.get("private") == 'true' elif request.values.get("nofollow", v.is_nofollow) != v.is_nofollow: updated = True @@ -199,7 +181,7 @@ def settings_profile_post(v): if frontsize in {"15", "25", "50", "100"}: v.frontsize = int(frontsize) updated = True - cache.delete_memoized(frontlist) + invalidate_cache(frontlist=True) else: abort(400) defaultsortingcomments = request.values.get("defaultsortingcomments") @@ -226,10 +208,11 @@ def settings_profile_post(v): theme = request.values.get("theme") if theme: if theme in THEMES: - if theme == "transparent" and not v.background: - abort(400, "You need to set a background to use the transparent theme!") v.theme = theme - if theme == "win98": v.themecolor = "30409f" + if theme == "win98": + v.themecolor = "30409f" + else: + v.themecolor = "fff" updated = True else: abort(400) @@ -275,7 +258,7 @@ def changelogsub(v): v.changelogsub = not v.changelogsub g.db.add(v) - cache.delete_memoized(frontlist) + invalidate_cache(frontlist=True) g.db.commit() if v.changelogsub: return {"message": "You have subscribed to the changelog!"} @@ -285,7 +268,6 @@ def changelogsub(v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def namecolor(v): - color = str(request.values.get("color", "")).strip() if color.startswith('#'): color = color[1:] if len(color) != 6: return render_template("settings_security.html", v=v, error="Invalid color code") @@ -298,7 +280,6 @@ def namecolor(v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def themecolor(v): - themecolor = str(request.values.get("themecolor", "")).strip() if themecolor.startswith('#'): themecolor = themecolor[1:] if len(themecolor) != 6: return render_template("settings_security.html", v=v, error="Invalid color code") @@ -311,7 +292,6 @@ def themecolor(v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def titlecolor(v): - titlecolor = str(request.values.get("titlecolor", "")).strip() if titlecolor.startswith('#'): titlecolor = titlecolor[1:] if len(titlecolor) != 6: return render_template("settings_profile.html", v=v, error="Invalid color code") @@ -419,7 +399,6 @@ def settings_security_post(v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def settings_log_out_others(v): - submitted_password = request.values.get("password", "").strip() if not v.verifyPass(submitted_password): @@ -557,7 +536,7 @@ def settings_block_user(v): target_id=user.id, ) g.db.add(new_block) - cache.delete_memoized(frontlist) + invalidate_cache(frontlist=True) g.db.commit() return {"message": f"@{user.username} blocked."} @@ -571,7 +550,7 @@ def settings_unblock_user(v): x = v.is_blocking(user) if not x: abort(409) g.db.delete(x) - cache.delete_memoized(frontlist) + invalidate_cache(frontlist=True) g.db.commit() return {"message": f"@{user.username} unblocked."} @@ -591,7 +570,6 @@ def settings_content_get(v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @is_not_permabanned def settings_name_change(v): - new_name=request.values.get("name").strip() if new_name==v.username: @@ -629,7 +607,6 @@ def settings_name_change(v): @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def settings_title_change(v): - if v.flairchanged: abort(403) new_name=request.values.get("title").strip()[:100].replace("𒐪","") @@ -659,3 +636,16 @@ def settings_profile(v): if v.flairchanged: ti = datetime.utcfromtimestamp(v.flairchanged).strftime('%Y-%m-%d %H:%M:%S') else: ti = '' return render_template("settings_profile.html", v=v, ti=ti) + + +# other settings i guess + +@app.post("/id//private/") +@auth_required +def post_set_user_profile_privacy(v: User, id: int, enabled: int): + if enabled != 0 and enabled != 1: abort(400, "'enabled' must be '0' or '1'") + user: User = get_account(id) + if not user.can_change_user_privacy(v): abort(403) + user.is_private = bool(enabled) + g.db.commit() + return {"message": f"{'Enabled' if user.is_private else 'Disabled'} private mode successfully!"} diff --git a/files/routes/static.py b/files/routes/static.py index 4945f317f..200db217e 100644 --- a/files/routes/static.py +++ b/files/routes/static.py @@ -1,17 +1,20 @@ +import calendar + +import matplotlib.pyplot as plt +from sqlalchemy import func + +from files.classes.award import AWARDS +from files.classes.badges import BadgeDef +from files.classes.mod_logs import ACTIONTYPES, ACTIONTYPES2 +from files.classes.visstate import StateMod +from files.helpers.alerts import * +from files.helpers.captcha import validate_captcha +from files.helpers.config.const import * +from files.helpers.config.environment import HCAPTCHA_SECRET, HCAPTCHA_SITEKEY from files.helpers.media import process_image from files.mail import * -from files.__main__ import app, limiter, mail -from files.helpers.alerts import * -from files.helpers.const import * -from files.helpers.captcha import validate_captcha -from files.classes.award import AWARDS -from sqlalchemy import func -from os import path -import calendar -import matplotlib.pyplot as plt -from files.classes.mod_logs import ACTIONTYPES, ACTIONTYPES2 -from files.classes.badges import BadgeDef -import logging +from files.__main__ import app, cache, limiter # violates isort but used to prevent getting shadowed + @app.get('/logged_out/') @app.get('/logged_out/') @@ -31,13 +34,6 @@ def logged_out(old = ""): return redirect(redirect_url) -@app.get("/marsey_list") -@cache.memoize(timeout=600, make_name=make_name) -def marsey_list(): - marseys = [f"{x.name} : {x.tags}" for x in g.db.query(Marsey).order_by(Marsey.count.desc())] - - return str(marseys).replace("'",'"') - @app.get('/sidebar') @auth_desired def sidebar(v): @@ -57,8 +53,6 @@ def support(v): @auth_desired @cache.memoize(timeout=86400, make_name=make_name) def participation_stats(v): - - day = int(time.time()) - 86400 week = int(time.time()) - 604800 @@ -69,38 +63,40 @@ def participation_stats(v): active_users = set(posters) | set(commenters) | set(voters) | set(commentvoters) - stats = {"marseys": g.db.query(Marsey.name).count(), - "users": g.db.query(User.id).count(), - "private users": g.db.query(User.id).filter_by(is_private=True).count(), - "banned users": g.db.query(User.id).filter(User.is_banned > 0).count(), - "verified email users": g.db.query(User.id).filter_by(is_activated=True).count(), - "coins in circulation": g.db.query(func.sum(User.coins)).scalar(), - "total shop sales": g.db.query(func.sum(User.coins_spent)).scalar(), - "signups last 24h": g.db.query(User.id).filter(User.created_utc > day).count(), - "total posts": g.db.query(Submission.id).count(), - "posting users": g.db.query(Submission.author_id).distinct().count(), - "listed posts": g.db.query(Submission.id).filter_by(is_banned=False).filter(Submission.deleted_utc == 0).count(), - "removed posts (by admins)": g.db.query(Submission.id).filter_by(is_banned=True).count(), - "deleted posts (by author)": g.db.query(Submission.id).filter(Submission.deleted_utc > 0).count(), - "posts last 24h": g.db.query(Submission.id).filter(Submission.created_utc > day).count(), - "total comments": g.db.query(Comment.id).filter(Comment.author_id.notin_((AUTOJANNY_ID,NOTIFICATIONS_ID))).count(), - "commenting users": g.db.query(Comment.author_id).distinct().count(), - "removed comments (by admins)": g.db.query(Comment.id).filter_by(is_banned=True).count(), - "deleted comments (by author)": g.db.query(Comment.id).filter(Comment.deleted_utc > 0).count(), - "comments last_24h": g.db.query(Comment.id).filter(Comment.created_utc > day, Comment.author_id.notin_((AUTOJANNY_ID,NOTIFICATIONS_ID))).count(), - "post votes": g.db.query(Vote.submission_id).count(), - "post voting users": g.db.query(Vote.user_id).distinct().count(), - "comment votes": g.db.query(CommentVote.comment_id).count(), - "comment voting users": g.db.query(CommentVote.user_id).distinct().count(), - "total upvotes": g.db.query(Vote.submission_id).filter_by(vote_type=1).count() + g.db.query(CommentVote.comment_id).filter_by(vote_type=1).count(), - "total downvotes": g.db.query(Vote.submission_id).filter_by(vote_type=-1).count() + g.db.query(CommentVote.comment_id).filter_by(vote_type=-1).count(), - "total awards": g.db.query(AwardRelationship.id).count(), - "awards given": g.db.query(AwardRelationship.id).filter(or_(AwardRelationship.submission_id != None, AwardRelationship.comment_id != None)).count(), - "users who posted, commented, or voted in the past 7 days": len(active_users), - } - - g.db.commit() + users: Query = g.db.query(User.id) + submissions: Query = g.db.query(Submission.id) + comments: Query = g.db.query(Comment.id) + stats = { + "marseys": g.db.query(Marsey.name).count(), + "users": users.count(), + "private users": users.filter_by(is_private=True).count(), + "banned users": users.filter(User.is_banned > 0).count(), + "verified email users": users.filter_by(is_activated=True).count(), + "coins in circulation": g.db.query(func.sum(User.coins)).scalar(), + "total shop sales": g.db.query(func.sum(User.coins_spent)).scalar(), + "signups last 24h": users.filter(User.created_utc > day).count(), + "total posts": submissions.count(), + "posting users": g.db.query(Submission.author_id).distinct().count(), + "listed posts": submissions.filter(Submission.state_mod == StateMod.VISIBLE).filter(Submission.state_user_deleted_utc == None).count(), + "removed posts (by admins)": submissions.filter(Submission.state_mod != StateMod.VISIBLE).count(), + "deleted posts (by author)": submissions.filter(Submission.state_user_deleted_utc != None).count(), + "posts last 24h": submissions.filter(Submission.created_utc > day).count(), + "total comments": comments.filter(Comment.author_id.notin_((AUTOJANNY_ID,NOTIFICATIONS_ID))).count(), + "commenting users": g.db.query(Comment.author_id).distinct().count(), + "removed comments (by admins)": comments.filter(Comment.state_mod != StateMod.VISIBLE).count(), + "deleted comments (by author)": comments.filter(Comment.state_user_deleted_utc != None).count(), + "comments last_24h": comments.filter(Comment.created_utc > day, Comment.author_id.notin_((AUTOJANNY_ID,NOTIFICATIONS_ID))).count(), + "post votes": g.db.query(Vote.submission_id).count(), + "post voting users": g.db.query(Vote.user_id).distinct().count(), + "comment votes": g.db.query(CommentVote.comment_id).count(), + "comment voting users": g.db.query(CommentVote.user_id).distinct().count(), + "total upvotes": g.db.query(Vote.submission_id).filter_by(vote_type=1).count() + g.db.query(CommentVote.comment_id).filter_by(vote_type=1).count(), + "total downvotes": g.db.query(Vote.submission_id).filter_by(vote_type=-1).count() + g.db.query(CommentVote.comment_id).filter_by(vote_type=-1).count(), + "total awards": g.db.query(AwardRelationship.id).count(), + "awards given": g.db.query(AwardRelationship.id).filter(or_(AwardRelationship.submission_id != None, AwardRelationship.comment_id != None)).count(), + "users who posted, commented, or voted in the past 7 days": len(active_users), + } return render_template("admin/content_stats.html", v=v, title="Content Statistics", data=stats) @@ -115,6 +111,7 @@ def weekly_chart(): f = send_file(file) return f + @app.get("/daily_chart") def daily_chart(): file = cached_chart(kind="daily", site=SITE) @@ -146,9 +143,9 @@ def cached_chart(kind, site): daily_signups = [g.db.query(User.id).filter(User.created_utc < day_cutoffs[i], User.created_utc > day_cutoffs[i + 1]).count() for i in range(len(day_cutoffs) - 1)][::-1] - post_stats = [g.db.query(Submission.id).filter(Submission.created_utc < day_cutoffs[i], Submission.created_utc > day_cutoffs[i + 1], Submission.is_banned == False).count() for i in range(len(day_cutoffs) - 1)][::-1] + post_stats = [g.db.query(Submission.id).filter(Submission.created_utc < day_cutoffs[i], Submission.created_utc > day_cutoffs[i + 1], Submission.state_mod == StateMod.VISIBLE).count() for i in range(len(day_cutoffs) - 1)][::-1] - comment_stats = [g.db.query(Comment.id).filter(Comment.created_utc < day_cutoffs[i], Comment.created_utc > day_cutoffs[i + 1],Comment.is_banned == False, Comment.author_id.notin_((AUTOJANNY_ID,NOTIFICATIONS_ID))).count() for i in range(len(day_cutoffs) - 1)][::-1] + comment_stats = [g.db.query(Comment.id).filter(Comment.created_utc < day_cutoffs[i], Comment.created_utc > day_cutoffs[i + 1],Comment.state_mod == StateMod.VISIBLE, Comment.author_id.notin_((AUTOJANNY_ID,NOTIFICATIONS_ID))).count() for i in range(len(day_cutoffs) - 1)][::-1] plt.rcParams["figure.figsize"] = (30, 20) @@ -156,7 +153,7 @@ def cached_chart(kind, site): posts_chart = plt.subplot2grid((30, 20), (10, 0), rowspan=6, colspan=30) comments_chart = plt.subplot2grid((30, 20), (20, 0), rowspan=6, colspan=30) - signup_chart.grid(), posts_chart.grid(), comments_chart.grid() + _ = signup_chart.grid(), posts_chart.grid(), comments_chart.grid() signup_chart.plot( daily_times, @@ -201,7 +198,7 @@ def patrons(v): @app.get("/admins") @auth_desired def admins(v): - if v and v.admin_level > 2: + if v and v.admin_level >= 3: admins = g.db.query(User).filter(User.admin_level>1).order_by(User.truescore.desc()).all() admins += g.db.query(User).filter(User.admin_level==1).order_by(User.truescore.desc()).all() else: admins = g.db.query(User).filter(User.admin_level>0).order_by(User.truescore.desc()).all() @@ -212,7 +209,6 @@ def admins(v): @app.get("/modlog") @auth_desired def log(v): - try: page = max(int(request.values.get("page", 1)), 1) except: page = 1 @@ -222,7 +218,7 @@ def log(v): kind = request.values.get("kind") - if v and v.admin_level > 1: + if v and v.admin_level >= 2: types = ACTIONTYPES else: types = ACTIONTYPES2 @@ -230,7 +226,7 @@ def log(v): if kind not in types: kind = None actions = g.db.query(ModAction) - if not (v and v.admin_level > 1): + if not (v and v.admin_level >= 2): actions = actions.filter(ModAction.kind.notin_(["shadowban","unshadowban","flair_post","edit_post"])) if admin_id: @@ -254,7 +250,6 @@ def log(v): @app.get("/log/") @auth_desired def log_item(v, id): - try: id = int(id) except: abort(404) @@ -264,7 +259,7 @@ def log_item(v, id): admins = [x[0] for x in g.db.query(User.username).filter(User.admin_level > 1).all()] - if v and v.admin_level > 1: types = ACTIONTYPES + if v and v.admin_level >= 2: types = ACTIONTYPES else: types = ACTIONTYPES2 return render_template("log.html", v=v, actions=[action], next_exists=False, page=1, action=action, admins=admins, types=types) @@ -280,22 +275,20 @@ def api(v): @app.get("/media") @auth_desired def contact(v): - return render_template("contact.html", v=v, - hcaptcha=app.config.get("HCAPTCHA_SITEKEY", "")) + return render_template("contact.html", v=v, hcaptcha=HCAPTCHA_SITEKEY) @app.post("/send_admin") @limiter.limit("1/second;2/minute;6/hour;10/day") @auth_desired def submit_contact(v: Optional[User]): - if not v and not validate_captcha(app.config.get("HCAPTCHA_SECRET", ""), - app.config.get("HCAPTCHA_SITEKEY", ""), + if not v and not validate_captcha(HCAPTCHA_SECRET, HCAPTCHA_SITEKEY, request.values.get("h-captcha-response", "")): abort(403, "CAPTCHA provided was not correct. Please try it again") body = request.values.get("message") email = request.values.get("email") if not body: abort(400) - header = "This message has been sent automatically to all admins via [/contact](/contact)\n" + header = "This message has been sent automatically to all admins via [/contact](/contact)\n" if not email: email = "" else: @@ -312,12 +305,14 @@ def submit_contact(v: Optional[User]): html += f'' else: abort(400, "Image files only") - new_comment = Comment(author_id=v.id if v else NOTIFICATIONS_ID, - parent_submission=None, - level=1, - body_html=html, - sentto=MODMAIL_ID, - ) + new_comment = Comment( + author_id=v.id if v else NOTIFICATIONS_ID, + parent_submission=None, + level=1, + body_html=html, + sentto=MODMAIL_ID, + state_mod=StateMod.VISIBLE, + ) g.db.add(new_comment) g.db.flush() new_comment.top_comment_id = new_comment.id @@ -407,14 +402,12 @@ def blocks(v): @app.get("/banned") @auth_desired def banned(v): - users = [x for x in g.db.query(User).filter(User.is_banned > 0, User.unban_utc == 0).all()] return render_template("banned.html", v=v, users=users) @app.get("/formatting") @auth_desired def formatting(v): - return render_template("formatting.html", v=v) @app.get("/service-worker.js") @@ -424,7 +417,6 @@ def serviceworker(): @app.get("/settings/security") @auth_required def settings_security(v): - return render_template("settings_security.html", v=v, mfa_secret=pyotp.random_base32() if not v.mfa_secret else None diff --git a/files/routes/subs.py b/files/routes/subs.py deleted file mode 100644 index d366ea8cb..000000000 --- a/files/routes/subs.py +++ /dev/null @@ -1,387 +0,0 @@ -from files.__main__ import app, limiter, mail -from files.helpers.alerts import * -from files.helpers.wrappers import * -from files.classes import * -from .front import frontlist - - - -@app.post("/exile/post/") -@is_not_permabanned -def exile_post(v, pid): - p = get_post(pid) - sub = p.sub - if not sub: abort(400) - - if not v.mods(sub): abort(403) - - u = p.author - - if u.mods(sub): abort(403) - - if u.admin_level < 2 and not u.exiled_from(sub): - exile = Exile(user_id=u.id, sub=sub, exiler_id=v.id) - g.db.add(exile) - - send_notification(u.id, f"@{v.username} has exiled you from /h/{sub} for [{p.title}]({p.shortlink})") - - g.db.commit() - - return {"message": "User exiled successfully!"} - - - -@app.post("/exile/comment/") -@is_not_permabanned -def exile_comment(v, cid): - c = get_comment(cid) - sub = c.post.sub - if not sub: abort(400) - - if not v.mods(sub): abort(403) - - u = c.author - - if u.mods(sub): abort(403) - - if u.admin_level < 2 and not u.exiled_from(sub): - exile = Exile(user_id=u.id, sub=sub, exiler_id=v.id) - g.db.add(exile) - - send_notification(u.id, f"@{v.username} has exiled you from /h/{sub} for [{c.permalink}]({c.shortlink})") - - g.db.commit() - - return {"message": "User exiled successfully!"} - - -@app.post("/h//unexile/") -@is_not_permabanned -def unexile(v, sub, uid): - u = get_account(uid) - - if not v.mods(sub): abort(403) - - if u.exiled_from(sub): - exile = g.db.query(Exile).filter_by(user_id=u.id, sub=sub).one_or_none() - g.db.delete(exile) - - send_notification(u.id, f"@{v.username} has revoked your exile from /h/{sub}") - - g.db.commit() - - - if request.headers.get("Authorization") or request.headers.get("xhr"): return {"message": "User unexiled successfully!"} - return redirect(f'/h/{sub}/exilees') - -@app.post("/h//block") -@auth_required -def block_sub(v, sub): - sub = g.db.query(Sub).filter_by(name=sub.strip().lower()).one_or_none() - if not sub: abort(404) - sub = sub.name - - if v.mods(sub): abort(409, "You can't block subs you mod!") - - existing = g.db.query(SubBlock).filter_by(user_id=v.id, sub=sub).one_or_none() - - if not existing: - block = SubBlock(user_id=v.id, sub=sub) - g.db.add(block) - g.db.commit() - cache.delete_memoized(frontlist) - - return {"message": "Sub blocked successfully!"} - - -@app.post("/h//unblock") -@auth_required -def unblock_sub(v, sub): - sub = g.db.query(Sub).filter_by(name=sub.strip().lower()).one_or_none() - if not sub: abort(404) - sub = sub.name - - block = g.db.query(SubBlock).filter_by(user_id=v.id, sub=sub).one_or_none() - - if block: - g.db.delete(block) - g.db.commit() - cache.delete_memoized(frontlist) - - return {"message": "Sub unblocked successfully!"} - -@app.get("/h//mods") -@auth_required -def mods(v, sub): - sub = g.db.query(Sub).filter_by(name=sub.strip().lower()).one_or_none() - if not sub: abort(404) - - users = g.db.query(User, Mod).join(Mod, Mod.user_id==User.id).filter_by(sub=sub.name).order_by(Mod.created_utc).all() - - return render_template("sub/mods.html", v=v, sub=sub, users=users) - - -@app.get("/h//exilees") -@auth_required -def exilees(v, sub): - sub = g.db.query(Sub).filter_by(name=sub.strip().lower()).one_or_none() - if not sub: abort(404) - - users = g.db.query(User, Exile).join(Exile, Exile.user_id==User.id).filter_by(sub=sub.name).all() - - return render_template("sub/exilees.html", v=v, sub=sub, users=users) - - -@app.get("/h//blockers") -@auth_required -def blockers(v, sub): - sub = g.db.query(Sub).filter_by(name=sub.strip().lower()).one_or_none() - if not sub: abort(404) - - users = g.db.query(User).join(SubBlock, SubBlock.user_id==User.id).filter_by(sub=sub.name).all() - - return render_template("sub/blockers.html", v=v, sub=sub, users=users) - - - -@app.post("/h//add_mod") -@limiter.limit("1/second;5/day") -@is_not_permabanned -def add_mod(v, sub): - sub = g.db.query(Sub).filter_by(name=sub.strip().lower()).one_or_none() - if not sub: abort(404) - sub = sub.name - - if not v.mods(sub): abort(403) - - user = request.values.get('user') - - if not user: abort(400) - - user = get_user(user) - - existing = g.db.query(Mod).filter_by(user_id=user.id, sub=sub).one_or_none() - - if not existing: - mod = Mod(user_id=user.id, sub=sub) - g.db.add(mod) - - if v.id != user.id: - send_repeatable_notification(user.id, f"@{v.username} has added you as a mod to /h/{sub}") - - g.db.commit() - - return redirect(f'/h/{sub}/mods') - - -@app.post("/h//remove_mod") -@is_not_permabanned -def remove_mod(v, sub): - sub = g.db.query(Sub).filter_by(name=sub.strip().lower()).one_or_none() - if not sub: abort(404) - sub = sub.name - - if not v.mods(sub): abort(403) - - uid = request.values.get('uid') - - if not uid: abort(400) - - try: uid = int(uid) - except: abort(400) - - user = g.db.query(User).filter_by(id=uid).one_or_none() - - if not user: abort(404) - - mod = g.db.query(Mod).filter_by(user_id=user.id, sub=sub).one_or_none() - if not mod: abort(400) - - if not (v.id == user.id or v.mod_date(sub) and v.mod_date(sub) < mod.created_utc): abort(403) - - g.db.delete(mod) - - if v.id != user.id: - send_repeatable_notification(user.id, f"@{v.username} has removed you as a mod from /h/{sub}") - - g.db.commit() - - return redirect(f'/h/{sub}/mods') - -@app.get("/create_sub") -@is_not_permabanned -def create_sub(v): - num = v.subs_created + 1 - for a in v.alts: - num += a.subs_created - cost = num * 100 - - return render_template("sub/create_sub.html", v=v, cost=cost) - - -@app.post("/create_sub") -@is_not_permabanned -def create_sub2(v): - name = request.values.get('name') - if not name: abort(400) - name = name.strip().lower() - - num = v.subs_created + 1 - for a in v.alts: - num += a.subs_created - cost = num * 100 - - if not valid_sub_regex.fullmatch(name): - return render_template("sub/create_sub.html", v=v, cost=cost, error="Sub name not allowed."), 400 - - sub = g.db.query(Sub).filter_by(name=name).one_or_none() - if not sub: - if v.coins < cost: - return render_template("sub/create_sub.html", v=v, cost=cost, error="You don't have enough coins!"), 403 - - v.coins -= cost - - v.subs_created += 1 - g.db.add(v) - - sub = Sub(name=name) - g.db.add(sub) - g.db.flush() - mod = Mod(user_id=v.id, sub=sub.name) - g.db.add(mod) - g.db.commit() - - return redirect(f'/h/{sub.name}') - -@app.post("/kick/") -@is_not_permabanned -def kick(v, pid): - post = get_post(pid) - - if not post.sub: abort(403) - if not v.mods(post.sub): abort(403) - - post.sub = None - g.db.add(post) - g.db.commit() - - cache.delete_memoized(frontlist) - - return {"message": "Post kicked successfully!"} - - -@app.get('/h//settings') -@is_not_permabanned -def sub_settings(v, sub): - sub = g.db.query(Sub).filter_by(name=sub.strip().lower()).one_or_none() - if not sub: abort(404) - - if not v.mods(sub.name): abort(403) - - return render_template('sub/settings.html', v=v, sidebar=sub.sidebar, sub=sub) - - -@app.post('/h//sidebar') -@limiter.limit("1/second;30/minute;200/hour;1000/day") -@is_not_permabanned -def post_sub_sidebar(v, sub): - sub = g.db.query(Sub).filter_by(name=sub.strip().lower()).one_or_none() - if not sub: abort(404) - - if not v.mods(sub.name): abort(403) - - sub.sidebar = request.values.get('sidebar', '').strip()[:500] - sub.sidebar_html = sanitize(sub.sidebar) - if len(sub.sidebar_html) > 1000: return "Sidebar is too big!" - - g.db.add(sub) - - g.db.commit() - - return redirect(f'/h/{sub.name}/settings') - - -@app.post('/h//css') -@limiter.limit("1/second;30/minute;200/hour;1000/day") -@is_not_permabanned -def post_sub_css(v, sub): - sub = g.db.query(Sub).filter_by(name=sub.strip().lower()).one_or_none() - if not sub: abort(404) - - if not v.mods(sub.name): abort(403) - - sub.css = request.values.get('css', '').strip() - g.db.add(sub) - - g.db.commit() - - return redirect(f'/h/{sub.name}/settings') - - -@app.get("/h//css") -def get_sub_css(sub): - sub = g.db.query(Sub).filter_by(name=sub.strip().lower()).one_or_none() - if not sub: abort(404) - resp=make_response(sub.css or "") - resp.headers.add("Content-Type", "text/css") - return resp - - -@app.post("/h//banner") -@limiter.limit("1/second;10/day") -@is_not_permabanned -def sub_banner(v, sub): - if request.headers.get("cf-ipcountry") == "T1": abort(403, "Image uploads are not allowed through TOR.") - - sub = g.db.query(Sub).filter_by(name=sub.lower().strip()).one_or_none() - if not sub: abort(404) - - if not v.mods(sub.name): abort(403) - - file = request.files["banner"] - - name = f'/images/{time.time()}'.replace('.','') + '.webp' - file.save(name) - bannerurl = process_image(name) - - if bannerurl: - if sub.bannerurl and '/images/' in sub.bannerurl: - fpath = '/images/' + sub.bannerurl.split('/images/')[1] - if path.isfile(fpath): os.remove(fpath) - sub.bannerurl = bannerurl - g.db.add(sub) - g.db.commit() - - return redirect(f'/h/{sub.name}/settings') - -@app.post("/h//sidebar_image") -@limiter.limit("1/second;10/day") -@is_not_permabanned -def sub_sidebar(v, sub): - if request.headers.get("cf-ipcountry") == "T1": abort(403, "Image uploads are not allowed through TOR.") - - sub = g.db.query(Sub).filter_by(name=sub.lower().strip()).one_or_none() - if not sub: abort(404) - - if not v.mods(sub.name): abort(403) - - file = request.files["sidebar"] - name = f'/images/{time.time()}'.replace('.','') + '.webp' - file.save(name) - sidebarurl = process_image(name) - - if sidebarurl: - if sub.sidebarurl and '/images/' in sub.sidebarurl: - fpath = '/images/' + sub.sidebarurl.split('/images/')[1] - if path.isfile(fpath): os.remove(fpath) - sub.sidebarurl = sidebarurl - g.db.add(sub) - g.db.commit() - - return redirect(f'/h/{sub.name}/settings') - -@app.get("/holes") -@auth_desired -def subs(v): - subs = g.db.query(Sub, func.count(Submission.sub)).outerjoin(Submission, Sub.name == Submission.sub).group_by(Sub.name).order_by(func.count(Submission.sub).desc()).all() - return render_template('sub/subs.html', v=v, subs=subs) diff --git a/files/routes/users.py b/files/routes/users.py index eb767422a..88d1b3624 100644 --- a/files/routes/users.py +++ b/files/routes/users.py @@ -1,27 +1,34 @@ -import qrcode import io -import time import math +import time +from collections import Counter +from urllib.parse import urlparse -from files.classes.leaderboard import SimpleLeaderboard, BadgeMarseyLeaderboard, UserBlockLeaderboard, LeaderboardMeta +import gevent +import qrcode + +import files.helpers.listing as listings +from files.__main__ import app, cache, limiter +from files.classes.leaderboard import (BadgeMarseyLeaderboard, LeaderboardMeta, + SimpleLeaderboard, UserBlockLeaderboard) from files.classes.views import ViewerRelationship +from files.classes.visstate import StateMod from files.helpers.alerts import * +from files.helpers.assetcache import assetcache_path +from files.helpers.config.const import * +from files.helpers.contentsorting import apply_time_filter, sort_objects from files.helpers.media import process_image from files.helpers.sanitize import * from files.helpers.strings import sql_ilike_clean -from files.helpers.const import * -from files.helpers.assetcache import assetcache_path -from files.helpers.contentsorting import apply_time_filter, sort_objects from files.mail import * -from flask import * -from files.__main__ import app, limiter -from collections import Counter -import gevent +from files.routes.importstar import * + # warning: do not move currently. these have import-time side effects but # until this is refactored to be not completely awful, there's not really # a better option. -from files.helpers.services import * +from files.helpers.services import * + @app.get("/@/upvoters//posts") @admin_level_required(3) @@ -33,7 +40,7 @@ def upvoters_posts(v, username, uid): page = max(1, int(request.values.get("page", 1))) - listing = g.db.query(Submission).join(Vote, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.is_banned == False, Submission.deleted_utc == 0, Vote.vote_type==1, Submission.author_id==id, Vote.user_id==uid).order_by(Submission.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() + listing = g.db.query(Submission).join(Vote, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.state_mod == StateMod.VISIBLE, Submission.state_user_deleted_utc == None, Vote.vote_type==1, Submission.author_id==id, Vote.user_id==uid).order_by(Submission.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() listing = [p.id for p in listing] next_exists = len(listing) > 25 @@ -54,7 +61,7 @@ def upvoters_comments(v, username, uid): page = max(1, int(request.values.get("page", 1))) - listing = g.db.query(Comment).join(CommentVote, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.is_banned == False, Comment.deleted_utc == 0, CommentVote.vote_type==1, Comment.author_id==id, CommentVote.user_id==uid).order_by(Comment.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() + listing = g.db.query(Comment).join(CommentVote, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.state_mod == StateMod.VISIBLE, Comment.state_user_deleted_utc == None, CommentVote.vote_type==1, Comment.author_id==id, CommentVote.user_id==uid).order_by(Comment.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() listing = [c.id for c in listing] next_exists = len(listing) > 25 @@ -75,7 +82,7 @@ def downvoters_posts(v, username, uid): page = max(1, int(request.values.get("page", 1))) - listing = g.db.query(Submission).join(Vote, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.is_banned == False, Submission.deleted_utc == 0, Vote.vote_type==-1, Submission.author_id==id, Vote.user_id==uid).order_by(Submission.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() + listing = g.db.query(Submission).join(Vote, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.state_mod == StateMod.VISIBLE, Submission.state_user_deleted_utc == None, Vote.vote_type==-1, Submission.author_id==id, Vote.user_id==uid).order_by(Submission.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() listing = [p.id for p in listing] next_exists = len(listing) > 25 @@ -96,7 +103,7 @@ def downvoters_comments(v, username, uid): page = max(1, int(request.values.get("page", 1))) - listing = g.db.query(Comment).join(CommentVote, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.is_banned == False, Comment.deleted_utc == 0, CommentVote.vote_type==-1, Comment.author_id==id, CommentVote.user_id==uid).order_by(Comment.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() + listing = g.db.query(Comment).join(CommentVote, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.state_mod == StateMod.VISIBLE, Comment.state_user_deleted_utc == None, CommentVote.vote_type==-1, Comment.author_id==id, CommentVote.user_id==uid).order_by(Comment.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() listing = [c.id for c in listing] next_exists = len(listing) > 25 @@ -116,7 +123,7 @@ def upvoting_posts(v, username, uid): page = max(1, int(request.values.get("page", 1))) - listing = g.db.query(Submission).join(Vote, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.is_banned == False, Submission.deleted_utc == 0, Vote.vote_type==1, Vote.user_id==id, Submission.author_id==uid).order_by(Submission.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() + listing = g.db.query(Submission).join(Vote, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.state_mod == StateMod.VISIBLE, Submission.state_user_deleted_utc == None, Vote.vote_type==1, Vote.user_id==id, Submission.author_id==uid).order_by(Submission.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() listing = [p.id for p in listing] next_exists = len(listing) > 25 @@ -137,7 +144,7 @@ def upvoting_comments(v, username, uid): page = max(1, int(request.values.get("page", 1))) - listing = g.db.query(Comment).join(CommentVote, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.is_banned == False, Comment.deleted_utc == 0, CommentVote.vote_type==1, CommentVote.user_id==id, Comment.author_id==uid).order_by(Comment.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() + listing = g.db.query(Comment).join(CommentVote, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.state_mod == StateMod.VISIBLE, Comment.state_user_deleted_utc == None, CommentVote.vote_type==1, CommentVote.user_id==id, Comment.author_id==uid).order_by(Comment.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() listing = [c.id for c in listing] next_exists = len(listing) > 25 @@ -158,7 +165,7 @@ def downvoting_posts(v, username, uid): page = max(1, int(request.values.get("page", 1))) - listing = g.db.query(Submission).join(Vote, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.is_banned == False, Submission.deleted_utc == 0, Vote.vote_type==-1, Vote.user_id==id, Submission.author_id==uid).order_by(Submission.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() + listing = g.db.query(Submission).join(Vote, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.state_mod == StateMod.VISIBLE, Submission.state_user_deleted_utc == None, Vote.vote_type==-1, Vote.user_id==id, Submission.author_id==uid).order_by(Submission.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() listing = [p.id for p in listing] next_exists = len(listing) > 25 @@ -179,7 +186,7 @@ def downvoting_comments(v, username, uid): page = max(1, int(request.values.get("page", 1))) - listing = g.db.query(Comment).join(CommentVote, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.is_banned == False, Comment.deleted_utc == 0, CommentVote.vote_type==-1, CommentVote.user_id==id, Comment.author_id==uid).order_by(Comment.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() + listing = g.db.query(Comment).join(CommentVote, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.state_mod == StateMod.VISIBLE, Comment.state_user_deleted_utc == None, CommentVote.vote_type==-1, CommentVote.user_id==id, Comment.author_id==uid).order_by(Comment.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() listing = [c.id for c in listing] next_exists = len(listing) > 25 @@ -194,9 +201,9 @@ def downvoting_comments(v, username, uid): def upvoters(v, username): id = get_user(username).id - votes = g.db.query(Vote.user_id, func.count(Vote.user_id)).join(Submission, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.is_banned == False, Submission.deleted_utc == 0, Vote.vote_type==1, Submission.author_id==id).group_by(Vote.user_id).order_by(func.count(Vote.user_id).desc()).all() + votes = g.db.query(Vote.user_id, func.count(Vote.user_id)).join(Submission, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.state_mod == StateMod.VISIBLE, Submission.state_user_deleted_utc == None, Vote.vote_type==1, Submission.author_id==id).group_by(Vote.user_id).order_by(func.count(Vote.user_id).desc()).all() - votes2 = g.db.query(CommentVote.user_id, func.count(CommentVote.user_id)).join(Comment, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.is_banned == False, Comment.deleted_utc == 0, CommentVote.vote_type==1, Comment.author_id==id).group_by(CommentVote.user_id).order_by(func.count(CommentVote.user_id).desc()).all() + votes2 = g.db.query(CommentVote.user_id, func.count(CommentVote.user_id)).join(Comment, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.state_mod == StateMod.VISIBLE, Comment.state_user_deleted_utc == None, CommentVote.vote_type==1, Comment.author_id==id).group_by(CommentVote.user_id).order_by(func.count(CommentVote.user_id).desc()).all() votes = Counter(dict(votes)) + Counter(dict(votes2)) @@ -220,9 +227,9 @@ def upvoters(v, username): def downvoters(v, username): id = get_user(username).id - votes = g.db.query(Vote.user_id, func.count(Vote.user_id)).join(Submission, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.is_banned == False, Submission.deleted_utc == 0, Vote.vote_type==-1, Submission.author_id==id).group_by(Vote.user_id).order_by(func.count(Vote.user_id).desc()).all() + votes = g.db.query(Vote.user_id, func.count(Vote.user_id)).join(Submission, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.state_mod == StateMod.VISIBLE, Submission.state_user_deleted_utc == None, Vote.vote_type==-1, Submission.author_id==id).group_by(Vote.user_id).order_by(func.count(Vote.user_id).desc()).all() - votes2 = g.db.query(CommentVote.user_id, func.count(CommentVote.user_id)).join(Comment, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.is_banned == False, Comment.deleted_utc == 0, CommentVote.vote_type==-1, Comment.author_id==id).group_by(CommentVote.user_id).order_by(func.count(CommentVote.user_id).desc()).all() + votes2 = g.db.query(CommentVote.user_id, func.count(CommentVote.user_id)).join(Comment, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.state_mod == StateMod.VISIBLE, Comment.state_user_deleted_utc == None, CommentVote.vote_type==-1, Comment.author_id==id).group_by(CommentVote.user_id).order_by(func.count(CommentVote.user_id).desc()).all() votes = Counter(dict(votes)) + Counter(dict(votes2)) @@ -244,9 +251,9 @@ def downvoters(v, username): def upvoting(v, username): id = get_user(username).id - votes = g.db.query(Submission.author_id, func.count(Submission.author_id)).join(Vote, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.is_banned == False, Submission.deleted_utc == 0, Vote.vote_type==1, Vote.user_id==id).group_by(Submission.author_id).order_by(func.count(Submission.author_id).desc()).all() + votes = g.db.query(Submission.author_id, func.count(Submission.author_id)).join(Vote, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.state_mod == StateMod.VISIBLE, Submission.state_user_deleted_utc == None, Vote.vote_type==1, Vote.user_id==id).group_by(Submission.author_id).order_by(func.count(Submission.author_id).desc()).all() - votes2 = g.db.query(Comment.author_id, func.count(Comment.author_id)).join(CommentVote, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.is_banned == False, Comment.deleted_utc == 0, CommentVote.vote_type==1, CommentVote.user_id==id).group_by(Comment.author_id).order_by(func.count(Comment.author_id).desc()).all() + votes2 = g.db.query(Comment.author_id, func.count(Comment.author_id)).join(CommentVote, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.state_mod == StateMod.VISIBLE, Comment.state_user_deleted_utc == None, CommentVote.vote_type==1, CommentVote.user_id==id).group_by(Comment.author_id).order_by(func.count(Comment.author_id).desc()).all() votes = Counter(dict(votes)) + Counter(dict(votes2)) @@ -268,9 +275,9 @@ def upvoting(v, username): def downvoting(v, username): id = get_user(username).id - votes = g.db.query(Submission.author_id, func.count(Submission.author_id)).join(Vote, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.is_banned == False, Submission.deleted_utc == 0, Vote.vote_type==-1, Vote.user_id==id).group_by(Submission.author_id).order_by(func.count(Submission.author_id).desc()).all() + votes = g.db.query(Submission.author_id, func.count(Submission.author_id)).join(Vote, Vote.submission_id==Submission.id).filter(Submission.ghost == False, Submission.state_mod == StateMod.VISIBLE, Submission.state_user_deleted_utc == None, Vote.vote_type==-1, Vote.user_id==id).group_by(Submission.author_id).order_by(func.count(Submission.author_id).desc()).all() - votes2 = g.db.query(Comment.author_id, func.count(Comment.author_id)).join(CommentVote, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.is_banned == False, Comment.deleted_utc == 0, CommentVote.vote_type==-1, CommentVote.user_id==id).group_by(Comment.author_id).order_by(func.count(Comment.author_id).desc()).all() + votes2 = g.db.query(Comment.author_id, func.count(Comment.author_id)).join(CommentVote, CommentVote.comment_id==Comment.id).filter(Comment.ghost == False, Comment.state_mod == StateMod.VISIBLE, Comment.state_user_deleted_utc == None, CommentVote.vote_type==-1, CommentVote.user_id==id).group_by(Comment.author_id).order_by(func.count(Comment.author_id).desc()).all() votes = Counter(dict(votes)) + Counter(dict(votes2)) @@ -376,7 +383,9 @@ def leaderboard(v:User): # note: lb_downvotes_received and lb_upvotes_given are global variables # that are populated by leaderboard_thread() in files.helpers.services - leaderboards = [coins, coins_spent, truescore, subscribers, posts, comments, received_awards, badges, blocks, lb_downvotes_received, lb_upvotes_given] + leaderboards = [coins, coins_spent, truescore, subscribers, posts, comments, received_awards, badges, blocks] + if lb_downvotes_received is not None and lb_upvotes_given is not None: + leaderboards.extend([lb_downvotes_received, lb_upvotes_given]) return render_template("leaderboard.html", v=v, leaderboards=leaderboards) @@ -446,7 +455,8 @@ def message2(v, username): parent_submission=None, level=1, sentto=user.id, - body_html=body_html + body_html=body_html, + state_mod=StateMod.VISIBLE, ) g.db.add(c) g.db.flush() @@ -474,7 +484,6 @@ def message2(v, username): @limiter.limit("1/second;6/minute;50/hour;200/day") @auth_required def messagereply(v): - message = request.values.get("body", "").strip()[:MESSAGE_BODY_LENGTH_MAXIMUM].strip() if not message and not request.files.get("file"): abort(400, "Message is empty!") @@ -505,11 +514,12 @@ def messagereply(v): level=parent.level + 1, sentto=user_id, body_html=body_html, + state_mod=StateMod.VISIBLE, ) g.db.add(c) g.db.flush() - if user_id and user_id != v.id and user_id != 2: + if user_id and user_id != v.id and user_id != MODMAIL_ID: notif = g.db.query(Notification).filter_by(comment_id=c.id, user_id=user_id).one_or_none() if not notif: notif = Notification(comment_id=c.id, user_id=user_id) @@ -530,7 +540,7 @@ def messagereply(v): 'notification': { 'title': f'New message from @{v.username}', 'body': notifbody, - 'deep_link': f'{SITE_FULL}/notifications?messages=true', + 'deep_link': f'{SITE_FULL}/notifications/messages', 'icon': SITE_FULL + assetcache_path(f'images/{SITE_ID}/icon.webp'), } }, @@ -540,7 +550,7 @@ def messagereply(v): 'body': notifbody, }, 'data': { - 'url': '/notifications?messages=true', + 'url': '/notifications/messages', } } }, @@ -586,7 +596,6 @@ def mfa_qr(secret, v): @app.get("/is_available/") def api_is_available(name): - name=name.strip() if len(name)<3 or len(name)>25: @@ -610,7 +619,7 @@ def api_is_available(name): def user_id(id:int): user = get_account(id) return redirect(user.url) - + @app.get("/u/") def redditor_moment_redirect(username:str): return redirect(f"/@{username}") @@ -637,13 +646,12 @@ def visitors(v): return render_template("viewers.html", v=v, viewers=viewers) -@app.get("/@") +@app.get("/@/posts") @auth_desired def u_username(username, v=None): u = get_user(username, v=v, include_blocks=True) - if username != u.username: - return redirect(SITE_FULL + request.full_path.replace(username, u.username)[:-1]) + if username != u.username: return redirect(f'/@{u.username}/posts') if u.reserved: if request.headers.get("Authorization") or request.headers.get("xhr"): abort(403, f"That username is reserved for: {u.reserved}") @@ -673,7 +681,7 @@ def u_username(username, v=None): try: page = max(int(request.values.get("page", 1)), 1) except: page = 1 - ids = u.userpagelisting(site=SITE, v=v, page=page, sort=sort, t=t) + ids = listings.userpagelisting(u, v=v, page=page, sort=sort, t=t) next_exists = (len(ids) > 25) ids = ids[:25] @@ -689,7 +697,7 @@ def u_username(username, v=None): if u.unban_utc: if request.headers.get("Authorization"): {"data": [x.json for x in listing]} - return render_template("userpage.html", + return render_template("userpage_submissions.html", unban=u.unban_string, u=u, v=v, @@ -703,7 +711,7 @@ def u_username(username, v=None): if request.headers.get("Authorization"): return {"data": [x.json for x in listing]} - return render_template("userpage.html", + return render_template("userpage_submissions.html", u=u, v=v, listing=listing, @@ -714,12 +722,13 @@ def u_username(username, v=None): is_following=(v and u.has_follower(v))) -@app.get("/@/comments") +@app.get("/@/") @auth_desired def u_username_comments(username, v=None): user = get_user(username, v=v, include_blocks=True) - if username != user.username: return redirect(f'/@{user.username}/comments') + if username != user.username: + return redirect(SITE_FULL + request.full_path.replace(username, user.username)[:-1]) u = user if u.reserved: @@ -748,10 +757,9 @@ def u_username_comments(username, v=None): if not v or (v.id != u.id and v.admin_level < 2): comments = comments.filter( - Comment.deleted_utc == 0, - Comment.is_banned == False, + Comment.state_user_deleted_utc == None, + Comment.state_mod == StateMod.VISIBLE, Comment.ghost == False, - (Comment.filter_state != 'filtered') & (Comment.filter_state != 'removed') ) comments = apply_time_filter(comments, t, Comment) @@ -795,11 +803,11 @@ def u_user_id_info(id, v=None): return user.json + @app.post("/follow/") @limiter.limit("1/second;30/minute;200/hour;1000/day") @auth_required def follow_user(username, v): - target = get_user(username) if target.id==v.id: abort(400, "You can't follow yourself!") @@ -862,19 +870,11 @@ def remove_follow(username, v): return {"message": "Follower removed!"} -from urllib.parse import urlparse -import re - -@app.get("/pp/") -@app.get("/uid//pic") -@app.get("/uid//pic/profile") +@app.get("/pp/") +@app.get("/uid//pic") +@app.get("/uid//pic/profile") @limiter.exempt -def user_profile_uid(v, id): - try: id = int(id) - except: - try: id = int(id, 36) - except: abort(404) - +def user_profile_uid(id:int): name = f"/pp/{id}" path = cache.get(name) tout = 5 * 60 # 5 min @@ -900,7 +900,6 @@ def user_profile_uid(v, id): @app.get("/@/pic") @limiter.exempt def user_profile_name(username:str): - name = f"/@{username}/pic" path = cache.get(name) tout = 5 * 60 # 5 min @@ -926,7 +925,6 @@ def user_profile_name(username:str): @app.get("/@/saved/posts") @auth_required def saved_posts(v, username): - page=int(request.values.get("page",1)) ids=v.saved_idlist(page=page) @@ -938,19 +936,19 @@ def saved_posts(v, username): listing = get_posts(ids, v=v, eager=True) if request.headers.get("Authorization"): return {"data": [x.json for x in listing]} - return render_template("userpage.html", - u=v, - v=v, - listing=listing, - page=page, - next_exists=next_exists, - ) + return render_template( + "userpage_submissions.html", + u=v, + v=v, + listing=listing, + page=page, + next_exists=next_exists, + ) @app.get("/@/saved/comments") @auth_required def saved_comments(v, username): - page=int(request.values.get("page",1)) ids=v.saved_comment_idlist(page=page) @@ -963,13 +961,15 @@ def saved_comments(v, username): if request.headers.get("Authorization"): return {"data": [x.json for x in listing]} - return render_template("userpage_comments.html", - u=v, - v=v, - listing=listing, - page=page, - next_exists=next_exists, - standalone=True) + return render_template( + "userpage_comments.html", + u=v, + v=v, + listing=listing, + page=page, + next_exists=next_exists, + standalone=True + ) @app.post("/fp/") diff --git a/files/routes/volunteer.py b/files/routes/volunteer.py index b78973a91..1ca651d83 100644 --- a/files/routes/volunteer.py +++ b/files/routes/volunteer.py @@ -1,18 +1,16 @@ from datetime import datetime, timedelta +from typing import Optional + +import sqlalchemy +from flask import abort, g, render_template, request + +import files.helpers.jinja2 +import files.routes.volunteer_janitor from files.__main__ import app from files.classes.user import User -import files.helpers.jinja2 from files.helpers.wrappers import auth_required from files.routes.volunteer_common import VolunteerDuty -import files.routes.volunteer_janitor -from flask import abort, render_template, g, request -from os import environ -import sqlalchemy -from typing import Optional -import pprint - - @files.helpers.jinja2.template_function diff --git a/files/routes/volunteer_janitor.py b/files/routes/volunteer_janitor.py index 707610d4e..2c8bf171c 100644 --- a/files/routes/volunteer_janitor.py +++ b/files/routes/volunteer_janitor.py @@ -4,7 +4,9 @@ from files.__main__ import app from files.classes.comment import Comment from files.classes.flags import CommentFlag from files.classes.user import User +from files.classes.visstate import StateReport from files.classes.volunteer_janitor import VolunteerJanitorRecord, VolunteerJanitorResult +from files.helpers.volunteer_janitor import update_comment_badness from files.routes.volunteer_common import VolunteerDuty from flask import g import pprint @@ -42,9 +44,8 @@ def get_duty(u: User) -> Optional[VolunteerDutyJanitor]: # find reported not-deleted comments not made by the current user reported_comments = g.db.query(Comment) \ - .where(Comment.filter_state == 'reported') \ - .where(Comment.deleted_utc == 0) \ - .where(Comment.is_approved == None) \ + .where(Comment.state_report == StateReport.REPORTED) \ + .where(Comment.state_user_deleted_utc == None) \ .where(Comment.author_id != u.id) \ .with_entities(Comment.id) @@ -93,4 +94,7 @@ def submitted(v: User, key: str, val: str) -> None: record.recorded_utc = sqlalchemy.func.now() record.result = VolunteerJanitorResult(int(val)) g.db.add(record) + + update_comment_badness(g.db, key) + g.db.commit() diff --git a/files/routes/votes.py b/files/routes/votes.py index d434a21ef..ffcdac96e 100644 --- a/files/routes/votes.py +++ b/files/routes/votes.py @@ -1,10 +1,13 @@ -from files.helpers.wrappers import * -from files.helpers.get import * -from files.helpers.const import * -from files.classes import * -from flask import * -from files.__main__ import app, limiter, cache -from os import environ +from files.__main__ import app, limiter +from files.classes.comment import Comment +from files.classes.submission import Submission +from files.classes.votes import CommentVote, Vote +from files.helpers.config.const import OWNER_ID +from files.helpers.config.environment import ENABLE_DOWNVOTES +from files.helpers.get import get_comment, get_post +from files.helpers.wrappers import admin_level_required, is_not_permabanned +from files.routes.importstar import * + @app.get("/votes") @limiter.exempt @@ -14,8 +17,8 @@ def admin_vote_info_get(v): if not link: return render_template("votes.html", v=v) try: - if "t2_" in link: thing = get_post(int(link.split("t2_")[1]), v=v) - elif "t3_" in link: thing = get_comment(int(link.split("t3_")[1]), v=v) + if "post_" in link: thing = get_post(link.split("post_")[1], v=v) + elif "comment_" in link: thing = get_comment(link.split("comment_")[1], v=v) else: abort(400) except: abort(400) @@ -53,17 +56,17 @@ def admin_vote_info_get(v): @limiter.limit("5/second;60/minute;600/hour;1000/day") @is_not_permabanned def api_vote_post(post_id, new, v): - - # make sure we're allowed in (is this really necessary? I'm not sure) + # make sure this account is not a bot if request.headers.get("Authorization"): abort(403) # make sure new is valid - if new == "-1" and environ.get('DISABLE_DOWNVOTES') == '1': abort(403, "forbidden.") + if new == "-1" and not ENABLE_DOWNVOTES: abort(403) if new not in ["-1", "0", "1"]: abort(400) new = int(new) # get the post - post = get_post(post_id) + post = get_post(post_id, v=v) + if getattr(post, 'is_blocking', False): abort(403, "Can't vote on things from users you've blocked") # get the old vote, if we have one vote = g.db.query(Vote).filter_by(user_id=v.id, submission_id=post.id).one_or_none() @@ -121,17 +124,17 @@ def api_vote_post(post_id, new, v): @limiter.limit("5/second;60/minute;600/hour;1000/day") @is_not_permabanned def api_vote_comment(comment_id, new, v): - - # make sure we're allowed in (is this really necessary? I'm not sure) + # make sure this account is not a bot if request.headers.get("Authorization"): abort(403) # make sure new is valid - if new == "-1" and environ.get('DISABLE_DOWNVOTES') == '1': abort(403, "forbidden.") + if new == "-1" and not ENABLE_DOWNVOTES: abort(403) if new not in ["-1", "0", "1"]: abort(400) new = int(new) # get the comment - comment = get_comment(comment_id) + comment = get_comment(comment_id, v=v) + if getattr(comment, 'is_blocking', False): abort(403, "Can't vote on things from users you've blocked") # get the old vote, if we have one vote = g.db.query(CommentVote).filter_by(user_id=v.id, comment_id=comment.id).one_or_none() diff --git a/files/templates/admin/admin_home.html b/files/templates/admin/admin_home.html index 46957bcd0..9fd2dd37f 100644 --- a/files/templates/admin/admin_home.html +++ b/files/templates/admin/admin_home.html @@ -6,9 +6,7 @@ {% endblock %} {% block content %} -


-

-

Admin Tools

+

Admin Tools

-

Content

+

Content

-

Filtering

+

Filtering

-

Users

+

Users

-

Safety

+

Safety

-

Grant

+

Grant

-

API Access Control

+

API Access Control

-

Statistics

+

Statistics

+
+

Scheduler

+
    + {%- if v.admin_level >= PERMS['SCHEDULER'] -%}
  • Tasks
  • {%- endif -%} + {%- if v.admin_level >= PERMS['SCHEDULER_POSTS'] -%}
  • Scheduled Posts
  • {%- endif -%} +
+
+ {% if v.admin_level >= 3 %} -

-	
- - -
- -
- - -
- -
- - -
- -
- - -
+
+

Performance

+ +
+{% endif %} +{% if v.admin_level >= 3 %} +

Site Settings

+ {%- macro site_setting_bool(name, label) -%} +
+ + +
+ {%- endmacro -%} + {%- macro site_setting_int(name, label) -%} +
+ + +
+ {%- endmacro -%} + {{site_setting_bool('signups', 'Signups')}} + {{site_setting_bool('bots', 'Bots')}} + {{site_setting_bool('FilterNewPosts', 'Filter New Posts')}} + {{site_setting_bool('Read-only mode', 'Read-Only Mode')}}
-

Comment Filtering

-
- - -
-
- - -
-
- - -
+

Comment Filtering

+ {{site_setting_int('FilterCommentsMinComments', 'Minimum Comments')}} + {{site_setting_int('FilterCommentsMinKarma', 'Minimum Karma')}} + {{site_setting_int('FilterCommentsMinAgeDays', 'Minimum Account Age (Days)')}} + +

Private Mode Requirements

+ {{site_setting_int('min_comments_private_profile', 'Minimum Comments')}} + {{site_setting_int('min_truescore_private_profile', 'Minimum Karma')}} + {{site_setting_int('min_age_days_private_profile', 'Minimum Account Age (Days)')}} diff --git a/files/templates/admin/alt_votes.html b/files/templates/admin/alt_votes.html index e9d5841a9..ee9c4aaab 100644 --- a/files/templates/admin/alt_votes.html +++ b/files/templates/admin/alt_votes.html @@ -74,7 +74,7 @@ Link Accounts
- + {{forms.formkey(v)}} diff --git a/files/templates/admin/app.html b/files/templates/admin/app.html index b0cf7d215..a60befa73 100644 --- a/files/templates/admin/app.html +++ b/files/templates/admin/app.html @@ -19,7 +19,7 @@
- + {{forms.formkey(v)}} @@ -56,12 +56,12 @@
-