commit 1fc69b9ce99d5a89d30f5b8ec5ed6936fcf091a3 Author: Aevann1 Date: Wed Jul 21 03:12:26 2021 +0200 sneed diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..8dc698e6b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.css linguist-detectable=false +*.js linguist-detectable=true +*.html linguist-detectable=false +*.py linguist-detectable=true diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..6747a83c0 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: Aevann1 +patreon: Aevann +custom: ["https://rdrama.gumroad.com/l/tfcvri"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..3cda725bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,115 @@ +ruqquscache/ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +#custom +*.DS_Store +#node_modules/ +package-lock.json +*.css.map +*.map +local.txt +*.scssc +.idea/* diff --git a/.well-known/assetlinks.json b/.well-known/assetlinks.json new file mode 100644 index 000000000..40a7e7e7a --- /dev/null +++ b/.well-known/assetlinks.json @@ -0,0 +1,9 @@ +[{ + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "net.rdrama", + "sha256_cert_fingerprints": + ["ED:D9:72:9B:CD:61:52:18:AB:95:D0:21:BD:7F:04:6F:89:04:AC:B9:73:A2:2E:90:A5:2B:0C:13:F8:4A:EC:18"] + } +}] \ No newline at end of file diff --git a/.well-known/brave-rewards-verification.txt b/.well-known/brave-rewards-verification.txt new file mode 100644 index 000000000..1ba71b66d --- /dev/null +++ b/.well-known/brave-rewards-verification.txt @@ -0,0 +1,4 @@ +This is a Brave Rewards publisher verification file. + +Domain: rdrama.net +Token: 0774158a4aec1e891263f84cf37919c0aa19309b9fba4ad9c4a0aae8946f5d0d diff --git a/.well-known/service-worker.js b/.well-known/service-worker.js new file mode 100644 index 000000000..f2918a743 --- /dev/null +++ b/.well-known/service-worker.js @@ -0,0 +1 @@ +importScripts("https://js.pusher.com/beams/service-worker.js"); \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..56f7fdf69 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM ubuntu:20.04 + +COPY supervisord.conf /etc/supervisord.conf + +RUN apt update \ + && apt install -y python3.8 python3-pip supervisor + +RUN mkdir -p /opt/Drama/service + +COPY requirements.txt /opt/Drama/service/requirements.txt + +RUN cd /opt/Drama/service \ + && pip3 install -r requirements.txt + +EXPOSE 80/tcp + +CMD [ "/usr/bin/supervisord", "-c", "/etc/supervisord.conf" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..d0a1fa148 --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/appspec.yml b/appspec.yml new file mode 100644 index 000000000..e31c52105 --- /dev/null +++ b/appspec.yml @@ -0,0 +1,13 @@ +version: 0.0 +os: linux +files: + - source: / + destination: ruqqus +permissions: + - object: ruqqus/* + mode: 4755 +hooks: + AfterInstall: + - location: scripts/install_pip + ApplicationStart: + - location: scripts/start_ruqqus \ No newline at end of file diff --git a/buildspec.yml b/buildspec.yml new file mode 100644 index 000000000..db85248f5 --- /dev/null +++ b/buildspec.yml @@ -0,0 +1,8 @@ +version: 0.2 +phases: + install: + runtime-versions: + python: 3.7 +artifacts: + files: + - '**/*' \ No newline at end of file diff --git a/compilecss.py b/compilecss.py new file mode 100644 index 000000000..0249fb3f6 --- /dev/null +++ b/compilecss.py @@ -0,0 +1,7 @@ +for theme in ['dark', 'light', 'coffee', 'tron', '4chan']: + with open(f"D:/#D/ruqqus/assets/style/{theme}_ff66ac.css", encoding='utf-8') as t: + text = t.read() + for color in ['805ad5','62ca56','38a169','80ffff','2a96f3','62ca56','eb4963','ff0000','f39731','30409f','3e98a7','e4432d','7b9ae4','ec72de','7f8fa6', 'f8db58']: + newtext = text.replace("ff66ac", color).replace("ff4097", color).replace("ff1a83", color).replace("ff3390", color) + with open(f"D:/#D/ruqqus/assets/style/{theme}_{color}.css", encoding='utf-8', mode='w') as nt: + nt.write(newtext) \ No newline at end of file diff --git a/dependabot.yml b/dependabot.yml new file mode 100644 index 000000000..f1747992c --- /dev/null +++ b/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..85a71ce94 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +version: '2.3' + +services: + ruqqus: + build: + context: . + volumes: + - "./:/opt/Drama/service" + environment: + - PYTHONPATH="opt/Drama/service" + - REDIS_URL=redis://127.0.0.1 + - DATABASE_URL=postgresql://postgres@127.0.0.1:5432/postgres + - DATABASE_CONNECTION_POOL_URL=postgresql://postgres@127.0.0.1:5432/postgres + - MASTER_KEY=${MASTER_KEY:-KTVciAUQFpFh2WdJ/oiHJlxl6FvzRZp8kYzAAv3l2OA=} + - SESSION_COOKIE_SECURE=false + - FLASK_DEBUG=1 + - FLASK_ENV=development + - domain=localhost + - SITE_NAME=Drama + - CLOUDFLARE_ZONE=vcxvdfgfc6r554etrgd + - CLOUDFLARE_KEY=vcxvdfgfc6r554etrgd + - TENOR_KEY=vcxvdfgfc6r554etrgd + - S3_BUCKET_NAME=i.ruqqus.ga + - AWS_ACCESS_KEY_ID=vcxvdfgfc6r554etrgd + - AWS_SECRET_ACCESS_KEY=vcxvdfgfc6r554etrgd + - MAILGUN_KEY=vcxvdfgfc6r554etrgd + - admin_email=drama@rdrama.net + - FORCE_HTTPS=1 + - DISCORD_SERVER_ID=vcxvdfgfc6r554etrgd + - DISCORD_CLIENT_ID=vcxvdfgfc6r554etrgd + - DISCORD_CLIENT_SECRET=vcxvdfgfc6r554etrgd + - DISCORD_BOT_TOKEN=vcxvdfgfc6r554etrgd + - imgurkey=vcxvdfgfc6r554etrgd + - FACEBOOK_TOKEN=vcxvdfgfc6r554etrgd + - HCAPTCHA_SITEKEY=vcxvdfgfc6r554etrgd + - HCAPTCHA_SECRET=vcxvdfgfc6r554etrgd + - youtubekey=vcxvdfgfc6r554etrgd + - PUSHER_KEY=vcxvdfgfc6r554etrgd + links: + - "redis" + - "postgres" + ports: + - "80:80" + depends_on: + - redis + - postgres + + redis: + image: redis + volumes: + - ./redis.conf:/opt/Drama/redis.conf + ports: + - "6379:6379" + + postgres: + image: postgres:12.3 + volumes: + - "./schema.sql:/docker-entrypoint-initdb.d/00-schema.sql" + environment: + - POSTGRES_HOST_AUTH_METHOD=trust + ports: + - "5432:5432" diff --git a/push.sh b/push.sh new file mode 100644 index 000000000..0a93042bc --- /dev/null +++ b/push.sh @@ -0,0 +1,17 @@ +git pull +git add . +git commit -m "sneed" +git push + +sass ./ruqqus/assets/style/dark.scss D:/#D/ruqqus/assets/style/dark_ff66ac.css +sass ./ruqqus/assets/style/light.scss D:/#D/ruqqus/assets/style/light_ff66ac.css +sass ./ruqqus/assets/style/coffee.scss D:/#D/ruqqus/assets/style/coffee_ff66ac.css +sass ./ruqqus/assets/style/tron.scss D:/#D/ruqqus/assets/style/tron_ff66ac.css +sass ./ruqqus/assets/style/4chan.scss D:/#D/ruqqus/assets/style/4chan_ff66ac.css +python ./compilecss.py +git add . +git commit -m "css" +git push + +cd D:\1 +git pull \ No newline at end of file diff --git a/pushforce.sh b/pushforce.sh new file mode 100644 index 000000000..26412e5da --- /dev/null +++ b/pushforce.sh @@ -0,0 +1,3 @@ +git add . +git commit -m "force push" +git push --force \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 000000000..684daa217 --- /dev/null +++ b/readme.md @@ -0,0 +1,78 @@ +# How to Install Drama Locally + + +## Overview + +Installing Drama locally is the fastest way to get the software up and running and start tinkering under the hood. + +--- + +## Windows + +### Install Docker + +Install Docker on your machine. + +[Docker installation for Windows](https://docs.docker.com/docker-for-windows/install/) + +### Download Drama + +Download the latest release of Drama from GitHub. + +[Drama Latest Release - GitHub](https://github.com/Drama/Drama/releases) + +### PowerShell + +Press shift+right click inside the code folder and run PowerShell. Then in PowerShell, run the following command: + +``` +docker-compose up +``` + +That's it! Visit `localhost` in your browser. + +--- + +## Linux + +### Install Docker + +Install Docker on your machine. + +[Docker installation for Ubuntu](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-18-04) + +### Install Docker-compose + +Install Docker-compose on your machine. + +[Docker-compose installation for Ubuntu](https://www.digitalocean.com/community/tutorials/how-to-install-docker-compose-on-ubuntu-18-04) + +### Download Drama + +Navigate to `/opt` + +``` +cd /opt +``` + +then clone Drama into your machine. + +``` +git clone https://github.com/Drama/Drama/ +``` + +### Run Drama + +Navigate to `/opt/Drama` + +``` +cd /opt/Drama +``` + +then run this command + +``` +docker-compose up +``` + +That's it! Visit `localhost` in your browser. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..706d408ed --- /dev/null +++ b/requirements.txt @@ -0,0 +1,69 @@ +async-timeout +attrs +beautifulsoup4 +bleach +boto3 +botocore +certifi +chardet +click +cycler +eventlet +Flask +Flask-Caching==1.9.0 +Flask-Compress==1.9.0 +Flask-Limiter==1.1.0 +Flask-Markdown==0.3 +flask-socketio==5.1.0 +gevent +gevent-websocket +greenlet +gunicorn +idna +idna-ssl +ImageHash +importlib-metadata +itsdangerous +Jinja2 +jmespath +kiwisolver +libsass +limits +lockfile +Markdown +MarkupSafe +matplotlib +mistletoe +multidict +newrelic +numpy +piexif +Pillow +psutil +psycogreen +psycopg2-binary +py3socket +pyotp +pyparsing +python-daemon +python-dateutil +PyWavelets +qrcode +redis +requests +s3transfer +scipy +six +soupsieve +SQLAlchemy==1.3.19 +typing-extensions +urllib3 +webencodings +websockets +Werkzeug +yattag +yarl +zipp +pusher_push_notifications +youtube-dl +testresources \ No newline at end of file diff --git a/ruqqus/__main__.py b/ruqqus/__main__.py new file mode 100644 index 000000000..c5a30d082 --- /dev/null +++ b/ruqqus/__main__.py @@ -0,0 +1,352 @@ +import gevent.monkey +gevent.monkey.patch_all() + +import os +from os import environ +import secrets +from flask import * +from flask_caching import Cache +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_compress import Compress +from flask_socketio import SocketIO +from time import sleep +from collections import deque +import psycopg2 + +from flaskext.markdown import Markdown +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.exc import OperationalError, StatementError, InternalError +from sqlalchemy.orm import Session, sessionmaker, scoped_session, Query as _Query +from sqlalchemy import * +from sqlalchemy.pool import QueuePool +import threading +import requests +import random +import redis +import gevent +import sys + +from redis import BlockingConnectionPool, ConnectionPool + +from werkzeug.middleware.proxy_fix import ProxyFix + + +_version = "2.37.4" + +app = Flask(__name__, + template_folder='./templates', + static_folder='./static' + ) +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=3) +app.url_map.strict_slashes = False + +app.config["SITE_NAME"]=environ.get("SITE_NAME", "Ruqqus").strip() + +app.config["SITE_COLOR"]=environ.get("SITE_COLOR", "805ad5").strip() + +app.config["RUQQUSPATH"]=environ.get("RUQQUSPATH", os.path.dirname(os.path.realpath(__file__))) + +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['DATABASE_URL'] = environ.get( + "DATABASE_CONNECTION_POOL_URL", + environ.get("DATABASE_URL")) + +app.config['SQLALCHEMY_READ_URIS'] = [ + environ.get("DATABASE_CONNECTION_READ_01_URL"), + environ.get("DATABASE_CONNECTION_READ_02_URL"), + environ.get("DATABASE_CONNECTION_READ_03_URL") +] + +app.config['SECRET_KEY'] = environ.get('MASTER_KEY') +app.config["SERVER_NAME"] = environ.get( + "domain", environ.get( + "SERVER_NAME", "")).strip() + +app.config["SHORT_DOMAIN"]=environ.get("SHORT_DOMAIN","").strip() +app.config["SESSION_COOKIE_NAME"] = "session_ruqqus" +app.config["VERSION"] = _version +app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 +app.config["SESSION_COOKIE_SECURE"] = bool(int(environ.get("FORCE_HTTPS", 1))) +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + +app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * 365 +app.config["SESSION_REFRESH_EACH_REQUEST"] = True + +app.config["FORCE_HTTPS"] = int(environ.get("FORCE_HTTPS", 1)) if ("localhost" not in app.config["SERVER_NAME"] and "127.0.0.1" not in app.config["SERVER_NAME"]) else 0 +app.config["DISABLE_SIGNUPS"]=int(environ.get("DISABLE_SIGNUPS",0)) + +app.jinja_env.cache = {} + +app.config["UserAgent"] = f"Content Aquisition for Pink message board v{_version}." + +if "localhost" in app.config["SERVER_NAME"]: + app.config["CACHE_TYPE"] = "null" +else: + app.config["CACHE_TYPE"] = environ.get("CACHE_TYPE", 'filesystem').strip() + +app.config["CACHE_DIR"] = environ.get("CACHE_DIR", "ruqquscache") + +# captcha configs +app.config["HCAPTCHA_SITEKEY"] = environ.get("HCAPTCHA_SITEKEY","").strip() +app.config["HCAPTCHA_SECRET"] = environ.get( + "HCAPTCHA_SECRET","").strip() +app.config["SIGNUP_HOURLY_LIMIT"]=int(environ.get("SIGNUP_HOURLY_LIMIT",0)) + +# antispam configs +app.config["SPAM_SIMILARITY_THRESHOLD"] = float( + environ.get("SPAM_SIMILARITY_THRESHOLD", 0.5)) +app.config["SPAM_SIMILAR_COUNT_THRESHOLD"] = int( + environ.get("SPAM_SIMILAR_COUNT_THRESHOLD", 5)) +app.config["SPAM_URL_SIMILARITY_THRESHOLD"] = float( + environ.get("SPAM_URL_SIMILARITY_THRESHOLD", 0.1)) +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", 5)) + +app.config["CACHE_REDIS_URL"] = environ.get( + "REDIS_URL").strip().lstrip() if environ.get("REDIS_URL") else None +app.config["CACHE_DEFAULT_TIMEOUT"] = 60 +app.config["CACHE_KEY_PREFIX"] = "flask_caching_" + +app.config["S3_BUCKET"]=environ.get("S3_BUCKET_NAME","i.rdrama.net").strip() + +app.config["REDIS_POOL_SIZE"]=int(environ.get("REDIS_POOL_SIZE", 10)) + +redispool=ConnectionPool( + max_connections=app.config["REDIS_POOL_SIZE"], + host=app.config["CACHE_REDIS_URL"][8:] + ) if app.config["CACHE_TYPE"]=="redis" else None +app.config["CACHE_OPTIONS"]={'connection_pool':redispool} if app.config["CACHE_TYPE"]=="redis" else {} + +app.config["READ_ONLY"]=bool(int(environ.get("READ_ONLY", False))) +app.config["BOT_DISABLE"]=bool(int(environ.get("BOT_DISABLE", False))) + +app.config["TENOR_KEY"]=environ.get("TENOR_KEY",'').strip() + + +Markdown(app) +cache = Cache(app) +Compress(app) + +class CorsMatch(str): + + def __eq__(self, other): + if isinstance(other, str): + if other in ['https://rdrama.net', f'https://{app.config["SERVER_NAME"]}']: + return True + + elif other.endswith(".rdrama.net"): + return True + + elif isinstance(other, list): + if f'https://{app.config["SERVER_NAME"]}' in other: + return True + elif any([x.endswith(".rdrama.net") for x in other]): + return True + + return False + + + +app.config["RATELIMIT_STORAGE_URL"] = environ.get("REDIS_URL").strip() if environ.get("REDIS_URL") else 'memory://' +app.config["RATELIMIT_KEY_PREFIX"] = "flask_limiting_" +app.config["RATELIMIT_ENABLED"] = True +app.config["RATELIMIT_DEFAULTS_DEDUCT_WHEN"]=lambda:True +app.config["RATELIMIT_DEFAULTS_EXEMPT_WHEN"]=lambda:False +app.config["RATELIMIT_HEADERS_ENABLED"]=True + + +def limiter_key_func(): + return request.remote_addr + + +limiter = Limiter( + app, + key_func=limiter_key_func, + default_limits=["100/minute"], + headers_enabled=True, + strategy="fixed-window" +) + +_engine=create_engine( + app.config['DATABASE_URL'], + poolclass=QueuePool, + pool_size=int(environ.get("PG_POOL_SIZE",10)), + pool_use_lifo=True +) + +def retry(f): + + def wrapper(self, *args, **kwargs): + try: + return f(self, *args, **kwargs) + except OperationalError as e: + #self.session.rollback() + raise(DatabaseOverload) + except: + self.session.rollback() + return f(self, *args, **kwargs) + + wrapper.__name__=f.__name__ + return wrapper + + +class RetryingQuery(_Query): + + @retry + def all(self): + return super().all() + + @retry + def count(self): + return super().count() + + @retry + def first(self): + return super().first() + +db_session=scoped_session(sessionmaker(bind=_engine, query_cls=RetryingQuery)) + +Base = declarative_base() + + +#set the shared redis cache for misc stuff + +r=redis.Redis( + host=app.config["CACHE_REDIS_URL"][8:], + decode_responses=True, + ssl_cert_reqs=None, + connection_pool=redispool + ) if app.config["CACHE_REDIS_URL"] else None + +local_ban_cache={} + +UA_BAN_CACHE_TTL = int(environ.get("UA_BAN_CACHE_TTL", 3600)) + + + +# import and bind all routing functions +import ruqqus.classes +from ruqqus.routes import * +import ruqqus.helpers.jinja2 + +@cache.memoize(UA_BAN_CACHE_TTL) +def get_useragent_ban_response(user_agent_str): + """ + Given a user agent string, returns a tuple in the form of: + (is_user_agent_banned, (insult, status_code)) + """ + #if request.path.startswith("/socket.io/"): + # return False, (None, None) + + result = g.db.query( + ruqqus.classes.Agent).filter( + ruqqus.classes.Agent.kwd.in_( + user_agent_str.split())).first() + if result: + return True, (result.mock or "Follow the robots.txt, dumbass", + result.status_code or 418) + return False, (None, None) + +def drop_connection(): + + g.db.close() + gevent.getcurrent().kill() + + +# enforce https +@app.before_request +def before_request(): + + if request.method.lower() != "get" and app.config["READ_ONLY"]: + return jsonify({"error":f"{app.config['SITE_NAME']} is currently in read-only mode."}), 500 + + if app.config["BOT_DISABLE"] and request.headers.get("X-User-Type")=="Bot": + abort(503) + + g.db = db_session() + + if g.db.query(IP).filter_by(addr=request.remote_addr).first(): + abort(503) + + g.timestamp = int(time.time()) + + session.permanent = True + + ua_banned, response_tuple = get_useragent_ban_response( + request.headers.get("User-Agent", "NoAgent")) + if ua_banned and request.path != "/robots.txt": + return response_tuple + + if app.config["FORCE_HTTPS"] and request.url.startswith( + "http://") and "localhost" not in app.config["SERVER_NAME"]: + url = request.url.replace("http://", "https://", 1) + return redirect(url, code=301) + + if not session.get("session_id"): + session["session_id"] = secrets.token_hex(16) + + ua=request.headers.get("User-Agent","") + if "CriOS/" in ua: + g.system="ios/chrome" + elif "Version/" in ua: + g.system="android/webview" + elif "Mobile Safari/" in ua: + g.system="android/chrome" + elif "Safari/" in ua: + g.system="ios/safari" + elif "Mobile/" in ua: + g.system="ios/webview" + else: + g.system="other/other" + + +def log_event(name, link): + + x = requests.get(link) + + if x.status_code != 200: + return + + text = f'> **{name}**\r> {link}' + + url = os.environ.get("DISCORD_WEBHOOK") + headers = {"Content-Type": "application/json"} + data = {"username": "ruqqus", + "content": text + } + + x = requests.post(url, headers=headers, json=data) + print(x.status_code) + + +@app.after_request +def after_request(response): + + try: g.db.commit() + except: pass + g.db.close() + + response.headers.add('Access-Control-Allow-Headers', + "Origin, X-Requested-With, Content-Type, Accept, x-auth" + ) + response.headers.add("Cache-Control", + "maxage=600") + response.headers.add("Strict-Transport-Security", "max-age=31536000") + response.headers.add("Referrer-Policy", "same-origin") + # response.headers.add("X-Content-Type-Options","nosniff") + response.headers.add("Feature-Policy", + "geolocation 'none'; midi 'none'; notifications 'none'; push 'none'; sync-xhr 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; vibrate 'none'; fullscreen 'none'; payment 'none';") + if not request.path.startswith("/embed/"): + response.headers.add("X-Frame-Options", + "deny") + + return response + + +@app.route("/", subdomain="www") +def www_redirect(path): + + return redirect(f"https://{app.config['SERVER_NAME']}/{path}") \ No newline at end of file diff --git a/ruqqus/classes/__init__.py b/ruqqus/classes/__init__.py new file mode 100644 index 000000000..e235f06b7 --- /dev/null +++ b/ruqqus/classes/__init__.py @@ -0,0 +1,20 @@ +from .alts import * +from .badges import * +from .boards import * +from .board_relationships import * +from .clients import * +from .comment import * +from .custom_errors import * +from .domains import Domain +from .flags import * +from .user import * +from .userblock import * +from .submission import * +from .votes import * +from .images import * +from .domains import * +from .subscriptions import * +from .ips import * +from .titles import * +from .paypal import * +from .mod_logs import * \ No newline at end of file diff --git a/ruqqus/classes/alts.py b/ruqqus/classes/alts.py new file mode 100644 index 000000000..7a8786706 --- /dev/null +++ b/ruqqus/classes/alts.py @@ -0,0 +1,15 @@ +from sqlalchemy import * +from ruqqus.__main__ import Base + + +class Alt(Base): + __tablename__ = "alts" + + id = Column(Integer, primary_key=True) + user1 = Column(Integer, ForeignKey("users.id")) + user2 = Column(Integer, ForeignKey("users.id")) + is_manual = Column(Boolean, default=False) + + def __repr__(self): + + return f"" diff --git a/ruqqus/classes/badges.py b/ruqqus/classes/badges.py new file mode 100644 index 000000000..e02d7d49a --- /dev/null +++ b/ruqqus/classes/badges.py @@ -0,0 +1,92 @@ +from flask import render_template +from sqlalchemy import * +from sqlalchemy.orm import relationship + +from ruqqus.__main__ import Base, app + + +class BadgeDef(Base): + + __tablename__ = "badge_defs" + + id = Column(BigInteger, primary_key=True) + name = Column(String(64)) + description = Column(String(64)) + icon = Column(String(64)) + kind = Column(Integer, default=1) + rank = Column(Integer, default=1) + qualification_expr = Column(String(128), default=None) + + def __repr__(self): + + return f"" + + @property + def path(self): + + return f"/assets/images/badges/{self.icon}" + + @property + def json_core(self): + data={ + "name": self.name, + "description": self.description, + "icon": self.icon + } + + + +class Badge(Base): + + __tablename__ = "badges" + + id = Column(Integer, primary_key=True) + + user_id = Column(Integer, ForeignKey('users.id')) + badge_id = Column(Integer, ForeignKey("badge_defs.id")) + description = Column(String(64)) + url = Column(String(256)) + created_utc = Column(Integer) + badge = relationship("BadgeDef", lazy="joined", innerjoin=True) + + def __repr__(self): + + return f"" + + @property + def text(self): + if self.description: + return self.description + else: + return self.badge.description + + @property + def type(self): + return self.badge.id + + @property + def name(self): + return self.badge.name + + @property + def path(self): + return self.badge.path + + @property + def rendered(self): + + return render_template("badge.html", b=self) + + @property + def json_core(self): + + return {'text': self.text, + 'name': self.name, + 'created_utc': self.created_utc, + 'url': self.url, + 'icon_url':f"https://{app.config['SERVER_NAME']}{self.path}" + } + + property + def json(self): + return self.json_core diff --git a/ruqqus/classes/board_relationships.py b/ruqqus/classes/board_relationships.py new file mode 100644 index 000000000..d9ba474f4 --- /dev/null +++ b/ruqqus/classes/board_relationships.py @@ -0,0 +1,198 @@ +from sqlalchemy import * +from sqlalchemy.orm import relationship +from ruqqus.__main__ import Base, cache +from .mix_ins import * +import time + + +class ModRelationship(Base, Age_times): + __tablename__ = "mods" + id = Column(BigInteger, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id")) + board_id = Column(Integer, ForeignKey("boards.id")) + created_utc = Column(Integer, default=0) + accepted = Column(Boolean, default=False) + invite_rescinded = Column(Boolean, default=False) + + perm_content = Column(Boolean, default=False) + perm_appearance = Column(Boolean, default=False) + perm_config = Column(Boolean, default=False) + perm_access = Column(Boolean, default=False) + perm_full = Column(Boolean, default=False) + #permRules = Column(Boolean, default=False) + #permTitles = Column(Boolean, default=False) + #permLodges = Column(Boolean, default=False) + + user = relationship("User", lazy="joined") + board = relationship("Board", lazy="joined") + + 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"" + + @property + def permlist(self): + if self.perm_full: + return "full" + + output=[] + for p in ["access","appearance", "config","content"]: + if self.__dict__[f"perm_{p}"]: + output.append(p) + + + return ", ".join(output) if output else "none" + + @property + def permchangelist(self): + output=[] + for p in ["full", "access","appearance", "config","content"]: + if self.__dict__.get(f"perm_{p}"): + output.append(f"+{p}") + else: + output.append(f"-{p}") + + return ", ".join(output) + + + @property + def json_core(self): + return { + 'user_id':self.user_id, + 'board_id':self.board_id, + 'created_utc':self.created_utc, + 'accepted':self.accepted, + 'invite_rescinded':self.invite_rescinded, + 'perm_content':self.perm_full or self.perm_content, + 'perm_config':self.perm_full or self.perm_config, + 'perm_access':self.perm_full or self.perm_access, + 'perm_appearance':self.perm_full or self.perm_appearance, + 'perm_full':self.perm_full, + } + + + @property + def json(self): + data=self.json_core + + data["user"]=self.user.json_core + #data["guild"]=self.board.json_core + + return data + + + + +class BanRelationship(Base, Stndrd, Age_times): + + __tablename__ = "bans" + id = Column(BigInteger, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id")) + board_id = Column(Integer, ForeignKey("boards.id")) + created_utc = Column(BigInteger, default=0) + banning_mod_id = Column(Integer, ForeignKey("users.id")) + is_active = Column(Boolean, default=False) + mod_note = Column(String(128), default="") + + user = relationship( + "User", + lazy="joined", + primaryjoin="User.id==BanRelationship.user_id") + banning_mod = relationship( + "User", + lazy="joined", + primaryjoin="User.id==BanRelationship.banning_mod_id") + board = relationship("Board") + + 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"" + + @property + def json_core(self): + return { + 'user_id':self.user_id, + 'board_id':self.board_id, + 'created_utc':self.created_utc, + 'mod_id':self.banning_mod_id + } + + + @property + def json(self): + data=self.json_core + + data["user"]=self.user.json_core + data["mod"]=self.banning_mod.json_core + data["guild"]=self.board.json_core + + return data + +class ContributorRelationship(Base, Stndrd, Age_times): + + __tablename__ = "contributors" + id = Column(BigInteger, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id")) + board_id = Column(Integer, ForeignKey("boards.id")) + created_utc = Column(BigInteger, default=0) + is_active = Column(Boolean, default=True) + approving_mod_id = Column(Integer, ForeignKey("users.id")) + + user = relationship( + "User", + lazy="joined", + primaryjoin="User.id==ContributorRelationship.user_id") + approving_mod = relationship( + "User", + lazy='joined', + primaryjoin="User.id==ContributorRelationship.approving_mod_id") + board = relationship("Board", lazy="subquery") + + 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"" + + +class PostRelationship(Base): + + __tablename__ = "postrels" + id = Column(BigInteger, primary_key=True) + post_id = Column(Integer, ForeignKey("submissions.id")) + board_id = Column(Integer, ForeignKey("boards.id")) + + post = relationship("Submission", lazy="subquery") + board = relationship("Board", lazy="subquery") + + def __repr__(self): + return f"" + + +class BoardBlock(Base, Stndrd, Age_times): + + __tablename__ = "boardblocks" + + id = Column(BigInteger, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id")) + board_id = Column(Integer, ForeignKey("boards.id")) + created_utc = Column(Integer) + + user = relationship("User") + board = relationship("Board") + + def __repr__(self): + return f"" diff --git a/ruqqus/classes/boards.py b/ruqqus/classes/boards.py new file mode 100644 index 000000000..7759b1ab5 --- /dev/null +++ b/ruqqus/classes/boards.py @@ -0,0 +1,414 @@ +from sqlalchemy.orm import lazyload +from .userblock import * +from .submission import * +from .board_relationships import * +from .comment import Comment +from .mix_ins import * +from ruqqus.__main__ import Base, cache + + +class Board(Base, Stndrd, Age_times): + + __tablename__ = "boards" + + id = Column(Integer, primary_key=True) + name = Column(String) + created_utc = Column(Integer) + description = Column(String) + + description_html=Column(String) + over_18=Column(Boolean, default=False) + is_nsfl=Column(Boolean, default=False) + is_banned=Column(Boolean, default=False) + disablesignups=Column(Boolean, default=False) + has_banner=Column(Boolean, default=False) + has_profile=Column(Boolean, default=False) + creator_id=Column(Integer, ForeignKey("users.id")) + ban_reason=Column(String(256), default=None) + color=Column(String(8), default="FF66AC") + restricted_posting=Column(Boolean, default=False) + hide_banner_data=Column(Boolean, default=False) + profile_nonce=Column(Integer, default=0) + banner_nonce=Column(Integer, default=0) + is_private=Column(Boolean, default=False) + color_nonce=Column(Integer, default=0) + rank_trending=Column(Float, default=0) + stored_subscriber_count=Column(Integer, default=1) + all_opt_out=Column(Boolean, default=False) + is_siegable=Column(Boolean, default=True) + secondary_color=Column(String(6), default="cfcfcf") + motd = Column(String(1000), default='') + + moderators=relationship("ModRelationship") + submissions=relationship("Submission", primaryjoin="Board.id==Submission.board_id") + contributors=relationship("ContributorRelationship", lazy="dynamic") + bans=relationship("BanRelationship", lazy="dynamic") + postrels=relationship("PostRelationship", lazy="dynamic") + trending_rank=deferred(Column(Float, server_default=FetchedValue())) + + # db side functions + subscriber_count = deferred(Column(Integer, server_default=FetchedValue())) + + def __init__(self, **kwargs): + + kwargs["created_utc"] = int(time.time()) + + super().__init__(**kwargs) + + def __repr__(self): + return f"" + + @property + def fullname(self): + return f"t4_{self.base36id}" + + @property + def mods_list(self): + + z = [x for x in self.moderators if x.accepted and not ( + x.user.is_deleted or (x.user.is_banned and not x.user.unban_utc))] + + z = sorted(z, key=lambda x: x.created_utc) + return z + + @property + def mods(self): + + z = [x for x in self.moderators if x.accepted] + + z = sorted(z, key=lambda x: x.created_utc) + + z = [x.user for x in z] + + return z + + @property + def invited_mods(self): + + z = [x.user for x in self.moderators if x.accepted == + False and x.invite_rescinded == False] + z = sorted(z, key=lambda x: x.created_utc) + return z + + @property + def mod_invites(self): + z = [x for x in self.moderators if x.accepted == + False and x.invite_rescinded == False] + z = sorted(z, key=lambda x: x.created_utc) + return z + + @property + def mods_count(self): + + return len(self.mods_list) + + @property + def permalink(self): + + return f"/+{self.name}" + + def can_take(self, post): + if self.is_banned: + return False + return not self.postrels.filter_by(post_id=post.id).first() + + def has_mod(self, user, perm=None): + + if user is None: + return None + + if self.is_banned: + return False + + m=self.__dict__.get("_mod") + if not m: + for x in user.moderates: + if x.board_id == self.id and x.accepted and not x.invite_rescinded: + self.__dict__["mod"]=x + m=x + + if not m: + return False + + if perm: + + return m if (m.perm_full or m.__dict__[f"perm_{perm}"]) else False + + else: + return m + + + return False + + def has_mod_record(self, user, perm=None): + + if user is None: + return None + + if self.is_banned: + return False + + for x in user.moderates: + if x.board_id == self.id and not x.invite_rescinded: + + if perm: + return x if x.__dict__[f"perm_{perm}"] else False + else: + return x + + + return False + def can_invite_mod(self, user): + + return user.id not in [ + x.user_id for x in self.moderators if not x.invite_rescinded] + + def has_rescinded_invite(self, user): + + return user.id in [ + x.user_id for x in self.moderators if x.invite_rescinded == True] + + def has_invite(self, user): + + if user is None: + return None + + for x in [ + i for i in self.moderators if not i.invite_rescinded and not i.accepted]: + + if x.user_id == user.id: + return x + + return None + + def has_ban(self, user): + + if user is None: + return None + + if user.admin_level >=2: + return None + + return g.db.query(BanRelationship).filter_by( + board_id=self.id, user_id=user.id, is_active=True).first() + + def has_contributor(self, user): + + if user is None: + return False + + return g.db.query(ContributorRelationship).filter_by( + user_id=user.id, board_id=self.id, is_active=True).first() + + def can_submit(self, user): + + if user is None: + return False + + if user.admin_level >= 4: + return True + + if self.has_ban(user): + return False + + if self.has_contributor(user) or self.has_mod(user): + return True + + if self.is_private or self.restricted_posting: + return False + + return True + + def can_comment(self, user): + + if user is None: + return False + + if user.admin_level >= 4: + return True + + if self.has_ban(user): + return False + + if self.has_contributor(user) or self.has_mod(user): + return True + + if self.is_private: + return False + + return True + + def can_view(self, user): + + if user is None: + return False + + if user.admin_level >= 4: + return True + + if self.has_contributor(user) or self.has_mod( + user) or self.has_invite(user): + return True + + if self.is_private: + return False + + @property + def banner_url(self): + return "/assets/images/preview.png" + + @property + def profile_url(self): + return "/assets/images/favicon.png" + + @property + def css_url(self): + return f"/assets/{self.fullname}/main/{self.color_nonce}.css" + + @property + def css_dark_url(self): + return f"/assets/{self.fullname}/dark/{self.color_nonce}.css" + + def has_participant(self, user): + return (g.db.query(Submission).filter_by(original_board_id=self.id, author_id=user.id).first() or + g.db.query(Comment).filter_by( + author_id=user.id, original_board_id=self.id).first() + ) + + @property + @lazy + def n_pins(self): + return g.db.query(Submission).filter_by( + board_id=self.id, is_pinned=True).count() + + @property + def can_pin_another(self): + + return self.n_pins < 4 + + @property + def json_core(self): + + if self.is_banned: + return {'name': self.name, + 'permalink': self.permalink, + 'is_banned': True, + 'ban_reason': self.ban_reason, + 'id': self.base36id + } + return {'name': self.name, + 'profile_url': self.profile_url, + 'banner_url': self.banner_url, + 'created_utc': self.created_utc, + 'permalink': self.permalink, + 'description': self.description, + 'description_html': self.description_html, + 'over_18': self.over_18, + 'is_banned': False, + 'is_private': self.is_private, + 'is_restricted': self.restricted_posting, + 'id': self.base36id, + 'fullname': self.fullname, + 'banner_url': self.banner_url, + 'profile_url': self.profile_url, + 'color': "#" + self.color, + 'is_siege_protected': not self.is_siegable + } + + @property + def json(self): + data=self.json_core + + if self.is_banned: + return data + + + data['guildmasters']=[x.json_core for x in self.mods] + data['subscriber_count']= self.subscriber_count + + return data + + + @property + def show_settings_icons(self): + return self.is_private or self.restricted_posting or self.over_18 or self.all_opt_out + + @cache.memoize(600) + def comment_idlist(self, page=1, v=None, nsfw=False, **kwargs): + + posts = g.db.query(Submission).options( + lazyload('*')).filter_by(board_id=self.id) + + if not nsfw: + posts = posts.filter_by(over_18=False) + + if v and not v.show_nsfl: + posts = posts.filter_by(is_nsfl=False) + + if self.is_private: + if v and (self.can_view(v) or v.admin_level >= 4): + pass + elif v: + posts = posts.filter(or_(Submission.post_public == True, + Submission.author_id == v.id + ) + ) + else: + posts = posts.filter_by(post_public=True) + + posts = posts.subquery() + + comments = g.db.query(Comment).options(lazyload('*')) + + if v and v.hide_offensive: + comments = comments.filter_by(is_offensive=False) + + if v and v.hide_bot: + comments = comments.filter_by(is_bot=False) + + if v and not self.has_mod(v) and v.admin_level <= 3: + # blocks + blocking = g.db.query( + UserBlock.target_id).filter_by( + user_id=v.id).subquery() + blocked = g.db.query( + UserBlock.user_id).filter_by( + target_id=v.id).subquery() + + comments = comments.filter( + Comment.author_id.notin_(blocking), + Comment.author_id.notin_(blocked) + ) + + if not v or not v.admin_level >= 3: + comments = comments.filter_by(is_banned=False).filter(Comment.deleted_utc == 0) + + comments = comments.join( + posts, Comment.parent_submission == posts.c.id) + + comments = comments.order_by(Comment.created_utc.desc()).offset( + 25 * (page - 1)).limit(26).all() + + return [x.id for x in comments] + + + def user_guild_rep(self, user): + + return user.guild_rep(self) + + def is_guildmaster(self, perm=None): + mod=self.__dict__.get('_is_guildmaster', False) + if not mod: + return False + if not perm: + return True + + return mod.__dict__[f"perm_{perm}"] + + + @property + def siege_rep_requirement(self): + + now=int(time.time()) + + return self.stored_subscriber_count//10 + min(180, (now-self.created_utc)//(60*60*24)) \ No newline at end of file diff --git a/ruqqus/classes/clients.py b/ruqqus/classes/clients.py new file mode 100644 index 000000000..f6dbf48d2 --- /dev/null +++ b/ruqqus/classes/clients.py @@ -0,0 +1,93 @@ +from flask import * +from sqlalchemy import * +from sqlalchemy.orm import relationship, lazyload + +from .mix_ins import Stndrd +from .submission import Submission +from .comment import Comment +from ruqqus.__main__ import Base + + +class OauthApp(Base, Stndrd): + + __tablename__ = "oauth_apps" + + id = Column(Integer, primary_key=True) + client_id = Column(String(64)) + client_secret = Column(String(128)) + app_name = Column(String(50)) + redirect_uri = Column(String(4096)) + author_id = Column(Integer, ForeignKey("users.id")) + is_banned = Column(Boolean, default=False) + description = Column(String(256), default=None) + + author = relationship("User") + + def __repr__(self): + return f"" + + @property + def permalink(self): + + return f"/admin/app/{self.base36id}" + + def idlist(self, page=1, **kwargs): + + posts = g.db.query(Submission.id).options(lazyload('*')).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()] + + def comments_idlist(self, page=1, **kwargs): + + posts = g.db.query(Comment.id).options(lazyload('*')).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, Stndrd): + + __tablename__ = "client_auths" + + id = Column(Integer, primary_key=True) + oauth_client = Column(Integer, ForeignKey("oauth_apps.id")) + oauth_code = Column(String(128)) + user_id = Column(Integer, ForeignKey("users.id")) + scope_identity = Column(Boolean, default=False) + scope_create = Column(Boolean, default=False) + scope_read = Column(Boolean, default=False) + scope_update = Column(Boolean, default=False) + scope_delete = Column(Boolean, default=False) + scope_vote = Column(Boolean, default=False) + scope_guildmaster = Column(Boolean, default=False) + access_token = Column(String(128)) + refresh_token = Column(String(128)) + access_token_expire_utc = Column(Integer) + + user = relationship("User", lazy="joined") + application = relationship("OauthApp", lazy="joined") + + @property + def scopelist(self): + + output = "" + output += "identity," if self.scope_identity else "" + output += "create," if self.scope_create else "" + output += "read," if self.scope_read else "" + output += "update," if self.scope_update else "" + output += "delete," if self.scope_delete else "" + output += "vote," if self.scope_vote else "" + output += "guildmaster," if self.scope_guildmaster else "" + + output = output.rstrip(',') + + return output diff --git a/ruqqus/classes/comment.py b/ruqqus/classes/comment.py new file mode 100644 index 000000000..6db8f25e5 --- /dev/null +++ b/ruqqus/classes/comment.py @@ -0,0 +1,508 @@ +from flask import * +from sqlalchemy import * +from sqlalchemy.orm import relationship, deferred +from sqlalchemy.ext.associationproxy import association_proxy +from .mix_ins import * +from ruqqus.helpers.base36 import * +from ruqqus.helpers.lazy import lazy +from ruqqus.__main__ import Base +from .votes import CommentVote + +class CommentAux(Base): + + __tablename__ = "comments_aux" + + key_id = Column(Integer, primary_key=True) + id = Column(Integer, ForeignKey("comments.id")) + body = Column(String(10000), default=None) + body_html = Column(String(20000)) + ban_reason = Column(String(256), default='') + + +class Comment(Base, Age_times, Scores, Stndrd, Fuzzing): + + __tablename__ = "comments" + + id = Column(Integer, primary_key=True) + comment_aux = relationship( + "CommentAux", + lazy="joined", + uselist=False, + innerjoin=True, + primaryjoin="Comment.id==CommentAux.id") + author_id = Column(Integer, ForeignKey("users.id")) + parent_submission = Column(Integer, ForeignKey("submissions.id")) + # this column is foreignkeyed to comment(id) but we can't do that yet as + # "comment" class isn't yet defined + parent_fullname = Column(Integer) + created_utc = Column(Integer, default=0) + edited_utc = Column(Integer, default=0) + is_banned = Column(Boolean, default=False) + distinguish_level = Column(Integer, default=0) + gm_distinguish = Column(Integer, ForeignKey("boards.id"), default=0) + distinguished_board = relationship("Board", lazy="joined", primaryjoin="Comment.gm_distinguish==Board.id") + deleted_utc = Column(Integer, default=0) + purged_utc = Column(Integer, default=0) + is_approved = Column(Integer, default=0) + approved_utc = Column(Integer, default=0) + creation_ip = Column(String(64), default='') + score_hot = Column(Float, default=0) + score_top = Column(Integer, default=1) + level = Column(Integer, default=0) + parent_comment_id = Column(Integer, ForeignKey("comments.id")) + original_board_id = Column(Integer, ForeignKey("boards.id")) + + over_18 = Column(Boolean, default=False) + is_offensive = Column(Boolean, default=False) + is_nsfl = Column(Boolean, default=False) + is_bot = Column(Boolean, default=False) + is_pinned = Column(Boolean, default=False) + creation_region=Column(String(2), default=None) + sentto=Column(Integer, default=None) + + app_id = Column(Integer, ForeignKey("oauth_apps.id"), default=None) + oauth_app=relationship("OauthApp") + + post = relationship("Submission") + flags = relationship("CommentFlag", backref="comment") + author = relationship( + "User", + lazy="joined", + innerjoin=True, + primaryjoin="User.id==Comment.author_id") + board = association_proxy("post", "board") + original_board = relationship( + "Board", primaryjoin="Board.id==Comment.original_board_id") + + upvotes = Column(Integer, default=1) + downvotes = Column(Integer, default=0) + + parent_comment = relationship("Comment", remote_side=[id]) + child_comments = relationship("Comment", remote_side=[parent_comment_id]) + + awards = relationship("AwardRelationship", lazy="joined") + + # These are virtual properties handled as postgres functions server-side + # There is no difference to SQLAlchemy, but they cannot be written to + ups = deferred(Column(Integer, server_default=FetchedValue())) + downs = deferred(Column(Integer, server_default=FetchedValue())) + is_public = deferred(Column(Boolean, server_default=FetchedValue())) + + score = deferred(Column(Integer, server_default=FetchedValue())) + + rank_fiery = deferred(Column(Float, server_default=FetchedValue())) + rank_hot = deferred(Column(Float, server_default=FetchedValue())) + + board_id = deferred(Column(Integer, server_default=FetchedValue())) + + def __init__(self, *args, **kwargs): + + if "created_utc" not in kwargs: + kwargs["created_utc"] = int(time.time()) + + kwargs["creation_ip"] = request.remote_addr + + super().__init__(*args, **kwargs) + + def __repr__(self): + + return f"" + + @property + @lazy + def score_disputed(self): + return (self.upvotes+1) * (self.downvotes+1) + + @property + @lazy + def fullname(self): + return f"t3_{self.base36id}" + + def children(self, v): + return sorted([x for x in self.child_comments if not x.author.shadowbanned or (v and v.id == x.author_id)], key=lambda x: x.score, reverse=True) + + @property + @lazy + def is_deleted(self): + return bool(self.deleted_utc) + + @property + @lazy + def is_top_level(self): + return self.parent_fullname and self.parent_fullname.startswith("t2_") + + @property + def is_archived(self): + return self.post.is_archived + + @property + @lazy + def parent(self): + + if not self.parent_submission: + return None + + if self.is_top_level: + return self.post + + else: + return g.db.query(Comment).get(self.parent_comment_id) + + @property + def replies(self): + + r = self.__dict__.get("replies", None) + if r is None: + r = self.child_comments + return r + + @replies.setter + def replies(self, value): + self.__dict__["replies"] = value + + @property + def replies2(self): + return self.__dict__.get("replies2", []) + + @replies2.setter + def replies2(self, value): + self.__dict__["replies2"] = value + + @property + @lazy + def permalink(self): + if self.post: return f"{self.post.permalink}/{self.id}/" + else: return f"/comment/{self.id}/" + + @property + def any_descendants_live(self): + + if self.replies == []: + return False + + if any([not x.is_banned and x.deleted_utc == 0 for x in self.replies]): + return True + + else: + return any([x.any_descendants_live for x in self.replies]) + + def rendered_comment(self, v=None, render_replies=True, + standalone=False, level=1, **kwargs): + + kwargs["post_base36id"] = kwargs.get( + "post_base36id", self.post.base36id if self.post else None) + + if self.is_banned or self.deleted_utc > 0: + if v and v.admin_level > 1: + return render_template("single_comment.html", + v=v, + c=self, + render_replies=render_replies, + standalone=standalone, + level=level, + **kwargs) + + elif self.any_descendants_live: + return render_template("single_comment_removed.html", + c=self, + render_replies=render_replies, + standalone=standalone, + level=level, + **kwargs) + else: + return "" + + return render_template("single_comment.html", + v=v, + c=self, + render_replies=render_replies, + standalone=standalone, + level=level, + **kwargs) + + @property + def active_flags(self): + if self.is_approved: + return 0 + else: + return self.flag_count + + def visibility_reason(self, v): + if not v or self.author_id == v.id: + return "this is your content." + elif not self.board: + return None + elif self.board.has_mod(v): + return f"you are a guildmaster of +{self.board.name}." + elif self.board.has_contributor(v): + return f"you are an approved contributor in +{self.board.name}." + elif self.parent.author_id == v.id: + return "this is a reply to your content." + elif v.admin_level >= 4: + return "you are a Drama admin." + + @property + def json_raw(self): + data= { + 'id': self.base36id, + 'fullname': self.fullname, + 'level': self.level, + 'author_name': self.author.username if not self.author.is_deleted else None, + 'body': self.body, + 'body_html': self.body_html, + 'is_archived': self.is_archived, + 'is_bot': self.is_bot, + 'created_utc': self.created_utc, + 'edited_utc': self.edited_utc or 0, + 'is_banned': bool(self.is_banned), + 'is_deleted': self.is_deleted, + 'is_nsfw': self.over_18, + 'is_offensive': self.is_offensive, + 'is_nsfl': self.is_nsfl, + 'permalink': self.permalink, + 'post_id': self.post.base36id, + 'score': self.score_fuzzed, + 'upvotes': self.upvotes_fuzzed, + 'downvotes': self.downvotes_fuzzed, + 'award_count': self.award_count, + 'is_bot': self.is_bot + } + + if self.ban_reason: + data["ban_reason"]=self.ban_reason + + return data + + + @property + def json_core(self): + if self.is_banned: + data= {'is_banned': True, + 'ban_reason': self.ban_reason, + 'id': self.base36id, + 'post': self.post.base36id, + 'level': self.level, + 'parent': self.parent_fullname + } + elif self.deleted_utc > 0: + data= {'deleted_utc': self.deleted_utc, + 'id': self.base36id, + 'post': self.post.base36id, + 'level': self.level, + 'parent': self.parent_fullname + } + else: + + data=self.json_raw + + if self.level>=2: + data['parent_comment_id']= base36encode(self.parent_comment_id), + + if "replies" in self.__dict__: + data['replies']=[x.json_core for x in self.replies] + + return data + + @property + def json(self): + + data=self.json_core + + if self.deleted_utc > 0 or self.is_banned: + return data + + data["author"]=self.author.json_core + data["post"]=self.post.json_core + data["guild"]=self.post.board.json_core + + if self.level >= 2: + data["parent"]=self.parent.json_core + + + return data + + + @property + def voted(self): + + x = self.__dict__.get("_voted") + if x is not None: + return x + + if g.v: + x = g.db.query(CommentVote).filter_by( + comment_id=self.id, + user_id=g.v.id + ).first() + + if x: + x = x.vote_type + else: + x = 0 + else: + x = 0 + return x + + @property + def title(self): + return self.__dict__.get("_title", self.author.title) + + @property + def is_blocking(self): + return self.__dict__.get('_is_blocking', 0) + + @property + def is_blocked(self): + return self.__dict__.get('_is_blocked', 0) + + @property + def body(self): + if self.comment_aux: return self.comment_aux.body + else: return "" + + @body.setter + def body(self, x): + self.comment_aux.body = x + g.db.add(self.comment_aux) + + @property + def body_html(self): + return self.comment_aux.body_html + + @body_html.setter + def body_html(self, x): + self.comment_aux.body_html = x + g.db.add(self.comment_aux) + + def realbody(self, v): + body = self.comment_aux.body_html + if not v or v.slurreplacer: body = body.replace(" nigger"," 🏀").replace(" Nigger"," 🏀").replace(" NIGGER"," 🏀").replace(" pedo"," libertarian").replace(" Pedo"," Libertarian ").replace(" PEDO"," LIBERTARIAN ").replace(" tranny"," 🚄").replace(" Tranny"," 🚄").replace(" TRANNY"," 🚄").replace(" fag"," cute twink").replace(" Fag"," Cute twink").replace(" FAG"," CUTE TWINK").replace(" faggot"," cute twink").replace(" Faggot"," Cute twink").replace(" FAGGOT"," CUTE TWINK").replace(" trump"," DDR").replace(" Trump"," DDR").replace(" TRUMP"," DDR").replace(" biden"," DDD").replace(" Biden"," DDD").replace(" BIDEN"," DDD").replace(" steve akins"," penny verity oaken").replace(" Steve Akins"," Penny Verity Oaken").replace(" STEVE AKINS"," PENNY VERITY OAKEN").replace(" RETARD"," RSLUR").replace(" rapist"," male feminist").replace(" Rapist"," Male feminist").replace(" RAPIST"," MALE FEMINIST").replace(" RETARD"," RSLUR").replace(" rapist"," male feminist").replace(" Rapist"," Male feminist").replace(" RAPIST"," MALE FEMINIST").replace(" RETARD"," RSLUR").replace(" rapist"," male feminist").replace(" Rapist"," Male feminist").replace(" RAPIST"," MALE FEMINIST").replace(" kill yourself"," keep yourself safe").replace(" KILL YOURSELF"," KEEP YOURSELF SAFE") + if v and not v.oldreddit: body = body.replace("old.reddit.com", "reddit.com") + return body + + @property + def ban_reason(self): + return self.comment_aux.ban_reason + + @ban_reason.setter + def ban_reason(self, x): + self.comment_aux.ban_reason = x + g.db.add(self.comment_aux) + + @property + def flag_count(self): + return len(self.flags) + + @property + def award_count(self): + return len(self.awards) + + def collapse_for_user(self, v): + + if self.over_18 and not (v and v.over_18) and not self.post.over_18: + return True + + if not v: + return False + + if self.is_offensive and v.hide_offensive: + return True + + if self.is_bot and v.hide_bot: + return True + + if any([x in self.body for x in v.filter_words]): + return True + + if self.is_banned: return True + + return False + + @property + def flagged_by(self): + return [x.user for x in self.flags] + + @property + def self_download_json(self): + + #This property should never be served to anyone but author and admin + if not self.is_banned and not self.is_banned: + return self.json_core + + data= { + "author": self.author.name, + "body": self.body, + "body_html": self.body_html, + "is_banned": bool(self.is_banned), + "deleted_utc": self.deleted_utc, + 'created_utc': self.created_utc, + 'id': self.base36id, + 'fullname': self.fullname, + 'permalink': self.permalink, + 'post_id': self.post.base36id, + 'level': self.level + } + if self.level>=2: + data['parent_comment_id']= base36encode(self.parent_comment_id) + + return data + + @property + def json_admin(self): + data= self.json_raw + + data["creation_ip"] = self.creation_ip + data["creation_region"] = self.creation_region + + return data + + def is_guildmaster(self, perm=None): + mod=self.__dict__.get('_is_guildmaster', False) + + if not mod: + return False + elif not perm: + return True + else: + return mod.perm_full or mod.__dict__[f"perm_{perm}"] + + return output + + @property + def is_exiled_for(self): + return self.__dict__.get('_is_exiled_for', None) + + @property + @lazy + def is_op(self): + return self.author_id==self.post.author_id and not self.author.is_deleted and not self.post.author.is_deleted and not self.post.is_deleted + + + + +class Notification(Base): + + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id")) + comment_id = Column(Integer, ForeignKey("comments.id")) + read = Column(Boolean, default=False) + followsender = Column(Integer, default=None) + unfollowsender = Column(Integer, default=None) + blocksender = Column(Integer, default=None) + unblocksender = Column(Integer, default=None) + + comment = relationship("Comment", lazy="joined", innerjoin=True) + user=relationship("User", innerjoin=True) + + # Server side computed values (copied from corresponding comment) + created_utc = Column(Integer, server_default=FetchedValue()) + + def __repr__(self): + + return f"" + + @property + def voted(self): + return 0 \ No newline at end of file diff --git a/ruqqus/classes/custom_errors.py b/ruqqus/classes/custom_errors.py new file mode 100644 index 000000000..55bf4d445 --- /dev/null +++ b/ruqqus/classes/custom_errors.py @@ -0,0 +1,5 @@ +class PaymentRequired(Exception): + status_code=402 + def __init__(self): + Exception.__init__(self) + self.status_code=402 diff --git a/ruqqus/classes/domains.py b/ruqqus/classes/domains.py new file mode 100644 index 000000000..7adaa7ba3 --- /dev/null +++ b/ruqqus/classes/domains.py @@ -0,0 +1,46 @@ +from sqlalchemy import * +from ruqqus.__main__ import Base + +reasons = { + 1: "URL shorteners are not allowed.", + 3: "Piracy is not allowed.", + 4: "Sites hosting digitally malicious content are not allowed.", + 5: "Spam", + 6: "Doxxing is not allowed.", + 7: "Sexualizing minors is strictly prohibited." +} + + +class Domain(Base): + + __tablename__ = "domains" + id = Column(Integer, primary_key=True) + domain = Column(String) + can_submit = Column(Boolean, default=True) + can_comment = Column(Boolean, default=True) + reason = Column(Integer, default=0) + show_thumbnail = Column(Boolean, default=False) + embed_function = Column(String(64), default=None) + embed_template = Column(String(32), default=None) + + @property + def reason_text(self): + return reasons.get(self.reason) + + @property + def permalink(self): + return f"/admin/domain/{self.domain}" + + + +class BadLink(Base): + + __tablename__ = "badlinks" + id = Column(Integer, primary_key=True) + reason = Column(Integer) + link = Column(String(512)) + autoban = Column(Boolean, default=False) + + @property + def reason_text(self): + return reasons.get(self.reason) diff --git a/ruqqus/classes/flags.py b/ruqqus/classes/flags.py new file mode 100644 index 000000000..996b20d1e --- /dev/null +++ b/ruqqus/classes/flags.py @@ -0,0 +1,53 @@ +from sqlalchemy import * +from sqlalchemy.orm import relationship +from ruqqus.__main__ import Base +from .mix_ins import * + +class Flag(Base, Stndrd): + + __tablename__ = "flags" + + id = Column(Integer, primary_key=True) + post_id = Column(Integer, ForeignKey("submissions.id")) + user_id = Column(Integer, ForeignKey("users.id")) + created_utc = Column(Integer) + + user = relationship("User", lazy = "joined", primaryjoin = "Flag.user_id == User.id", uselist = False) + + def __repr__(self): + + return f"" + + +class CommentFlag(Base, Stndrd): + + __tablename__ = "commentflags" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id")) + comment_id = Column(Integer, ForeignKey("comments.id")) + created_utc = Column(Integer) + + user = relationship("User", lazy = "joined", primaryjoin = "CommentFlag.user_id == User.id", uselist = False) + + def __repr__(self): + + return f"" + + +class Report(Base): + + __tablename__ = "reports" + + id = Column(Integer, primary_key=True) + post_id = Column(Integer, ForeignKey("submissions.id")) + user_id = Column(Integer, ForeignKey("users.id")) + created_utc = Column(Integer) + + board_id = Column(Integer, server_default=FetchedValue()) + + user = relationship("User", lazy = "joined", primaryjoin = "Report.user_id == User.id", uselist = False) + + def __repr__(self): + + return f"" diff --git a/ruqqus/classes/images.py b/ruqqus/classes/images.py new file mode 100644 index 000000000..adeefde37 --- /dev/null +++ b/ruqqus/classes/images.py @@ -0,0 +1,37 @@ +from sqlalchemy import * +from flask import g +from ruqqus.__main__ import Base + + +class Image(Base): + __tablename__ = "images" + id = Column(BigInteger, primary_key=True) + state = Column(String(8)) + number = Column(Integer) + text = Column(String(64)) + deletehash = Column(String(64)) + + @property + def path(self): + return f"/assets/images/platy.jpg" + + + +def random_image(): + n=g.db.query(Image).count() + return g.db.query(Image).order_by(Image.id.asc()).first() + + + +class BadPic(Base): + + #Class for tracking fuzzy hashes of banned csam images + + __tablename__="badpics" + id = Column(BigInteger, primary_key=True) + description=Column(String(255), default=None) + phash=Column(String(64)) + ban_reason=Column(String(64)) + ban_time=Column(Integer) + + \ No newline at end of file diff --git a/ruqqus/classes/ips.py b/ruqqus/classes/ips.py new file mode 100644 index 000000000..85f69ed0b --- /dev/null +++ b/ruqqus/classes/ips.py @@ -0,0 +1,25 @@ +from sqlalchemy import * +from ruqqus.__main__ import Base + + +class IP(Base): + + __tablename__ = "ips" + + id = Column(Integer, primary_key=True) + addr = Column(String(64)) + reason = Column(String(256), default="") + banned_by = Column(Integer, ForeignKey("users.id"), default=True) + until_utc=Column(Integer, default=None) + + +class Agent(Base): + + __tablename__ = "useragents" + + id = Column(Integer, primary_key=True) + kwd = Column(String(64)) + reason = Column(String(256), default="") + banned_by = Column(Boolean, ForeignKey("users.id"), default=True) + mock = Column(String(256), default="") + status_code = Column(Integer, default=418) diff --git a/ruqqus/classes/mix_ins.py b/ruqqus/classes/mix_ins.py new file mode 100644 index 000000000..46bc9da5c --- /dev/null +++ b/ruqqus/classes/mix_ins.py @@ -0,0 +1,174 @@ +from ruqqus.helpers.base36 import * +from ruqqus.helpers.lazy import lazy +import math +import random +import time + +class Stndrd: + + @property + @lazy + def base36id(self): + return base36encode(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 str(time.strftime("%d/%B/%Y %H:%M:%S UTC", time.gmtime(self.created_utc))) + + @property + @lazy + def created_iso(self): + + t = time.gmtime(self.created_utc) + return time.strftime("%Y-%m-%dT%H:%M:%S+00:00", t) + + +class Age_times: + + @property + def age(self): + + now = int(time.time()) + + return now - self.created_utc + + @property + def created_date(self): + + return time.strftime("%d %b %Y", time.gmtime(self.created_utc)) + + @property + def created_datetime(self): + return str(time.strftime("%d/%B/%Y %H:%M:%S UTC", time.gmtime(self.created_utc))) + + @property + def age_string(self): + + age = self.age + + 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) + + # compute number of months + months = now.tm_mon - ctd.tm_mon + 12 * (now.tm_year - ctd.tm_year) + # remove a month count if current day of month < creation day of month + 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 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" + + @property + def edited_date(self): + return time.strftime("%d %B %Y", time.gmtime(self.edited_utc)) + + @property + def edited_datetime(self): + return str(time.strftime("%d/%B/%Y %H:%M:%S UTC", time.gmtime(self.edited_utc))) + +class Scores: + + @property + #@cache.memoize(timeout=60) + def score_percent(self): + # try: + # return int((self.ups/(self.ups+self.downs))*100) + # except ZeroDivisionError: + # return 0 + + return 101 + + @property + #@cache.memoize(timeout=60) + def score(self): + return int(self.score_top) or 0 + + +class Fuzzing: + + @property + #@cache.memoize(timeout=60) + def score_fuzzed(self): + + real = self.score_top if self.score_top else self.score + real = int(real) + if real <= 10: + return real + + k = 0.01 + + a = math.floor(real * (1 - k)) + b = math.ceil(real * (1 + k)) + return random.randint(a, b) + + @property + def upvotes_fuzzed(self): + + if self.upvotes <= 10 or self.is_archived: + return self.upvotes + + lower = int(self.upvotes * 0.99) + upper = int(self.upvotes * 1.01) + 1 + + return random.randint(lower, upper) + + @property + def downvotes_fuzzed(self): + if self.downvotes <= 10 or self.is_archived: + return self.downvotes + + lower = int(self.downvotes * 0.99) + upper = int(self.downvotes * 1.01) + 1 + + return random.randint(lower, upper) diff --git a/ruqqus/classes/mod_logs.py b/ruqqus/classes/mod_logs.py new file mode 100644 index 000000000..fdcc541e0 --- /dev/null +++ b/ruqqus/classes/mod_logs.py @@ -0,0 +1,387 @@ +from sqlalchemy import * +from sqlalchemy.orm import relationship +from ruqqus.__main__ import Base +from .mix_ins import * +import time + +class ModAction(Base, Stndrd, Age_times): + __tablename__ = "modactions" + id = Column(BigInteger, primary_key=True) + + user_id = Column(Integer, ForeignKey("users.id")) + board_id = Column(Integer, ForeignKey("boards.id")) + kind = Column(String(32)) + target_user_id = Column(Integer, ForeignKey("users.id"), default=0) + target_submission_id = Column(Integer, ForeignKey("submissions.id"), default=0) + target_comment_id = Column(Integer, ForeignKey("comments.id"), default=0) + _note=Column(String(256), default=None) + created_utc = Column(Integer, default=0) + + + user = relationship("User", lazy="joined", primaryjoin="User.id==ModAction.user_id") + target_user = relationship("User", lazy="joined", primaryjoin="User.id==ModAction.target_user_id") + board = relationship("Board", lazy="joined") + target_post = relationship("Submission", lazy="joined") + target_comment = relationship("Comment", lazy="joined") + + + def __init__(self, *args, **kwargs): + if "created_utc" not in kwargs: + kwargs["created_utc"] = int(time.time()) + + if "note" in kwargs: + kwargs["_note"]=kwargs["note"] + + super().__init__(*args, **kwargs) + + def __repr__(self): + return f"" + + @property + def actiontype(self): + return ACTIONTYPES[self.kind] + + @property + def note(self): + + if self.kind=="exile_user": + if self.target_post: + return f'for post' + elif self.target_comment: + return f'for comment' + else: return self._note + else: + return self._note or "" + + @note.setter + def note(self, x): + self._note=x + + @property + def string(self): + + output = self.actiontype["str"].format(self=self) + + if self.note: + output += f" ({self.note})" + + return output + + @property + def target_link(self): + if self.target_user: + if self.target_user.is_deleted: + return "[deleted user]" + else: + return f'{self.target_user.username}' + elif self.target_post: + return f'{self.target_post.title}' + elif self.target_comment: + return f'comment' + + else: + return '' + + @property + def json(self): + data={ + "id":self.base36id, + "guild": self.board.name, + "kind": self.kind, + "created_utc": self.created_utc, + "mod": self.user.username, + } + + if self.target_user_id: + data["target_user_id"]=self.target_user.base36id + data["target_user"]=self.target_user.username + + if self.target_comment_id: + data["target_comment_id"]=self.target_comment.base36id + + if self.target_submission_id: + data["target_submission_id"]=self.target_submission.base36id + + if self._note: + data["note"]=self._note + + return data + + + + + @property + def icon(self): + return self.actiontype['icon'] + + @property + def color(self): + return self.actiontype['color'] + + @property + def permalink(self): + return f"/log/{self.base36id}" + @property + def title_text(self): + if self.user.is_deleted: + return f"[deleted user] {self.actiontype['title'].format(self=self)}" + else: + return f"@{self.user.username} {self.actiontype['title'].format(self=self)}" + + + + +ACTIONTYPES={ + "kick_post":{ + "str":'kicked post {self.target_link}', + "icon":"fa-sign-out fa-flip-horizontal", + "color": "bg-danger", + "title": 'kicked post {self.target_post.title}' + }, + "approve_post":{ + "str":'approved post {self.target_link}', + "icon":"fa-check", + "color": "bg-success", + "title": 'approved post {self.target_post.title}' + }, + "yank_post":{ + "str":'yanked post {self.target_link}', + "icon":"fa-hand-lizard", + "color": "bg-muted", + "title": 'yanked post {self.target_post.title}' + }, + "exile_user":{ + "str":'banned user {self.target_link}', + "icon":"fa-user-slash", + "color": "bg-danger", + "title": 'banned user {self.target_user.username}' + }, + "unexile_user":{ + "str":'unbanned user {self.target_link}', + "icon": "fa-user-slash", + "color": "bg-muted", + "title": 'unbanned user {self.target_user.username}' + }, + "nuke_user":{ + "str":'removed all content of {self.target_link}', + "icon":"fa-user-slash", + "color": "bg-danger", + "title": 'removed all content of {self.target_user.username}' + }, + "unnuke_user":{ + "str":'approved all content of {self.target_link}', + "icon": "fa-user-slash", + "color": "bg-muted", + "title": 'approved all content of {self.target_user.username}' + }, + "shadowban": { + "str": 'shadowbanned {self.target_link}', + "icon": "fa-user-slash", + "color": "bg-danger", + "title": 'shadowbanned {self.target_user.username}' + }, + "unshadowban": { + "str": 'unshadowbanned {self.target_link}', + "icon": "fa-user-slash", + "color": "bg-muted", + "title": 'unshadowbanned {self.target_user.username}' + }, + "agendaposter": { + "str": "set agendaposter theme on {self.target_link}", + "icon": "fa-user-slash", + "color": "bg-muted", + "title": "set agendaposter theme on {self.target_link}" + }, + "unagendaposter": { + "str": "removed agendaposter theme from {self.target_link}", + "icon": "fa-user-slash", + "color": "bg-muted", + "title": "removed agendaposter theme from {self.target_link}" + }, + "set_flair_locked":{ + "str":"set {self.target_link}'s flair (locked)", + "icon": "fa-user-slash", + "color": "bg-muted", + "title": "set {self.target_link}'s flair (locked)" + }, + "set_flair_notlocked":{ + "str":"set {self.target_link}'s flair (not locked)", + "icon": "fa-user-slash", + "color": "bg-muted", + "title": "set {self.target_link}'s flair (not locked)" + }, + "contrib_user":{ + "str":'added contributor {self.target_link}', + "icon": "fa-user-check", + "color": "bg-info", + "title": 'added contributor {self.target_user.username}' + }, + "uncontrib_user":{ + "str":'removed contributor {self.target_link}', + "icon": "fa-user-check", + "color": "bg-muted", + "title": 'removed user {self.target_user.username}' + }, + "herald_comment":{ + "str":'heralded their {self.target_link}', + "icon": "fa-crown", + "color": "bg-warning", + "title": 'heralded their comment' + }, + "herald_post":{ + "str":'heralded their post {self.target_link}', + "icon": "fa-crown", + "color": "bg-warning", + "title": 'heralded their post {self.target_post.title}' + }, + "unherald_comment":{ + "str":'un-heralded their {self.target_link}', + "icon": "fa-crown", + "color": "bg-muted", + "title": 'un-heralded their comment' + }, + "unherald_post":{ + "str":'un-heralded their post {self.target_link}', + "icon": "fa-crown", + "color": "bg-muted", + "title": 'un-heralded their post {self.target_post.title}' + }, + "pin_comment":{ + "str":'pinned a {self.target_link}', + "icon":"fa-thumbtack fa-rotate--45", + "color": "bg-info", + "title": 'pinned a comment' + }, + "unpin_comment":{ + "str":'un-pinned a {self.target_link}', + "icon":"fa-thumbtack fa-rotate--45", + "color": "bg-muted", + "title": 'un-pinned a comment' + }, + "pin_post":{ + "str":'pinned post {self.target_link}', + "icon":"fa-thumbtack fa-rotate--45", + "color": "bg-success", + "title": 'pinned post {self.target_post.title}' + }, + "unpin_post":{ + "str":'un-pinned post {self.target_link}', + "icon":"fa-thumbtack fa-rotate--45", + "color": "bg-muted", + "title": 'un-pinned post {self.target_post.title}' + }, + "invite_mod":{ + "str":'invited badmin {self.target_link}', + "icon":"fa-user-crown", + "color": "bg-info", + "title": 'invited badmin @{self.target_user.username}' + }, + "uninvite_mod":{ + "str":'rescinded badmin invitation to {self.target_link}', + "icon":"fa-user-crown", + "color": "bg-muted", + "title": 'rescinded badmin invitation to @{self.target_user.username}' + }, + "accept_mod_invite":{ + "str":'accepted badmin invitation', + "icon":"fa-user-crown", + "color": "bg-warning", + "title": 'accepted badmin invitation' + }, + "remove_mod":{ + "str":'removed badmin {self.target_link}', + "icon":"fa-user-crown", + "color": "bg-danger", + "title": 'removed badmin @{self.target_user.username}' + }, + "dethrone_self":{ + "str":'stepped down as guildmaster', + "icon":"fa-user-crown", + "color": "bg-danger", + "title": 'stepped down as guildmaster' + }, + "add_mod":{ + "str":'added badmin {self.target_link}', + "icon":"fa-user-crown", + "color": "bg-success", + "title": 'added badmin @{self.target_user.username}' + }, + "update_settings":{ + "str":'updated setting', + "icon":"fa-cog", + "color": "bg-info", + "title": 'updated settings' + }, + "update_appearance":{ + "str":'updated appearance', + "icon":"fa-palette", + "color": "bg-info", + "title": 'updated appearance' + }, + "set_nsfw":{ + "str":'set nsfw on post {self.target_link}', + "icon":"fa-eye-evil", + "color": "bg-danger", + "title": 'set nsfw on post {self.target_post.title}' + }, + "unset_nsfw":{ + "str":'un-set nsfw on post {self.target_link}', + "icon":"fa-eye-evil", + "color": "bg-muted", + "title": 'un-set nsfw on post {self.target_post.title}' + }, + "set_nsfl":{ + "str":'set nsfl on post {self.target_link}', + "icon":"fa-skull", + "color": "bg-black", + "title": 'set nsfl on post {self.target_post.title}' + }, + "unset_nsfl":{ + "str":'un-set nsfl on post {self.target_link}', + "icon":"fa-skull", + "color": "bg-muted", + "title": 'un-set nsfw on post {self.target_post.title}' + }, + "ban_post":{ + "str": 'removed post {self.target_link}', + "icon":"fa-feather-alt", + "color": "bg-danger", + "title": "removed post {self.target_post.title}" + }, + "unban_post":{ + "str": 'reinstated post {self.target_link}', + "icon":"fa-feather-alt", + "color": "bg-muted", + "title": "reinstated post {self.target_post.title}" + }, + "ban_comment":{ + "str": 'removed {self.target_link}', + "icon":"fa-comment", + "color": "bg-danger", + "title": "removed comment" + }, + "unban_comment":{ + "str": 'reinstated {self.target_link}', + "icon":"fa-comment", + "color": "bg-muted", + "title": "reinstated comment" + }, + "change_perms":{ + "str": 'changed permissions on badmin {self.target_link}', + "icon":"fa-user-cog", + "color": "bg-info", + "title": "changed permissions on {self.target_user.username}" + }, + "change_invite":{ + "str": 'changed permissions on badmin invitation to {self.target_link}', + "icon":"fa-user-cog", + "color": "bg-muted", + "title": "changed permissions on invitation to {self.target_user.username}" + }, + "create_guild":{ + "str": 'created +{self.board.name}', + "icon": "fa-chess-rook", + "color": "bg-primary", + "title": "created +{self.board.name}" + } +} \ No newline at end of file diff --git a/ruqqus/classes/paypal.py b/ruqqus/classes/paypal.py new file mode 100644 index 000000000..346df08d0 --- /dev/null +++ b/ruqqus/classes/paypal.py @@ -0,0 +1,275 @@ +import requests +from os import environ +from sqlalchemy import * +from sqlalchemy.orm import relationship +from .mix_ins import * +from ruqqus.__main__ import Base, app + +PAYPAL_ID=environ.get("PAYPAL_CLIENT_ID", "").strip() +PAYPAL_SECRET=environ.get("PAYPAL_CLIENT_SECRET", "").strip() +PAYPAL_WEBHOOK_ID=environ.get("PAYPAL_WEBHOOK_ID", "").strip() + +PAYPAL_URL="https://api.paypal.com" + +STATUSES={ + 1:"CREATED", + 2:"AUTHORIZED", + 3:"CAPTURED", + -2:"REVERSED" +} + +class PayPalClient(): + + def __init__(self): + + self.paypal_token=None + self.token_expires=0 + self.webhook_id=PAYPAL_WEBHOOK_ID + + def print(self, x): + + try: + print(x) + except OSError: + pass + + def new_token(self): + + url=f"{PAYPAL_URL}/v1/oauth2/token" + + headers={ + "Accept":"application/json" + } + + data={ + "grant_type":"client_credentials" + } + + x=requests.post(url, headers=headers, data=data, auth=(PAYPAL_ID,PAYPAL_SECRET)) + + x=x.json() + + self.paypal_token=x["access_token"] + self.token_expires=int(time.time())+int(x["expires_in"]) + + def _get(self, url): + + if time.time()>self.token_expires: + self.new_token() + + url=PAYPAL_URL+url + + headers={ + "Content-Type":"application/json", + # "Accept":"application/json", + "Authorization":f"Bearer {self.paypal_token}" + } + + return requests.get(url, headers=headers) + + + def _post(self, url, data=None): + + if time.time()>self.token_expires: + self.new_token() + + url=PAYPAL_URL+url + + headers={ + "Content-Type":"application/json", + # "Accept":"application/json", + "Authorization":f"Bearer {self.paypal_token}" + } + + return requests.post(url, headers=headers, json=data) + + def create(self, txn): + + if not txn.id: + raise ValueError("txn must be flushed first") + + url="/v2/checkout/orders" + + data={ + "intent":"CAPTURE", + "purchase_units": + [ + { + "amount": { + "currency_code":"USD", + "value": str(txn.usd_cents/100) + } + } + ], + "application_context":{ + "return_url":f"https://{app.config['SERVER_NAME']}/shop/buy_coins_completed?txid={txn.base36id}" + } + } + + r=self._post(url, data=data) + + x=r.json() + + if x["status"]=="CREATED": + txn.paypal_id=x["id"] + txn.status=1 + + def authorize(self, txn): + + url=f"{txn.paypal_url}/authorize" + + x= self._post(url) + x=x.json() + + status=x["status"] + if status in ["SAVED", "COMPLETED"]: + txn.status=2 + + return x["status"] in ["SAVED", "COMPLETED"] + + + def capture(self, txn): + + url=f"{txn.paypal_url}/capture" + + x=self._post(url) + x=x.json() + + try: + status=x["status"] + if status=="COMPLETED": + txn.status=3 + except KeyError: + abort(403) + + return status=="COMPLETED" + + +class PayPalTxn(Base, Stndrd, Age_times): + + __tablename__="paypal_txns" + + id=Column(Integer, primary_key=True) + user_id=Column(Integer, ForeignKey("users.id")) + created_utc=Column(Integer) + paypal_id=Column(String) + usd_cents=Column(Integer) + coin_count=Column(Integer) + promo_id=Column(Integer, ForeignKey("promocodes.id")) + + status=Column(Integer, default=0) #0=initialized 1=created, 2=authorized, 3=captured, -1=failed, -2=reversed + + user=relationship("User", lazy="joined") + promo=relationship("PromoCode", lazy="joined") + + @property + def approve_url(self): + + return f"https://www.paypal.com/checkoutnow?token={self.paypal_id}" + + @property + def paypal_url(self): + + return f"/v2/checkout/orders/{self.paypal_id}" + + @property + def permalink(self): + return f"/paypaltxn/{self.base36id}" + + @property + def display_usd(self): + s=str(self.usd_cents) + d=s[0:-2] or '0' + c=s[-2:] + return f"${d}.{c}" + + @property + def status_text(self): + return STATUSES[self.status] + +class PromoCode(Base): + + __tablename__="promocodes" + + id=Column(Integer, primary_key=True) + code=Column(String(64)) + is_active=Column(Boolean) + percent_off=Column(Integer, default=None) + flat_cents_off=Column(Integer, default=None) + flat_cents_min=Column(Integer, default=None) + promo_start_utc=Column(Integer, default=None) + promo_end_utc=Column(Integer, default=None) + promo_info=Column(String(64), default=None) + + def adjust_price(self, cents): + + now=int(time.time()) + + if self.promo_start_utc and now < self.promo_start_utc: + return cents + + elif self.promo_end_utc and now > self.promo_end_utc: + return cents + + if not self.is_active: + return cents + + if self.percent_off: + x = (100-self.percent_off)/100 + return int(cents * x) + + if self.flat_cents_off: + if cents >= self.flat_cents_min: + cents -= self.flat_cents_off + return cents + + else: + return cents + + @property + def display_flat_off(self): + s=str(self.flat_cents_off) + d=s[0:-2] or '0' + c=s[-2:] + return f"${d}.{c}" + + @property + def display_flat_min(self): + s=str(self.flat_cents_min) + d=s[0:-2] or '0' + c=s[-2:] + return f"${d}.{c}" + + @property + def promo_text(self): + + now=int(time.time()) + + if self.promo_start_utc and now < self.promo_start_utc: + return f"This promotion hasn't started yet. Try again later." + + elif self.promo_end_utc and now > self.promo_end_utc: + return f"This promotion has already ended. Sorry about that." + + elif self.percent_off: + text= f"Save {self.percent_off}% on all purchases with code {self.code}." + + elif self.flat_cents_off and self.flat_cents_min: + text= f"Save {self.display_flat_off} on any purchase over {self.display_flat_min} with code {self.code}." + + if self.promo_info: + text += f" Your purchase will also support {self.promo_info}." + + return text + + + +class AwardRelationship(Base): + + __tablename__="award_relationships" + + id=Column(Integer, primary_key=True) + + user_id=Column(Integer, ForeignKey("users.id")) + submission_id=Column(Integer, ForeignKey("submissions.id"), default=None) + comment_id=Column(Integer, ForeignKey("comments.id"), default=None) \ No newline at end of file diff --git a/ruqqus/classes/submission.py b/ruqqus/classes/submission.py new file mode 100644 index 000000000..030becf41 --- /dev/null +++ b/ruqqus/classes/submission.py @@ -0,0 +1,610 @@ +from flask import render_template, request, g +from sqlalchemy import * +from sqlalchemy.orm import relationship, deferred +import re, random +from urllib.parse import urlparse +from .mix_ins import * +from ruqqus.helpers.base36 import * +from ruqqus.helpers.lazy import lazy +from ruqqus.__main__ import Base + +class SubmissionAux(Base): + + __tablename__ = "submissions_aux" + + # we don't care about this ID + key_id = Column(BigInteger, primary_key=True) + id = Column(BigInteger, ForeignKey("submissions.id")) + title = Column(String(500), default=None) + title_html = Column(String(500), default=None) + url = Column(String(500), default=None) + body = Column(String(10000), default="") + body_html = Column(String(20000), default="") + ban_reason = Column(String(128), default="") + embed_url = Column(String(256), default="") + meta_title=Column(String(512), default="") + meta_description=Column(String(1024), default="") + + +class Submission(Base, Stndrd, Age_times, Scores, Fuzzing): + + __tablename__ = "submissions" + + id = Column(BigInteger, primary_key=True) + submission_aux = relationship( + "SubmissionAux", + lazy="joined", + uselist=False, + innerjoin=True, + primaryjoin="Submission.id==SubmissionAux.id") + author_id = Column(BigInteger, ForeignKey("users.id")) + repost_id = Column(BigInteger, ForeignKey("submissions.id"), default=0) + edited_utc = Column(BigInteger, default=0) + created_utc = Column(BigInteger, default=0) + thumburl = Column(String, default=None) + is_banned = Column(Boolean, default=False) + views = Column(Integer, default=0) + deleted_utc = Column(Integer, default=0) + purged_utc = Column(Integer, default=0) + distinguish_level = Column(Integer, default=0) + gm_distinguish = Column(Integer, ForeignKey("boards.id"), default=0) + distinguished_board = relationship("Board", lazy="joined", primaryjoin="Board.id==Submission.gm_distinguish") + created_str = Column(String(255), default=None) + stickied = Column(Boolean, default=False) + is_pinned = Column(Boolean, default=False) + private = Column(Boolean, default=False) + _comments = relationship( + "Comment", + lazy="dynamic", + primaryjoin="Comment.parent_submission==Submission.id", + backref="submissions") + domain_ref = Column(Integer, ForeignKey("domains.id")) + domain_obj = relationship("Domain") + flags = relationship("Flag", backref="submission") + is_approved = Column(Integer, ForeignKey("users.id"), default=0) + approved_utc = Column(Integer, default=0) + board_id = Column(Integer, ForeignKey("boards.id"), default=None) + original_board_id = Column(Integer, ForeignKey("boards.id"), default=None) + over_18 = Column(Boolean, default=False) + original_board = relationship( + "Board", primaryjoin="Board.id==Submission.original_board_id") + creation_ip = Column(String(64), default="") + mod_approved = Column(Integer, default=None) + accepted_utc = Column(Integer, default=0) + has_thumb = Column(Boolean, default=False) + post_public = Column(Boolean, default=True) + score_hot = Column(Float, default=0) + score_top = Column(Float, default=1) + score_activity = Column(Float, default=0) + is_offensive = Column(Boolean, default=False) + is_nsfl = Column(Boolean, default=False) + board = relationship( + "Board", + lazy="joined", + innerjoin=True, + primaryjoin="Submission.board_id==Board.id") + author = relationship( + "User", + lazy="joined", + innerjoin=True, + primaryjoin="Submission.author_id==User.id") + is_pinned = Column(Boolean, default=False) + score_best = Column(Float, default=0) + reports = relationship("Report", backref="submission") + is_bot = Column(Boolean, default=False) + + upvotes = Column(Integer, default=1) + downvotes = Column(Integer, default=0) + creation_region=Column(String(2), default=None) + + app_id=Column(Integer, ForeignKey("oauth_apps.id"), default=None) + oauth_app=relationship("OauthApp") + + approved_by = relationship( + "User", + uselist=False, + primaryjoin="Submission.is_approved==User.id") + + # not sure if we need this + reposts = relationship("Submission", lazy="joined", remote_side=[id]) + + # These are virtual properties handled as postgres functions server-side + # There is no difference to SQLAlchemy, but they cannot be written to + + ups = deferred(Column(Integer, server_default=FetchedValue())) + downs = deferred(Column(Integer, server_default=FetchedValue())) + comment_count = Column(Integer, server_default=FetchedValue()) + score = deferred(Column(Float, server_default=FetchedValue())) + + awards = relationship("AwardRelationship", lazy="joined") + + rank_hot = deferred(Column(Float, server_default=FetchedValue())) + rank_fiery = deferred(Column(Float, server_default=FetchedValue())) + rank_activity = deferred(Column(Float, server_default=FetchedValue())) + rank_best = deferred(Column(Float, server_default=FetchedValue())) + + def __init__(self, *args, **kwargs): + + if "created_utc" not in kwargs: + kwargs["created_utc"] = int(time.time()) + kwargs["created_str"] = time.strftime( + "%I:%M %p on %d %b %Y", time.gmtime( + kwargs["created_utc"])) + + kwargs["creation_ip"] = request.remote_addr + + super().__init__(*args, **kwargs) + + def __repr__(self): + return f"" + + @property + @lazy + def board_base36id(self): + return base36encode(self.board_id) + + @property + @lazy + def is_deleted(self): + return bool(self.deleted_utc) + + @property + @lazy + def hotscore(self): + return 10000000*(self.upvotes - self.downvotes + 1)/(((self.age+3600)/1000)**(1.35)) + + @property + @lazy + def score_disputed(self): + return (self.upvotes+1) * (self.downvotes+1) + + @property + def is_repost(self): + return bool(self.repost_id) + + @property + def is_archived(self): + return false + + @property + @lazy + def fullname(self): + return f"t2_{self.base36id}" + + @property + @lazy + def permalink(self): + + output = self.title.lower() + + output = re.sub('&\w{2,3};', '', output) + + output = [re.sub('\W', '', word) for word in output.split()] + output = [x for x in output if x][0:6] + + output = '-'.join(output) + + if not output: + output = '-' + + return f"/post/{self.id}/{output}" + + @property + def is_archived(self): + + now = int(time.time()) + + cutoff = now - (60 * 60 * 24 * 180) + + return self.created_utc < cutoff + + def rendered_page(self, sort=None, comment=None, comment_info=None, v=None): + + # check for banned + if v and v.admin_level >= 3: + template = "submission.html" + elif self.is_banned: + template = "submission_banned.html" + else: + template = "submission.html" + + # load and tree comments + # calling this function with a comment object will do a comment + # permalink thing + if "replies" not in self.__dict__ and "_preloaded_comments" in self.__dict__: + self.tree_comments(comment=comment) + + # return template + is_allowed_to_comment = self.board.can_comment( + v) and not self.is_archived + + return render_template(template, + v=v, + p=self, + sort=sort, + linked_comment=comment, + comment_info=comment_info, + is_allowed_to_comment=is_allowed_to_comment, + render_replies=True, + ) + + @property + @lazy + def domain(self): + + if not self.url: + return "text post" + domain = urlparse(self.url).netloc + if domain.startswith("www."): + domain = domain.split("www.")[1] + return domain.replace("old.reddit.com", "reddit.com") + + def tree_comments(self, comment=None, v=None): + + comments = self.__dict__.get('_preloaded_comments',[]) + if not comments: + return + + pinned_comment=[] + + index = {} + for c in comments: + + if c.is_pinned and c.parent_fullname==self.fullname: + pinned_comment+=[c] + continue + + if c.parent_fullname in index: + index[c.parent_fullname].append(c) + else: + index[c.parent_fullname] = [c] + + for c in comments: + c.__dict__["replies"] = index.get(c.fullname, []) + + if comment: + self.__dict__["replies"] = [comment] + else: + self.__dict__["replies"] = pinned_comment + index.get(self.fullname, []) + + @property + def active_flags(self): + if self.is_approved: + return 0 + else: + return len(self.flags) + + @property + #@lazy + def thumb_url(self): + + if self.over_18: return f"/assets/images/nsfw.png" + elif self.has_thumb: + if self.thumburl: return self.thumburl + else: return f"https://s3.eu-central-1.amazonaws.com/i.ruqqus.ga/posts/{self.base36id}/thumb.png" + elif self.is_image: + return self.url + else: + return None + + def visibility_reason(self, v): + + + if not v or self.author_id == v.id: + return "this is your content." + elif self.is_pinned: + return "a guildmaster has pinned it." + elif self.board.has_mod(v): + return f"you are a guildmaster of +{self.board.name}." + elif self.board.has_contributor(v): + return f"you are an approved contributor in +{self.board.name}." + elif v.admin_level >= 4: + return "you are a Drama admin." + + @property + + def json_raw(self): + data = {'author_name': self.author.username if not self.author.is_deleted else None, + 'permalink': self.permalink, + 'is_banned': bool(self.is_banned), + 'is_deleted': self.is_deleted, + 'created_utc': self.created_utc, + 'id': self.base36id, + 'fullname': self.fullname, + 'title': self.title, + 'is_nsfw': self.over_18, + 'is_nsfl': self.is_nsfl, + 'is_bot': self.is_bot, + 'thumb_url': self.thumb_url, + 'domain': self.domain, + 'is_archived': self.is_archived, + 'url': self.url, + 'body': self.body, + 'body_html': self.body_html, + 'created_utc': self.created_utc, + 'edited_utc': self.edited_utc or 0, + 'guild_name': self.board.name, + 'guild_id': base36encode(self.board_id), + 'comment_count': self.comment_count, + 'score': self.score_fuzzed, + 'upvotes': self.upvotes_fuzzed, + 'downvotes': self.downvotes_fuzzed, + 'award_count': self.award_count, + 'is_offensive': self.is_offensive, + 'meta_title': self.meta_title, + 'meta_description': self.meta_description, + 'voted': self.voted + } + if self.ban_reason: + data["ban_reason"]=self.ban_reason + + if self.board_id != self.original_board_id and self.original_board: + data['original_guild_name'] = self.original_board.name + data['original_guild_id'] = base36encode(self.original_board_id) + return data + + @property + def json_core(self): + + if self.is_banned: + return {'is_banned': True, + 'is_deleted': self.is_deleted, + 'ban_reason': self.ban_reason, + 'id': self.base36id, + 'title': self.title, + 'permalink': self.permalink, + 'guild_name': self.board.name + } + elif self.is_deleted: + return {'is_banned': bool(self.is_banned), + 'is_deleted': True, + 'id': self.base36id, + 'title': self.title, + 'permalink': self.permalink, + 'guild_name': self.board.name + } + + return self.json_raw + + @property + def json(self): + + data=self.json_core + + if self.deleted_utc > 0 or self.is_banned: + return data + + data["author"]=self.author.json_core + data["guild"]=self.board.json_core + data["original_guild"]=self.original_board.json_core if not self.board_id==self.original_board_id else None + data["comment_count"]=self.comment_count + + + if "replies" in self.__dict__: + data["replies"]=[x.json_core for x in self.replies] + + if "_voted" in self.__dict__: + data["voted"] = self._voted + + return data + + @property + def voted(self): + return self._voted if "_voted" in self.__dict__ else 0 + + @property + def user_title(self): + return self._title if "_title" in self.__dict__ else self.author.title + + @property + def title(self): + return self.submission_aux.title + + @title.setter + def title(self, x): + self.submission_aux.title = x + g.db.add(self.submission_aux) + + @property + def url(self): + return self.submission_aux.url + + @url.setter + def url(self, x): + self.submission_aux.url = x + g.db.add(self.submission_aux) + + def realurl(self, v): + if v and v.agendaposter and random.randint(1, 10) < 4: + return 'https://secure.actblue.com/donate/ms_blm_homepage_2019' + elif self.url: + if v and not v.oldreddit: return self.url.replace("old.reddit.com", "reddit.com") + if self.url: return self.url + return "" + + @property + def body(self): + return self.submission_aux.body + + @body.setter + def body(self, x): + self.submission_aux.body = x + g.db.add(self.submission_aux) + + @property + def body_html(self): + return self.submission_aux.body_html + + @body_html.setter + def body_html(self, x): + self.submission_aux.body_html = x + g.db.add(self.submission_aux) + + def realbody(self, v): + body = self.submission_aux.body_html + if not v or v.slurreplacer: body = body.replace(" nigger"," 🏀").replace(" Nigger"," 🏀").replace(" NIGGER"," 🏀").replace(" pedo"," libertarian").replace(" Pedo"," Libertarian ").replace(" PEDO"," LIBERTARIAN ").replace(" tranny"," 🚄").replace(" Tranny"," 🚄").replace(" TRANNY"," 🚄").replace(" fag"," cute twink").replace(" Fag"," Cute twink").replace(" FAG"," CUTE TWINK").replace(" faggot"," cute twink").replace(" Faggot"," Cute twink").replace(" FAGGOT"," CUTE TWINK").replace(" trump"," DDR").replace(" Trump"," DDR").replace(" TRUMP"," DDR").replace(" biden"," DDD").replace(" Biden"," DDD").replace(" BIDEN"," DDD").replace(" steve akins"," penny verity oaken").replace(" Steve Akins"," Penny Verity Oaken").replace(" STEVE AKINS"," PENNY VERITY OAKEN").replace(" RETARD"," RSLUR").replace(" rapist"," male feminist").replace(" Rapist"," Male feminist").replace(" RAPIST"," MALE FEMINIST").replace(" RETARD"," RSLUR").replace(" rapist"," male feminist").replace(" Rapist"," Male feminist").replace(" RAPIST"," MALE FEMINIST").replace(" RETARD"," RSLUR").replace(" rapist"," male feminist").replace(" Rapist"," Male feminist").replace(" RAPIST"," MALE FEMINIST").replace(" kill yourself"," keep yourself safe").replace(" KILL YOURSELF"," KEEP YOURSELF SAFE") + if v and not v.oldreddit: body = body.replace("old.reddit.com", "reddit.com") + return body + + @property + def title_html(self): + return self.submission_aux.title_html + + @title_html.setter + def title_html(self, x): + self.submission_aux.title_html = x + g.db.add(self.submission_aux) + + def realtitle(self, v): + if self.title_html: title = self.title_html + else: title = self.title + if not v or v.slurreplacer: title = title.replace(" nigger"," 🏀").replace(" Nigger"," 🏀").replace(" NIGGER"," 🏀").replace(" pedo"," libertarian").replace(" Pedo"," Libertarian ").replace(" PEDO"," LIBERTARIAN ").replace(" tranny"," 🚄").replace(" Tranny"," 🚄").replace(" TRANNY"," 🚄").replace(" fag"," cute twink").replace(" Fag"," Cute twink").replace(" FAG"," CUTE TWINK").replace(" faggot"," cute twink").replace(" Faggot"," Cute twink").replace(" FAGGOT"," CUTE TWINK").replace(" trump"," DDR").replace(" Trump"," DDR").replace(" TRUMP"," DDR").replace(" biden"," DDD").replace(" Biden"," DDD").replace(" BIDEN"," DDD").replace(" steve akins"," penny verity oaken").replace(" Steve Akins"," Penny Verity Oaken").replace(" STEVE AKINS"," PENNY VERITY OAKEN").replace(" RETARD"," RSLUR").replace(" rapist"," male feminist").replace(" Rapist"," Male feminist").replace(" RAPIST"," MALE FEMINIST").replace(" RETARD"," RSLUR").replace(" rapist"," male feminist").replace(" Rapist"," Male feminist").replace(" RAPIST"," MALE FEMINIST").replace(" RETARD"," RSLUR").replace(" rapist"," male feminist").replace(" Rapist"," Male feminist").replace(" RAPIST"," MALE FEMINIST").replace(" kill yourself"," keep yourself safe").replace(" KILL YOURSELF"," KEEP YOURSELF SAFE") + return title + + @property + def ban_reason(self): + return self.submission_aux.ban_reason + + @ban_reason.setter + def ban_reason(self, x): + self.submission_aux.ban_reason = x + g.db.add(self.submission_aux) + + @property + def embed_url(self): + return self.submission_aux.embed_url + + @embed_url.setter + def embed_url(self, x): + self.submission_aux.embed_url = x + g.db.add(self.submission_aux) + + @property + def meta_title(self): + return self.submission_aux.meta_title + + @meta_title.setter + def meta_title(self, x): + self.submission_aux.meta_title=x + g.db.add(self.submission_aux) + + @property + def meta_description(self): + return self.submission_aux.meta_description + + @meta_description.setter + def meta_description(self, x): + self.submission_aux.meta_description=x + g.db.add(self.submission_aux) + + + def is_guildmaster(self, perm=None): + mod=self.__dict__.get('_is_guildmaster', False) + + if not mod: + return False + elif not perm: + return True + else: + return mod.perm_full or mod.__dict__[f"perm_{perm}"] + + return output + + @property + def is_blocking_guild(self): + return self.__dict__.get('_is_blocking_guild', False) + + @property + def is_blocked(self): + return self.__dict__.get('_is_blocked', False) + + @property + def is_blocking(self): + return self.__dict__.get('_is_blocking', False) + + @property + def is_subscribed(self): + return self.__dict__.get('_is_subscribed', False) + + @property + def is_public(self): + return self.post_public or not self.board.is_private + + @property + def flag_count(self): + return len(self.flags) + + @property + def report_count(self): + return len(self.reports) + + @property + def award_count(self): + return len(self.awards) + + @property + def embed_template(self): + return f"site_embeds/{self.domain_obj.embed_template}.html" + + @property + def flagged_by(self): + return [x.user for x in self.flags] + + @property + def is_image(self): + if self.url: return self.url.endswith('jpg') or self.url.endswith('png') or self.url.endswith('.gif') or self.url.endswith('jpeg') or self.url.endswith('?maxwidth=9999') or self.url.endswith('?maxwidth=8888') + else: return False + + @property + def self_download_json(self): + + #This property should never be served to anyone but author and admin + if not self.is_banned and self.deleted_utc == 0: + return self.json_core + + data= { + "title":self.title, + "author": self.author.username, + "url": self.url, + "body": self.body, + "body_html": self.body_html, + "is_banned": bool(self.is_banned), + "deleted_utc": self.deleted_utc, + 'created_utc': self.created_utc, + 'id': self.base36id, + 'fullname': self.fullname, + 'guild_name': self.board.name, + 'comment_count': self.comment_count, + 'permalink': self.permalink + } + + if self.original_board_id and (self.original_board_id!= self.board_id): + + data['original_guild_name'] = self.original_board.name + + return data + + @property + def json_admin(self): + + data=self.json_raw + + data["creation_ip"]=self.creation_ip + data["creation_region"]=self.creation_region + + return data + + @property + def is_exiled_for(self): + return self.__dict__.get('_is_exiled_for', None) + + + +class SaveRelationship(Base, Stndrd): + + __tablename__="save_relationship" + + id=Column(Integer, primary_key=true) + user_id=Column(Integer, ForeignKey("users.id")) + submission_id=Column(Integer, ForeignKey("submissions.id")) + type=Column(Integer) diff --git a/ruqqus/classes/subscriptions.py b/ruqqus/classes/subscriptions.py new file mode 100644 index 000000000..4dbf70af1 --- /dev/null +++ b/ruqqus/classes/subscriptions.py @@ -0,0 +1,52 @@ +from sqlalchemy import * +from sqlalchemy.orm import relationship +from ruqqus.__main__ import Base +import time + + +class Subscription(Base): + __tablename__ = "subscriptions" + id = Column(BigInteger, primary_key=True) + user_id = Column(BigInteger, ForeignKey("users.id")) + board_id = Column(BigInteger, ForeignKey("boards.id")) + created_utc = Column(BigInteger, default=0) + is_active = Column(Boolean, default=True) + submission_id = Column(BigInteger, default=0) + + user = relationship("User", uselist=False) + board = relationship("Board", uselist=False) + + 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"" + + +class Follow(Base): + __tablename__ = "follows" + id = Column(BigInteger, primary_key=True) + user_id = Column(BigInteger, ForeignKey("users.id")) + target_id = Column(BigInteger, ForeignKey("users.id")) + created_utc = Column(BigInteger, default=0) + + user = relationship( + "User", + uselist=False, + primaryjoin="User.id==Follow.user_id") + target = relationship( + "User", + lazy="joined", + primaryjoin="User.id==Follow.target_id") + + 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"" \ No newline at end of file diff --git a/ruqqus/classes/titles.py b/ruqqus/classes/titles.py new file mode 100644 index 000000000..9a265b0c7 --- /dev/null +++ b/ruqqus/classes/titles.py @@ -0,0 +1,37 @@ +from sqlalchemy import * +from flask import render_template +from ruqqus.__main__ import Base + +class Title(Base): + + __tablename__ = "titles" + id = Column(Integer, primary_key=True) + is_before = Column(Boolean, default=True) + text = Column(String(64)) + qualification_expr = Column(String(256)) + requirement_string = Column(String(512)) + color = Column(String(6), default="888888") + kind = Column(Integer, default=1) + + background_color_1 = Column(String(6), default=None) + background_color_2 = Column(String(6), default=None) + gradient_angle = Column(Integer, default=0) + box_shadow_color = Column(String(32), default=None) + text_shadow_color = Column(String(32), default=None) + + def check_eligibility(self, v): + + return bool(eval(self.qualification_expr, {}, {"v": v})) + + @property + def rendered(self): + return render_template('title.html', t=self) + + @property + def json(self): + + return {'id': self.id, + 'text': self.text, + 'color': f'#{self.color}', + 'kind': self.kind + } diff --git a/ruqqus/classes/user.py b/ruqqus/classes/user.py new file mode 100644 index 000000000..32452f04f --- /dev/null +++ b/ruqqus/classes/user.py @@ -0,0 +1,1002 @@ +from sqlalchemy.orm import deferred, contains_eager, aliased +from secrets import token_hex +import pyotp + +from ruqqus.helpers.discord import delete_role +from ruqqus.helpers.aws import * +from .alts import Alt +from .titles import Title +from .submission import SaveRelationship +from .comment import Notification +from .boards import Board +from .board_relationships import * +from .subscriptions import * +from .userblock import * +from .badges import * +from .clients import * +from .paypal import PayPalTxn +from ruqqus.__main__ import Base, cache +from ruqqus.helpers.security import * + +class User(Base, Stndrd, Age_times): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + username = Column(String, default=None) + namecolor = Column(String, default='ff66ac') + customtitle = Column(String, default=None) + customtitleplain = Column(String, default=None) + titlecolor = Column(String, default='ff66ac') + theme = Column(String, default='dark') + themecolor = Column(String, default='ff66ac') + song = Column(String, default=None) + profileurl = Column(String, default=None) + bannerurl = Column(String, default=None) + email = Column(String, default=None) + css = deferred(Column(String, default=None)) + profilecss = deferred(Column(String, default=None)) + passhash = deferred(Column(String, default=None)) + created_utc = Column(Integer, default=0) + admin_level = Column(Integer, default=0) + agendaposter = Column(Boolean, default=False) + agendaposter_expires_utc = Column(Integer, default=0) + changelogsub = Column(Boolean, default=False) + is_activated = Column(Boolean, default=False) + shadowbanned = Column(Boolean, default=False) + over_18 = Column(Boolean, default=False) + hidevotedon = Column(Boolean, default=False) + slurreplacer = Column(Boolean, default=True) + flairchanged = Column(Boolean, default=False) + newtab = Column(Boolean, default=False) + newtabexternal = Column(Boolean, default=True) + oldreddit = Column(Boolean, default=False) + creation_ip = Column(String, default=None) + submissions = relationship( + "Submission", + lazy="dynamic", + primaryjoin="Submission.author_id==User.id", + backref="author_rel") + comments = relationship( + "Comment", + lazy="dynamic", + primaryjoin="Comment.author_id==User.id") + votes = relationship("Vote", lazy="dynamic", backref="users") + commentvotes = relationship("CommentVote", lazy="dynamic", backref="users") + bio = Column(String, default="") + bio_html = Column(String, default="") + badges = relationship("Badge", lazy="dynamic", backref="user") + real_id = Column(String, default=None) + notifications = relationship( + "Notification", + lazy="dynamic") + + # unread_notifications_relationship=relationship( + # "Notification", + # primaryjoin="and_(Notification.user_id==User.id, Notification.read==False)") + + referred_by = Column(Integer, default=None) + is_banned = Column(Integer, default=0) + unban_utc = Column(Integer, default=0) + ban_reason = Column(String, default="") + feed_nonce = Column(Integer, default=0) + login_nonce = Column(Integer, default=0) + title_id = Column(Integer, ForeignKey("titles.id"), default=None) + title = relationship("Title", lazy="joined") + has_profile = Column(Boolean, default=False) + has_banner = Column(Boolean, default=False) + reserved = Column(String(256), default=None) + is_nsfw = Column(Boolean, default=False) + tos_agreed_utc = Column(Integer, default=0) + profile_nonce = Column(Integer, default=0) + banner_nonce = Column(Integer, default=0) + last_siege_utc = Column(Integer, default=0) + mfa_secret = deferred(Column(String(16), default=None)) + hide_offensive = Column(Boolean, default=False) + hide_bot = Column(Boolean, default=False) + show_nsfl = Column(Boolean, default=False) + is_private = Column(Boolean, default=False) + read_announcement_utc = Column(Integer, default=0) + unban_utc = Column(Integer, default=0) + + is_deleted = Column(Boolean, default=False) + delete_reason = Column(String(500), default='') + filter_nsfw = Column(Boolean, default=False) + stored_subscriber_count = Column(Integer, default=0) + defaultsortingcomments = Column(String, default="top") + defaultsorting = Column(String, default="hot") + defaulttime = Column(String, default="all") + coin_balance = Column(Integer, default=0) + premium_expires_utc = Column(Integer, default=0) + negative_balance_cents = Column(Integer, default=0) + + is_nofollow = Column(Boolean, default=False) + custom_filter_list = Column(String(1000), default="") + discord_id = Column(String(64), default=None) + creation_region = Column(String(2), default=None) + ban_evade = Column(Integer, default=0) + + profile_upload_ip = deferred(Column(String(255), default=None)) + banner_upload_ip = deferred(Column(String(255), default=None)) + profile_upload_region = deferred(Column(String(2))) + banner_upload_region = deferred(Column(String(2))) + + # stuff to support name changes + profile_set_utc = deferred(Column(Integer, default=0)) + banner_set_utc = deferred(Column(Integer, default=0)) + original_username = deferred(Column(String(255))) + name_changed_utc = deferred(Column(Integer, default=0)) + + moderates = relationship("ModRelationship") + banned_from = relationship("BanRelationship", primaryjoin="BanRelationship.user_id==User.id") + subscriptions = relationship("Subscription") + boards_created = relationship("Board", lazy="dynamic") + contributes = relationship( + "ContributorRelationship", + lazy="dynamic", + primaryjoin="ContributorRelationship.user_id==User.id") + board_blocks = relationship("BoardBlock", lazy="dynamic") + + following = relationship("Follow", primaryjoin="Follow.user_id==User.id") + followers = relationship("Follow", primaryjoin="Follow.target_id==User.id") + + blocking = relationship("UserBlock", lazy="dynamic", primaryjoin="User.id==UserBlock.user_id") + blocked = relationship("UserBlock", lazy="dynamic", primaryjoin="User.id==UserBlock.target_id") + + _applications = relationship("OauthApp", lazy="dynamic") + authorizations = relationship("ClientAuth", lazy="dynamic") + + saved_posts = relationship( + "SaveRelationship", + lazy="dynamic", + primaryjoin="User.id==SaveRelationship.user_id") + + _transactions = relationship( + "PayPalTxn", + lazy="dynamic", + primaryjoin="PayPalTxn.user_id==User.id") + + # properties defined as SQL server-side functions + referral_count = deferred(Column(Integer, server_default=FetchedValue())) + follower_count = deferred(Column(Integer, server_default=FetchedValue())) + + def __init__(self, **kwargs): + + if "password" in kwargs: + kwargs["passhash"] = self.hash_password(kwargs["password"]) + kwargs.pop("password") + + kwargs["created_utc"] = int(time.time()) + + super().__init__(**kwargs) + + @property + @lazy + def dramacoins(self): + posts = sum([x[0] - 1 for x in + g.db.query(Submission.score).options(lazyload('*')).filter_by(author_id=self.id, is_banned=False, + deleted_utc=0).all()]) + comments = sum([x[0] - 1 for x in + g.db.query(Comment.score).options(lazyload('*')).filter_by(author_id=self.id, is_banned=False, + deleted_utc=0).all()]) + return int(posts + comments) + + def has_block(self, target): + + return g.db.query(UserBlock).filter_by( + user_id=self.id, target_id=target.id).first() + + def is_blocked_by(self, user): + + return g.db.query(UserBlock).filter_by( + user_id=user.id, target_id=self.id).first() + + def any_block_exists(self, other): + + return g.db.query(UserBlock).filter( + or_(and_(UserBlock.user_id == self.id, UserBlock.target_id == other.id), and_( + UserBlock.user_id == other.id, UserBlock.target_id == self.id))).first() + + def has_blocked_guild(self, board): + + return g.db.query(BoardBlock).filter_by( + user_id=self.id, board_id=board.id).first() + + def validate_2fa(self, token): + + x = pyotp.TOTP(self.mfa_secret) + return x.verify(token, valid_window=1) + + @property + def age(self): + return int(time.time()) - self.created_utc + + @property + def strid(self): + return str(self.id) + + @cache.memoize(300) + def userpagelisting(self, v=None, page=1, sort="new", t="all"): + + submissions = g.db.query(Submission).options(lazyload('*')).filter_by(author_id=self.id, is_pinned=False) + + if not (v and (v.admin_level >= 3 or v.id == self.id)): + submissions = submissions.filter_by(deleted_utc=0) + submissions = submissions.filter_by(is_banned=False) + + now = int(time.time()) + if t == 'hour': + cutoff = now - 3600 + elif t == 'day': + cutoff = now - 86400 + elif t == 'week': + cutoff = now - 604800 + elif t == 'month': + cutoff = now - 2592000 + elif t == 'year': + cutoff = now - 31536000 + else: + cutoff = 0 + submissions = submissions.filter(Submission.created_utc >= cutoff) + + if sort == "new": + submissions = submissions.order_by(Submission.created_utc.desc()).all() + elif sort == "old": + submissions = submissions.order_by(Submission.created_utc.asc()).all() + elif sort == "controversial": + submissions = sorted(submissions.all(), key=lambda x: x.score_disputed, reverse=True) + elif sort == "top": + submissions = submissions.order_by(Submission.score.desc()).all() + elif sort == "bottom": + submissions = submissions.order_by(Submission.score.asc()).all() + elif sort == "comments": + submissions = submissions.order_by(Submission.comment_count.desc()).all() + + firstrange = 25 * (page - 1) + secondrange = firstrange + 26 + listing = [x.id for x in submissions[firstrange:secondrange]] + return listing + + @cache.memoize(300) + def commentlisting(self, v=None, page=1, sort="new", t="all"): + comments = self.comments.options(lazyload('*')).filter(Comment.parent_submission is not None).join(Comment.post) + + if (not v) or (v.id != self.id and v.admin_level == 0): + comments = comments.filter(Comment.deleted_utc == 0) + comments = comments.filter(Comment.is_banned == False) + + if v and v.admin_level >= 4: + pass + elif v: + m = g.db.query(ModRelationship).filter_by(user_id=v.id, invite_rescinded=False).subquery() + c = v.contributes.subquery() + + comments = comments.join(m, + m.c.board_id == Submission.board_id, + isouter=True + ).join(c, + c.c.board_id == Submission.board_id, + isouter=True + ).join(Board, Board.id == Submission.board_id) + comments = comments.filter(or_(Comment.author_id == v.id, + Submission.post_public == True, + Board.is_private == False, + m.c.board_id != None, + c.c.board_id != None)) + else: + comments = comments.join(Board, Board.id == Submission.board_id).filter( + or_(Submission.post_public == True, Board.is_private == False)) + + comments = comments.options(contains_eager(Comment.post)) + + now = int(time.time()) + if t == 'hour': + cutoff = now - 3600 + elif t == 'day': + cutoff = now - 86400 + elif t == 'week': + cutoff = now - 604800 + elif t == 'month': + cutoff = now - 2592000 + elif t == 'year': + cutoff = now - 31536000 + else: + cutoff = 0 + comments = comments.filter(Comment.created_utc >= cutoff) + + if sort == "new": + comments = comments.order_by(Comment.created_utc.desc()).all() + elif sort == "old": + comments = comments.order_by(Comment.created_utc.asc()).all() + elif sort == "controversial": + comments = sorted(comments.all(), key=lambda x: x.score_disputed, reverse=True) + elif sort == "top": + comments = comments.order_by(Comment.score.desc()).all() + elif sort == "bottom": + comments = comments.order_by(Comment.score.asc()).all() + + firstrange = 25 * (page - 1) + secondrange = firstrange + 26 + return [x.id for x in comments[firstrange:secondrange]] + + @property + @lazy + def mods_anything(self): + + return bool([i for i in self.moderates if i.accepted]) + + @property + def boards_modded(self): + + z = [x.board for x in self.moderates if x and x.board and x.accepted and not x.board.is_banned] + z = sorted(z, key=lambda x: x.name) + + return z + + @property + def base36id(self): + return base36encode(self.id) + + @property + def fullname(self): + return f"t1_{self.base36id}" + + @property + @cache.memoize(timeout=60) + def has_report_queue(self): + board_ids = [ + x.board_id for x in self.moderates.filter_by( + accepted=True).all()] + return bool(g.db.query(Submission).filter(Submission.board_id.in_( + board_ids), Submission.mod_approved == 0, Submission.is_banned == False).join(Submission.reports).first()) + + @property + def banned_by(self): + + if not self.is_banned: + return None + + return g.db.query(User).filter_by(id=self.is_banned).first() + + def has_badge(self, badgedef_id): + return self.badges.filter_by(badge_id=badgedef_id).first() + + @property + @lazy + def patron(self): + if self.has_badge(21) or self.has_badge(22) or self.has_badge(23) or self.has_badge(24): return True + else: return False + + def vote_status_on_post(self, post): + + return post.voted + + def vote_status_on_comment(self, comment): + + return comment.voted + + def hash_password(self, password): + return generate_password_hash( + password, method='pbkdf2:sha512', salt_length=8) + + def verifyPass(self, password): + return check_password_hash(self.passhash, password) + + @property + def feedkey(self): + + return generate_hash(f"{self.username}{self.id}{self.feed_nonce}{self.created_utc}") + + @property + def formkey(self): + + if "session_id" not in session: + session["session_id"] = token_hex(16) + + msg = f"{session['session_id']}+{self.id}+{self.login_nonce}" + + return generate_hash(msg) + + def validate_formkey(self, formkey): + + return validate_hash(f"{session['session_id']}+{self.id}+{self.login_nonce}", formkey) + + @property + def url(self): + return f"/@{self.username}" + + @property + def permalink(self): + return self.url + + @property + def uid_permalink(self): + return f"/uid/{self.base36id}" + + @property + def original_link(self): + return f"/@{self.original_username}" + + def __repr__(self): + return f"" + + @property + @lazy + def post_notifications_count(self): + return self.notifications.filter(Notification.read == False).join(Notification.comment).filter( + Comment.author_id == 2360).count() + + def notification_subscriptions(self, page=1, all_=False): + + notifications = self.notifications.join(Notification.comment).filter(Comment.author_id == 2360) + + notifications = notifications.options( + contains_eager(Notification.comment) + ) + + notifications = notifications.order_by(Notification.id.desc()).offset(25 * (page - 1)).limit(26) + + output = [] + for x in notifications: + x.read = True + g.db.add(x) + output.append(x.comment_id) + + g.db.commit() + return output + + def notification_commentlisting(self, page=1, all_=False): + + notifications = self.notifications.join(Notification.comment).filter( + Comment.is_banned == False, + Comment.deleted_utc == 0, + Comment.author_id != 2360, + ) + + if not all_: + notifications = notifications.filter(Notification.read == False) + + notifications = notifications.options( + contains_eager(Notification.comment) + ) + + notifications = notifications.order_by( + Notification.id.desc()).offset(25 * (page - 1)).limit(26) + + output = [] + for x in notifications: + x.read = True + g.db.add(x) + output.append(x.comment_id) + return output + + @property + @lazy + def notifications_count(self): + + return self.notifications.join(Notification.comment).filter(Notification.read == False, + Comment.is_banned == False, + Comment.deleted_utc == 0).count() + + @property + def post_count(self): + + return self.submissions.filter_by(is_banned=False, deleted_utc=0).count() + + @property + def comment_count(self): + + return self.comments.filter(Comment.parent_submission != None).filter_by(is_banned=False, deleted_utc=0).count() + + @property + @lazy + def alts(self): + + subq = g.db.query(Alt).filter( + or_( + Alt.user1 == self.id, + Alt.user2 == self.id + ) + ).subquery() + + data = g.db.query( + User, + aliased(Alt, alias=subq) + ).join( + subq, + or_( + subq.c.user1 == User.id, + subq.c.user2 == User.id + ) + ).filter( + User.id != self.id + ).order_by(User.username.asc()).all() + + data = [x for x in data] + output = [] + for x in data: + user = x[0] + user._is_manual = x[1].is_manual + output.append(user) + + return output + + def alts_subquery(self): + return g.db.query(User.id).filter( + or_( + User.id.in_( + g.db.query(Alt.user1).filter( + Alt.user2 == self.id + ).subquery() + ), + User.id.in_( + g.db.query(Alt.user2).filter( + Alt.user1 == self.id + ).subquery() + ).subquery() + ) + ).subquery() + + def alts_threaded(self, db): + + subq = db.query(Alt).filter( + or_( + Alt.user1 == self.id, + Alt.user2 == self.id + ) + ).subquery() + + data = db.query( + User, + aliased(Alt, alias=subq) + ).join( + subq, + or_( + subq.c.user1 == User.id, + subq.c.user2 == User.id + ) + ).filter( + User.id != self.id + ).order_by(User.username.asc()).all() + + data = [x for x in data] + output = [] + for x in data: + user = x[0] + user._is_manual = x[1].is_manual + output.append(user) + + return output + + def has_follower(self, user): + + return g.db.query(Follow).filter_by( + target_id=self.id, user_id=user.id).first() + + def set_profile(self, file): + + self.del_profile() + self.profile_nonce += 1 + + imageurl = upload_file(name=f"profile.gif", file=file, resize=(100, 100)) + if imageurl: + self.profileurl = imageurl + self.has_profile = True + self.profile_upload_ip = request.remote_addr + self.profile_set_utc = int(time.time()) + self.profile_upload_region = request.headers.get("cf-ipcountry") + g.db.add(self) + + def set_banner(self, file): + + self.del_banner() + self.banner_nonce += 1 + + imageurl = upload_file(name=f"banner.gif", file=file) + if imageurl: + self.bannerurl = imageurl + self.has_banner = True + self.banner_upload_ip = request.remote_addr + self.banner_set_utc = int(time.time()) + self.banner_upload_region = request.headers.get("cf-ipcountry") + g.db.add(self) + + def del_profile(self): + + self.has_profile = False + g.db.add(self) + + def del_banner(self): + + self.has_banner = False + g.db.add(self) + + @property + def banner_url(self): + if self.has_banner: + if self.bannerurl: + return self.bannerurl + else: + return f"https://s3.eu-central-1.amazonaws.com/i.ruqqus.ga/uid/{self.base36id}/banner-{self.banner_nonce}.png" + else: + return "/assets/images/default_bg.png" + + @cache.memoize(0) + def defaultpicture(self): + pic = random.randint(1, 50) + return f"/assets/images/defaultpictures/{pic}.png" + + @property + def profile_url(self): + if self.has_profile and not self.is_deleted: + if self.profileurl: + return self.profileurl + else: + return f"https://s3.eu-central-1.amazonaws.com/i.ruqqus.ga/uid/{self.base36id}/profile-{self.profile_nonce}.png" + else: + return self.defaultpicture() + + @property + def available_titles(self): + + locs = {"v": self, + "Board": Board, + "Submission": Submission + } + + titles = [ + i for i in g.db.query(Title).order_by( + text("id asc")).all() if eval( + i.qualification_expr, {}, locs)] + return titles + + @property + def can_make_guild(self): + return False + + # return (self.has_premium or self.dramacoins >= 250 or (self.created_utc <= 1592974538 and self.dramacoins >= 50)) and len([x for x in self.boards_modded if x.is_siegable]) < 10 + + @property + def can_join_gms(self): + return len([x for x in self.boards_modded if x.is_siegable]) < 10 + + @property + def can_siege(self): + + if self.is_suspended: + return False + + now = int(time.time()) + + return now - max(self.last_siege_utc, + self.created_utc) > 60 * 60 * 24 * 7 + + @property + def can_submit_image(self): + # Has premium + # Has 1000 Rep, or 500 for older accounts + # if connecting through Tor, must have verified email + return (self.has_premium or self.dramacoins >= 0 or ( + self.created_utc <= 1592974538 and self.dramacoins >= 500)) and ( + self.is_activated or request.headers.get("cf-ipcountry") != "T1") + + @property + def can_upload_avatar(self): + return (self.has_premium or self.dramacoins >= 0 or self.created_utc <= 1592974538) and ( + self.is_activated or request.headers.get("cf-ipcountry") != "T1") + + @property + def can_upload_banner(self): + return (self.has_premium or self.dramacoins >= 0 or self.created_utc <= 1592974538) and ( + self.is_activated or request.headers.get("cf-ipcountry") != "T1") + + @property + def json_raw(self): + data = {'username': self.username, + 'permalink': self.permalink, + 'is_banned': bool(self.is_banned), + 'is_premium': self.has_premium_no_renew, + 'created_utc': self.created_utc, + 'id': self.base36id, + 'is_private': self.is_private, + 'profile_url': self.profile_url, + 'banner_url': self.banner_url, + 'title': self.title.json if self.title else None, + 'bio': self.bio, + 'bio_html': self.bio_html, + 'flair': self.customtitle + } + + if self.real_id: + data['real_id'] = self.real_id + + return data + + @property + def json_core(self): + + now = int(time.time()) + if self.is_banned and (self.unban_utc == 0 or now < self.unban_utc): + return {'username': self.username, + 'permalink': self.permalink, + 'is_banned': True, + 'is_permanent_ban': not bool(self.unban_utc), + 'ban_reason': self.ban_reason, + 'id': self.base36id + } + + elif self.is_deleted: + return {'username': self.username, + 'permalink': self.permalink, + 'is_deleted': True, + 'id': self.base36id + } + return self.json_raw + + @property + def json(self): + data = self.json_core + + if self.is_deleted or self.is_banned: + return data + + data["badges"] = [x.json_core for x in self.badges] + data['dramacoins'] = int(self.dramacoins) + data['post_count'] = self.post_count + data['comment_count'] = self.comment_count + + return data + + @property + def can_use_darkmode(self): + return True + + # return self.referral_count or self.has_earned_darkmode or + # self.has_badge(16) or self.has_badge(17) + + @property + def is_valid(self): + if self.is_banned and self.unban_utc == 0: + return False + + elif self.is_deleted: + return False + + else: + return True + + def ban(self, admin=None, reason=None, days=0): + + if days > 0: + ban_time = int(time.time()) + (days * 86400) + self.unban_utc = ban_time + + else: + self.unban_utc = 0 + if self.has_banner: + self.del_banner() + if self.has_profile: + self.del_profile() + + delete_role(self, "linked") + + self.is_banned = admin.id if admin else 2317 + if reason: + self.ban_reason = reason + + try: + g.db.add(self) + except: + pass + + def unban(self): + + # Takes care of all functions needed for account reinstatement. + + self.is_banned = None + self.unban_utc = None + + g.db.add(self) + + @property + def is_suspended(self): + return (self.is_banned and (self.unban_utc == + 0 or self.unban_utc > time.time())) + + @property + def is_blocking(self): + return self.__dict__.get('_is_blocking', 0) + + @property + def is_blocked(self): + return self.__dict__.get('_is_blocked', 0) + + def refresh_selfset_badges(self): + + # check self-setting badges + badge_types = g.db.query(BadgeDef).filter( + BadgeDef.qualification_expr.isnot(None)).all() + for badge in badge_types: + if eval(badge.qualification_expr, {}, {'v': self}): + if not self.has_badge(badge.id): + new_badge = Badge(user_id=self.id, + badge_id=badge.id, + created_utc=int(time.time()) + ) + g.db.add(new_badge) + + else: + bad_badge = self.has_badge(badge.id) + if bad_badge: + g.db.delete(bad_badge) + + try: + g.db.add(self) + except: + pass + + @property + def applications(self): + return [x for x in self._applications.order_by( + OauthApp.id.asc()).all()] + + def subscribed_idlist(self, page=1): + posts = g.db.query(Subscription.submission_id).filter_by(user_id=self.id).all() + return [x[0] for x in posts] + + def saved_idlist(self, page=1): + + posts = g.db.query(Submission.id).options(lazyload('*')).filter_by(is_banned=False, + deleted_utc=0 + ) + + saved = g.db.query(SaveRelationship.submission_id).filter(SaveRelationship.user_id == self.id).subquery() + posts = posts.filter(Submission.id.in_(saved)) + + if self.admin_level == 0: + blocking = g.db.query( + UserBlock.target_id).filter_by( + user_id=self.id).subquery() + blocked = g.db.query( + UserBlock.user_id).filter_by( + target_id=self.id).subquery() + + posts = posts.filter( + Submission.author_id.notin_(blocking), + Submission.author_id.notin_(blocked) + ) + + posts = posts.order_by(Submission.created_utc.desc()) + + return [x[0] for x in posts.offset(25 * (page - 1)).limit(26).all()] + + def saved_comment_idlist(self, page=1): + + comments = g.db.query(Comment.id).options(lazyload('*')).filter_by(is_banned=False, deleted_utc=0) + + saved = g.db.query(SaveRelationship.submission_id).filter(SaveRelationship.user_id == self.id).subquery() + comments = comments.filter(Comment.id.in_(saved)) + + if self.admin_level == 0: + blocking = g.db.query( + UserBlock.target_id).filter_by( + user_id=self.id).subquery() + blocked = g.db.query( + UserBlock.user_id).filter_by( + target_id=self.id).subquery() + + comments = comments.filter( + Comment.author_id.notin_(blocking), + Comment.author_id.notin_(blocked) + ) + + comments = comments.order_by(Comment.created_utc.desc()) + + return [x[0] for x in comments.offset(25 * (page - 1)).limit(26).all()] + + def guild_rep(self, guild, recent=0): + + posts = g.db.query(Submission.score).filter_by( + is_banned=False, + original_board_id=guild.id) + + if recent: + cutoff = int(time.time()) - 60 * 60 * 24 * recent + posts = posts.filter(Submission.created_utc > cutoff) + + posts = posts.all() + + post_rep = sum([x[0] for x in posts]) - len(list(sum([x[0] for x in posts]))) + + comments = g.db.query(Comment.score).filter_by( + is_banned=False, + original_board_id=guild.id) + + if recent: + cutoff = int(time.time()) - 60 * 60 * 24 * recent + comments = comments.filter(Comment.created_utc > cutoff) + + comments = comments.all() + + comment_rep = sum([x[0] for x in comments]) - len(list(sum([x[0] for x in comments]))) + + return int(post_rep + comment_rep) + + @property + def has_premium(self): + + now = int(time.time()) + + if self.negative_balance_cents: + return False + + elif self.premium_expires_utc > now: + return True + + elif self.coin_balance >= 1: + self.coin_balance -= 1 + self.premium_expires_utc = now + 60 * 60 * 24 * 7 + + g.db.add(self) + + return True + + else: + + if self.premium_expires_utc: + self.premium_expires_utc = 0 + g.db.add(self) + + return False + + @property + def has_premium_no_renew(self): + + now = int(time.time()) + + if self.negative_balance_cents: + return False + elif self.premium_expires_utc > now: + return True + elif self.coin_balance >= 1: + return True + else: + return False + + @property + def renew_premium_time(self): + return time.strftime("%d %b %Y at %H:%M:%S", + time.gmtime(self.premium_expires_utc)) + + @property + def filter_words(self): + l = [i.strip() for i in self.custom_filter_list.split('\n')] if self.custom_filter_list else [] + l = [i for i in l if i] + return l + + @property + def boards_modded_ids(self): + return [x.id for x in self.boards_modded] + + @property + def txn_history(self): + + return self._transactions.filter(PayPalTxn.status != 1).order_by(PayPalTxn.created_utc.desc()).all() + + @property + def json_admin(self): + data = self.json_raw + + data['creation_ip'] = self.creation_ip + data['creation_region'] = self.creation_region + data['email'] = self.email + data['email_verified'] = self.is_activated + + return data + + @property + def can_upload_comment_image(self): + return self.dramacoins >= 0 and (request.headers.get("cf-ipcountry") != "T1" or self.is_activated) + + @property + def can_change_name(self): + return True +# return self.name_changed_utc < int(time.time())-60*60*24*90 diff --git a/ruqqus/classes/userblock.py b/ruqqus/classes/userblock.py new file mode 100644 index 000000000..de01ec89c --- /dev/null +++ b/ruqqus/classes/userblock.py @@ -0,0 +1,25 @@ +from sqlalchemy import * +from sqlalchemy.orm import relationship +from .mix_ins import * +from ruqqus.__main__ import Base + +class UserBlock(Base, Stndrd, Age_times): + + __tablename__ = "userblocks" + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id")) + target_id = Column(Integer, ForeignKey("users.id")) + created_utc = Column(Integer) + + user = relationship( + "User", + innerjoin=True, + primaryjoin="User.id==UserBlock.user_id") + target = relationship( + "User", + innerjoin=True, + primaryjoin="User.id==UserBlock.target_id") + + def __repr__(self): + + return f"" diff --git a/ruqqus/classes/votes.py b/ruqqus/classes/votes.py new file mode 100644 index 000000000..b1aa34b3e --- /dev/null +++ b/ruqqus/classes/votes.py @@ -0,0 +1,128 @@ +from flask import * +from time import time +from sqlalchemy import * +from sqlalchemy.orm import relationship +from ruqqus.helpers.base36 import * +from ruqqus.__main__ import Base + +class Vote(Base): + + __tablename__ = "votes" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id")) + vote_type = Column(Integer) + submission_id = Column(Integer, ForeignKey("submissions.id")) + created_utc = Column(Integer, default=0) + creation_ip = Column(String, default=None) + app_id = Column(Integer, ForeignKey("oauth_apps.id"), default=None) + + user = relationship("User", lazy="subquery") + post = relationship("Submission", lazy="subquery") + + def __init__(self, *args, **kwargs): + + if "created_utc" not in kwargs: + kwargs["created_utc"] = int(time()) + + kwargs["creation_ip"]=request.remote_addr + + super().__init__(*args, **kwargs) + + def __repr__(self): + return f"" + + def change_to(self, x): + """ + 1 - upvote + 0 - novote + -1 - downvote + """ + if x in ["-1", "0", "1"]: + x = int(x) + elif x not in [-1, 0, 1]: + abort(400) + + self.vote_type = x + self.created_utc = int(time()) + + g.db.add(self) + + @property + def json_core(self): + data={ + "user_id": self.user_id, + "submission_id":self.submission_id, + "created_utc": self.created_utc, + "vote_type":self.vote_type + } + return data + + @property + def json(self): + data=self.json_core + data["user"]=self.user.json_core + data["post"]=self.post.json_core + + return data + + +class CommentVote(Base): + + __tablename__ = "commentvotes" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id")) + vote_type = Column(Integer) + comment_id = Column(Integer, ForeignKey("comments.id")) + created_utc = Column(Integer, default=0) + creation_ip = Column(String, default=None) + app_id = Column(Integer, ForeignKey("oauth_apps.id"), default=None) + + user = relationship("User", lazy="subquery") + comment = relationship("Comment", lazy="subquery") + + def __init__(self, *args, **kwargs): + if "created_utc" not in kwargs: + kwargs["created_utc"] = int(time()) + + kwargs["creation_ip"]=request.remote_addr + + super().__init__(*args, **kwargs) + + def __repr__(self): + return f"" + + def change_to(self, x): + """ + 1 - upvote + 0 - novote + -1 - downvote + """ + if x in ["-1", "0", "1"]: + x = int(x) + elif x not in [-1, 0, 1]: + abort(400) + + self.vote_type = x + self.created_utc = int(time()) + + g.db.add(self) + + @property + def json_core(self): + data={ + "user_id": self.user_id, + "comment_id":self.comment_id, + "created_utc": self.created_utc, + "vote_type":self.vote_type + } + return data + + @property + def json(self): + data=self.json_core + data["user"]=self.user.json_core + data["comment"]=self.comment.json_core + + return data \ No newline at end of file diff --git a/ruqqus/helpers/alerts.py b/ruqqus/helpers/alerts.py new file mode 100644 index 000000000..570d53499 --- /dev/null +++ b/ruqqus/helpers/alerts.py @@ -0,0 +1,180 @@ +import mistletoe + +from ruqqus.classes import * +from flask import g +from .markdown import * +from .sanitize import * + + +def send_notification(vid, user, text): + + text = text.replace('r/', 'r\/').replace('u/', 'u\/') + with CustomRenderer() as renderer: + text_html = renderer.render(mistletoe.Document(text)) + + text_html = sanitize(text_html, linkgen=True) + + new_comment = Comment(author_id=vid, + parent_submission=None, + distinguish_level=6, + ) + g.db.add(new_comment) + + g.db.flush() + + new_aux = CommentAux(id=new_comment.id, + body=text, + body_html=text_html, + ) + g.db.add(new_aux) + + notif = Notification(comment_id=new_comment.id, + user_id=user.id) + g.db.add(notif) + g.db.commit() + + +def send_pm(vid, user, text): + + with CustomRenderer() as renderer: text_html = renderer.render(mistletoe.Document(text)) + + text_html = sanitize(text_html, linkgen=True) + + new_comment = Comment(author_id=vid, + parent_submission=None, + level=1, + sentto=user.id + ) + g.db.add(new_comment) + + g.db.flush() + + new_aux = CommentAux(id=new_comment.id, body=text, body_html=text_html) + g.db.add(new_aux) + + notif = Notification(comment_id=new_comment.id, user_id=user.id) + g.db.add(notif) + g.db.commit() + + +def send_follow_notif(vid, user, text): + + with CustomRenderer() as renderer: + text_html = renderer.render(mistletoe.Document(text)) + text_html = sanitize(text_html, linkgen=True) + + new_comment = Comment(author_id=1046, + parent_submission=None, + distinguish_level=6, + ) + g.db.add(new_comment) + g.db.flush() + + new_aux = CommentAux(id=new_comment.id, + body=text, + body_html=text_html, + ) + g.db.add(new_aux) + + notif = Notification(comment_id=new_comment.id, + user_id=user, + followsender=vid) + g.db.add(notif) + g.db.commit() + +def send_unfollow_notif(vid, user, text): + + with CustomRenderer() as renderer: + text_html = renderer.render(mistletoe.Document(text)) + text_html = sanitize(text_html, linkgen=True) + + new_comment = Comment(author_id=1046, + parent_submission=None, + distinguish_level=6, + ) + g.db.add(new_comment) + g.db.flush() + + new_aux = CommentAux(id=new_comment.id, + body=text, + body_html=text_html, + ) + g.db.add(new_aux) + + notif = Notification(comment_id=new_comment.id, + user_id=user, + unfollowsender=vid) + g.db.add(notif) + g.db.commit() + +def send_block_notif(vid, user, text): + + with CustomRenderer() as renderer: + text_html = renderer.render(mistletoe.Document(text)) + text_html = sanitize(text_html, linkgen=True) + + new_comment = Comment(author_id=1046, + parent_submission=None, + distinguish_level=6, + ) + g.db.add(new_comment) + g.db.flush() + + new_aux = CommentAux(id=new_comment.id, + body=text, + body_html=text_html, + ) + g.db.add(new_aux) + + notif = Notification(comment_id=new_comment.id, + user_id=user, + blocksender=vid) + g.db.add(notif) + g.db.commit() + +def send_unblock_notif(vid, user, text): + + with CustomRenderer() as renderer: + text_html = renderer.render(mistletoe.Document(text)) + text_html = sanitize(text_html, linkgen=True) + + new_comment = Comment(author_id=1046, + parent_submission=None, + distinguish_level=6, + ) + g.db.add(new_comment) + g.db.flush() + + new_aux = CommentAux(id=new_comment.id, + body=text, + body_html=text_html, + ) + g.db.add(new_aux) + + notif = Notification(comment_id=new_comment.id, + user_id=user, + unblocksender=vid) + g.db.add(notif) + g.db.commit() + +def send_admin(vid, text): + + with CustomRenderer() as renderer: text_html = renderer.render(mistletoe.Document(text)) + + text_html = sanitize(text_html, linkgen=True) + + new_comment = Comment(author_id=vid, + parent_submission=None, + level=1, + sentto=0 + ) + g.db.add(new_comment) + g.db.flush() + new_aux = CommentAux(id=new_comment.id, body=text, body_html=text_html) + g.db.add(new_aux) + + admins = g.db.query(User).filter(User.admin_level > 0).all() + for admin in admins: + notif = Notification(comment_id=new_comment.id, user_id=admin.id) + g.db.add(notif) + g.db.commit() diff --git a/ruqqus/helpers/aws.py b/ruqqus/helpers/aws.py new file mode 100644 index 000000000..4c35aa575 --- /dev/null +++ b/ruqqus/helpers/aws.py @@ -0,0 +1,309 @@ +import requests +from os import environ +import piexif +import time +from urllib.parse import urlparse +from PIL import Image as IImage +import imagehash +from os import remove +import base64 +import io +from ruqqus.classes.images import * +from ruqqus.__main__ import db_session +from .base36 import hex2bin + +CF_KEY = environ.get("CLOUDFLARE_KEY").strip() +CF_ZONE = environ.get("CLOUDFLARE_ZONE").strip() +imgurkey = environ.get("imgurkey").strip() + +def check_phash(db, name): + + return db.query(BadPic).filter( + func.levenshtein( + BadPic.phash, + hex2bin(str(imagehash.phash(IImage.open(name)))) + ) < 10 + ).first() + + +def upload_from_url(name, url): + + print('upload from url') + + x = requests.get(url) + + print('got content') + + tempname = name.replace("/", "_") + + with open(tempname, "wb") as file: + for chunk in x.iter_content(1024): + file.write(chunk) + + if tempname.split('.')[-1] in ['jpg', 'jpeg']: + piexif.remove(tempname) + + upload_file(tempname, + Key=name, + ExtraArgs={'ACL': 'public-read', + "ContentType": "image/png", + "StorageClass": "INTELLIGENT_TIERING" + } + ) + + remove(tempname) + + +def crop_and_resize(img, resize): + + i = img + + # get constraining dimension + org_ratio = i.width / i.height + new_ratio = resize[0] / resize[1] + + if new_ratio > org_ratio: + crop_height = int(i.width / new_ratio) + box = (0, (i.height // 2) - (crop_height // 2), + i.width, (i.height // 2) + (crop_height // 2)) + else: + crop_width = int(new_ratio * i.height) + box = ((i.width // 2) - (crop_width // 2), 0, + (i.width // 2) + (crop_width // 2), i.height) + + return i.resize(resize, box=box) + + +def upload_file(name, file, resize=None): + + if resize: + tempname = name.replace("/", "_") + + print(type(file)) + file.save(tempname) + + if tempname.split('.')[-1] in ['jpg', 'jpeg']: + piexif.remove(tempname) + + i = IImage.open(tempname) + i = crop_and_resize(i, resize) + img = io.BytesIO() + i.save(img, format='PNG') + req = requests.post('https://api.imgur.com/3/upload.json', headers = {"Authorization": f"Client-ID {imgurkey}"}, data = {'image': base64.b64encode(img.getvalue())}) + try: resp = req.json()['data'] + except Exception as e: + print(e) + print(req) + print(req.text) + return + remove(tempname) + else: + req = requests.post('https://api.imgur.com/3/upload.json', headers = {"Authorization": f"Client-ID {imgurkey}"}, data = {'image': base64.b64encode(file.read())}) + try: resp = req.json()['data'] + except Exception as e: + print(e) + print(req) + print(req.text) + return + try: url = resp['link'].replace(".png", "_d.png").replace(".jpg", "_d.jpg").replace(".jpeg", "_d.jpeg") + "?maxwidth=9999" + except Exception as e: + print(e) + print(req) + print(req.text) + return + + new_image = Image( + text=url, + deletehash=resp["deletehash"], + ) + + g.db.add(new_image) + return(url) + + +def upload_from_file(name, filename, resize=None): + + tempname = name.replace("/", "_") + + if filename.split('.')[-1] in ['jpg', 'jpeg']: + piexif.remove(tempname) + + i = IImage.open(tempname) + if resize: i = crop_and_resize(i, resize) + img = io.BytesIO() + i.save(img, format='PNG') + try: + req = requests.post('https://api.imgur.com/3/upload.json', headers = {"Authorization": f"Client-ID {imgurkey}"}, data = {'image': base64.b64encode(img.getvalue())}) + resp = req.json()['data'] + remove(filename) + url = resp['link'].replace(".png", "_d.png").replace(".jpg", "_d.jpg").replace(".jpeg", "_d.jpeg") + "?maxwidth=9999" + except Exception as e: + print(e) + print(req) + print(req.text) + return + + new_image = Image( + text=url, + deletehash=resp["deletehash"], + ) + + g.db.add(new_image) + return(url) + +def delete_file(name): + + image = g.db.query(Image).filter(Image.text == name).first() + if image: + requests.delete(f'https://api.imgur.com/3/image/{image.deletehash}', headers = {"Authorization": f"Client-ID {imgurkey}"}) + headers = {"Authorization": f"Bearer {CF_KEY}", "Content-Type": "application/json"} + data = {'files': [name]} + url = f"https://api.cloudflare.com/client/v4/zones/{CF_ZONE}/purge_cache" + requests.post(url, headers=headers, json=data) + + +def check_csam(post): + + # Relies on Cloudflare's photodna implementation + # 451 returned by CF = positive match + + # ignore non-link posts + if not post.url: + return + + parsed_url = urlparse(post.url) + + headers = {"User-Agent": "Drama webserver"} + for i in range(10): + x = requests.get(post.url, headers=headers) + + if x.status_code in [200, 451]: + break + else: + time.sleep(20) + + db=db_session() + + if x.status_code == 451: + + # ban user and alts + post.author.ban_reason="Sexualizing Minors" + post.author.is_banned=1 + db.add(v) + for alt in post.author.alts_threaded(db): + alt.ban_reason="Sexualizing Minors" + alt.is_banned=1 + db.add(alt) + + # remove content + post.is_banned = True + db.add(post) + + db.commit() + + # nuke aws + delete_file(parsed_url.path.lstrip('/')) + db.close() + return + + #check phash + tempname = f"test_post_{post.base36id}" + + with open(tempname, "wb") as file: + for chunk in x.iter_content(1024): + file.write(chunk) + + h=check_phash(db, tempname) + if h: + + now=int(time.time()) + unban=now+60*60*24*h.ban_time if h.ban_time else 0 + # ban user and alts + post.author.ban_reason=h.ban_reason + post.author.is_banned=1 + post.author.unban_utc = unban + db.add(v) + for alt in post.author.alts_threaded(db): + alt.ban_reason=h.ban_reason + alt.is_banned=1 + alt.unban_utc = unban + db.add(alt) + + # remove content + post.is_banned = True + db.add(post) + + db.commit() + + # nuke aws + delete_file(parsed_url.path.lstrip('/')) + + remove(tempname) + db.close() + + + + +def check_csam_url(url, v, delete_content_function): + + parsed_url = urlparse(url) + + headers = {"User-Agent": "Drama webserver"} + for i in range(10): + x = requests.get(url, headers=headers) + + if x.status_code in [200, 451]: + break + else: + time.sleep(20) + + db=db_session() + + if x.status_code == 451: + v.ban_reason="Sexualizing Minors" + v.is_banned=1 + db.add(v) + for alt in v.alts_threaded(db): + alt.ban_reason="Sexualizing Minors" + alt.is_banned=1 + db.add(alt) + + delete_content_function() + + db.commit() + db.close() + delete_file(parsed_url.path.lstrip('/')) + return + + tempname=f"test_from_url_{parsed_url.path}" + tempname=tempname.replace('/','_') + + with open(tempname, "wb") as file: + for chunk in x.iter_content(1024): + file.write(chunk) + + h=check_phash(db, tempname) + if h: + + now=int(time.time()) + unban=now+60*60*24*h.ban_time if h.ban_time else 0 + # ban user and alts + v.ban_reason=h.ban_reason + v.is_banned=1 + v.unban_utc = unban + db.add(v) + for alt in v.alts_threaded(db): + alt.ban_reason=h.ban_reason + alt.is_banned=1 + alt.unban_utc = unban + db.add(alt) + + delete_content_function() + + db.commit() + + # nuke aws + delete_file(parsed_url.path.lstrip('/')) + + remove(tempname) + db.close() \ No newline at end of file diff --git a/ruqqus/helpers/base36.py b/ruqqus/helpers/base36.py new file mode 100644 index 000000000..e796df637 --- /dev/null +++ b/ruqqus/helpers/base36.py @@ -0,0 +1,68 @@ +from flask import abort + +def base36encode(number, alphabet='0123456789abcdefghijklmnopqrstuvwxyz'): + """Converts an integer to a base36 string.""" + if not isinstance(number, int): + raise TypeError('number must be an integer') + + base36 = '' + sign = '' + + if number < 0: + sign = '-' + number = -number + + if 0 <= number < len(alphabet): + return sign + alphabet[number] + + while number != 0: + number, i = divmod(number, len(alphabet)) + base36 = alphabet[i] + base36 + + return sign + base36 + + +def base36decode(number): + try: + return int(str(number), 36) + except ValueError: + abort(400) + + +def base_encode(number, base): + + alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'[0:base] + + output = '' + sign = '' + + if number < 0: + sign = '-' + number = -number + + if 0 <= number < len(alphabet): + return sign + alphabet[number] + + while number != 0: + number, i = divmod(number, len(alphabet)) + output = alphabet[i] + output + + return sign + output + +#got this one from stackoverflow +def hex2bin(hexstr): + value = int(hexstr, 16) + bindigits = [] + + # Seed digit: 2**0 + digit = (value % 2) + value //= 2 + bindigits.append(digit) + + while value > 0: + # Next power of 2**n + digit = (value % 2) + value //= 2 + bindigits.append(digit) + + return ''.join([str(d) for d in bindigits]) \ No newline at end of file diff --git a/ruqqus/helpers/discord.py b/ruqqus/helpers/discord.py new file mode 100644 index 000000000..b96fa6dd1 --- /dev/null +++ b/ruqqus/helpers/discord.py @@ -0,0 +1,67 @@ +from os import environ +import requests +import threading + +SERVER_ID = environ.get("DISCORD_SERVER_ID",'').strip() +CLIENT_ID = environ.get("DISCORD_CLIENT_ID",'').strip() +CLIENT_SECRET = environ.get("DISCORD_CLIENT_SECRET",'').strip() +BOT_TOKEN = environ.get("DISCORD_BOT_TOKEN",'').strip() +AUTH = environ.get("DISCORD_AUTH",'').strip() + +ROLES={ + "linked": "849621030926286920", + "admin": "846509661288267776", + "feedback": "850716291714383883", + "newuser": "854783259229421589", + "norep": "850971811918512208", + } + +def discord_wrap(f): + + def wrapper(*args, **kwargs): + + user=args[0] + if not user.discord_id: + return + + + thread=threading.Thread(target=f, args=args, kwargs=kwargs) + thread.start() + + wrapper.__name__=f.__name__ + return wrapper + + + +@discord_wrap +def add_role(user, role_name): + role_id = ROLES[role_name] + url = f"https://discordapp.com/api/guilds/{SERVER_ID}/members/{user.discord_id}/roles/{role_id}" + headers = {"Authorization": f"Bot {BOT_TOKEN}"} + requests.put(url, headers=headers) + +@discord_wrap +def delete_role(user, role_name): + role_id = ROLES[role_name] + url = f"https://discordapp.com/api/guilds/{SERVER_ID}/members/{user.discord_id}/roles/{role_id}" + headers = {"Authorization": f"Bot {BOT_TOKEN}"} + requests.delete(url, headers=headers) + +@discord_wrap +def remove_user(user): + url=f"https://discordapp.com/api/guilds/{SERVER_ID}/members/{user.discord_id}" + headers = {"Authorization": f"Bot {BOT_TOKEN}"} + requests.delete(url, headers=headers) + +@discord_wrap +def set_nick(user, nick): + url=f"https://discordapp.com/api/guilds/{SERVER_ID}/members/{user.discord_id}" + headers = {"Authorization": f"Bot {BOT_TOKEN}"} + data={"nick": nick} + requests.patch(url, headers=headers, json=data) + +def send_message(message): + url=f"https://discordapp.com/api/channels/850266802449678366/messages" + headers = {"Authorization": f"Bot {BOT_TOKEN}"} + data={"content": message} + requests.post(url, headers=headers, data=data) \ No newline at end of file diff --git a/ruqqus/helpers/embed.py b/ruqqus/helpers/embed.py new file mode 100644 index 000000000..a030a9d11 --- /dev/null +++ b/ruqqus/helpers/embed.py @@ -0,0 +1,80 @@ +import re +from urllib.parse import * +import requests +from os import environ +from ruqqus.__main__ import app + +youtube_regex = re.compile("^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|shorts\/|\&v=)([^#\&\?]*).*") + +ruqqus_regex = re.compile("^.*rdrama.net/post/+\w+/(\w+)(/\w+/(\w+))?") + +twitter_regex=re.compile("/status/(\d+)") + +FACEBOOK_TOKEN=environ.get("FACEBOOK_TOKEN","").strip() + + + +def youtube_embed(url): + + try: + yt_id = re.match(youtube_regex, url).group(2) + except AttributeError: + return "error" + + if not yt_id or len(yt_id) != 11: + return "error" + + x = urlparse(url) + params = parse_qs(x.query) + t = params.get('t', params.get('start', [0]))[0] + if t: + return f"https://youtube.com/embed/{yt_id}?start={t}" + else: + return f"https://youtube.com/embed/{yt_id}" + + +def ruqqus_embed(url): + + matches = re.match(ruqqus_regex, url) + + post_id = matches.group(1) + comment_id = matches.group(3) + + if comment_id: + return f"https://{app.config['SERVER_NAME']}/embed/comment/{comment_id}" + else: + return f"https://{app.config['SERVER_NAME']}/embed/post/{post_id}" + + +def bitchute_embed(url): + + return url.replace("/video/", "/embed/") + +def twitter_embed(url): + + + oembed_url=f"https://publish.twitter.com/oembed" + params={ + "url":url, + "omit_script":"t" + } + x=requests.get(oembed_url, params=params) + + return x.json()["html"] + +def instagram_embed(url): + + oembed_url=f"https://graph.facebook.com/v9.0/instagram_oembed" + params={ + "url":url, + "access_token":FACEBOOK_TOKEN, + "omitscript":'true' + } + + headers={ + "User-Agent":"Instagram embedder for Drama" + } + + x=requests.get(oembed_url, params=params, headers=headers) + + return x.json()["html"] \ No newline at end of file diff --git a/ruqqus/helpers/filters.py b/ruqqus/helpers/filters.py new file mode 100644 index 000000000..c69cdc0cc --- /dev/null +++ b/ruqqus/helpers/filters.py @@ -0,0 +1,41 @@ +from bs4 import BeautifulSoup +from flask import * +from urllib.parse import urlparse +from ruqqus.classes import Domain + +def filter_comment_html(html_text): + + soup = BeautifulSoup(html_text, features="html.parser") + + links = soup.find_all("a") + + domain_list = set() + + for link in links: + + href=link.get("href", None) + if not href: + continue + + domain = urlparse(href).netloc + + # parse domain into all possible subdomains + parts = domain.split(".") + for i in range(len(parts)): + new_domain = parts[i] + for j in range(i + 1, len(parts)): + new_domain += "." + parts[j] + + domain_list.add(new_domain) + + # search db for domain rules that prohibit commenting + bans = [ + x for x in g.db.query(Domain).filter_by( + can_comment=False).filter( + Domain.domain.in_( + list(domain_list))).all()] + + if bans: + return bans + else: + return [] diff --git a/ruqqus/helpers/get.py b/ruqqus/helpers/get.py new file mode 100644 index 000000000..1abdca1c9 --- /dev/null +++ b/ruqqus/helpers/get.py @@ -0,0 +1,758 @@ +from ruqqus.classes import * +from flask import g +from sqlalchemy.orm import joinedload, aliased + +import re + + +def get_user(username, v=None, nSession=None, graceful=False): + + username = username.replace('\\', '') + username = username.replace('_', '\_') + username = username.replace('%', '') + + if not nSession: + nSession = g.db + + user = nSession.query( + User + ).filter( + or_( + User.username.ilike(username), + User.original_username.ilike(username) + ) + ).first() + + if not user: + if not graceful: + abort(404) + else: + return None + + if v: + block = nSession.query(UserBlock).filter( + or_( + and_( + UserBlock.user_id == v.id, + UserBlock.target_id == user.id + ), + and_(UserBlock.user_id == user.id, + UserBlock.target_id == v.id + ) + ) + ).first() + + user._is_blocking = block and block.user_id == v.id + user._is_blocked = block and block.target_id == v.id + + return user + +def get_account(base36id, v=None, nSession=None, graceful=False): + + if not nSession: + nSession = g.db + + if isinstance(base36id, str): id = base36decode(base36id) + else: id = base36id + + user = nSession.query(User + ).filter( + User.id == id + ).first() + + if not user: + if not graceful: + abort(404) + else: + return None + + if v: + block = nSession.query(UserBlock).filter( + or_( + and_( + UserBlock.user_id == v.id, + UserBlock.target_id == user.id + ), + and_(UserBlock.user_id == user.id, + UserBlock.target_id == v.id + ) + ) + ).first() + + user._is_blocking = block and block.user_id == v.id + user._is_blocked = block and block.target_id == v.id + + return user + + +def get_post(pid, v=None, graceful=False, nSession=None, **kwargs): + + if isinstance(pid, str): + i = base36decode(pid) + else: + i = pid + + nSession = nSession or kwargs.get("session")or g.db + + if v: + vt = nSession.query(Vote).filter_by( + user_id=v.id, submission_id=i).subquery() + mod = nSession.query(ModRelationship).filter_by( + user_id=v.id, accepted=True, invite_rescinded=False).subquery() + boardblocks = nSession.query( + BoardBlock).filter_by(user_id=v.id).subquery() + blocking = v.blocking.subquery() + + items = nSession.query( + Submission, + vt.c.vote_type, + aliased(ModRelationship, alias=mod), + boardblocks.c.id, + blocking.c.id, + # aliased(ModAction, alias=exile) + ).options( + joinedload(Submission.author).joinedload(User.title) + ) + + if v.admin_level>=4: + items=items.options(joinedload(Submission.oauth_app)) + + items=items.filter(Submission.id == i + ).join( + vt, + vt.c.submission_id == Submission.id, + isouter=True + ).join( + mod, + mod.c.board_id == Submission.board_id, + isouter=True + ).join( + boardblocks, + boardblocks.c.board_id == Submission.board_id, + isouter=True + ).join( + blocking, + blocking.c.target_id == Submission.author_id, + isouter=True + # ).join( + # exile, + # and_(exile.c.target_submission_id==Submission.id, exile.c.board_id==Submission.original_board_id), + # isouter=True + ).first() + + if not items and not graceful: + abort(404) + + x = items[0] + x._voted = items[1] or 0 + x._is_guildmaster = items[2] or 0 + x._is_blocking_guild = items[3] or 0 + x._is_blocking = items[4] or 0 + # x._is_exiled_for=items[5] or 0 + + else: + items = nSession.query( + Submission, + # aliased(ModAction, alias=exile) + ).options( + joinedload(Submission.author).joinedload(User.title) + # ).join( + # exile, + # and_(exile.c.target_submission_id==Submission.id, exile.c.board_id==Submission.original_board_id), + # isouter=True + ).filter(Submission.id == i).first() + + if not items and not graceful: + abort(404) + + x=items + # x._is_exiled_for=items[1] or 0 + + return x + + +def get_posts(pids, sort="hot", v=None): + + if not pids: + return [] + + pids=tuple(pids) + + # exile=g.db.query(ModAction).options( + # lazyload('*') + # ).filter( + # ModAction.kind=="exile_user", + # ModAction.target_submission_id.in_(pids) + # ).subquery() + + if v: + vt = g.db.query(Vote).filter( + Vote.submission_id.in_(pids), + Vote.user_id==v.id + ).subquery() + + mod = g.db.query(ModRelationship).filter_by( + user_id=v.id, accepted=True, invite_rescinded=False).subquery() + + boardblocks = g.db.query(BoardBlock).filter_by( + user_id=v.id).subquery() + blocking = v.blocking.subquery() + blocked = v.blocked.subquery() + subs = g.db.query(Subscription).filter_by(user_id=v.id, is_active=True).subquery() + + query = g.db.query( + Submission, + vt.c.vote_type, + aliased(ModRelationship, alias=mod), + boardblocks.c.id, + blocking.c.id, + blocked.c.id, + subs.c.id, + # aliased(ModAction, alias=exile) + ).options( + joinedload(Submission.author).joinedload(User.title) + ).filter( + Submission.id.in_(pids) + ).join( + vt, vt.c.submission_id==Submission.id, isouter=True + ).join( + mod, + mod.c.board_id == Submission.board_id, + isouter=True + ).join( + boardblocks, + boardblocks.c.board_id == Submission.board_id, + isouter=True + ).join( + blocking, + blocking.c.target_id == Submission.author_id, + isouter=True + ).join( + blocked, + blocked.c.user_id == Submission.author_id, + isouter=True + ).join( + subs, + subs.c.board_id == Submission.board_id, + isouter=True + # ).join( + # exile, + # and_(exile.c.target_submission_id==Submission.id, exile.c.board_id==Submission.original_board_id), + # isouter=True + ).order_by(None).all() + + posts=[x for x in query] + + output = [p[0] for p in query] + for i in range(len(output)): + output[i]._voted = posts[i][1] or 0 + output[i]._is_guildmaster = posts[i][2] or 0 + output[i]._is_blocking_guild = posts[i][3] or 0 + output[i]._is_blocking = posts[i][4] or 0 + output[i]._is_blocked = posts[i][5] or 0 + output[i]._is_subscribed = posts[i][6] or 0 + # output[i]._is_exiled_for=posts[i][7] or 0 + else: + query = g.db.query( + Submission, + # aliased(ModAction, alias=exile) + ).options( + joinedload(Submission.author).joinedload(User.title) + ).filter(Submission.id.in_(pids) + # ).join( + # exile, + # and_(exile.c.target_submission_id==Submission.id, exile.c.board_id==Submission.original_board_id), + # isouter=True + ).order_by(None).all() + + output=[x for x in query] + + # output=[] + # for post in posts: + # p=post[0] + # p._is_exiled_for=post[1] or 0 + # output.append(p) + + return sorted(output, key=lambda x: pids.index(x.id)) + + +def get_post_with_comments(pid, sort="top", v=None): + + post = get_post(pid, v=v) + + if v: + votes = g.db.query(CommentVote).filter_by(user_id=v.id).subquery() + + blocking = v.blocking.subquery() + + blocked = v.blocked.subquery() + + comms = g.db.query( + Comment, + votes.c.vote_type, + blocking.c.id, + blocked.c.id, + ).options( + joinedload(Comment.author) + ) + if v.admin_level >=4: + comms=comms.options(joinedload(Comment.oauth_app)) + + comms=comms.filter( + Comment.parent_submission == post.id + ).join( + votes, + votes.c.comment_id == Comment.id, + isouter=True + ).join( + blocking, + blocking.c.target_id == Comment.author_id, + isouter=True + ).join( + blocked, + blocked.c.user_id == Comment.author_id, + isouter=True + ) + + if sort == "top": + comments = comms.order_by(Comment.score.desc()).all() + elif sort == "bottom": + comments = comms.order_by(Comment.score.asc()).all() + elif sort == "new": + comments = comms.order_by(Comment.created_utc.desc()).all() + elif sort == "old": + comments = comms.order_by(Comment.created_utc.asc()).all() + elif sort == "controversial": + comments = sorted(comms.all(), key=lambda x: x[0].score_disputed, reverse=True) + elif sort == "random": + c = comms.all() + comments = random.sample(c, k=len(c)) + else: + abort(422) + + output = [] + for c in comments: + comment = c[0] + if comment.author and comment.author.shadowbanned and not (v and v.id == comment.author_id): continue + comment._voted = c[1] or 0 + comment._is_blocking = c[2] or 0 + comment._is_blocked = c[3] or 0 + output.append(comment) + + post._preloaded_comments = output + + else: + comms = g.db.query( + Comment + ).options( + joinedload(Comment.author).joinedload(User.title) + ).filter( + Comment.parent_submission == post.id + ) + + if sort == "top": + comments = comms.order_by(Comment.score.desc()).all() + elif sort == "bottom": + comments = comms.order_by(Comment.score.asc()).all() + elif sort == "new": + comments = comms.order_by(Comment.created_utc.desc()).all() + elif sort == "old": + comments = comms.order_by(Comment.created_utc.asc()).all() + elif sort == "controversial": + comments = sorted(comms.all(), key=lambda x: x.score_disputed, reverse=True) + elif sort == "random": + c = comms.all() + comments = random.sample(c, k=len(c)) + else: + abort(422) + + if random.random() < 0.1: + for comment in comments: + if comment.author and comment.author.shadowbanned: + rand = random.randint(500,1400) + vote = CommentVote(user_id=rand, + vote_type=random.choice([-1, 1]), + comment_id=comment.id) + g.db.add(vote) + try: g.db.flush() + except: g.db.rollback() + comment.upvotes = comment.ups + comment.downvotes = comment.downs + g.db.add(comment) + + post._preloaded_comments = [x for x in comments if not (x.author and x.author.shadowbanned) or (v and v.id == x.author_id)] + + return post + + +def get_comment(cid, nSession=None, v=None, graceful=False, **kwargs): + + if isinstance(cid, str): + i = base36decode(cid) + else: + i = cid + + nSession = nSession or kwargs.get('session') or g.db + + exile = nSession.query(ModAction + ).options( + lazyload('*') + ).filter_by( + kind="exile_user" + ).subquery() + + if v: + blocking = v.blocking.subquery() + blocked = v.blocked.subquery() + vt = nSession.query(CommentVote).filter( + CommentVote.user_id == v.id, + CommentVote.comment_id == i).subquery() + + mod=nSession.query(ModRelationship + ).filter_by( + user_id=v.id, + accepted=True + ).subquery() + + + items = nSession.query( + Comment, + vt.c.vote_type, + aliased(ModRelationship, alias=mod), + aliased(ModAction, alias=exile) + ).options( + joinedload(Comment.author).joinedload(User.title) + ) + + if v.admin_level >=4: + items=items.options(joinedload(Comment.oauth_app)) + + items=items.filter( + Comment.id == i + ).join( + vt, + vt.c.comment_id == Comment.id, + isouter=True + ).join( + Comment.post, + isouter=True + ).join( + mod, + mod.c.board_id==Submission.board_id, + isouter=True + ).join( + exile, + and_(exile.c.target_comment_id==Comment.id, exile.c.board_id==Comment.original_board_id), + isouter=True + ).first() + + if not items and not graceful: + abort(404) + + x = items[0] + x._voted = items[1] or 0 + x._is_guildmaster=items[2] or 0 + x._is_exiled_for=items[3] or 0 + + block = nSession.query(UserBlock).filter( + or_( + and_( + UserBlock.user_id == v.id, + UserBlock.target_id == x.author_id + ), + and_(UserBlock.user_id == x.author_id, + UserBlock.target_id == v.id + ) + ) + ).first() + + x._is_blocking = block and block.user_id == v.id + x._is_blocked = block and block.target_id == v.id + + else: + q = nSession.query( + Comment, + aliased(ModAction, alias=exile) + ).options( + joinedload(Comment.author).joinedload(User.title) + ).join( + exile, + and_(exile.c.target_comment_id==Comment.id, exile.c.board_id==Comment.original_board_id), + isouter=True + ).filter(Comment.id == i).first() + + if not q and not graceful: + abort(404) + + x=q[0] + x._is_exiled_for=q[1] + + + return x + + +def get_comments(cids, v=None, nSession=None, sort="new", + load_parent=False, **kwargs): + + if not cids: + return [] + + cids=tuple(cids) + + nSession = nSession or kwargs.get('session') or g.db + + exile=nSession.query(ModAction + ).options( + lazyload('*') + ).filter( + ModAction.kind=="exile_user", + ModAction.target_comment_id.in_(cids) + ).distinct(ModAction.target_comment_id).subquery() + + if v: + vt = nSession.query(CommentVote).filter( + CommentVote.comment_id.in_(cids), + CommentVote.user_id==v.id + ).subquery() + + mod=nSession.query(ModRelationship + ).filter_by( + user_id=v.id, + accepted=True + ).subquery() + + + + query = nSession.query( + Comment, + aliased(CommentVote, alias=vt), + aliased(ModRelationship, alias=mod), + aliased(ModAction, alias=exile) + ).options( + joinedload(Comment.author).joinedload(User.title) + ) + + if v.admin_level >=4: + query=query.options(joinedload(Comment.oauth_app)) + + if load_parent: + query = query.options( + joinedload( + Comment.parent_comment + ).joinedload( + Comment.author + ).joinedload( + User.title + ) + ) + + query = query.join( + vt, + vt.c.comment_id == Comment.id, + isouter=True + ).join( + Comment.post, + isouter=True + ).join( + mod, + mod.c.board_id==Submission.board_id, + isouter=True + ).join( + exile, + and_(exile.c.target_comment_id==Comment.id, exile.c.board_id==Comment.original_board_id), + isouter=True + ).filter( + Comment.id.in_(cids) + ) + + + + query=query.options( + # contains_eager(Comment.post).contains_eager(Submission.board) + ).order_by(None).all() + + comments=[x for x in query] + + output = [x[0] for x in comments] + for i in range(len(output)): + output[i]._voted = comments[i][1].vote_type if comments[i][1] else 0 + output[i]._is_guildmaster = comments[i][2] + output[i]._is_exiled_for = comments[i][3] + + + + else: + query = nSession.query( + Comment, + aliased(ModAction, alias=exile) + ).options( + joinedload(Comment.author).joinedload(User.title), + joinedload(Comment.post).joinedload(Submission.board) + ).filter( + Comment.id.in_(cids) + ).join( + exile, + and_(exile.c.target_comment_id==Comment.id, exile.c.board_id==Comment.original_board_id), + isouter=True + ).order_by(None).all() + + comments=[x for x in query] + + output=[x[0] for x in comments] + for i in range(len(output)): + output[i]._is_exiled_for=comments[i][1] + + + output = sorted(output, key=lambda x: cids.index(x.id)) + + return output + + +def get_board(bid, graceful=False): + + return g.db.query(Board).first() + + +def get_guild(name, graceful=False): + + return g.db.query(Board).first() + + +def get_domain(s): + + # parse domain into all possible subdomains + parts = s.split(".") + domain_list = set([]) + for i in range(len(parts)): + new_domain = parts[i] + for j in range(i + 1, len(parts)): + new_domain += "." + parts[j] + + domain_list.add(new_domain) + + domain_list = tuple(list(domain_list)) + + doms = [x for x in g.db.query(Domain).filter( + Domain.domain.in_(domain_list)).all()] + + if not doms: + return None + + # return the most specific domain - the one with the longest domain + # property + doms = sorted(doms, key=lambda x: len(x.domain), reverse=True) + + return doms[0] + + +def get_title(x): + + title = g.db.query(Title).filter_by(id=x).first() + + if not title: + abort(400) + + else: + return title + + +def get_mod(uid, bid): + + mod = g.db.query(ModRelationship).filter_by(board_id=bid, + user_id=uid, + accepted=True, + invite_rescinded=False).first() + + return mod + + +def get_application(client_id, graceful=False): + + application = g.db.query(OauthApp).filter_by(client_id=client_id).first() + if not application and not graceful: + abort(404) + + return application + + +def get_from_permalink(link, v=None): + + if "@" in link: + + name = re.search("/@(\w+)", link) + if name: + name=name.match(1) + return get_user(name) + + if "+" in link: + + x = re.search("/\+(\w+)$", link) + if x: + name=x.match(1) + return get_guild(name) + + ids = re.search("://[^/]+\w+/post/(\w+)/[^/]+(/(\w+))?", link) + + try: + post_id = ids.group(1) + comment_id = ids.group(3) + except: abort(400) + + if comment_id: + return get_comment(int(comment_id), v=v) + + else: + return get_post(int(post_id), v=v) + + +def get_from_fullname(fullname, v=None, graceful=False): + + parts = fullname.split('_') + + if len(parts) != 2: + if graceful: + return None + else: + abort(400) + + kind = parts[0] + b36 = parts[1] + + if kind == 't1': + return get_account(b36, v=v, graceful=graceful) + elif kind == 't2': + return get_post(b36, v=v, graceful=graceful) + elif kind == 't3': + return get_comment(b36, v=v, graceful=graceful) + elif kind == 't4': + return get_board(b36, graceful=graceful) + +def get_txn(paypal_id): + + txn= g.db.query(PayPalTxn).filter_by(paypal_id=paypal_id).first() + + if not txn: + abort(404) + + return txn + +def get_txid(txid): + + txn= g.db.query(PayPalTxn).filter_by(id=base36decode(txid)).first() + + if not txn: + abort(404) + elif txn.status==1: + abort(404) + + return txn + + +def get_promocode(code): + + code = code.replace('\\', '') + code = code.replace("_", "\_") + + code = g.db.query(PromoCode).filter(PromoCode.code.ilike(code)).first() + + return code \ No newline at end of file diff --git a/ruqqus/helpers/jinja2.py b/ruqqus/helpers/jinja2.py new file mode 100644 index 000000000..2c80862e2 --- /dev/null +++ b/ruqqus/helpers/jinja2.py @@ -0,0 +1,87 @@ +from os import environ, path +import calendar +from .get import * +from ruqqus.__main__ import app, cache + + +@app.template_filter("total_users") +@cache.memoize(timeout=60) +def total_users(x): + + return db.query(User).filter_by(is_banned=0).count() + + +@app.template_filter("source_code") +@cache.memoize(timeout=60 * 60 * 24) +def source_code(file_name): + + return open(path.expanduser('~') + '/ruqqus/' + + file_name, mode="r+").read() + + +@app.template_filter("full_link") +def full_link(url): + + return f"https://{app.config['SERVER_NAME']}{url}" + + +@app.template_filter("env") +def env_var_filter(x): + + x = environ.get(x, 1) + + try: + return int(x) + except BaseException: + try: + return float(x) + except BaseException: + return x + + +@app.template_filter("js_str_escape") +def js_str_escape(s): + + s = s.replace("'", r"\'") + + return s + + +@app.template_filter("is_mod") +@cache.memoize(60) +def jinja_is_mod(uid, bid): + + return bool(get_mod(uid, bid)) + +@app.template_filter("coin_goal") +@cache.cached(timeout=600, key_prefix="premium_coin_goal") +def coin_goal(x): + + now = time.gmtime() + midnight_month_start = time.struct_time((now.tm_year, + now.tm_mon, + 1, + 0, + 0, + 0, + now.tm_wday, + now.tm_yday, + 0) + ) + cutoff = calendar.timegm(midnight_month_start) + + coins=g.db.query(func.sum(PayPalTxn.coin_count)).filter( + PayPalTxn.created_utc>cutoff, + PayPalTxn.status==3).all()[0][0] or 0 + + + return int(100*coins/1000) + + +@app.template_filter("app_config") +def app_config(x): + return app.config.get(x) + +# @app.template_filter("general_chat_count") +# def general_chat_count(x): +# return get_guild("general").chat_count \ No newline at end of file diff --git a/ruqqus/helpers/lazy.py b/ruqqus/helpers/lazy.py new file mode 100644 index 000000000..3e2a699ea --- /dev/null +++ b/ruqqus/helpers/lazy.py @@ -0,0 +1,20 @@ +# Prevents certain properties from having to be recomputed each time they +# are referenced + + +def lazy(f): + + 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) + + return o.__dict__["_lazy"][f.__name__] + + wrapper.__name__ = f.__name__ + return wrapper diff --git a/ruqqus/helpers/markdown.py b/ruqqus/helpers/markdown.py new file mode 100644 index 000000000..179546773 --- /dev/null +++ b/ruqqus/helpers/markdown.py @@ -0,0 +1,109 @@ +from .get import * + +from mistletoe.span_token import SpanToken +from mistletoe.html_renderer import HTMLRenderer +import re + +from flask import g + +#preprocess re + +enter_re=re.compile("(\n\r?\w+){3,}") + + + +# add token/rendering for @username mentions + + +class UserMention(SpanToken): + + pattern = re.compile("(^|\s|\n)@(\w{1,25})") + parse_inner = False + + def __init__(self, match_obj): + self.target = (match_obj.group(1), match_obj.group(2)) + +class SubMention(SpanToken): + + pattern = re.compile("(^|\s|\n)r/(\w{3,25})") + parse_inner = False + + def __init__(self, match_obj): + + self.target = (match_obj.group(1), match_obj.group(2)) + +class RedditorMention(SpanToken): + + pattern = re.compile("(^|\s|\n)u/(\w{3,25})") + parse_inner = False + + def __init__(self, match_obj): + + self.target = (match_obj.group(1), match_obj.group(2)) + +class SubMention2(SpanToken): + + pattern = re.compile("(^|\s|\n)/r/(\w{3,25})") + parse_inner = False + + def __init__(self, match_obj): + + self.target = (match_obj.group(1), match_obj.group(2)) + +class RedditorMention2(SpanToken): + + pattern = re.compile("(^|\s|\n)/u/(\w{3,25})") + parse_inner = False + + def __init__(self, match_obj): + + self.target = (match_obj.group(1), match_obj.group(2)) + +# class OpMention(SpanToken): + +# pattern = re.compile("(^|\W|\s)@([Oo][Pp])\b") +# parse_inner = False + +# def __init__(self, match_obj): +# self.target = (match_obj.group(1), match_obj.group(2)) + + +class CustomRenderer(HTMLRenderer): + + def __init__(self, **kwargs): + super().__init__(UserMention, + SubMention, + RedditorMention, + SubMention2, + RedditorMention2, + ) + + for i in kwargs: + self.__dict__[i] = kwargs[i] + + def render_user_mention(self, token): + space = token.target[0] + target = token.target[1] + + user = get_user(target, graceful=True) + + + try: + if g.v.admin_level == 0 and g.v.any_block_exists(user): + return f"{space}@{target}" + except BaseException: + pass + + if not user: return f"{space}@{target}" + + return f'{space}@{user.username}' + + def render_sub_mention(self, token): + space = token.target[0] + target = token.target[1] + return f'{space}r/{target}' + + def render_redditor_mention(self, token): + space = token.target[0] + target = token.target[1] + return f'{space}u/{target}' \ No newline at end of file diff --git a/ruqqus/helpers/redis.py b/ruqqus/helpers/redis.py new file mode 100644 index 000000000..22966e96f --- /dev/null +++ b/ruqqus/helpers/redis.py @@ -0,0 +1,118 @@ +import flask_caching +from flask_caching import backends +import hashlib + + +class CustomCache(backends.rediscache.RedisCache): + + def __init__(self, app, config, *args): + + self.caches = [ + flask_caching.Cache( + app, + config={"CACHE_TYPE": 'redis', + "CACHE_REDIS_URL": url} + ) for url in app.config['redis_urls'] + ] + + def key_to_cache(self, key): + + return self.caches[self.key_to_cache_number(key)] + + def key_to_cache_number(self, key): + + return int(hashlib.md5(bytes(key, 'utf-8')).hexdigest() + [-5:], 16) % len(self.caches) + + def sharded_keys(self, keys, return_index=False): + + sharded_keys = {i: [] for i in range(len(self.caches))} + idx = {} + for key in keys: + cache = self.key_to_cache_number(key) + sharded_keys[cache].append(key) + idx[key] = [cache, len(sharded_keys[cache]) - 1] + + if not return_index: + return sharded_keys + else: + return sharded_keys, idx + + def get(self, key): + + cache = self.key_to_cache(key) + + return cache.get(key) + + def get_many(self, *keys): + + sharded_keys, idx = self.sharded_keys(keys, return_index=True) + + objects = {i: self.caches[i].get_many( + *sharded_keys[i]) for i in range(len(self.caches))} + + output = [objects[idx[key][0]][idx[key][1]] for key in keys] + + return output + + def set(self, key, value, timeout=None): + cache = self.key_to_cache(key) + return cache.set(key, value, timeout=timeout) + + def add(self, key, value, timeout=None): + cache = self.key_to_cache(key) + return cache.add(key, value, timeout=timeout) + + def set_many(self, mapping, timeout=None): + + caches = {i: {} for i in range(len(self.caches))} + for key in mapping: + caches[self.key_to_cache_number(key)][key] = mapping[key] + + for i in caches: + self.caches[i].set_many(caches[i], timeout=timeout) + + def delete(self, key): + + cache = self.key_to_cache(key) + return cache.delete(key) + + def delete_many(self, *keys): + + if not keys: + return + + sharded_keys = self.sharded_keys(keys) + + for i in sharded_keys: + self.caches[i].delete_many(*sharded_keys[i]) + + return True + + def has(self, key): + cache = self.key_to_cache(key) + return cache.has(key) + + def clear(self): + + return any([i.clear() for i in self.caches]) + + def inc(self, key, delta=1): + cache = self.key_to_cache(key) + cache.inc(key, delta=delta) + + def dec(self, key, delta=1): + cache = self.key_to_cache(key) + cache.dec(key, delta=delta) + + def unlink(self, *keys): + + if not keys: + return + + sharded_keys = self.sharded_keys(keys) + + for i in sharded_keys: + self.caches[i].unlink(*sharded_keys[i]) + + return True diff --git a/ruqqus/helpers/sanitize.py b/ruqqus/helpers/sanitize.py new file mode 100644 index 000000000..b4c1a54fb --- /dev/null +++ b/ruqqus/helpers/sanitize.py @@ -0,0 +1,220 @@ +import bleach +from bs4 import BeautifulSoup +from bleach.linkifier import LinkifyFilter +from urllib.parse import ParseResult, urlunparse +from functools import partial +from .get import * +import os.path + +_allowed_tags = tags = ['b', + 'blockquote', + 'br', + 'code', + 'del', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'i', + 'li', + 'ol', + 'p', + 'pre', + 'strong', + 'sub', + 'sup', + 'table', + 'tbody', + 'th', + 'thead', + 'td', + 'tr', + 'ul', + 'marquee', + 'a', + 'img', + 'span', + ] + +_allowed_attributes = { + 'a': ['href', 'title', "rel", "data-original-name"], + 'i': [], + 'img': ['src', 'class'], + 'span': ['style'] + } + +_allowed_protocols = [ + 'http', + 'https' + ] + +_allowed_styles =[ + 'color', + 'font-weight' +] + +# filter to make all links show domain on hover + + +def a_modify(attrs, new=False): + + raw_url=attrs.get((None, "href"), None) + if raw_url: + parsed_url = urlparse(raw_url) + + domain = parsed_url.netloc + attrs[(None, "target")] = "_blank" + if domain and not domain.endswith("rdrama.net"): + attrs[(None, "rel")] = "nofollow noopener" + + # Force https for all external links in comments + # (Ruqqus already forces its own https) + new_url = ParseResult(scheme="https", + netloc=parsed_url.netloc, + path=parsed_url.path, + params=parsed_url.params, + query=parsed_url.query, + fragment=parsed_url.fragment) + + attrs[(None, "href")] = urlunparse(new_url) + + return attrs + + + + + + +_clean_wo_links = bleach.Cleaner(tags=_allowed_tags, + attributes=_allowed_attributes, + protocols=_allowed_protocols, + ) + +_clean_w_links = bleach.Cleaner(tags=_allowed_tags, + attributes=_allowed_attributes, + protocols=_allowed_protocols, + styles=_allowed_styles, + filters=[partial(LinkifyFilter, + skip_tags=["pre"], + parse_email=False, + callbacks=[a_modify] + ) + ] + ) + + +def sanitize(text, linkgen=False, flair=False): + + text = text.replace("\ufeff", "").replace("m.youtube.com", "youtube.com") + + if linkgen: + sanitized = _clean_w_links.clean(text) + + #soupify + soup = BeautifulSoup(sanitized, features="html.parser") + + #img elements - embed + for tag in soup.find_all("img"): + + url = tag.get("src", "") + if not url: continue + netloc = urlparse(url).netloc + + domain = get_domain(netloc) + if not(netloc) or (domain and domain.show_thumbnail): + + if "profile-pic-20" not in tag.get("class", ""): + #print(tag.get('class')) + # set classes and wrap in link + + tag["rel"] = "nofollow" + tag["style"] = "max-height: 100px; max-width: 100%;" + tag["class"] = "in-comment-image rounded-sm my-2" + + link = soup.new_tag("a") + link["href"] = tag["src"] + link["rel"] = "nofollow noopener" + link["target"] = "_blank" + + link["onclick"] = f"expandDesktopImage('{tag['src']}');" + link["data-toggle"] = "modal" + link["data-target"] = "#expandImageModal" + + tag.wrap(link) + else: + # non-whitelisted images get replaced with links + new_tag = soup.new_tag("a") + new_tag.string = tag["src"] + new_tag["href"] = tag["src"] + new_tag["rel"] = "nofollow noopener" + tag.replace_with(new_tag) + + #disguised link preventer + for tag in soup.find_all("a"): + + if re.match("https?://\S+", str(tag.string)): + try: + tag.string = tag["href"] + except: + tag.string = "" + + #clean up tags in code + for tag in soup.find_all("code"): + tag.contents=[x.string for x in tag.contents if x.string] + + #whatever else happens with images, there are only two sets of classes allowed + for tag in soup.find_all("img"): + if 'profile-pic-20' not in tag.attrs.get("class",""): + tag.attrs['class']="in-comment-image rounded-sm my-2" + + #table format + for tag in soup.find_all("table"): + tag.attrs['class']="table table-striped" + + for tag in soup.find_all("thead"): + tag.attrs['class']="bg-primary text-white" + + + sanitized = str(soup) + + else: + sanitized = _clean_wo_links.clean(text) + + start = '<s>' + end = '</s>' + if start in sanitized and end in sanitized and start in sanitized.split(end)[0] and end in sanitized.split(start)[1]: sanitized = sanitized.replace(start, '').replace(end, '') + + if flair: emojisize = 20 + else: emojisize = 30 + for i in re.finditer(':(.{1,30}?):', sanitized): + if os.path.isfile(f'/d/ruqqus/assets/images/emojis/{i.group(1)}.gif'): + sanitized = sanitized.replace(f':{i.group(1)}:', f'') + + sanitized = sanitized.replace("https://www.", "https://").replace("https://youtu.be/", "https://youtube.com/embed/").replace("https://music.youtube.com/watch?v=", "https://youtube.com/embed/").replace("/watch?v=", "/embed/").replace("https://open.spotify.com/", "https://open.spotify.com/embed/").replace("https://streamable.com/", "https://streamable.com/e/").replace("https://youtube.com/shorts/", "https://youtube.com/embed/") + + for i in re.finditer('{url}' + htmlsource = f'
' + sanitized = sanitized.replace(replacing, htmlsource) + + for i in re.finditer('{url}' + htmlsource = f'' + sanitized = sanitized.replace(replacing, htmlsource) + + sanitized = sanitized.replace("https://mobile.twitter.com", "https://twitter.com") + + for rd in ["https://reddit.com/", "https://new.reddit.com/", "https://www.reddit.com/", "https://redd.it/"]: + sanitized = sanitized.replace(rd, "https://old.reddit.com/") + + for i in re.finditer('(/comments/.*?)"', sanitized): + url = i.group(1) + if not "sort=" in url: sanitized = sanitized.replace(url, f"{url}?sort=controversial") + + return sanitized \ No newline at end of file diff --git a/ruqqus/helpers/security.py b/ruqqus/helpers/security.py new file mode 100644 index 000000000..f921b805d --- /dev/null +++ b/ruqqus/helpers/security.py @@ -0,0 +1,23 @@ +from werkzeug.security import * +from os import environ + + +def generate_hash(string): + + msg = bytes(string, "utf-16") + + return hmac.new(key=bytes(environ.get("MASTER_KEY"), "utf-16"), + msg=msg, + digestmod='md5' + ).hexdigest() + + +def validate_hash(string, hashstr): + + return hmac.compare_digest(hashstr, generate_hash(string)) + + +def hash_password(password): + + return generate_password_hash( + password, method='pbkdf2:sha512', salt_length=8) diff --git a/ruqqus/helpers/session.py b/ruqqus/helpers/session.py new file mode 100644 index 000000000..11b0a10f9 --- /dev/null +++ b/ruqqus/helpers/session.py @@ -0,0 +1,35 @@ +from flask import * +import time +from .security import * + + +def session_over18(board): + + now = int(time.time()) + + return session.get('over_18', {}).get(board.base36id, 0) >= now + + +def session_isnsfl(board): + + now = int(time.time()) + + return session.get('show_nsfl', {}).get(board.base36id, 0) >= now + + +def make_logged_out_formkey(t): + + s = f"{t}+{session['session_id']}" + + return generate_hash(s) + + +def validate_logged_out_formkey(t, k): + + now = int(time.time()) + if now - t > 3600: + return False + + s = f"{t}+{session['session_id']}" + + return validate_hash(s, k) diff --git a/ruqqus/helpers/sqla_values.py b/ruqqus/helpers/sqla_values.py new file mode 100644 index 000000000..fb1745d31 --- /dev/null +++ b/ruqqus/helpers/sqla_values.py @@ -0,0 +1,33 @@ +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.sql.expression import FromClause + + +class values(FromClause): + named_with_column = True + + def __init__(self, columns, *args, **kw): + self._column_args = columns + self.list = args + self.alias_name = self.name = kw.pop('alias_name', None) + + def _populate_column_collection(self): + for c in self._column_args: + c._make_proxy(self) + + +@compiles(values) +def compile_values(element, compiler, asfrom=False, **kw): + columns = element.columns + v = "VALUES %s" % ", ".join( + "(%s)" % ", ".join( + compiler.render_literal_value(elem, column.type) + for elem, column in zip(tup, columns)) + for tup in element.list + ) + if asfrom: + if element.alias_name: + v = "(%s) AS %s (%s)" % (v, element.alias_name, + (", ".join(c.name for c in element.columns))) + else: + v = "(%s)" % v + return v diff --git a/ruqqus/helpers/thumbs.py b/ruqqus/helpers/thumbs.py new file mode 100644 index 000000000..5152c5313 --- /dev/null +++ b/ruqqus/helpers/thumbs.py @@ -0,0 +1,20 @@ +from .get import * + +def expand_url(post_url, fragment_url): + + # convert src into full url + if fragment_url.startswith("https://"): + return fragment_url + elif fragment_url.startswith("http://"): + return f"https://{fragment_url.split('http://')[1]}" + elif fragment_url.startswith('//'): + return f"https:{fragment_url}" + elif fragment_url.startswith('/'): + parsed_url = urlparse(post_url) + return f"https://{parsed_url.netloc}{fragment_url}" + else: + return f"{post_url}{'/' if not post_url.endswith('/') else ''}{fragment_url}" + +def thumbnail_thread(pid, debug=False): + + return True, "Success" \ No newline at end of file diff --git a/ruqqus/helpers/wrappers.py b/ruqqus/helpers/wrappers.py new file mode 100644 index 000000000..be30e8873 --- /dev/null +++ b/ruqqus/helpers/wrappers.py @@ -0,0 +1,557 @@ +from werkzeug.wrappers.response import Response as RespObj +from .get import * +from .alerts import send_notification +from ruqqus.__main__ import Base, app, db_session + + +def get_logged_in_user(db=None): + + if not db: + db=g.db + + if request.path.startswith("/api/v1"): + + token = request.headers.get("Authorization") + if not token: + + #let admins hit api/v1 from browser + # x=request.session.get('user_id') + # nonce=request.session.get('login_nonce') + # if not x or not nonce: + # return None, None + # user=g.db.query(User).filter_by(id=x).first() + # if not user: + # return None, None + # if user.admin_level >=3 and nonce>=user.login_nonce: + # return user, None + return None, None + + token = token.split() + if len(token) < 2: + return None, None + + token = token[1] + if not token: + return None, None + + client = db.query(ClientAuth).filter( + ClientAuth.access_token == token).first() + #ClientAuth.access_token_expire_utc > int(time.time() + + x = (client.user, client) if client else (None, None) + + + elif "user_id" in session: + + uid = session.get("user_id") + nonce = session.get("login_nonce", 0) + if not uid: + x= (None, None) + v = db.query(User).options( + joinedload(User.moderates).joinedload(ModRelationship.board), #joinedload(Board.reports), + ).filter_by( + id=uid, + is_deleted=False + ).first() + + if v and v.agendaposter_expires_utc and v.agendaposter_expires_utc < g.timestamp: + v.agendaposter_expires_utc = 0 + v.agendaposter = False + + g.db.add(v) + + if v and (nonce < v.login_nonce): + x= (None, None) + else: + x=(v, None) + + else: + x=(None, None) + + if x[0]: + x[0].client=x[1] + + return x + +def check_ban_evade(v): + + if not v or not v.ban_evade or v.admin_level > 0: + return + + if random.randint(0,30) < v.ban_evade: + v.ban(reason="ban evasion") + send_notification(1046, v, "Your Drama account has been permanently suspended for the following reason:\n\n> ban evasion") + + for post in g.db.query(Submission).filter_by(author_id=v.id).all(): + if post.is_banned: + continue + + post.is_banned=True + post.ban_reason="ban evasion" + g.db.add(post) + + ma=ModAction( + kind="ban_post", + user_id=2317, + target_submission_id=post.id, + board_id=post.board_id, + note="ban evasion" + ) + g.db.add(ma) + + g.db.commit() + + for comment in g.db.query(Comment).filter_by(author_id=v.id).all(): + if comment.is_banned: + continue + + comment.is_banned=True + comment.ban_reason="ban evasion" + g.db.add(comment) + + ma=ModAction( + kind="ban_comment", + user_id=2317, + target_comment_id=comment.id, + board_id=comment.post.board_id, + note="ban evasion" + ) + g.db.add(ma) + + g.db.commit() + try: abort(403) + except Exception as e: print(e) + + else: + v.ban_evade +=1 + g.db.add(v) + g.db.commit() + + + + +# Wrappers +def auth_desired(f): + # decorator for any view that changes if user is logged in (most pages) + + def wrapper(*args, **kwargs): + + v, c = get_logged_in_user() + + if c: + kwargs["c"] = c + + check_ban_evade(v) + + resp = make_response(f(*args, v=v, **kwargs)) + if v: + resp.headers.add("Cache-Control", "private") + resp.headers.add( + "Access-Control-Allow-Origin", + app.config["SERVER_NAME"]) + else: + resp.headers.add("Cache-Control", "public") + return resp + + wrapper.__name__ = f.__name__ + return wrapper + + +def auth_required(f): + # decorator for any view that requires login (ex. settings) + + def wrapper(*args, **kwargs): + + v, c = get_logged_in_user() + + #print(v, c) + + if not v: + abort(401) + + check_ban_evade(v) + + if c: + kwargs["c"] = c + + g.v = v + + # an ugly hack to make api work + resp = make_response(f(*args, v=v, **kwargs)) + + resp.headers.add("Cache-Control", "private") + resp.headers.add( + "Access-Control-Allow-Origin", + app.config["SERVER_NAME"]) + return resp + + wrapper.__name__ = f.__name__ + return wrapper + + +def is_not_banned(f): + # decorator that enforces lack of ban + + def wrapper(*args, **kwargs): + + v, c = get_logged_in_user() + + #print(v, c) + + if not v: + abort(401) + + check_ban_evade(v) + + if v.is_suspended: + abort(403) + + if c: + kwargs["c"] = c + + g.v = v + + resp = make_response(f(*args, v=v, **kwargs)) + resp.headers.add("Cache-Control", "private") + resp.headers.add( + "Access-Control-Allow-Origin", + app.config["SERVER_NAME"]) + return resp + + wrapper.__name__ = f.__name__ + return wrapper + +# Require tos agreement + + +def tos_agreed(f): + + def wrapper(*args, **kwargs): + + v = kwargs['v'] + + cutoff = int(environ.get("tos_cutoff", 0)) + + if v.tos_agreed_utc > cutoff: + return f(*args, **kwargs) + else: + return redirect("/terms#agreebox") + + wrapper.__name__ = f.__name__ + return wrapper + +def premium_required(f): + + #decorator that enforces valid premium status + #use under auth_required or is_not_banned + + def wrapper(*args, **kwargs): + + v=kwargs["v"] + + if not v.has_premium: + abort(403) + + return f(*args, **kwargs) + + wrapper.__name__=f.__name__ + return wrapper + + +def no_negative_balance(s): + + def wrapper_maker(f): + + #decorator that enforces valid premium status + #use under auth_required or is_not_banned + + def wrapper(*args, **kwargs): + + v=kwargs["v"] + + if v.negative_balance_cents: + if s=="toast": + return jsonify({"error":"You can't do that while your account balance is negative. Visit your account settings to bring your balance up to zero."}), 402 + elif s=="html": + raise(PaymentRequired) + else: + raise(PaymentRequired) + + return f(*args, **kwargs) + + wrapper.__name__=f.__name__ + return wrapper + + return wrapper_maker + +def is_guildmaster(*perms): + # decorator that enforces guildmaster status and verifies permissions + # use under auth_required + def wrapper_maker(f): + + def wrapper(*args, **kwargs): + + v = kwargs["v"] + boardname = kwargs.get("boardname") + board_id = kwargs.get("bid") + bid=request.values.get("bid", request.values.get("board_id")) + + if boardname: + board = get_guild(boardname) + elif board_id: + board = get_board(board_id) + elif bid: + board = get_board(bid) + else: + return jsonify({"error": f"no guild specified"}), 400 + + m=board.has_mod(v) + if not m: + return jsonify({"error":f"You aren't a guildmaster of +{board.name}"}), 403 + + if perms: + for perm in perms: + if not m.__dict__.get(f"perm_{perm}") and not m.perm_full: + return jsonify({"error":f"Permission `{perm}` required"}), 403 + + + if v.is_banned and not v.unban_utc: + abort(403) + + return f(*args, board=board, **kwargs) + + wrapper.__name__ = f.__name__ + return wrapper + + return wrapper_maker + + +# this wrapper takes args and is a bit more complicated +def admin_level_required(x): + + def wrapper_maker(f): + + def wrapper(*args, **kwargs): + + v, c = get_logged_in_user() + + if c: + return jsonify({"error": "No admin api access"}), 403 + + if not v: + abort(401) + + if v.is_banned: + abort(403) + + if v.admin_level < x: + abort(403) + + g.v = v + + response = f(*args, v=v, **kwargs) + + if isinstance(response, tuple): + resp = make_response(response[0]) + else: + resp = make_response(response) + + resp.headers.add("Cache-Control", "private") + resp.headers.add( + "Access-Control-Allow-Origin", + app.config["SERVER_NAME"]) + return resp + + wrapper.__name__ = f.__name__ + return wrapper + + return wrapper_maker + + +def validate_formkey(f): + """Always use @auth_required or @admin_level_required above @validate_form""" + + def wrapper(*args, v, **kwargs): + + if not request.path.startswith("/api/v1"): + + submitted_key = request.values.get("formkey", "none") + + if not submitted_key: + + abort(401) + + elif not v.validate_formkey(submitted_key): + abort(401) + + return f(*args, v=v, **kwargs) + + wrapper.__name__ = f.__name__ + return wrapper + + +def no_cors(f): + """ + Decorator prevents content being iframe'd + """ + + def wrapper(*args, **kwargs): + + origin = request.headers.get("Origin", None) + + if origin and origin != "https://" + app.config["SERVER_NAME"] and app.config["FORCE_HTTPS"]==1: + + return "This page may not be embedded in other webpages.", 403 + + resp = make_response(f(*args, **kwargs)) + resp.headers.add("Access-Control-Allow-Origin", + app.config["SERVER_NAME"] + ) + + return resp + + wrapper.__name__ = f.__name__ + return wrapper + +# wrapper for api-related things that discriminates between an api url +# and an html url for the same content +# f should return {'api':lambda:some_func(), 'html':lambda:other_func()} + + +def public(*scopes, no_ban=False): + + def wrapper_maker(f): + + def wrapper(*args, **kwargs): + + if request.path.startswith(('/api/v1','/api/v2')): + + v = kwargs.get('v') + + result = f(*args, **kwargs) + + if isinstance(result, dict): + resp = result['api']() + else: + resp = result + + if not isinstance(resp, RespObj): + resp = make_response(resp) + + resp.headers.add("Cache-Control", "private") + resp.headers.add( + "Access-Control-Allow-Origin", + app.config["SERVER_NAME"]) + return resp + + else: + + result = f(*args, **kwargs) + + if not isinstance(result, dict): + return result + + try: + if request.path.startswith('/inpage/'): + return result['inpage']() + elif request.path.startswith(('/api/vue/','/test/')): + return result['api']() + else: + return result['html']() + except KeyError: + return result + + wrapper.__name__ = f.__name__ + return wrapper + + return wrapper_maker + + +def api(*scopes, no_ban=False): + + def wrapper_maker(f): + + def wrapper(*args, **kwargs): + + if request.path.startswith(('/api/v1','/api/v2')): + + v = kwargs.get('v') + client = kwargs.get('c') + + if not v or not client: + return jsonify( + {"error": "401 Not Authorized. Invalid or Expired Token"}), 401 + + kwargs.pop('c') + + # validate app associated with token + if client.application.is_banned: + return jsonify({"error": f"403 Forbidden. The application `{client.application.app_name}` is suspended."}), 403 + + # validate correct scopes for request + for scope in scopes: + if not client.__dict__.get(f"scope_{scope}"): + return jsonify({"error": f"401 Not Authorized. Scope `{scope}` is required."}), 403 + + if (request.method == "POST" or no_ban) and client.user.is_suspended: + return jsonify({"error": f"403 Forbidden. The user account is suspended."}), 403 + + result = f(*args, **kwargs) + + if isinstance(result, dict): + resp = result['api']() + else: + resp = result + + if not isinstance(resp, RespObj): + resp = make_response(resp) + + resp.headers.add("Cache-Control", "private") + resp.headers.add( + "Access-Control-Allow-Origin", + app.config["SERVER_NAME"]) + return resp + + else: + + result = f(*args, **kwargs) + + if not isinstance(result, dict): + return result + + try: + if request.path.startswith('/inpage/'): + return result['inpage']() + elif request.path.startswith(('/api/vue/','/test/')): + return result['api']() + else: + return result['html']() + except KeyError: + return result + + wrapper.__name__ = f.__name__ + return wrapper + + return wrapper_maker + + +SANCTIONS=[ + "CU", #Cuba + "IR", #Iran + "KP", #North Korea + "SY", #Syria + "TR", #Turkey + "VE", #Venezuela +] + +def no_sanctions(f): + + def wrapper(*args, **kwargs): + + if request.headers.get("cf-ipcountry","") in SANCTIONS: + abort(451) + + return f(*args, **kwargs) + + wrapper.__name__=f.__name__ + return wrapper \ No newline at end of file diff --git a/ruqqus/mail/__init__.py b/ruqqus/mail/__init__.py new file mode 100644 index 000000000..2db6c49d0 --- /dev/null +++ b/ruqqus/mail/__init__.py @@ -0,0 +1 @@ +from .mail import * diff --git a/ruqqus/mail/mail.py b/ruqqus/mail/mail.py new file mode 100644 index 000000000..a42412f82 --- /dev/null +++ b/ruqqus/mail/mail.py @@ -0,0 +1,98 @@ +from os import environ +import requests +import time +from flask import * +from urllib.parse import quote + +from ruqqus.helpers.security import * +from ruqqus.helpers.wrappers import * +from ruqqus.classes import * +from ruqqus.__main__ import app + + +def send_mail(to_address, subject, html, plaintext=None, files={}, + from_address="Drama "): + + url = "https://api.mailgun.net/v3/rdrama.net/messages" + + data = {"from": from_address, + "to": [to_address], + "subject": subject, + "text": plaintext, + "html": html, + } + + return requests.post(url, + auth=( + "api", environ.get("MAILGUN_KEY").strip()), + data=data, + files=[("attachment", (k, files[k])) for k in files] + ) + + +def send_verification_email(user, email=None): + + if not email: + email = user.email + + url = f"https://{app.config['SERVER_NAME']}/activate" + now = int(time.time()) + + token = generate_hash(f"{email}+{user.id}+{now}") + params = f"?email={quote(email)}&id={user.id}&time={now}&token={token}" + + link = url + params + + send_mail(to_address=email, + html=render_template("email/email_verify.html", + action_url=link, + v=user), + subject="Validate your Drama account email." + ) + + +@app.route("/api/verify_email", methods=["POST"]) +@is_not_banned +def api_verify_email(v): + + send_verification_email(v) + + return "", 204 + + +@app.route("/activate", methods=["GET"]) +@auth_desired +def activate(v): + + email = request.args.get("email", "") + id = request.args.get("id", "") + timestamp = int(request.args.get("time", "0")) + token = request.args.get("token", "") + + if int(time.time()) - timestamp > 3600: + return render_template("message.html", v=v, title="Verification link expired.", + message="That link has expired. Visit your settings to send yourself another verification email."), 410 + + if not validate_hash(f"{email}+{id}+{timestamp}", token): + abort(403) + + user = g.db.query(User).filter_by(id=id).first() + if not user: + abort(404) + + if user.is_activated and user.email == email: + return render_template("message_success.html", v=v, + title="Email already verified.", message="Email already verified."), 404 + + user.email = email + user.is_activated = True + + if not any([b.badge_id == 2 for b in user.badges]): + mail_badge = Badge(user_id=user.id, + badge_id=2, + created_utc=time.time()) + g.db.add(mail_badge) + + g.db.add(user) + + return render_template("message_success.html", v=v, title="Email verified.", message=f"Your email {email} has been verified. Thank you.") diff --git a/ruqqus/routes/__init__.py b/ruqqus/routes/__init__.py new file mode 100644 index 000000000..eb789f8f0 --- /dev/null +++ b/ruqqus/routes/__init__.py @@ -0,0 +1,16 @@ +from .admin import * +from .boards import * +from .comments import * +from .discord import * +from .errors import * +from .flagging import * +from .front import * +from .login import * +from .oauth import * +from .posts import * +from .search import * +from .settings import * +from .static import * +from .users import * +from .votes import * +from .feeds import * \ No newline at end of file diff --git a/ruqqus/routes/admin.py b/ruqqus/routes/admin.py new file mode 100644 index 000000000..3203d5123 --- /dev/null +++ b/ruqqus/routes/admin.py @@ -0,0 +1,1234 @@ +from urllib.parse import urlparse +import time +import calendar +from sqlalchemy import func +from sqlalchemy.orm import lazyload +import threading +import subprocess +import imagehash +from os import remove +from PIL import Image as IMAGE + +from ruqqus.helpers.wrappers import * +from ruqqus.helpers.alerts import * +from ruqqus.helpers.base36 import * +from ruqqus.helpers.sanitize import * +from ruqqus.helpers.markdown import * +from ruqqus.helpers.security import * +from ruqqus.helpers.get import * +from ruqqus.helpers.aws import * +from ruqqus.classes import * +from ruqqus.classes.domains import reasons as REASONS +from flask import * +import matplotlib.pyplot as plt +from .front import frontlist +from ruqqus.__main__ import app, cache + +@app.route("/admin/shadowbanned", methods=["GET"]) +@auth_required +def shadowbanned(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + if not (v and v.admin_level == 6): abort(404) + users = [x for x in g.db.query(User).filter(User.shadowbanned == True).all()] + return render_template("banned.html", v=v, users=users) + + +@app.route("/admin/agendaposters", methods=["GET"]) +@auth_required +def agendaposters(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + if not (v and v.admin_level == 6): abort(404) + users = [x for x in g.db.query(User).filter(User.agendaposter == True).all()] + return render_template("banned.html", v=v, users=users) + + +@app.route("/admin/flagged/posts", methods=["GET"]) +@admin_level_required(3) +def flagged_posts(v): + + page = max(1, int(request.args.get("page", 1))) + + posts = g.db.query(Submission).filter_by( + is_approved=0, + purged_utc=0, + is_banned=False + ).join(Submission.flags + ).options(contains_eager(Submission.flags) + ).order_by(Submission.id.desc()).offset(25 * (page - 1)).limit(26) + + listing = [p.id for p in posts] + next_exists = (len(listing) == 26) + listing = listing[0:25] + + listing = get_posts(listing, v=v) + + return render_template("admin/flagged_posts.html", + next_exists=next_exists, listing=listing, page=page, v=v) + + +@app.route("/admin/image_posts", methods=["GET"]) +@admin_level_required(3) +@api("read") +def image_posts_listing(v): + + page = int(request.args.get('page', 1)) + + posts = g.db.query(Submission).filter_by(domain_ref=1).order_by(Submission.id.desc() + ).offset(25 * (page - 1) + ).limit(26) + + posts = [x.id for x in posts] + next_exists = (len(posts) == 26) + posts = posts[0:25] + + posts = get_posts(posts, v=v) + + return {'html': lambda: render_template("admin/image_posts.html", + v=v, + listing=posts, + next_exists=next_exists, + page=page, + sort="new" + ), + 'api': lambda: [x.json for x in posts] + } + + +@app.route("/admin/flagged/comments", methods=["GET"]) +@admin_level_required(3) +def flagged_comments(v): + + page = max(1, int(request.args.get("page", 1))) + + posts = g.db.query(Comment + ).filter_by( + is_approved=0, + purged_utc=0, + is_banned=False + ).join(Comment.flags).options(contains_eager(Comment.flags) + ).order_by(Comment.id.desc()).offset(25 * (page - 1)).limit(26).all() + + listing = [p.id for p in posts] + next_exists = (len(listing) == 26) + listing = listing[0:25] + + listing = get_comments(listing, v=v) + + return render_template("admin/flagged_comments.html", + next_exists=next_exists, + listing=listing, + page=page, + v=v, + standalone=True) + + +@app.route("/admin", methods=["GET"]) +@admin_level_required(3) +def admin_home(v): + b = g.db.query(Board).filter_by(id=1).first() + return render_template("admin/admin_home.html", v=v, b=b) + + +@app.route("/admin/badge_grant", methods=["GET"]) +@admin_level_required(4) +def badge_grant_get(v): + + badge_types = g.db.query(BadgeDef).filter_by( + kind=3).order_by(BadgeDef.rank).all() + + errors = {"already_owned": "That user already has that badge.", + "no_user": "That user doesn't exist." + } + + return render_template("admin/badge_grant.html", + v=v, + badge_types=badge_types, + error=errors.get( + request.args.get("error"), + None) if request.args.get('error') else None, + msg="Badge successfully assigned" if request.args.get( + "msg") else None + ) + + +@app.route("/admin/badge_grant", methods=["POST"]) +@admin_level_required(4) +@validate_formkey +def badge_grant_post(v): + + user = get_user(request.form.get("username"), graceful=True) + if not user: return redirect("/badge_grant?error=no_user") + + badge_id = int(request.form.get("badge_id")) + + badge = g.db.query(BadgeDef).filter_by(id=badge_id).first() + if badge.kind != 3: + abort(403) + + if user.has_badge(badge_id): + g.db.query(Badge).filter_by(badge_id=badge_id, user_id=user.id,).delete() + g.db.commit() + return redirect(user.permalink) + + new_badge = Badge(badge_id=badge_id, + user_id=user.id, + created_utc=int(time.time()) + ) + + desc = request.form.get("description") + if desc: new_badge.description = desc + + url = request.form.get("url") + if url: new_badge.url = url + + g.db.add(new_badge) + + g.db.commit() + + text = f""" + @{v.username} has given you the following profile badge: + \n\n![]({new_badge.path}) + \n\n{new_badge.name} + """ + + send_notification(1046, user, text) + + return redirect(user.permalink) + + +@app.route("/admin/users", methods=["GET"]) +@admin_level_required(2) +def users_list(v): + + page = int(request.args.get("page", 1)) + + users = g.db.query(User).filter_by(is_banned=0 + ).order_by(User.created_utc.desc() + ).offset(25 * (page - 1)).limit(26) + + users = [x for x in users] + + next_exists = (len(users) == 26) + users = users[0:25] + + return render_template("admin/new_users.html", + v=v, + users=users, + next_exists=next_exists, + page=page, + ) + + +@app.route("/admin/content_stats", methods=["GET"]) +@admin_level_required(2) +def participation_stats(v): + + now = int(time.time()) + + day = now - 86400 + + data = {"valid_users": g.db.query(User).count(), + "private_users": g.db.query(User).filter_by(is_private=False).count(), + "banned_users": g.db.query(User).filter(User.is_banned > 0, User.unban_utc == 0).count(), + "verified_users": g.db.query(User).filter_by(is_activated=True).count(), + "signups_last_24h": g.db.query(User).filter(User.created_utc > day).count(), + "total_posts": g.db.query(Submission).count(), + "posting_users": g.db.query(Submission.author_id).distinct().count(), + "listed_posts": g.db.query(Submission).filter_by(is_banned=False).filter(Submission.deleted_utc > 0).count(), + "removed_posts": g.db.query(Submission).filter_by(is_banned=True).count(), + "deleted_posts": g.db.query(Submission).filter(Submission.deleted_utc > 0).count(), + "posts_last_24h": g.db.query(Submission).filter(Submission.created_utc > day).count(), + "total_comments": g.db.query(Comment).count(), + "commenting_users": g.db.query(Comment.author_id).distinct().count(), + "removed_comments": g.db.query(Comment).filter_by(is_banned=True).count(), + "deleted_comments": g.db.query(Comment).filter(Comment.deleted_utc>0).count(), + "comments_last_24h": g.db.query(Comment).filter(Comment.created_utc > day).count(), + "post_votes": g.db.query(Vote).count(), + "post_voting_users": g.db.query(Vote.user_id).distinct().count(), + "post_votes_last_24h": g.db.query(Vote).filter(Vote.created_utc > day).count(), + "comment_votes": g.db.query(CommentVote).count(), + "comment_voting_users": g.db.query(CommentVote.user_id).distinct().count(), + "comment_votes_last_24h": g.db.query(CommentVote).filter(CommentVote.created_utc > day).count() + } + + + return render_template("admin/content_stats.html", v=v, title="Content Statistics", data=data) + + +@app.route("/votes", methods=["GET"]) +@auth_desired +def admin_vote_info_get(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + if not request.args.get("link"): + return render_template("admin/votes.html", v=v) + + thing = get_from_permalink(request.args.get("link"), v=v) + + if isinstance(thing, Submission): + + ups = g.db.query(Vote + ).options(joinedload(Vote.user) + ).filter_by(submission_id=thing.id, vote_type=1 + ).order_by(Vote.creation_ip.asc() + ).all() + + downs = g.db.query(Vote + ).options(joinedload(Vote.user) + ).filter_by(submission_id=thing.id, vote_type=-1 + ).order_by(Vote.creation_ip.asc() + ).all() + + elif isinstance(thing, Comment): + + ups = g.db.query(CommentVote + ).options(joinedload(CommentVote.user) + ).filter_by(comment_id=thing.id, vote_type=1 + ).order_by(CommentVote.creation_ip.asc() + ).all() + + downs = g.db.query(CommentVote + ).options(joinedload(CommentVote.user) + ).filter_by(comment_id=thing.id, vote_type=-1 + ).order_by(CommentVote.creation_ip.asc() + ).all() + + else: + abort(400) + + return render_template("admin/votes.html", + v=v, + thing=thing, + ups=ups, + downs=downs,) + + +@app.route("/admin/alt_votes", methods=["GET"]) +@admin_level_required(4) +def alt_votes_get(v): + + if not request.args.get("u1") or not request.args.get("u2"): + return render_template("admin/alt_votes.html", v=v) + + u1 = request.args.get("u1") + u2 = request.args.get("u2") + + if not u1 or not u2: + return redirect("/admin/alt_votes") + + u1 = get_user(u1) + u2 = get_user(u2) + + u1_post_ups = g.db.query( + Vote.submission_id).filter_by( + user_id=u1.id, + vote_type=1).all() + u1_post_downs = g.db.query( + Vote.submission_id).filter_by( + user_id=u1.id, + vote_type=-1).all() + u1_comment_ups = g.db.query( + CommentVote.comment_id).filter_by( + user_id=u1.id, + vote_type=1).all() + u1_comment_downs = g.db.query( + CommentVote.comment_id).filter_by( + user_id=u1.id, + vote_type=-1).all() + u2_post_ups = g.db.query( + Vote.submission_id).filter_by( + user_id=u2.id, + vote_type=1).all() + u2_post_downs = g.db.query( + Vote.submission_id).filter_by( + user_id=u2.id, + vote_type=-1).all() + u2_comment_ups = g.db.query( + CommentVote.comment_id).filter_by( + user_id=u2.id, + vote_type=1).all() + u2_comment_downs = g.db.query( + CommentVote.comment_id).filter_by( + user_id=u2.id, + vote_type=-1).all() + + data = {} + data['u1_only_post_ups'] = len( + [x for x in u1_post_ups if x not in u2_post_ups]) + data['u2_only_post_ups'] = len( + [x for x in u2_post_ups if x not in u1_post_ups]) + data['both_post_ups'] = len(list(set(u1_post_ups) & set(u2_post_ups))) + + data['u1_only_post_downs'] = len( + [x for x in u1_post_downs if x not in u2_post_downs]) + data['u2_only_post_downs'] = len( + [x for x in u2_post_downs if x not in u1_post_downs]) + data['both_post_downs'] = len( + list(set(u1_post_downs) & set(u2_post_downs))) + + data['u1_only_comment_ups'] = len( + [x for x in u1_comment_ups if x not in u2_comment_ups]) + data['u2_only_comment_ups'] = len( + [x for x in u2_comment_ups if x not in u1_comment_ups]) + data['both_comment_ups'] = len( + list(set(u1_comment_ups) & set(u2_comment_ups))) + + data['u1_only_comment_downs'] = len( + [x for x in u1_comment_downs if x not in u2_comment_downs]) + data['u2_only_comment_downs'] = len( + [x for x in u2_comment_downs if x not in u1_comment_downs]) + data['both_comment_downs'] = len( + list(set(u1_comment_downs) & set(u2_comment_downs))) + + data['u1_post_ups_unique'] = 100 * \ + data['u1_only_post_ups'] // len(u1_post_ups) if u1_post_ups else 0 + data['u2_post_ups_unique'] = 100 * \ + data['u2_only_post_ups'] // len(u2_post_ups) if u2_post_ups else 0 + data['u1_post_downs_unique'] = 100 * \ + data['u1_only_post_downs'] // len( + u1_post_downs) if u1_post_downs else 0 + data['u2_post_downs_unique'] = 100 * \ + data['u2_only_post_downs'] // len( + u2_post_downs) if u2_post_downs else 0 + + data['u1_comment_ups_unique'] = 100 * \ + data['u1_only_comment_ups'] // len( + u1_comment_ups) if u1_comment_ups else 0 + data['u2_comment_ups_unique'] = 100 * \ + data['u2_only_comment_ups'] // len( + u2_comment_ups) if u2_comment_ups else 0 + data['u1_comment_downs_unique'] = 100 * \ + data['u1_only_comment_downs'] // len( + u1_comment_downs) if u1_comment_downs else 0 + data['u2_comment_downs_unique'] = 100 * \ + data['u2_only_comment_downs'] // len( + u2_comment_downs) if u2_comment_downs else 0 + + return render_template("admin/alt_votes.html", + u1=u1, + u2=u2, + v=v, + data=data + ) + + +@app.route("/admin/link_accounts", methods=["POST"]) +@admin_level_required(4) +@validate_formkey +def admin_link_accounts(v): + + u1 = int(request.form.get("u1")) + u2 = int(request.form.get("u2")) + + new_alt = Alt( + user1=u1, + user2=u2, + is_manual=True + ) + + g.db.add(new_alt) + g.db.commit() + + return redirect(f"/admin/alt_votes?u1={g.db.query(User).get(u1).username}&u2={g.db.query(User).get(u2).username}") + + +@app.route("/admin/removed", methods=["GET"]) +@admin_level_required(3) +def admin_removed(v): + + page = int(request.args.get("page", 1)) + + ids = g.db.query(Submission.id).options(lazyload('*')).filter_by(is_banned=True).order_by( + Submission.id.desc()).offset(25 * (page - 1)).limit(26).all() + + ids=[x[0] for x in ids] + + next_exists = len(ids) == 26 + + ids = ids[0:25] + + posts = get_posts(ids, v=v) + + return render_template("admin/removed_posts.html", + v=v, + listing=posts, + page=page, + next_exists=next_exists + ) + + +@app.route("/admin/appdata", methods=["GET"]) +@admin_level_required(4) +def admin_appdata(v): + + url=request.args.get("link") + + if url: + + thing = get_from_permalink(url, v=v) + + return render_template( + "admin/app_data.html", + v=v, + thing=thing + ) + + else: + return render_template( + "admin/app_data.html", + v=v) + + +@app.route("/admin/domain/", methods=["GET"]) +@admin_level_required(4) +def admin_domain_domain(domain_name, v): + + d_query=domain_name.replace("_","\_") + domain=g.db.query(Domain).filter_by(domain=d_query).first() + + if not domain: + domain=Domain(domain=domain_name) + + return render_template( + "admin/manage_domain.html", + v=v, + domain_name=domain_name, + domain=domain, + reasons=REASONS + ) + + +@app.route("/admin/image_purge", methods=["POST"]) +@admin_level_required(5) +def admin_image_purge(v): + + url=request.form.get("url") + aws.delete_file(url) + return redirect("/admin/image_purge") + + +@app.route("/admin/image_ban", methods=["POST"]) +@admin_level_required(4) +@validate_formkey +def admin_image_ban(v): + + i=request.files['file'] + + + #make phash + tempname = f"admin_image_ban_{v.username}_{int(time.time())}" + + i.save(tempname) + + h=imagehash.phash(IMAGE.open(tempname)) + h=hex2bin(str(h)) + + #check db for existing + badpic = g.db.query(BadPic).filter_by( + phash=h + ).first() + + remove(tempname) + + if badpic: + return render_template("admin/image_ban.html", v=v, existing=badpic) + + new_bp=BadPic( + phash=h, + ban_reason=request.form.get("ban_reason"), + ban_time=int(request.form.get("ban_length",0)) + ) + + g.db.add(new_bp) + g.db.commit() + + return render_template("admin/image_ban.html", v=v, success=True) + + +@app.route("/agendaposter/", methods=["POST"]) +@admin_level_required(6) +@validate_formkey +def agendaposter(user_id, v): + user = g.db.query(User).filter_by(id=user_id).first() + + expiry = request.form.get("days", 0) + if expiry: + expiry = int(expiry) + expiry = g.timestamp + expiry*60*60*24 + else: + expiry = 0 + + user.agendaposter = not user.agendaposter + user.agendaposter_expires_utc = expiry + g.db.add(user) + for alt in user.alts: + if alt.admin_level > 0: break + alt.agendaposter = user.agendaposter + alt.agendaposter_expires_utc = expiry + g.db.add(alt) + + note = None + + if not user.agendaposter: kind = "unagendaposter" + else: + kind = "agendaposter" + note = f"for {request.form.get('days')} days" if expiry else "never expires" + + ma = ModAction( + kind=kind, + user_id=v.id, + target_user_id=user.id, + board_id=1, + note = note + ) + g.db.add(ma) + + if 'toast' in request.args: + return "", 204 + else: + return redirect(user.url) + + +@app.route("/patron/", methods=["POST"]) +@admin_level_required(6) +@validate_formkey +def patron(user_id, v): + user = g.db.query(User).filter_by(id=user_id).first() + user.patron = not user.patron + g.db.add(user) + return "", 204 + + +@app.route("/disablesignups", methods=["POST"]) +@admin_level_required(6) +@validate_formkey +def disablesignups(v): + board = g.db.query(Board).filter_by(id=1).first() + board.disablesignups = not board.disablesignups + g.db.add(board) + return "", 204 + + +@app.route("/shadowban/", methods=["POST"]) +@admin_level_required(6) +@validate_formkey +def shadowban(user_id, v): + user = g.db.query(User).filter_by(id=user_id).first() + if user.admin_level != 0: abort(403) + user.shadowbanned = True + g.db.add(user) + for alt in user.alts: + if alt.admin_level > 0: break + alt.shadowbanned = True + g.db.add(alt) + ma = ModAction( + kind="shadowban", + user_id=v.id, + target_user_id=user.id, + board_id=1, + ) + g.db.add(ma) + cache.delete_memoized(frontlist) + return "", 204 + + +@app.route("/unshadowban/", methods=["POST"]) +@admin_level_required(6) +@validate_formkey +def unshadowban(user_id, v): + user = g.db.query(User).filter_by(id=user_id).first() + if user.admin_level != 0: abort(403) + user.shadowbanned = False + g.db.add(user) + for alt in user.alts: + alt.shadowbanned = False + g.db.add(alt) + + ma = ModAction( + kind="unshadowban", + user_id=v.id, + target_user_id=user.id, + board_id=1, + ) + g.db.add(ma) + cache.delete_memoized(frontlist) + return "", 204 + + +@app.route("/admin/title_change/", methods=["POST"]) +@admin_level_required(6) +@validate_formkey +def admin_title_change(user_id, v): + + user = g.db.query(User).filter_by(id=user_id).first() + + if user.admin_level != 0: abort(403) + + new_name=request.form.get("title").strip() + + user.customtitleplain=new_name + new_name=new_name.replace('_','\_') + new_name = sanitize(new_name, linkgen=True) + + user=g.db.query(User).with_for_update().options(lazyload('*')).filter_by(id=user.id).first() + user.customtitle=new_name + user.flairchanged = bool(request.form.get("locked")) + g.db.add(user) + + if user.flairchanged: kind = "set_flair_locked" + else: kind = "set_flair_notlocked" + + ma=ModAction( + kind=kind, + user_id=v.id, + target_user_id=user.id, + board_id=1, + note=f'"{new_name}"' + ) + g.db.add(ma) + + return (redirect(user.url), user) + +@app.route("/api/ban_user/", methods=["POST"]) +@admin_level_required(6) +@validate_formkey +def ban_user(user_id, v): + + user = g.db.query(User).filter_by(id=user_id).first() + + if user.admin_level != 0: abort(403) + + # check for number of days for suspension + days = int(request.form.get("days")) if request.form.get('days') else 0 + reason = request.form.get("reason", "") + message = request.form.get("reason", "") + + if not user: abort(400) + + if user.admin_level > 0: abort(403) + + if days > 0: + if message: + text = f"Your Drama account has been suspended for {days} days for the following reason:\n\n> {message}" + else: + text = f"Your Drama account has been suspended for {days} days." + user.ban(admin=v, reason=reason, days=days) + + else: + if message: + text = f"Your Drama account has been permanently suspended for the following reason:\n\n> {message}" + else: + text = "Your Drama account has been permanently suspended." + + user.ban(admin=v, reason=reason) + + + for x in user.alts: + if x.admin_level > 0: break + x.ban(admin=v, reason=reason) + + send_notification(1046, user, text) + + if days == 0: duration = "permenant" + elif days == 1: duration = "1 day" + else: duration = f"{days} days" + ma=ModAction( + kind="exile_user", + user_id=v.id, + target_user_id=user.id, + board_id=1, + note=f'reason: "{reason}", duration: {duration}' + ) + g.db.add(ma) + g.db.commit() + + if request.args.get("notoast"): return (redirect(user.url), user) + + return jsonify({"message": f"@{user.username} was banned"}) + + +@app.route("/api/unban_user/", methods=["POST"]) +@admin_level_required(6) +@validate_formkey +def unban_user(user_id, v): + + user = g.db.query(User).filter_by(id=user_id).first() + + if not user: + abort(400) + + user.unban() + + for x in user.alts: + if x.admin_level == 0: + x.unban() + + send_notification(1046, user, + "Your Drama account has been reinstated. Please carefully review and abide by the [rules](/post/2510) to ensure that you don't get suspended again.") + + ma=ModAction( + kind="unexile_user", + user_id=v.id, + target_user_id=user.id, + board_id=1, + ) + g.db.add(ma) + g.db.commit() + + if request.args.get("notoast"): return (redirect(user.url), user) + return jsonify({"message": f"@{user.username} was unbanned"}) + +@app.route("/api/ban_post/", methods=["POST"]) +@admin_level_required(3) +@validate_formkey +def ban_post(post_id, v): + + post = g.db.query(Submission).filter_by(id=base36decode(post_id)).first() + + if not post: + abort(400) + + post.is_banned = True + post.is_approved = 0 + post.approved_utc = 0 + post.stickied = False + post.is_pinned = False + + ban_reason=request.form.get("reason", "") + with CustomRenderer() as renderer: + ban_reason = renderer.render(mistletoe.Document(ban_reason)) + ban_reason = sanitize(ban_reason, linkgen=True) + + post.ban_reason = ban_reason + + g.db.add(post) + + cache.delete_memoized(frontlist) + + ma=ModAction( + kind="ban_post", + user_id=v.id, + target_submission_id=post.id, + board_id=post.board_id, + ) + g.db.add(ma) + return "", 204 + + +@app.route("/api/unban_post/", methods=["POST"]) +@admin_level_required(3) +@validate_formkey +def unban_post(post_id, v): + + post = g.db.query(Submission).filter_by(id=base36decode(post_id)).first() + + if not post: + abort(400) + + if post.is_banned: + ma=ModAction( + kind="unban_post", + user_id=v.id, + target_submission_id=post.id, + board_id=post.board_id, + ) + g.db.add(ma) + + post.is_banned = False + post.is_approved = v.id + post.approved_utc = int(time.time()) + + g.db.add(post) + + cache.delete_memoized(frontlist) + + return "", 204 + + +@app.route("/api/distinguish/", methods=["POST"]) +@admin_level_required(1) +@validate_formkey +def api_distinguish_post(post_id, v): + + post = g.db.query(Submission).filter_by(id=base36decode(post_id)).first() + + if not post: + abort(404) + + if not post.author_id == v.id: + abort(403) + + if post.distinguish_level: + post.distinguish_level = 0 + else: + post.distinguish_level = v.admin_level + + g.db.add(post) + + return "", 204 + + +@app.route("/api/sticky/", methods=["POST"]) +@admin_level_required(3) +def api_sticky_post(post_id, v): + + post = g.db.query(Submission).filter_by(id=base36decode(post_id)).first() + if post: + post.stickied = not (post.stickied) + g.db.add(post) + g.db.commit() + cache.delete_memoized(frontlist) + + return "", 204 + +@app.route("/api/pin/", methods=["POST"]) +@auth_required +def api_pin_post(post_id, v): + + post = g.db.query(Submission).filter_by(id=base36decode(post_id)).first() + if post: + post.is_pinned = not (post.is_pinned) + g.db.add(post) + + return "", 204 + +@app.route("/api/ban_comment/", methods=["post"]) +@admin_level_required(1) +def api_ban_comment(c_id, v): + + comment = g.db.query(Comment).filter_by(id=base36decode(c_id)).first() + if not comment: + abort(404) + + comment.is_banned = True + comment.is_approved = 0 + comment.approved_utc = 0 + + g.db.add(comment) + ma=ModAction( + kind="ban_comment", + user_id=v.id, + target_comment_id=comment.id, + board_id=comment.post.board_id, + ) + g.db.add(ma) + return "", 204 + + +@app.route("/api/unban_comment/", methods=["post"]) +@admin_level_required(1) +def api_unban_comment(c_id, v): + + comment = g.db.query(Comment).filter_by(id=base36decode(c_id)).first() + if not comment: + abort(404) + g.db.add(comment) + + if comment.is_banned: + ma=ModAction( + kind="unban_comment", + user_id=v.id, + target_comment_id=comment.id, + board_id=comment.post.board_id, + ) + g.db.add(ma) + + comment.is_banned = False + comment.is_approved = v.id + comment.approved_utc = int(time.time()) + + + return "", 204 + + +@app.route("/api/distinguish_comment/", methods=["post"]) +@app.route("/api/v1/distinguish_comment/", methods=["post"]) +@auth_required +@api("read") +def admin_distinguish_comment(c_id, v): + + if v.admin_level == 0: abort(403) + + comment = get_comment(c_id, v=v) + + if comment.author_id != v.id: + abort(403) + + comment.distinguish_level = 0 if comment.distinguish_level else v.admin_level + + g.db.add(comment) + g.db.commit() + html=render_template( + "comments.html", + v=v, + comments=[comment], + render_replies=False, + is_allowed_to_comment=True + ) + + html=str(BeautifulSoup(html, features="html.parser").find(id=f"comment-{comment.base36id}-only")) + + return jsonify({"html":html, "api":html}) + + +@app.route("/admin/dump_cache", methods=["GET"]) +@admin_level_required(3) +@validate_formkey +def admin_dump_cache(v): + cache.clear() + return jsonify({"message": "Internal cache cleared."}) + + +@app.route("/admin/ban_domain", methods=["POST"]) +@admin_level_required(4) +@validate_formkey +def admin_ban_domain(v): + + domain=request.form.get("domain",'').strip() + + if not domain: + abort(400) + + reason=int(request.form.get("reason",0)) + if not reason: + abort(400) + + d_query=domain.replace("_","\_") + d=g.db.query(Domain).filter_by(domain=d_query).first() + if d: + d.can_submit=False + d.can_comment=False + d.reason=reason + else: + d=Domain( + domain=domain, + can_submit=False, + can_comment=False, + reason=reason, + show_thumbnail=False, + embed_function=None, + embed_template=None + ) + + g.db.add(d) + g.db.commit() + return redirect(d.permalink) + + +@app.route("/admin/nuke_user", methods=["POST"]) +@admin_level_required(4) +@validate_formkey +def admin_nuke_user(v): + + user=get_user(request.form.get("user")) + + for post in g.db.query(Submission).filter_by(author_id=user.id).all(): + if post.is_banned: + continue + + post.is_banned=True + g.db.add(post) + + for comment in g.db.query(Comment).filter_by(author_id=user.id).all(): + if comment.is_banned: + continue + + comment.is_banned=True + g.db.add(comment) + + ma=ModAction( + kind="nuke_user", + user_id=v.id, + target_user_id=user.id, + board_id=1, + ) + g.db.add(ma) + + return redirect(user.permalink) + +@app.route("/admin/unnuke_user", methods=["POST"]) +@admin_level_required(4) +@validate_formkey +def admin_nunuke_user(v): + + user=get_user(request.form.get("user")) + + for post in g.db.query(Submission).filter_by(author_id=user.id).all(): + if not post.is_banned: + continue + + post.is_banned=False + g.db.add(post) + + for comment in g.db.query(Comment).filter_by(author_id=user.id).all(): + if not comment.is_banned: + continue + + comment.is_banned=False + g.db.add(comment) + + ma=ModAction( + kind="unnuke_user", + user_id=v.id, + target_user_id=user.id, + board_id=1, + ) + g.db.add(ma) + + return redirect(user.permalink) + +@app.route("/api/user_stat_data", methods=['GET']) +@admin_level_required(2) +@cache.memoize(timeout=60) +def user_stat_data(v): + + days = int(request.args.get("days", 25)) + + now = time.gmtime() + midnight_this_morning = time.struct_time((now.tm_year, + now.tm_mon, + now.tm_mday, + 0, + 0, + 0, + now.tm_wday, + now.tm_yday, + 0) + ) + today_cutoff = calendar.timegm(midnight_this_morning) + + day = 3600 * 24 + + day_cutoffs = [today_cutoff - day * i for i in range(days)] + day_cutoffs.insert(0, calendar.timegm(now)) + + daily_signups = [{"date": time.strftime("%d", time.gmtime(day_cutoffs[i + 1])), + "day_start":day_cutoffs[i + 1], + "signups": g.db.query(User).filter(User.created_utc < day_cutoffs[i], + User.created_utc > day_cutoffs[i + 1], + User.is_banned == 0 + ).count() + } for i in range(len(day_cutoffs) - 1) + ] + + user_stats = {'current_users': g.db.query(User).filter_by(is_banned=0, reserved=None).count(), + 'banned_users': g.db.query(User).filter(User.is_banned != 0).count(), + 'reserved_users': g.db.query(User).filter(User.reserved is not None).count(), + 'email_verified_users': g.db.query(User).filter_by(is_banned=0, is_activated=True).count(), + 'real_id_verified_users': g.db.query(User).filter(User.reserved is not None, User.real_id is not None).count() + } + + post_stats = [{"date": time.strftime("%d", time.gmtime(day_cutoffs[i + 1])), + "day_start":day_cutoffs[i + 1], + "posts": g.db.query(Submission).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) + ] + + comment_stats = [{"date": time.strftime("%d", time.gmtime(day_cutoffs[i + 1])), + "day_start": day_cutoffs[i + 1], + "comments": g.db.query(Comment).filter(Comment.created_utc < day_cutoffs[i], + Comment.created_utc > day_cutoffs[i + 1], + Comment.is_banned == False, + Comment.author_id != 1 + ).count() + } for i in range(len(day_cutoffs) - 1) + ] + + vote_stats = [{"date": time.strftime("%d", time.gmtime(day_cutoffs[i + 1])), + "day_start": day_cutoffs[i + 1], + "votes": g.db.query(Vote).join(Vote.user).filter(Vote.created_utc < day_cutoffs[i], + Vote.created_utc > day_cutoffs[i + 1], + User.is_banned == 0 + ).count() + } for i in range(len(day_cutoffs) - 1) + ] + + x = create_plot(sign_ups={'daily_signups': daily_signups}, + posts={'post_stats': post_stats}, + comments={'comment_stats': comment_stats}, + votes={'vote_stats': vote_stats} + ) + + final = { + "multi_plot": x, + "user_stats": user_stats, + "signup_data": daily_signups, + "post_data": post_stats, + "comment_data": comment_stats, + "vote_data": vote_stats + } + + return jsonify(final) + + +def create_plot(**kwargs): + + if not kwargs: + return abort(400) + + # create multiple charts + daily_signups = [d["signups"] for d in kwargs["sign_ups"]['daily_signups']][::-1] + post_stats = [d["posts"] for d in kwargs["posts"]['post_stats']][::-1] + comment_stats = [d["comments"] for d in kwargs["comments"]['comment_stats']][::-1] + vote_stats = [d["votes"] for d in kwargs["votes"]['vote_stats']][::-1] + daily_times = [d["date"] for d in kwargs["sign_ups"]['daily_signups']] + + multi_plots = multiple_plots(sign_ups=daily_signups, + posts=post_stats, + comments=comment_stats, + votes=vote_stats, + daily_times=daily_times) + + return multi_plots + + +def multiple_plots(**kwargs): + + # create multiple charts + signup_chart = plt.subplot2grid((20, 4), (0, 0), rowspan=5, colspan=4) + posts_chart = plt.subplot2grid((20, 4), (5, 0), rowspan=5, colspan=4) + comments_chart = plt.subplot2grid((20, 4), (10, 0), rowspan=5, colspan=4) + votes_chart = plt.subplot2grid((20, 4), (15, 0), rowspan=5, colspan=4) + + signup_chart.grid(), posts_chart.grid( + ), comments_chart.grid(), votes_chart.grid() + + signup_chart.plot( + kwargs['daily_times'][::-1], + kwargs['sign_ups'], + color='red') + posts_chart.plot( + kwargs['daily_times'][::-1], + kwargs['posts'], + color='green') + comments_chart.plot( + kwargs['daily_times'][::-1], + kwargs['comments'], + color='gold') + votes_chart.plot( + kwargs['daily_times'][::-1], + kwargs['votes'], + color='silver') + + signup_chart.set_ylabel("Signups") + posts_chart.set_ylabel("Posts") + comments_chart.set_ylabel("Comments") + votes_chart.set_ylabel("Votes") + comments_chart.set_xlabel("Time (UTC)") + votes_chart.set_xlabel("Time (UTC)") + + signup_chart.legend(loc='upper left', frameon=True) + posts_chart.legend(loc='upper left', frameon=True) + comments_chart.legend(loc='upper left', frameon=True) + votes_chart.legend(loc='upper left', frameon=True) + + now = int(time.time()) + name = "multiplot.png" + + plt.savefig(name) + plt.clf() + + return upload_from_file(name, name) \ No newline at end of file diff --git a/ruqqus/routes/boards.py b/ruqqus/routes/boards.py new file mode 100644 index 000000000..45de3e9fb --- /dev/null +++ b/ruqqus/routes/boards.py @@ -0,0 +1,319 @@ +from ruqqus.helpers.wrappers import * +from ruqqus.helpers.alerts import * +from ruqqus.classes import * +from flask import * +from ruqqus.__main__ import app, limiter, cache + +valid_board_regex = re.compile("^[a-zA-Z0-9][a-zA-Z0-9_]{2,24}$") + +@app.route("/mod/distinguish_post//", methods=["POST"]) +@app.route("/api/v1/distinguish_post//", methods=["POST"]) +@auth_required +@is_guildmaster("content") +@api("guildmaster") +def mod_distinguish_post(bid, pid, board, v): + + #print(pid, board, v) + + post = get_post(pid, v=v) + + if not post.board_id==board.id: + abort(400) + + if post.author_id != v.id: + abort(403) + + if post.gm_distinguish: + post.gm_distinguish = 0 + else: + post.gm_distinguish = board.id + g.db.add(post) + + ma=ModAction( + kind="herald_post" if post.gm_distinguish else "unherald_post", + user_id=v.id, + target_submission_id=post.id, + board_id=board.id + ) + g.db.add(ma) + + return "", 204 + +@app.route("/mod/invite_mod/", methods=["POST"]) +@auth_required +@is_guildmaster("full") +@validate_formkey +def mod_invite_username(bid, board, v): + + username = request.form.get("username", '').lstrip('@') + user = get_user(username) + if not user: + return jsonify({"error": "That user doesn't exist."}), 404 + + if board.has_ban(user): + return jsonify({"error": f"@{user.username} is exiled from +{board.name} and can't currently become a guildmaster."}), 409 + if not user.can_join_gms: + return jsonify({"error": f"@{user.username} already leads enough guilds."}), 409 + + x = g.db.query(ModRelationship).filter_by( + user_id=user.id, board_id=board.id).first() + + if x and x.accepted: + return jsonify({"error": f"@{user.username} is already a mod."}), 409 + + if x and not x.invite_rescinded: + return jsonify({"error": f"@{user.username} has already been invited."}), 409 + + if x: + + x.invite_rescinded = False + g.db.add(x) + + else: + x = ModRelationship( + user_id=user.id, + board_id=board.id, + accepted=False, + perm_full=True, + perm_content=True, + perm_appearance=True, + perm_access=True, + perm_config=True + ) + + text = f"You have been invited to become an admin. You can [click here](/badmins) and accept this invitation. Or, if you weren't expecting this, you can ignore it." + send_notification(1046, user, text) + + g.db.add(x) + + ma=ModAction( + kind="invite_mod", + user_id=v.id, + target_user_id=user.id, + board_id=1 + ) + g.db.add(ma) + + return "", 204 + + +@app.route("/mod//rescind/", methods=["POST"]) +@auth_required +@is_guildmaster("full") +@validate_formkey +def mod_rescind_bid_username(bid, username, board, v): + + user = get_user(username) + + invitation = g.db.query(ModRelationship).filter_by(board_id=board.id, + user_id=user.id, + accepted=False).first() + if not invitation: + abort(404) + + invitation.invite_rescinded = True + + g.db.add(invitation) + ma=ModAction( + kind="uninvite_mod", + user_id=v.id, + target_user_id=user.id, + board_id=1 + ) + g.db.add(ma) + return "", 204 + + +@app.route("/mod/accept/", methods=["POST"]) +@app.route("/api/v1/accept_invite/", methods=["POST"]) +@auth_required +@validate_formkey +@api("guildmaster") +def mod_accept_board(bid, v): + + board = get_board(bid) + + x = board.has_invite(v) + if not x: + abort(404) + + if not v.can_join_gms: + return jsonify({"error": f"You already lead enough guilds."}), 409 + if board.has_ban(v): + return jsonify({"error": f"You are exiled from +{board.name} and can't currently become a guildmaster."}), 409 + x.accepted = True + x.created_utc=int(time.time()) + g.db.add(x) + + ma=ModAction( + kind="accept_mod_invite", + user_id=v.id, + target_user_id=v.id, + board_id=board.id + ) + g.db.add(ma) + + v.admin_level = 6 + return "", 204 + +@app.route("/mod//step_down", methods=["POST"]) +@auth_required +@is_guildmaster() +@validate_formkey +def mod_step_down(bid, board, v): + + + v_mod = board.has_mod(v) + + if not v_mod: + abort(404) + + g.db.delete(v_mod) + + ma=ModAction( + kind="dethrone_self", + user_id=v.id, + target_user_id=v.id, + board_id=board.id + ) + g.db.add(ma) + v.admin_level = 0 + return "", 204 + + + +@app.route("/mod//remove/", methods=["POST"]) +@auth_required +@is_guildmaster("full") +@validate_formkey +def mod_remove_username(bid, username, board, v): + + user = get_user(username) + + u_mod = board.has_mod(user) + v_mod = board.has_mod(v) + + if not u_mod: + abort(400) + elif not v_mod: + abort(400) + + if not u_mod.board_id==board.id: + abort(400) + + if not v_mod.board_id==board.id: + abort(400) + + if v_mod.id > u_mod.id: + abort(403) + + g.db.delete(u_mod) + + ma=ModAction( + kind="remove_mod", + user_id=v.id, + target_user_id=user.id, + board_id=board.id + ) + g.db.add(ma) + + user.admin_level = 0 + return "", 204 + +@app.route("/badmins", methods=["GET"]) +@app.route("/api/vue/mod/mods", methods=["GET"]) +@app.route("/api/v1/mod/mods", methods=["GET"]) +@auth_desired +@public("read") +def board_about_mods(v): + + board = get_guild("general") + + me = board.has_mod(v) + + return { + "html":lambda:render_template("mods.html", v=v, b=board, me=me), + "api":lambda:jsonify({"data":[x.json for x in board.mods_list]}) + } + +@app.route("/log", methods=["GET"]) +@app.route("/api/v1/mod_log", methods=["GET"]) +@auth_desired +@api("read") +def board_mod_log(v): + + page=int(request.args.get("page",1)) + + if v and v.admin_level == 6: actions = g.db.query(ModAction).order_by(ModAction.id.desc()).offset(25 * (page - 1)).limit(26).all() + else: actions=g.db.query(ModAction).filter(ModAction.kind!="shadowban", ModAction.kind!="unshadowban").order_by(ModAction.id.desc()).offset(25*(page-1)).limit(26).all() + actions=[i for i in actions] + + next_exists=len(actions)==26 + actions=actions[0:25] + + return { + "html":lambda:render_template( + "modlog.html", + v=v, + actions=actions, + next_exists=next_exists, + page=page + ), + "api":lambda:jsonify({"data":[x.json for x in actions]}) + } + +@app.route("/log/", methods=["GET"]) +@auth_desired +def mod_log_item(aid, v): + + action=g.db.query(ModAction).filter_by(id=base36decode(aid)).first() + + if not action: + abort(404) + + if request.path != action.permalink: + return redirect(action.permalink) + + return render_template("modlog.html", + v=v, + actions=[action], + next_exists=False, + page=1, + action=action + ) + +@app.route("/mod/edit_perms", methods=["POST"]) +@auth_required +@is_guildmaster("full") +@validate_formkey +def board_mod_perms_change(boardname, board, v): + + user=get_user(request.form.get("username")) + + v_mod=board.has_mod(v) + u_mod=board.has_mod_record(user) + + if v_mod.id > u_mod.id: + return jsonify({"error":"You can't change perms on badmins above you."}), 403 + + #print({x:request.form.get(x) for x in request.form}) + + u_mod.perm_full = bool(request.form.get("perm_full" , False)) + u_mod.perm_access = bool(request.form.get("perm_access" , False)) + u_mod.perm_appearance = bool(request.form.get("perm_appearance" , False)) + u_mod.perm_config = bool(request.form.get("perm_config" , False)) + u_mod.perm_content = bool(request.form.get("perm_content" , False)) + + g.db.add(u_mod) + g.db.commit() + + ma=ModAction( + kind="change_perms" if u_mod.accepted else "change_invite", + user_id=v.id, + board_id=board.id, + target_user_id=user.id, + note=u_mod.permchangelist + ) + g.db.add(ma) + + return redirect(f"{board.permalink}/mod/mods") \ No newline at end of file diff --git a/ruqqus/routes/comments.py b/ruqqus/routes/comments.py new file mode 100644 index 000000000..d97da9f35 --- /dev/null +++ b/ruqqus/routes/comments.py @@ -0,0 +1,912 @@ +import threading + +from ruqqus.helpers.wrappers import * +from ruqqus.helpers.filters import * +from ruqqus.helpers.alerts import * +from ruqqus.helpers.aws import * +from ruqqus.helpers.session import * +from ruqqus.classes import * +from ruqqus.routes.front import comment_idlist +from pusher_push_notifications import PushNotifications + +from flask import * +from ruqqus.__main__ import app, limiter + +choices = ['Wow, you must be a JP fan.', 'This is one of the worst posts I have EVER seen. Delete it.', "No, don't reply like this, please do another wall of unhinged rant please.", '# 😴😴😴', "Ma'am we've been over this before. You need to stop.", "I've known more coherent downies.", "Your pulitzer's in the mail", "That's great and all, but I asked for my burger without cheese.", 'That degree finally paying off', "That's nice sweaty. Why don't you have a seat in the time out corner with Pizzashill until you calm down, then you can have your Capri Sun.", "All them words won't bring your pa back.", "You had a chance to not be completely worthless, but it looks like you threw it away. At least you're consistent.", 'Some people are able to display their intelligence by going on at length on a subject and never actually saying anything. This ability is most common in trades such as politics, public relations, and law. You have impressed me by being able to best them all, while still coming off as an absolute idiot.', "You can type 10,000 characters and you decided that these were the one's that you wanted.", 'Have you owned the libs yet?', "I don't know what you said, because I've seen another human naked.", 'Impressive. Normally people with such severe developmental disabilities struggle to write much more than a sentence or two. He really has exceded our expectations for the writing portion. Sadly the coherency of his writing, along with his abilities in the social skills and reading portions, are far behind his peers with similar disabilities.', "This is a really long way of saying you don't fuck.", "Sorry ma'am, looks like his delusions have gotten worse. We'll have to admit him,", 'https://i.kym-cdn.com/photos/images/newsfeed/001/038/094/0a1.jpg', 'If only you could put that energy into your relationships', 'Posts like this is why I do Heroine.', 'still unemployed then?', 'K', 'look im gunna have 2 ask u 2 keep ur giant dumps in the toilet not in my replys 😷😷😷', "Mommy is soooo proud of you, sweaty. Let's put this sperg out up on the fridge with all your other failures.", "Good job bobby, here's a star", "That was a mistake. You're about to find out the hard way why.", 'You sat down and wrote all this shit. You could have done so many other things with your life. What happened to your life that made you decide writing novels of bullshit on reddit was the best option?', "I don't have enough spoons to read this shit", "All those words won't bring daddy back.", 'OUT!', "Mommy is soooo proud of you, sweaty. Let's put this sperg out up on the fridge with all your other failures."] + +PUSHER_KEY = environ.get("PUSHER_KEY", "").strip() + +beams_client = PushNotifications( + instance_id='02ddcc80-b8db-42be-9022-44c546b4dce6', + secret_key=PUSHER_KEY, +) + +@app.route("/api/v1/post//comment/", methods=["GET"]) +def comment_cid_api_redirect(cid=None, pid=None): + redirect(f'/api/v1/comment/') + +@app.route("/comment/", methods=["GET"]) +@app.route("/comment/", methods=["GET"]) +@app.route("/post_short//", methods=["GET"]) +@app.route("/post_short///", methods=["GET"]) +@app.route("/api/v1/comment/", methods=["GET"]) +@app.route("/post///", methods=["GET"]) +@app.route("/api/vue/comment/") +@auth_desired +@api("read") +def post_pid_comment_cid(cid, pid=None, anything=None, v=None): + + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + try: cid = int(cid) + except Exception as e: pass + + comment = get_comment(cid, v=v) + + if not comment.parent_submission and not (v and v.admin_level == 6): abort(403) + + if not pid: + if comment.parent_submission: pid = comment.parent_submission + else: pid = 6489 + + try: pid = int(pid) + except: abort(404) + + post = get_post(pid, v=v) + board = post.board + + if post.over_18 and not (v and v.over_18) and not session_over18(comment.board): + t = int(time.time()) + return {'html': lambda: render_template("errors/nsfw.html", + v=v, + t=t, + lo_formkey=make_logged_out_formkey( + t), + board=comment.board + ), + 'api': lambda: {'error': f'This content is not suitable for some users and situations.'} + + } + + post._preloaded_comments = [comment] + + # context improver + context = int(request.args.get("context", 0)) + comment_info = comment + c = comment + while context > 0 and not c.is_top_level: + + parent = get_comment(c.parent_comment_id, v=v) + + post._preloaded_comments += [parent] + + c = parent + context -= 1 + top_comment = c + + if v: defaultsortingcomments = v.defaultsortingcomments + else: defaultsortingcomments = "top" + sort=request.args.get("sort", defaultsortingcomments) + + # children comments + + current_ids = [comment.id] + + exile=g.db.query(ModAction + ).filter_by( + kind="exile_user" + ).distinct(ModAction.target_comment_id).subquery() + + for i in range(6 - context): + if v: + + votes = g.db.query(CommentVote).filter( + CommentVote.user_id == v.id).subquery() + + blocking = v.blocking.subquery() + blocked = v.blocked.subquery() + + + comms = g.db.query( + Comment, + votes.c.vote_type, + blocking.c.id, + blocked.c.id, + aliased(ModAction, alias=exile) + ).select_from(Comment).options( + joinedload(Comment.author).joinedload(User.title) + ).filter( + Comment.parent_comment_id.in_(current_ids) + ).join( + votes, + votes.c.comment_id == Comment.id, + isouter=True + ).join( + blocking, + blocking.c.target_id == Comment.author_id, + isouter=True + ).join( + blocked, + blocked.c.user_id == Comment.author_id, + isouter=True + ).join( + exile, + exile.c.target_comment_id==Comment.id, + isouter=True + ) + + if sort == "top": + comments = comms.order_by(Comment.score.desc()).all() + elif sort == "bottom": + comments = comms.order_by(Comment.score.asc()).all() + elif sort == "new": + comments = comms.order_by(Comment.created_utc.desc()).all() + elif sort == "old": + comments = comms.order_by(Comment.created_utc.asc()).all() + elif sort == "controversial": + comments = sorted(comms.all(), key=lambda x: x.score_disputed, reverse=True) + elif sort == "random": + c = comms.all() + comments = random.sample(c, k=len(c)) + else: + abort(422) + + output = [] + for c in comms: + comment = c[0] + comment._voted = c[1] or 0 + comment._is_blocking = c[2] or 0 + comment._is_blocked = c[3] or 0 + comment._is_guildmaster=top_comment._is_guildmaster + comment._is_exiled_for=c[4] or 0 + output.append(comment) + else: + + comms = g.db.query( + Comment, + aliased(ModAction, alias=exile) + ).options( + joinedload(Comment.author).joinedload(User.title) + ).filter( + Comment.parent_comment_id.in_(current_ids) + ).join( + exile, + exile.c.target_comment_id==Comment.id, + isouter=True + ) + + if sort == "top": + comments = comms.order_by(Comment.score.asc()).all() + elif sort == "bottom": + comments = comms.order_by(Comment.score.desc()).all() + elif sort == "new": + comments = comms.order_by(Comment.created_utc.desc()).all() + elif sort == "old": + comments = comms.order_by(Comment.created_utc.asc()).all() + elif sort == "controversial": + comments = sorted(comms.all(), key=lambda x: x[0].score_disputed, reverse=True) + elif sort == "random": + c = comms.all() + comments = random.sample(c, k=len(c)) + else: + abort(422) + + output = [] + for c in comms: + comment=c[0] + comment._is_exiled_for=c[1] or 0 + output.append(comment) + + post._preloaded_comments += output + + current_ids = [x.id for x in output] + + + post.tree_comments() + + post.replies=[top_comment] + + return {'html': lambda: post.rendered_page(v=v, sort=sort, comment=top_comment, comment_info=comment_info), + 'api': lambda: top_comment.json + } + +@app.route("/api/comment", methods=["POST"]) +@app.route("/api/v1/comment", methods=["POST"]) +@limiter.limit("6/minute") +@is_not_banned +@no_negative_balance('toast') +@tos_agreed +@validate_formkey +@api("create") +def api_comment(v): + + parent_submission = base36decode(request.form.get("submission")) + parent_fullname = request.form.get("parent_fullname") + + # get parent item info + parent_id = parent_fullname.split("_")[1] + if parent_fullname.startswith("t2"): + parent_post = get_post(parent_id, v=v) + parent = parent_post + parent_comment_id = None + level = 1 + parent_submission = base36decode(parent_id) + elif parent_fullname.startswith("t3"): + parent = get_comment(parent_id, v=v) + parent_comment_id = parent.id + level = parent.level + 1 + parent_id = parent.parent_submission + parent_submission = parent_id + parent_post = get_post(base36encode(parent_id)) + else: + abort(400) + + #process and sanitize + body = request.form.get("body", "")[0:10000] + body = body.strip() + + if not body and not request.files.get('file'): return jsonify({"error":"You need to actually write something!"}), 400 + + for i in re.finditer('^(https:\/\/.*\.(png|jpg|jpeg|gif))', body, re.MULTILINE): body = body.replace(i.group(1), f'![]({i.group(1)})') + with CustomRenderer(post_id=parent_id) as renderer: body_md = renderer.render(mistletoe.Document(body)) + body_html = sanitize(body_md, linkgen=True) + + # Run safety filter + bans = filter_comment_html(body_html) + + if bans: + ban = bans[0] + reason = f"Remove the {ban.domain} link from your comment and try again." + if ban.reason: + reason += f" {ban.reason_text}" + + #auto ban for digitally malicious content + if any([x.reason==4 for x in bans]): + v.ban(days=30, reason="Digitally malicious content") + if any([x.reason==7 for x in bans]): + v.ban( reason="Sexualizing minors") + return jsonify({"error": reason}), 401 + + # check existing + existing = g.db.query(Comment).join(CommentAux).filter(Comment.author_id == v.id, + Comment.deleted_utc == 0, + Comment.parent_comment_id == parent_comment_id, + Comment.parent_submission == parent_submission, + CommentAux.body == body + ).options(contains_eager(Comment.comment_aux)).first() + if existing: + return jsonify({"error": f"You already made that comment: {existing.permalink}"}), 409 + + if parent.author.any_block_exists(v) and not v.admin_level>=3: + return jsonify( + {"error": "You can't reply to users who have blocked you, or users you have blocked."}), 403 + + # check for archive and ban state + post = get_post(parent_id) + if post.is_archived or not post.board.can_comment(v): + + return jsonify({"error": "You can't comment on this."}), 403 + + # get bot status + is_bot = request.headers.get("X-User-Type","")=="Bot" + + # check spam - this should hopefully be faster + if not is_bot: + now = int(time.time()) + cutoff = now - 60 * 60 * 24 + + similar_comments = g.db.query(Comment + ).options( + lazyload('*') + ).join(Comment.comment_aux + ).filter( + Comment.author_id == v.id, + CommentAux.body.op( + '<->')(body) < app.config["COMMENT_SPAM_SIMILAR_THRESHOLD"], + Comment.created_utc > cutoff + ).options(contains_eager(Comment.comment_aux)).all() + + threshold = app.config["COMMENT_SPAM_COUNT_THRESHOLD"] + if v.age >= (60 * 60 * 24 * 7): + threshold *= 3 + elif v.age >= (60 * 60 * 24): + threshold *= 2 + + if len(similar_comments) > threshold: + text = "Your Drama account has been suspended for 1 day for the following reason:\n\n> Too much spam!" + send_notification(1046, v, text) + + v.ban(reason="Spamming.", + days=1) + + for alt in v.alts: + if not alt.is_suspended: + alt.ban(reason="Spamming.") + + for comment in similar_comments: + comment.is_banned = True + comment.ban_reason = "Automatic spam removal. This happened because the post's creator submitted too much similar content too quickly." + g.db.add(comment) + ma=ModAction( + user_id=2317, + target_comment_id=comment.id, + kind="ban_comment", + board_id=comment.post.board_id, + note="spam" + ) + g.db.add(ma) + + g.db.commit() + return jsonify({"error": "Too much spam!"}), 403 + + # check badlinks + soup = BeautifulSoup(body_html, features="html.parser") + links = [x['href'] for x in soup.find_all('a') if x.get('href')] + + for link in links: + parse_link = urlparse(link) + check_url = ParseResult(scheme="https", + netloc=parse_link.netloc, + path=parse_link.path, + params=parse_link.params, + query=parse_link.query, + fragment='') + check_url = urlunparse(check_url) + + badlink = g.db.query(BadLink).filter( + literal(check_url).contains( + BadLink.link)).first() + + if badlink: + return jsonify({"error": f"Remove the following link and try again: `{check_url}`. Reason: {badlink.reason_text}"}), 403 + + # create comment + c = Comment(author_id=v.id, + parent_submission=parent_submission, + parent_fullname=parent.fullname, + parent_comment_id=parent_comment_id, + level=level, + over_18=post.over_18 or request.form.get("over_18","")=="true", + is_nsfl=post.is_nsfl or request.form.get("is_nsfl","")=="true", + original_board_id=parent_post.board_id, + is_bot=is_bot, + app_id=v.client.application.id if v.client else None, + creation_region=request.headers.get("cf-ipcountry") + ) + + g.db.add(c) + g.db.flush() + + if v.dramacoins >= 0: + if request.files.get("file"): + file=request.files["file"] + if not file.content_type.startswith('image/'): + return jsonify({"error": "That wasn't an image!"}), 400 + + name = f'comment/{c.base36id}/{secrets.token_urlsafe(8)}' + url = upload_file(name, file) + + body = request.form.get("body") + f"\n\n![]({url})" + with CustomRenderer(post_id=parent_id) as renderer: + body_md = renderer.render(mistletoe.Document(body)) + body_html = sanitize(body_md, linkgen=True) + + # #csam detection + # def del_function(): + # delete_file(name) + # c.is_banned=True + # g.db.add(c) + # g.db.commit() + + # csam_thread=threading.Thread(target=check_csam_url, + # args=(f"https://s3.eu-central-1.amazonaws.com/i.ruqqus.ga/{name}", + # v, + # del_function + # ) + # ) + # csam_thread.start() + + c_aux = CommentAux( + id=c.id, + body_html=body_html, + body=body + ) + + g.db.add(c_aux) + g.db.flush() + + if len(body) >= 1000 and v.username != "Snappy" and "" not in body_html: + c2 = Comment(author_id=1832, + parent_submission=parent_submission, + parent_fullname=c.fullname, + parent_comment_id=c.id, + level=level+1, + original_board_id=1, + is_bot=True, + ) + + g.db.add(c2) + g.db.flush() + + body = random.choice(choices) + with CustomRenderer(post_id=parent_id) as renderer: body_md = renderer.render(mistletoe.Document(body)) + body_html2 = sanitize(body_md, linkgen=True) + c_aux = CommentAux( + id=c2.id, + body_html=body_html2, + body=body + ) + g.db.add(c_aux) + g.db.flush() + n = Notification(comment_id=c2.id, user_id=v.id) + g.db.add(n) + + + + + + + + if random.random() < 0.001 and v.username != "Snappy" and v.username != "zozbot": + c2 = Comment(author_id=1833, + parent_submission=parent_submission, + parent_fullname=c.fullname, + parent_comment_id=c.id, + level=level+1, + original_board_id=1, + is_bot=True, + ) + + g.db.add(c2) + g.db.flush() + + body = "zoz" + with CustomRenderer(post_id=parent_id) as renderer: body_md = renderer.render(mistletoe.Document(body)) + body_html2 = sanitize(body_md, linkgen=True) + c_aux = CommentAux( + id=c2.id, + body_html=body_html2, + body=body + ) + g.db.add(c_aux) + g.db.flush() + n = Notification(comment_id=c2.id, user_id=v.id) + g.db.add(n) + + + + + c3 = Comment(author_id=1833, + parent_submission=parent_submission, + parent_fullname=c2.fullname, + parent_comment_id=c2.id, + level=level+2, + original_board_id=1, + is_bot=True, + ) + + g.db.add(c3) + g.db.flush() + + body = "zle" + with CustomRenderer(post_id=parent_id) as renderer: body_md = renderer.render(mistletoe.Document(body)) + body_html2 = sanitize(body_md, linkgen=True) + c_aux = CommentAux( + id=c3.id, + body_html=body_html2, + body=body + ) + g.db.add(c_aux) + g.db.flush() + + + + + + + c4 = Comment(author_id=1833, + parent_submission=parent_submission, + parent_fullname=c3.fullname, + parent_comment_id=c3.id, + level=level+3, + original_board_id=1, + is_bot=True, + ) + + g.db.add(c4) + g.db.flush() + + body = "zozzle" + with CustomRenderer(post_id=parent_id) as renderer: body_md = renderer.render(mistletoe.Document(body)) + body_html2 = sanitize(body_md, linkgen=True) + c_aux = CommentAux( + id=c4.id, + body_html=body_html2, + body=body + ) + g.db.add(c_aux) + g.db.flush() + + + + + + + + + + + + + + # queue up notification for parent author + notify_users = set() + + for x in g.db.query(Subscription.user_id).filter_by(submission_id=c.parent_submission).all(): + notify_users.add(x) + + if parent.author.id != v.id: notify_users.add(parent.author.id) + + soup = BeautifulSoup(body_html, features="html.parser") + mentions = soup.find_all("a", href=re.compile("^/@(\w+)")) + for mention in mentions: + username = mention["href"].split("@")[1] + + user = g.db.query(User).filter_by(username=username).first() + + if user: + if v.any_block_exists(user): + continue + if user.id != v.id: + notify_users.add(user.id) + + for x in notify_users: + n = Notification(comment_id=c.id, user_id=x) + g.db.add(n) + try: g.db.flush() + except: g.db.rollback() + + if parent.author.id != v.id: + beams_client.publish_to_interests( + interests=[str(parent.author.id)], + publish_body={ + 'web': { + 'notification': { + 'title': f'New reply by @{v.username}', + 'body': c.body, + 'deep_link': f'https://rdrama.net{c.permalink}?context=5#context', + }, + }, + }, + ) + + # create auto upvote + vote = CommentVote(user_id=v.id, + comment_id=c.id, + vote_type=1 + ) + + g.db.add(vote) + c=get_comment(c.id, v=v) + + + # print(f"Content Event: @{v.username} comment {c.base36id}") + + board = get_board(1) + cache.delete_memoized(comment_idlist) + cache.delete_memoized(User.commentlisting, v) + + return {"html": lambda: jsonify({"html": render_template("comments.html", + v=v, + comments=[c], + render_replies=False, + is_allowed_to_comment=True + )}), + "api": lambda: c.json + } + + + +@app.route("/edit_comment/", methods=["POST"]) +@is_not_banned +@validate_formkey +@api("edit") +def edit_comment(cid, v): + + c = get_comment(cid, v=v) + + if not c.author_id == v.id: abort(403) + + if c.is_banned or c.deleted_utc > 0: abort(403) + + body = request.form.get("body", "")[0:10000] + for i in re.finditer('^(https:\/\/.*\.(png|jpg|jpeg|gif))', body, re.MULTILINE): body = body.replace(i.group(1), f'![]({i.group(1)})') + with CustomRenderer(post_id=c.post.base36id) as renderer: body_md = renderer.render(mistletoe.Document(body)) + body_html = sanitize(body_md, linkgen=True) + + bans = filter_comment_html(body_html) + + if bans: + + ban = bans[0] + reason = f"Remove the {ban.domain} link from your comment and try again." + + #auto ban for digitally malicious content + if any([x.reason==4 for x in bans]): + v.ban(days=30, reason="Digitally malicious content is not allowed.") + return jsonify({"error":"Digitally malicious content is not allowed."}) + + if ban.reason: + reason += f" {ban.reason_text}" + + return jsonify({"error": reason}), 401 + + return {'html': lambda: render_template("comment_failed.html", + action=f"/edit_comment/{c.base36id}", + badlinks=[ + x.domain for x in bans], + body=body, + v=v + ), + 'api': lambda: ({'error': f'A blacklisted domain was used.'}, 400) + } + + # check badlinks + soup = BeautifulSoup(body_html, features="html.parser") + links = [x['href'] for x in soup.find_all('a') if x.get('href')] + + for link in links: + parse_link = urlparse(link) + check_url = ParseResult(scheme="https", + netloc=parse_link.netloc, + path=parse_link.path, + params=parse_link.params, + query=parse_link.query, + fragment='') + check_url = urlunparse(check_url) + + badlink = g.db.query(BadLink).filter( + literal(check_url).contains( + BadLink.link)).first() + + if badlink: + return jsonify({"error": f"Remove the following link and try again: `{check_url}`. Reason: {badlink.reason_text}"}), 403 + + # check spam - this should hopefully be faster + now = int(time.time()) + cutoff = now - 60 * 60 * 24 + + similar_comments = g.db.query(Comment + ).options( + lazyload('*') + ).join(Comment.comment_aux + ).filter( + Comment.author_id == v.id, + CommentAux.body.op( + '<->')(body) < app.config["SPAM_SIMILARITY_THRESHOLD"], + Comment.created_utc > cutoff + ).options(contains_eager(Comment.comment_aux)).all() + + threshold = app.config["SPAM_SIMILAR_COUNT_THRESHOLD"] + if v.age >= (60 * 60 * 24 * 30): + threshold *= 4 + elif v.age >= (60 * 60 * 24 * 7): + threshold *= 3 + elif v.age >= (60 * 60 * 24): + threshold *= 2 + + if len(similar_comments) > threshold: + text = "Your Drama account has been suspended for 1 day for the following reason:\n\n> Too much spam!" + send_notification(1046, v, text) + + v.ban(reason="Spamming.", + days=1) + + for comment in similar_comments: + comment.is_banned = True + comment.ban_reason = "Automatic spam removal. This happened because the post's creator submitted too much similar content too quickly." + g.db.add(comment) + + g.db.commit() + return jsonify({"error": "Too much spam!"}), 403 + + if v.dramacoins >= 0: + if request.files.get("file"): + file=request.files["file"] + if not file.content_type.startswith('image/'): + return jsonify({"error": "That wasn't an image!"}), 400 + + name = f'comment/{c.base36id}/{secrets.token_urlsafe(8)}' + url = upload_file(name, file) + + body += f"\n\n![]({url})" + with CustomRenderer(post_id=c.parent_submission) as renderer: + body_md = renderer.render(mistletoe.Document(body)) + body_html = sanitize(body_md, linkgen=True) + + # #csam detection + # def del_function(): + # delete_file(name) + # c.is_banned=True + # g.db.add(c) + # g.db.commit() + + # csam_thread=threading.Thread(target=check_csam_url, + # args=(f"https://s3.eu-central-1.amazonaws.com/i.ruqqus.ga/{name}", + # v, + # del_function + # ) + # ) + # csam_thread.start() + + c.body = body + c.body_html = body_html + + if int(time.time()) - c.created_utc > 60 * 3: c.edited_utc = int(time.time()) + + g.db.add(c) + + g.db.commit() + + path = request.form.get("current_page", "/") + + # queue up notifications for username mentions + notify_users = set() + soup = BeautifulSoup(body_html, features="html.parser") + mentions = soup.find_all("a", href=re.compile("^/@(\w+)")) + + if len(mentions) > 0: + notifs = g.db.query(Notification) + for mention in mentions: + username = mention["href"].split("@")[1] + + user = g.db.query(User).filter_by(username=username).first() + + if user: + if v.any_block_exists(user): + continue + if user.id != v.id: + notify_users.add(user.id) + + for x in notify_users: + notif = notifs.filter_by(comment_id=c.id, user_id=x).first() + if not notif: + n = Notification(comment_id=c.id, user_id=x) + g.db.add(n) + + return jsonify({"html": c.body_html}) + +@app.route("/delete/comment/", methods=["POST"]) +@app.route("/api/v1/delete/comment/", methods=["POST"]) +@auth_required +@validate_formkey +@api("delete") +def delete_comment(cid, v): + + c = g.db.query(Comment).filter_by(id=base36decode(cid)).first() + + if not c: + abort(404) + + if not c.author_id == v.id: + abort(403) + + c.deleted_utc = int(time.time()) + + g.db.add(c) + + cache.delete_memoized(User.commentlisting, v) + + return {"html": lambda: ("", 204), + "api": lambda: ("", 204)} + +@app.route("/undelete/comment/", methods=["POST"]) +@app.route("/api/v1/undelete/comment/", methods=["POST"]) +@auth_required +@validate_formkey +@api("delete") +def undelete_comment(cid, v): + + c = g.db.query(Comment).filter_by(id=base36decode(cid)).first() + + if not c: + abort(404) + + if not c.author_id == v.id: + abort(403) + + c.deleted_utc = 0 + + g.db.add(c) + + cache.delete_memoized(User.commentlisting, v) + + return {"html": lambda: ("", 204), + "api": lambda: ("", 204)} + +@app.route("/embed/comment/", methods=["GET"]) +@app.route("/embed/post//comment/", methods=["GET"]) +@app.route("/api/v1/embed/comment/", methods=["GET"]) +@app.route("/api/v1/embed/post//comment/", methods=["GET"]) +def embed_comment_cid(cid, pid=None): + + comment = get_comment(cid) + + if not comment.parent: + abort(403) + + if comment.is_banned or comment.deleted_utc > 0: + return {'html': lambda: render_template("embeds/comment_removed.html", c=comment), + 'api': lambda: {'error': f'Comment {cid} has been removed'} + } + + if comment.board.is_banned: + abort(410) + + return render_template("embeds/comment.html", c=comment) + +@app.route("/comment_pin/", methods=["POST"]) +@auth_required +@validate_formkey +def mod_toggle_comment_pin(cid, v): + + comment = get_comment(cid, v=v) + + if v.admin_level != 6 and v.id != comment.post.author_id: + abort(403) + + comment.is_pinned = not comment.is_pinned + + g.db.add(comment) + g.db.commit() + + if v.admin_level == 6: + ma=ModAction( + kind="pin_comment" if comment.is_pinned else "unpin_comment", + user_id=v.id, + board_id=1, + target_comment_id=comment.id + ) + g.db.add(ma) + + html=render_template( + "comments.html", + v=v, + comments=[comment], + render_replies=False, + is_allowed_to_comment=True + ) + + html=str(BeautifulSoup(html, features="html.parser").find(id=f"comment-{comment.base36id}-only")) + + return jsonify({"html":html}) + + +@app.route("/save_comment/", methods=["POST"]) +@auth_required +@validate_formkey +def save_comment(cid, v): + + comment=get_comment(cid) + + new_save=SaveRelationship(user_id=v.id, submission_id=comment.id, type=2) + + g.db.add(new_save) + + try: g.db.flush() + except: abort(422) + + return "", 204 + +@app.route("/unsave_comment/", methods=["POST"]) +@auth_required +@validate_formkey +def unsave_comment(cid, v): + + comment=get_comment(cid) + + save=g.db.query(SaveRelationship).filter_by(user_id=v.id, submission_id=comment.id, type=2).first() + + g.db.delete(save) + + return "", 204 \ No newline at end of file diff --git a/ruqqus/routes/discord.py b/ruqqus/routes/discord.py new file mode 100644 index 000000000..a66107612 --- /dev/null +++ b/ruqqus/routes/discord.py @@ -0,0 +1,156 @@ +from ruqqus.helpers.wrappers import * +from ruqqus.helpers.security import * +from ruqqus.helpers.discord import add_role, delete_role +from ruqqus.__main__ import app + +SERVER_ID = environ.get("DISCORD_SERVER_ID",'').strip() +CLIENT_ID = environ.get("DISCORD_CLIENT_ID",'').strip() +CLIENT_SECRET = environ.get("DISCORD_CLIENT_SECRET",'').strip() +BOT_TOKEN = environ.get("DISCORD_BOT_TOKEN").strip() +DISCORD_ENDPOINT = "https://discordapp.com/api/v6" + + +WELCOME_CHANNEL="846509313941700618" + +@app.route("/discord", methods=["GET"]) +@auth_required +def join_discord(v): + + if v.is_banned != 0: return "You're banned" + if v.admin_level == 0 and v.dramacoins < 150: return "You must earn 150 dramacoins before entering the Discord server. You earn dramacoins by making posts/comments and getting upvoted." + + now=int(time.time()) + + state=generate_hash(f"{now}+{v.id}+discord") + + state=f"{now}.{state}" + + return redirect(f"https://discord.com/api/oauth2/authorize?client_id={CLIENT_ID}&redirect_uri=https%3A%2F%2F{app.config['SERVER_NAME']}%2Fdiscord_redirect&response_type=code&scope=identify%20guilds.join&state={state}") + +@app.route("/discord_redirect", methods=["GET"]) +@auth_required +def discord_redirect(v): + + + #validate state + now=int(time.time()) + state=request.args.get('state','').split('.') + + timestamp=state[0] + + state=state[1] + + if int(timestamp) < now-600: + abort(400) + + if not validate_hash(f"{timestamp}+{v.id}+discord", state): + abort(400) + + #get discord token + code = request.args.get("code","") + if not code: + abort(400) + + data={ + "client_id":CLIENT_ID, + 'client_secret': CLIENT_SECRET, + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': f"https://{app.config['SERVER_NAME']}/discord_redirect", + 'scope': 'identify guilds.join' + } + headers={ + 'Content-Type': 'application/x-www-form-urlencoded' + } + url="https://discord.com/api/oauth2/token" + + x=requests.post(url, headers=headers, data=data) + + x=x.json() + + + try: + token=x["access_token"] + except KeyError: + abort(403) + + + #get user ID + url="https://discord.com/api/users/@me" + headers={ + 'Authorization': f"Bearer {token}" + } + x=requests.get(url, headers=headers) + + x=x.json() + + + + #add user to discord + headers={ + 'Authorization': f"Bot {BOT_TOKEN}", + 'Content-Type': "application/json" + } + + #remove existing user if applicable + if v.discord_id and v.discord_id != x['id']: + url=f"https://discord.com/api/guilds/{SERVER_ID}/members/{v.discord_id}" + requests.delete(url, headers=headers) + + if g.db.query(User).filter(User.id!=v.id, User.discord_id==x["id"]).first(): + return render_template("message.html", title="Discord account already linked.", error="That Discord account is already in use by another user.", v=v) + + v.discord_id=x["id"] + g.db.add(v) + g.db.commit() + + url=f"https://discord.com/api/guilds/{SERVER_ID}/members/{x['id']}" + + name=v.username + if v.real_id: + name+= f" | {v.real_id}" + + data={ + "access_token":token, + "nick":name, + } + + x=requests.put(url, headers=headers, json=data) + + if x.status_code in [201, 204]: + + if v.admin_level > 0: add_role(v, "admin") + else: add_role(v, "newuser") + + time.sleep(0.1) + + add_role(v, "feedback") + + time.sleep(0.1) + + if v.dramacoins > 100: add_role(v, "linked") + else: add_role(v, "norep") + + else: + return jsonify(x.json()) + + #check on if they are already there + #print(x.status_code) + + if x.status_code==204: + + ##if user is already a member, remove old roles and update nick + delete_role(v, "nick") + + + url=f"https://discord.com/api/guilds/{SERVER_ID}/members/{v.discord_id}" + data={ + "nick": name + } + + req=requests.patch(url, headers=headers, json=data) + + #print(req.status_code) + #print(url) + + return redirect(f"https://discord.com/channels/{SERVER_ID}/{WELCOME_CHANNEL}") \ No newline at end of file diff --git a/ruqqus/routes/errors.py b/ruqqus/routes/errors.py new file mode 100644 index 000000000..e3f1ec5ac --- /dev/null +++ b/ruqqus/routes/errors.py @@ -0,0 +1,204 @@ +from ruqqus.helpers.wrappers import * +from ruqqus.helpers.session import * +from ruqqus.classes.custom_errors import * +from flask import * +from urllib.parse import quote, urlencode +import time +from ruqqus.__main__ import app, r, cache, db_session +import gevent + +# Errors + + + + +@app.errorhandler(401) +def error_401(e): + + path = request.path + qs = urlencode(dict(request.args)) + argval = quote(f"{path}?{qs}", safe='') + output = f"/login?redirect={argval}" + + if request.path.startswith("/api/v1/"): + return jsonify({"error": "401 Not Authorized"}), 401 + else: + return redirect(output) + +@app.errorhandler(PaymentRequired) +@auth_desired +@api() +def error_402(e, v): + return{"html": lambda: (render_template('errors/402.html', v=v), 402), + "api": lambda: (jsonify({"error": "402 Payment Required"}), 402) + } + +@app.errorhandler(403) +@auth_desired +@api() +def error_403(e, v): + return{"html": lambda: (render_template('errors/403.html', v=v), 403), + "api": lambda: (jsonify({"error": "403 Forbidden"}), 403) + } + + +@app.errorhandler(404) +@auth_desired +@api() +def error_404(e, v): + return{"html": lambda: (render_template('errors/404.html', v=v), 404), + "api": lambda: (jsonify({"error": "404 Not Found"}), 404) + } + + +@app.errorhandler(405) +@auth_desired +@api() +def error_405(e, v): + return{"html": lambda: (render_template('errors/405.html', v=v), 405), + "api": lambda: (jsonify({"error": "405 Method Not Allowed"}), 405) + } + + +@app.errorhandler(409) +@auth_desired +@api() +def error_409(e, v): + return{"html": lambda: (render_template('errors/409.html', v=v), 409), + "api": lambda: (jsonify({"error": "409 Conflict"}), 409) + } + + +@app.errorhandler(413) +@auth_desired +@api() +def error_413(e, v): + return{"html": lambda: (render_template('errors/413.html', v=v), 413), + "api": lambda: (jsonify({"error": "413 Request Payload Too Large"}), 413) + } + + +@app.errorhandler(422) +@auth_desired +@api() +def error_422(e, v): + return{"html": lambda: (render_template('errors/422.html', v=v), 422), + "api": lambda: (jsonify({"error": "422 Unprocessable Entity"}), 422) + } + + +@app.errorhandler(429) +@auth_desired +@api() +def error_429(e, v): + return{"html": lambda: (render_template('errors/429.html', v=v), 429), + "api": lambda: (jsonify({"error": "429 Too Many Requests"}), 429) + } + + +@app.errorhandler(451) +@auth_desired +@api() +def error_451(e, v): + return{"html": lambda: (render_template('errors/451.html', v=v), 451), + "api": lambda: (jsonify({"error": "451 Unavailable For Legal Reasons"}), 451) + } + + +@app.errorhandler(500) +@auth_desired +@api() +def error_500(e, v): + try: + g.db.rollback() + except AttributeError: + pass + + return{"html": lambda: (render_template('errors/500.html', v=v), 500), + "api": lambda: (jsonify({"error": "500 Internal Server Error"}), 500) + } + + +@app.route("/allow_nsfw_logged_in/", methods=["POST"]) +@auth_required +@validate_formkey +def allow_nsfw_logged_in(bid, v): + + cutoff = int(time.time()) + 3600 + + if not session.get("over_18", None): + session["over_18"] = {} + + session["over_18"][bid] = cutoff + + return redirect(request.form.get("redir")) + + +@app.route("/allow_nsfw_logged_out/", methods=["POST"]) +@auth_desired +def allow_nsfw_logged_out(bid, v): + + if v: + return redirect('/') + + t = int(request.form.get('time')) + + if not validate_logged_out_formkey(t, + request.form.get("formkey") + ): + abort(403) + + if not session.get("over_18", None): + session["over_18"] = {} + + cutoff = int(time.time()) + 3600 + session["over_18"][bid] = cutoff + + return redirect(request.form.get("redir")) + + +@app.route("/allow_nsfl_logged_in/", methods=["POST"]) +@auth_required +@validate_formkey +def allow_nsfl_logged_in(bid, v): + + cutoff = int(time.time()) + 3600 + + if not session.get("show_nsfl", None): + session["show_nsfl"] = {} + + session["show_nsfl"][bid] = cutoff + + return redirect(request.form.get("redir")) + + +@app.route("/allow_nsfl_logged_out/", methods=["POST"]) +@auth_desired +def allow_nsfl_logged_out(bid, v): + + if v: + return redirect('/') + + t = int(request.form.get('time')) + + if not validate_logged_out_formkey(t, + request.form.get("formkey") + ): + abort(403) + + if not session.get("show_nsfl", None): + session["show_nsfl"] = {} + + cutoff = int(time.time()) + 3600 + session["show_nsfl"][bid] = cutoff + + return redirect(request.form.get("redir")) + + +@app.route("/error/", methods=["GET"]) +@auth_desired +def error_all_preview(eid, v): + + eid=int(eid) + return render_template(f"errors/{eid}.html", v=v) + diff --git a/ruqqus/routes/feeds.py b/ruqqus/routes/feeds.py new file mode 100644 index 000000000..d96b09e86 --- /dev/null +++ b/ruqqus/routes/feeds.py @@ -0,0 +1,73 @@ +import html +from .front import frontlist +from datetime import datetime +from ruqqus.helpers.jinja2 import full_link +from ruqqus.helpers.get import * +from yattag import Doc + +from ruqqus.__main__ import app + +@app.route('/rss//', methods=["GET"]) +def feeds_user(sort='hot', t='all'): + + page = int(request.args.get("page", 1)) + + posts = frontlist( + sort=sort, + page=page, + t=t, + v=None, + hide_offensive=False, + ids_only=False) + + domain = environ.get( + "domain", environ.get( + "SERVER_NAME", None)).strip() + + doc, tag, text = Doc().tagtext() + + with tag("feed", ("xmlns:media","http://search.yahoo.com/mrss/"), xmlns="http://www.w3.org/2005/Atom",): + with tag("title", type="text"): + text(f"{sort} posts from {domain}") + + doc.stag("link", href=request.url) + doc.stag("link", href=request.url_root) + + for post in posts: + #print("POST IMAGE "+ str( post.is_image )) + board_name = f"+{post.board.name}" + with tag("entry", ("xml:base", request.url)): + with tag("title", type="text"): + text(post.title) + + with tag("id"): + text(post.fullname) + + if (post.edited_utc > 0): + with tag("updated"): + text(datetime.utcfromtimestamp(post.edited_utc).isoformat()) + + with tag("published"): + text(datetime.utcfromtimestamp(post.created_utc).isoformat()) + + doc.stag("link", href=post.url) + + with tag("author"): + with tag("name"): + text(post.author.username) + with tag("uri"): + text(f'https://{domain}/@{post.author.username}') + + doc.stag("link", href=full_link(post.permalink)) + + doc.stag("category", term=board_name, label=board_name, schema=full_link("/" + board_name)) + + image_url = post.thumb_url or post.embed_url or post.url + + doc.stag("media:thumbnail", url=image_url) + + if len(post.body_html) > 0: + with tag("content", type="html"): + text(html.escape(f"
{post.body_html}")) + + return Response( ""+ doc.getvalue(), mimetype="application/xml") \ No newline at end of file diff --git a/ruqqus/routes/flagging.py b/ruqqus/routes/flagging.py new file mode 100644 index 000000000..60521dce1 --- /dev/null +++ b/ruqqus/routes/flagging.py @@ -0,0 +1,53 @@ +from ruqqus.helpers.wrappers import * +from ruqqus.helpers.get import * +from flask import g +from ruqqus.__main__ import app + + +@app.route("/api/flag/post/", methods=["POST"]) +@auth_desired +def api_flag_post(pid, v): + + post = get_post(pid) + + if v: + existing = g.db.query(Flag).filter_by( + user_id=v.id, post_id=post.id).filter( + Flag.created_utc >= post.edited_utc).first() + + if existing: + return "", 409 + + flag = Flag(post_id=post.id, + user_id=v.id, + created_utc=int(time.time()) + ) + + + g.db.add(flag) + + return "", 204 + + +@app.route("/api/flag/comment/", methods=["POST"]) +@auth_desired +def api_flag_comment(cid, v): + + comment = get_comment(cid) + + if v: + existing = g.db.query(CommentFlag).filter_by( + user_id=v.id, comment_id=comment.id).filter( + CommentFlag.created_utc >= comment.edited_utc).first() + + if existing: + return "", 409 + + flag = CommentFlag(comment_id=comment.id, + user_id=v.id, + created_utc=int(time.time()) + ) + + g.db.add(flag) + + return "", 204 \ No newline at end of file diff --git a/ruqqus/routes/front.py b/ruqqus/routes/front.py new file mode 100644 index 000000000..f7dd354e6 --- /dev/null +++ b/ruqqus/routes/front.py @@ -0,0 +1,438 @@ +from ruqqus.helpers.wrappers import * +from ruqqus.helpers.get import * + +from ruqqus.__main__ import app, cache +from ruqqus.classes.submission import Submission + +@app.route("/post/", methods=["GET"]) +def slash_post(): + return redirect("/") + +# this is a test + +@app.route("/notifications", methods=["GET"]) +@auth_required +def notifications(v): + + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + page = int(request.args.get('page', 1)) + all_ = request.args.get('all', False) + messages = request.args.get('messages', False) + posts = request.args.get('posts', False) + if messages: + if v.admin_level == 6: comments = g.db.query(Comment).filter(or_(Comment.author_id==v.id, Comment.sentto==v.id, Comment.sentto==0)).filter(Comment.parent_submission == None).order_by(Comment.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() + else: comments = g.db.query(Comment).filter(or_(Comment.author_id==v.id, Comment.sentto==v.id)).filter(Comment.parent_submission == None).order_by(Comment.created_utc.desc()).offset(25 * (page - 1)).limit(26).all() + next_exists = (len(comments) == 26) + comments = comments[:25] + elif posts: + cids = v.notification_subscriptions(page=page, all_=all_) + next_exists = (len(cids) == 26) + cids = cids[:25] + comments = get_comments(cids, v=v, sort="new", load_parent=True) + else: + cids = v.notification_commentlisting(page=page, all_=all_) + next_exists = (len(cids) == 26) + cids = cids[:25] + comments = get_comments(cids, v=v, sort="new", load_parent=True) + + listing = [] + for c in comments: + c._is_blocked = False + c._is_blocking = False + if c.parent_submission: + while c.parent_comment: + parent = c.parent_comment + if c not in parent.replies2: + parent.replies2 = parent.replies2 + [c] + parent.replies = parent.replies2 + c = parent + if c not in listing: + listing.append(c) + c.replies = c.replies2 + else: + if c.parent_comment: + while c.level > 1: + c = c.parent_comment + + 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=True, + is_notification_page=True) + +@cache.memoize(timeout=1500) +def frontlist(v=None, sort="hot", page=1,t="all", ids_only=True, filter_words='', **kwargs): + + posts = g.db.query(Submission).options(lazyload('*')).filter_by(is_banned=False,stickied=False,private=False,).filter(Submission.deleted_utc == 0) + + if v and v.admin_level == 0: + blocking = g.db.query( + UserBlock.target_id).filter_by( + user_id=v.id).subquery() + blocked = g.db.query( + UserBlock.user_id).filter_by( + target_id=v.id).subquery() + posts = posts.filter( + Submission.author_id.notin_(blocking), + Submission.author_id.notin_(blocked) + ) + + if not (v and v.changelogsub): + posts=posts.join(Submission.submission_aux) + posts=posts.filter(not_(SubmissionAux.title.ilike(f'%[changelog]%'))) + + if v and filter_words: + for word in filter_words: + posts=posts.filter(not_(SubmissionAux.title.ilike(f'%{word}%'))) + + if t != 'all': + cutoff = 0 + now = int(time.time()) + if t == 'hour': + cutoff = now - 3600 + elif t == 'day': + cutoff = now - 86400 + elif t == 'week': + cutoff = now - 604800 + elif t == 'month': + cutoff = now - 2592000 + elif t == 'year': + cutoff = now - 31536000 + posts = posts.filter(Submission.created_utc >= cutoff) + + gt = kwargs.get("gt") + lt = kwargs.get("lt") + + if gt: + posts = posts.filter(Submission.created_utc > gt) + + if lt: + posts = posts.filter(Submission.created_utc < lt) + + if sort == "hot": + posts = sorted(posts.all(), key=lambda x: x.hotscore, reverse=True) + elif sort == "new": + posts = posts.order_by(Submission.created_utc.desc()).all() + elif sort == "old": + posts = posts.order_by(Submission.created_utc.asc()).all() + elif sort == "controversial": + posts = sorted(posts.all(), key=lambda x: x.score_disputed, reverse=True) + elif sort == "top": + posts = posts.order_by(Submission.score.desc()).all() + elif sort == "bottom": + posts = posts.order_by(Submission.score.asc()).all() + elif sort == "comments": + posts = posts.order_by(Submission.comment_count.desc()).all() + elif sort == "random": + posts = posts.all() + posts = random.sample(posts, k=len(posts)) + else: + abort(400) + + firstrange = 25 * (page - 1) + secondrange = firstrange+100 + posts = posts[firstrange:secondrange] + + if v and v.hidevotedon: posts = [x for x in posts if x.voted == 0] + + if page == 1: posts = g.db.query(Submission).filter_by(stickied=True).all() + posts + + words = [' captainmeta4 ', ' cm4 ', ' dissident001 ', ' ladine '] + + for post in posts: + if post.author and post.author.admin_level == 0: + for word in words: + if word in post.title.lower(): + posts.remove(post) + break + + if random.random() < 0.02: + for post in posts: + if post.author and post.author.shadowbanned: + rand = random.randint(500,1400) + vote = Vote(user_id=rand, + vote_type=random.choice([-1, 1]), + submission_id=post.id) + g.db.add(vote) + try: g.db.flush() + except: g.db.rollback() + post.upvotes = post.ups + post.downvotes = post.downs + post.views = post.views + random.randint(7,10) + g.db.add(post) + + posts = [x for x in posts if not (x.author and x.author.shadowbanned) or (v and v.id == x.author_id)][:26] + + if ids_only: + posts = [x.id for x in posts] + return posts + return posts + +@app.route("/", methods=["GET"]) +@app.route("/api/v1/listing", methods=["GET"]) +@auth_desired +def front_all(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + page = int(request.args.get("page") or 1) + + # prevent invalid paging + page = max(page, 1) + + if v: + defaultsorting = v.defaultsorting + defaulttime = v.defaulttime + else: + defaultsorting = "hot" + defaulttime = "all" + + sort=request.args.get("sort", defaultsorting) + t=request.args.get('t', defaulttime) + + ids = frontlist(sort=sort, + page=page, + t=t, + v=v, + gt=int(request.args.get("utc_greater_than", 0)), + lt=int(request.args.get("utc_less_than", 0)), + filter_words=v.filter_words if v else [], + ) + + # check existence of next page + next_exists = (len(ids) == 26) + ids = ids[0:25] + + # check if ids exist + posts = get_posts(ids, sort=sort, v=v) + + if request.path == "/": return render_template("home.html", v=v, listing=posts, next_exists=next_exists, sort=sort, t=t, page=page) + else: return jsonify({"data": [x.json for x in posts], "next_exists": next_exists}) + +@cache.memoize(timeout=1500) +def changeloglist(v=None, sort="new", page=1 ,t="all", **kwargs): + + posts = g.db.query(Submission).options(lazyload('*')).filter_by(is_banned=False,stickied=False,private=False,).filter(Submission.deleted_utc == 0) + + if v and v.admin_level == 0: + blocking = g.db.query( + UserBlock.target_id).filter_by( + user_id=v.id).subquery() + blocked = g.db.query( + UserBlock.user_id).filter_by( + target_id=v.id).subquery() + posts = posts.filter( + Submission.author_id.notin_(blocking), + Submission.author_id.notin_(blocked) + ) + + posts=posts.join(Submission.submission_aux).join(Submission.author) + posts=posts.filter(SubmissionAux.title.ilike(f'%[changelog]%', User.admin_level == 6)) + + if t != 'all': + cutoff = 0 + now = int(time.time()) + if t == 'hour': + cutoff = now - 3600 + elif t == 'day': + cutoff = now - 86400 + elif t == 'week': + cutoff = now - 604800 + elif t == 'month': + cutoff = now - 2592000 + elif t == 'year': + cutoff = now - 31536000 + posts = posts.filter(Submission.created_utc >= cutoff) + + gt = kwargs.get("gt") + lt = kwargs.get("lt") + + if gt: + posts = posts.filter(Submission.created_utc > gt) + + if lt: + posts = posts.filter(Submission.created_utc < lt) + + if sort == "hot": + posts = sorted(posts.all(), key=lambda x: x.hotscore, reverse=True) + elif sort == "new": + posts = posts.order_by(Submission.created_utc.desc()).all() + elif sort == "old": + posts = posts.order_by(Submission.created_utc.asc()).all() + elif sort == "controversial": + posts = sorted(posts.all(), key=lambda x: x.score_disputed, reverse=True) + elif sort == "top": + posts = posts.order_by(Submission.score.desc()).all() + elif sort == "bottom": + posts = posts.order_by(Submission.score.asc()).all() + elif sort == "comments": + posts = posts.order_by(Submission.comment_count.desc()).all() + elif sort == "random": + posts = posts.all() + posts = random.sample(posts, k=len(posts)) + else: + abort(400) + + firstrange = 25 * (page - 1) + secondrange = firstrange+26 + posts = posts[firstrange:secondrange] + + posts = [x.id for x in posts] + return posts + +@app.route("/changelog", methods=["GET"]) +@app.route("/api/v1/changelog", methods=["GET"]) +@auth_desired +@api("read") +def changelog(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + page = int(request.args.get("page") or 1) + page = max(page, 1) + + sort=request.args.get("sort", "new") + t=request.args.get('t', "all") + + ids = changeloglist(sort=sort, + page=page, + t=t, + v=v, + gt=int(request.args.get("utc_greater_than", 0)), + lt=int(request.args.get("utc_less_than", 0)), + ) + + # check existence of next page + next_exists = (len(ids) == 26) + ids = ids[0:25] + + # check if ids exist + posts = get_posts(ids, sort=sort, v=v) + + return {'html': lambda: render_template("changelog.html", + v=v, + listing=posts, + next_exists=next_exists, + sort=sort, + t=t, + page=page, + ), + 'api': lambda: jsonify({"data": [x.json for x in posts], + "next_exists": next_exists + } + ) + } + +@app.route("/random", methods=["GET"]) +@auth_desired +def random_post(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + x = g.db.query(Submission).filter(Submission.deleted_utc == 0, Submission.is_banned == False, Submission.score > 20) + + total = x.count() + n = random.randint(0, total - 1) + + post = x.order_by(Submission.id.asc()).offset(n).limit(1).first() + return redirect(post.permalink) + +@cache.memoize(600) +def comment_idlist(page=1, v=None, nsfw=False, sort="new", t="all", **kwargs): + + posts = g.db.query(Submission).options(lazyload('*')) + + posts = posts.subquery() + + comments = g.db.query(Comment).options(lazyload('*')) + + if v and v.admin_level <= 3: + blocking = g.db.query( + UserBlock.target_id).filter_by( + user_id=v.id).subquery() + blocked = g.db.query( + UserBlock.user_id).filter_by( + target_id=v.id).subquery() + + comments = comments.filter( + Comment.author_id.notin_(blocking), + Comment.author_id.notin_(blocked) + ) + + if not v or not v.admin_level >= 3: + comments = comments.filter_by(is_banned=False).filter(Comment.deleted_utc == 0) + + comments = comments.join(posts, Comment.parent_submission == posts.c.id) + + now = int(time.time()) + if t == 'hour': + cutoff = now - 3600 + elif t == 'day': + cutoff = now - 86400 + elif t == 'week': + cutoff = now - 604800 + elif t == 'month': + cutoff = now - 2592000 + elif t == 'year': + cutoff = now - 31536000 + else: + cutoff = 0 + comments = comments.filter(Comment.created_utc >= cutoff) + + if sort == "new": + comments = comments.order_by(Comment.created_utc.desc()).all() + elif sort == "old": + comments = comments.order_by(Comment.created_utc.asc()).all() + elif sort == "controversial": + comments = sorted(comments.all(), key=lambda x: x.score_disputed, reverse=True) + elif sort == "top": + comments = comments.order_by(Comment.score.desc()).all() + elif sort == "bottom": + comments = comments.order_by(Comment.score.asc()).all() + + firstrange = 25 * (page - 1) + secondrange = firstrange+100 + comments = comments[firstrange:secondrange] + + comments = [x.id for x in comments if not (x.author and x.author.shadowbanned) or (v and v.id == x.author_id)] + + return comments[:26] + +@app.route("/comments", methods=["GET"]) +@app.route("/api/v1/front/comments", methods=["GET"]) +@auth_desired +@api("read") +def all_comments(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + page = int(request.args.get("page", 1)) + + sort=request.args.get("sort", "new") + t=request.args.get("t", "all") + + idlist = comment_idlist(v=v, + page=page, + sort=sort, + t=t, + ) + + comments = get_comments(idlist, v=v) + + next_exists = len(idlist) == 26 + + idlist = idlist[0:25] + + board = get_board(1) + return {"html": lambda: render_template("home_comments.html", + v=v, + sort=sort, + t=t, + page=page, + comments=comments, + standalone=True, + next_exists=next_exists), + "api": lambda: jsonify({"data": [x.json for x in comments]})} diff --git a/ruqqus/routes/login.py b/ruqqus/routes/login.py new file mode 100644 index 000000000..0449db4c3 --- /dev/null +++ b/ruqqus/routes/login.py @@ -0,0 +1,609 @@ +from urllib.parse import urlencode +from ruqqus.mail import * +from ruqqus.__main__ import app, limiter + +valid_username_regex = re.compile("^[a-zA-Z0-9_\-]{3,25}$") +valid_password_regex = re.compile("^.{8,100}$") + + +@app.route("/login", methods=["GET"]) +@no_cors +@auth_desired +def login_get(v): + + redir = request.args.get("redirect", "/") + if v: + return redirect(redir) + + return render_template("login.html", + failed=False, + i=random_image(), + redirect=redir) + + +def check_for_alts(current_id): + # account history + past_accs = set(session.get("history", [])) + past_accs.add(current_id) + session["history"] = list(past_accs) + + # record alts + for past_id in session["history"]: + + if past_id == current_id: + continue + + check1 = g.db.query(Alt).filter_by( + user1=current_id, user2=past_id).first() + check2 = g.db.query(Alt).filter_by( + user1=past_id, user2=current_id).first() + + if not check1 and not check2: + + try: + new_alt = Alt(user1=past_id, + user2=current_id) + g.db.add(new_alt) + + except BaseException: + pass + +# login post procedure + + +@no_cors +@app.route("/login", methods=["POST"]) +@limiter.limit("6/minute") +def login_post(): + + username = request.form.get("username") + + if "@" in username: + account = g.db.query(User).filter( + User.email.ilike(username), + User.is_deleted == False).first() + else: + account = get_user(username, graceful=True) + + if not account: + time.sleep(random.uniform(0, 2)) + return render_template("login.html", failed=True, i=random_image()) + + if account.is_deleted: + time.sleep(random.uniform(0, 2)) + return render_template("login.html", failed=True, i=random_image()) + + # test password + + if request.form.get("password"): + + if not account.verifyPass(request.form.get("password")): + time.sleep(random.uniform(0, 2)) + return render_template("login.html", failed=True, i=random_image()) + + if account.mfa_secret: + now = int(time.time()) + hash = generate_hash(f"{account.id}+{now}+2fachallenge") + return render_template("login_2fa.html", + v=account, + time=now, + hash=hash, + i=random_image(), + redirect=request.form.get("redirect", "/") + ) + elif request.form.get("2fa_token", "x"): + now = int(time.time()) + + if now - int(request.form.get("time")) > 600: + return redirect('/login') + + formhash = request.form.get("hash") + if not validate_hash(f"{account.id}+{request.form.get('time')}+2fachallenge", + formhash + ): + return redirect("/login") + + if not account.validate_2fa(request.form.get("2fa_token", "").strip()): + hash = generate_hash(f"{account.id}+{time}+2fachallenge") + return render_template("login_2fa.html", + v=account, + time=now, + hash=hash, + failed=True, + i=random_image() + ) + + else: + abort(400) + + if account.is_banned and account.unban_utc > 0 and time.time() > account.unban_utc: + account.unban() + + # set session and user id + session["user_id"] = account.id + session["session_id"] = token_hex(16) + session["login_nonce"] = account.login_nonce + session.permanent = True + + check_for_alts(account.id) + + account.refresh_selfset_badges() + + # check for previous page + + redir = request.form.get("redirect", "/") + if redir: + return redirect(redir) + else: + return redirect(account.url) + + +@app.route("/me", methods=["GET"]) +@auth_required +def me(v): + return redirect(v.url) + + +@app.route("/logout", methods=["POST"]) +@auth_required +@validate_formkey +def logout(v): + + session.pop("user_id", None) + session.pop("session_id", None) + + return "", 204 + +# signing up + + +@app.route("/signup", methods=["GET"]) +@no_cors +@auth_desired +def sign_up_get(v): + board = g.db.query(Board).filter_by(id=1).first() + if board.disablesignups: return "Signups are disable for the time being.", 403 + + if v: + return redirect("/") + + agent = request.headers.get("User-Agent", None) + if not agent: + abort(403) + + # check for referral in link + ref_id = None + ref = request.args.get("ref", None) + if ref: + ref_user = g.db.query(User).filter(User.username.ilike(ref)).first() + + else: + ref_user = None + + if ref_user and (ref_user.id in session.get("history", [])): + return render_template("sign_up_failed_ref.html", + i=random_image()) + + # check tor + # if request.headers.get("CF-IPCountry")=="T1": + # return render_template("sign_up_tor.html", + # i=random_image(), + # ref_user=ref_user) + + # Make a unique form key valid for one account creation + now = int(time.time()) + token = token_hex(16) + session["signup_token"] = token + ip = request.remote_addr + + formkey_hashstr = str(now) + token + agent + + # formkey is a hash of session token, timestamp, and IP address + formkey = hmac.new(key=bytes(environ.get("MASTER_KEY"), "utf-16"), + msg=bytes(formkey_hashstr, "utf-16"), + digestmod='md5' + ).hexdigest() + + redir = request.args.get("redirect", None) + + error = request.args.get("error", None) + + return render_template("sign_up.html", + formkey=formkey, + now=now, + i=random_image(), + redirect=redir, + ref_user=ref_user, + error=error, + hcaptcha=app.config["HCAPTCHA_SITEKEY"] + ) + +# signup api + + +@app.route("/signup", methods=["POST"]) +@no_cors +@auth_desired +def sign_up_post(v): + board = g.db.query(Board).filter_by(id=1).first() + if board.disablesignups: return "Signups are disable for the time being.", 403 + + if v: + abort(403) + + agent = request.headers.get("User-Agent", None) + if not agent: + abort(403) + + # check tor + # if request.headers.get("CF-IPCountry")=="T1": + # return render_template("sign_up_tor.html", + # i=random_image() + # ) + + form_timestamp = request.form.get("now", '0') + form_formkey = request.form.get("formkey", "none") + + submitted_token = session.get("signup_token", "") + if not submitted_token: + abort(400) + + correct_formkey_hashstr = form_timestamp + submitted_token + agent + + correct_formkey = hmac.new(key=bytes(environ.get("MASTER_KEY"), "utf-16"), + msg=bytes(correct_formkey_hashstr, "utf-16"), + digestmod='md5' + ).hexdigest() + + now = int(time.time()) + + username = request.form.get("username") + + # define function that takes an error message and generates a new signup + # form + def new_signup(error): + + args = {"error": error} + if request.form.get("referred_by"): + user = g.db.query(User).filter_by( + id=request.form.get("referred_by")).first() + if user: + args["ref"] = user.username + + return redirect(f"/signup?{urlencode(args)}") + + if app.config["DISABLE_SIGNUPS"]: + return new_signup("New account registration is currently closed. Please come back later.") + + if now - int(form_timestamp) < 5: + #print(f"signup fail - {username } - too fast") + return new_signup("There was a problem. Please try again.") + + if not hmac.compare_digest(correct_formkey, form_formkey): + #print(f"signup fail - {username } - mismatched formkeys") + return new_signup("There was a problem. Please try again.") + + # check for matched passwords + if not request.form.get( + "password") == request.form.get("password_confirm"): + return new_signup("Passwords did not match. Please try again.") + + # check username/pass conditions + if not re.match(valid_username_regex, request.form.get("username")): + #print(f"signup fail - {username } - mismatched passwords") + return new_signup("Invalid username") + + if not re.match(valid_password_regex, request.form.get("password")): + #print(f"signup fail - {username } - invalid password") + return new_signup("Password must be between 8 and 100 characters.") + + # if not re.match(valid_email_regex, request.form.get("email")): + # return new_signup("That's not a valid email.") + + # Check for existing acocunts + email = request.form.get("email") + email = email.strip() + if not email: + email = None + + #counteract gmail username+2 and extra period tricks - convert submitted email to actual inbox + if email and email.endswith("@gmail.com"): + gmail_username=email.split('@')[0] + gmail_username=gmail_username.split('+')[0] + gmail_username=gmail_username.replace('.','') + email=f"{gmail_username}@gmail.com" + + + existing_account = get_user(request.form.get("username"), graceful=True) + if existing_account and existing_account.reserved: + return redirect(existing_account.permalink) + + if existing_account or (email and g.db.query( + User).filter(User.email.ilike(email)).first()): + # #print(f"signup fail - {username } - email already exists") + return new_signup( + "An account with that username or email already exists.") + + # check bot + if app.config.get("HCAPTCHA_SITEKEY"): + token = request.form.get("h-captcha-response") + if not token: + return new_signup("Unable to verify captcha [1].") + + data = {"secret": app.config["HCAPTCHA_SECRET"], + "response": token, + "sitekey": app.config["HCAPTCHA_SITEKEY"]} + url = "https://hcaptcha.com/siteverify" + + x = requests.post(url, data=data) + + if not x.json()["success"]: + #print(x.json()) + return new_signup("Unable to verify captcha [2].") + + # kill tokens + session.pop("signup_token") + + # get referral + ref_id = int(request.form.get("referred_by", 0)) + + # upgrade user badge + if ref_id: + ref_user = g.db.query(User).options( + lazyload('*')).filter_by(id=ref_id).first() + if ref_user: + ref_user.refresh_selfset_badges() + g.db.add(ref_user) + + # make new user + try: + new_user = User( + username=username, + original_username = username, + password=request.form.get("password"), + email=email, + created_utc=int(time.time()), + creation_ip=request.remote_addr, + referred_by=ref_id or None, + tos_agreed_utc=int(time.time()), + creation_region=request.headers.get("cf-ipcountry"), + ban_evade = int(any([x.is_banned for x in g.db.query(User).filter(User.id.in_(tuple(session.get("history", [])))).all() if x])) + ) + + except Exception as e: + #print(e) + return new_signup("Please enter a valid email") + + g.db.add(new_user) + g.db.commit() + + # give a beta badge + beta_badge = Badge(user_id=new_user.id, + badge_id=6) + + g.db.add(beta_badge) + + # check alts + + check_for_alts(new_user.id) + + # send welcome/verify email + if email: + send_verification_email(new_user) + + # send welcome message + send_notification(1046, new_user, "Dude bussy lmao") + + session["user_id"] = new_user.id + session["session_id"] = token_hex(16) + + redir = request.form.get("redirect", None) + + # #print(f"Signup event: @{new_user.username}") + + return redirect("/") + + +@app.route("/forgot", methods=["GET"]) +def get_forgot(): + + return render_template("forgot_password.html", + i=random_image() + ) + + +@app.route("/forgot", methods=["POST"]) +def post_forgot(): + + username = request.form.get("username").lstrip('@') + email = request.form.get("email",'').strip() + + email=email.replace("_","\_") + + user = g.db.query(User).filter( + User.username.ilike(username), + User.email.ilike(email), + User.is_deleted == False).first() + + if user: + # generate url + now = int(time.time()) + token = generate_hash(f"{user.id}+{now}+forgot+{user.login_nonce}") + url = f"https://{app.config['SERVER_NAME']}/reset?id={user.id}&time={now}&token={token}" + + send_mail(to_address=user.email, + subject="Drama - Password Reset Request", + html=render_template("email/password_reset.html", + action_url=url, + v=user) + ) + + return render_template("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.", + i=random_image()) + + +@app.route("/reset", methods=["GET"]) +def get_reset(): + + user_id = request.args.get("id") + timestamp = int(request.args.get("time",0)) + token = request.args.get("token") + + now = int(time.time()) + + if now - timestamp > 600: + return render_template("message.html", + title="Password reset link expired", + error="That password reset link has expired.") + + user = g.db.query(User).filter_by(id=user_id).first() + + if not validate_hash(f"{user_id}+{timestamp}+forgot+{user.login_nonce}", token): + abort(400) + + if not user: + abort(404) + + reset_token = generate_hash(f"{user.id}+{timestamp}+reset+{user.login_nonce}") + + return render_template("reset_password.html", + v=user, + token=reset_token, + time=timestamp, + i=random_image() + ) + + +@app.route("/reset", methods=["POST"]) +@auth_desired +def post_reset(v): + if v: + return redirect('/') + + user_id = request.form.get("user_id") + timestamp = int(request.form.get("time")) + token = request.form.get("token") + + password = request.form.get("password") + confirm_password = request.form.get("confirm_password") + + now = int(time.time()) + + if now - timestamp > 600: + return render_template("message.html", + title="Password reset expired", + error="That password reset form has expired.") + + user = g.db.query(User).filter_by(id=user_id).first() + + if not validate_hash(f"{user_id}+{timestamp}+reset+{user.login_nonce}", token): + abort(400) + if not user: + abort(404) + + if not password == confirm_password: + return render_template("reset_password.html", + v=user, + token=token, + time=timestamp, + i=random_image(), + error="Passwords didn't match.") + + user.passhash = hash_password(password) + g.db.add(user) + + return render_template("message_success.html", + title="Password reset successful!", + message="Login normally to access your account.") + +@app.route("/lost_2fa") +@auth_desired +def lost_2fa(v): + + return render_template( + "lost_2fa.html", + i=random_image(), + v=v + ) + +@app.route("/request_2fa_disable", methods=["POST"]) +@limiter.limit("6/minute") +def request_2fa_disable(): + + username=request.form.get("username") + user=get_user(username, graceful=True) + if not user or not user.email or not user.mfa_secret: + return render_template("message.html", + title="Removal request received", + message="If username, password, and email match, we will send you an email.") + + + email=request.form.get("email") + if email and email.endswith("@gmail.com"): + gmail_username=email.split('@')[0] + gmail_username=gmail_username.split('+')[0] + gmail_username=gmail_username.replace('.','') + email=f"{gmail_username}@gmail.com" + + if email != user.email: + return render_template("message.html", + title="Removal request received", + message="If username, password, and email match, we will send you an email.") + + + password =request.form.get("password") + if not user.verifyPass(password): + return render_template("message.html", + title="Removal request received", + message="If username, password, and email match, we will send you an email.") + + #compute token + valid=int(time.time())+60*60*24*3 + token=generate_hash(f"{user.id}+{user.username}+disable2fa+{valid}+{user.mfa_secret}+{user.login_nonce}") + + action_url=f"https://{app.config['SERVER_NAME']}/reset_2fa?id={user.base36id}&t={valid}&token={token}" + + send_mail(to_address=user.email, + subject="Drama - 2FA Removal Request", + html=render_template("email/2fa_remove.html", + action_url=action_url, + v=user) + ) + + return render_template("message.html", + title="Removal request received", + message="If username, password, and email match, we will send you an email.") + +@app.route("/reset_2fa", methods=["GET"]) +def reset_2fa(): + + now=int(time.time()) + t=int(request.args.get("t")) + + if now t+3600*24: + return render_template("message.html", + title="Expired Link", + error="That link has expired.") + + token=request.args.get("token") + uid=request.args.get("id") + + user=get_account(uid) + + if not validate_hash(f"{user.id}+{user.username}+disable2fa+{t}+{user.mfa_secret}+{user.login_nonce}", token): + abort(403) + + #validation successful, remove 2fa + user.mfa_secret=None + + g.db.add(user) + g.db.commit() + + return render_template("message_success.html", + title="Two-factor authentication removed.", + message="Login normally to access your account.") diff --git a/ruqqus/routes/oauth.py b/ruqqus/routes/oauth.py new file mode 100644 index 000000000..3e921026a --- /dev/null +++ b/ruqqus/routes/oauth.py @@ -0,0 +1,480 @@ +from ruqqus.helpers.wrappers import * +from ruqqus.helpers.alerts import * +from ruqqus.helpers.get import * +from ruqqus.classes import * +from flask import * +from ruqqus.__main__ import app + +SCOPES = { + 'identity': 'See your username', + 'create': 'Save posts and comments as you', + 'read': 'View Drama as you, including private or restricted content', + 'update': 'Edit your posts and comments', + 'delete': 'Delete your posts and comments', + 'vote': 'Cast votes as you', + 'guildmaster': 'Perform Badmin actions' +} + + +@app.route("/oauth/authorize", methods=["GET"]) +@auth_required +def oauth_authorize_prompt(v): + ''' + This page takes the following URL parameters: + * client_id - Your application client ID + * scope - Comma-separated list of scopes. Scopes are described above + * redirect_uri - Your redirect link + * state - Your anti-csrf token + ''' + + client_id = request.args.get("client_id") + + application = get_application(client_id) + if not application: + return jsonify({"oauth_error": "Invalid `client_id`"}), 401 + + if application.is_banned: + return jsonify({"oauth_error": f"Application `{application.app_name}` is suspended."}), 403 + + scopes_txt = request.args.get('scope', "") + + scopes = scopes_txt.split(',') + if not scopes: + return jsonify( + {"oauth_error": "One or more scopes must be specified as a comma-separated list."}), 400 + + for scope in scopes: + if scope not in SCOPES: + return jsonify({"oauth_error": f"The provided scope `{scope}` is not valid."}), 400 + + if any(x in scopes for x in ["create", "update", + "guildmaster"]) and "identity" not in scopes: + return jsonify({"oauth_error": f"`identity` scope required when requesting `create`, `update`, or `guildmaster` scope."}), 400 + + redirect_uri = request.args.get("redirect_uri") + if not redirect_uri: + return jsonify({"oauth_error": f"`redirect_uri` must be provided."}), 400 + + valid_redirect_uris = [x.strip() + for x in application.redirect_uri.split(",")] + + if redirect_uri not in valid_redirect_uris: + return jsonify({"oauth_error": "Invalid redirect_uri"}), 400 + + state = request.args.get("state") + if not state: + return jsonify({'oauth_error': 'state argument required'}), 400 + + permanent = bool(request.args.get("permanent")) + + return render_template("oauth.html", + v=v, + application=application, + SCOPES=SCOPES, + state=state, + scopes=scopes, + scopes_txt=scopes_txt, + redirect_uri=redirect_uri, + permanent=int(permanent), + i=random_image() + ) + + +@app.route("/oauth/authorize", methods=["POST"]) +@auth_required +@validate_formkey +def oauth_authorize_post(v): + + client_id = request.form.get("client_id") + scopes_txt = request.form.get("scopes") + state = request.form.get("state") + redirect_uri = request.form.get("redirect_uri") + + application = get_application(client_id) + if not application: + return jsonify({"oauth_error": "Invalid `client_id`"}), 401 + if application.is_banned: + return jsonify({"oauth_error": f"Application `{application.app_name}` is suspended."}), 403 + + valid_redirect_uris = [x.strip() + for x in application.redirect_uri.split(",")] + if redirect_uri not in valid_redirect_uris: + return jsonify({"oauth_error": "Invalid redirect_uri"}), 400 + + scopes = scopes_txt.split(',') + if not scopes: + return jsonify( + {"oauth_error": "One or more scopes must be specified as a comma-separated list"}), 400 + + for scope in scopes: + if scope not in SCOPES: + return jsonify({"oauth_error": f"The provided scope `{scope}` is not valid."}), 400 + + if any(x in scopes for x in ["create", "update", + "guildmaster"]) and "identity" not in scopes: + return jsonify({"oauth_error": f"`identity` scope required when requesting `create`, `update`, or `guildmaster` scope."}), 400 + + if not state: + return jsonify({'oauth_error': 'state argument required'}), 400 + + permanent = bool(int(request.values.get("permanent", 0))) + + new_auth = ClientAuth( + oauth_client=application.id, + oauth_code=secrets.token_urlsafe(128)[0:128], + user_id=v.id, + scope_identity="identity" in scopes, + scope_create="create" in scopes, + scope_read="read" in scopes, + scope_update="update" in scopes, + scope_delete="delete" in scopes, + scope_vote="vote" in scopes, + scope_guildmaster="guildmaster" in scopes, + refresh_token=secrets.token_urlsafe(128)[0:128] if permanent else None + ) + + g.db.add(new_auth) + + return redirect(f"{redirect_uri}?code={new_auth.oauth_code}&scopes={scopes_txt}&state={state}") + + +@app.route("/oauth/grant", methods=["POST"]) +def oauth_grant(): + ''' + This endpoint takes the following parameters: + * code - The code parameter provided in the redirect + * client_id - Your client ID + * client_secret - your client secret + ''' + + application = g.db.query(OauthApp).filter_by( + client_id=request.values.get("client_id"), + client_secret=request.values.get("client_secret")).first() + if not application: + return jsonify( + {"oauth_error": "Invalid `client_id` or `client_secret`"}), 401 + if application.is_banned: + return jsonify({"oauth_error": f"Application `{application.app_name}` is suspended."}), 403 + + if request.values.get("grant_type") == "code": + + code = request.values.get("code") + if not code: + return jsonify({"oauth_error": "code required"}), 400 + + auth = g.db.query(ClientAuth).filter_by( + oauth_code=code, + access_token=None, + oauth_client=application.id + ).first() + + if not auth: + return jsonify({"oauth_error": "Invalid code"}), 401 + + auth.oauth_code = None + auth.access_token = secrets.token_urlsafe(128)[0:128] + auth.access_token_expire_utc = int(time.time()) + 60 * 60 + + g.db.add(auth) + + g.db.commit() + + data = { + "access_token": auth.access_token, + "scopes": auth.scopelist, + "expires_at": auth.access_token_expire_utc, + "token_type": "Bearer" + } + + if auth.refresh_token: + data["refresh_token"] = auth.refresh_token + + return jsonify(data) + + elif request.values.get("grant_type") == "refresh": + + refresh_token = request.values.get('refresh_token') + if not refresh_token: + return jsonify({"oauth_error": "refresh_token required"}), 401 + + auth = g.db.query(ClientAuth).filter_by( + refresh_token=refresh_token, + oauth_code=None, + oauth_client=application.id + ).first() + + if not auth: + return jsonify({"oauth_error": "Invalid refresh_token"}), 401 + + auth.access_token = secrets.token_urlsafe(128)[0:128] + auth.access_token_expire_utc = int(time.time()) + 60 * 60 + + g.db.add(auth) + + data = { + "access_token": auth.access_token, + "scopes": auth.scopelist, + "expires_at": auth.access_token_expire_utc + } + + return jsonify(data) + + else: + return jsonify({"oauth_error": f"Invalid grant_type `{request.values.get('grant_type','')}`. Expected `code` or `refresh`."}), 400 + + +@app.route("/api_keys", methods=["POST"]) +@is_not_banned +def request_api_keys(v): + + new_app = OauthApp( + app_name=request.form.get('name'), + redirect_uri=request.form.get('redirect_uri'), + author_id=v.id, + description=request.form.get("description")[0:256] + ) + + g.db.add(new_app) + + send_admin(1046, f"@{v.username} has requested API keys for `{request.form.get('name')}`. You can approve or deny the request [here](/admin/apps).") + + return redirect('/settings/apps') + + +@app.route("/delete_app/", methods=["POST"]) +@is_not_banned +@validate_formkey +def delete_oauth_app(v, aid): + + aid = int(aid) + app = g.db.query(OauthApp).filter_by(id=aid).first() + + for auth in g.db.query(ClientAuth).filter_by(oauth_client=app.id).all(): + g.db.delete(auth) + + g.db.commit() + + g.db.delete(app) + + return redirect('/apps') + + +@app.route("/edit_app/", methods=["POST"]) +@is_not_banned +@validate_formkey +def edit_oauth_app(v, aid): + + aid = int(aid) + app = g.db.query(OauthApp).filter_by(id=aid).first() + + app.redirect_uri = request.form.get('redirect_uri') + app.app_name = request.form.get('name') + app.description = request.form.get("description")[0:256] + + g.db.add(app) + + return redirect('/settings/apps') + + +@app.route("/api/v1/identity") +@auth_required +@api("identity") +def api_v1_identity(v): + + return jsonify(v.json) + + +@app.route("/admin/app/approve/", methods=["POST"]) +@admin_level_required(3) +@validate_formkey +def admin_app_approve(v, aid): + + app = g.db.query(OauthApp).filter_by(id=base36decode(aid)).first() + + app.client_id = secrets.token_urlsafe(64)[0:64] + app.client_secret = secrets.token_urlsafe(128)[0:128] + + g.db.add(app) + + u = get_account(app.author_id, v=v) + send_notification(1046, u, f"Your application `{app.app_name}` has been approved.") + + return jsonify({"message": f"{app.app_name} approved"}) + + +@app.route("/admin/app/revoke/", methods=["POST"]) +@admin_level_required(3) +@validate_formkey +def admin_app_revoke(v, aid): + + app = g.db.query(OauthApp).filter_by(id=base36decode(aid)).first() + + app.client_id = None + app.client_secret = None + + g.db.add(app) + + u = get_account(app.author_id, v=v) + send_notification(1046, u, f"Your application `{app.app_name}` has been revoked.") + + return jsonify({"message": f"{app.app_name} revoked"}) + + +@app.route("/admin/app/reject/", methods=["POST"]) +@admin_level_required(3) +@validate_formkey +def admin_app_reject(v, aid): + + app = g.db.query(OauthApp).filter_by(id=base36decode(aid)).first() + + for auth in g.db.query(ClientAuth).filter_by(oauth_client=app.id).all(): + g.db.delete(auth) + + g.db.flush() + u = get_account(app.author_id, v=v) + send_notification(1046, u, f"Your application `{app.app_name}` has been rejected.") + + g.db.delete(app) + + return jsonify({"message": f"{app.app_name} rejected"}) + + +@app.route("/admin/app/", methods=["GET"]) +@admin_level_required(3) +def admin_app_id(v, aid): + + aid=base36decode(aid) + + oauth = g.db.query(OauthApp).options( + joinedload( + OauthApp.author)).filter_by( + id=aid).first() + + pids=oauth.idlist(page=int(request.args.get("page",1)), + ) + + next_exists=len(pids)==101 + pids=pids[0:100] + + posts=get_posts(pids, v=v) + + return render_template("admin/app.html", + v=v, + app=oauth, + listing=posts, + next_exists=next_exists + ) + +@app.route("/admin/app//comments", methods=["GET"]) +@admin_level_required(3) +def admin_app_id_comments(v, aid): + + aid=base36decode(aid) + + oauth = g.db.query(OauthApp).options( + joinedload( + OauthApp.author)).filter_by( + id=aid).first() + + cids=oauth.comments_idlist(page=int(request.args.get("page",1)), + ) + + next_exists=len(cids)==101 + cids=cids[0:100] + + comments=get_comments(cids, v=v) + + + return render_template("admin/app.html", + v=v, + app=oauth, + comments=comments, + next_exists=next_exists, + standalone=True + ) + + +@app.route("/admin/apps", methods=["GET"]) +@admin_level_required(3) +def admin_apps_list(v): + + apps = g.db.query(OauthApp).options( + joinedload( + OauthApp.author)).filter( + OauthApp.client_id==None).order_by( + OauthApp.id.desc()).all() + + return render_template("admin/apps.html", v=v, apps=apps) + + +@app.route("/oauth/reroll/", methods=["POST"]) +@auth_required +def reroll_oauth_tokens(aid, v): + + aid = base36decode(aid) + + a = g.db.query(OauthApp).filter_by(id=aid).first() + + if a.author_id != v.id: + abort(403) + + a.client_id = secrets.token_urlsafe(64)[0:64] + a.client_secret = secrets.token_urlsafe(128)[0:128] + + g.db.add(a) + + return jsonify({"message": "Tokens Rerolled", + "id": a.client_id, + "secret": a.client_secret + } + ) + + +@app.route("/oauth/rescind/", methods=["POST"]) +@auth_required +@validate_formkey +def oauth_rescind_app(aid, v): + + aid = base36decode(aid) + auth = g.db.query(ClientAuth).filter_by(id=aid).first() + + if auth.user_id != v.id: + abort(403) + + g.db.delete(auth) + + return jsonify({"message": f"{auth.application.app_name} Revoked"}) + +@app.route("/api/v1/release", methods=["POST"]) +@auth_required +@api() +def oauth_release_auth(v): + + token=request.headers.get("Authorization").split()[1] + + auth = g.db.query(ClientAuth).filter_by(user_id=v.id, access_token=token).first() + if not auth: + abort(404) + + if not auth.refresh_token: + abort(400) + + auth.access_token_expire_utc=0 + g.db.add(auth) + + return jsonify({"message":"Authorization released"}) + +@app.route("/api/v1/kill", methods=["POST"]) +@auth_required +@api() +def oauth_kill_auth(v): + + token=request.headers.get("Authorization").split()[1] + + auth = g.db.query(ClientAuth).filter_by(user_id=v.id, access_token=token).first() + if not auth: + abort(404) + + g.db.delete(auth) + + return jsonify({"message":"Authorization released"}) diff --git a/ruqqus/routes/posts.py b/ruqqus/routes/posts.py new file mode 100644 index 000000000..976f008fb --- /dev/null +++ b/ruqqus/routes/posts.py @@ -0,0 +1,1116 @@ +from urllib.parse import urlparse +import mistletoe +import urllib.parse +import threading +import gevent + +from ruqqus.helpers.wrappers import * +from ruqqus.helpers.sanitize import * +from ruqqus.helpers.filters import * +from ruqqus.helpers.embed import * +from ruqqus.helpers.markdown import * +from ruqqus.helpers.session import * +from ruqqus.helpers.thumbs import * +from ruqqus.helpers.alerts import send_notification +from ruqqus.helpers.discord import send_message +from ruqqus.classes import * +from .front import frontlist +from flask import * +from io import BytesIO +from ruqqus.__main__ import app, limiter, cache, db_session +from PIL import Image as PILimage + +BAN_REASONS = ['', + "URL shorteners are not permitted.", + "Pornographic material is not permitted.", # defunct + "Copyright infringement is not permitted." + ] + +BUCKET = "i.ruqqus.ga" + +with open("snappy.txt", "r") as f: + snappyquotes = f.read().split("{[para]}") + +@app.route("/api/publish/", methods=["POST"]) +@is_not_banned +@validate_formkey +def publish(pid, v): + post = get_post(pid) + if not post.author_id == v.id: abort(403) + post.private = False + g.db.add(post) + cache.delete_memoized(frontlist) + g.db.commit() + return "", 204 + +@app.route("/submit", methods=["GET"]) +@auth_required +@no_negative_balance("html") +def submit_get(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + board = request.args.get("guild", "general") + b = get_guild(board, graceful=True) + if not b: + + b = get_guild("general") + + + return render_template("submit.html", + v=v, + b=b + ) + +@app.route("/post_short/", methods=["GET"]) +@app.route("/post_short/", methods=["GET"]) +@app.route("/post_short//", methods=["GET"]) +def incoming_post_shortlink(base36id=None): + + if not base36id: + return redirect('/') + + if base36id == "robots.txt": + return redirect('/robots.txt') + + try: + x=base36decode(base36id) + except: + abort(400) + + post = get_post(base36id) + return redirect(post.permalink) + +@app.route("/post/", methods=["GET"]) +@app.route("/post//", methods=["GET"]) +@app.route("/post//", methods=["GET"]) +@app.route("/api/v1/post/", methods=["GET"]) +@app.route("/test/post/", methods=["GET"]) +@auth_desired +@api("read") +def post_base36id(pid, anything=None, v=None): + try: pid = int(pid) + except Exception as e: pass + + if v: defaultsortingcomments = v.defaultsortingcomments + else: defaultsortingcomments = "top" + sort=request.args.get("sort", defaultsortingcomments) + + post = get_post_with_comments(pid, v=v, sort=sort) + + post.views += 1 + g.db.add(post) + g.db.commit() + + board = post.board + + if post.over_18 and not (v and v.over_18) and not session_over18(board): + t = int(time.time()) + return {"html":lambda:render_template("errors/nsfw.html", + v=v, + t=t, + lo_formkey=make_logged_out_formkey(t), + board=post.board + + ), + "api":lambda:(jsonify({"error":"Must be 18+ to view"}), 451) + } + + post.tree_comments() + + return { + "html":lambda:post.rendered_page(v=v, sort=sort), + "api":lambda:jsonify(post.json) + } + +@app.route("/edit_post/", methods=["POST"]) +@is_not_banned +@no_negative_balance("html") +@validate_formkey +def edit_post(pid, v): + + p = get_post(pid) + + if not p.author_id == v.id: + abort(403) + + if p.is_banned: + abort(403) + + if p.board.has_ban(v): + abort(403) + + body = request.form.get("body", "") + for i in re.finditer('^(https:\/\/.*\.(png|jpg|jpeg|gif))', body, re.MULTILINE): body = body.replace(i.group(1), f'![]({i.group(1)})') + with CustomRenderer() as renderer: + body_md = renderer.render(mistletoe.Document(body)) + body_html = sanitize(body_md, linkgen=True) + + # Run safety filter + bans = filter_comment_html(body_html) + if bans: + ban = bans[0] + reason = f"Remove the {ban.domain} link from your post and try again." + if ban.reason: + reason += f" {ban.reason_text}" + + #auto ban for digitally malicious content + if any([x.reason==4 for x in bans]): + v.ban(days=30, reason="Digitally malicious content is not allowed.") + abort(403) + + return {"error": reason}, 403 + + # check spam + soup = BeautifulSoup(body_html, features="html.parser") + links = [x['href'] for x in soup.find_all('a') if x.get('href')] + + for link in links: + parse_link = urlparse(link) + check_url = ParseResult(scheme="https", + netloc=parse_link.netloc, + path=parse_link.path, + params=parse_link.params, + query=parse_link.query, + fragment='') + check_url = urlunparse(check_url) + + badlink = g.db.query(BadLink).filter( + literal(check_url).contains( + BadLink.link)).first() + if badlink: + if badlink.autoban: + text = "Your Drama account has been suspended for 1 day for the following reason:\n\n> Too much spam!" + send_notification(1046, v, text) + v.ban(days=1, reason="spam") + + return redirect('/notifications') + else: + + return {"error": f"The link `{badlink.link}` is not allowed. Reason: {badlink.reason}"} + + + p.body = body + p.body_html = body_html + title = request.form.get("title") + p.title = title + p.title_html = sanitize(title.replace('_','\_'), linkgen=True, flair=True) + + if int(time.time()) - p.created_utc > 60 * 3: p.edited_utc = int(time.time()) + g.db.add(p) + + notify_users = set() + + soup = BeautifulSoup(body_html, features="html.parser") + for mention in soup.find_all("a", href=re.compile("^/@(\w+)")): + username = mention["href"].split("@")[1] + user = g.db.query(User).filter_by(username=username).first() + if user and not v.any_block_exists(user) and user.id != v.id: notify_users.add(user) + + for x in notify_users: send_notification(1046, x, f"@{v.username} has mentioned you: https://rdrama.net{p.permalink}") + + return redirect(p.permalink) + +@app.route("/submit/title", methods=['GET']) +@limiter.limit("6/minute") +@is_not_banned +@no_negative_balance("html") +def get_post_title(v): + + url = request.args.get("url", None) + if not url: + return abort(400) + + #mimic chrome browser agent + headers = {"User-Agent": f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36"} + try: + x = requests.get(url, headers=headers) + except BaseException: + return jsonify({"error": "Could not reach page"}), 400 + + + if not x.status_code == 200: + return jsonify({"error": f"Page returned {x.status_code}"}), x.status_code + + + try: + soup = BeautifulSoup(x.content, 'html.parser') + + data = {"url": url, + "title": soup.find('title').string + } + + return jsonify(data) + except BaseException: + return jsonify({"error": f"Could not find a title"}), 400 + +def thumbs(new_post): + pid = new_post.base36id + post = get_post(pid, graceful=True, session=g.db) + if not post: + # account for possible follower lag + time.sleep(60) + post = get_post(pid, session=g.db) + + fetch_url=post.url + + #get the content + + #mimic chrome browser agent + headers={"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"} + + try: + x=requests.get(fetch_url, headers=headers) + except: + return False, "Unable to connect to source" + + if x.status_code != 200: + return False, f"Source returned status {x.status_code}." + + #if content is image, stick with that. Otherwise, parse html. + + if x.headers.get("Content-Type","").startswith("text/html"): + #parse html, find image, load image + soup=BeautifulSoup(x.content, 'html.parser') + #parse html + + #first, set metadata + try: + meta_title=soup.find('title') + if meta_title: + post.submission_aux.meta_title=str(meta_title.string)[0:500] + + meta_desc = soup.find('meta', attrs={"name":"description"}) + if meta_desc: + post.submission_aux.meta_description=meta_desc['content'][0:1000] + + if meta_title or meta_desc: + g.db.add(post.submission_aux) + g.db.commit() + + except Exception as e: + pass + + #create list of urls to check + thumb_candidate_urls=[] + + #iterate through desired meta tags + meta_tags = [ + "ruqqus:thumbnail", + "twitter:image", + "og:image", + "thumbnail" + ] + + for tag_name in meta_tags: + + + + tag = soup.find( + 'meta', + attrs={ + "name": tag_name, + "content": True + } + ) + if not tag: + tag = soup.find( + 'meta', + attrs={ + 'property': tag_name, + 'content': True + } + ) + if tag: + thumb_candidate_urls.append(expand_url(post.url, tag['content'])) + + #parse html doc for elements + for tag in soup.find_all("img", attrs={'src':True}): + thumb_candidate_urls.append(expand_url(post.url, tag['src'])) + + + #now we have a list of candidate urls to try + for url in thumb_candidate_urls: + + try: + image_req=requests.get(url, headers=headers) + except: + continue + + if image_req.status_code >= 400: + continue + + if not image_req.headers.get("Content-Type","").startswith("image/"): + continue + + if image_req.headers.get("Content-Type","").startswith("image/svg"): + continue + + image = PILimage.open(BytesIO(image_req.content)) + if image.width < 30 or image.height < 30: + continue + + break + + else: + #getting here means we are out of candidate urls (or there never were any) + return False, "No usable images" + + + + + elif x.headers.get("Content-Type","").startswith("image/"): + #image is originally loaded fetch_url + image_req=x + image = PILimage.open(BytesIO(x.content)) + + else: + + print(f'Unknown content type {x.headers.get("Content-Type")}') + return False, f'Unknown content type {x.headers.get("Content-Type")} for submitted content' + + name = f"posts/{post.base36id}/thumb.png" + tempname = name.replace("/", "_") + + with open(tempname, "wb") as file: + for chunk in image_req.iter_content(1024): + file.write(chunk) + + post.thumburl = upload_from_file(name, tempname, resize=(375, 227)) + if post.thumburl: post.has_thumb = True + g.db.add(post) + g.db.commit() + + try: remove(tempname) + except FileNotFoundError: pass + +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 Exception as e: print(e) + +@app.route("/submit", methods=['POST']) +@app.route("/api/v1/submit", methods=["POST"]) +@app.route("/api/vue/submit", methods=["POST"]) +@limiter.limit("6/minute") +@is_not_banned +@no_negative_balance('html') +@tos_agreed +@validate_formkey +@api("create") +def submit_post(v): + + title = request.form.get("title", "").strip() + + title = title.strip() + title = title.replace("\n", "") + title = title.replace("\r", "") + title = title.replace("\t", "") + + url = request.form.get("url", "") + + if url: + repost = g.db.query(Submission).join(Submission.submission_aux).filter( + SubmissionAux.url.ilike(url), + Submission.deleted_utc == 0, + Submission.is_banned == False + ).first() + else: + repost = None + + if repost: + return redirect(repost.permalink) + + board = get_guild(request.form.get('board', 'general'), graceful=True) + if not board: + board = get_guild('general') + + if not title: + return {"html": lambda: (render_template("submit.html", + v=v, + error="Please enter a better title.", + title=title, + url=url, + body=request.form.get( + "body", ""), + b=board + ), 400), + "api": lambda: ({"error": "Please enter a better title"}, 400) + } + + # if len(title)<10: + # return render_template("submit.html", + # v=v, + # error="Please enter a better title.", + # title=title, + # url=url, + # body=request.form.get("body",""), + # b=board + # ) + + + elif len(title) > 500: + return {"html": lambda: (render_template("submit.html", + v=v, + error="500 character limit for titles.", + title=title[0:500], + url=url, + body=request.form.get( + "body", ""), + b=board + ), 400), + "api": lambda: ({"error": "500 character limit for titles"}, 400) + } + + parsed_url = urlparse(url) + if not (parsed_url.scheme and parsed_url.netloc) and not request.form.get( + "body") and not request.files.get("file", None): + return {"html": lambda: (render_template("submit.html", + v=v, + error="Please enter a url or some text.", + title=title, + url=url, + body=request.form.get( + "body", ""), + b=board + ), 400), + "api": lambda: ({"error": "`url` or `body` parameter required."}, 400) + } + # sanitize title + title = bleach.clean(title, tags=[]) + + # Force https for submitted urls + + if request.form.get("url"): + new_url = ParseResult(scheme="https", + netloc=parsed_url.netloc, + path=parsed_url.path, + params=parsed_url.params, + query=parsed_url.query, + fragment=parsed_url.fragment) + url = urlunparse(new_url) + else: + url = "" + + if "i.imgur.com" in url: url = url.replace(".png", "_d.png").replace(".jpg", "_d.jpg").replace(".jpeg", "_d.jpeg") + "?maxwidth=8888" + + body = request.form.get("body", "") + # check for duplicate + dup = g.db.query(Submission).join(Submission.submission_aux).filter( + + Submission.author_id == v.id, + Submission.deleted_utc == 0, + Submission.board_id == board.id, + SubmissionAux.title == title, + SubmissionAux.url == url, + SubmissionAux.body == body + ).first() + + if dup: + return redirect(dup.permalink) + + + # check for domain specific rules + + parsed_url = urlparse(url) + + domain = parsed_url.netloc + + # check ban status + domain_obj = get_domain(domain) + if domain_obj: + if not domain_obj.can_submit: + + if domain_obj.reason==4: + v.ban(days=30, reason="Digitally malicious content") + elif domain_obj.reason==7: + v.ban(reason="Sexualizing minors") + + return {"html": lambda: (render_template("submit.html", + v=v, + error=BAN_REASONS[domain_obj.reason], + title=title, + url=url, + body=request.form.get( + "body", ""), + b=board + ), 400), + "api": lambda: ({"error": BAN_REASONS[domain_obj.reason]}, 400) + } + + # check for embeds + if domain_obj.embed_function: + try: + embed = eval(domain_obj.embed_function)(url) + except BaseException: + embed = None + else: + embed = None + else: + + embed = None + + # board + board_name = request.form.get("board", "general") + board_name = board_name.lstrip("+") + board_name = board_name.strip() + + board = get_guild(board_name, graceful=True) + + if not board: + + return {"html": lambda: (render_template("submit.html", + v=v, + error=f"Please enter a Guild to submit to.", + title=title, + url=url, body=request.form.get( + "body", ""), + b=None + ), 403), + "api": lambda: (jsonify({"error": f"403 Forbidden - +{board.name} has been banned."})) + } + + if board.is_banned: + + return {"html": lambda: (render_template("submit.html", + v=v, + error=f"+{board.name} has been banned.", + title=title, + url=url, body=request.form.get( + "body", ""), + b=None + ), 403), + "api": lambda: (jsonify({"error": f"403 Forbidden - +{board.name} has been banned."})) + } + + if board.has_ban(v): + return {"html": lambda: (render_template("submit.html", + v=v, + error=f"You are exiled from +{board.name}.", + title=title, + url=url, body=request.form.get( + "body", ""), + b=None + ), 403), + "api": lambda: (jsonify({"error": f"403 Not Authorized - You are exiled from +{board.name}"}), 403) + } + + if (board.restricted_posting or board.is_private) and not ( + board.can_submit(v)): + return {"html": lambda: (render_template("submit.html", + v=v, + error=f"You are not an approved contributor for +{board.name}.", + title=title, + url=url, + body=request.form.get( + "body", ""), + b=None + ), 403), + "api": lambda: (jsonify({"error": f"403 Not Authorized - You are not an approved contributor for +{board.name}"}), 403) + } + + # similarity check + now = int(time.time()) + cutoff = now - 60 * 60 * 24 + + + similar_posts = g.db.query(Submission).options( + lazyload('*') + ).join( + Submission.submission_aux + ).filter( + #or_( + # and_( + Submission.author_id == v.id, + SubmissionAux.title.op('<->')(title) < app.config["SPAM_SIMILARITY_THRESHOLD"], + Submission.created_utc > cutoff + # ), + # and_( + # SubmissionAux.title.op('<->')(title) < app.config["SPAM_SIMILARITY_THRESHOLD"]/2, + # Submission.created_utc > cutoff + # ) + #) + ).all() + + if url: + similar_urls = g.db.query(Submission).options( + lazyload('*') + ).join( + Submission.submission_aux + ).filter( + #or_( + # and_( + Submission.author_id == v.id, + SubmissionAux.url.op('<->')(url) < app.config["SPAM_URL_SIMILARITY_THRESHOLD"], + Submission.created_utc > cutoff + # ), + # and_( + # SubmissionAux.url.op('<->')(url) < app.config["SPAM_URL_SIMILARITY_THRESHOLD"]/2, + # Submission.created_utc > cutoff + # ) + #) + ).all() + else: + similar_urls = [] + + threshold = app.config["SPAM_SIMILAR_COUNT_THRESHOLD"] + if v.age >= (60 * 60 * 24 * 7): + threshold *= 3 + elif v.age >= (60 * 60 * 24): + threshold *= 2 + + if max(len(similar_urls), len(similar_posts)) >= threshold: + + text = "Your Drama account has been suspended for 1 day for the following reason:\n\n> Too much spam!" + send_notification(1046, v, text) + + v.ban(reason="Spamming.", + days=1) + + for alt in v.alts: + if not alt.is_suspended: + alt.ban(reason="Spamming.") + + for post in similar_posts + similar_urls: + post.is_banned = True + post.is_pinned = False + post.ban_reason = "Automatic spam removal. This happened because the post's creator submitted too much similar content too quickly." + g.db.add(post) + ma=ModAction( + user_id=2317, + target_submission_id=post.id, + kind="ban_post", + board_id=post.board_id, + note="spam" + ) + g.db.add(ma) + g.db.commit() + return redirect("/notifications") + + # catch too-long body + if len(str(body)) > 10000: + + return {"html": lambda: (render_template("submit.html", + v=v, + error="10000 character limit for text body.", + title=title, + url=url, + body=request.form.get( + "body", ""), + b=board + ), 400), + "api": lambda: ({"error": "10000 character limit for text body."}, 400) + } + + if len(url) > 2048: + + return {"html": lambda: (render_template("submit.html", + v=v, + error="2048 character limit for URLs.", + title=title, + url=url, + body=request.form.get( + "body", ""), + b=board + ), 400), + "api": lambda: ({"error": "2048 character limit for URLs."}, 400) + } + + # render text + for i in re.finditer('^(https:\/\/.*\.(png|jpg|jpeg|gif))', body, re.MULTILINE): body = body.replace(i.group(1), f'![]({i.group(1)})') + with CustomRenderer() as renderer: + body_md = renderer.render(mistletoe.Document(body)) + body_html = sanitize(body_md, linkgen=True) + + # Run safety filter + bans = filter_comment_html(body_html) + if bans: + ban = bans[0] + reason = f"Remove the {ban.domain} link from your post and try again." + if ban.reason: + reason += f" {ban.reason_text}" + + #auto ban for digitally malicious content + if any([x.reason==4 for x in bans]): + v.ban(days=30, reason="Digitally malicious content is not allowed.") + abort(403) + + return {"html": lambda: (render_template("submit.html", + v=v, + error=reason, + title=title, + url=url, + body=request.form.get( + "body", ""), + b=board + ), 403), + "api": lambda: ({"error": reason}, 403) + } + + # check spam + soup = BeautifulSoup(body_html, features="html.parser") + links = [x['href'] for x in soup.find_all('a') if x.get('href')] + + if url: + links = [url] + links + + for link in links: + parse_link = urlparse(link) + check_url = ParseResult(scheme="https", + netloc=parse_link.netloc, + path=parse_link.path, + params=parse_link.params, + query=parse_link.query, + fragment='') + check_url = urlunparse(check_url) + + badlink = g.db.query(BadLink).filter( + literal(check_url).contains( + BadLink.link)).first() + if badlink: + if badlink.autoban: + text = "Your Drama account has been suspended for 1 day for the following reason:\n\n> Too much spam!" + send_notification(1046, v, text) + v.ban(days=1, reason="spam") + + return redirect('/notifications') + else: + + return {"html": lambda: (render_template("submit.html", + v=v, + error=f"The link `{badlink.link}` is not allowed. Reason: {badlink.reason}.", + title=title, + url=url, + body=request.form.get( + "body", ""), + b=board + ), 400), + "api": lambda: ({"error": f"The link `{badlink.link}` is not allowed. Reason: {badlink.reason}"}, 400) + } + + # check for embeddable video + domain = parsed_url.netloc + + if request.files.get('file') and not v.can_submit_image: + abort(403) + + + new_post = Submission( + private=bool(request.form.get("private","")), + author_id=v.id, + domain_ref=domain_obj.id if domain_obj else None, + board_id=board.id, + original_board_id=board.id, + over_18=bool(request.form.get("over_18","")), + is_nsfl=bool(request.form.get("is_nsfl","")), + post_public=not board.is_private, + repost_id=repost.id if repost else None, + is_offensive=False, + app_id=v.client.application.id if v.client else None, + creation_region=request.headers.get("cf-ipcountry"), + is_bot = request.headers.get("X-User-Type","").lower()=="bot" + ) + + g.db.add(new_post) + g.db.flush() + + for rd in ["https://reddit.com/", "https://new.reddit.com/", "https://www.reddit.com/", "https://redd.it/"]: + url = url.replace(rd, "https://old.reddit.com/") + + url = url.replace("https://mobile.twitter.com", "https://twitter.com") + + if url.startswith("https://old.reddit.com/") and '/comments/' in url and '?sort=' not in url: url += "?sort=controversial" + + title_html = sanitize(title.replace('_','\_'), linkgen=True, flair=True) + + new_post_aux = SubmissionAux(id=new_post.id, + url=url, + body=body, + body_html=body_html, + embed_url=embed, + title=title, + title_html=title_html + ) + g.db.add(new_post_aux) + g.db.flush() + + vote = Vote(user_id=v.id, + vote_type=1, + submission_id=new_post.id + ) + g.db.add(vote) + g.db.flush() + + g.db.refresh(new_post) + + # check for uploaded image + if request.files.get('file'): + + #check file size + if request.content_length > 16 * 1024 * 1024: + g.db.rollback() + abort(413) + + file = request.files['file'] + if not file.content_type.startswith('image/'): + return {"html": lambda: (render_template("submit.html", + v=v, + error=f"Image files only.", + title=title, + body=request.form.get( + "body", ""), + b=board + ), 400), + "api": lambda: ({"error": f"Image files only"}, 400) + } + + name = f'post/{new_post.base36id}/{secrets.token_urlsafe(8)}' + new_post.url = upload_file(name, file) + new_post.domain_ref = 1 # id of i.ruqqus.ga domain + g.db.add(new_post) + g.db.add(new_post.submission_aux) + g.db.commit() + + # #csam detection + # def del_function(): + # db=db_session() + # delete_file(name) + # new_post.is_banned=True + # db.add(new_post) + # db.commit() + # ma=ModAction( + # kind="ban_post", + # user_id=2317, + # note="banned image", + # target_submission_id=new_post.id + # ) + # db.add(ma) + # db.commit() + # db.close() + + + # csam_thread=threading.Thread(target=check_csam_url, + # args=(new_post.url, + # v, + # del_function + # ) + # ) + # csam_thread.start() + + g.db.commit() + + # spin off thumbnail generation and csam detection as new threads + if (new_post.url or request.files.get('file')) and (v.is_activated or request.headers.get('cf-ipcountry')!="T1"): thumbs(new_post) + + # expire the relevant caches: front page new, board new + cache.delete_memoized(frontlist) + cache.delete_memoized(User.userpagelisting) + g.db.commit() + + notify_users = set() + + soup = BeautifulSoup(body_html, features="html.parser") + for mention in soup.find_all("a", href=re.compile("^/@(\w+)")): + username = mention["href"].split("@")[1] + user = g.db.query(User).filter_by(username=username).first() + if user and not v.any_block_exists(user) and user.id != v.id: notify_users.add(user) + + for x in notify_users: send_notification(1046, x, f"@{v.username} has mentioned you: https://rdrama.net{new_post.permalink}") + + if not new_post.private: + for follow in v.followers: + user = get_account(follow.user_id) + send_notification(2360, user, f"@{v.username} has made a new post: [{title}](https://rdrama.net{new_post.permalink})") + + new_post.upvotes = new_post.ups + new_post.downvotes = new_post.downs + g.db.add(new_post) + g.db.commit() + + c = Comment(author_id=261, + distinguish_level=6, + parent_submission=new_post.id, + parent_fullname=new_post.fullname, + level=1, + over_18=False, + is_nsfl=False, + is_offensive=False, + original_board_id=1, + is_bot=True, + app_id=None, + creation_region=request.headers.get("cf-ipcountry") + ) + + g.db.add(c) + g.db.flush() + + if v.id == 995: body = "fuck off carp" + else: body = random.choice(snappyquotes) + if new_post.url: + body += f"\n\n---\n\nSnapshots:\n\n* [reveddit.com](https://reveddit.com/{new_post.url})\n* [archive.org](https://web.archive.org/{new_post.url})\n* [archive.ph](https://archive.ph/?url={urllib.parse.quote(new_post.url)}&run=1) (click to archive)" + gevent.spawn(archiveorg, new_post.url) + with CustomRenderer(post_id=new_post.id) as renderer: body_md = renderer.render(mistletoe.Document(body)) + body_html = sanitize(body_md, linkgen=True) + c_aux = CommentAux( + id=c.id, + body_html=body_html, + body=body + ) + g.db.add(c_aux) + g.db.flush() + n = Notification(comment_id=c.id, user_id=v.id) + g.db.add(n) + g.db.commit() + send_message(f"https://rdrama.net{new_post.permalink}") + return {"html": lambda: redirect(new_post.permalink), + "api": lambda: jsonify(new_post.json) + } + +# @app.route("/api/nsfw//", methods=["POST"]) +# @auth_required +# @validate_formkey +# def api_nsfw_pid(pid, x, v): + +# try: +# x=bool(int(x)) +# except: +# abort(400) + +# post=get_post(pid) + +# if not v.admin_level >=3 and not post.author_id==v.id and not post.board.has_mod(v): +# abort(403) + +# post.over_18=x +# g.db.add(post) +# + +# return "", 204 + + +@app.route("/delete_post/", methods=["POST"]) +@app.route("/api/v1/delete_post/", methods=["POST"]) +@auth_required +@api("delete") +@validate_formkey +def delete_post_pid(pid, v): + + post = get_post(pid) + if not post.author_id == v.id: + abort(403) + + post.deleted_utc = int(time.time()) + post.is_pinned = False + post.stickied = False + + g.db.add(post) + + cache.delete_memoized(frontlist) + + # delete i.ruqqus.ga + if post.domain == "i.ruqqus.ga": + + segments = post.url.split("/") + pid = segments[4] + rand = segments[5] + if pid == post.base36id: + key = f"post/{pid}/{rand}" + delete_file(key) + #post.is_image = False + g.db.add(post) + + return "", 204 + +@app.route("/undelete_post/", methods=["POST"]) +@app.route("/api/v1/undelete_post/", methods=["POST"]) +@auth_required +@api("delete") +@validate_formkey +def undelete_post_pid(pid, v): + post = get_post(pid) + if not post.author_id == v.id: abort(403) + post.deleted_utc =0 + g.db.add(post) + cache.delete_memoized(frontlist) + return "", 204 + +@app.route("/embed/post/", methods=["GET"]) +def embed_post_pid(pid): + + post = get_post(pid) + + if post.is_banned or post.board.is_banned: + abort(410) + + return render_template("embeds/submission.html", p=post) + +@app.route("/api/toggle_comment_nsfw/", methods=["POST"]) +@app.route("/api/v1/toggle_comment_nsfw/", methods=["POST"]) +@is_not_banned +@api("update") +@validate_formkey +def toggle_comment_nsfw(cid, v): + + comment = g.db.query(Comment).filter_by(id=base36decode(cid)).first() + if not comment.author_id == v.id and not v.admin_level >= 3: abort(403) + comment.over_18 = not comment.over_18 + g.db.add(comment) + return "", 204 + +@app.route("/api/toggle_post_nsfw/", methods=["POST"]) +@app.route("/api/v1/toggle_post_nsfw/", methods=["POST"]) +@is_not_banned +@api("update") +@validate_formkey +def toggle_post_nsfw(pid, v): + + post = get_post(pid) + + mod=post.board.has_mod(v) + + if not post.author_id == v.id and not v.admin_level >= 3 and not mod: + abort(403) + + if post.board.over_18 and post.over_18: + abort(403) + + post.over_18 = not post.over_18 + g.db.add(post) + + if post.author_id!=v.id: + ma=ModAction( + kind="set_nsfw" if post.over_18 else "unset_nsfw", + user_id=v.id, + target_submission_id=post.id, + board_id=post.board.id, + ) + g.db.add(ma) + + return "", 204 + +@app.route("/save_post/", methods=["POST"]) +@auth_required +@validate_formkey +def save_post(pid, v): + + post=get_post(pid) + + new_save=SaveRelationship(user_id=v.id, submission_id=post.id, type=1) + + g.db.add(new_save) + + try: g.db.flush() + except: abort(422) + + return "", 204 + +@app.route("/unsave_post/", methods=["POST"]) +@auth_required +@validate_formkey +def unsave_post(pid, v): + + post=get_post(pid) + + save=g.db.query(SaveRelationship).filter_by(user_id=v.id, submission_id=post.id, type=1).first() + + g.db.delete(save) + + return "", 204 \ No newline at end of file diff --git a/ruqqus/routes/search.py b/ruqqus/routes/search.py new file mode 100644 index 000000000..dd8696fc7 --- /dev/null +++ b/ruqqus/routes/search.py @@ -0,0 +1,324 @@ +from ruqqus.helpers.wrappers import * +import re +from sqlalchemy import * +from flask import * +from ruqqus.classes.domains import reasons as REASONS +from ruqqus.__main__ import app, cache +import random + +query_regex=re.compile("(\w+):(\S+)") +valid_params=[ + 'author', + 'domain', + 'over18' +] + +def searchparse(text): + + #takes test in filter:term format and returns data + + criteria = {x[0]:x[1] for x in query_regex.findall(text)} + + for x in criteria: + if x in valid_params: + text = text.replace(f"{x}:{criteria[x]}", "") + + text=text.strip() + + if text: + criteria['q']=text + + return criteria + + +@cache.memoize(300) +def searchlisting(criteria, v=None, page=1, t="None", sort="top", b=None): + + posts = g.db.query(Submission).options( + lazyload('*') + ).join( + Submission.submission_aux, + ).join( + Submission.author + ) + + if 'q' in criteria: + words=criteria['q'].split() + words=[SubmissionAux.title.ilike('%'+x+'%') for x in words] + words=tuple(words) + posts=posts.filter(*words) + + if 'over18' in criteria: + posts = posts.filter(Submission.over_18==True) + + if 'author' in criteria: + posts=posts.filter( + Submission.author_id==get_user(criteria['author']).id, + User.is_private==False, + User.is_deleted==False + ) + + if 'domain' in criteria: + domain=criteria['domain'] + posts=posts.filter( + or_( + SubmissionAux.url.ilike("https://"+domain+'/%'), + SubmissionAux.url.ilike("https://"+domain+'/%'), + SubmissionAux.url.ilike("https://"+domain), + SubmissionAux.url.ilike("https://"+domain), + SubmissionAux.url.ilike("https://www."+domain+'/%'), + SubmissionAux.url.ilike("https://www."+domain+'/%'), + SubmissionAux.url.ilike("https://www."+domain), + SubmissionAux.url.ilike("https://www."+domain), + SubmissionAux.url.ilike("https://old." + domain + '/%'), + SubmissionAux.url.ilike("https://old." + domain + '/%'), + SubmissionAux.url.ilike("https://old." + domain), + SubmissionAux.url.ilike("https://old." + domain) + ) + ) + + if not(v and v.admin_level >= 3): + posts = posts.filter( + Submission.deleted_utc == 0, + Submission.is_banned == False, + ) + + if v and v.admin_level >= 4: + pass + elif v: + blocking = g.db.query( + UserBlock.target_id).filter_by( + user_id=v.id).subquery() + blocked = g.db.query( + UserBlock.user_id).filter_by( + target_id=v.id).subquery() + + posts = posts.filter( + Submission.author_id.notin_(blocking), + Submission.author_id.notin_(blocked), + ) + + if t: + now = int(time.time()) + if t == 'hour': + cutoff = now - 3600 + elif t == 'day': + cutoff = now - 86400 + elif t == 'week': + cutoff = now - 604800 + elif t == 'month': + cutoff = now - 2592000 + elif t == 'year': + cutoff = now - 31536000 + else: + cutoff = 0 + posts = posts.filter(Submission.created_utc >= cutoff) + + posts=posts.options( + contains_eager(Submission.submission_aux), + contains_eager(Submission.author), + ) + + if sort == "new": + posts = posts.order_by(Submission.created_utc.desc()).all() + elif sort == "old": + posts = posts.order_by(Submission.created_utc.asc()).all() + elif sort == "controversial": + posts = sorted(posts.all(), key=lambda x: x.score_disputed, reverse=True) + elif sort == "top": + posts = posts.order_by(Submission.score.desc()).all() + elif sort == "bottom": + posts = posts.order_by(Submission.score.asc()).all() + elif sort == "comments": + posts = posts.order_by(Submission.comment_count.desc()).all() + elif sort == "random": + posts = posts.all() + posts = random.sample(posts, k=len(posts)) + else: + abort(400) + + total = len(posts) + + firstrange = 25 * (page - 1) + secondrange = firstrange+26 + posts = posts[firstrange:secondrange] + + return total, [x.id for x in posts] + + +@cache.memoize(300) +def searchcommentlisting(criteria, v=None, page=1, t="None", sort="top"): + + comments = g.db.query(Comment).options(lazyload('*')).filter(Comment.parent_submission != None).join(Comment.comment_aux) + + if 'q' in criteria: + words=criteria['q'].split() + words=[CommentAux.body.ilike('%'+x+'%') for x in words] + words=tuple(words) + comments=comments.filter(*words) + + if not(v and v.admin_level >= 3): + comments = comments.filter( + Comment.deleted_utc == 0, + Comment.is_banned == False) + + if t: + now = int(time.time()) + if t == 'hour': + cutoff = now - 3600 + elif t == 'day': + cutoff = now - 86400 + elif t == 'week': + cutoff = now - 604800 + elif t == 'month': + cutoff = now - 2592000 + elif t == 'year': + cutoff = now - 31536000 + else: + cutoff = 0 + comments = comments.filter(Comment.created_utc >= cutoff) + + comments=comments.options(contains_eager(Comment.comment_aux)) + + if sort == "new": + comments = comments.order_by(Comment.created_utc.desc()).all() + elif sort == "old": + comments = comments.order_by(Comment.created_utc.asc()).all() + elif sort == "controversial": + comments = sorted(comments.all(), key=lambda x: x.score_disputed, reverse=True) + elif sort == "top": + comments = comments.order_by(Comment.score.desc()).all() + elif sort == "bottom": + comments = comments.order_by(Comment.score.asc()).all() + + total = len(list(comments)) + firstrange = 25 * (page - 1) + secondrange = firstrange+26 + comments = comments[firstrange:secondrange] + return total, [x.id for x in comments] + +@app.route("/search/posts", methods=["GET"]) +@app.route("/api/v1/search", methods=["GET"]) +@app.route("/api/vue/search") +@auth_desired +@api("read") +def searchposts(v, search_type="posts"): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + query = request.args.get("q", '').strip() + + page = max(1, int(request.args.get("page", 1))) + + sort = request.args.get("sort", "top").lower() + t = request.args.get('t', 'all').lower() + + criteria=searchparse(query) + total, ids = searchlisting(criteria, v=v, page=page, t=t, sort=sort) + + next_exists = (len(ids) == 26) + ids = ids[0:25] + + posts = get_posts(ids, v=v) + + if v and v.admin_level>3 and "domain" in criteria: + domain=criteria['domain'] + domain_obj=get_domain(domain) + else: + domain=None + domain_obj=None + + return {"html":lambda:render_template("search.html", + v=v, + query=query, + total=total, + page=page, + listing=posts, + sort=sort, + t=t, + next_exists=next_exists, + domain=domain, + domain_obj=domain_obj, + reasons=REASONS + ), + "api":lambda:jsonify({"data":[x.json for x in posts]}) + } + +@app.route("/search/comments", methods=["GET"]) +@app.route("/api/v1/search/comments", methods=["GET"]) +@app.route("/api/vue/search/comments") +@auth_desired +@api("read") +def searchcomments(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + query = request.args.get("q", '').strip() + + try: page = max(1, int(request.args.get("page", 1))) + except: page = 1 + + sort = request.args.get("sort", "top").lower() + t = request.args.get('t', 'all').lower() + + criteria=searchparse(query) + total, ids = searchcommentlisting(criteria, v=v, page=page, t=t, sort=sort) + + next_exists = (len(ids) == 26) + ids = ids[0:25] + + comments = get_comments(ids, v=v) + + return {"html":lambda:render_template("search_comments.html", + v=v, + query=query, + total=total, + page=page, + comments=comments, + sort=sort, + t=t, + next_exists=next_exists, + ), + "api":lambda:jsonify({"data":[x.json for x in comments]}) + } + +@app.route("/search/users", methods=["GET"]) +@app.route("/api/v1/search/users", methods=["GET"]) +@app.route("/api/vue/search/users") +@auth_desired +@api("read") +def searchusers(v, search_type="posts"): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + query = request.args.get("q", '').strip() + + page = max(1, int(request.args.get("page", 1))) + sort = request.args.get("sort", "top").lower() + t = request.args.get('t', 'all').lower() + term=query.lstrip('@') + term=term.replace('\\','') + term=term.replace('_','\_') + + now=int(time.time()) + users=g.db.query(User).filter(User.username.ilike(f'%{term}%')) + + users=users.order_by(User.username.ilike(term).desc(), User.stored_subscriber_count.desc()) + + total=users.count() + + users=[x for x in users.offset(25 * (page-1)).limit(26)] + next_exists=(len(users)==26) + users=users[0:25] + + + + return {"html":lambda:render_template("search_users.html", + v=v, + query=query, + total=total, + page=page, + users=users, + sort=sort, + t=t, + next_exists=next_exists + ), + "api":lambda:jsonify({"data":[x.json for x in users]}) + } \ No newline at end of file diff --git a/ruqqus/routes/settings.py b/ruqqus/routes/settings.py new file mode 100644 index 000000000..5c52cba53 --- /dev/null +++ b/ruqqus/routes/settings.py @@ -0,0 +1,723 @@ +from __future__ import unicode_literals +from ruqqus.helpers.alerts import * +from ruqqus.helpers.sanitize import * +from ruqqus.helpers.filters import filter_comment_html +from ruqqus.helpers.markdown import * +from ruqqus.helpers.discord import remove_user, set_nick +from ruqqus.mail import * +from .front import frontlist +from ruqqus.__main__ import app, cache +import youtube_dl + +valid_username_regex = re.compile("^[a-zA-Z0-9_\-]{3,25}$") +valid_title_regex = re.compile("^((?!<).){3,100}$") +valid_password_regex = re.compile("^.{8,100}$") + +youtubekey = environ.get("youtubekey").strip() + +@app.route("/settings/profile", methods=["POST"]) +@auth_required +@validate_formkey +def settings_profile_post(v): + updated = False + + if request.values.get("slurreplacer", v.slurreplacer) != v.slurreplacer: + updated = True + v.slurreplacer = request.values.get("slurreplacer", None) == 'true' + + if request.values.get("hidevotedon", v.hidevotedon) != v.hidevotedon: + updated = True + v.hidevotedon = request.values.get("hidevotedon", None) == 'true' + + if request.values.get("newtab", v.newtab) != v.newtab: + updated = True + v.newtab = request.values.get("newtab", None) == 'true' + + if request.values.get("newtabexternal", v.newtabexternal) != v.newtabexternal: + updated = True + v.newtabexternal = request.values.get("newtabexternal", None) == 'true' + + if request.values.get("oldreddit", v.oldreddit) != v.oldreddit: + updated = True + v.oldreddit = request.values.get("oldreddit", None) == 'true' + + if request.values.get("over18", v.over_18) != v.over_18: + updated = True + v.over_18 = request.values.get("over18", None) == 'true' + + if request.values.get("hide_offensive", + v.hide_offensive) != v.hide_offensive: + updated = True + v.hide_offensive = request.values.get("hide_offensive", None) == 'true' + + if request.values.get("hide_bot", + v.hide_bot) != v.hide_bot: + updated = True + v.hide_bot = request.values.get("hide_bot", None) == 'true' + + if request.values.get("filter_nsfw", v.filter_nsfw) != v.filter_nsfw: + updated = True + v.filter_nsfw = not request.values.get("filter_nsfw", None) == 'true' + + if request.values.get("private", v.is_private) != v.is_private: + updated = True + v.is_private = request.values.get("private", None) == 'true' + + if request.values.get("nofollow", v.is_nofollow) != v.is_nofollow: + updated = True + v.is_nofollow = request.values.get("nofollow", None) == 'true' + + if request.values.get("bio") is not None: + bio = request.values.get("bio")[0:1500] + + if bio == v.bio: + return render_template("settings_profile.html", + v=v, + error="You didn't change anything") + + for i in re.finditer('^(https:\/\/.*\.(png|jpg|jpeg|gif))', bio, re.MULTILINE): bio = bio.replace(i.group(1), f'![]({i.group(1)})') + with CustomRenderer() as renderer: + bio_html = renderer.render(mistletoe.Document(bio)) + bio_html = sanitize(bio_html, linkgen=True) + + # Run safety filter + bans = filter_comment_html(bio_html) + + if bans: + ban = bans[0] + reason = f"Remove the {ban.domain} link from your bio and try again." + if ban.reason: + reason += f" {ban.reason_text}" + + #auto ban for digitally malicious content + if any([x.reason==4 for x in bans]): + v.ban(days=30, reason="Digitally malicious content is not allowed.") + return jsonify({"error": reason}), 401 + + v.bio = bio + v.bio_html=bio_html + g.db.add(v) + return render_template("settings_profile.html", + v=v, + msg="Your bio has been updated.") + + if request.values.get("filters") is not None: + + filters=request.values.get("filters")[0:1000].strip() + + if filters==v.custom_filter_list: + return render_template("settings_profile.html", + v=v, + error="You didn't change anything") + + v.custom_filter_list=filters + g.db.add(v) + return render_template("settings_profile.html", + v=v, + msg="Your custom filters have been updated.") + + + + x = request.values.get("title_id", None) + if x: + x = int(x) + if x == 0: + v.title_id = None + updated = True + elif x > 0: + title = get_title(x) + if bool(eval(title.qualification_expr)): + v.title_id = title.id + updated = True + else: + return jsonify({"error": f"You don't meet the requirements for title `{title.text}`."}), 403 + else: + abort(400) + + defaultsortingcomments = request.values.get("defaultsortingcomments") + if defaultsortingcomments: + if defaultsortingcomments in ["new", "old", "controversial", "top", "bottom", "random"]: + v.defaultsortingcomments = defaultsortingcomments + updated = True + else: + abort(400) + + defaultsorting = request.values.get("defaultsorting") + if defaultsorting: + if defaultsorting in ["hot", "new", "old", "comments", "controversial", "top", "bottom", "random"]: + v.defaultsorting = defaultsorting + updated = True + else: + abort(400) + + defaulttime = request.values.get("defaulttime") + if defaulttime: + if defaulttime in ["hour", "day", "week", "month", "year", "all"]: + v.defaulttime = defaulttime + updated = True + else: + abort(400) + + theme = request.values.get("theme") + if theme: + v.theme = theme + if theme == "coffee": v.themecolor = "38a169" + elif theme == "tron": v.themecolor = "80ffff" + g.db.add(v) + return "", 204 + + if updated: + g.db.add(v) + + return jsonify({"message": "Your settings have been updated."}) + + else: + return jsonify({"error": "You didn't change anything."}), 400 + +@app.route("/changelogsub", methods=["POST"]) +@auth_required +@validate_formkey +def changelogsub(v): + v.changelogsub = not v.changelogsub + g.db.add(v) + cache.delete_memoized(frontlist) + return "", 204 + +@app.route("/settings/namecolor", methods=["POST"]) +@auth_required +@validate_formkey +def namecolor(v): + color = str(request.form.get("color", "")).strip() + if color not in ['ff66ac','805ad5','62ca56','38a169','80ffff','2a96f3','eb4963','ff0000','f39731','30409f','3e98a7','e4432d','7b9ae4','ec72de','7f8fa6', 'f8db58']: abort(400) + v.namecolor = color + g.db.add(v) + return redirect("/settings/profile") + +@app.route("/settings/themecolor", methods=["POST"]) +@auth_required +@validate_formkey +def themecolor(v): + themecolor = str(request.form.get("themecolor", "")).strip() + if themecolor not in ['ff66ac','805ad5','62ca56','38a169','80ffff','2a96f3','eb4963','ff0000','f39731','30409f','3e98a7','e4432d','7b9ae4','ec72de','7f8fa6', 'f8db58']: abort(400) + v.themecolor = themecolor + g.db.add(v) + return redirect("/settings/profile") + +@app.route("/settings/titlecolor", methods=["POST"]) +@auth_required +@validate_formkey +def titlecolor(v): + titlecolor = str(request.form.get("titlecolor", "")).strip() + if titlecolor not in ['ff66ac','805ad5','62ca56','38a169','80ffff','2a96f3','eb4963','ff0000','f39731','30409f','3e98a7','e4432d','7b9ae4','ec72de','7f8fa6', 'f8db58']: abort(400) + v.titlecolor = titlecolor + g.db.add(v) + return redirect("/settings/profile") + +@app.route("/settings/security", methods=["POST"]) +@auth_required +@validate_formkey +def settings_security_post(v): + if request.form.get("new_password"): + if request.form.get( + "new_password") != request.form.get("cnf_password"): + return redirect("/settings/security?error=" + + escape("Passwords do not match.")) + + if not re.match(valid_password_regex, request.form.get("new_password")): + #print(f"signup fail - {username } - invalid password") + return redirect("/settings/security?error=" + + escape("Password must be between 8 and 100 characters.")) + + if not v.verifyPass(request.form.get("old_password")): + return render_template( + "settings_security.html", v=v, error="Incorrect password") + + v.passhash = v.hash_password(request.form.get("new_password")) + + g.db.add(v) + + return redirect("/settings/security?msg=" + + escape("Your password has been changed.")) + + if request.form.get("new_email"): + + if not v.verifyPass(request.form.get('password')): + return redirect("/settings/security?error=" + + escape("Invalid password.")) + + new_email = request.form.get("new_email","").strip() + #counteract gmail username+2 and extra period tricks - convert submitted email to actual inbox + if new_email.endswith("@gmail.com"): + gmail_username=new_email.split('@')[0] + gmail_username=gmail_username.split("+")[0] + gmail_username=gmail_username.replace('.','') + new_email=f"{gmail_username}@gmail.com" + if new_email == v.email: + return redirect("/settings/security?error=" + + escape("That email is already yours!")) + + # check to see if email is in use + existing = g.db.query(User).filter(User.id != v.id, + func.lower(User.email) == new_email.lower()).first() + if existing: + return redirect("/settings/security?error=" + + escape("That email address is already in use.")) + + url = f"https://{app.config['SERVER_NAME']}/activate" + + now = int(time.time()) + + token = generate_hash(f"{new_email}+{v.id}+{now}") + params = f"?email={quote(new_email)}&id={v.id}&time={now}&token={token}" + + link = url + params + + send_mail(to_address=new_email, + subject="Verify your email address.", + html=render_template("email/email_change.html", + action_url=link, + v=v) + ) + + return redirect("/settings/security?msg=" + escape( + "Check your email and click the verification link to complete the email change.")) + + if request.form.get("2fa_token", ""): + + if not v.verifyPass(request.form.get('password')): + return redirect("/settings/security?error=" + + escape("Invalid password or token.")) + + secret = request.form.get("2fa_secret") + x = pyotp.TOTP(secret) + if not x.verify(request.form.get("2fa_token"), valid_window=1): + return redirect("/settings/security?error=" + + escape("Invalid password or token.")) + + v.mfa_secret = secret + g.db.add(v) + + return redirect("/settings/security?msg=" + + escape("Two-factor authentication enabled.")) + + if request.form.get("2fa_remove", ""): + + if not v.verifyPass(request.form.get('password')): + return redirect("/settings/security?error=" + + escape("Invalid password or token.")) + + token = request.form.get("2fa_remove") + + if not v.validate_2fa(token): + return redirect("/settings/security?error=" + + escape("Invalid password or token.")) + + v.mfa_secret = None + g.db.add(v) + + return redirect("/settings/security?msg=" + + escape("Two-factor authentication disabled.")) + +@app.route("/settings/log_out_all_others", methods=["POST"]) +@auth_required +@validate_formkey +def settings_log_out_others(v): + + submitted_password = request.form.get("password", "") + + if not v.verifyPass(submitted_password): + return render_template("settings_security.html", + v=v, error="Incorrect Password"), 401 + + # increment account's nonce + v.login_nonce += 1 + + # update cookie accordingly + session["login_nonce"] = v.login_nonce + + g.db.add(v) + + return render_template("settings_security.html", v=v, + msg="All other devices have been logged out") + + +@app.route("/settings/images/profile", methods=["POST"]) +@auth_required +@validate_formkey +def settings_images_profile(v): + if v.can_upload_avatar: + + if request.content_length > 16 * 1024 * 1024: + g.db.rollback() + abort(413) + + v.set_profile(request.files["profile"]) + return render_template("settings_profile.html", + v=v, msg="Profile picture successfully updated.") + + return render_template("settings_profile.html", v=v, + msg="Avatars require 300 reputation.") + + +@app.route("/settings/images/banner", methods=["POST"]) +@auth_required +@validate_formkey +def settings_images_banner(v): + if v.can_upload_banner: + if request.content_length > 16 * 1024 * 1024: + g.db.rollback() + abort(413) + + v.set_banner(request.files["banner"]) + + return render_template("settings_profile.html", + v=v, msg="Banner successfully updated.") + + return render_template("settings_profile.html", v=v, + msg="Banners require 500 reputation.") + + +@app.route("/settings/delete/profile", methods=["POST"]) +@auth_required +@validate_formkey +def settings_delete_profile(v): + + v.del_profile() + + return render_template("settings_profile.html", v=v, + msg="Profile picture successfully removed.") + +@app.route("/settings/delete/banner", methods=["POST"]) +@auth_required +@validate_formkey +def settings_delete_banner(v): + + v.del_banner() + + return render_template("settings_profile.html", v=v, + msg="Banner successfully removed.") + + +@app.route("/settings/toggle_collapse", methods=["POST"]) +@auth_required +@validate_formkey +def settings_toggle_collapse(v): + + session["sidebar_collapsed"] = not session.get("sidebar_collapsed", False) + + return "", 204 + + +@app.route("/settings/read_announcement", methods=["POST"]) +@auth_required +@validate_formkey +def update_announcement(v): + + v.read_announcement_utc = int(time.time()) + g.db.add(v) + + return "", 204 + + +@app.route("/settings/blocks", methods=["GET"]) +@auth_required +def settings_blockedpage(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + #users=[x.target for x in v.blocked] + + return render_template("settings_blocks.html", + v=v) + +@app.route("/settings/css", methods=["GET"]) +@auth_required +def settings_css_get(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + return render_template("settings_css.html", v=v) + +@app.route("/settings/css", methods=["POST"]) +@auth_required +def settings_css(v): + css = request.form.get("css").replace('\\', '')[0:50000] + + if not v.agendaposter: + v.css = css + else: + v.css = 'body *::before, body *::after { content: "Trans rights are human rights!"; }' + g.db.add(v) + return render_template("settings_css.html", v=v) + +@app.route("/settings/profilecss", methods=["GET"]) +@auth_required +def settings_profilecss_get(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + if v.dramacoins < 1000: return "You must have +1000 dramacoins to set profile css." + return render_template("settings_profilecss.html", v=v) + +@app.route("/settings/profilecss", methods=["POST"]) +@auth_required +def settings_profilecss(v): + if v.dramacoins < 1000: return "You must have +1000 dramacoins to set profile css." + profilecss = request.form.get("profilecss").replace('\\', '')[0:50000] + v.profilecss = profilecss + g.db.add(v) + return render_template("settings_profilecss.html", v=v) + +@app.route("/settings/block", methods=["POST"]) +@auth_required +@validate_formkey +def settings_block_user(v): + + user = get_user(request.values.get("username"), graceful=True) + + if not user: + return jsonify({"error": "That user doesn't exist."}), 404 + + if user.id == v.id: + return jsonify({"error": "You can't block yourself."}), 409 + + if v.has_block(user): + return jsonify({"error": f"You have already blocked @{user.username}."}), 409 + + if user.id == 1046: + return jsonify({"error": "You can't block @Drama."}), 409 + + new_block = UserBlock(user_id=v.id, + target_id=user.id, + created_utc=int(time.time()) + ) + g.db.add(new_block) + + cache.delete_memoized(frontlist) + + existing = g.db.query(Notification).filter_by(blocksender=v.id, user_id=user.id).first() + if not existing: send_block_notif(v.id, user.id, f"@{v.username} has blocked you!") + + if request.args.get("notoast"): return "", 204 + return jsonify({"message": f"@{user.username} blocked."}) + + +@app.route("/settings/unblock", methods=["POST"]) +@auth_required +@validate_formkey +def settings_unblock_user(v): + + user = get_user(request.values.get("username")) + + x = v.has_block(user) + + if not x: abort(409) + + g.db.delete(x) + + cache.delete_memoized(frontlist) + + existing = g.db.query(Notification).filter_by(unblocksender=v.id, user_id=user.id).first() + if not existing: send_unblock_notif(v.id, user.id, f"@{v.username} has unblocked you!") + + if request.args.get("notoast"): return "", 204 + return jsonify({"message": f"@{user.username} unblocked."}) + + +@app.route("/settings/apps", methods=["GET"]) +@auth_required +def settings_apps(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + return render_template("settings_apps.html", v=v) + + +@app.route("/settings/remove_discord", methods=["POST"]) +@auth_required +@validate_formkey +def settings_remove_discord(v): + + if v.admin_level>1: + return render_template("settings_filters.html", v=v, error="Admins can't disconnect Discord.") + + remove_user(v) + + v.discord_id=None + g.db.add(v) + g.db.commit() + + return redirect("/settings/profile") + +@app.route("/settings/content", methods=["GET"]) +@auth_required +def settings_content_get(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + return render_template("settings_filters.html", v=v) + +@app.route("/settings/name_change", methods=["POST"]) +@auth_required +@validate_formkey +def settings_name_change(v): + + new_name=request.form.get("name").strip() + + #make sure name is different + if new_name==v.username: + return render_template("settings_profile.html", + v=v, + error="You didn't change anything") + + #verify acceptability + if not re.match(valid_username_regex, new_name): + return render_template("settings_profile.html", + v=v, + error=f"This isn't a valid username.") + + #verify availability + name=new_name.replace('_','\_') + + x= g.db.query(User).options( + lazyload('*') + ).filter( + or_( + User.username.ilike(name), + User.original_username.ilike(name) + ) + ).first() + + if x and x.id != v.id: + return render_template("settings_profile.html", + v=v, + error=f"Username `{new_name}` is already in use.") + + v=g.db.query(User).with_for_update().options(lazyload('*')).filter_by(id=v.id).first() + + v.username=new_name + v.name_changed_utc=int(time.time()) + + set_nick(v, new_name) + + g.db.add(v) + g.db.commit() + + return redirect("/settings/profile") + +@app.route("/settings/song_change", methods=["POST"]) +@auth_required +@validate_formkey +def settings_song_change(v): + song=request.form.get("song").strip() + + if song == "" and v.song and os.path.isfile(f"/songs/{v.song}.mp3") and g.db.query(User).filter_by(song=v.song).count() == 1: + os.remove(f"/songs/{v.song}.mp3") + v.song=None + g.db.add(v) + return redirect("/settings/profile") + + song = song.replace("https://music.youtube.com", "https://youtube.com") + if song.startswith(("https://www.youtube.com/watch?v=", "https://youtube.com/watch?v=", "https://m.youtube.com/watch?v=")): + id = song.split("v=")[1] + elif song.startswith("https://youtu.be/"): + id = song.split("https://youtu.be/")[1] + else: + return render_template("settings_profile.html", + v=v, + error=f"Not a youtube link.") + + if "?" in id: id = id.split("?")[0] + if "&" in id: id = id.split("&")[0] + + if os.path.isfile(f'/songs/{id}.mp3'): + v.song=id + g.db.add(v) + g.db.commit() + return redirect("/settings/profile") + + + req = requests.get(f"https://www.googleapis.com/youtube/v3/videos?id={id}&key={youtubekey}&part=contentDetails").json() + try: duration = req['items'][0]['contentDetails']['duration'] + except: + print(req) + abort(400) + if "H" in duration: + return render_template("settings_profile.html", + v=v, + error=f"Duration of the video must not exceed 10 minutes.") + + if "M" in duration: + duration = int(duration.split("PT")[1].split("M")[0]) + if duration > 10: + return render_template("settings_profile.html", + v=v, + error=f"Duration of the video must not exceed 10 minutes.") + + + if v.song and os.path.isfile(f"/songs/{v.song}.mp3") and g.db.query(User).filter_by(song=v.song).count() == 1: + os.remove(f"/songs/{v.song}.mp3") + + ydl_opts = { + 'outtmpl': '/songs/%(title)s.%(ext)s', + 'format': 'bestaudio/best', + 'postprocessors': [{ + 'key': 'FFmpegExtractAudio', + 'preferredcodec': 'mp3', + 'preferredquality': '192', + }], + } + + with youtube_dl.YoutubeDL(ydl_opts) as ydl: + try: ydl.download([f"https://youtube.com/watch?v={id}"]) + except Exception as e: + print(e) + return render_template("settings_profile.html", + v=v, + error=f"Age-restricted videos aren't allowed.") + + files = os.listdir("/songs/") + paths = [os.path.join("/songs/", basename) for basename in files] + songfile = max(paths, key=os.path.getctime) + os.rename(songfile, f"/songs/{id}.mp3") + + v.song=id + g.db.add(v) + + return redirect("/settings/profile") + +@app.route("/settings/title_change", methods=["POST"]) +@auth_required +@validate_formkey +def settings_title_change(v): + + if v.flairchanged: abort(403) + + new_name=request.form.get("title").strip() + + #verify acceptability + if not re.match(valid_title_regex, new_name): + return render_template("settings_profile.html", + v=v, + error=f"This isn't a valid flair.") + + #make sure name is different + if new_name==v.customtitle: + return render_template("settings_profile.html", + v=v, + error="You didn't change anything") + + v.customtitleplain = new_name + new_name = new_name.replace('_','\_') + new_name = sanitize(new_name, linkgen=True, flair=True) + + v = g.db.query(User).with_for_update().options(lazyload('*')).filter_by(id=v.id).first() + v.customtitle = new_name + + g.db.add(v) + g.db.commit() + + return redirect("/settings/profile") + +@app.route("/settings/badges", methods=["POST"]) +@auth_required +@validate_formkey +def settings_badge_recheck(v): + + v.refresh_selfset_badges() + + return jsonify({"message":"Badges Refreshed"}) \ No newline at end of file diff --git a/ruqqus/routes/static.py b/ruqqus/routes/static.py new file mode 100644 index 000000000..ddf7d639b --- /dev/null +++ b/ruqqus/routes/static.py @@ -0,0 +1,194 @@ +from ruqqus.mail import * +from ruqqus.__main__ import app, limiter +from ruqqus.helpers.alerts import * + +@app.route("/sex") +def index(): + return render_template("index.html", **{"greeting": "Hello from Flask!"}) + +@app.route("/assets/favicon.ico", methods=["GET"]) +def favicon(): + return send_file("./assets/images/favicon.png") + +@app.route("/oauthhelp", methods=["GET"]) +@auth_desired +def oauthhelp(v): + return render_template("oauthhelp.html", v=v) + +@app.route("/contact", methods=["GET"]) +@auth_desired +def contact(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + return render_template("contact.html", v=v) + +@app.route("/contact", methods=["POST"]) +@auth_desired +def submit_contact(v): + message = f'This message has been sent automatically to all admins via https://rdrama.net/contact, user email is "{v.email}"\n\nMessage:\n\n' + request.form.get("message", "") + send_admin(v.id, message) + return render_template("contact.html", v=v, msg="Your message has been sent.") + +@app.route('/archives') +@limiter.exempt +def archivesindex(): + return redirect("/archives/index.html") + +@app.route('/archives/') +@limiter.exempt +def archives(path): + resp = make_response(send_from_directory('/archives', path)) + resp.headers.add("Cache-Control", "public") + if request.path.endswith('.css'): resp.headers.add("Content-Type", "text/css") + return resp + +@app.route('/assets/') +@limiter.exempt +def static_service(path): + resp = make_response(send_from_directory('./assets', path)) + resp.headers.add("Cache-Control", "public") + + if request.path.endswith('.css'): + resp.headers.add("Content-Type", "text/css") + return resp + +@app.route("/robots.txt", methods=["GET"]) +def robots_txt(): + return send_file("./assets/robots.txt") + +@app.route("/settings", methods=["GET"]) +@auth_required +def settings(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + return redirect("/settings/profile") + + +@app.route("/settings/profile", methods=["GET"]) +@auth_required +def settings_profile(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + return render_template("settings_profile.html", + v=v) + + +@app.route("/titles", methods=["GET"]) +@auth_desired +def titles(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + titles = [x for x in g.db.query(Title).order_by(text("id asc")).all()] + return render_template("/titles.html", + v=v, + titles=titles) + +@app.route("/badges", methods=["GET"]) +@auth_desired +def badges(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + badges = [ + x for x in g.db.query(BadgeDef).order_by( + text("rank asc, id asc")).all()] + return render_template("badges.html", + v=v, + badges=badges) + +@app.route("/blocks", methods=["GET"]) +@auth_desired +def blocks(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + blocks=g.db.query(UserBlock).all() + users = [] + targets = [] + for x in blocks: + users.append(get_account(x.user_id)) + targets.append(get_account(x.target_id)) + + return render_template("blocks.html", v=v, users=users, targets=targets) + +@app.route("/banned", methods=["GET"]) +@auth_desired +def banned(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + 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.route("/formatting", methods=["GET"]) +@auth_desired +def formatting(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + return render_template("formatting.html", v=v) + +@app.route("/.well-known/brave-rewards-verification.txt", methods=["GET"]) +def brave(): + with open(".well-known/brave-rewards-verification.txt", "r") as f: return Response(f.read(), mimetype='text/plain') + +@app.route("/.well-known/assetlinks.json", methods=["GET"]) +def googleplayapp(): + with open(".well-known/assetlinks.json", "r") as f: return Response(f.read(), mimetype='application/json') + +@app.route("/service-worker.js") +def serviceworker(): + with open(".well-known/service-worker.js", "r") as f: return Response(f.read(), mimetype='application/javascript') + +@app.route("/badmins", methods=["GET"]) +@auth_desired +def help_admins(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + admins = g.db.query(User).filter( + User.admin_level > 1, + User.id > 1).order_by( + User.id.asc()).all() + admins = [x for x in admins] + + exadmins = g.db.query(User).filter_by( + admin_level=1).order_by( + User.id.asc()).all() + exadmins = [x for x in exadmins] + + return render_template("admins.html", + v=v, + admins=admins, + exadmins=exadmins + ) + + +@app.route("/settings/security", methods=["GET"]) +@auth_required +def settings_security(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + return render_template("settings_security.html", + v=v, + mfa_secret=pyotp.random_base32() if not v.mfa_secret else None, + error=request.args.get("error") or None, + msg=request.args.get("msg") or None + ) + +@app.route("/imagehosts", methods=["GET"]) +def info_image_hosts(): + + sites = g.db.query(Domain).filter_by( + show_thumbnail=True).order_by( + Domain.domain.asc()).all() + + sites = [x.domain for x in sites] + + text = "\n".join(sites) + + resp = make_response(text) + resp.mimetype = "text/plain" + return resp + +@app.route("/dismiss_mobile_tip", methods=["POST"]) +def dismiss_mobile_tip(): + + session["tooltip_last_dismissed"]=int(time.time()) + session.modified=True + + return "", 204 diff --git a/ruqqus/routes/users.py b/ruqqus/routes/users.py new file mode 100644 index 000000000..0484a9826 --- /dev/null +++ b/ruqqus/routes/users.py @@ -0,0 +1,522 @@ +import qrcode +import io +from datetime import datetime + +from ruqqus.helpers.alerts import * +from ruqqus.helpers.sanitize import * +from ruqqus.helpers.markdown import * +from ruqqus.mail import * +from flask import * +from ruqqus.__main__ import app, cache, limiter, db_session +from pusher_push_notifications import PushNotifications + +PUSHER_KEY = environ.get("PUSHER_KEY", "").strip() + +beams_client = PushNotifications( + instance_id='02ddcc80-b8db-42be-9022-44c546b4dce6', + secret_key=PUSHER_KEY, +) + +@app.route("/api/v1/user/", methods=["GET"]) +@auth_desired +@api("read") +def user_info(v, username): + user = get_user(username, v=v) + return jsonify(user.json) + + +@app.route("/leaderboard", methods=["GET"]) +@auth_desired +def leaderboard(v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + users1, users2 = leaderboard() + return render_template("leaderboard.html", v=v, users1=users1, users2=users2) + if request.path == "/": render_template("mods.html", v=v, b=board, me=me) + else: return jsonify({"data":[x.json for x in board.mods_list]}) + +@cache.memoize(timeout=86400) +def leaderboard(): + users = g.db.query(User).options(lazyload('*')) + users1= sorted(users, key=lambda x: x.dramacoins, reverse=True)[:100] + users2 = sorted(users1, key=lambda x: x.follower_count, reverse=True)[:10] + return users1[:25], users2 + +@app.get("/@/css") +def get_css(username): + user = get_user(username) + if user.css: css = user.css + else: css = "" + resp=make_response(css) + resp.headers.add("Content-Type", "text/css") + return resp + +@app.get("/@/profilecss") +def get_profilecss(username): + user = get_user(username) + if user.profilecss: profilecss = user.profilecss + else: profilecss = "" + resp=make_response(profilecss) + resp.headers.add("Content-Type", "text/css") + return resp + +@app.route("/@/reply/", methods=["POST"]) +@auth_required +def messagereply(v, username, id): + message = request.form.get("message", "") + user = get_user(username) + with CustomRenderer() as renderer: text_html = renderer.render(mistletoe.Document(message)) + text_html = sanitize(text_html, linkgen=True) + parent = get_comment(int(id), v=v) + new_comment = Comment(author_id=v.id, + parent_submission=None, + parent_fullname=parent.fullname, + parent_comment_id=id, + level=parent.level + 1, + sentto=user.id + ) + g.db.add(new_comment) + g.db.flush() + new_aux = CommentAux(id=new_comment.id, body=message, body_html=text_html) + g.db.add(new_aux) + notif = Notification(comment_id=new_comment.id, user_id=user.id) + g.db.add(notif) + g.db.commit() + return redirect('/notifications?all=true') + +@app.route("/songs/", methods=["GET"]) +def songs(id): + try: id = int(id) + except: return '', 400 + user = g.db.query(User).filter_by(id=id).first() + return send_from_directory('/songs/', f'{user.song}.mp3') + +@app.route("/subscribe/", methods=["POST"]) +@auth_required +def subscribe(v, post_id): + new_sub = Subscription(user_id=v.id, submission_id=post_id) + g.db.add(new_sub) + g.db.commit() + return "", 204 + +@app.route("/unsubscribe/", methods=["POST"]) +@auth_required +def unsubscribe(v, post_id): + sub=g.db.query(Subscription).filter_by(user_id=v.id, submission_id=post_id).first() + g.db.delete(sub) + return "", 204 + +@app.route("/@/message", methods=["POST"]) +@auth_required +def message2(v, username): + user = get_user(username, v=v) + if user.is_blocking: return jsonify({"error": "You're blocking this user."}), 403 + if user.is_blocked: return jsonify({"error": "This user is blocking you."}), 403 + message = request.form.get("message", "") + send_pm(v.id, user, message) + beams_client.publish_to_interests( + interests=[str(user.id)], + publish_body={ + 'web': { + 'notification': { + 'title': f'New message from @{v.username}', + 'body': message, + 'deep_link': f'https://rdrama.net/notifications', + }, + }, + }, + ) + return redirect('/notifications?sent=true') + +@app.route("/2faqr/", methods=["GET"]) +@auth_required +def mfa_qr(secret, v): + x = pyotp.TOTP(secret) + qr = qrcode.QRCode( + error_correction=qrcode.constants.ERROR_CORRECT_L + ) + qr.add_data(x.provisioning_uri(v.username, issuer_name="Drama")) + img = qr.make_image(fill_color="#FF66AC", back_color="white") + + mem = io.BytesIO() + + img.save(mem, format="PNG") + mem.seek(0, 0) + return send_file(mem, mimetype="image/png", as_attachment=False) + + +@app.route("/api/is_available/", methods=["GET"]) +@app.route("/api/v1/is_available/", methods=["GET"]) +@auth_desired +@api("read") +def api_is_available(name, v): + + name=name.strip() + + if len(name)<3 or len(name)>25: + return jsonify({name:False}) + + name=name.replace('_','\_') + + x= g.db.query(User).options( + lazyload('*') + ).filter( + or_( + User.username.ilike(name), + User.original_username.ilike(name) + ) + ).first() + + if x: + return jsonify({name: False}) + else: + return jsonify({name: True}) + + +@app.route("/uid/", methods=["GET"]) +def user_uid(uid): + + user = get_account(uid) + + return redirect(user.permalink) + +@app.route("/id/", methods=["GET"]) +def user_uid2(uid): + + user = get_account(int(uid)) + return redirect(user.permalink) + +# Allow Id of user to be queryied, and then redirect the bot to the +# actual user api endpoint. +# So they get the data and then there will be no need to reinvent +# the wheel. +@app.route("/api/v1/uid/", methods=["GET"]) +@auth_desired +@api("read") +def user_by_uid(uid, v=None): + user=get_account(uid) + + return redirect(f'/api/v1/user/{user.username}/info') + +@app.route("/u/", methods=["GET"]) +def redditor_moment_redirect(username): + return redirect(f"/@{username}") + +@app.route("/@/followers", methods=["GET"]) +@auth_required +def followers(username, v): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + u = get_user(username, v=v) + users = [x.user for x in u.followers] + return render_template("followers.html", v=v, u=u, users=users) + +@app.route("/@", methods=["GET"]) +@app.route("/api/v1/user//listing", methods=["GET"]) +@auth_desired +@public("read") +def u_username(username, v=None): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + # username is unique so at most this returns one result. Otherwise 404 + + # case insensitive search + + u = get_user(username, v=v) + + # check for wrong cases + + if username != u.username: + return redirect(request.path.replace(username, u.username)) + + if u.reserved: + return {'html': lambda: render_template("userpage_reserved.html", + u=u, + v=v), + 'api': lambda: {"error": f"That username is reserved for: {u.reserved}"} + } + + if u.is_deleted and (not v or v.admin_level < 3): + return {'html': lambda: render_template("userpage_deleted.html", + u=u, + v=v), + 'api': lambda: {"error": "That user deactivated their account."} + } + + if u.is_private and (not v or (v.id != u.id and v.admin_level < 3)): + return {'html': lambda: render_template("userpage_private.html", + u=u, + v=v), + 'api': lambda: {"error": "That userpage is private"} + } + + if u.is_blocking and (not v or v.admin_level < 3): + return {'html': lambda: render_template("userpage_blocking.html", + u=u, + v=v), + 'api': lambda: {"error": f"You are blocking @{u.username}."} + } + + if u.is_blocked and (not v or v.admin_level < 3): + return {'html': lambda: render_template("userpage_blocked.html", + u=u, + v=v), + 'api': lambda: {"error": "This person is blocking you."} + } + + sort = request.args.get("sort", "new") + t = request.args.get("t", "all") + page = int(request.args.get("page", "1")) + page = max(page, 1) + + ids = u.userpagelisting(v=v, page=page, sort=sort, t=t) + + # we got 26 items just to see if a next page exists + next_exists = (len(ids) == 26) + ids = ids[0:25] + + # If page 1, check for sticky + if page == 1: + sticky = [] + sticky = g.db.query(Submission).filter_by(is_pinned=True, author_id=u.id).all() + if sticky: + for p in sticky: + ids = [p.id] + ids + + listing = get_posts(ids, v=v, sort="new") + + if u.unban_utc: + unban = datetime.fromtimestamp(u.unban_utc).strftime('%c') + return {'html': lambda: render_template("userpage.html", + unban=unban, + u=u, + v=v, + listing=listing, + page=page, + sort=sort, + t=t, + next_exists=next_exists, + is_following=(v and u.has_follower(v))), + 'api': lambda: jsonify({"data": [x.json for x in listing]}) + } + + return {'html': lambda: render_template("userpage.html", + u=u, + v=v, + listing=listing, + page=page, + sort=sort, + t=t, + next_exists=next_exists, + is_following=(v and u.has_follower(v))), + 'api': lambda: jsonify({"data": [x.json for x in listing]}) + } + + +@app.route("/@/comments", methods=["GET"]) +@app.route("/api/v1/user//comments", methods=["GET"]) +@auth_desired +@public("read") +def u_username_comments(username, v=None): + if v and v.is_banned and not v.unban_utc: return render_template("seized.html") + + # username is unique so at most this returns one result. Otherwise 404 + + # case insensitive search + + user = get_user(username, v=v) + + # check for wrong cases + + if username != user.username: return redirect(f'{user.url}/comments') + + u = user + + if u.reserved: + return {'html': lambda: render_template("userpage_reserved.html", + u=u, + v=v), + 'api': lambda: {"error": f"That username is reserved for: {u.reserved}"} + } + + if u.is_private and (not v or (v.id != u.id and v.admin_level < 3)): + return {'html': lambda: render_template("userpage_private.html", + u=u, + v=v), + 'api': lambda: {"error": "That userpage is private"} + } + + if u.is_blocking and (not v or v.admin_level < 3): + return {'html': lambda: render_template("userpage_blocking.html", + u=u, + v=v), + 'api': lambda: {"error": f"You are blocking @{u.username}."} + } + + if u.is_blocked and (not v or v.admin_level < 3): + return {'html': lambda: render_template("userpage_blocked.html", + u=u, + v=v), + 'api': lambda: {"error": "This person is blocking you."} + } + + page = int(request.args.get("page", "1")) + sort=request.args.get("sort","new") + t=request.args.get("t","all") + + ids = user.commentlisting( + v=v, + page=page, + sort=sort, + t=t, + ) + + # we got 26 items just to see if a next page exists + next_exists = (len(ids) == 26) + ids = ids[0:25] + + listing = get_comments(ids, v=v) + + is_following = (v and user.has_follower(v)) + + board = get_board(1) + return {"html": lambda: render_template("userpage_comments.html", + u=user, + v=v, + listing=listing, + page=page, + sort=sort, + t=t, + next_exists=next_exists, + is_following=is_following, + standalone=True), + "api": lambda: jsonify({"data": [c.json for c in listing]}) + } + +@app.route("/api/v1/user//info", methods=["GET"]) +@auth_desired +@public("read") +def u_username_info(username, v=None): + + user=get_user(username, v=v) + + if user.is_blocking: + return jsonify({"error": "You're blocking this user."}), 401 + elif user.is_blocked: + return jsonify({"error": "This user is blocking you."}), 403 + + return jsonify(user.json) + + +@app.route("/api/follow/", methods=["POST"]) +@auth_required +def follow_user(username, v): + + target = get_user(username) + + if target.id==v.id: + return jsonify({"error": "You can't follow yourself!"}), 400 + + # check for existing follow + if g.db.query(Follow).filter_by(user_id=v.id, target_id=target.id).first(): + abort(409) + + new_follow = Follow(user_id=v.id, + target_id=target.id) + + g.db.add(new_follow) + g.db.flush() + target.stored_subscriber_count=target.follower_count + g.db.add(target) + g.db.commit() + + existing = g.db.query(Notification).filter_by(followsender=v.id, user_id=target.id).first() + if not existing: send_follow_notif(v.id, target.id, f"@{v.username} has followed you!") + return "", 204 + + +@app.route("/api/unfollow/", methods=["POST"]) +@auth_required +def unfollow_user(username, v): + + target = get_user(username) + + # check for existing follow + follow = g.db.query(Follow).filter_by( + user_id=v.id, target_id=target.id).first() + + if not follow: + abort(409) + + g.db.delete(follow) + + existing = g.db.query(Notification).filter_by(followsender=v.id, user_id=target.id).first() + if not existing: send_unfollow_notif(v.id, target.id, f"@{v.username} has unfollowed you!") + return "", 204 + + +@app.route("/@/pic/profile") +@limiter.exempt +def user_profile(username): + x = get_user(username) + return redirect(x.profile_url) + +@app.route("/uid//pic/profile") +@limiter.exempt +def user_profile_uid(uid): + x=get_account(uid) + return redirect(x.profile_url) + + +@app.route("/@/saved/posts", methods=["GET"]) +@app.route("/api/v1/saved/posts", methods=["GET"]) +@auth_required +@api("read") +def saved_posts(v, username): + + page=int(request.args.get("page",1)) + + ids=v.saved_idlist(page=page) + + next_exists=len(ids)==26 + + ids=ids[0:25] + + listing = get_posts(ids, v=v, sort="new") + + return {'html': lambda: render_template("userpage.html", + u=v, + v=v, + listing=listing, + page=page, + next_exists=next_exists, + ), + 'api': lambda: jsonify({"data": [x.json for x in listing]}) + } + + +@app.route("/@/saved/comments", methods=["GET"]) +@app.route("/api/v1/saved/comments", methods=["GET"]) +@auth_required +@api("read") +def saved_comments(v, username): + + page=int(request.args.get("page",1)) + + ids=v.saved_comment_idlist(page=page) + + next_exists=len(ids)==26 + + ids=ids[0:25] + + listing = get_comments(ids, v=v, sort="new") + + + return {'html': lambda: render_template("userpage_comments.html", + u=v, + v=v, + listing=listing, + page=page, + next_exists=next_exists, + standalone=True), + 'api': lambda: jsonify({"data": [x.json for x in listing]}) + } \ No newline at end of file diff --git a/ruqqus/routes/votes.py b/ruqqus/routes/votes.py new file mode 100644 index 000000000..49b5d325b --- /dev/null +++ b/ruqqus/routes/votes.py @@ -0,0 +1,120 @@ +from ruqqus.helpers.wrappers import * +from ruqqus.helpers.get import * +from ruqqus.classes import * +from flask import * +from ruqqus.__main__ import app + + +@app.route("/api/v1/vote/post//", methods=["POST"]) +@app.route("/api/vote/post//", methods=["POST"]) +@is_not_banned +@no_negative_balance("toast") +@api("vote") +@validate_formkey +def api_vote_post(post_id, x, v): + + if x not in ["-1", "0", "1"]: + abort(400) + + # disallow bots + if request.headers.get("X-User-Type","") == "Bot": + abort(403) + + x = int(x) + + if x==-1: + count=g.db.query(Vote).filter( + Vote.user_id.in_( + tuple( + [v.id]+[x.id for x in v.alts] + ) + ), + Vote.created_utc > (int(time.time())-3600), + Vote.vote_type==-1 + ).count() + if count >=15 and v.admin_level==0: + return jsonify({"error": "You're doing that too much. Try again later."}), 403 + + post = get_post(post_id) + + if post.is_banned: + return jsonify({"error":"That post has been removed."}), 403 + elif post.deleted_utc > 0: + return jsonify({"error":"That post has been deleted."}), 403 + elif post.is_archived: + return jsonify({"error":"That post is archived and can no longer be voted on."}), 403 + + # check for existing vote + existing = g.db.query(Vote).filter_by( + user_id=v.id, submission_id=post.id).first() + if existing: + existing.change_to(x) + g.db.add(existing) + + else: + vote = Vote(user_id=v.id, + vote_type=x, + submission_id=base36decode(post_id), + creation_ip=request.remote_addr, + app_id=v.client.application.id if v.client else None + ) + + g.db.add(vote) + + try: + g.db.flush() + except: + return jsonify({"error":"Vote already exists."}), 422 + + posts = [] + posts.append(post) + + post.upvotes = post.ups + post.downvotes = post.downs + g.db.add(post) + return "", 204 + +@app.route("/api/v1/vote/comment//", methods=["POST"]) +@app.route("/api/vote/comment//", methods=["POST"]) +@is_not_banned +@no_negative_balance("toast") +@api("vote") +@validate_formkey +def api_vote_comment(comment_id, x, v): + + if x not in ["-1", "0", "1"]: + abort(400) + + # disallow bots + if request.headers.get("X-User-Type","") == "Bot": + abort(403) + + x = int(x) + + comment = get_comment(comment_id) + + # check for existing vote + existing = g.db.query(CommentVote).filter_by( + user_id=v.id, comment_id=comment.id).first() + if existing: + existing.change_to(x) + g.db.add(existing) + else: + + vote = CommentVote(user_id=v.id, + vote_type=x, + comment_id=base36decode(comment_id), + creation_ip=request.remote_addr, + app_id=v.client.application.id if v.client else None + ) + + g.db.add(vote) + try: + g.db.flush() + except: + return jsonify({"error":"Vote already exists."}), 422 + + comment.upvotes = comment.ups + comment.downvotes = comment.downs + g.db.add(comment) + return make_response(""), 204 diff --git a/ruqqus/static/index.js b/ruqqus/static/index.js new file mode 100644 index 000000000..79ba34553 --- /dev/null +++ b/ruqqus/static/index.js @@ -0,0 +1,7 @@ +const vm = new Vue({ + el: '#vm', + delimiters: ['[[', ']]'], + data: { + greeting: 'Hello, Vue!' + } +}) diff --git a/ruqqus/templates/2fa_modal.html b/ruqqus/templates/2fa_modal.html new file mode 100644 index 000000000..3aa05f0c1 --- /dev/null +++ b/ruqqus/templates/2fa_modal.html @@ -0,0 +1,67 @@ + + + \ No newline at end of file diff --git a/ruqqus/templates/admin/admin_home.html b/ruqqus/templates/admin/admin_home.html new file mode 100644 index 000000000..2a16d5510 --- /dev/null +++ b/ruqqus/templates/admin/admin_home.html @@ -0,0 +1,40 @@ +{% extends "default.html" %} + +{% block sidebarblock %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} + +{% block title %} +Drama + +{% endblock %} + +{% block content %} +

+

+

 Admin Tools

+{% filter markdown %} +* [Advanced Stats](/api/user_stat_data) +* [Ban Domain](/admin/domain/enter%20domain%20here) +* [Shadowbanned Users](/admin/shadowbanned) +* [Users with Agendaposter Theme](/admin/agendaposters) +* [Flagged Posts](/admin/flagged/posts) +* [Flagged Comments](/admin/flagged/comments) +* [Image Posts](/admin/image_posts) +* [Removed Posts](/admin/removed) +* [Users Feed](/admin/users) +* [Remove image from imgur and from cloudflare cache](/admin/image_purge) +* [Perceptive Hash Image Ban](/admin/image_ban) +* [Multi Vote Analysis](/admin/alt_votes) +* [App Queue](/admin/apps) +* [App Data](/admin/appdata) +* [Badges](/admin/badge_grant) +* [Content Stats](/admin/content_stats) + +{% endfilter %} + +
+ + +
+ +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/admin/alt_votes.html b/ruqqus/templates/admin/alt_votes.html new file mode 100644 index 000000000..9cdce00f8 --- /dev/null +++ b/ruqqus/templates/admin/alt_votes.html @@ -0,0 +1,94 @@ +{% extends "default.html" %} + +{% block sidebarblock %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} + +{% block title %} +Drama + +{% endblock %} + +{% block content %} +
+
+
+
+
+
Vote Info
+ +
+ + + + +
+ +{% if u1 and u2 %} + + +

Analysis

+ +

{{u1.username}} Creation IP: {{u1.creation_ip}}

+

{{u2.username}} Creation IP: {{u2.creation_ip}}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@{{u1.username}} only(% unique)Both@{{u2.username}} only (% unique)
Post Upvotes{{data['u1_only_post_ups']}} ({{data['u1_post_ups_unique']}}%){{data['both_post_ups']}}{{data['u2_only_post_ups']}} ({{data['u2_post_ups_unique']}}%)
Post Downvotes{{data['u1_only_post_downs']}} ({{data['u1_post_downs_unique']}}%){{data['both_post_downs']}}{{data['u2_only_post_downs']}} ({{data['u2_post_downs_unique']}}%)
Comment Upvotes{{data['u1_only_comment_ups']}} ({{data['u1_comment_ups_unique']}}%){{data['both_comment_ups']}}{{data['u2_only_comment_ups']}} ({{data['u2_comment_ups_unique']}}%)
Comment Downvotes{{data['u1_only_comment_downs']}} ({{data['u1_comment_downs_unique']}}%){{data['both_comment_downs']}}{{data['u2_only_comment_downs']}} ({{data['u2_comment_downs_unique']}}%)
+ +

Link Accounts

+ +{% if u2 in u1.alts %} +

Accounts are known alts of eachother.

+{% else %} + +

Two accounts controlled by different people should have most uniqueness percentages at or above 70-80%

+

A sockpuppet account will have its uniqueness percentages significantly lower.

+ +Link Accounts +
+ + + + +
+ +{% endif %} + +{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/admin/app.html b/ruqqus/templates/admin/app.html new file mode 100644 index 000000000..e2b3be60f --- /dev/null +++ b/ruqqus/templates/admin/app.html @@ -0,0 +1,76 @@ +{% extends "default.html" %} + +{% block sidebarblock %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} + +{% block title %} +API App Administration + +{% endblock %} + +{% block content %} + +
+
+
+ +
+
+
+ +
+
+ + + + + + + + + + + +
+
+ +
+ +
+ +{% if listing %} + {% include "submission_listing.html" %} +{% elif comments %} + {% include "comments.html" %} +{% endif %} + +
+
+ + + + + + + +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/admin/app_data.html b/ruqqus/templates/admin/app_data.html new file mode 100644 index 000000000..8a72acabb --- /dev/null +++ b/ruqqus/templates/admin/app_data.html @@ -0,0 +1,79 @@ +{% extends "default.html" %} + +{% block sidebarblock %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} + +{% block title %} +Drama + +{% endblock %} + +{% block content %} +
+
+
+
+
+
App Info
+ +
+ + + +
+ +{% if thing %} + +

Info

+

{{thing.permalink}}

+ + +

Author: @{{thing.author.username}}

+ +{% if thing.oauth_app %} + + +
+
+ +
+ + + + + + + + + + +
+
+ +
+ + + +{% endif %} + + + +{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/admin/apps.html b/ruqqus/templates/admin/apps.html new file mode 100644 index 000000000..96aff06ba --- /dev/null +++ b/ruqqus/templates/admin/apps.html @@ -0,0 +1,76 @@ +{% extends "default.html" %} + +{% block sidebarblock %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} + +{% block title %} +API App Administration + +{% endblock %} + +{% block content %} + +
+
+
+{% for app in apps %} +
+
+ +
+ + + + + + + {% if app.client_secret %} + + + {% endif %} + + + + + + +
+
+ +
+{% endfor %} + +
+
+
+ + + + + + + +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/admin/badge_grant.html b/ruqqus/templates/admin/badge_grant.html new file mode 100644 index 000000000..0e6ae8dcd --- /dev/null +++ b/ruqqus/templates/admin/badge_grant.html @@ -0,0 +1,76 @@ +{% extends "default.html" %} + +{% block title %} +Badge Grant +{% endblock %} + +{% block pagetype %}message{% endblock %} + +{% block sidebarblock %}{% endblock %} + +{% block content %} + + {% if error %} + + {% endif %} + {% if msg %} + + {% endif %} + +

+

+
Badge Grant
+ +
+ + + +
+ + + + + + + + + + + + +{% for badge in badge_types %} + + + + + + +{% endfor %} +
SelectImageNameDefault Description
{{badge.name}}{{badge.description}}
+ +
+ + +
+ + + + +
+{% endblock %} diff --git a/ruqqus/templates/admin/cache.html b/ruqqus/templates/admin/cache.html new file mode 100644 index 000000000..7ba222c17 --- /dev/null +++ b/ruqqus/templates/admin/cache.html @@ -0,0 +1,27 @@ +{% extends "default.html" %} + +{% block sidebarblock %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} + +{% block title %} +Drama + +{% endblock %} + +{% block content %} +

+

+
Cache Dump
+Clear internal cache + + + +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/admin/content_stats.html b/ruqqus/templates/admin/content_stats.html new file mode 100644 index 000000000..8d1e6253e --- /dev/null +++ b/ruqqus/templates/admin/content_stats.html @@ -0,0 +1,28 @@ +{% extends "default.html" %} + +{% block sidebarblock %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} + +{% block title %} +Drama + +{% endblock %} + +{% block content %} +

+
+
+	
+		
+		
+	
+
+{% for entry in data %}
+	
+		
+		
+	
+{% endfor %}
+
StatisticValue
{{entry}}{{data[entry]}}
+ +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/admin/flagged_comments.html b/ruqqus/templates/admin/flagged_comments.html new file mode 100644 index 000000000..e6ebef5e4 --- /dev/null +++ b/ruqqus/templates/admin/flagged_comments.html @@ -0,0 +1,24 @@ +{% extends "admin/flagged_posts.html" %} + + + +{% block listing %} + + +
+ {% with comments=listing %} + {% include "comments.html" %} + {% endwith %} + {% if not listing %} +
+
+
+
There are no comments here (yet).
+
+
+
+ {% endif %} +
+ +{% endblock %} + diff --git a/ruqqus/templates/admin/flagged_posts.html b/ruqqus/templates/admin/flagged_posts.html new file mode 100644 index 000000000..ef9cccba8 --- /dev/null +++ b/ruqqus/templates/admin/flagged_posts.html @@ -0,0 +1,115 @@ +{% extends "userpage.html" %} + +{% block adminpanel %}{% endblock %} +{% block pagetype %}userpage{% endblock %} +{% block banner %}{% endblock %} +{% block mobileBanner %}{% endblock %} +{% block desktopBanner %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} +{% block desktopUserBanner %}{% endblock %} +{% block mobileUserBanner %}{% endblock %} + +{% block adminscripts %} + +{% endblock %} + + + {% block postNav %} +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ {% endblock %} + + +{% block enlargeThumbJS %} + +{% endblock %} + +{% block fixedMobileBarJS %} + +{% endblock %} + +{% block title %} +Flagged Posts + +{% endblock %} + +{% block content %} + + +
+ +
+ + {% block listing %} +
+ {% include "submission_listing.html" %} +
+ {% endblock %} +
+
+{% endblock %} + +{% block pagenav %} + +{% endblock %} diff --git a/ruqqus/templates/admin/image_ban.html b/ruqqus/templates/admin/image_ban.html new file mode 100644 index 000000000..e9c0b8fcf --- /dev/null +++ b/ruqqus/templates/admin/image_ban.html @@ -0,0 +1,45 @@ +{% extends "default.html" %} + +{% block sidebarblock %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} + +{% block title %} +Image Ban + +{% endblock %} + +{% block content %} + +{% if existing %} +

Image already banned for: {{existing.ban_reason}}

+{% elif success %} +

Image banned.

+{% endif %} + +
+
+
+
+
+
Perceptive Hash Image Ban
+

Upload an image to add its hash to the ban list.

+ +
+ + + + + + + Enter the number of days to ban a user who attempts to upload this image + + +
+ + +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/admin/image_posts.html b/ruqqus/templates/admin/image_posts.html new file mode 100644 index 000000000..32cd60a91 --- /dev/null +++ b/ruqqus/templates/admin/image_posts.html @@ -0,0 +1,105 @@ +{% extends "userpage.html" %} + +{% block adminpanel %}{% endblock %} +{% block pagetype %}userpage{% endblock %} +{% block banner %}{% endblock %} +{% block mobileBanner %}{% endblock %} +{% block desktopBanner %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} +{% block desktopUserBanner %}{% endblock %} +{% block mobileUserBanner %}{% endblock %} + +{% block adminscripts %} + +{% endblock %} + + +{% block postNav %}{% endblock %} + + +{% block enlargeThumbJS %} + +{% endblock %} + +{% block fixedMobileBarJS %} + +{% endblock %} + +{% block title %} +Image feed + +{% endblock %} + + +{% block sidebarblock %} +

+

+
+{% endblock %}
+
+{% block content %}
+
+
+
+ +
+ + {% block listing %} +
+ {% include "submission_listing.html" %} +
+ {% endblock %} +
+
+{% endblock %} + +{% block pagenav %} + +{% endblock %} diff --git a/ruqqus/templates/admin/image_purge.html b/ruqqus/templates/admin/image_purge.html new file mode 100644 index 000000000..557e76eae --- /dev/null +++ b/ruqqus/templates/admin/image_purge.html @@ -0,0 +1,22 @@ +{% extends "default.html" %} + +{% block sidebarblock %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} + +{% block title %} +Purge Image + +{% endblock %} + +{% block content %} +

Imgur and Cloudflare Image Purge

+

Paste an image link here to remove it from imgur and from cloudflare cache. You need to enter the full url of the image like this "https://i.imgur.com/63OclpM_d.png?maxwidth=9999"

+ +
+ + + +
+ + +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/admin/manage_domain.html b/ruqqus/templates/admin/manage_domain.html new file mode 100644 index 000000000..7d332765c --- /dev/null +++ b/ruqqus/templates/admin/manage_domain.html @@ -0,0 +1,53 @@ +{% extends "default.html" %} + +{% block sidebarblock %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} + +{% block title %} +Drama + +{% endblock %} + +{% block content %} + + +Get Domain Record + +

{{domain_name}}

+ +

Current

+ +

can_submit

+

{{domain.can_submit}}

+ +

can_comment

+

{{domain.can_comment}}

+ +

reason

+

{{domain.reason_text}}

+ +

show_thumbnail

+

{{domain.show_thumbnail}}

+ +

embed_function

+

{{domain.embed_function}}

+ +

embed_template

+

{{domain.embed_template}}

+ +

Actions

+ +
+ + + + + +
+ +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/admin/new_users.html b/ruqqus/templates/admin/new_users.html new file mode 100644 index 000000000..cb44eb421 --- /dev/null +++ b/ruqqus/templates/admin/new_users.html @@ -0,0 +1,19 @@ +{% extends "mine/mine.html" %} + +{% block maincontent %} + + +{% include "user_listing.html" %} +{% endblock %} + +{% block sidebarblock %} + +{% endblock %} + +{% block navbar %}{% endblock %} + +{% block sidebar %}{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/admin/removed_posts.html b/ruqqus/templates/admin/removed_posts.html new file mode 100644 index 000000000..dd550af72 --- /dev/null +++ b/ruqqus/templates/admin/removed_posts.html @@ -0,0 +1,60 @@ +{% extends "admin/image_posts.html" %} + + +{% block title %} +Removed Content + +{% endblock %} + +{% block content %} + + +
+
+
+
+
+

+				
Removed Posts
+ +
+ +
+ +
+
+
+ +
+ +
+ + {% block listing %} +
+ {% include "submission_listing.html" %} +
+ {% endblock %} +
+
+{% endblock %} + +{% block pagenav %} + +{% endblock %} diff --git a/ruqqus/templates/admin/votes.html b/ruqqus/templates/admin/votes.html new file mode 100644 index 000000000..117216c90 --- /dev/null +++ b/ruqqus/templates/admin/votes.html @@ -0,0 +1,64 @@ +{% extends "default.html" %} + +{% block sidebarblock %}{% endblock %} +{% block sidebarLeftblock %}{% endblock %} + +{% block title %} +Drama + +{% endblock %} + +{% block content %} +

Vote Info

+ +
+ + + +
+ +{% if thing %} + +

Info

+

{{thing.permalink}}

+

Author: @{{thing.author.username}}

+

Author Created At: {{thing.author.created_utc}} ({{thing.author.created_datetime}} UTC)

+

Counted Upvotes: {{thing.upvotes}} out of {{ups | length}}

+

Counted Downvotes: {{thing.downvotes}} out of {{downs | length}}

+ +

Upvotes

+ + + + + + + + {% for vote in ups %} + + + + {% endfor %} +
User
{{vote.user.username}}
+ +

Downvotes

+ + + + + + + + {% for vote in downs %} + + + + {% endfor %} +
User
{{vote.user.username}}
+ + + +{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/authforms.html b/ruqqus/templates/authforms.html new file mode 100644 index 000000000..24f87bc8b --- /dev/null +++ b/ruqqus/templates/authforms.html @@ -0,0 +1,131 @@ + + + + + + + + + + + {% block pagetitle %}Drama - the open, free-speech social platform{% endblock %} + + + + + + + + + + + + + {% if v %} + + {% if v.agendaposter %}{% elif v.css %}{% endif %} + {% else %} + + {% endif %} + + + + + + + + + + +
+
+ +
+ +
+ +
+ +
+ Drama +
+ +

{% block authtitle %}{% endblock %}

+ +

{% block authtext %}{% endblock %}

+ + {% if error %} + + {% endif %} + {% if msg %} + + {% endif %} + + {% block content %} + {% endblock %} + +
+ +
+ +
+ +
+ +
+ +
+ +
+ {{i.text}} +
+ + +
+ +
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/ruqqus/templates/badge.html b/ruqqus/templates/badge.html new file mode 100644 index 000000000..0793c2e35 --- /dev/null +++ b/ruqqus/templates/badge.html @@ -0,0 +1,5 @@ +{% if b.url %} + +{% else %} + +{% endif %} diff --git a/ruqqus/templates/badge_pair.html b/ruqqus/templates/badge_pair.html new file mode 100644 index 000000000..ee0f8e1a1 --- /dev/null +++ b/ruqqus/templates/badge_pair.html @@ -0,0 +1,6 @@ +
+{{bp[0].rendered | safe}} +{% if bp|length > 1 %} +{{bp[1].rendered | safe}} +{% endif %} +
\ No newline at end of file diff --git a/ruqqus/templates/badges.html b/ruqqus/templates/badges.html new file mode 100644 index 000000000..305e0a39d --- /dev/null +++ b/ruqqus/templates/badges.html @@ -0,0 +1,69 @@ +{% extends "default.html" %} +{% block content %} +
+
+
+
+

User Badges

+
This page describes the requirements for obtaining all profile badges.
+
Badges are sorted into bronze, silver, gold, and diamond tiers, based on the relative difficulty of obtaining them.
+ +

Unlockable Badges

+
These badges are automatically granted through different kinds of activity on Drama.
+ + + + + + + + +{% for badge in badges if badge.kind==1 %} + + + + +{% endfor %} +
NameImageDescription
{{badge.name}} + {{badge.description}}
+ + +

Granted Badges

+
These badges can be granted by staff.
+ + + + + + + + +{% for badge in badges if badge.kind==3 %} + + + + +{% endfor %} +
NameImageDescription
{{badge.name}} + {{badge.description}}
+ +

Unobtainable Badges

+
There is no way to acquire these badges if you don't already have them.
+ + + + + + + + +{% for badge in badges if badge.kind==4 %} + + + + +{% endfor %} +
NameImageDescription
{{badge.name}} + {{badge.description}}
+ +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/banned.html b/ruqqus/templates/banned.html new file mode 100644 index 000000000..abd87a911 --- /dev/null +++ b/ruqqus/templates/banned.html @@ -0,0 +1,23 @@ +{% extends "guild_settings.html" %} + +{% block content %} + + + + + + + + + +{% for user in users %} + + + + + + +{% endfor %} +
#NameReasonBanned by
{{users.index(user)+1}}{{user.username}}{% if user.ban_reason %}{{user.ban_reason}}{% endif %}{{user.banned_by.username}}
+ +{% endblock %} diff --git a/ruqqus/templates/blocks.html b/ruqqus/templates/blocks.html new file mode 100644 index 000000000..a4275fbbc --- /dev/null +++ b/ruqqus/templates/blocks.html @@ -0,0 +1,22 @@ +{% extends "guild_settings.html" %} + +{% block pagetitle %}Blocks{% endblock %} + +{% block content %} +

Blocks

+

+
+
+	
+		
+		
+	
+
+{% for user in users %}
+	
+		
+		
+	
+{% endfor %}
+
UserTarget
{{user.username}}{{targets[loop.index-1].username}}
+{% endblock %} diff --git a/ruqqus/templates/bootstrap.html b/ruqqus/templates/bootstrap.html new file mode 100644 index 000000000..214548b81 --- /dev/null +++ b/ruqqus/templates/bootstrap.html @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/ruqqus/templates/changelog.html b/ruqqus/templates/changelog.html new file mode 100644 index 000000000..4a7d5cace --- /dev/null +++ b/ruqqus/templates/changelog.html @@ -0,0 +1,107 @@ +{% extends "guild_settings.html" %} + +{% block pagetitle %}Changelog{% endblock %} + +{% block desktopBanner %} + +
+
+
+ + {% block navbar %} +
+ +
+
+ + +
+ +
+ {% endblock %} +
+
+
+ +{% endblock %} + +{% block content %} + +{% if v %} + +{% endif %} + +
+ +
+ +
+ + {% include "submission_listing.html" %} + +
+
+
+ +{% endblock %} + +{% block pagenav %} +{% if listing %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/comment_failed.html b/ruqqus/templates/comment_failed.html new file mode 100644 index 000000000..890620717 --- /dev/null +++ b/ruqqus/templates/comment_failed.html @@ -0,0 +1,54 @@ +{% extends "default.html" %} + +{% block title %} +Unable to post comment + + +{% set ups=c.upvotes %} +{% set downs=c.downvotes %} +{% set score=ups-downs %} +{% include "emoji_modal.html" %} + + + +{% if (c.is_banned or c.deleted_utc > 0 or c.is_blocking or c.is_blocked) and not (v and v.admin_level>0) and not (v and v.id==c.author_id) %} + + + +
+ + + + +
+ +
+ + + + +
+ {% if c.is_banned and c.ban_reason %}Reason: {{c.ban_reason}}{% endif %} +
+
+ + + + {% if render_replies %} + {% if level<6 %} +
+ {% set standalone=False %} + {% for reply in c.children(v) %} + {{single_comment(reply, level=level+1)}} + {% endfor %} +
+ {% elif c.children(v) %} +
+ {% set standalone=False %} + {% for reply in c.children(v) %} + {{single_comment(reply, level=level+1)}} + {% endfor %} +
+ + {% endif %} + {% endif %} +
+
+ + +{% else %} + +{% set score=c.score %} + +{% if v %} +{% set voted=c.voted %} +{% set adjust = voted %} + +{% else %} +{% set voted=-2 %} +{% set adjust=0 %} + +{% endif %} + +{% if standalone and level==1 %} + +{% endif %} + +
+ + + +
+ +
+ + + + {% if c.is_banned and c.ban_reason %} +
Reason: {{c.ban_reason}}
+ {% endif %} + +
+ {{c.realbody(v) | safe}} + + {% if not c.parent_submission and c.author_id!=1046 and c.author_id!=2360 and c.author_id!=v.id %} + Reply +

+					
+ +

+						
+						 
+						
+						 
+						
+						 
+						
+						 
+						
+						 
+						
+					
+ {% endif %} +
+ + {% if c.parent_submission %} + {% if v and v.id==c.author_id and (standalone or is_allowed_to_comment) %} + + {% endif %} + + {% if c.active_flags %} +
+ Reported by: + +
+ {% endif %} + +
+
    + + {% if v and request.path.startswith('/@') and v.admin_level == 0%} + + {% if voted==1 %} +
  • + {% endif %} + + {% elif v %} + +
  • +
  • + + {% else %} +
  • +
  • + + {% endif %} + +
  • + {{score}} +
  • + + {% if v and request.path.startswith('/@') and v.admin_level == 0 %} + + {% if voted==-1 %} +
  • + {% endif %} + + {% elif v %} +
  • +
  • + + {% else %} + +
  • +
  • + + {% endif %} + +
  • Votes
  • + + {% if v and c.id in v.saved_comment_idlist() %} +
  • Unsave
  • + {% else %} +
  • Save
  • + {% endif %} + + {% if v %} + {% if standalone or is_allowed_to_comment %} +
  • Reply
  • + {% endif %} + + {% endif %} +
  • Context
  • +
  • Copy link
  • + + {% if v and v.admin_level>=3 and request.path.startswith ('/admin') %} +
  • Remove
  • +
  • Approve
  • + {% endif %} +
  • + +
  • +
  • + +
  • + {% if v and request.path.startswith('/@') and v.admin_level == 0 %} + {% if voted==1 %} +
  • +
  • {% endif %} + {% elif v %} +
  • +
  • + {% else %} +
  • +
  • + {% endif %} +
  • + {{score}} +
  • + {% if v and request.path.startswith('/@') and v.admin_level == 0 %} + {% if voted==-1 %} +
  • + {% endif %} + {% elif v %} +
  • +
  • + {% else %} +
  • +
  • + {% endif %} +
+ +
+ {% endif %} +
+ + +
+ + + +
+ + + + + {% if render_replies %} + {% if level<6 %} +
+ {% for reply in c.children(v) %} + {{single_comment(reply, level=level+1)}} + {% endfor %} +
+ {% elif c.children(v) %} +
+ {% for reply in c.children(v) %} + {{single_comment(reply, level=level+1)}} + {% endfor %} +
+ + {% endif %} + {% endif %} + +
+ + + + + +
+ +{% endif %} + +{% endmacro %} + +{% for comment in comments %} + + {{single_comment(comment)}} + +{% endfor %} + + + +
+
+
+
+
+ \ No newline at end of file diff --git a/ruqqus/templates/contact.html b/ruqqus/templates/contact.html new file mode 100644 index 000000000..6fa5ebc98 --- /dev/null +++ b/ruqqus/templates/contact.html @@ -0,0 +1,80 @@ +{% extends "default.html" %} + +{% block title %} +Drama - Contact + +{% endblock %} + + + + +{% block content %} + + {% if request.args.get('error') or error %} + + {% endif %} + {% if request.args.get('msg') or msg %} + + {% endif %} + +

Contact Drama Admins

+{% if v and v.is_activated and not v.is_suspended %} + +

Use this form to contact Drama Admins.

+ + + + +
+ + + + + + +
+ +{% elif v and v.is_suspended %} + +

Your Drama account has been suspended. You are not permitted to use this form.

+ + +{% elif v %} + +

Please verify your email address in order to ensure we can respond to your message if needed. Then, refresh this page.

+ +{% else %} + +

In order to ensure that we can respond to your message, please first sign up or log in and make sure you have verified your email address. Then, refresh this page.

+ +{% endif %} + +
+
+
+
+ +

If you can see this line, we haven't been contacted by any law enforcement or governmental organizations in 2021 yet.

+ +
+
+
+
+ +{% endblock %} diff --git a/ruqqus/templates/control_panel_logged_in.html b/ruqqus/templates/control_panel_logged_in.html new file mode 100644 index 000000000..1dbd3b861 --- /dev/null +++ b/ruqqus/templates/control_panel_logged_in.html @@ -0,0 +1,7 @@ +

My Profile

+

Settings

+

Submit

+
+ + +
diff --git a/ruqqus/templates/control_panel_logged_out.html b/ruqqus/templates/control_panel_logged_out.html new file mode 100644 index 000000000..3125dc6ab --- /dev/null +++ b/ruqqus/templates/control_panel_logged_out.html @@ -0,0 +1 @@ +

Sign In | Sign Up

diff --git a/ruqqus/templates/default.html b/ruqqus/templates/default.html new file mode 100644 index 000000000..8dd3a8c33 --- /dev/null +++ b/ruqqus/templates/default.html @@ -0,0 +1,373 @@ + + + + {% if v %} + + + + {% endif %} + + + + + + + + + {% block title %} + Drama + + + + + + + + + + + + + + + + + + {% endblock %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% block stylesheets %} + + {% if v %} + + {% if v.agendaposter %}{% elif v.css %}{% endif %} + {% else %} + + {% endif %} + + {% endblock %} + + + + + {% block fixedMobileBarJS %} + {% endblock %} + + {% if v %} + + + {% endif %} + + + + + + +{% include "header.html" %} + + + +{% block mobileUserBanner %} +{% endblock %} + +{% block mobileBanner %} +{% endblock %} + +{% block postNav %} +{% endblock %} + +
+
+ + {% block leftSidebar %} + {% block leftSidebarBlock %} + {% endblock %} + {% endblock %} + +
+ + {% block desktopUserBanner %} + {% endblock %} + + {% block desktopBanner %} + {% endblock %} + + {% block PseudoSubmitForm %} + {% endblock %} + + {% block searchText %} + {% endblock %} + + {% block content %} + {% endblock %} + + {% block pagenav %} + {% endblock %} + +
+ +
+
+ +{% block mobilenavbar %} +{% include "mobile_navigation_bar.html" %} +{% endblock %} + +{% block actionsModal %} +{% endblock %} + +{% block reportCommentModal %} +{% endblock %} + +{% block guildModal %} +{% endblock %} + +{% block GIFtoast %} +{% endblock %} + +{% block GIFpicker %} +{% endblock %} + + + + + + + + + + +{% if v %} +{% include "flag_post_modal.html" %} +{% include "flag_comment_modal.html" %} +{% include "gif_modal.html" %} +{% include "delete_comment_modal.html" %} +{% include "delete_post_modal.html" %} +{% endif %} + +{% include "expanded_image_modal.html" %} + +{% include "bootstrap.html" %} + + + + + + + + + + + + + +{% if request.path=='/' and g.system and g.timestamp>session.get('tooltip_last_dismissed',0)+60*60*24 and (not g.system.endswith('/chrome') and not g.system.endswith('/other')) and not g.system.endswith('/webview') %} + +
+
+
+{% endif %} + + + + \ No newline at end of file diff --git a/ruqqus/templates/delete_comment_modal.html b/ruqqus/templates/delete_comment_modal.html new file mode 100644 index 000000000..39bd8b09e --- /dev/null +++ b/ruqqus/templates/delete_comment_modal.html @@ -0,0 +1,28 @@ + + diff --git a/ruqqus/templates/delete_post_modal.html b/ruqqus/templates/delete_post_modal.html new file mode 100644 index 000000000..823e32205 --- /dev/null +++ b/ruqqus/templates/delete_post_modal.html @@ -0,0 +1,46 @@ + + diff --git a/ruqqus/templates/developers.html b/ruqqus/templates/developers.html new file mode 100644 index 000000000..6b4441ac0 --- /dev/null +++ b/ruqqus/templates/developers.html @@ -0,0 +1,43 @@ +{% extends "default.html" %} + +{% block title %} +Drama - FAQ + +{% endblock %} + +{% block content %} +{% filter markdown %} + +# Developer Help + +Tips to help ensure your website works nicely with Drama + +## SSL Required + +To help protect user privacy and safety, Drama forces HTTPS on all assets uploaded and submitted to Drama. This includes automatic conversion of all "https://" schemas into "https://". + +Make sure your website has a valid SSL certificate in order to ensure that Drama users can navigate to it from Drama. + +We will not grant exemptions to https conversion. + +## Share to Drama + +To create a "share to Drama" button, create an element which has `onclick="window.location.href='https://rdrama.net/submit?url='+window.location.href"` + +Use [Drama branding and assets](/press) to style your "share to Drama" button. + + +## Thumbnails + +Thumbnail images for posts are determined by using the first successful condition below: + +1. If the submitted link has a `Content-Type: image/*` header, the link itself is used as the thumbnail asset. +2. If the submitted link has a `Content-Type: text/html` header, and there is a `` element, the `content` attribute of that element will be scraped and used as the thumbnail. (Use this option to explicitly set a thumbnail) +3. If the submitted link has a `Content-Type: text/html` header, and no thumbnail meta element is found, the `src` attribute of the first `img` element will be scraped and used as the thumbnail. + +## Applications + +To request ruqqus API keys, visit [this page](/apps) + +{% endfilter %} +{% endblock %} diff --git a/ruqqus/templates/email/2fa_remove.html b/ruqqus/templates/email/2fa_remove.html new file mode 100644 index 000000000..0da9679a0 --- /dev/null +++ b/ruqqus/templates/email/2fa_remove.html @@ -0,0 +1,42 @@ +{% extends "email/default.html" %} + +{% block image %}verify.png{% endblock %} + +{% block title %}Remove Two-Factor Authentication{% endblock %} + +{% block preheader %}Remove Two-Factor Authentication.{% endblock %} + +{% block content %} +

We received a request to remove two-factor authentication from your account. In 72 hours, click the link below.

+

If you didn't make this request, change your password and use the Log Out Everywhere feature in your Security Settings to permanently invalidate the link.

+ + + + + + +

Please note that Drama will never ask you for your email, password, or two-factor token via email, text, or phone.

+

If you have any questions, feel free to email the Drama team..

+

Thanks, +
The Drama Team

+

P.S. Feel free to tweet at us on Twitter @ruqqus.

+ + + + + + +{% endblock %} diff --git a/ruqqus/templates/email/default.html b/ruqqus/templates/email/default.html new file mode 100644 index 000000000..bf84fa17e --- /dev/null +++ b/ruqqus/templates/email/default.html @@ -0,0 +1,476 @@ + + + + + + + + + + + + + {% block preheader %}Thanks for joining Drama! Please take a sec to verify the email you used to sign up.{% endblock %} + + + + + + + diff --git a/ruqqus/templates/email/email_change.html b/ruqqus/templates/email/email_change.html new file mode 100644 index 000000000..2a36d3161 --- /dev/null +++ b/ruqqus/templates/email/email_change.html @@ -0,0 +1,57 @@ +{% extends "email/default.html" %} + +{% block title %}Verify Your Email{% endblock %} + +{% block preheader %}Verify your new Drama email.{% endblock %} + +{% block content %} +

You told us you wanted to change your Drama account email. To finish this process, please verify your new email address:

+ + + + + + +

For reference, here's your current information:

+ + + + + +

Please note that Drama will never ask you for your email, password, or two-factor token via email, text, or phone.

+ + + + + +{% endblock %} diff --git a/ruqqus/templates/email/email_verify.html b/ruqqus/templates/email/email_verify.html new file mode 100644 index 000000000..d7410ca62 --- /dev/null +++ b/ruqqus/templates/email/email_verify.html @@ -0,0 +1,56 @@ +{% extends "email/default.html" %} + +{% block title %}Welcome to Drama!{% endblock %} + +{% block content %} +

Thanks for joining Drama. We’re happy to have you on board. To get the most out of Drama, please verify your account email:

+ + + + + + +

For reference, here's your username.

+ + + + + +

Please note that Drama will never ask you for your email, password, or two-factor token via email, text, or phone.

+ + + + + + +{% endblock %} diff --git a/ruqqus/templates/email/password_reset.html b/ruqqus/templates/email/password_reset.html new file mode 100644 index 000000000..4d8e58b1c --- /dev/null +++ b/ruqqus/templates/email/password_reset.html @@ -0,0 +1,55 @@ +{% extends "email/default.html" %} + +{% block title %}Reset Your Password{% endblock %} +{% block preheader %}Reset your Drama password.{% endblock %} + +{% block content %} +

To reset your password, click the button below:

+ + + + + + +

For reference, here's your login information:

+ + + + + + + + + + +{% endblock %} diff --git a/ruqqus/templates/embeds/comment.html b/ruqqus/templates/embeds/comment.html new file mode 100644 index 000000000..ee30d46b3 --- /dev/null +++ b/ruqqus/templates/embeds/comment.html @@ -0,0 +1,130 @@ +{% extends "embeds/embed_default.html" %} + +{% set score=c.score_fuzzed %} + +{% block title %} +@{{c.author.username}} comments on "{{c.post.title}}" + +{% endblock %} + + +{% block content %} + + +
+ + + + + +
+ +
+ + + +
+ {{c.body_html | safe}} +
+ + +
+
    + + + +
  • +
  • + + +
  • + {{score}}
  • + +{% if not c.board.downvotes_disabled %} + + + +
  • +
  • + +{% endif %} +
  • Copy link +
  • +
  • + +
  • +
  • + +
  • + +
  • +
  • +
  • +{{score}} +
  • + +
  • +
  • +
+ +
+
+ + + + + +
+{% endblock %} diff --git a/ruqqus/templates/embeds/comment_removed.html b/ruqqus/templates/embeds/comment_removed.html new file mode 100644 index 000000000..e0da84ef0 --- /dev/null +++ b/ruqqus/templates/embeds/comment_removed.html @@ -0,0 +1,38 @@ +{% extends "embeds/embed_default.html" %} + +{% block title %} +{@{{c.author.username}} comments on "{{c.post.title}}" + +{% endblock %} + +{% endblock %} + + + +
+ + + + +
+ +
+ + + + +
+ {% if c.is_banned and c.ban_reason %}Reason: {{c.ban_reason}}{% endif %} +
+ +
+ +
+ + +
+{% block content %} \ No newline at end of file diff --git a/ruqqus/templates/embeds/embed_default.html b/ruqqus/templates/embeds/embed_default.html new file mode 100644 index 000000000..7b8e628b5 --- /dev/null +++ b/ruqqus/templates/embeds/embed_default.html @@ -0,0 +1,113 @@ + + + + + + + + + + {% block title %} + Drama + + {% endblock %} + + + + + + + + {% block stylesheets %} + {% if v %} + + {% if v.agendaposter %}{% elif v.css %}{% endif %} + {% else %} + + {% endif %} + {% endblock %} + + + + + + + + + + + +
+
+ +
+ + + {% block content %} + {% endblock %} + + +
+ + + + + + + + +{% include "bootstrap.html" %} + + + + + + + + + + + +{% block enlargeThumbJS %} +{% endblock %} + +{% block toggleView %} +{% endblock %} + +{% block embedJS %} +{% endblock %} + +{% block formatJS %} +{% endblock %} + + + + + + + diff --git a/ruqqus/templates/embeds/submission.html b/ruqqus/templates/embeds/submission.html new file mode 100644 index 000000000..2121adcd1 --- /dev/null +++ b/ruqqus/templates/embeds/submission.html @@ -0,0 +1,148 @@ +{% extends "embeds/embed_default.html" %} + + +{% set score=p.score_fuzzed %} + + +{% block title %} +{{p.title | safe}} + +{% endblock %} + +{% block pagetype %}thread{% endblock %} + + + {% block content %} +
+ +
+ +
+
+ + {% if p.thumb_url %} +
+ Unable to anonymously load image +
+ {% endif %} + +
+ + + + + {% if p.url %} +

{{p.title | safe}}

+ {% else %} +

{{p.title | safe}}

+ {% endif %} + +
+ {{p.body_html | safe}} + +
+ + +
+ +
+ +
+ + + + + +
+
+
+ {{score}} + {% if not p.board.downvotes_disabled %} +
+
+ +
+ + {% endif %} + +
+ + + + +
+ +
+ + + +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ + {% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/emoji_modal.html b/ruqqus/templates/emoji_modal.html new file mode 100644 index 000000000..de392e9f9 --- /dev/null +++ b/ruqqus/templates/emoji_modal.html @@ -0,0 +1,65 @@ + + \ No newline at end of file diff --git a/ruqqus/templates/errors/400.html b/ruqqus/templates/errors/400.html new file mode 100644 index 000000000..bf7a3d5b4 --- /dev/null +++ b/ruqqus/templates/errors/400.html @@ -0,0 +1,19 @@ +{% extends "errors/default.html" %} + +{% block title %} +400 Bad Request +{% endblock %} + +{% block pagetype %}error-400{% endblock %} + +{% block content %} +
+
+
+ +

400 Bad Request

+

That request was bad and you should feel bad.

+
+
+
+{% endblock %} diff --git a/ruqqus/templates/errors/401.html b/ruqqus/templates/errors/401.html new file mode 100644 index 000000000..29fdf9ac8 --- /dev/null +++ b/ruqqus/templates/errors/401.html @@ -0,0 +1,23 @@ +{% extends "errors/default.html" %} + +{% block title %} +401 Not Authorized +{% endblock %} + +{% block pagetype %}error-401{% endblock %} + +{% block content %} +
+
+
+ + + +

401 Not Authorized

+

You need to log in if you want to get into this castle.

+ + +
+
+
+{% endblock %} diff --git a/ruqqus/templates/errors/402.html b/ruqqus/templates/errors/402.html new file mode 100644 index 000000000..837566d0a --- /dev/null +++ b/ruqqus/templates/errors/402.html @@ -0,0 +1,20 @@ +{% extends "errors/default.html" %} + +{% block title %} +402 Payment Required +{% endblock %} + +{% block pagetype %}error-402{% endblock %} + +{% block content %} +
+
+
+ +

402 Payment Required

+

The royal bank has told us that you owe them some coin. You'll need to pay off your debts before you can continue.

+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/errors/403.html b/ruqqus/templates/errors/403.html new file mode 100644 index 000000000..b6ae227e6 --- /dev/null +++ b/ruqqus/templates/errors/403.html @@ -0,0 +1,20 @@ +{% extends "errors/default.html" %} + +{% block title %} +403 Forbidden +{% endblock %} + +{% block pagetype %}error-403{% endblock %} + +{% block content %} +
+
+
+ +

403 Forbidden

+

Our security warlocks have cast an impenetrable shield spell around this page.

+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/errors/404.html b/ruqqus/templates/errors/404.html new file mode 100644 index 000000000..dbb2ab6c9 --- /dev/null +++ b/ruqqus/templates/errors/404.html @@ -0,0 +1,20 @@ +{% extends "errors/default.html" %} + +{% block title %} +404 Page Not Found +{% endblock %} + +{% block pagetype %}error-404{% endblock %} + +{% block content %} +
+
+
+ +

404 Page Not Found

+

The internet gremlins have led you to a dark, musty dead-end. There's nothing here.

+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/errors/405.html b/ruqqus/templates/errors/405.html new file mode 100644 index 000000000..34da0e997 --- /dev/null +++ b/ruqqus/templates/errors/405.html @@ -0,0 +1,20 @@ +{% extends "default.html" %} + +{% block title %} +405 Method Not Allowed +{% endblock %} + +{% block pagetype %}error-405{% endblock %} + +{% block content %} +
+
+
+ +

405 Method Not Allowed

+

Careful, stranger. The internet gnomes don’t take kindly to outsiders that don’t follow their customs.

+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/errors/409.html b/ruqqus/templates/errors/409.html new file mode 100644 index 000000000..3bf5c29fd --- /dev/null +++ b/ruqqus/templates/errors/409.html @@ -0,0 +1,20 @@ +{% extends "errors/default.html" %} + +{% block title %} +409 Conflict +{% endblock %} + +{% block pagetype %}error-409{% endblock %} + +{% block content %} +
+
+
+ +

409 Conflict

+

There were no survivors.

+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/errors/410.html b/ruqqus/templates/errors/410.html new file mode 100644 index 000000000..cf33264cc --- /dev/null +++ b/ruqqus/templates/errors/410.html @@ -0,0 +1,20 @@ +{% extends "errors/default.html" %} + +{% block title %} +410 Gone +{% endblock %} + +{% block pagetype %}error-410{% endblock %} + +{% block content %} +
+
+
+ +

410 Gone

+

There's nothing left here but a giant smouldering crater.

+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/errors/413.html b/ruqqus/templates/errors/413.html new file mode 100644 index 000000000..a11ac1373 --- /dev/null +++ b/ruqqus/templates/errors/413.html @@ -0,0 +1,20 @@ +{% extends "errors/default.html" %} + +{% block title %} +413 Request Entity Too Large +{% endblock %} + +{% block pagetype %}error-413{% endblock %} + +{% block content %} +
+
+
+ +

413 Image Size Too Large

+

There's a 1 MB limit to profile picture uploads, and a 16 MB limit to all other image uploads.

+ +
+
+
+{% endblock %} diff --git a/ruqqus/templates/errors/418.html b/ruqqus/templates/errors/418.html new file mode 100644 index 000000000..de958a39f --- /dev/null +++ b/ruqqus/templates/errors/418.html @@ -0,0 +1,20 @@ +{% extends "errors/default.html" %} + +{% block title %} +418 I'm A Teapot +{% endblock %} + +{% block pagetype %}error-418{% endblock %} + +{% block content %} +
+
+
+ +

418 I'm A Teapot

+

Sorry, I've been cursed and can't make you coffee right now.

+ +
+
+
+{% endblock %} diff --git a/ruqqus/templates/errors/422.html b/ruqqus/templates/errors/422.html new file mode 100644 index 000000000..29089e64a --- /dev/null +++ b/ruqqus/templates/errors/422.html @@ -0,0 +1,20 @@ +{% extends "errors/default.html" %} + +{% block title %} +422 Unprocessable Entity +{% endblock %} + +{% block pagetype %}error-422{% endblock %} + +{% block content %} +
+
+
+ +

422 Unprocessable Entity

+

The server thinks you're asking something silly. It might take you more seriously if you take off the jester outfit.

+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/errors/429.html b/ruqqus/templates/errors/429.html new file mode 100644 index 000000000..0e54ebe3e --- /dev/null +++ b/ruqqus/templates/errors/429.html @@ -0,0 +1,19 @@ +{% extends "errors/default.html" %} + +{% block title %} +429 Too Many Requests +{% endblock %} + +{% block pagetype %}error-429{% endblock %} + +{% block content %} +
+
+
+ +

429 Too Many Requests

+

Slow down there, friend! The royal guard doesn't like it when you go that fast!

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/errors/451.html b/ruqqus/templates/errors/451.html new file mode 100644 index 000000000..0a26a5884 --- /dev/null +++ b/ruqqus/templates/errors/451.html @@ -0,0 +1,20 @@ +{% extends "errors/default.html" %} + +{% block title %} +451 Unavailable For Legal Reasons +{% endblock %} + +{% block pagetype %}error-451{% endblock %} + +{% block content %} +
+
+
+ +

451 Unavailable For Legal Reasons

+ +

By royal decree, we are unable to show this to you right now.

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/errors/500.html b/ruqqus/templates/errors/500.html new file mode 100644 index 000000000..0754f9523 --- /dev/null +++ b/ruqqus/templates/errors/500.html @@ -0,0 +1,21 @@ +{% extends "errors/default.html" %} + +{% block title %} +500 Internal Server Error +{% endblock %} + +{% block pagetype %}error-500{% endblock %} + +{% block content %} +
+
+
+ +

500 Internal Server Error

+

Something has broken. A master dwarven computersmith has been dispatched to un-break it.

+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/errors/502.html b/ruqqus/templates/errors/502.html new file mode 100644 index 000000000..de411fd48 --- /dev/null +++ b/ruqqus/templates/errors/502.html @@ -0,0 +1,74 @@ + + + + + +502 Bad Gateway + + + + + + + + + + + + + + + + + + + +{% if v %} + + {% if v.agendaposter %}{% elif v.css %}{% endif %} +{% else %} + +{% endif %} + + + + + + + + +
+
+
+
+
+
+ +

502 Bad Gateway

+

Don't go this way, stranger. The gate is closed, and the guards are saying:

+

"We're restarting the server right now, try again in a few moments."

+

Reload

+
+
+
+
+ +
+
+ + + + + + + + + \ No newline at end of file diff --git a/ruqqus/templates/errors/503.html b/ruqqus/templates/errors/503.html new file mode 100644 index 000000000..37d279e6f --- /dev/null +++ b/ruqqus/templates/errors/503.html @@ -0,0 +1,130 @@ + + + + + + + + + + + +503 Service Unavailable + + + + + + + + + + + + + + + {% if v %} + + {% if v.agendaposter %}{% elif v.css %}{% endif %} + {% else %} + + {% endif %} + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+ + + + +

503 Service Unavailable

+

Drama is unavailable.

+

The server you connected to is most likely being restarted right now. Try again in a few moments.

+ +
+
+
+ + + + + +
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruqqus/templates/errors/default.html b/ruqqus/templates/errors/default.html new file mode 100644 index 000000000..d376c5d85 --- /dev/null +++ b/ruqqus/templates/errors/default.html @@ -0,0 +1,6 @@ +{% extends "default.html" %} + +{% block sidebarLeftblock %}{% endblock %} +{% block sidebar %}{% endblock %} + +{% block customPadding %}{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/errors/nsfw.html b/ruqqus/templates/errors/nsfw.html new file mode 100644 index 000000000..22b4ff108 --- /dev/null +++ b/ruqqus/templates/errors/nsfw.html @@ -0,0 +1,38 @@ +{% extends "errors/default.html" %} + +{% block title %} ++18 +{% endblock %} + +{% block pagetype %}error-451{% endblock %} + +{% block content %} +
+
+
+ +

Are you over 18?

+

This post is rated +18 (Pornographic Content). You must be 18 or older to continue. Are you sure you want to proceed?

+
+ + {% if v %} +
+ + + +
+ {% else %} +
+ + + + +
+ {% endif %} + +
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/expanded_image_modal.html b/ruqqus/templates/expanded_image_modal.html new file mode 100644 index 000000000..f60eb617d --- /dev/null +++ b/ruqqus/templates/expanded_image_modal.html @@ -0,0 +1,20 @@ + + + diff --git a/ruqqus/templates/flag_comment_modal.html b/ruqqus/templates/flag_comment_modal.html new file mode 100644 index 000000000..fb54db159 --- /dev/null +++ b/ruqqus/templates/flag_comment_modal.html @@ -0,0 +1,45 @@ + + diff --git a/ruqqus/templates/flag_post_modal.html b/ruqqus/templates/flag_post_modal.html new file mode 100644 index 000000000..b34da3a90 --- /dev/null +++ b/ruqqus/templates/flag_post_modal.html @@ -0,0 +1,47 @@ + + diff --git a/ruqqus/templates/followers.html b/ruqqus/templates/followers.html new file mode 100644 index 000000000..4c0c5bc34 --- /dev/null +++ b/ruqqus/templates/followers.html @@ -0,0 +1,21 @@ +{% extends "default.html" %} +{% block content %} +

+
@{{u.username}}'s followers
+

+
+
+	
+		
+		
+	
+
+{% for user in users %}
+	
+			
+				
+	
+{% endfor %}
+
#Name
{{users.index(user)+1}}{{user.username}}
+ +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/follows.html b/ruqqus/templates/follows.html new file mode 100644 index 000000000..e64e4ffed --- /dev/null +++ b/ruqqus/templates/follows.html @@ -0,0 +1,37 @@ +{% extends "home.html" %} + +{% block sidebarLeftblock %} + + + +{% endblock %} + +{% block sidebarblock %} + +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/forgot_password.html b/ruqqus/templates/forgot_password.html new file mode 100644 index 000000000..b98dfadad --- /dev/null +++ b/ruqqus/templates/forgot_password.html @@ -0,0 +1,31 @@ +{% extends "authforms.html" %} + +{% block pagetitle %}Drama Password Reset{% endblock %} + +{% block authtitle %}Reset your password.{% endblock %} + +{% block authtext %}If there's an email address associated with your account, you can use it to recover your Drama account and change your password.{% endblock %} + +{% block content %} + +
+ +
+ + + + + + + + + + + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/formatting.html b/ruqqus/templates/formatting.html new file mode 100644 index 000000000..a41f15c5f --- /dev/null +++ b/ruqqus/templates/formatting.html @@ -0,0 +1,151 @@ +{% extends "default.html" %} +{% block title %} +Drama - Formatting + +{% endblock %} + +{% block content %} +
+
+
+
+{% filter markdown %} +# Formatting + +On Drama, you can use Markdown formatting. + + +## Inline formatting + +{% endfilter %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDisplays as
Italics*text*text
Bold**text**text
Code`text`text
Strikethrough~~text~~text
Links[Drama](https://rdrama.net)Drama
Emojis:marseylove:
+ +

* We have customized image embeds to add pop-overs and operate on a whitelist system. Embeds from known safe image hosts will use a pop-up display as demonstrated here. Attempted embeds to non-whitelisted sites will be converted into a link. The whitelist may be viewed here.

+ +{% filter markdown %} + +## Block formatting + +These Markdown tags format an entire paragraph of text at a time. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+Use three backticks above and below.
+Or, indent the lines with four spaces.
+
+ + +
NameTypeDisplays as
Blockquote> text
text
Headers 1-6# Header 1
## Header 2
### Header 3
#### Header 4
##### Header 5
###### Header 6

Header 1

Header 2

Header 3

Header 4

Header 5
Header 6
Ordered list1. First thing
2. Second thing
  1. First thing
  2. Second thing
Unordered list* First thing
* Second thing
  • First thing
  • Second thing
Code Block```
Use three backticks above and below.
Or, indent the lines with four spaces.
```
+
Spoilers<s> bussy > gussy </s>

bussy > gussy

+ + +## Custom Formatting + +We also have some custom hooks for mentioning users and subreddits. Note that these only work if the mentioned user or subreddit actually exists. + +{% endfilter %} + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDisplays as
Username Mention@QuadNarca@QuadNarca
Subreddit Mentionr/Dramar/Drama
Redditor Mentionu/Bardfinnu/Bardfinn
+ +{% include "expanded_image_modal.html" %} + + + +{% endblock %} diff --git a/ruqqus/templates/gif_modal.html b/ruqqus/templates/gif_modal.html new file mode 100644 index 000000000..5b9dae222 --- /dev/null +++ b/ruqqus/templates/gif_modal.html @@ -0,0 +1,143 @@ + + diff --git a/ruqqus/templates/gm_exile_modal.html b/ruqqus/templates/gm_exile_modal.html new file mode 100644 index 000000000..032048c93 --- /dev/null +++ b/ruqqus/templates/gm_exile_modal.html @@ -0,0 +1,25 @@ + + \ No newline at end of file diff --git a/ruqqus/templates/gm_invitation_modal.html b/ruqqus/templates/gm_invitation_modal.html new file mode 100644 index 000000000..86c8513d7 --- /dev/null +++ b/ruqqus/templates/gm_invitation_modal.html @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/ruqqus/templates/guild_details_modal.html b/ruqqus/templates/guild_details_modal.html new file mode 100644 index 000000000..6819e47e7 --- /dev/null +++ b/ruqqus/templates/guild_details_modal.html @@ -0,0 +1,89 @@ +{% set board = b if b else p.board %} + + \ No newline at end of file diff --git a/ruqqus/templates/guild_settings.html b/ruqqus/templates/guild_settings.html new file mode 100644 index 000000000..f84a463b5 --- /dev/null +++ b/ruqqus/templates/guild_settings.html @@ -0,0 +1,215 @@ + + + + + + + + + + + + {% block pagetitle %}Drama{% endblock %} + + + + + + + + + {% if v %} + + {% else %} + + {% endif %} + + + + + + + + + + + {% include "header.html" %} + + + + {% block subHeader %} +
+
+
+
+
+
+ +
+
+
+
+
+
+ {% endblock %} + + + {% block subNav %} + +{% if True %} + {% set mod = (v.admin_level==6) %} + +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ {% endif %} + {% endblock %} + + +
+ + {% block content %} + {% endblock %} + +
+ + + + + + + + + + +{% block mobilenavbar %} +{% include "mobile_navigation_bar.html" %} +{% endblock %} + +{% block invitationModal %} +{% endblock %} + +{% block exileModal %} +{% endblock %} + +{% block approveModal %} +{% endblock %} + +{% block errorToasts %} +{% endblock %} + +{% include "bootstrap.html" %} + + + + + + + + + + + {% block scripts %} + {% endblock %} + + + + \ No newline at end of file diff --git a/ruqqus/templates/header.html b/ruqqus/templates/header.html new file mode 100644 index 000000000..dcdb72002 --- /dev/null +++ b/ruqqus/templates/header.html @@ -0,0 +1,187 @@ + + + \ No newline at end of file diff --git a/ruqqus/templates/home.html b/ruqqus/templates/home.html new file mode 100644 index 000000000..753b91146 --- /dev/null +++ b/ruqqus/templates/home.html @@ -0,0 +1,136 @@ +{% extends "default.html" %} + +{% block desktopBanner %} + +
+
+
+ + {% block navbar %} +
+ +
+
+ + +
+ +
+ {% endblock %} +
+
+
+ +{% endblock %} + +{% block PseudoSubmitForm %} + + +
+ +
+
+
+
Create post
+
    +
  • +
  • +
+
+
+ {% if v %} + + + + {% else %} + + + + {% endif %} +
+
+
+ +
+{% endblock %} + +{% block content %} + +
+ +
+ +
+ + {% include "submission_listing.html" %} + +
+
+
+ +{% endblock %} + +{% block pagenav %} +{% if listing %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/home_comments.html b/ruqqus/templates/home_comments.html new file mode 100644 index 000000000..9ddf401fe --- /dev/null +++ b/ruqqus/templates/home_comments.html @@ -0,0 +1,90 @@ +{% extends "default.html" %} + +{% block PseudoSubmitForm %}{% endblock %} +{% block sortnav %}{% endblock %} + +{% block content %} + + +

+	
+ +
+
+ + +
+ +
+
+ + +
+ +
+ +
+ + {% include "comments.html" %} + +
+
+
+ + +{% endblock %} + + +{% block pagenav %} + +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/index.html b/ruqqus/templates/index.html new file mode 100644 index 000000000..d1c724572 --- /dev/null +++ b/ruqqus/templates/index.html @@ -0,0 +1,8 @@ + +
+

{{ greeting }}

+

[[ greeting ]]

+
+ + + \ No newline at end of file diff --git a/ruqqus/templates/leaderboard.html b/ruqqus/templates/leaderboard.html new file mode 100644 index 000000000..5f0909270 --- /dev/null +++ b/ruqqus/templates/leaderboard.html @@ -0,0 +1,50 @@ +{% extends "guild_settings.html" %} + +{% block pagetitle %}Leaderboard{% endblock %} + +{% block content %} +

+
Top 25 dramatards by dramacoins
+

+
+
+	
+		
+		
+		
+	
+
+{% for user in users1 %}
+	
+		
+		
+		
+	
+{% endfor %}
+
#NameCoins
{{users1.index(user)+1}}{{user.username}}{{user.dramacoins}}
+
+
+
+
+
Top 10 dramatards by followers
+
+
+
+
+ + + + + + + + +{% for user in users2 %} + + + + + +{% endfor %} +
#NameFollowers
{{users2.index(user)+1}}{{user.username}}{{user.follower_count}}
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/login.html b/ruqqus/templates/login.html new file mode 100644 index 000000000..12e12be67 --- /dev/null +++ b/ruqqus/templates/login.html @@ -0,0 +1,136 @@ + + + + + + + + + + + {% block title %} + Login - Drama + {% endblock %} + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ +
+ +
+ Drama +
+ + {% block content %} + +
+ +

Welcome back.

+ +

Glad to have you back!

+ + {% if failed %} + + {% endif %} + +
+ + + + + + + + + + + + Forgot password? + + + +
+ Don't have an account? +
+ +
+ +
+ + {% endblock %} + +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ +
+
+ +{% include "bootstrap.html" %} + + + + + + diff --git a/ruqqus/templates/login_2fa.html b/ruqqus/templates/login_2fa.html new file mode 100644 index 000000000..cfe58086c --- /dev/null +++ b/ruqqus/templates/login_2fa.html @@ -0,0 +1,128 @@ + + + + + + + + + + + 2-Step Login - Drama + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ +
+ +
+ ruqqus +
+ +
+ +

Two-step login

+ +

To login, please enter the 6-digit verification code generated in your authenticator app.

+ + {% if failed %} + + {% endif %} + +
+ + + + + + + + + + + + Lost your 2FA device? + + + +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ +
+
+ +{% include "bootstrap.html" %} + + + + + + + + + + + + diff --git a/ruqqus/templates/lost_2fa.html b/ruqqus/templates/lost_2fa.html new file mode 100644 index 000000000..76e066de8 --- /dev/null +++ b/ruqqus/templates/lost_2fa.html @@ -0,0 +1,36 @@ +{% extends "authforms.html" %} + +{% block pagetitle %}Drama Two-Factor Removal{% endblock %} + +{% block authtitle %}Remove the two-factor authentication from your account.{% endblock %} + +{% block authtext %}If all information is correct, you will be able to remove 2-factor authentication from your account in 24 hours.{% endblock %} + +{% block content %} + +
+ +
+ + + + + + + + + + + + + + + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/message.html b/ruqqus/templates/message.html new file mode 100644 index 000000000..13bc84ae3 --- /dev/null +++ b/ruqqus/templates/message.html @@ -0,0 +1,33 @@ +{% extends "default.html" %} + +{% block title %} +{{title}} +{% endblock %} + +{% block sidebar %}{% endblock %} + +{% block pagetype %}message{% endblock %} + +{% block customPadding %}{% endblock %} + +{% block content %} +
+
+
+ + + + + + +

{{title}}

+
{{message if message else error}}
+ {% if link and link_text %} + {{link_text}} + {% else %} + Go to homepage + {% endif %} +
+
+
+{% endblock %} diff --git a/ruqqus/templates/message_success.html b/ruqqus/templates/message_success.html new file mode 100644 index 000000000..29f2b4cef --- /dev/null +++ b/ruqqus/templates/message_success.html @@ -0,0 +1,17 @@ +{% extends "default.html" %} + +{% block title %} +{{title}} +{% endblock %} + +{% block pagetype %}message-success{% endblock %} + +{% block content %} +
+ + success state +
{{title}}
+

{{text}}

+ +
+{% endblock %} diff --git a/ruqqus/templates/mine/mine.html b/ruqqus/templates/mine/mine.html new file mode 100644 index 000000000..be56a3640 --- /dev/null +++ b/ruqqus/templates/mine/mine.html @@ -0,0 +1,44 @@ +{% extends "home.html" %} + +{% block enlargeThumbJS %} + +{% endblock %} + +{% block PseudoSubmitForm %}{% endblock %} + + + +{% block customPadding %}{% endblock %} + +{% block content %} + + +
+ + {% block maincontent %} + {% endblock %} + +
+ +{% endblock %} + +{% block pagenav %} + +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/mine/users.html b/ruqqus/templates/mine/users.html new file mode 100644 index 000000000..be2263181 --- /dev/null +++ b/ruqqus/templates/mine/users.html @@ -0,0 +1,16 @@ +{% extends "mine/mine.html" %} + +{% block maincontent %} +{% include "user_listing.html" %} +{% endblock %} + +{% block sidebarblock %} + +{% endblock %} + +{% block sidebar %}{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/mobile_navigation_bar.html b/ruqqus/templates/mobile_navigation_bar.html new file mode 100644 index 000000000..f934f1184 --- /dev/null +++ b/ruqqus/templates/mobile_navigation_bar.html @@ -0,0 +1,69 @@ +
+ +
+ + +{% if v and v.defaultsorting == 'new' %} + +{% else %} + +{% endif %} + + +{% if v %} + +{% else %} + +{% endif %} +
+
\ No newline at end of file diff --git a/ruqqus/templates/modlog.html b/ruqqus/templates/modlog.html new file mode 100644 index 000000000..98aed8208 --- /dev/null +++ b/ruqqus/templates/modlog.html @@ -0,0 +1,86 @@ +{% extends "guild_settings.html" %} + +{% block pagetitle %}Moderation Log{% endblock %} + +{% block stylesheets %} + +{% if v %} + + {% if v.agendaposter %}{% elif v.css %}{% endif %} +{% else %} + +{% endif %} + +{% endblock %} + +{% block content %} +
+ +
+ +
+ +
+
Moderation Log
+
+ +
+ +
+ {% for ma in actions %} + +
+ +
+
+
+ + avatar + +
+
+ {% if not ma.user.is_deleted %} + @{{ma.user.username}} + {% else %} + [deleted user] + {% endif %} + + {{ma.string | safe}} +
+ +
{{ma.age_string}}
+ +
+
+
+
+ + + {% else %} +
There's nothing here right now.
+ {% endfor %} +
+ + + + +
+
+{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/mods.html b/ruqqus/templates/mods.html new file mode 100644 index 000000000..c84e8d0fd --- /dev/null +++ b/ruqqus/templates/mods.html @@ -0,0 +1,303 @@ +{% extends "guild_settings.html" %} + +{% block pagetitle %}Badmins{% endblock %} + +{% block scripts %} + + + {% if b.has_invite(v) %} + + {% endif %} + + {% endblock %} + + + + {% block content %} + +
+ +
+ + {% if request.args.get('error') or error %} + + {% endif %} + + {% if request.args.get('msg') or msg %} + + {% endif %} + +
+ +
+ +
+ +
+ +
+

Badmins

+
+ +
+ {% if me %} + Resign + {% endif %} + {% if me and me.perm_full %} + Add badmin + {% endif %} +
+ +
+ +
+ + + + + + + + + + + {% for mod in b.mods_list %} + + + + + + + {% else %} + + {% endfor %} + + +
UserBadmin Since
+ + @{{mod.user.username}} + {{mod.created_date}} + {% if me and me.id < mod.id %} + + {% endif %} + + +
There are no badmins.
+
+ + + +

Pending

+ +

These users have been invited to be badmins.

+ +
+ + + + + + + + + + + {% for m in b.mod_invites %} + + + + + + {% else %} + + {% endfor %} + + +
UserInvited On
+ + @{{m.user.username}} + {{m.created_date}} + +
There are no badmin invitations.
+
+ + +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + +{% endblock %} + +{% block errorToasts %} + + + +{% endblock %} + +{% block invitationModal %} +{% if b.has_invite(v) %} +{% include "gm_invitation_modal.html" %} +{% endif %} +{% endblock %} diff --git a/ruqqus/templates/notifications.html b/ruqqus/templates/notifications.html new file mode 100644 index 000000000..df10d089e --- /dev/null +++ b/ruqqus/templates/notifications.html @@ -0,0 +1,124 @@ +{% extends "default.html" %} + +{% block fixedMobileBarJS %}{% endblock %} + +{% block pagetype %}{% endblock %} + +{% block PseudoSubmitForm %}{% endblock %} + +{% block navbar %} +
+{% endblock %} + +{% block gifKeyboard %} + +{% endblock %} + +{% block content %} + + + +
+ +

Inbox

+ + {% with comments=notifications %} + + {% include "comments.html" %} + {% endwith %} + + {% if not notifications %} +
+ + + + + + +
No unread messages
+
When someone comments or replies, it will show up here.
+ View entire inbox +
+ + {% endif %} + +
+ +{% endblock %} + +{% block pagenav %} +{% if notifications %} + +{% endif %} +{% endblock %} + +{% block GIFpicker %} +{% if v %} +{% include "gif_modal.html" %} +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/oauth.html b/ruqqus/templates/oauth.html new file mode 100644 index 000000000..be5383ead --- /dev/null +++ b/ruqqus/templates/oauth.html @@ -0,0 +1,43 @@ +{% extends "login.html" %} + +{% block title %} +Application Request for Access +{% endblock %} + +{% block content %} + +
+ +

{{application.app_name}}

+ +

wants to access your @{{v.username}} account.

+

It will be able to:

+
    + {% for scope in scopes %} +
  • {{SCOPES[scope]}}
  • + {% endfor %} + {% if permanent %} +
  • Maintain this access indefinitely, or until you revoke it
  • + {% endif %} +
+ +

It will not be able to see your password, or change your account settings.

+ +
+ + + + + + + + + + + No, back to Drama + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/oauthhelp.html b/ruqqus/templates/oauthhelp.html new file mode 100644 index 000000000..a0b76c976 --- /dev/null +++ b/ruqqus/templates/oauthhelp.html @@ -0,0 +1,117 @@ +{% extends "default.html" %} + +{% block title %} +Drama - OAuth2 + +{% endblock %} + +{% block content %} +{% filter markdown %} +# OAuth2 + +The OAuth2 authorization flow is used to enable users to authorize third-party applications to access their Drama account without having to provide their login information to the application. + +This page explains how to obtain API application keys, how to prompt a user for authorization, and how to obtain and use access tokens. + +## Step 1: Create your Application + +In the [apps tab of Drama settings](/settings/apps), fill in and submit the form to request new API keys. You will need: + +* an application name +* a Redirect URI, or a comma-separated list of redirect URIs. May not use HTTP unless using localhost (use HTTPS instead). +* a brief description of what your application is intended to do + +Don't worry too much about accuracy; you will be able to change all of these later. + +Drama administrators will review and approve or deny your request for API keys. You'll know when your request has been approved when a client ID and secret appear in your application information. + +DO NOT reveal your Client Secret. Anyone with your Client Secret will be able to pretend to be you. You are responsible for keeping your Client Secret a secret! + +## Step 2: Prompt Your User for Authorization + +Send your user to `https://rdrama.net/oauth/authorize`, with the following URL parameters: + +* `client_id` - Your application's Client ID +* `redirect_uri` - The redirect URI (or one of the URIs) specified in your application information. Must not use HTTP unless using localhost (use HTTPS instead). +* `state` - This is your own anti-cross-site-forgery token. We don't do anything with this, except give it to the user to pass back to you later. You are responsible for generating and validating the state token. +* `scope` - A comma-separated list of permission scopes that you are requesting. Valid scopes are: `identity`, `create`, `read`, `update`, `delete`, `vote`, and `guildmaster`. +* `permanent` - optional. Set to `true` if you are requesting indefinite access. Omit if not. + +If done correctly, the user will see that your application wants to access their Drama account, and be prompted to approve or deny the request. + +## Step 3: Catch the redirect + +The user clicks "Authorize". Drama will redirect the user's browser to GET the designated redirect URI. The following URL parameters will be included, which your server should process: + +* `code` - a **single-use** authorization code. +* `state` - The state token from earlier. + +Validate the state token. How you do this is up to you. + +## Step 4: Exchange code for access token + +Make a POST request to `https://rdrama.net/oauth/grant`. Include the following form parameters: + +* `client_id` - Your application's Client ID +* `client_secret` - Your application's Client Secret +* `grant_type` - Set to the word "code" +* `code` - The `code` parameter that was given to you in the previous step. + +Python example: + +
+	import requests
+	import pprint
+
+	code=request.args.get("code")
+
+	headers={"User-Agent": "Porpl Reader v1 by @captainmeta4"}
+	url="/oauth/grant"
+	data={"client_id": my_client_id,
+			"client_secret": my_client_secret,
+			"grant_type": "code",
+			"code": code
+			}
+
+	r=requests.post(url, headers=headers, data=data)
+
+	pprint.pprint(r.json())
+
+ +If everything is good, we will respond with the following (example) JSON body: + +
+	{
+	"access_token": 		#Access token
+	"scopes": 				#Comma-separated list of scopes included in authorization
+	"expires_at": 			#Unix epoch integer time at which access token expires
+	"token_type": "Bearer"
+	"refresh_token":		#This key is omitted in temporary authorizations
+	}
+
+ +Store the access and refresh tokens. You should also store expiration timestamp and the scopes list, so that you pre-emptively avoid sending requests to Drama that won't be accepted. + +## Step 5: Using the Access Token + +To use the access token, include the following header in subsequent API requests to Drama: `Authorization: Bearer access_token_goes_here` + +Python example, presuming that the application has obtained a valid `read` authorization: + +
+	import requests
+	import pprint
+
+	headers={"Authorization": "Bearer " + access_token,
+			 "User-Agent": "Drama Reader v1 by @carpathianflorist"}
+	url="/api/v1/front/listing"
+
+	r=requests.get(url, headers=headers)
+
+	pprint.pprint(r.json())
+
+ +The expected result of this would be a large JSON representation of the submissions that make up the user's personal front page. + +{% endfilter %} +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/reset_password.html b/ruqqus/templates/reset_password.html new file mode 100644 index 000000000..02c5e27da --- /dev/null +++ b/ruqqus/templates/reset_password.html @@ -0,0 +1,33 @@ +{% extends "authforms.html" %} + +{% block pagetitle %}Drama Password Reset{% endblock %} + +{% block authtitle %}Change your password.{% endblock %} + +{% block content %} + +
+ +
+ + + + + + + + + + + + + + + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/search.html b/ruqqus/templates/search.html new file mode 100644 index 000000000..1473be385 --- /dev/null +++ b/ruqqus/templates/search.html @@ -0,0 +1,239 @@ +{% extends "home.html" %} + +{% block pagetype %}search{% endblock %} + +{% block title %} +Search for "{{query}}" - Drama" + +{% endblock %} + +{% block enlargeThumbJS %} + +{% endblock %} + +{% block toggleView %} + +{% endblock %} + +{% block PseudoSubmitForm %}{% endblock %} + +{% block navbar %} + {% if not '/users/' in request.path %} +
+ + + +
+ {% endif %} +{% endblock %} + +{% block content %} + + +
+ +
+ +
+ +
+
    +
  • +
  • +
+
+
+
+
Advanced search parameters (with examples): "author:quadnarca", "domain:reddit.com", "over18:true"
+
+
Showing {% block listinglength %}{{listing | length}}{% endblock %} of {{total}} result{{'s' if total != 1 else ''}} for
+

{{query}}

+ +
+
+
+
+ +
+ + + +{% if not '/users/' in request.path %} + +
+ + + +
+ {% endif %} + +
+
+
+ +
+
+
+ +
+ +
+ +
+ + {% block listing_template %}{% include "submission_listing.html" %}{% endblock %} + +
+
+
+ + {% if not '/users/' in request.path %} +
+ +
+ +
+ +
+ +
+ + + + + +
+ +
+ + + +
+ +
+ +
+ {% endif %} + {% endblock %} + + {% block pagenav %} + {% if listing %} + + {% endif %} + {% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/search_comments.html b/ruqqus/templates/search_comments.html new file mode 100644 index 000000000..9ef954a28 --- /dev/null +++ b/ruqqus/templates/search_comments.html @@ -0,0 +1,30 @@ +{% extends "search.html" %} + +{% block listing_template %} +
+ {% with comments=comments %} + {% include "comments.html" %} + {% endwith %} +
+{% endblock %} + +{% block listinglength %} +{{comments | length}} +{% endblock %} + +{% block pagenav %} + {% if comments %} + + {% endif %} +{% endblock %} diff --git a/ruqqus/templates/search_users.html b/ruqqus/templates/search_users.html new file mode 100644 index 000000000..01f8f7d35 --- /dev/null +++ b/ruqqus/templates/search_users.html @@ -0,0 +1,26 @@ +{% extends "search.html" %} + +{% block listing_template %} +
+{% include "user_listing.html" %} +
+{% endblock %} + +{% block listinglength %}{{users | length}}{% endblock %} + +{% block pagenav %} + {% if boards %} + + {% endif %} +{% endblock %} diff --git a/ruqqus/templates/seized.html b/ruqqus/templates/seized.html new file mode 100644 index 000000000..797cc6846 --- /dev/null +++ b/ruqqus/templates/seized.html @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/ruqqus/templates/settings.html b/ruqqus/templates/settings.html new file mode 100644 index 000000000..4e19aecba --- /dev/null +++ b/ruqqus/templates/settings.html @@ -0,0 +1,227 @@ + + + + + + + + + + + + + {% block pagetitle %}Settings - Drama{% endblock %} + + + + + + + + + + + + + + + + + + + + + + + + + {% if v.agendaposter %}{% elif v.css %}{% endif %} + + + + + + + + + + {% include "header.html" %} + +
+ +
+ +
+ + {% if error %} + + {% endif %} + {% if msg %} + + {% endif %} + +
+ +

Settings

+ +

Settings

+ +
+ + + + + +
+ + + +
+ + + +
+ + + +
+ + {% block content %} + {% endblock %} + +
+
+ +
+ + {% if v %} + {% include "2fa_modal.html" %} + {% endif %} + + + +{% block clipboard %} + + + +{% endblock %} + +{% include "bootstrap.html" %} + + + + + + + + + + + + + + + + +{% block onload %}{% endblock %} + + + + \ No newline at end of file diff --git a/ruqqus/templates/settings_apps.html b/ruqqus/templates/settings_apps.html new file mode 100644 index 000000000..8508212b1 --- /dev/null +++ b/ruqqus/templates/settings_apps.html @@ -0,0 +1,172 @@ +{% extends "settings.html" %} + +{% block title %} +Drama - FAQ + +{% endblock %} + +{% block content %} +
+
+
+ + +

OAuth Guide

+ +

+
+

Your API Applications

+ +{% for app in v.applications if app.client_secret %} + +
+
+
+
+ +
+
+ + + + + {% if app.client_secret %} + + + + Click to show + + {% endif %} + + + + + + +
+
+ +
+ +
+{% else %} +

None

+{% endfor %} + +

API Applications Awaiting Approval

+ +{% for app in v.applications if not app.client_secret %} + +
+
+
+
+ +
+
+ + + + + {% if app.client_secret %} + + + + Click to show + + {% endif %} + + + + + + +
+
+ +
+ +
+{% else %} +

None

+{% endfor %} + +

Your Authorized Applications

+ +{% for auth in v.authorizations.all() %} + +
+
+
+ +
+
+ + + + + +
+
+ +
+{% else %} +

None

+{% endfor %} + +

Request API Keys

+ +
+
+
+
+ + + + + + + + + +
+
+ +
+
+
+
+
+{% endblock %} + + +{% block clipboard %} + + + +{% endblock %} diff --git a/ruqqus/templates/settings_blocks.html b/ruqqus/templates/settings_blocks.html new file mode 100644 index 000000000..248837112 --- /dev/null +++ b/ruqqus/templates/settings_blocks.html @@ -0,0 +1,124 @@ +{% extends "settings.html" %} + +{% block pagetitle %}Block Settings - Drama{% endblock %} + +{% block content %} + +
+ +
+ + {% if error %} + + {% endif %} + +
+ +
+ + +
+ +
+ +
+ +
+

Users you block

+

You have blocked the following users. They cannot reply to your content or notify you with a username mention.

+
+
+ +
+
+ + + + {% if v.blocking.first() %} +
+ + + + + + + + + + {% for block in v.blocking %} + + + + + + + + {% else %} + + {% endfor %} + +
UserBlocked since
+ + @{{block.target.username}} + {{block.created_date}} + +
There are no blocked users
+
+ {% else %} +
+ +

No blocked users

+
+ {% endif %} + +
+ +
+ + + + + + +{% endblock %} diff --git a/ruqqus/templates/settings_css.html b/ruqqus/templates/settings_css.html new file mode 100644 index 000000000..5e4614525 --- /dev/null +++ b/ruqqus/templates/settings_css.html @@ -0,0 +1,51 @@ +{% extends "settings.html" %} + +{% block pagetitle %}Custom CSS - Drama{% endblock %} + +{% block content %} + +
+ +
+ +
+ +
+ +

Edit your custom CSS for the site.

+ +
+ +
+
+
+ + + Limit of 50000 characters +
+ +
+
+
+ +
+ +
+ +
+ +
+
+{% if v.agendaposter %} + +{% endif %} +
+ +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/settings_filters.html b/ruqqus/templates/settings_filters.html new file mode 100644 index 000000000..766c8449d --- /dev/null +++ b/ruqqus/templates/settings_filters.html @@ -0,0 +1,253 @@ +{% extends "settings.html" %} + +{% block pagetitle %}Profile Settings - Drama{% endblock %} + +{% block content %} + + +
+ +
+ +
+ +

Default Sorting and Time Filter

+ +
+ +
+
+ +
+ +
+

Change the default sorting for comments.

+
+ +
+ +
+ +
+ +
+
+ +
+ +
+

Change the default sorting for posts.

+
+ +
+ +
+ +
+ +
+
+ +
+ +
+

Change the default time filter for posts.

+
+ +
+ +
+ +
+ +
+ +

Tab Behaviour

+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ + Enable if you would like to automatically open threads in new tabs. + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ + +
+ + Enable if you would like to automatically open external links in new tabs. + +
+ +
+ + + +
+ + +

Reddit Links

+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ + Enable if you would like to automatically convert reddit.com links to old.reddit.com links. + +
+ +
+ +
+ +

Content Filters

+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ + Enable if you would like to not get a warning before viewing +18 content. + +
+ +
+ + + +
+ +
+ +
+ +
+ +
+ + +
+ + Enable if you would like to automatically replace slurs with funny r/drama equivalents. + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ + +
+ + Enable if you would like to automatically hide posts you have voted on from your frontpage. + +
+ +
+ +
+ +
+ +
+ +
+
+ + Hides matching content from Home, All, and Trending, and collapses matching comments. +
+ +
+
+ Use a new line for each filter entry. Limit of 1000 characters. + +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +{% endblock %} diff --git a/ruqqus/templates/settings_profile.html b/ruqqus/templates/settings_profile.html new file mode 100644 index 000000000..70bbf7d7a --- /dev/null +++ b/ruqqus/templates/settings_profile.html @@ -0,0 +1,494 @@ +{% extends "settings.html" %} + +{% block pagetitle %}Profile Settings - Drama{% endblock %} + +{% block content %} +{% include "emoji_modal.html" %} +{% include "gif_modal.html" %} + +
+ +
+ +
+ +

Theme

+ +
+ +
+
+ +
+ +
+

Change the theme for the website.

+
+ +
+ +
+ +
+ +
+ + + +
+ +
+ + + {% for themecolor in ['ff66ac','805ad5','62ca56','38a169','80ffff','2a96f3','eb4963','ff0000','f39731','30409f','3e98a7','e4432d','7b9ae4','ec72de','7f8fa6', 'f8db58'] %} + + + {% endfor %} + +
+ +
+ +
+ +
+ + +

Profile Picture

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + {% if v.can_upload_avatar %} + +
+ + +
+ + {% else %} + +
You can add a custom avatar after earning 30 Dramacoins.{% if request.headers.get("cf-ipcountry")=="T1" and not v.is_activated %} Additionally, you must have a verified email address to upload images via Tor.{% endif %}
+ + {% endif %} + +
+ + {% if v.has_profile %} +
+
+ + +
+
+ {% endif %} + +
+ + {% if v.can_upload_avatar %} + +
JPG, PNG, GIF files are supported. Max file size is 16 MB.
+ + {% endif %} + +
+ +
+ +
+

Profile Banner

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + {% if v.can_upload_banner %} + +
+ + +
+ + {% else %} + +
You can add a custom banner after earning 30 Dramacoins.{% if request.headers.get("cf-ipcountry")=="T1" and not v.is_activated %} Additionally, you must have a verified email address to upload images via Tor.{% endif %}
+ + {% endif %} + +
+ +
+ + {% if v.has_banner %} +
+ + +
{% endif %} + +
+ +
+ + {% if v.can_upload_banner %} + +
JPG, PNG, GIF files are supported. Max file size is 16 MB.
+ + {% endif %} + +
+ +
+ +
+ +

Referrals

+ +

Invite a friend.

+ +
+ +
+ +
+ +
+ +
+ +
+ + + + + + {{v.referral_count}} + + +
+ +
Share this link with a friend. {% if v.referral_count==0 %} When they sign up, you'll get the bronze recruitment badge. Learn more.{% elif v.referral_count<10 %} When you refer 10 friends, you'll receive the silver recruitment badge. Learn more.{% elif v.referral_count<100 %} When you refer 100 friends, you'll receive the gold recruitment badge. Learn more.{% endif %}
+ +
+ +
+ +
+ + +

Linked Accounts

+ +

Manage your connections to other services.

+ +
+ +
+ +
+ +
+ +
+ + {% if v.discord_id %} +
+ + +
+ +
Disconnecting your Discord account will remove you from the Drama Discord server.
+ {% else %} + Link Discord +
Link your Discord account to join the Drama Discord server.
+ {% endif %} + +
+ +
+ +
+ + +

RSS Feed

+ +

Subscribe to the Drama RSS feed.

+ +
+ +
+ +
+ + + +
You can change the feed by replacing "hot" with whatever sorting you want and "all" with whatever time filter you want.
+ +
+ +
+ +
+ +

Your Profile

+ +

Edit how others see you on Drama.

+ +
+ +
+ + + +
+

Your original username will always stay reserved for you: {{v.original_username}}

+ + +
+ + + 3-25 characters, including letters, numbers, _ , and - +
+ +
+
+
+ +
+ +
+ + + +
+

Must be a youtube video link.

+ +
+ + +
In some browsers, users have to click at least once anywhere in the profile page for the anthem to play. +
+ +
+
+
+ +
+ +
+ + + +
+ +
+ + + {% for color in ['ff66ac','805ad5','62ca56','38a169','80ffff','2a96f3','eb4963','ff0000','f39731','30409f','3e98a7','e4432d','7b9ae4','ec72de','7f8fa6', 'f8db58'] %} + + + {% endfor %} + +
+ +
+ +
+ + {% if not v.flairchanged %} +
+ + + +
+ +
+ + +
+ +     + Limit of 100 characters + +
+
+
+ +
+ {% endif %} + +
+ + + +
+ +
+ + + {% for titlecolor in ['ff66ac','805ad5','62ca56','38a169','80ffff','2a96f3','eb4963','ff0000','f39731','30409f','3e98a7','e4432d','7b9ae4','ec72de','7f8fa6', 'f8db58'] %} + + + {% endfor %} + +
+ +
+ +
+ +
+ + + +
+ + +
+ +
+ +
+ +
+ +   + +   + +   + +   + +   +
+ +

+										
+										
+ Limit of 1500 characters + +
+
+
+ +
+ +
+ + +
+

Profile badges show off all of your site achievements. If your badges look not quite up to date, use this tool to refresh them.

+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ + This will hide your post and comment history from others. We will also ask search engines to not index your profile page. (Your content will still be accessible via direct link.) + +
+ +
+
+ +
+ +
+ +
+ +
+ + +
+ + Prevent other users from following you. + +
+ +
+
+
+ +
+ +
+ + + + + + + +{% endblock %} diff --git a/ruqqus/templates/settings_profilecss.html b/ruqqus/templates/settings_profilecss.html new file mode 100644 index 000000000..a924b770f --- /dev/null +++ b/ruqqus/templates/settings_profilecss.html @@ -0,0 +1,41 @@ +{% extends "settings.html" %} + +{% block pagetitle %}Custom profilecss - Drama{% endblock %} + +{% block content %} + +
+ +
+ +
+ +
+ +

Edit your profile css.

+ +
+ +
+
+
+ + + Limit of 50000 characters +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/ruqqus/templates/settings_security.html b/ruqqus/templates/settings_security.html new file mode 100644 index 000000000..681d69bc6 --- /dev/null +++ b/ruqqus/templates/settings_security.html @@ -0,0 +1,302 @@ +{% extends "settings.html" %} + +{% block pagetitle %}Security Settings - Drama{% endblock %} + +{% block content %} + + + +
+ +
+ +
+ +

Password

+ +

Change your account password.

+ +
+ +
+ +
+ +
+ + + + + Minimum of 8 + characters + required. + Your password + meets the + requirements. + + +
+ +
+ + + + + + Passwords do not + match. + Passwords match. + + +
+ +
+ + + + + +
+ +
+ + + +
+ +
+ +

Email

+ +

Change the email address used to sign in to your account.

+ +
+ +
+ + +
+ +
+ + + +
+ + + {% if v.email and not v.is_activated %} +
Email not verified. You will not be able to recover your account with this email until you verify it. Verify now.
+ {% elif not v.email %} +
Add an email to secure your account in case you forget your password.
+ {% endif %} + +
+ +
+ +
+ + + +
+ + + + Password required to update your email. + +
+ +
+ + Password required to update your email. + +
+ + + +
+ +
+ + +

Two-Factor Authentication

+ +

Change the two-factor settings for your account.

+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ + This requires entering a randomly-generated, 6-digit code and your password to login. See Google Authenticator or Authy for more details. + +
+ +
+ +
+ +

Log Out Everywhere

+ +

Log all other devices out of your Drama account.

+ +
+ +
+ + +
+ +
+ + + +
+ + + +
+ +
+ + This will also invalidate any existing recovery links associated with this account. + +
+ + + +
+ +
+ +
+ +
+ +
+ + + + + + + +