Compare commits
169 Commits
reserved_n
...
package_ga
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2866589109 | ||
|
|
2a82c08d8b | ||
|
|
0a89849157 | ||
|
|
adaf44bc2b | ||
|
|
8b5d767d3c | ||
|
|
767bc9ef12 | ||
|
|
dfc0af21ee | ||
|
|
cfd67dce33 | ||
|
|
0241c51f6f | ||
|
|
958020b19b | ||
|
|
34d66a3d96 | ||
|
|
0689565ded | ||
|
|
8fcbdd0666 | ||
|
|
c7d251b206 | ||
|
|
f3ff44203c | ||
|
|
ee2311025c | ||
|
|
b8e40b166d | ||
|
|
d7dd0274fa | ||
|
|
b67e9a8130 | ||
|
|
794d113ce9 | ||
|
|
d8336989a8 | ||
|
|
64acf3047f | ||
|
|
f137dfa978 | ||
|
|
cb25e5e6d8 | ||
|
|
acdeaf19cf | ||
|
|
d30b907a8a | ||
|
|
76fbe00361 | ||
|
|
6a0c48e3d6 | ||
|
|
a0e016a9e5 | ||
|
|
15adae088c | ||
|
|
0e3ca147a2 | ||
|
|
89ca64a7a0 | ||
|
|
7f71996e02 | ||
|
|
530b5a1c00 | ||
|
|
2e2bf46553 | ||
|
|
195f5c12c4 | ||
|
|
f6be8e3546 | ||
|
|
c452c5b528 | ||
|
|
924cdc5d49 | ||
|
|
c77bceefa1 | ||
|
|
ab5c2bf384 | ||
|
|
28b08a7138 | ||
|
|
601a38aec2 | ||
|
|
2e5bf618dc | ||
|
|
c0fbf806de | ||
|
|
be73d1b48f | ||
|
|
6e98b55afb | ||
|
|
d180e05117 | ||
|
|
625e16d215 | ||
|
|
d2deb46110 | ||
|
|
75b8d191ff | ||
|
|
f87c292b74 | ||
|
|
a01cc55591 | ||
|
|
f32ba909b7 | ||
|
|
936852cafb | ||
|
|
d6005f9543 | ||
|
|
5418abd820 | ||
|
|
90bff5fd0b | ||
|
|
b68c9ff64f | ||
|
|
9b9234929b | ||
|
|
6d9f2e8b8c | ||
|
|
2a1672544f | ||
|
|
8f622ba5c9 | ||
|
|
e42f6b2cfa | ||
|
|
9200d7becd | ||
|
|
a70454cf1f | ||
|
|
07db1943fb | ||
|
|
3d35f6507a | ||
|
|
7a650eb1e4 | ||
|
|
d7c765c972 | ||
|
|
173261a69f | ||
|
|
5c5608680b | ||
|
|
09eea443cf | ||
|
|
d471720541 | ||
|
|
17270000eb | ||
|
|
154cc97603 | ||
|
|
3b6f243940 | ||
|
|
0a149ed440 | ||
|
|
595b86df6c | ||
|
|
770d17b42a | ||
|
|
8ad066409c | ||
|
|
4ac8949c3a | ||
|
|
83b2cf48d4 | ||
|
|
2bbb117eac | ||
|
|
f61112a8d7 | ||
|
|
3566b030c5 | ||
|
|
2d54fe4ed7 | ||
|
|
7fdd2cc7c9 | ||
|
|
81a85cbbe5 | ||
|
|
4902436b6b | ||
|
|
b82bcb0af9 | ||
|
|
eeea5d004a | ||
|
|
97ee0a9f85 | ||
|
|
958f92fd63 | ||
|
|
dfef268b05 | ||
|
|
e7d2f09eb4 | ||
|
|
5bb9012655 | ||
|
|
a291b2cd6f | ||
|
|
ead077fb92 | ||
|
|
1c9d6ac865 | ||
|
|
d098ee9dff | ||
|
|
b8d95dd222 | ||
|
|
7c93db95a3 | ||
|
|
d529634b7f | ||
|
|
765b5603c1 | ||
|
|
eec39a3fc5 | ||
|
|
72f66530aa | ||
|
|
99ee1cfc7e | ||
|
|
f8e82b63e3 | ||
|
|
afdf06b3f6 | ||
|
|
d21a86587f | ||
|
|
38071165d1 | ||
|
|
1cfc152d3b | ||
|
|
2db2f61992 | ||
|
|
4543f6ca39 | ||
|
|
f8d518300d | ||
|
|
347e214944 | ||
|
|
99b4d8e084 | ||
|
|
313cab6b2d | ||
|
|
494559cfd7 | ||
|
|
e3326aa0f1 | ||
|
|
bdd3ab4360 | ||
|
|
4f9ec2e8a4 | ||
|
|
14fd30c4f4 | ||
|
|
a7103b5b35 | ||
|
|
f6ce676e7e | ||
|
|
c2fbf7603a | ||
|
|
c3a4ea239c | ||
|
|
e2708933d3 | ||
|
|
cb2d9d4b07 | ||
|
|
1ba70226b8 | ||
|
|
d08710684d | ||
|
|
625e4cf9ee | ||
|
|
c8b310ebdb | ||
|
|
d971dd6700 | ||
|
|
e20863a7e1 | ||
|
|
8f2a87e5ed | ||
|
|
ae88360e20 | ||
|
|
7d97c2a27b | ||
|
|
02b7d55c2d | ||
|
|
55b5893cce | ||
|
|
1018e1c29c | ||
|
|
e5a4161e76 | ||
|
|
a3f437e482 | ||
|
|
9fcbbdc472 | ||
|
|
7aac597216 | ||
|
|
95b3c66366 | ||
|
|
3b354de2fc | ||
|
|
411392eb76 | ||
|
|
15c3e4edec | ||
|
|
fa0572ae44 | ||
|
|
ade75ace49 | ||
|
|
56539bb369 | ||
|
|
1c63bf0beb | ||
|
|
b10949d8cd | ||
|
|
853cc3ff6e | ||
|
|
a0cc6eb997 | ||
|
|
8b18e6f86d | ||
|
|
68e4d98bc5 | ||
|
|
390bf7a657 | ||
|
|
deb5c02ce6 | ||
|
|
004c5cd383 | ||
|
|
7b4254da58 | ||
|
|
d4903f04f1 | ||
|
|
f2b544ae68 | ||
|
|
ec91295677 | ||
|
|
4943fbd776 | ||
|
|
2478df8c0d | ||
|
|
85a178d90e |
@@ -1,4 +1,4 @@
|
||||
FROM python:3.6
|
||||
FROM python:3.10
|
||||
|
||||
RUN groupadd -g 5123 cdb && \
|
||||
useradd -r -u 5123 -g cdb cdb
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
Content database for Minetest mods, games, and more.\
|
||||
Developed by rubenwardy, license AGPLv3.0+.
|
||||
|
||||
See [Getting Started](docs/getting_started.md).
|
||||
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
|
||||
|
||||
See [Developer Intro](docs/dev_intro.md) for an overview of the code organisation.
|
||||
|
||||
## Credits
|
||||
|
||||
* `app/public/static/placeholder.png`: erlehmann, Warr1024. License: CC BY-SA 3.0
|
||||
|
||||
## How-tos
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from flask import *
|
||||
@@ -26,7 +27,6 @@ from flask_login import logout_user, current_user, LoginManager
|
||||
import os, redis
|
||||
from app.markdown import init_markdown, MARKDOWN_EXTENSIONS, MARKDOWN_EXTENSION_CONFIG
|
||||
|
||||
|
||||
app = Flask(__name__, static_folder="public/static")
|
||||
app.config["FLATPAGES_ROOT"] = "flatpages"
|
||||
app.config["FLATPAGES_EXTENSION"] = ".md"
|
||||
@@ -39,6 +39,10 @@ app.config["LANGUAGES"] = {
|
||||
"fr": "Français",
|
||||
"id": "Bahasa Indonesia",
|
||||
"ms": "Bahasa Melayu",
|
||||
"pl": "Język Polski",
|
||||
"ru": "русский язык",
|
||||
"sk": "Slovenčina",
|
||||
"zh_Hans": "汉语",
|
||||
}
|
||||
|
||||
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
|
||||
@@ -65,7 +69,7 @@ login_manager.init_app(app)
|
||||
login_manager.login_view = "users.login"
|
||||
|
||||
|
||||
from .sass import sass
|
||||
from .sass import init_app as sass
|
||||
sass(app)
|
||||
|
||||
|
||||
@@ -85,27 +89,39 @@ def load_user(user_id):
|
||||
from .blueprints import create_blueprints
|
||||
create_blueprints(app)
|
||||
|
||||
|
||||
@app.route("/uploads/<path:path>")
|
||||
def send_upload(path):
|
||||
return send_from_directory(app.config["UPLOAD_DIR"], path)
|
||||
|
||||
|
||||
@app.route("/<path:path>/")
|
||||
def flatpage(path):
|
||||
page = pages.get_or_404(path)
|
||||
template = page.meta.get("template", "flatpage.html")
|
||||
return render_template(template, page=page)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def check_for_ban():
|
||||
if current_user.is_authenticated:
|
||||
if current_user.rank == models.UserRank.BANNED:
|
||||
flash(gettext("You have been banned."), "danger")
|
||||
if current_user.ban and current_user.ban.has_expired:
|
||||
models.db.session.delete(current_user.ban)
|
||||
if current_user.rank == models.UserRank.BANNED:
|
||||
current_user.rank = models.UserRank.MEMBER
|
||||
models.db.session.commit()
|
||||
elif current_user.ban or current_user.rank == models.UserRank.BANNED:
|
||||
if current_user.ban:
|
||||
flash(gettext("Banned:") + " " + current_user.ban.message, "danger")
|
||||
else:
|
||||
flash(gettext("You have been banned."), "danger")
|
||||
logout_user()
|
||||
return redirect(url_for("users.login"))
|
||||
elif current_user.rank == models.UserRank.NOT_JOINED:
|
||||
current_user.rank = models.UserRank.MEMBER
|
||||
models.db.session.commit()
|
||||
|
||||
|
||||
from .utils import clearNotifications, is_safe_url
|
||||
|
||||
|
||||
@@ -114,23 +130,40 @@ def check_for_notifications():
|
||||
if current_user.is_authenticated:
|
||||
clearNotifications(request.path)
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return render_template("404.html"), 404
|
||||
|
||||
|
||||
@app.errorhandler(500)
|
||||
def server_error(e):
|
||||
return render_template("500.html"), 500
|
||||
|
||||
|
||||
@babel.localeselector
|
||||
def get_locale():
|
||||
if not request:
|
||||
return None
|
||||
|
||||
locales = app.config["LANGUAGES"].keys()
|
||||
|
||||
if request:
|
||||
locale = request.cookies.get("locale")
|
||||
if locale in locales:
|
||||
return locale
|
||||
if current_user.is_authenticated and current_user.locale in locales:
|
||||
return current_user.locale
|
||||
|
||||
return request.accept_languages.best_match(locales)
|
||||
locale = request.cookies.get("locale")
|
||||
if locale not in locales:
|
||||
locale = request.accept_languages.best_match(locales)
|
||||
|
||||
return None
|
||||
if locale and current_user.is_authenticated:
|
||||
new_session = models.db.create_session({})()
|
||||
new_session.query(models.User) \
|
||||
.filter(models.User.username == current_user.username) \
|
||||
.update({ "locale": locale })
|
||||
new_session.commit()
|
||||
new_session.close()
|
||||
|
||||
return locale
|
||||
|
||||
|
||||
@app.route("/set-locale/", methods=["POST"])
|
||||
@@ -152,4 +185,8 @@ def set_locale():
|
||||
expire_date = expire_date + datetime.timedelta(days=5*365)
|
||||
resp.set_cookie("locale", locale, expires=expire_date)
|
||||
|
||||
if current_user.is_authenticated:
|
||||
current_user.locale = locale
|
||||
models.db.session.commit()
|
||||
|
||||
return resp
|
||||
|
||||
@@ -16,20 +16,26 @@
|
||||
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
import requests
|
||||
from celery import group
|
||||
from flask import *
|
||||
from sqlalchemy import or_
|
||||
from flask import redirect, url_for, flash, current_app, jsonify
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.models import *
|
||||
from app.logic.game_support import GameSupportResolver
|
||||
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \
|
||||
NotificationType, PackageUpdateConfig, License, UserRank, PackageType, PackageGameSupport
|
||||
from app.tasks.emails import send_pending_digests
|
||||
from app.tasks.forumtasks import importTopicList, checkAllForumAccounts
|
||||
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates
|
||||
from app.utils import addNotification, get_system_user
|
||||
from app.utils.image import get_image_size
|
||||
|
||||
actions = {}
|
||||
|
||||
|
||||
def action(title: str):
|
||||
def func(f):
|
||||
name = f.__name__
|
||||
@@ -42,20 +48,21 @@ def action(title: str):
|
||||
|
||||
return func
|
||||
|
||||
|
||||
@action("Delete stuck releases")
|
||||
def del_stuck_releases():
|
||||
PackageRelease.query.filter(PackageRelease.task_id != None).delete()
|
||||
PackageRelease.query.filter(PackageRelease.task_id.isnot(None)).delete()
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
@action("Check releases")
|
||||
|
||||
@action("Check all releases (postReleaseCheckUpdate)")
|
||||
def check_releases():
|
||||
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
|
||||
|
||||
tasks = []
|
||||
for release in releases:
|
||||
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||
tasks.append(checkZipRelease.s(release.id, zippath))
|
||||
tasks.append(checkZipRelease.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
@@ -65,14 +72,14 @@ def check_releases():
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
@action("Reimport packages")
|
||||
|
||||
@action("Check latest release of all packages (postReleaseCheckUpdate)")
|
||||
def reimport_packages():
|
||||
tasks = []
|
||||
for package in Package.query.filter(Package.state!=PackageState.DELETED).all():
|
||||
for package in Package.query.filter(Package.state != PackageState.DELETED).all():
|
||||
release = package.releases.first()
|
||||
if release:
|
||||
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||
tasks.append(checkZipRelease.s(release.id, zippath))
|
||||
tasks.append(checkZipRelease.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
@@ -82,42 +89,46 @@ def reimport_packages():
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
@action("Import topic list")
|
||||
|
||||
@action("Import forum topic list")
|
||||
def import_topic_list():
|
||||
task = importTopicList.delay()
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
|
||||
|
||||
|
||||
@action("Check all forum accounts")
|
||||
def check_all_forum_accounts():
|
||||
task = checkAllForumAccounts.delay()
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
|
||||
|
||||
@action("Import screenshots")
|
||||
|
||||
@action("Import screenshots from Git")
|
||||
def import_screenshots():
|
||||
packages = Package.query \
|
||||
.filter(Package.state!=PackageState.DELETED) \
|
||||
.outerjoin(PackageScreenshot, Package.id==PackageScreenshot.package_id) \
|
||||
.filter(PackageScreenshot.id==None) \
|
||||
.filter(Package.state != PackageState.DELETED) \
|
||||
.outerjoin(PackageScreenshot, Package.id == PackageScreenshot.package_id) \
|
||||
.filter(PackageScreenshot.id.is_(None)) \
|
||||
.all()
|
||||
for package in packages:
|
||||
importRepoScreenshot.delay(package.id)
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
@action("Clean uploads")
|
||||
|
||||
@action("Remove unused uploads")
|
||||
def clean_uploads():
|
||||
upload_dir = app.config['UPLOAD_DIR']
|
||||
upload_dir = current_app.config['UPLOAD_DIR']
|
||||
|
||||
(_, _, filenames) = next(os.walk(upload_dir))
|
||||
existing_uploads = set(filenames)
|
||||
|
||||
if len(existing_uploads) != 0:
|
||||
def getURLsFromDB(column):
|
||||
results = db.session.query(column).filter(column != None, column != "").all()
|
||||
def get_filenames_from_column(column):
|
||||
results = db.session.query(column).filter(column.isnot(None), column != "").all()
|
||||
return set([os.path.basename(x[0]) for x in results])
|
||||
|
||||
release_urls = getURLsFromDB(PackageRelease.url)
|
||||
screenshot_urls = getURLsFromDB(PackageScreenshot.url)
|
||||
release_urls = get_filenames_from_column(PackageRelease.url)
|
||||
screenshot_urls = get_filenames_from_column(PackageScreenshot.url)
|
||||
|
||||
db_urls = release_urls.union(screenshot_urls)
|
||||
unreachable = existing_uploads.difference(db_urls)
|
||||
@@ -136,7 +147,8 @@ def clean_uploads():
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
@action("Delete metapackages")
|
||||
|
||||
@action("Delete unused metapackages")
|
||||
def del_meta_packages():
|
||||
query = MetaPackage.query.filter(~MetaPackage.dependencies.any(), ~MetaPackage.packages.any())
|
||||
count = query.count()
|
||||
@@ -146,6 +158,7 @@ def del_meta_packages():
|
||||
flash("Deleted " + str(count) + " unused meta packages", "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Delete removed packages")
|
||||
def del_removed_packages():
|
||||
query = Package.query.filter_by(state=PackageState.DELETED)
|
||||
@@ -158,24 +171,6 @@ def del_removed_packages():
|
||||
flash("Deleted {} soft deleted packages packages".format(count), "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
@action("Add update config")
|
||||
def add_update_config():
|
||||
added = 0
|
||||
for pkg in Package.query.filter(Package.repo != None, Package.releases.any(), Package.update_config == None).all():
|
||||
pkg.update_config = PackageUpdateConfig()
|
||||
pkg.update_config.auto_created = True
|
||||
|
||||
release: PackageRelease = pkg.releases.first()
|
||||
if release and release.commit_hash:
|
||||
pkg.update_config.last_commit = release.commit_hash
|
||||
|
||||
db.session.add(pkg.update_config)
|
||||
added += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash("Added {} update configs".format(added), "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
@action("Run update configs")
|
||||
def run_update_config():
|
||||
@@ -184,29 +179,31 @@ def run_update_config():
|
||||
flash("Started update configs", "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
def _package_list(packages: List[str]):
|
||||
# Who needs translations?
|
||||
if len(packages) >= 3:
|
||||
packages[len(packages) - 1] = "and " + packages[len(packages) - 1]
|
||||
packages_list = ", ".join(packages)
|
||||
else:
|
||||
packages_list = "and ".join(packages)
|
||||
packages_list = " and ".join(packages)
|
||||
return packages_list
|
||||
|
||||
|
||||
@action("Send WIP package notification")
|
||||
def remind_wip():
|
||||
users = User.query.filter(User.packages.any(or_(Package.state==PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)))
|
||||
users = User.query.filter(User.packages.any(or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)))
|
||||
system_user = get_system_user()
|
||||
for user in users:
|
||||
packages = db.session.query(Package.title).filter(
|
||||
Package.author_id==user.id,
|
||||
or_(Package.state==PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)) \
|
||||
Package.author_id == user.id,
|
||||
or_(Package.state == PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)) \
|
||||
.all()
|
||||
|
||||
packages = [pkg[0] for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
havent = "haven't" if len(packages) > 1 else "hasn't"
|
||||
if len(packages_list) + 54 > 100:
|
||||
if len(packages_list) + 54 > 100:
|
||||
packages_list = packages_list[0:(100-54-1)] + "…"
|
||||
|
||||
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
@@ -214,6 +211,7 @@ def remind_wip():
|
||||
url_for('todo.view_user', username=user.username))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Send outdated package notification")
|
||||
def remind_outdated():
|
||||
users = User.query.filter(User.maintained_packages.any(
|
||||
@@ -234,6 +232,7 @@ def remind_outdated():
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Import licenses from SPDX")
|
||||
def import_licenses():
|
||||
renames = {
|
||||
@@ -284,7 +283,56 @@ def import_licenses():
|
||||
|
||||
@action("Delete inactive users")
|
||||
def delete_inactive_users():
|
||||
users = User.query.filter(User.is_active==False, User.packages==None, User.forum_topics==None, User.rank==UserRank.NOT_JOINED).all()
|
||||
users = User.query.filter(User.is_active == False, ~User.packages.any(), ~User.forum_topics.any(),
|
||||
User.rank == UserRank.NOT_JOINED).all()
|
||||
for user in users:
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Send Video URL notification")
|
||||
def remind_video_url():
|
||||
users = User.query.filter(User.maintained_packages.any(
|
||||
and_(Package.video_url.is_(None), Package.type==PackageType.GAME, Package.state==PackageState.APPROVED)))
|
||||
system_user = get_system_user()
|
||||
for user in users:
|
||||
packages = db.session.query(Package.title).filter(
|
||||
or_(Package.author==user, Package.maintainers.any(User.id==user.id)),
|
||||
Package.video_url.is_(None),
|
||||
Package.type == PackageType.GAME,
|
||||
Package.state == PackageState.APPROVED) \
|
||||
.all()
|
||||
|
||||
packages = [pkg[0] for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
|
||||
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
f"You should add a video to {packages_list}",
|
||||
url_for('users.profile', username=user.username))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Update screenshot sizes")
|
||||
def update_screenshot_sizes():
|
||||
import sys
|
||||
|
||||
for screenshot in PackageScreenshot.query.all():
|
||||
width, height = get_image_size(screenshot.file_path)
|
||||
print(f"{screenshot.url}: {width}, {height}", file=sys.stderr)
|
||||
screenshot.width = width
|
||||
screenshot.height = height
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Detect game support")
|
||||
def detect_game_support():
|
||||
resolver = GameSupportResolver()
|
||||
resolver.update_all()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Send pending notif digests")
|
||||
def do_send_pending_digests():
|
||||
send_pending_digests.delay()
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import *
|
||||
from flask import redirect, render_template, url_for, request, flash
|
||||
from flask_login import current_user, login_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms import StringField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from app.utils import rank_required, addAuditLog, addNotification, get_system_user
|
||||
from . import bp
|
||||
@@ -48,9 +48,10 @@ def admin_page():
|
||||
else:
|
||||
flash("Unknown action: " + action, "danger")
|
||||
|
||||
deleted_packages = Package.query.filter(Package.state==PackageState.DELETED).all()
|
||||
deleted_packages = Package.query.filter(Package.state == PackageState.DELETED).all()
|
||||
return render_template("admin/list.html", deleted_packages=deleted_packages, actions=actions)
|
||||
|
||||
|
||||
class SwitchUserForm(FlaskForm):
|
||||
username = StringField("Username")
|
||||
submit = SubmitField("Switch")
|
||||
@@ -69,14 +70,13 @@ def switch_user():
|
||||
else:
|
||||
flash("Unable to login as user", "danger")
|
||||
|
||||
|
||||
# Process GET or invalid POST
|
||||
return render_template("admin/switch_user.html", form=form)
|
||||
|
||||
|
||||
class SendNotificationForm(FlaskForm):
|
||||
title = StringField("Title", [InputRequired(), Length(1, 300)])
|
||||
url = StringField("URL", [InputRequired(), Length(1, 100)], default="/")
|
||||
title = StringField("Title", [InputRequired(), Length(1, 300)])
|
||||
url = StringField("URL", [InputRequired(), Length(1, 100)], default="/")
|
||||
submit = SubmitField("Send")
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ def send_bulk_notification():
|
||||
form = SendNotificationForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
||||
"Sent bulk notification", None, None, form.title.data)
|
||||
"Sent bulk notification", url_for("admin.admin_page"), None, form.title.data)
|
||||
|
||||
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all()
|
||||
addNotification(users, get_system_user(), NotificationType.OTHER, form.title.data, form.url.data, None)
|
||||
@@ -121,5 +121,10 @@ def restore():
|
||||
db.session.commit()
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
deleted_packages = Package.query.filter(Package.state==PackageState.DELETED).join(Package.author).order_by(db.asc(User.username), db.asc(Package.name)).all()
|
||||
return render_template("admin/restore.html", deleted_packages=deleted_packages)
|
||||
deleted_packages = Package.query \
|
||||
.filter(Package.state == PackageState.DELETED) \
|
||||
.join(Package.author) \
|
||||
.order_by(db.asc(User.username), db.asc(Package.name)) \
|
||||
.all()
|
||||
|
||||
return render_template("admin/restore.html", deleted_packages=deleted_packages)
|
||||
|
||||
@@ -39,8 +39,8 @@ def audit():
|
||||
return render_template("admin/audit.html", log=pagination.items, pagination=pagination)
|
||||
|
||||
|
||||
@bp.route("/admin/audit/<int:id>/")
|
||||
@bp.route("/admin/audit/<int:id_>/")
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def audit_view(id):
|
||||
entry = AuditLogEntry.query.get(id)
|
||||
def audit_view(id_):
|
||||
entry = AuditLogEntry.query.get(id_)
|
||||
return render_template("admin/audit_view.html", entry=entry)
|
||||
|
||||
@@ -14,18 +14,17 @@
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask import request, abort, url_for, redirect, render_template, flash
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from wtforms import TextAreaField, SubmitField, StringField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app.markdown import render_markdown
|
||||
from app.models import *
|
||||
from app.tasks.emails import send_user_email
|
||||
from app.tasks.emails import send_user_email, send_bulk_email as task_send_bulk
|
||||
from app.utils import rank_required, addAuditLog
|
||||
from . import bp
|
||||
from ...models import UserRank, User, AuditSeverity
|
||||
|
||||
|
||||
class SendEmailForm(FlaskForm):
|
||||
@@ -55,7 +54,7 @@ def send_single_email():
|
||||
|
||||
text = form.text.data
|
||||
html = render_markdown(text)
|
||||
task = send_user_email.delay(user.email, form.subject.data, text, html)
|
||||
task = send_user_email.delay(user.email, user.locale or "en",form.subject.data, text, html)
|
||||
return redirect(url_for("tasks.check", id=task.id, r=next_url))
|
||||
|
||||
return render_template("admin/send_email.html", form=form, user=user)
|
||||
@@ -67,12 +66,11 @@ def send_bulk_email():
|
||||
form = SendEmailForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
||||
"Sent bulk email", None, None, form.text.data)
|
||||
"Sent bulk email", url_for("admin.admin_page"), None, form.text.data)
|
||||
|
||||
text = form.text.data
|
||||
html = render_markdown(text)
|
||||
for user in User.query.filter(User.email != None).all():
|
||||
send_user_email.delay(user.email, form.subject.data, text, html)
|
||||
task_send_bulk.delay(form.subject.data, text, html)
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@@ -15,15 +15,14 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.fields.html5 import URLField
|
||||
from wtforms.validators import *
|
||||
from wtforms import StringField, BooleanField, SubmitField, URLField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
|
||||
from app.models import *
|
||||
from app.utils import rank_required, nonEmptyOrNone
|
||||
from . import bp
|
||||
from ...models import UserRank, License, db
|
||||
|
||||
|
||||
@bp.route("/licenses/")
|
||||
@@ -31,11 +30,13 @@ from . import bp
|
||||
def license_list():
|
||||
return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all())
|
||||
|
||||
|
||||
class LicenseForm(FlaskForm):
|
||||
name = StringField("Name", [InputRequired(), Length(3,100)])
|
||||
is_foss = BooleanField("Is FOSS")
|
||||
url = URLField("URL", [Optional], filters=[nonEmptyOrNone])
|
||||
submit = SubmitField("Save")
|
||||
name = StringField("Name", [InputRequired(), Length(3, 100)])
|
||||
is_foss = BooleanField("Is FOSS")
|
||||
url = URLField("URL", [Optional], filters=[nonEmptyOrNone])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/licenses/new/", methods=["GET", "POST"])
|
||||
@bp.route("/licenses/<name>/edit/", methods=["GET", "POST"])
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask import redirect, render_template, abort, url_for, request
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
||||
|
||||
from app.models import *
|
||||
from . import bp
|
||||
from ...models import Permission, Tag, db
|
||||
|
||||
|
||||
@bp.route("/tags/")
|
||||
@@ -40,12 +40,14 @@ def tag_list():
|
||||
|
||||
return render_template("admin/tags/list.html", tags=query.all())
|
||||
|
||||
|
||||
class TagForm(FlaskForm):
|
||||
title = StringField("Title", [InputRequired(), Length(3,100)])
|
||||
title = StringField("Title", [InputRequired(), Length(3, 100)])
|
||||
description = TextAreaField("Description", [Optional(), Length(0, 500)])
|
||||
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
is_protected = BooleanField("Is Protected")
|
||||
submit = SubmitField("Save")
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/tags/new/", methods=["GET", "POST"])
|
||||
@bp.route("/tags/<name>/edit/", methods=["GET", "POST"])
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from wtforms import StringField, IntegerField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app.models import *
|
||||
from app.utils import rank_required
|
||||
from . import bp
|
||||
from ...models import UserRank, MinetestRelease, db
|
||||
|
||||
|
||||
@bp.route("/versions/")
|
||||
@@ -30,10 +30,12 @@ from . import bp
|
||||
def version_list():
|
||||
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
|
||||
|
||||
|
||||
class VersionForm(FlaskForm):
|
||||
name = StringField("Name", [InputRequired(), Length(3,100)])
|
||||
name = StringField("Name", [InputRequired(), Length(3, 100)])
|
||||
protocol = IntegerField("Protocol")
|
||||
submit = SubmitField("Save")
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/versions/new/", methods=["GET", "POST"])
|
||||
@bp.route("/versions/<name>/edit/", methods=["GET", "POST"])
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from wtforms import StringField, TextAreaField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
||||
|
||||
from app.models import *
|
||||
from app.utils import rank_required
|
||||
from . import bp
|
||||
from ...models import UserRank, ContentWarning, db
|
||||
|
||||
|
||||
@bp.route("/admin/warnings/")
|
||||
@@ -30,11 +30,14 @@ from . import bp
|
||||
def warning_list():
|
||||
return render_template("admin/warnings/list.html", warnings=ContentWarning.query.order_by(db.asc(ContentWarning.title)).all())
|
||||
|
||||
|
||||
class WarningForm(FlaskForm):
|
||||
title = StringField("Title", [InputRequired(), Length(3,100)])
|
||||
title = StringField("Title", [InputRequired(), Length(3, 100)])
|
||||
description = TextAreaField("Description", [Optional(), Length(0, 500)])
|
||||
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
submit = SubmitField("Save")
|
||||
name = StringField("Name", [Optional(), Length(1, 20),
|
||||
Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@bp.route("/admin/warnings/new/", methods=["GET", "POST"])
|
||||
@bp.route("/admin/warnings/<name>/edit/", methods=["GET", "POST"])
|
||||
|
||||
@@ -13,13 +13,14 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
from typing import List
|
||||
|
||||
import flask_sqlalchemy
|
||||
from flask import request, jsonify, current_app
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy.orm import subqueryload, joinedload
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from app import csrf
|
||||
@@ -30,7 +31,8 @@ from app.querybuilder import QueryBuilder
|
||||
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, isYes
|
||||
from . import bp
|
||||
from .auth import is_api_authd
|
||||
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, api_order_screenshots, api_edit_package
|
||||
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
|
||||
api_order_screenshots, api_edit_package, api_set_cover_image
|
||||
from functools import wraps
|
||||
|
||||
|
||||
@@ -101,10 +103,11 @@ def resolve_package_deps(out, package, only_hard, depth=1):
|
||||
|
||||
elif dep.meta_package:
|
||||
name = dep.meta_package.name
|
||||
fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages]
|
||||
fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages if pkg.state == PackageState.APPROVED]
|
||||
|
||||
if depth == 1 and not dep.optional:
|
||||
most_likely = next((pkg for pkg in dep.meta_package.packages if pkg.type == PackageType.MOD), None)
|
||||
most_likely = next((pkg for pkg in dep.meta_package.packages \
|
||||
if pkg.type == PackageType.MOD and pkg.state == PackageState.APPROVED), None)
|
||||
if most_likely:
|
||||
resolve_package_deps(out, most_likely, only_hard, depth + 1)
|
||||
|
||||
@@ -302,7 +305,7 @@ def create_screenshot(token: APIToken, package: Package):
|
||||
if file is None:
|
||||
error(400, "Missing 'file' in multipart body")
|
||||
|
||||
return api_create_screenshot(token, package, data["title"], file)
|
||||
return api_create_screenshot(token, package, data["title"], file, isYes(data.get("is_cover_image")))
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
|
||||
@@ -355,7 +358,7 @@ def order_screenshots(token: APIToken, package: Package):
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to delete screenshots")
|
||||
error(403, "You do not have the permission to change screenshots")
|
||||
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
@@ -367,6 +370,28 @@ def order_screenshots(token: APIToken, package: Package):
|
||||
return api_order_screenshots(token, package, request.json)
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/cover-image/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def set_cover_image(token: APIToken, package: Package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to change screenshots")
|
||||
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
json = request.json
|
||||
if json is None or not isinstance(json, dict) or "cover_image" not in json:
|
||||
error(400, "Expected body to be an object with cover_image as a key")
|
||||
|
||||
return api_set_cover_image(token, package, request.json["cover_image"])
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/reviews/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
@@ -477,6 +502,26 @@ def homepage():
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/welcome/v1/")
|
||||
@cors_allowed
|
||||
def welcome_v1():
|
||||
featured = Package.query \
|
||||
.filter(Package.type == PackageType.GAME, Package.state == PackageState.APPROVED,
|
||||
Package.tags.any(name="featured")) \
|
||||
.order_by(func.random()) \
|
||||
.limit(5).all()
|
||||
|
||||
mtg = Package.query.filter(Package.author.has(username="Minetest"), Package.name == "minetest_game").one()
|
||||
featured.insert(2, mtg)
|
||||
|
||||
def map_packages(packages: List[Package]):
|
||||
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
|
||||
|
||||
return jsonify({
|
||||
"featured": map_packages(featured),
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/minetest_versions/")
|
||||
@cors_allowed
|
||||
def versions():
|
||||
|
||||
@@ -19,7 +19,7 @@ from flask import jsonify, abort, make_response, url_for, current_app
|
||||
|
||||
from app.logic.packages import do_edit_package
|
||||
from app.logic.releases import LogicError, do_create_vcs_release, do_create_zip_release
|
||||
from app.logic.screenshots import do_create_screenshot, do_order_screenshots
|
||||
from app.logic.screenshots import do_create_screenshot, do_order_screenshots, do_set_cover_image
|
||||
from app.models import APIToken, Package, MinetestRelease, PackageScreenshot
|
||||
|
||||
|
||||
@@ -69,13 +69,13 @@ def api_create_zip_release(token: APIToken, package: Package, title: str, file,
|
||||
})
|
||||
|
||||
|
||||
def api_create_screenshot(token: APIToken, package: Package, title: str, file, reason="API"):
|
||||
def api_create_screenshot(token: APIToken, package: Package, title: str, file, is_cover_image: bool, reason="API"):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
reason += ", token=" + token.name
|
||||
|
||||
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, reason)
|
||||
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, is_cover_image, reason)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
@@ -94,6 +94,17 @@ def api_order_screenshots(token: APIToken, package: Package, order: [any]):
|
||||
})
|
||||
|
||||
|
||||
def api_set_cover_image(token: APIToken, package: Package, cover_image):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
guard(do_set_cover_image)(token.owner, package, cover_image)
|
||||
|
||||
return jsonify({
|
||||
"success": True
|
||||
})
|
||||
|
||||
|
||||
def api_edit_package(token: APIToken, package: Package, data: dict, reason: str = "API"):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
@@ -20,7 +20,7 @@ from flask_babel import lazy_gettext
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.models import db, User, APIToken, Package, Permission
|
||||
|
||||
@@ -53,12 +53,11 @@ def view(name):
|
||||
.filter(Dependency.optional==True, Package.state==PackageState.APPROVED) \
|
||||
.all()
|
||||
|
||||
similar_topics = None
|
||||
if mpackage.packages.filter_by(state=PackageState.APPROVED).count() == 0:
|
||||
similar_topics = ForumTopic.query \
|
||||
.filter_by(name=name) \
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||
.all()
|
||||
similar_topics = ForumTopic.query \
|
||||
.filter_by(name=name) \
|
||||
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||
.all()
|
||||
|
||||
return render_template("metapackages/view.html", mpackage=mpackage,
|
||||
dependers=dependers, optional_dependers=optional_dependers,
|
||||
|
||||
@@ -65,4 +65,4 @@ def get_package_tabs(user: User, package: Package):
|
||||
]
|
||||
|
||||
|
||||
from . import packages, screenshots, releases, reviews
|
||||
from . import packages, screenshots, releases, reviews, game_hub
|
||||
|
||||
54
app/blueprints/packages/game_hub.py
Normal file
54
app/blueprints/packages/game_hub.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2022 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import render_template, abort
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from . import bp
|
||||
from app.utils import is_package_page
|
||||
from ...models import Package, PackageType, PackageState, db, PackageRelease
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/hub/")
|
||||
@is_package_page
|
||||
def game_hub(package: Package):
|
||||
if package.type != PackageType.GAME:
|
||||
abort(404)
|
||||
|
||||
def join(query):
|
||||
return query.options(
|
||||
joinedload(Package.license),
|
||||
joinedload(Package.media_license))
|
||||
|
||||
query = Package.query.filter(Package.supported_games.any(game=package), Package.state==PackageState.APPROVED)
|
||||
count = query.count()
|
||||
|
||||
new = join(query.order_by(db.desc(Package.approved_at))).limit(4).all()
|
||||
pop_mod = join(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all()
|
||||
pop_gam = join(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(8).all()
|
||||
pop_txp = join(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(8).all()
|
||||
high_reviewed = join(query.order_by(db.desc(Package.score - Package.score_downloads))) \
|
||||
.filter(Package.reviews.any()).limit(4).all()
|
||||
|
||||
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
|
||||
.filter(Package.supported_games.any(game=package), Package.state==PackageState.APPROVED) \
|
||||
.order_by(db.desc(PackageRelease.releaseDate)) \
|
||||
.limit(20).all()
|
||||
updated = updated[:4]
|
||||
|
||||
return render_template("packages/game_hub.html", package=package, count=count,
|
||||
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam,
|
||||
high_reviewed=high_reviewed)
|
||||
@@ -13,18 +13,18 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import typing
|
||||
from urllib.parse import quote as urlescape
|
||||
|
||||
from flask import render_template
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_login import login_required
|
||||
from sqlalchemy import or_, func
|
||||
from jinja2 import Markup
|
||||
from sqlalchemy import or_, func, and_
|
||||
from sqlalchemy.orm import joinedload, subqueryload
|
||||
from wtforms import *
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.querybuilder import QueryBuilder
|
||||
@@ -115,9 +115,6 @@ def getReleases(package):
|
||||
@bp.route("/packages/<author>/<name>/")
|
||||
@is_package_page
|
||||
def view(package):
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
show_similar = not package.approved and (
|
||||
current_user in package.maintainers or
|
||||
package.checkPerm(current_user, Permission.APPROVE_NEW))
|
||||
@@ -126,7 +123,7 @@ def view(package):
|
||||
if show_similar and package.type != PackageType.TXP:
|
||||
conflicting_modnames = db.session.query(MetaPackage.name) \
|
||||
.filter(MetaPackage.id.in_([ mp.id for mp in package.provides ])) \
|
||||
.filter(MetaPackage.packages.any(Package.id != package.id)) \
|
||||
.filter(MetaPackage.packages.any(and_(Package.id != package.id, Package.state == PackageState.APPROVED))) \
|
||||
.all()
|
||||
|
||||
conflicting_modnames += db.session.query(ForumTopic.name) \
|
||||
@@ -208,9 +205,6 @@ def shield(package, type):
|
||||
@bp.route("/packages/<author>/<name>/download/")
|
||||
@is_package_page
|
||||
def download(package):
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
release = package.getDownloadRelease()
|
||||
|
||||
if release is None:
|
||||
@@ -250,10 +244,67 @@ class PackageForm(FlaskForm):
|
||||
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0,999999)])
|
||||
video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
def handle_create_edit(package: typing.Optional[Package], form: PackageForm, author: User):
|
||||
wasNew = False
|
||||
if package is None:
|
||||
package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
|
||||
if package is not None:
|
||||
if package.state == PackageState.DELETED:
|
||||
package.review_thread_id = None
|
||||
db.session.delete(package)
|
||||
else:
|
||||
flash(Markup(
|
||||
f"<a class='btn btn-sm btn-danger float-right' href='{package.getURL('packages.view')}'>View</a>" +
|
||||
gettext("Package already exists")), "danger")
|
||||
return None
|
||||
|
||||
package = Package()
|
||||
package.author = author
|
||||
package.maintainers.append(author)
|
||||
wasNew = True
|
||||
|
||||
try:
|
||||
do_edit_package(current_user, package, wasNew, True, {
|
||||
"type": form.type.data,
|
||||
"title": form.title.data,
|
||||
"name": form.name.data,
|
||||
"short_desc": form.short_desc.data,
|
||||
"dev_state": form.dev_state.data,
|
||||
"tags": form.tags.raw_data,
|
||||
"content_warnings": form.content_warnings.raw_data,
|
||||
"license": form.license.data,
|
||||
"media_license": form.media_license.data,
|
||||
"desc": form.desc.data,
|
||||
"repo": form.repo.data,
|
||||
"website": form.website.data,
|
||||
"issueTracker": form.issueTracker.data,
|
||||
"forums": form.forums.data,
|
||||
"video_url": form.video_url.data,
|
||||
})
|
||||
|
||||
if wasNew:
|
||||
msg = f"Created package {author.username}/{form.name.data}"
|
||||
addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
if wasNew and package.repo is not None:
|
||||
importRepoScreenshot.delay(package.id)
|
||||
|
||||
next_url = package.getURL("packages.view")
|
||||
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
|
||||
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
|
||||
elif wasNew:
|
||||
next_url = package.getURL("packages.setup_releases")
|
||||
|
||||
return redirect(next_url)
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
||||
|
||||
@bp.route("/packages/new/", methods=["GET", "POST"])
|
||||
@bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@@ -288,65 +339,23 @@ def create_edit(author=None, name=None):
|
||||
# Initial form class from post data and default data
|
||||
if request.method == "GET":
|
||||
if package is None:
|
||||
form.name.data = request.args.get("bname")
|
||||
form.title.data = request.args.get("title")
|
||||
form.repo.data = request.args.get("repo")
|
||||
form.name.data = request.args.get("bname")
|
||||
form.title.data = request.args.get("title")
|
||||
form.repo.data = request.args.get("repo")
|
||||
form.forums.data = request.args.get("forums")
|
||||
form.license.data = None
|
||||
form.media_license.data = None
|
||||
else:
|
||||
form.tags.data = list(package.tags)
|
||||
form.content_warnings.data = list(package.content_warnings)
|
||||
form.tags.data = package.tags
|
||||
form.content_warnings.data = package.content_warnings
|
||||
|
||||
if request.method == "POST" and form.type.data == PackageType.TXP:
|
||||
form.license.data = form.media_license.data
|
||||
|
||||
if form.validate_on_submit():
|
||||
wasNew = False
|
||||
if not package:
|
||||
package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
|
||||
if package is not None:
|
||||
if package.state == PackageState.READY_FOR_REVIEW:
|
||||
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
|
||||
else:
|
||||
flash(gettext("Package already exists!"), "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
package = Package()
|
||||
package.author = author
|
||||
package.maintainers.append(author)
|
||||
wasNew = True
|
||||
|
||||
try:
|
||||
do_edit_package(current_user, package, wasNew, True, {
|
||||
"type": form.type.data,
|
||||
"title": form.title.data,
|
||||
"name": form.name.data,
|
||||
"short_desc": form.short_desc.data,
|
||||
"dev_state": form.dev_state.data,
|
||||
"tags": form.tags.raw_data,
|
||||
"content_warnings": form.content_warnings.raw_data,
|
||||
"license": form.license.data,
|
||||
"media_license": form.media_license.data,
|
||||
"desc": form.desc.data,
|
||||
"repo": form.repo.data,
|
||||
"website": form.website.data,
|
||||
"issueTracker": form.issueTracker.data,
|
||||
"forums": form.forums.data,
|
||||
})
|
||||
|
||||
if wasNew and package.repo is not None:
|
||||
importRepoScreenshot.delay(package.id)
|
||||
|
||||
next_url = package.getURL("packages.view")
|
||||
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
|
||||
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
|
||||
elif wasNew:
|
||||
next_url = package.getURL("packages.setup_releases")
|
||||
|
||||
return redirect(next_url)
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
ret = handle_create_edit(package, form, author)
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
package_query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
if package is not None:
|
||||
@@ -418,7 +427,7 @@ def remove(package):
|
||||
|
||||
if "delete" in request.form:
|
||||
if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
|
||||
flash(gettext("You don't have permission to do that."), "danger")
|
||||
flash(gettext("You don't have permission to do that"), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
package.state = PackageState.DELETED
|
||||
@@ -426,7 +435,7 @@ def remove(package):
|
||||
url = url_for("users.profile", username=package.author.username)
|
||||
msg = "Deleted {}, reason={}".format(package.title, reason)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
|
||||
addAuditLog(AuditSeverity.EDITOR, current_user, msg, url)
|
||||
addAuditLog(AuditSeverity.EDITOR, current_user, msg, url, package)
|
||||
db.session.commit()
|
||||
|
||||
flash(gettext("Deleted package"), "success")
|
||||
@@ -434,7 +443,7 @@ def remove(package):
|
||||
return redirect(url)
|
||||
elif "unapprove" in request.form:
|
||||
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
|
||||
flash(gettext("You don't have permission to do that."), "danger")
|
||||
flash(gettext("You don't have permission to do that"), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
package.state = PackageState.WIP
|
||||
@@ -463,7 +472,7 @@ class PackageMaintainersForm(FlaskForm):
|
||||
@is_package_page
|
||||
def edit_maintainers(package):
|
||||
if not package.checkPerm(current_user, Permission.EDIT_MAINTAINERS):
|
||||
flash(gettext("You do not have permission to edit maintainers"), "danger")
|
||||
flash(gettext("You don't have permission to edit maintainers"), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
form = PackageMaintainersForm(formdata=request.form)
|
||||
@@ -591,9 +600,6 @@ def alias_create_edit(package: Package, alias_id: int = None):
|
||||
@login_required
|
||||
@is_package_page
|
||||
def share(package):
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
return render_template("packages/share.html", package=package,
|
||||
tabs=get_package_tabs(current_user, package), current_tab="share")
|
||||
|
||||
@@ -601,9 +607,6 @@ def share(package):
|
||||
@bp.route("/packages/<author>/<name>/similar/")
|
||||
@is_package_page
|
||||
def similar(package):
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
packages_modnames = {}
|
||||
for metapackage in package.provides:
|
||||
packages_modnames[metapackage] = Package.query.filter(Package.id != package.id,
|
||||
|
||||
@@ -20,7 +20,7 @@ from flask_babel import gettext, lazy_gettext
|
||||
from flask_login import login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
|
||||
@@ -33,9 +33,6 @@ from . import bp, get_package_tabs
|
||||
@bp.route("/packages/<author>/<name>/releases/", methods=["GET", "POST"])
|
||||
@is_package_page
|
||||
def list_releases(package):
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
return render_template("packages/releases_list.html",
|
||||
package=package,
|
||||
tabs=get_package_tabs(current_user, package), current_tab="releases")
|
||||
@@ -52,7 +49,7 @@ def get_mt_releases(is_max):
|
||||
|
||||
|
||||
class CreatePackageReleaseForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
|
||||
uploadOpt = RadioField(lazy_gettext("Method"), choices=[("upload", lazy_gettext("File Upload"))], default="upload")
|
||||
vcsLabel = StringField(lazy_gettext("Git reference (ie: commit hash, branch, or tag)"), default=None)
|
||||
fileUpload = FileField(lazy_gettext("File Upload"))
|
||||
@@ -60,7 +57,8 @@ class CreatePackageReleaseForm(FlaskForm):
|
||||
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
|
||||
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
class EditPackageReleaseForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
|
||||
@@ -110,9 +108,6 @@ def create_release(package):
|
||||
@bp.route("/packages/<author>/<name>/releases/<id>/download/")
|
||||
@is_package_page
|
||||
def download_release(package, id):
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
release = PackageRelease.query.get(id)
|
||||
if release is None or release.package != package:
|
||||
abort(404)
|
||||
|
||||
@@ -25,8 +25,8 @@ from flask_wtf import FlaskForm
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
|
||||
Permission
|
||||
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required
|
||||
Permission, AuditSeverity
|
||||
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required, addAuditLog
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
|
||||
|
||||
@@ -54,10 +54,11 @@ def review(package):
|
||||
flash(gettext("You can't review your own package!"), "danger")
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
|
||||
abort(404)
|
||||
|
||||
review = PackageReview.query.filter_by(package=package, author=current_user).first()
|
||||
can_review = review is not None or current_user.canReviewRL()
|
||||
|
||||
if not can_review:
|
||||
flash(gettext("You've reviewed too many packages recently. Please wait before trying again, and consider making your reviews more detailed"), "danger")
|
||||
|
||||
form = ReviewForm(formdata=request.form, obj=review)
|
||||
|
||||
@@ -68,7 +69,7 @@ def review(package):
|
||||
form.comment.data = review.thread.replies[0].comment
|
||||
|
||||
# Validate and submit
|
||||
elif form.validate_on_submit():
|
||||
elif can_review and form.validate_on_submit():
|
||||
was_new = False
|
||||
if not review:
|
||||
was_new = True
|
||||
@@ -129,28 +130,41 @@ def review(package):
|
||||
form=form, package=package, review=review)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/review/delete/", methods=["POST"])
|
||||
@bp.route("/packages/<author>/<name>/reviews/<reviewer>/delete/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def delete_review(package):
|
||||
review = PackageReview.query.filter_by(package=package, author=current_user).first()
|
||||
def delete_review(package, reviewer):
|
||||
review = PackageReview.query \
|
||||
.filter(PackageReview.package == package, PackageReview.author.has(username=reviewer)) \
|
||||
.first()
|
||||
if review is None or review.package != package:
|
||||
abort(404)
|
||||
|
||||
if not review.checkPerm(current_user, Permission.DELETE_REVIEW):
|
||||
abort(403)
|
||||
|
||||
thread = review.thread
|
||||
|
||||
reply = ThreadReply()
|
||||
reply.thread = thread
|
||||
reply.author = current_user
|
||||
reply.comment = "_converted review into a thread_"
|
||||
reply.is_status_update = True
|
||||
db.session.add(reply)
|
||||
|
||||
thread.review = None
|
||||
|
||||
msg = "Converted review by {} to thread".format(review.author.display_name)
|
||||
addAuditLog(AuditSeverity.MODERATION if current_user.username != reviewer else AuditSeverity.NORMAL,
|
||||
current_user, msg, thread.getViewURL(), thread.package)
|
||||
|
||||
notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title)
|
||||
addNotification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
|
||||
|
||||
db.session.delete(review)
|
||||
|
||||
package.recalcScore()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
@@ -228,4 +242,4 @@ def review_votes(package):
|
||||
user_biases_info.sort(key=lambda x: -abs(x.balance))
|
||||
|
||||
return render_template("packages/review_votes.html", form=form, package=package, reviews=package.reviews,
|
||||
user_biases=user_biases_info)
|
||||
user_biases=user_biases_info)
|
||||
|
||||
@@ -20,7 +20,7 @@ from flask_babel import gettext, lazy_gettext
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_login import login_required
|
||||
from wtforms import *
|
||||
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.utils import *
|
||||
@@ -87,7 +87,7 @@ def create_screenshot(package):
|
||||
form = CreateScreenshotForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data)
|
||||
do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data, False)
|
||||
return redirect(package.getURL("packages.screenshots"))
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
||||
@@ -54,7 +54,8 @@ def report():
|
||||
|
||||
task = None
|
||||
for admin in User.query.filter_by(rank=UserRank.ADMIN).all():
|
||||
task = send_user_email.delay(admin.email, f"User report from {user_info}", text)
|
||||
task = send_user_email.delay(admin.email, admin.locale or "en",
|
||||
f"User report from {user_info}", text)
|
||||
|
||||
post_discord_webhook.delay(None if is_anon else current_user.username, f"**New Report**\n{url}\n\n{form.message.data}", True)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.utils import *
|
||||
|
||||
bp = Blueprint("tasks", __name__)
|
||||
|
||||
|
||||
@csrf.exempt
|
||||
@bp.route("/tasks/getmeta/new/", methods=["POST"])
|
||||
@login_required
|
||||
@@ -36,6 +37,7 @@ def start_getmeta():
|
||||
"poll_url": url_for("tasks.check", id=aresult.id),
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/tasks/<id>/")
|
||||
def check(id):
|
||||
result = celery.AsyncResult(id)
|
||||
@@ -43,7 +45,6 @@ def check(id):
|
||||
traceback = result.traceback
|
||||
result = result.result
|
||||
|
||||
None
|
||||
if isinstance(result, Exception):
|
||||
info = {
|
||||
'id': id,
|
||||
|
||||
@@ -36,10 +36,12 @@ def list_all():
|
||||
if not Permission.SEE_THREAD.check(current_user):
|
||||
query = query.filter_by(private=False)
|
||||
|
||||
package = None
|
||||
pid = request.args.get("pid")
|
||||
if pid:
|
||||
pid = get_int_or_abort(pid)
|
||||
query = query.filter_by(package_id=pid)
|
||||
package = Package.query.get(pid)
|
||||
query = query.filter_by(package=package)
|
||||
|
||||
query = query.filter_by(review_id=None)
|
||||
|
||||
@@ -50,7 +52,7 @@ def list_all():
|
||||
|
||||
pagination = query.paginate(page, num, True)
|
||||
|
||||
return render_template("threads/list.html", pagination=pagination, threads=pagination.items)
|
||||
return render_template("threads/list.html", pagination=pagination, threads=pagination.items, package=package)
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
|
||||
@@ -218,54 +220,51 @@ def view(id):
|
||||
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
|
||||
abort(404)
|
||||
|
||||
if current_user.is_authenticated and request.method == "POST":
|
||||
comment = request.form["comment"]
|
||||
form = CommentForm(formdata=request.form) if thread.checkPerm(current_user, Permission.COMMENT_THREAD) else None
|
||||
|
||||
if not thread.checkPerm(current_user, Permission.COMMENT_THREAD):
|
||||
flash(gettext("You cannot comment on this thread"), "danger")
|
||||
return redirect(thread.getViewURL())
|
||||
# Check that title is none to load comments into textarea if redirected from new thread page
|
||||
if form and form.validate_on_submit() and request.form.get("title") is None:
|
||||
comment = form.comment.data
|
||||
|
||||
if not current_user.canCommentRL():
|
||||
flash(gettext("Please wait before commenting again"), "danger")
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
if 2000 >= len(comment) > 3:
|
||||
reply = ThreadReply()
|
||||
reply.author = current_user
|
||||
reply.comment = comment
|
||||
db.session.add(reply)
|
||||
reply = ThreadReply()
|
||||
reply.author = current_user
|
||||
reply.comment = comment
|
||||
db.session.add(reply)
|
||||
|
||||
thread.replies.append(reply)
|
||||
if not current_user in thread.watchers:
|
||||
thread.watchers.append(current_user)
|
||||
thread.replies.append(reply)
|
||||
if current_user not in thread.watchers:
|
||||
thread.watchers.append(current_user)
|
||||
|
||||
for mentioned_username in get_user_mentions(render_markdown(comment)):
|
||||
mentioned = User.query.filter_by(username=mentioned_username)
|
||||
if mentioned is None:
|
||||
continue
|
||||
for mentioned_username in get_user_mentions(render_markdown(comment)):
|
||||
mentioned = User.query.filter_by(username=mentioned_username).first()
|
||||
if mentioned is None:
|
||||
continue
|
||||
|
||||
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
|
||||
addNotification(mentioned, current_user, NotificationType.THREAD_REPLY,
|
||||
msg, thread.getViewURL(), thread.package)
|
||||
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
|
||||
addNotification(mentioned, current_user, NotificationType.THREAD_REPLY,
|
||||
msg, thread.getViewURL(), thread.package)
|
||||
|
||||
msg = "New comment on '{}'".format(thread.title)
|
||||
addNotification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.getViewURL(), thread.package)
|
||||
thread.watchers.append(mentioned)
|
||||
|
||||
if thread.author == get_system_user():
|
||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
||||
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
|
||||
thread.getViewURL(), thread.package)
|
||||
post_discord_webhook.delay(current_user.username,
|
||||
"Replied to bot messages: {}".format(thread.getViewURL(absolute=True)), True)
|
||||
msg = "New comment on '{}'".format(thread.title)
|
||||
addNotification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.getViewURL(), thread.package)
|
||||
|
||||
db.session.commit()
|
||||
if thread.author == get_system_user():
|
||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
||||
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
|
||||
thread.getViewURL(), thread.package)
|
||||
post_discord_webhook.delay(current_user.username,
|
||||
"Replied to bot messages: {}".format(thread.getViewURL(absolute=True)), True)
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
db.session.commit()
|
||||
|
||||
else:
|
||||
flash(gettext("Comment needs to be between 3 and 2000 characters."), "danger")
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
return render_template("threads/view.html", thread=thread)
|
||||
return render_template("threads/view.html", thread=thread, form=form)
|
||||
|
||||
|
||||
class ThreadForm(FlaskForm):
|
||||
@@ -284,27 +283,25 @@ def new():
|
||||
if "pid" in request.args:
|
||||
package = Package.query.get(int(request.args.get("pid")))
|
||||
if package is None:
|
||||
flash(gettext("Unable to find that package!"), "danger")
|
||||
abort(404)
|
||||
|
||||
# Don't allow making orphan threads on approved packages for now
|
||||
if package is None:
|
||||
abort(403)
|
||||
def_is_private = request.args.get("private") or False
|
||||
if package is None and not current_user.rank.atLeast(UserRank.APPROVER):
|
||||
abort(404)
|
||||
|
||||
def_is_private = request.args.get("private") or False
|
||||
if package is None:
|
||||
def_is_private = True
|
||||
allow_change = package and package.approved
|
||||
allow_private_change = not package or package.approved
|
||||
is_review_thread = package and not package.approved
|
||||
|
||||
# Check that user can make the thread
|
||||
if not package.checkPerm(current_user, Permission.CREATE_THREAD):
|
||||
if package and not package.checkPerm(current_user, Permission.CREATE_THREAD):
|
||||
flash(gettext("Unable to create thread!"), "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
# Only allow creating one thread when not approved
|
||||
elif is_review_thread and package.review_thread is not None:
|
||||
flash(gettext("An approval thread already exists!"), "danger")
|
||||
return redirect(package.review_thread.getViewURL())
|
||||
# Redirect submit to `view` page, which checks for `title` in the form data and so won't commit the reply
|
||||
flash(gettext("An approval thread already exists! Consider replying there instead"), "danger")
|
||||
return redirect(package.review_thread.getViewURL(), code=307)
|
||||
|
||||
elif not current_user.canOpenThreadRL():
|
||||
flash(gettext("Please wait before opening another thread"), "danger")
|
||||
@@ -324,12 +321,12 @@ def new():
|
||||
thread = Thread()
|
||||
thread.author = current_user
|
||||
thread.title = form.title.data
|
||||
thread.private = form.private.data if allow_change else def_is_private
|
||||
thread.private = form.private.data if allow_private_change else def_is_private
|
||||
thread.package = package
|
||||
db.session.add(thread)
|
||||
|
||||
thread.watchers.append(current_user)
|
||||
if package is not None and package.author != current_user:
|
||||
if package and package.author != current_user:
|
||||
thread.watchers.append(package.author)
|
||||
|
||||
reply = ThreadReply()
|
||||
@@ -346,7 +343,7 @@ def new():
|
||||
package.review_thread = thread
|
||||
|
||||
for mentioned_username in get_user_mentions(render_markdown(form.comment.data)):
|
||||
mentioned = User.query.filter_by(username=mentioned_username)
|
||||
mentioned = User.query.filter_by(username=mentioned_username).first()
|
||||
if mentioned is None:
|
||||
continue
|
||||
|
||||
@@ -354,6 +351,8 @@ def new():
|
||||
addNotification(mentioned, current_user, NotificationType.NEW_THREAD,
|
||||
msg, thread.getViewURL(), thread.package)
|
||||
|
||||
thread.watchers.append(mentioned)
|
||||
|
||||
notif_msg = "New thread '{}'".format(thread.title)
|
||||
if package is not None:
|
||||
addNotification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.getViewURL(), package)
|
||||
@@ -361,7 +360,6 @@ def new():
|
||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
||||
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.getViewURL(), package)
|
||||
|
||||
|
||||
if is_review_thread:
|
||||
post_discord_webhook.delay(current_user.username,
|
||||
"Opened approval thread: {}".format(thread.getViewURL(absolute=True)), True)
|
||||
@@ -371,7 +369,7 @@ def new():
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
|
||||
return render_template("threads/new.html", form=form, allow_private_change=allow_change, package=package)
|
||||
return render_template("threads/new.html", form=form, allow_private_change=allow_private_change, package=package)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/comments/")
|
||||
@@ -380,4 +378,4 @@ def user_comments(username):
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
return render_template("threads/user_comments.html", user=user, replies=user.replies)
|
||||
return render_template("threads/user_comments.html", user=user, replies=user.replies)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
from celery import uuid
|
||||
from flask import *
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.models import *
|
||||
from app.querybuilder import QueryBuilder
|
||||
@@ -74,14 +74,19 @@ def view_editor():
|
||||
|
||||
unfulfilled_meta_packages = MetaPackage.query \
|
||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
||||
.filter(MetaPackage.dependencies.any(Package.state == PackageState.APPROVED, optional=False)) \
|
||||
.filter(MetaPackage.dependencies.any(Dependency.depender.has(state=PackageState.APPROVED), optional=False)) \
|
||||
.order_by(db.asc(MetaPackage.name)).count()
|
||||
|
||||
audit_log = AuditLogEntry.query \
|
||||
.filter(AuditLogEntry.package.has()) \
|
||||
.order_by(db.desc(AuditLogEntry.created_at)) \
|
||||
.limit(20).all()
|
||||
|
||||
return render_template("todo/editor.html", current_tab="editor",
|
||||
packages=packages, wip_packages=wip_packages, releases=releases, screenshots=screenshots,
|
||||
canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn,
|
||||
license_needed=license_needed, total_packages=total_packages, total_to_tag=total_to_tag,
|
||||
unfulfilled_meta_packages=unfulfilled_meta_packages)
|
||||
unfulfilled_meta_packages=unfulfilled_meta_packages, audit_log=audit_log)
|
||||
|
||||
|
||||
@bp.route("/todo/topics/")
|
||||
@@ -143,7 +148,7 @@ def tags_user():
|
||||
def metapackages():
|
||||
mpackages = MetaPackage.query \
|
||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
||||
.filter(MetaPackage.dependencies.any(optional=False)) \
|
||||
.filter(MetaPackage.dependencies.any(Dependency.depender.has(state=PackageState.APPROVED), optional=False)) \
|
||||
.order_by(db.asc(MetaPackage.name)).all()
|
||||
|
||||
return render_template("todo/metapackages.html", mpackages=mpackages)
|
||||
@@ -168,6 +173,11 @@ def view_user(username=None):
|
||||
Package.state == PackageState.CHANGES_NEEDED)) \
|
||||
.order_by(db.asc(Package.created_at)).all()
|
||||
|
||||
packages_with_small_screenshots = user.maintained_packages \
|
||||
.filter(Package.screenshots.any(and_(PackageScreenshot.width < PackageScreenshot.SOFT_MIN_SIZE[0],
|
||||
PackageScreenshot.height < PackageScreenshot.SOFT_MIN_SIZE[1]))) \
|
||||
.all()
|
||||
|
||||
outdated_packages = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED,
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
||||
@@ -180,12 +190,14 @@ def view_user(username=None):
|
||||
.all()
|
||||
|
||||
needs_tags = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED) \
|
||||
.filter_by(tags=None).order_by(db.asc(Package.title)).all()
|
||||
.filter(Package.state != PackageState.DELETED, Package.tags==None) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
return render_template("todo/user.html", current_tab="user", user=user,
|
||||
unapproved_packages=unapproved_packages, outdated_packages=outdated_packages,
|
||||
needs_tags=needs_tags, topics_to_add=topics_to_add)
|
||||
needs_tags=needs_tags, topics_to_add=topics_to_add,
|
||||
packages_with_small_screenshots=packages_with_small_screenshots,
|
||||
screenshot_min_size=PackageScreenshot.HARD_MIN_SIZE, screenshot_rec_size=PackageScreenshot.SOFT_MIN_SIZE)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/update-configs/apply-all/", methods=["POST"])
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
from flask_babel import gettext, lazy_gettext, get_locale
|
||||
from flask_login import current_user, login_required, logout_user, login_user
|
||||
from flask_wtf import FlaskForm
|
||||
from sqlalchemy import or_
|
||||
@@ -102,7 +102,8 @@ def logout():
|
||||
class RegisterForm(FlaskForm):
|
||||
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[nonEmptyOrNone])
|
||||
username = StringField(lazy_gettext("Username"), [InputRequired(),
|
||||
Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext("Only a-zA-Z0-9._ allowed"))])
|
||||
Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext(
|
||||
"Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed"))])
|
||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
||||
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
|
||||
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()])
|
||||
@@ -142,7 +143,7 @@ def handle_register(form):
|
||||
|
||||
user_by_email = User.query.filter_by(email=form.email.data).first()
|
||||
if user_by_email:
|
||||
send_anon_email.delay(form.email.data, gettext("Email already in use"),
|
||||
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
|
||||
gettext("We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
|
||||
display_name=user_by_email.display_name))
|
||||
return redirect(url_for("users.email_sent"))
|
||||
@@ -168,7 +169,7 @@ def handle_register(form):
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(form.email.data, token)
|
||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
||||
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
@@ -209,25 +210,11 @@ def forgot_password():
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(form.email.data, token)
|
||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
||||
else:
|
||||
send_anon_email.delay(email, "Unable to find account", """
|
||||
<p>
|
||||
We were unable to perform the password reset as we could not find an account
|
||||
associated with this email.
|
||||
</p>
|
||||
<p>
|
||||
This may be because you used another email with your account, or because you never
|
||||
confirmed your email.
|
||||
</p>
|
||||
<p>
|
||||
You can use GitHub to log in if it is associated with your account.
|
||||
Otherwise, you may need to contact rubenwardy for help.
|
||||
</p>
|
||||
<p>
|
||||
If you weren't expecting to receive this email, then you can safely ignore it.
|
||||
</p>
|
||||
""")
|
||||
html = render_template("emails/unable_to_find_account.html")
|
||||
send_anon_email.delay(email, get_locale().language, gettext("Unable to find account"),
|
||||
html, html)
|
||||
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
@@ -269,7 +256,7 @@ def handle_set_password(form):
|
||||
|
||||
user_by_email = User.query.filter_by(email=form.email.data).first()
|
||||
if user_by_email:
|
||||
send_anon_email.delay(form.email.data, gettext("Email already in use"),
|
||||
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
|
||||
gettext(u"We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
|
||||
display_name=user_by_email.display_name))
|
||||
else:
|
||||
@@ -282,7 +269,7 @@ def handle_set_password(form):
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(form.email.data, token)
|
||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
||||
|
||||
flash(gettext("Your password has been changed successfully."), "success")
|
||||
return redirect(url_for("users.email_sent"))
|
||||
@@ -360,6 +347,7 @@ def verify_email():
|
||||
|
||||
if user.email:
|
||||
send_user_email.delay(user.email,
|
||||
user.locale or "en",
|
||||
gettext("Email address changed"),
|
||||
gettext("Your email address has changed. If you didn't request this, please contact an administrator."))
|
||||
|
||||
@@ -401,7 +389,7 @@ def unsubscribe_verify():
|
||||
|
||||
sub.token = randomString(32)
|
||||
db.session.commit()
|
||||
send_unsubscribe_verify.delay(form.email.data)
|
||||
send_unsubscribe_verify.delay(form.email.data, get_locale().language)
|
||||
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def claim_forums():
|
||||
method = request.args.get("method")
|
||||
|
||||
if not is_username_valid(username):
|
||||
flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger")
|
||||
flash(gettext("Invalid username, Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed. Consider contacting an admin"), "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
@@ -62,7 +62,7 @@ def claim_forums():
|
||||
username = request.form.get("username")
|
||||
|
||||
if not is_username_valid(username):
|
||||
flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger")
|
||||
flash(gettext("Invalid username, Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed. Consider contacting an admin"), "danger")
|
||||
elif ctype == "github":
|
||||
task = checkForumAccount.delay(username)
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim_forums", username=username, method="github")))
|
||||
|
||||
@@ -66,6 +66,9 @@ class Medal:
|
||||
|
||||
@classmethod
|
||||
def make_locked(cls, description: str, progress: Tuple[int, int]):
|
||||
if progress[0] is None or progress[1] is None:
|
||||
raise Exception("Invalid progress")
|
||||
|
||||
return Medal(description=description, progress=progress)
|
||||
|
||||
|
||||
@@ -127,7 +130,7 @@ def get_user_medals(user: User) -> Tuple[List[Medal], List[Medal]]:
|
||||
|
||||
unlocked.append(Medal.make_unlocked(
|
||||
place_to_color(review_idx + 1), "fa-star-half-alt", title, description))
|
||||
else:
|
||||
elif review_boundary is not None:
|
||||
description = gettext(u"Consider writing more helpful reviews to get a medal.")
|
||||
if review_idx:
|
||||
description += " " + gettext(u"You are in place %(place)s.", place=review_idx + 1)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from flask import *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
from flask_babel import gettext, lazy_gettext, get_locale
|
||||
from flask_login import current_user, login_required, logout_user
|
||||
from flask_wtf import FlaskForm
|
||||
from sqlalchemy import or_
|
||||
@@ -156,7 +156,7 @@ def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new,
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(newEmail, token)
|
||||
send_verify_email.delay(newEmail, token, get_locale().language)
|
||||
return redirect(url_for("users.email_sent"))
|
||||
|
||||
db.session.commit()
|
||||
@@ -342,7 +342,7 @@ def modtools_set_email(username):
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
send_verify_email.delay(user.email, token)
|
||||
send_verify_email.delay(user.email, token, user.locale or "en")
|
||||
|
||||
flash(f"Set email and sent a password reset on {user.username}", "success")
|
||||
return redirect(url_for("users.modtools", username=username))
|
||||
@@ -358,11 +358,45 @@ def modtools_ban(username):
|
||||
if not user.checkPerm(current_user, Permission.CHANGE_RANK):
|
||||
abort(403)
|
||||
|
||||
user.rank = UserRank.BANNED
|
||||
message = request.form["message"]
|
||||
expires_at = request.form.get("expires_at")
|
||||
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, f"Banned {user.username}",
|
||||
user.ban = UserBan()
|
||||
user.ban.banned_by = current_user
|
||||
user.ban.message = message
|
||||
|
||||
if expires_at and expires_at != "":
|
||||
user.ban.expires_at = expires_at
|
||||
else:
|
||||
user.rank = UserRank.BANNED
|
||||
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, f"Banned {user.username}, expires {user.ban.expires_at or '-'}, message: {message}",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Banned {user.username}", "success")
|
||||
return redirect(url_for("users.modtools", username=username))
|
||||
return redirect(url_for("users.modtools", username=username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/modtools/unban/", methods=["POST"])
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def modtools_unban(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.checkPerm(current_user, Permission.CHANGE_RANK):
|
||||
abort(403)
|
||||
|
||||
if user.ban:
|
||||
db.session.delete(user.ban)
|
||||
|
||||
if user.rank == UserRank.BANNED:
|
||||
user.rank = UserRank.MEMBER
|
||||
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, f"Unbanned {user.username}",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Unbanned {user.username}", "success")
|
||||
return redirect(url_for("users.modtools", username=username))
|
||||
|
||||
69
app/blueprints/zipgrep/__init__.py
Normal file
69
app/blueprints/zipgrep/__init__.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2022 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from celery import uuid
|
||||
from flask import Blueprint, render_template, redirect, request, abort
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app.tasks import celery
|
||||
from app.utils import rank_required
|
||||
|
||||
bp = Blueprint("zipgrep", __name__)
|
||||
|
||||
from app.models import *
|
||||
from app.tasks.zipgrep import search_in_releases
|
||||
|
||||
|
||||
class SearchForm(FlaskForm):
|
||||
query = StringField(lazy_gettext("Text to find (regex)"), [InputRequired(), Length(6, 100)])
|
||||
file_filter = StringField(lazy_gettext("File filter"), [InputRequired(), Length(1, 100)], default="*.lua")
|
||||
remember_me = BooleanField(lazy_gettext("Remember me"), default=True)
|
||||
submit = SubmitField(lazy_gettext("Search"))
|
||||
|
||||
|
||||
@bp.route("/zipgrep/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def zipgrep_search():
|
||||
form = SearchForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
task_id = uuid()
|
||||
search_in_releases.apply_async((form.query.data, form.file_filter.data), task_id=task_id)
|
||||
result_url = url_for("zipgrep.view_results", id=task_id)
|
||||
return redirect(url_for("tasks.check", id=task_id, r=result_url))
|
||||
|
||||
return render_template("zipgrep/search.html", form=form)
|
||||
|
||||
|
||||
@bp.route("/zipgrep/<id>/")
|
||||
def view_results(id):
|
||||
result = celery.AsyncResult(id)
|
||||
if result.status == "PENDING":
|
||||
abort(404)
|
||||
|
||||
if result.status != "SUCCESS" or isinstance(result.result, Exception):
|
||||
result_url = url_for("zipgrep.view_results", id=id)
|
||||
return redirect(url_for("tasks.check", id=id, r=result_url))
|
||||
|
||||
matches = result.result["matches"]
|
||||
for match in matches:
|
||||
match["package"] = Package.query.filter(
|
||||
Package.name == match["package"]["name"],
|
||||
Package.author.has(username=match["package"]["author"])).one()
|
||||
|
||||
return render_template("zipgrep/view_results.html", query=result.result["query"], matches=matches)
|
||||
@@ -89,6 +89,8 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
|
||||
* `website`: Website URL.
|
||||
* `issue_tracker`: Issue tracker URL.
|
||||
* `forums`: forum topic ID.
|
||||
* `video_url`: URL to a video.
|
||||
* `game_support`: Array of game support information objects. Not currently documented, as subject to change.
|
||||
* GET `/api/packages/<username>/<name>/dependencies/`
|
||||
* Returns dependencies, with suggested candidates
|
||||
* If query argument `only_hard` is present, only hard deps will be returned.
|
||||
@@ -224,6 +226,7 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/
|
||||
* `url`: absolute URL to screenshot.
|
||||
* `created_at`: ISO time.
|
||||
* `order`: Number used in ordering.
|
||||
* `is_cover_image`: true for cover image.
|
||||
* GET `/api/packages/<username>/<name>/screenshots/<id>/` (Read)
|
||||
* Returns screenshot dictionary like above.
|
||||
* POST `/api/packages/<username>/<name>/screenshots/new/` (Create)
|
||||
@@ -231,12 +234,16 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/
|
||||
* Body is multipart form data.
|
||||
* `title`: human-readable name for the screenshot, shown as a caption and alt text.
|
||||
* `file`: multipart file to upload, like `<input type=file>`.
|
||||
* `is_cover_image`: set cover image to this.
|
||||
* DELETE `/api/packages/<username>/<name>/screenshots/<id>/` (Delete)
|
||||
* Requires authentication.
|
||||
* Deletes screenshot.
|
||||
* POST `/api/packages/<username>/<name>/screenshots/order/`
|
||||
* Requires authentication.
|
||||
* Body is a JSON array containing the screenshot IDs in their order.
|
||||
* POST `/api/packages/<username>/<name>/screenshots/cover-image/`
|
||||
* Requires authentication.
|
||||
* Body is a JSON dictionary with "cover_image" containing the screenshot ID.
|
||||
|
||||
Currently, to get a different size of thumbnail you can replace the number in `/thumbnails/1/` with any number from 1-3.
|
||||
The resolutions returned may change in the future, and we may move to a more capable thumbnail generation.
|
||||
@@ -248,6 +255,11 @@ Examples:
|
||||
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" \
|
||||
-F title="My Release" -F file=@path/to/screnshot.png
|
||||
|
||||
# Create screenshot and set it as the cover image
|
||||
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" \
|
||||
-F title="My Release" -F file=@path/to/screnshot.png -F is_cover_image="true"
|
||||
|
||||
# Delete screenshot
|
||||
curl -X DELETE https://content.minetest.net/api/packages/username/name/screenshots/3/ \
|
||||
@@ -257,6 +269,11 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/screensho
|
||||
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/order/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d "[13, 2, 5, 7]"
|
||||
|
||||
# Set cover image
|
||||
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/cover-image/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d "{ 'cover_image': 123 }"
|
||||
```
|
||||
|
||||
|
||||
@@ -329,9 +346,11 @@ Supported query parameters:
|
||||
### Tags
|
||||
|
||||
* GET `/api/tags/` ([View](/api/tags/)): List of:
|
||||
* `name`: technical name
|
||||
* `title`: human-readable title
|
||||
* `description`: tag description or null
|
||||
* `name`: technical name.
|
||||
* `title`: human-readable title.
|
||||
* `description`: tag description or null.
|
||||
* `is_protected`: boolean, whether the tag is protected (can only be set by Editors in the web interface).
|
||||
* `views`: number of views of this tag.
|
||||
|
||||
### Content Warnings
|
||||
|
||||
@@ -375,3 +394,5 @@ Supported query parameters:
|
||||
* `pop_txp`: popular textures
|
||||
* `pop_game`: popular games
|
||||
* `high_reviewed`: highest reviewed
|
||||
* GET `/api/welcome/v1/` ([View](/api/welcome/v1/)) - in-menu welcome dialog. Experimental (may change without warning)
|
||||
* `featured`: featured games
|
||||
|
||||
@@ -25,8 +25,8 @@ A flag can be:
|
||||
There are also two meta-flags, which are designed so that we can change how different platforms filter the package list
|
||||
without making a release.
|
||||
|
||||
* `android_default`: currently same as `*, deprecated`. Hides all content warnings, WIP packages, and deprecated packages
|
||||
* `desktop_default`: currently same as `deprecated`. Hides all WIP and deprecated packages
|
||||
* `android_default`: currently same as `*, deprecated`. Hides all content warnings and deprecated packages
|
||||
* `desktop_default`: currently same as `deprecated`. Hides deprecated packages
|
||||
|
||||
## Content Warnings
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ is available.
|
||||
### Meta and packaging
|
||||
|
||||
* MUST: `screenshot.png` is present and up-to-date, with a correct aspect ratio (3:2, at least 300x200).
|
||||
* MUST: Have a high resolution cover image on ContentDB (at least 1280x768 pixels).
|
||||
* MUST: Have a high resolution cover image on ContentDB (at least 1280x720 pixels).
|
||||
It may be shown cropped to 16:9 aspect ratio, or shorter.
|
||||
* MUST: mod.conf/game.conf/texture_pack.conf present with:
|
||||
* name (if mod or game)
|
||||
|
||||
@@ -61,6 +61,7 @@ It should be a JSON dictionary with one or more of the following optional keys:
|
||||
* `website`: Website URL.
|
||||
* `issue_tracker`: Issue tracker URL.
|
||||
* `forums`: forum topic ID.
|
||||
* `video_url`: URL to a video.
|
||||
|
||||
Use `null` to unset fields where relevant.
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ the listings and to combat abuse.
|
||||
* **Don't use the name of another mod unless your mod is a fork or reimplementation.** <sup>3</sup>
|
||||
* **Licenses must allow derivatives, redistribution, and must not discriminate.** <sup>4</sup>
|
||||
* **Don't put promotions or advertisements in any package metadata.** <sup>5</sup>
|
||||
* **Don't manipulate package placement using reviews or downloads.** <sup>6</sup>
|
||||
* **Screenshots must not be misleading.** <sup>7</sup>
|
||||
* **The ContentDB admin reserves the right to remove packages for any reason**,
|
||||
including ones not covered by this document, and to ban users who abuse
|
||||
this service. <sup>1</sup>
|
||||
@@ -46,7 +48,7 @@ but still has value. Note that this doesn't mean that you should add a thing
|
||||
you started working on yesterday, it's worth adding all the basic stuff to
|
||||
make your package useful.
|
||||
|
||||
You should make sure to mark Work in Progress stuff as such in the "maintenance status" column,
|
||||
You should make sure to mark Work in Progress stuff as such in the "maintenance status" column,
|
||||
as this will help advise players.
|
||||
|
||||
Adding non-player facing mods, such as libraries and server tools, is perfectly fine
|
||||
@@ -151,6 +153,42 @@ You must not attempt to unfairly manipulate your package's ranking, whether by r
|
||||
Doing so may result in temporary or permanent suspension from ContentDB.
|
||||
|
||||
|
||||
## 7. Reporting Violations
|
||||
## 7. Screenshots
|
||||
|
||||
1. **Screenshots must not violate copyright.** You should have the rights to the
|
||||
screenshot.
|
||||
|
||||
2. **Screenshots must depict the actual content of the package in some way, and
|
||||
not be misleading.**
|
||||
|
||||
Do not use idealized mockups or blender concept renders if they do not
|
||||
accurately reflect in-game appearance.
|
||||
|
||||
Content in screenshots that is prominently displayed or "focal" should be
|
||||
either present in, or interact with, the package in some way. These can
|
||||
include things in other packages if they have a dependency relationship
|
||||
(either way), or if the submitted package in some way enhances, extends, or
|
||||
alters that content.
|
||||
|
||||
Unrelated package content can be allowed to show what the package content
|
||||
will look like in a typical/realistic game scene, but should be "in the
|
||||
background" only as far as possible.
|
||||
|
||||
3. **Screenshots must only contain content appropriate for the Content Warnings of
|
||||
the package.**
|
||||
|
||||
4. **Screenshots should be MOSTLY in-game screenshots, if applicable.** Some
|
||||
alterations on in-game screenshots are okay, such as collages, added text,
|
||||
some reasonable compositing.
|
||||
|
||||
Don't just use one of the textures from the package; show it in-situ as it
|
||||
actually looks in the game.
|
||||
|
||||
5. **Packages should have a screenshot when reasonably applicable.**
|
||||
|
||||
6. **Screenshots should be of reasonable dimensions.** We recommend using 1920x1080.
|
||||
|
||||
|
||||
## 8. Reporting Violations
|
||||
|
||||
Please click "Report" on the package page.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
title: Privacy Policy
|
||||
|
||||
Last Updated: 2022-01-23
|
||||
([View updates](https://github.com/minetest/contentdb/commits/master/app/flatpages/privacy_policy.md))
|
||||
|
||||
## What Information is Collected
|
||||
|
||||
**All users:**
|
||||
@@ -9,13 +12,14 @@ title: Privacy Policy
|
||||
* IP address
|
||||
* Page URL
|
||||
* Response status code
|
||||
* Preferred language/locale. This defaults to the browser's locale, but can be changed by the user
|
||||
|
||||
**With an account:**
|
||||
|
||||
* Email address
|
||||
* Passwords (hashed and salted using BCrypt)
|
||||
* Profile information, such as website URLs and donation URLs
|
||||
* Comments and threads
|
||||
* Comments, threads, and reviews
|
||||
* Audit log actions (such as edits and logins) and their time stamps
|
||||
|
||||
ContentDB collects usernames of content creators from the forums,
|
||||
@@ -30,10 +34,12 @@ Please avoid giving other personal information as we do not want it.
|
||||
|
||||
* Logged HTTP requests may be used for debugging ContentDB.
|
||||
* Email addresses are used to:
|
||||
* Provide essential system messages, such as password resets.
|
||||
* Provide essential system messages, such as password resets and privacy policy updates.
|
||||
* Send notifications - the user may configure this to their needs, including opting out.
|
||||
* The admin may use ContentDB to send emails when they need to contact a user.
|
||||
* Passwords are used to authenticate the user.
|
||||
* The audit log is used to record actions that may be harmful
|
||||
* The audit log is used to record actions that may be harmful.
|
||||
* Preferred language/locale is used to translate emails and the ContentDB interface.
|
||||
* Other information is displayed as part of ContentDB's service.
|
||||
|
||||
## Who has access
|
||||
@@ -43,7 +49,7 @@ Please avoid giving other personal information as we do not want it.
|
||||
* Encrypted backups may be shared with selected Minetest staff members (moderators + core devs).
|
||||
The keys and the backups themselves are given to different people,
|
||||
requiring at least two staff members to read a backup.
|
||||
* Emails are visible to moderators and the admin.
|
||||
* Email addresses are visible to moderators and the admin.
|
||||
They have access to assist users, and they are not permitted to share email addresses.
|
||||
* Hashing protects passwords from being read whilst stored in the database or in backups.
|
||||
* Profile information is public, including URLs and linked accounts.
|
||||
@@ -52,11 +58,12 @@ Please avoid giving other personal information as we do not want it.
|
||||
* The complete audit log is visible to moderators.
|
||||
Users may see their own audit log actions on their account settings page.
|
||||
Owners, maintainers, and editors may be able to see the actions on a package in the future.
|
||||
* Preferred language can only be viewed by this with access to the database or a backup.
|
||||
* We may be required to share information with law enforcement.
|
||||
|
||||
## Location
|
||||
|
||||
The ContentDB production server is currently located in Canada.
|
||||
The ContentDB production server is currently located in Germany.
|
||||
Backups are stored in the UK.
|
||||
Encrypted backups may be stored in other countries, such as the US or EU.
|
||||
|
||||
|
||||
188
app/logic/game_support.py
Normal file
188
app/logic/game_support.py
Normal file
@@ -0,0 +1,188 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2022 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import sys
|
||||
|
||||
from typing import List, Dict, Optional, Iterator, Iterable
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.models import Package, MetaPackage, PackageType, PackageState, PackageGameSupport, db
|
||||
|
||||
"""
|
||||
get_game_support(package):
|
||||
if package is a game:
|
||||
return [ package ]
|
||||
|
||||
for all hard dependencies:
|
||||
support = support AND get_meta_package_support(dep)
|
||||
|
||||
return support
|
||||
|
||||
get_meta_package_support(meta):
|
||||
for package implementing meta package:
|
||||
support = support OR get_game_support(package)
|
||||
|
||||
return support
|
||||
"""
|
||||
|
||||
|
||||
minetest_game_mods = {
|
||||
"beds", "boats", "bucket", "carts", "default", "dungeon_loot", "env_sounds", "fire", "flowers",
|
||||
"give_initial_stuff", "map", "player_api", "sethome", "spawn", "tnt", "walls", "wool",
|
||||
"binoculars", "bones", "butterflies", "creative", "doors", "dye", "farming", "fireflies", "game_commands",
|
||||
"keys", "mtg_craftguide", "screwdriver", "sfinv", "stairs", "vessels", "weather", "xpanes",
|
||||
}
|
||||
|
||||
|
||||
mtg_mod_blacklist = {
|
||||
"repixture", "tutorial", "runorfall", "realtest_mt5", "mevo", "xaenvironment",
|
||||
"survivethedays"
|
||||
}
|
||||
|
||||
|
||||
class PackageSet:
|
||||
packages: Dict[str, Package]
|
||||
|
||||
def __init__(self, packages: Optional[Iterable[Package]] = None):
|
||||
self.packages = {}
|
||||
if packages:
|
||||
self.update(packages)
|
||||
|
||||
def update(self, packages: Iterable[Package]):
|
||||
for package in packages:
|
||||
key = package.getId()
|
||||
if key not in self.packages:
|
||||
self.packages[key] = package
|
||||
|
||||
def intersection_update(self, other):
|
||||
keys = set(self.packages.keys())
|
||||
keys.difference_update(set(other.packages.keys()))
|
||||
for key in keys:
|
||||
del self.packages[key]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.packages)
|
||||
|
||||
def __iter__(self):
|
||||
return self.packages.values().__iter__()
|
||||
|
||||
|
||||
class GameSupportResolver:
|
||||
checked_packages = set()
|
||||
checked_metapackages = set()
|
||||
resolved_packages: Dict[str, PackageSet] = {}
|
||||
resolved_metapackages: Dict[str, PackageSet] = {}
|
||||
|
||||
def resolve_for_meta_package(self, meta: MetaPackage, history: List[str]) -> PackageSet:
|
||||
print(f"Resolving for {meta.name}", file=sys.stderr)
|
||||
|
||||
key = meta.name
|
||||
if key in self.resolved_metapackages:
|
||||
return self.resolved_metapackages.get(key)
|
||||
|
||||
if key in self.checked_metapackages:
|
||||
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
|
||||
return PackageSet()
|
||||
|
||||
self.checked_metapackages.add(key)
|
||||
|
||||
retval = PackageSet()
|
||||
|
||||
for package in meta.packages:
|
||||
if package.state != PackageState.APPROVED:
|
||||
continue
|
||||
|
||||
if meta.name in minetest_game_mods and package.name in mtg_mod_blacklist:
|
||||
continue
|
||||
|
||||
ret = self.resolve(package, history)
|
||||
if len(ret) == 0:
|
||||
retval = PackageSet()
|
||||
break
|
||||
|
||||
retval.update(ret)
|
||||
|
||||
self.resolved_metapackages[key] = retval
|
||||
return retval
|
||||
|
||||
def resolve(self, package: Package, history: List[str]) -> PackageSet:
|
||||
db.session.merge(package)
|
||||
|
||||
key = package.getId()
|
||||
print(f"Resolving for {key}", file=sys.stderr)
|
||||
|
||||
history = history.copy()
|
||||
history.append(key)
|
||||
|
||||
if package.type == PackageType.GAME:
|
||||
return PackageSet([package])
|
||||
|
||||
if key in self.resolved_packages:
|
||||
return self.resolved_packages.get(key)
|
||||
|
||||
if key in self.checked_packages:
|
||||
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
|
||||
return PackageSet()
|
||||
|
||||
self.checked_packages.add(key)
|
||||
|
||||
if package.type != PackageType.MOD:
|
||||
raise LogicError(500, "Got non-mod")
|
||||
|
||||
retval = PackageSet()
|
||||
|
||||
for dep in package.dependencies.filter_by(optional=False).all():
|
||||
ret = self.resolve_for_meta_package(dep.meta_package, history)
|
||||
if len(ret) == 0:
|
||||
continue
|
||||
elif len(retval) == 0:
|
||||
retval.update(ret)
|
||||
else:
|
||||
retval.intersection_update(ret)
|
||||
if len(retval) == 0:
|
||||
raise LogicError(500, f"Detected game support contradiction, {key} may not be compatible with any games")
|
||||
|
||||
self.resolved_packages[key] = retval
|
||||
return retval
|
||||
|
||||
def update_all(self) -> None:
|
||||
for package in Package.query.filter(Package.type == PackageType.MOD, Package.state != PackageState.DELETED).all():
|
||||
retval = self.resolve(package, [])
|
||||
for game in retval:
|
||||
support = PackageGameSupport(package, game)
|
||||
db.session.add(support)
|
||||
|
||||
def update(self, package: Package) -> None:
|
||||
previous_supported: Dict[str, PackageGameSupport] = {}
|
||||
for support in package.supported_games.all():
|
||||
previous_supported[support.game.getId()] = support
|
||||
|
||||
retval = self.resolve(package, [])
|
||||
for game in retval:
|
||||
assert game
|
||||
|
||||
lookup = previous_supported.pop(game.getId(), None)
|
||||
if lookup is None:
|
||||
support = PackageGameSupport(package, game)
|
||||
db.session.add(support)
|
||||
elif lookup.confidence == 0:
|
||||
lookup.supports = True
|
||||
db.session.merge(lookup)
|
||||
|
||||
for game, support in previous_supported.items():
|
||||
if support.confidence == 0:
|
||||
db.session.remove(support)
|
||||
@@ -23,6 +23,7 @@ from app.logic.LogicError import LogicError
|
||||
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \
|
||||
License, UserRank, PackageDevState
|
||||
from app.utils import addAuditLog
|
||||
from app.utils.url import clean_youtube_url
|
||||
|
||||
|
||||
def check(cond: bool, msg: str):
|
||||
@@ -61,6 +62,7 @@ ALLOWED_FIELDS = {
|
||||
"issue_tracker": str,
|
||||
"issueTracker": str,
|
||||
"forums": int,
|
||||
"video_url": str,
|
||||
}
|
||||
|
||||
ALIASES = {
|
||||
@@ -104,11 +106,11 @@ def validate(data: dict):
|
||||
def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict,
|
||||
reason: str = None):
|
||||
if not package.checkPerm(user, Permission.EDIT_PACKAGE):
|
||||
raise LogicError(403, lazy_gettext("You do not have permission to edit this package"))
|
||||
raise LogicError(403, lazy_gettext("You don't have permission to edit this package"))
|
||||
|
||||
if "name" in data and package.name != data["name"] and \
|
||||
not package.checkPerm(user, Permission.CHANGE_NAME):
|
||||
raise LogicError(403, lazy_gettext("You do not have permission to change the package name"))
|
||||
raise LogicError(403, lazy_gettext("You don't have permission to change the package name"))
|
||||
|
||||
for alias, to in ALIASES.items():
|
||||
if alias in data:
|
||||
@@ -128,8 +130,13 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
||||
if "media_license" in data:
|
||||
data["media_license"] = get_license(data["media_license"])
|
||||
|
||||
if "video_url" in data and data["video_url"] is not None:
|
||||
data["video_url"] = clean_youtube_url(data["video_url"]) or data["video_url"]
|
||||
if "dQw4w9WgXcQ" in data["video_url"]:
|
||||
raise LogicError(403, "Never gonna give you up / Never gonna let you down / Never gonna run around and desert you")
|
||||
|
||||
for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license",
|
||||
"repo", "website", "issueTracker", "forums"]:
|
||||
"repo", "website", "issueTracker", "forums", "video_url"]:
|
||||
if key in data:
|
||||
setattr(package, key, data[key])
|
||||
|
||||
@@ -152,7 +159,7 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
||||
raise LogicError(400, "Unknown tag: " + tag_id)
|
||||
|
||||
if not was_web and tag.is_protected:
|
||||
break
|
||||
continue
|
||||
|
||||
if tag.is_protected and tag not in old_tags and not user.rank.atLeast(UserRank.EDITOR):
|
||||
raise LogicError(400, lazy_gettext("Unable to add protected tag %(title)s to package", title=tag.title))
|
||||
|
||||
@@ -29,7 +29,7 @@ from app.utils import AuditSeverity, addAuditLog, nonEmptyOrNone
|
||||
|
||||
def check_can_create_release(user: User, package: Package):
|
||||
if not package.checkPerm(user, Permission.MAKE_RELEASE):
|
||||
raise LogicError(403, lazy_gettext("You do not have permission to make releases"))
|
||||
raise LogicError(403, lazy_gettext("You don't have permission to make releases"))
|
||||
|
||||
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
|
||||
count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count()
|
||||
|
||||
@@ -6,9 +6,10 @@ from app.logic.LogicError import LogicError
|
||||
from app.logic.uploads import upload_file
|
||||
from app.models import User, Package, PackageScreenshot, Permission, NotificationType, db, AuditSeverity
|
||||
from app.utils import addNotification, addAuditLog
|
||||
from app.utils.image import get_image_size
|
||||
|
||||
|
||||
def do_create_screenshot(user: User, package: Package, title: str, file, reason: str = None):
|
||||
def do_create_screenshot(user: User, package: Package, title: str, file, is_cover_image: bool, reason: str = None):
|
||||
thirty_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=30)
|
||||
count = package.screenshots.filter(PackageScreenshot.created_at > thirty_minutes_ago).count()
|
||||
if count >= 20:
|
||||
@@ -27,6 +28,13 @@ def do_create_screenshot(user: User, package: Package, title: str, file, reason:
|
||||
ss.url = uploaded_url
|
||||
ss.approved = package.checkPerm(user, Permission.APPROVE_SCREENSHOT)
|
||||
ss.order = counter
|
||||
ss.width, ss.height = get_image_size(uploaded_path)
|
||||
|
||||
if ss.is_too_small():
|
||||
raise LogicError(429,
|
||||
lazy_gettext("Screenshot is too small, it should be at least %(width)s by %(height)s pixels",
|
||||
width=PackageScreenshot.HARD_MIN_SIZE[0], height=PackageScreenshot.HARD_MIN_SIZE[1]))
|
||||
|
||||
db.session.add(ss)
|
||||
|
||||
if reason is None:
|
||||
@@ -39,6 +47,10 @@ def do_create_screenshot(user: User, package: Package, title: str, file, reason:
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if is_cover_image:
|
||||
package.cover_image = ss
|
||||
db.session.commit()
|
||||
|
||||
return ss
|
||||
|
||||
|
||||
@@ -58,3 +70,18 @@ def do_order_screenshots(_user: User, package: Package, order: [any]):
|
||||
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(ss_id)))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def do_set_cover_image(_user: User, package: Package, cover_image):
|
||||
try:
|
||||
cover_image = int(cover_image)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(cover_image)))
|
||||
|
||||
for screenshot in package.screenshots.all():
|
||||
if screenshot.id == cover_image:
|
||||
package.cover_image = screenshot
|
||||
db.session.commit()
|
||||
return
|
||||
|
||||
raise LogicError(400, "Unable to find screenshot")
|
||||
|
||||
@@ -70,10 +70,15 @@ class FlaskMailHandler(logging.Handler):
|
||||
return subject
|
||||
|
||||
def emit(self, record):
|
||||
subject = self.getSubject(record)
|
||||
text = self.format(record) if self.formatter else None
|
||||
html = "<pre>{}</pre>".format(text)
|
||||
|
||||
if "The recipient has exceeded message rate limit. Try again later" in subject:
|
||||
return
|
||||
|
||||
for email in self.send_to:
|
||||
send_user_email.delay(email, self.getSubject(record), text, html)
|
||||
send_user_email.delay(email, "en", subject, text, html)
|
||||
|
||||
|
||||
def build_handler(app):
|
||||
|
||||
@@ -117,8 +117,8 @@ class ForumTopic(db.Model):
|
||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
author = db.relationship("User", back_populates="forum_topics")
|
||||
|
||||
wip = db.Column(db.Boolean, server_default="0")
|
||||
discarded = db.Column(db.Boolean, server_default="0")
|
||||
wip = db.Column(db.Boolean, default=False, nullable=False)
|
||||
discarded = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
type = db.Column(db.Enum(PackageType), nullable=False)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
|
||||
@@ -26,6 +26,7 @@ from sqlalchemy_utils.types import TSVectorType
|
||||
|
||||
from . import db
|
||||
from .users import Permission, UserRank, User
|
||||
from .. import app
|
||||
|
||||
|
||||
class PackageQuery(BaseQuery, SearchQueryMixin):
|
||||
@@ -343,6 +344,25 @@ class Dependency(db.Model):
|
||||
return retval
|
||||
|
||||
|
||||
class PackageGameSupport(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
|
||||
package = db.relationship("Package", foreign_keys=[package_id])
|
||||
|
||||
game_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
|
||||
game = db.relationship("Package", foreign_keys=[game_id])
|
||||
|
||||
supports = db.Column(db.Boolean, nullable=False, default=True)
|
||||
confidence = db.Column(db.Integer, nullable=False, default=1)
|
||||
|
||||
__table_args__ = (db.UniqueConstraint("game_id", "package_id", name="_package_game_support_uc"),)
|
||||
|
||||
def __init__(self, package, game):
|
||||
self.package = package
|
||||
self.game = game
|
||||
|
||||
|
||||
class Package(db.Model):
|
||||
query_class = PackageQuery
|
||||
|
||||
@@ -382,18 +402,26 @@ class Package(db.Model):
|
||||
downloads = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
review_thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True, default=None)
|
||||
review_thread = db.relationship("Thread", uselist=False, foreign_keys=[review_thread_id], back_populates="is_review_thread")
|
||||
review_thread = db.relationship("Thread", uselist=False, foreign_keys=[review_thread_id],
|
||||
back_populates="is_review_thread", post_update=True)
|
||||
|
||||
# Downloads
|
||||
repo = db.Column(db.String(200), nullable=True)
|
||||
website = db.Column(db.String(200), nullable=True)
|
||||
issueTracker = db.Column(db.String(200), nullable=True)
|
||||
forums = db.Column(db.Integer, nullable=True)
|
||||
video_url = db.Column(db.String(200), nullable=True, default=None)
|
||||
|
||||
provides = db.relationship("MetaPackage", secondary=PackageProvides, order_by=db.asc("name"), back_populates="packages")
|
||||
|
||||
dependencies = db.relationship("Dependency", back_populates="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id])
|
||||
|
||||
supported_games = db.relationship("PackageGameSupport", back_populates="package", lazy="dynamic",
|
||||
foreign_keys=[PackageGameSupport.package_id])
|
||||
|
||||
game_supported_mods = db.relationship("PackageGameSupport", back_populates="game", lazy="dynamic",
|
||||
foreign_keys=[PackageGameSupport.game_id])
|
||||
|
||||
tags = db.relationship("Tag", secondary=Tags, back_populates="packages")
|
||||
|
||||
content_warnings = db.relationship("ContentWarning", secondary=ContentWarnings, back_populates="packages")
|
||||
@@ -405,7 +433,7 @@ class Package(db.Model):
|
||||
lazy="dynamic", order_by=db.asc("package_screenshot_order"), cascade="all, delete, delete-orphan")
|
||||
|
||||
main_screenshot = db.relationship("PackageScreenshot", uselist=False, foreign_keys="PackageScreenshot.package_id",
|
||||
lazy=True, order_by=db.asc("package_screenshot_order"),
|
||||
lazy=True, order_by=db.asc("package_screenshot_order"), viewonly=True,
|
||||
primaryjoin="and_(Package.id==PackageScreenshot.package_id, PackageScreenshot.approved)")
|
||||
|
||||
cover_image_id = db.Column(db.Integer, db.ForeignKey("package_screenshot.id"), nullable=True, default=None)
|
||||
@@ -448,6 +476,14 @@ class Package(db.Model):
|
||||
for e in PackagePropertyKey:
|
||||
setattr(self, e.name, getattr(package, e.name))
|
||||
|
||||
@classmethod
|
||||
def get_by_key(cls, key):
|
||||
parts = key.split("/")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
return Package.query.filter(Package.name == parts[1], Package.author.has(username=parts[0])).first()
|
||||
|
||||
def getId(self):
|
||||
return "{}/{}".format(self.author.username, self.name)
|
||||
|
||||
@@ -469,6 +505,11 @@ class Package(db.Model):
|
||||
def getSortedOptionalDependencies(self):
|
||||
return self.getSortedDependencies(False)
|
||||
|
||||
def getSortedSupportedGames(self):
|
||||
supported = self.supported_games.all()
|
||||
supported.sort(key=lambda x: -x.game.score)
|
||||
return supported
|
||||
|
||||
def getAsDictionaryKey(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
@@ -527,6 +568,7 @@ class Package(db.Model):
|
||||
"website": self.website,
|
||||
"issue_tracker": self.issueTracker,
|
||||
"forums": self.forums,
|
||||
"video_url": self.video_url,
|
||||
|
||||
"tags": [x.name for x in self.tags],
|
||||
"content_warnings": [x.name for x in self.content_warnings],
|
||||
@@ -539,7 +581,15 @@ class Package(db.Model):
|
||||
"release": release and release.id,
|
||||
|
||||
"score": round(self.score * 10) / 10,
|
||||
"downloads": self.downloads
|
||||
"downloads": self.downloads,
|
||||
|
||||
"game_support": [
|
||||
{
|
||||
"supports": support.supports,
|
||||
"confidence": support.confidence,
|
||||
"game": support.game.getAsDictionaryShort(base_url, version)
|
||||
} for support in self.supported_games.all()
|
||||
]
|
||||
}
|
||||
|
||||
def getThumbnailOrPlaceholder(self, level=2):
|
||||
@@ -607,10 +657,7 @@ class Package(db.Model):
|
||||
isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers
|
||||
isApprover = user.rank.atLeast(UserRank.APPROVER)
|
||||
|
||||
if perm == Permission.SEE_PACKAGE:
|
||||
return self.state == PackageState.APPROVED or isMaintainer or isApprover
|
||||
|
||||
elif perm == Permission.CREATE_THREAD:
|
||||
if perm == Permission.CREATE_THREAD:
|
||||
return user.rank.atLeast(UserRank.MEMBER)
|
||||
|
||||
# Members can edit their own packages, and editors can edit any packages
|
||||
@@ -675,7 +722,11 @@ class Package(db.Model):
|
||||
if not (self.checkPerm(user, Permission.APPROVE_NEW) or self.checkPerm(user, Permission.EDIT_PACKAGE)):
|
||||
return False
|
||||
|
||||
if state == PackageState.APPROVED and ("Other" in self.license.name or "Other" in self.media_license.name):
|
||||
if state == PackageState.APPROVED and ("Other" in self.license.name or "Other" in self.media_license.name):
|
||||
return False
|
||||
|
||||
provides = self.provides
|
||||
if state == PackageState.APPROVED and len(provides) == 1 and provides[0].name != self.name:
|
||||
return False
|
||||
|
||||
if self.getMissingHardDependenciesQuery().count() > 0:
|
||||
@@ -684,7 +735,8 @@ class Package(db.Model):
|
||||
needsScreenshot = \
|
||||
(self.type == self.type.GAME or self.type == self.type.TXP) and \
|
||||
self.screenshots.count() == 0
|
||||
return self.releases.count() > 0 and not needsScreenshot
|
||||
|
||||
return self.releases.filter(PackageRelease.task_id.is_(None)).count() > 0 and not needsScreenshot
|
||||
|
||||
elif state == PackageState.CHANGES_NEEDED:
|
||||
return self.checkPerm(user, Permission.APPROVE_NEW)
|
||||
@@ -815,7 +867,13 @@ class Tag(db.Model):
|
||||
|
||||
def getAsDictionary(self):
|
||||
description = self.description if self.description != "" else None
|
||||
return { "name": self.name, "title": self.title, "description": description }
|
||||
return {
|
||||
"name": self.name,
|
||||
"title": self.title,
|
||||
"description": description,
|
||||
"is_protected": self.is_protected,
|
||||
"views": self.views,
|
||||
}
|
||||
|
||||
|
||||
class MinetestRelease(db.Model):
|
||||
@@ -883,6 +941,10 @@ class PackageRelease(db.Model):
|
||||
# If the release is approved, then the task_id must be null and the url must be present
|
||||
CK_approval_valid = db.CheckConstraint("not approved OR (task_id IS NULL AND (url = '') IS NOT FALSE)")
|
||||
|
||||
@property
|
||||
def file_path(self):
|
||||
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||
|
||||
def getAsDictionary(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
@@ -984,6 +1046,9 @@ class PackageRelease(db.Model):
|
||||
|
||||
|
||||
class PackageScreenshot(db.Model):
|
||||
HARD_MIN_SIZE = (920, 517)
|
||||
SOFT_MIN_SIZE = (1280, 720)
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
|
||||
@@ -995,6 +1060,22 @@ class PackageScreenshot(db.Model):
|
||||
approved = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
width = db.Column(db.Integer, nullable=False)
|
||||
height = db.Column(db.Integer, nullable=False)
|
||||
|
||||
def is_very_small(self):
|
||||
return self.width < 720 or self.height < 405
|
||||
|
||||
def is_too_small(self):
|
||||
return self.width < PackageScreenshot.HARD_MIN_SIZE[0] or self.height < PackageScreenshot.HARD_MIN_SIZE[1]
|
||||
|
||||
def is_low_res(self):
|
||||
return self.width < PackageScreenshot.SOFT_MIN_SIZE[0] or self.height < PackageScreenshot.SOFT_MIN_SIZE[1]
|
||||
|
||||
@property
|
||||
def file_path(self):
|
||||
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||
|
||||
def getEditURL(self):
|
||||
return url_for("packages.edit_screenshot",
|
||||
author=self.package.author.username,
|
||||
@@ -1016,8 +1097,11 @@ class PackageScreenshot(db.Model):
|
||||
"order": self.order,
|
||||
"title": self.title,
|
||||
"url": base_url + self.url,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"approved": self.approved,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"is_cover_image": self.package.cover_image == self,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Tuple, List
|
||||
from flask import url_for
|
||||
|
||||
from . import db
|
||||
from .users import Permission, UserRank
|
||||
from .users import Permission, UserRank, User
|
||||
from .packages import Package
|
||||
|
||||
watchers = db.Table("watchers",
|
||||
@@ -88,7 +88,7 @@ class Thread(db.Model):
|
||||
if self.package:
|
||||
isMaintainer = isMaintainer or user in self.package.maintainers
|
||||
|
||||
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.APPROVER)
|
||||
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.APPROVER) or user in self.watchers
|
||||
|
||||
if perm == Permission.SEE_THREAD:
|
||||
return canSee
|
||||
@@ -107,6 +107,20 @@ class Thread(db.Model):
|
||||
else:
|
||||
raise Exception("Permission {} is not related to threads".format(perm.name))
|
||||
|
||||
def get_visible_to(self) -> list[User]:
|
||||
retval = {
|
||||
self.author.username: self.author
|
||||
}
|
||||
|
||||
for user in self.watchers:
|
||||
retval[user.username] = user
|
||||
|
||||
if self.package:
|
||||
for user in self.package.maintainers:
|
||||
retval[user.username] = user
|
||||
|
||||
return list(retval.values())
|
||||
|
||||
def get_latest_reply(self):
|
||||
return ThreadReply.query.filter_by(thread_id=self.id).order_by(db.desc(ThreadReply.id)).first()
|
||||
|
||||
@@ -122,6 +136,8 @@ class ThreadReply(db.Model):
|
||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
author = db.relationship("User", back_populates="replies", foreign_keys=[author_id])
|
||||
|
||||
is_status_update = db.Column(db.Boolean, server_default="0", nullable=False)
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
def get_url(self):
|
||||
@@ -200,7 +216,8 @@ class PackageReview(db.Model):
|
||||
def getDeleteURL(self):
|
||||
return url_for("packages.delete_review",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name)
|
||||
name=self.package.name,
|
||||
reviewer=self.author.username)
|
||||
|
||||
def getVoteUrl(self, next_url=None):
|
||||
return url_for("packages.review_vote",
|
||||
@@ -213,6 +230,20 @@ class PackageReview(db.Model):
|
||||
(pos, neg, _) = self.get_totals()
|
||||
self.score = 3 * (pos - neg) + 1
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to PackageReview.checkPerm()")
|
||||
|
||||
if perm == Permission.DELETE_REVIEW:
|
||||
return user == self.author or user.rank.atLeast(UserRank.MODERATOR)
|
||||
else:
|
||||
raise Exception("Permission {} is not related to reviews".format(perm.name))
|
||||
|
||||
|
||||
class PackageReviewVote(db.Model):
|
||||
review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), primary_key=True)
|
||||
|
||||
@@ -59,7 +59,6 @@ class UserRank(enum.Enum):
|
||||
|
||||
|
||||
class Permission(enum.Enum):
|
||||
SEE_PACKAGE = "SEE_PACKAGE"
|
||||
EDIT_PACKAGE = "EDIT_PACKAGE"
|
||||
DELETE_PACKAGE = "DELETE_PACKAGE"
|
||||
CHANGE_AUTHOR = "CHANGE_AUTHOR"
|
||||
@@ -87,6 +86,7 @@ class Permission(enum.Enum):
|
||||
TOPIC_DISCARD = "TOPIC_DISCARD"
|
||||
CREATE_TOKEN = "CREATE_TOKEN"
|
||||
EDIT_MAINTAINERS = "EDIT_MAINTAINERS"
|
||||
DELETE_REVIEW = "DELETE_REVIEW"
|
||||
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
|
||||
CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME"
|
||||
|
||||
@@ -148,6 +148,8 @@ class User(db.Model, UserMixin):
|
||||
email = db.Column(db.String(255), nullable=True, unique=True)
|
||||
email_confirmed_at = db.Column(db.DateTime(), nullable=True, server_default=None)
|
||||
|
||||
locale = db.Column(db.String(10), nullable=True, default=None)
|
||||
|
||||
# User information
|
||||
profile_pic = db.Column(db.String(255), nullable=True, server_default=None)
|
||||
is_active = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
|
||||
@@ -181,6 +183,8 @@ class User(db.Model, UserMixin):
|
||||
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.desc("created_at"))
|
||||
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
|
||||
ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False)
|
||||
|
||||
def __init__(self, username=None, active=False, email=None, password=None):
|
||||
self.username = username
|
||||
self.display_name = username
|
||||
@@ -266,6 +270,25 @@ class User(db.Model, UserMixin):
|
||||
return Thread.query.filter_by(author=self) \
|
||||
.filter(Thread.created_at > hour_ago).count() < 2 * factor
|
||||
|
||||
def canReviewRL(self):
|
||||
from app.models import PackageReview
|
||||
|
||||
factor = 1
|
||||
if self.rank.atLeast(UserRank.ADMIN):
|
||||
return True
|
||||
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
|
||||
factor *= 5
|
||||
|
||||
five_mins_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=5)
|
||||
if PackageReview.query.filter_by(author=self) \
|
||||
.filter(PackageReview.created_at > five_mins_ago).count() > 2 * factor:
|
||||
return False
|
||||
|
||||
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
|
||||
return PackageReview.query.filter_by(author=self) \
|
||||
.filter(PackageReview.created_at > hour_ago).count() < 10 * factor
|
||||
|
||||
|
||||
def __eq__(self, other):
|
||||
if other is None:
|
||||
return False
|
||||
@@ -307,6 +330,11 @@ class EmailSubscription(db.Model):
|
||||
self.blacklisted = False
|
||||
self.token = None
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
from ..utils import abs_url_for
|
||||
return abs_url_for('users.unsubscribe', token=self.token)
|
||||
|
||||
|
||||
class NotificationType(enum.Enum):
|
||||
# Package / release / etc
|
||||
@@ -475,3 +503,21 @@ class UserNotificationPreferences(db.Model):
|
||||
|
||||
value = 1 if value else 0
|
||||
setattr(self, "pref_" + notification_type.toName(), value)
|
||||
|
||||
|
||||
class UserBan(db.Model):
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
|
||||
user = db.relationship("User", foreign_keys=[user_id], back_populates="ban")
|
||||
|
||||
message = db.Column(db.UnicodeText, nullable=False)
|
||||
|
||||
banned_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
banned_by = db.relationship("User", foreign_keys=[banned_by_id])
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
expires_at = db.Column(db.DateTime, nullable=True, default=None)
|
||||
|
||||
@property
|
||||
def has_expired(self):
|
||||
return self.expires_at and datetime.datetime.now() > self.expires_at
|
||||
|
||||
17
app/public/static/gallery.js
Normal file
17
app/public/static/gallery.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
window.addEventListener("load", event => {
|
||||
document.querySelectorAll(".gallery").forEach(gallery => {
|
||||
const primary = gallery.querySelector(".primary-image img");
|
||||
const images = gallery.querySelectorAll("a[data-image]");
|
||||
|
||||
images.forEach(image => {
|
||||
const imageFullUrl = image.getAttribute("data-image");
|
||||
image.removeAttribute("href");
|
||||
image.addEventListener("click", event => {
|
||||
primary.src = imageFullUrl;
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
$("textarea.markdown").each(function() {
|
||||
async function render(plainText, preview) {
|
||||
const response = await fetch(new Request("/api/markdown/", {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
const min = $("#min_rel");
|
||||
const max = $("#max_rel");
|
||||
const none = $("#min_rel option:first-child").attr("value");
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
$(".topic-discard").click(function() {
|
||||
const ele = $(this);
|
||||
const tid = ele.attr("data-tid");
|
||||
|
||||
37
app/public/static/video_embed.js
Normal file
37
app/public/static/video_embed.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
document.querySelectorAll(".video-embed").forEach(ele => {
|
||||
try {
|
||||
const href = ele.getAttribute("href");
|
||||
const url = new URL(href);
|
||||
|
||||
if (url.host == "www.youtube.com") {
|
||||
ele.addEventListener("click", () => {
|
||||
ele.parentNode.classList.add("d-block");
|
||||
ele.classList.add("embed-responsive");
|
||||
ele.classList.add("embed-responsive-16by9");
|
||||
ele.innerHTML = `
|
||||
<iframe title="YouTube video player" frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen>
|
||||
</iframe>`;
|
||||
|
||||
const embedURL = new URL("https://www.youtube.com/");
|
||||
embedURL.pathname = "/embed/" + url.searchParams.get("v");
|
||||
embedURL.searchParams.set("autoplay", "1");
|
||||
|
||||
const iframe = ele.children[0];
|
||||
iframe.setAttribute("src", embedURL);
|
||||
});
|
||||
|
||||
ele.setAttribute("data-src", href);
|
||||
ele.removeAttribute("href");
|
||||
|
||||
ele.querySelector(".label").innerText = "YouTube";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(url);
|
||||
return;
|
||||
}
|
||||
});
|
||||
@@ -75,6 +75,10 @@ class QueryBuilder:
|
||||
if self.search is not None and self.search.strip() == "":
|
||||
self.search = None
|
||||
|
||||
self.game = args.get("game")
|
||||
if self.game:
|
||||
self.game = Package.get_by_key(self.game)
|
||||
|
||||
def setSortIfNone(self, name, dir="desc"):
|
||||
if self.order_by is None:
|
||||
self.order_by = name
|
||||
@@ -132,6 +136,9 @@ class QueryBuilder:
|
||||
|
||||
query = query.filter_by(author=author)
|
||||
|
||||
if self.game:
|
||||
query = query.filter(Package.supported_games.any(game=self.game))
|
||||
|
||||
for tag in self.tags:
|
||||
query = query.filter(Package.tags.any(Tag.id == tag.id))
|
||||
|
||||
|
||||
43
app/sass.py
43
app/sass.py
@@ -12,16 +12,16 @@ Code unabashedly adapted from https://github.com/weapp/flask-coffee2js
|
||||
import os
|
||||
import os.path
|
||||
import codecs
|
||||
from flask import *
|
||||
from scss import Scss
|
||||
import sass
|
||||
from flask import send_from_directory
|
||||
|
||||
def _convert(dir, src, dst):
|
||||
|
||||
def _convert(dir_path, src, dst):
|
||||
original_wd = os.getcwd()
|
||||
os.chdir(dir)
|
||||
os.chdir(dir_path)
|
||||
|
||||
css = Scss()
|
||||
source = codecs.open(src, 'r', encoding='utf-8').read()
|
||||
output = css.compile(source)
|
||||
output = sass.compile(string=source)
|
||||
|
||||
os.chdir(original_wd)
|
||||
|
||||
@@ -29,8 +29,9 @@ def _convert(dir, src, dst):
|
||||
outfile.write(output)
|
||||
outfile.close()
|
||||
|
||||
def _getDirPath(app, originalPath, create=False):
|
||||
path = originalPath
|
||||
|
||||
def _get_dir_path(app, original_path, create=False):
|
||||
path = original_path
|
||||
|
||||
if not os.path.isdir(path):
|
||||
path = os.path.join(app.root_path, path)
|
||||
@@ -39,25 +40,25 @@ def _getDirPath(app, originalPath, create=False):
|
||||
if create:
|
||||
os.mkdir(path)
|
||||
else:
|
||||
raise IOError("Unable to find " + originalPath)
|
||||
raise IOError("Unable to find " + original_path)
|
||||
|
||||
return path
|
||||
|
||||
def sass(app, inputDir='scss', outputPath='static', force=False, cacheDir="public/static"):
|
||||
static_url_path = app.static_url_path
|
||||
inputDir = _getDirPath(app, inputDir)
|
||||
cacheDir = _getDirPath(app, cacheDir or outputPath, True)
|
||||
|
||||
def init_app(app, input_dir='scss', dest='static', force=False, cache_dir="public/static"):
|
||||
input_dir = _get_dir_path(app, input_dir)
|
||||
cache_dir = _get_dir_path(app, cache_dir or dest, True)
|
||||
|
||||
def _sass(filepath):
|
||||
sassfile = "%s/%s.scss" % (inputDir, filepath)
|
||||
cacheFile = "%s/%s.css" % (cacheDir, filepath)
|
||||
scss_file = "%s/%s.scss" % (input_dir, filepath)
|
||||
cache_file = "%s/%s.css" % (cache_dir, filepath)
|
||||
|
||||
# Source file exists, and needs regenerating
|
||||
if os.path.isfile(sassfile) and (force or not os.path.isfile(cacheFile) or
|
||||
os.path.getmtime(sassfile) > os.path.getmtime(cacheFile)):
|
||||
_convert(inputDir, sassfile, cacheFile)
|
||||
app.logger.debug('Compiled %s into %s' % (sassfile, cacheFile))
|
||||
if os.path.isfile(scss_file) and (force or not os.path.isfile(cache_file) or
|
||||
os.path.getmtime(scss_file) > os.path.getmtime(cache_file)):
|
||||
_convert(input_dir, scss_file, cache_file)
|
||||
app.logger.debug('Compiled %s into %s' % (scss_file, cache_file))
|
||||
|
||||
return send_from_directory(cacheDir, filepath + ".css")
|
||||
return send_from_directory(cache_dir, filepath + ".css")
|
||||
|
||||
app.add_url_rule("/%s/<path:filepath>.css" % outputPath, 'sass', _sass)
|
||||
app.add_url_rule("/%s/<path:filepath>.css" % dest, 'sass', _sass)
|
||||
|
||||
@@ -32,4 +32,8 @@
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.status-update p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "components.scss";
|
||||
@import "packages.scss";
|
||||
@import "gallery.scss";
|
||||
@import "packagegrid.scss";
|
||||
@import "comments.scss";
|
||||
|
||||
|
||||
79
app/scss/gallery.scss
Normal file
79
app/scss/gallery.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
.gallery-thumbnails {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
overflow: auto hidden;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
li {
|
||||
display: block;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
|
||||
&:hover img, .active img {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 200px;
|
||||
height: 133px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.video-embed {
|
||||
min-width: 200px;
|
||||
min-height: 133px;
|
||||
background: #111;
|
||||
position: relative;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
cursor: pointer;
|
||||
|
||||
.fa-play {
|
||||
display: block;
|
||||
font-size: 200%;
|
||||
color: #f44;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #191919;
|
||||
|
||||
.fa-play {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.5rem;
|
||||
color: #555;
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot-add {
|
||||
display: block !important;
|
||||
width: 200px;
|
||||
height: 133px;
|
||||
background: #444;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
line-height: 133px !important;
|
||||
font-size: 80px;
|
||||
|
||||
&:hover {
|
||||
background: #555;
|
||||
color: #999;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,3 @@
|
||||
.screenshot_list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 2em;
|
||||
|
||||
li, li a {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 5px;
|
||||
padding: 0;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 200px;
|
||||
height: 133px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-tr {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
@@ -34,23 +5,6 @@
|
||||
color: #ccc !important;;
|
||||
}
|
||||
|
||||
.screenshot-add {
|
||||
display: block !important;
|
||||
width: 200px;
|
||||
height: 133px;
|
||||
background: #444;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
line-height: 133px !important;
|
||||
font-size: 80px;
|
||||
|
||||
&:hover {
|
||||
background: #555;
|
||||
color: #999;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.info-row {
|
||||
vertical-align: middle;
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from flask import render_template, escape
|
||||
from flask_babel import force_locale, gettext, lazy_gettext
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from app.models import Notification, db, EmailSubscription, User
|
||||
@@ -35,113 +37,131 @@ def get_email_subscription(email):
|
||||
return ret
|
||||
|
||||
|
||||
def gen_headers(sub: EmailSubscription, is_bulk: bool) -> Dict[str,str]:
|
||||
headers = {"List-Help": f"<{abs_url_for('flatpage', path='help/faq/')}>", "List-Unsubscribe": f"<{sub.url}>"}
|
||||
|
||||
if is_bulk:
|
||||
headers["Precedence"] = "Bulk"
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
@celery.task()
|
||||
def send_verify_email(email, token):
|
||||
def send_verify_email(email, token, locale):
|
||||
sub = get_email_subscription(email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
msg = Message("Confirm email address", recipients=[email])
|
||||
with force_locale(locale or "en"):
|
||||
msg = Message("Confirm email address", recipients=[email], extra_headers=gen_headers(sub, False))
|
||||
|
||||
msg.body = """
|
||||
This email has been sent to you because someone (hopefully you)
|
||||
has entered your email address as a user's email.
|
||||
msg.body = """
|
||||
This email has been sent to you because someone (hopefully you)
|
||||
has entered your email address as a user's email.
|
||||
|
||||
If it wasn't you, then just delete this email.
|
||||
If it wasn't you, then just delete this email.
|
||||
|
||||
If this was you, then please click this link to confirm the address:
|
||||
If this was you, then please click this link to confirm the address:
|
||||
|
||||
{}
|
||||
""".format(abs_url_for('users.verify_email', token=token))
|
||||
{}
|
||||
""".format(abs_url_for('users.verify_email', token=token))
|
||||
|
||||
msg.html = render_template("emails/verify.html", token=token, sub=sub)
|
||||
mail.send(msg)
|
||||
msg.html = render_template("emails/verify.html", token=token, sub=sub)
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
@celery.task()
|
||||
def send_unsubscribe_verify(email):
|
||||
def send_unsubscribe_verify(email, locale):
|
||||
sub = get_email_subscription(email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
msg = Message("Confirm unsubscribe", recipients=[email])
|
||||
with force_locale(locale or "en"):
|
||||
msg = Message("Confirm unsubscribe", recipients=[email], extra_headers=gen_headers(sub, False))
|
||||
|
||||
msg.body = """
|
||||
We're sorry to see you go. You just need to do one more thing before your email is blacklisted.
|
||||
|
||||
Click this link to blacklist email: {}
|
||||
""".format(abs_url_for('users.unsubscribe', token=sub.token))
|
||||
msg.body = """
|
||||
We're sorry to see you go. You just need to do one more thing before your email is blacklisted.
|
||||
|
||||
Click this link to blacklist email: {}
|
||||
""".format(abs_url_for('users.unsubscribe', token=sub.token))
|
||||
|
||||
msg.html = render_template("emails/verify_unsubscribe.html", sub=sub)
|
||||
mail.send(msg)
|
||||
msg.html = render_template("emails/verify_unsubscribe.html", sub=sub)
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
@celery.task()
|
||||
def send_email_with_reason(email, subject, text, html, reason):
|
||||
@celery.task(rate_limit="25/m")
|
||||
def send_email_with_reason(email: str, locale: str, subject: str, text: str, html: str, reason: str, conn: any):
|
||||
sub = get_email_subscription(email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
from flask_mail import Message
|
||||
msg = Message(subject, recipients=[email])
|
||||
with force_locale(locale or "en"):
|
||||
msg = Message(subject, recipients=[email], extra_headers=gen_headers(sub, conn is not None))
|
||||
|
||||
msg.body = text
|
||||
html = html or f"<pre>{escape(text)}</pre>"
|
||||
msg.html = render_template("emails/base.html", subject=subject, content=html, reason=reason, sub=sub)
|
||||
mail.send(msg)
|
||||
msg.body = text
|
||||
html = html or f"<pre>{escape(text)}</pre>"
|
||||
msg.html = render_template("emails/base.html", subject=subject, content=html, reason=reason, sub=sub)
|
||||
if conn:
|
||||
conn.send(msg)
|
||||
else:
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
@celery.task()
|
||||
def send_user_email(email: str, subject: str, text: str, html=None):
|
||||
return send_email_with_reason(email, subject, text, html,
|
||||
"You are receiving this email because you are a registered user of ContentDB.")
|
||||
@celery.task(rate_limit="25/m")
|
||||
def send_user_email(email: str, locale: str, subject: str, text: str, html=None, conn=None):
|
||||
return send_email_with_reason(email, locale, subject, text, html,
|
||||
lazy_gettext("You are receiving this email because you are a registered user of ContentDB."), conn)
|
||||
|
||||
|
||||
@celery.task()
|
||||
def send_anon_email(email: str, subject: str, text: str, html=None):
|
||||
return send_email_with_reason(email, subject, text, html,
|
||||
"You are receiving this email because someone (hopefully you) entered your email address as a user's email.")
|
||||
@celery.task(rate_limit="25/m")
|
||||
def send_anon_email(email: str, locale: str, subject: str, text: str, html=None):
|
||||
return send_email_with_reason(email, locale, subject, text, html,
|
||||
lazy_gettext("You are receiving this email because someone (hopefully you) entered your email address as a user's email."), None)
|
||||
|
||||
|
||||
def send_single_email(notification):
|
||||
def send_single_email(notification, locale):
|
||||
sub = get_email_subscription(notification.user.email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
msg = Message(notification.title, recipients=[notification.user.email])
|
||||
with force_locale(locale or "en"):
|
||||
msg = Message(notification.title, recipients=[notification.user.email], extra_headers=gen_headers(sub, False))
|
||||
|
||||
msg.body = """
|
||||
New notification: {}
|
||||
|
||||
View: {}
|
||||
|
||||
Manage email settings: {}
|
||||
Unsubscribe: {}
|
||||
""".format(notification.title, abs_url(notification.url),
|
||||
abs_url_for("users.email_notifications", username=notification.user.username),
|
||||
abs_url_for("users.unsubscribe", token=sub.token))
|
||||
msg.body = """
|
||||
New notification: {}
|
||||
|
||||
View: {}
|
||||
|
||||
Manage email settings: {}
|
||||
Unsubscribe: {}
|
||||
""".format(notification.title, abs_url(notification.url),
|
||||
abs_url_for("users.email_notifications", username=notification.user.username),
|
||||
abs_url_for("users.unsubscribe", token=sub.token))
|
||||
|
||||
msg.html = render_template("emails/notification.html", notification=notification, sub=sub)
|
||||
mail.send(msg)
|
||||
msg.html = render_template("emails/notification.html", notification=notification, sub=sub)
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
def send_notification_digest(notifications: [Notification]):
|
||||
def send_notification_digest(notifications: [Notification], locale):
|
||||
user = notifications[0].user
|
||||
|
||||
sub = get_email_subscription(user.email)
|
||||
if sub.blacklisted:
|
||||
return
|
||||
|
||||
msg = Message("{} new notifications".format(len(notifications)), recipients=[user.email])
|
||||
with force_locale(locale or "en"):
|
||||
msg = Message(gettext("%(num)d new notifications", num=len(notifications)), recipients=[user.email])
|
||||
|
||||
msg.body = "".join(["<{}> {}\nView: {}\n\n".format(notification.causer.display_name, notification.title, abs_url(notification.url)) for notification in notifications])
|
||||
msg.body = "".join(["<{}> {}\n{}: {}\n\n".format(notification.causer.display_name, notification.title, gettext("View"), abs_url(notification.url)) for notification in notifications])
|
||||
|
||||
msg.body += "Manage email settings: {}\nUnsubscribe: {}".format(
|
||||
abs_url_for("users.email_notifications", username=user.username),
|
||||
abs_url_for("users.unsubscribe", token=sub.token))
|
||||
msg.body += "{}: {}\n{}: {}".format(
|
||||
gettext("Manage email settings"),
|
||||
abs_url_for("users.email_notifications", username=user.username),
|
||||
gettext("Unsubscribe"),
|
||||
abs_url_for("users.unsubscribe", token=sub.token))
|
||||
|
||||
msg.html = render_template("emails/notification_digest.html", notifications=notifications, user=user, sub=sub)
|
||||
mail.send(msg)
|
||||
msg.html = render_template("emails/notification_digest.html", notifications=notifications, user=user, sub=sub)
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
@celery.task()
|
||||
@@ -154,7 +174,7 @@ def send_pending_digests():
|
||||
notification.emailed = True
|
||||
|
||||
if len(to_send) > 0:
|
||||
send_notification_digest(to_send)
|
||||
send_notification_digest(to_send, user.locale or "en")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@@ -174,6 +194,13 @@ def send_pending_notifications():
|
||||
db.session.commit()
|
||||
|
||||
if len(to_send) > 1:
|
||||
send_notification_digest(to_send)
|
||||
send_notification_digest(to_send, user.locale or "en")
|
||||
elif len(to_send) > 0:
|
||||
send_single_email(to_send[0])
|
||||
send_single_email(to_send[0], user.locale or "en")
|
||||
|
||||
|
||||
@celery.task()
|
||||
def send_bulk_email(subject: str, text: str, html=None):
|
||||
with mail.connect() as conn:
|
||||
for user in User.query.filter(User.email.isnot(None)).all():
|
||||
send_user_email(user.email, user.locale or "en", subject, text, html, conn)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import os, shutil, gitdb
|
||||
from zipfile import ZipFile
|
||||
@@ -22,11 +23,13 @@ from kombu import uuid
|
||||
|
||||
from app.models import *
|
||||
from app.tasks import celery, TaskError
|
||||
from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog, get_system_user
|
||||
from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog
|
||||
from app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir
|
||||
from .minetestcheck import build_tree, MinetestCheckError, ContentType
|
||||
from ..logic.LogicError import LogicError
|
||||
from ..logic.game_support import GameSupportResolver
|
||||
from ..logic.packages import do_edit_package, ALIASES
|
||||
from ..utils.image import get_image_size
|
||||
|
||||
|
||||
@celery.task()
|
||||
@@ -112,6 +115,11 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
|
||||
for meta in getMetaPackages(optional_depends):
|
||||
db.session.add(Dependency(package, meta=meta, optional=True))
|
||||
|
||||
# Update game supports
|
||||
# if package.type == PackageType.MOD:
|
||||
# resolver = GameSupportResolver()
|
||||
# resolver.update(package)
|
||||
|
||||
# Update min/max
|
||||
if tree.meta.get("min_minetest_version"):
|
||||
release.min_rel = MinetestRelease.get(tree.meta["min_minetest_version"], None)
|
||||
@@ -213,6 +221,10 @@ def importRepoScreenshot(id):
|
||||
ss.package = package
|
||||
ss.title = "screenshot.png"
|
||||
ss.url = "/uploads/" + filename
|
||||
ss.width, ss.height = get_image_size(destPath)
|
||||
if ss.is_too_small():
|
||||
return None
|
||||
|
||||
db.session.add(ss)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
62
app/tasks/zipgrep.py
Normal file
62
app/tasks/zipgrep.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2022 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import subprocess
|
||||
from subprocess import Popen, PIPE
|
||||
from typing import Optional
|
||||
|
||||
from app.models import Package, PackageState, PackageRelease
|
||||
from app.tasks import celery
|
||||
|
||||
|
||||
@celery.task()
|
||||
def search_in_releases(query: str, file_filter: str):
|
||||
packages = list(Package.query.filter(Package.state == PackageState.APPROVED).all())
|
||||
running = []
|
||||
results = []
|
||||
|
||||
while len(packages) > 0 or len(running) > 0:
|
||||
# Check running
|
||||
for i in range(len(running) - 1, -1, -1):
|
||||
package: Package = running[i][0]
|
||||
handle: subprocess.Popen[str] = running[i][1]
|
||||
exit_code = handle.poll()
|
||||
if exit_code is None:
|
||||
continue
|
||||
elif exit_code == 0:
|
||||
results.append({
|
||||
"package": package.getAsDictionaryKey(),
|
||||
"lines": handle.stdout.read(),
|
||||
})
|
||||
|
||||
del running[i]
|
||||
|
||||
# Create new
|
||||
while len(running) < 1 and len(packages) > 0:
|
||||
package = packages.pop()
|
||||
release: Optional[PackageRelease] = package.getDownloadRelease()
|
||||
if release:
|
||||
handle = Popen(["zipgrep", query, release.file_path, file_filter], stdout=PIPE, encoding="UTF-8")
|
||||
running.append([package, handle])
|
||||
|
||||
if len(running) > 0:
|
||||
running[0][1].wait()
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"matches": results,
|
||||
}
|
||||
12
app/templates/500.html
Normal file
12
app/templates/500.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
500 - Internal Server Error
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ self.title() }}</h1>
|
||||
<p>
|
||||
Don't worry, this error will have been automatically reported.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}title{% endblock %} - {{ config.USER_APP_NAME }}</title>
|
||||
<link rel="stylesheet" type="text/css" href="/static/libs/bootstrap.min.css">
|
||||
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=32">
|
||||
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=35">
|
||||
<link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" />
|
||||
<link rel="shortcut icon" href="/favicon-16.png" sizes="16x16">
|
||||
<link rel="icon" href="/favicon-128.png" sizes="128x128">
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<p>
|
||||
{% block footer %}
|
||||
{{ reason }} <br>
|
||||
<a href="{{ abs_url_for('users.unsubscribe', token=sub.token) }}">
|
||||
<a href="{{ sub.url }}">
|
||||
{{ _("Unsubscribe") }}
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% for type, group in notifications | groupby("package.title") %}
|
||||
{% for title, group in notifications | selectattr("package") | groupby("package.title") %}
|
||||
<h2>
|
||||
{{ type or _("Other Notifications") }}
|
||||
{{ title }}
|
||||
</h2>
|
||||
|
||||
<ul>
|
||||
@@ -17,6 +17,23 @@
|
||||
</ul>
|
||||
{% endfor %}
|
||||
|
||||
{% set other_notifications = notifications | selectattr("package", "none") %}
|
||||
|
||||
{% if other_notifications %}
|
||||
<h2>
|
||||
{{ _("Other Notifications") }}
|
||||
</h2>
|
||||
|
||||
<ul>
|
||||
{% for notification in other_notifications %}
|
||||
<li>
|
||||
<a href="{{ notification.url | abs_url }}">{{ notification.title }}</a> -
|
||||
{{ _("from %(username)s.", username=notification.causer.username) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin-top: 3em;">
|
||||
<a class="btn" href="{{ abs_url_for('notifications.list_all') }}">
|
||||
{{ _("View Notifications") }}
|
||||
|
||||
13
app/templates/emails/unable_to_find_account.html
Normal file
13
app/templates/emails/unable_to_find_account.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<p>
|
||||
{{ _("We were unable to perform the password reset as we could not find an account associated with this email.") }}
|
||||
</p>
|
||||
<p>
|
||||
{{ _("This may be because you used another email with your account, or because you never confirmed your email.") }}
|
||||
</p>
|
||||
<p>
|
||||
{{ _("You can use GitHub to log in if it is associated with your account.") }}
|
||||
{{ _("Otherwise, you may need to contact rubenwardy for help.") }}
|
||||
</p>
|
||||
<p>
|
||||
{{ _("If you weren't expecting to receive this email, then you can safely ignore it.") }}
|
||||
</p>
|
||||
@@ -3,7 +3,7 @@
|
||||
{% for entry in log %}
|
||||
<a class="list-group-item list-group-item-action"
|
||||
{% if entry.description and current_user.rank.atLeast(current_user.rank.MODERATOR) %}
|
||||
href="{{ url_for('admin.audit_view', id=entry.id) }}">
|
||||
href="{{ url_for('admin.audit_view', id_=entry.id) }}">
|
||||
{% else %}
|
||||
href="{{ entry.url }}">
|
||||
{% endif %}
|
||||
|
||||
@@ -14,19 +14,24 @@
|
||||
</div>
|
||||
|
||||
{% set level = "warning" %}
|
||||
{% if package.releases.count() == 0 %}
|
||||
{% if package.releases.filter_by(task_id=None).count() == 0 %}
|
||||
{% set message %}
|
||||
{% if package.checkPerm(current_user, "MAKE_RELEASE") %}
|
||||
{% if package.update_config %}
|
||||
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL("packages.create_release") }}">
|
||||
{{ _("Create first release") }}
|
||||
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL('packages.create_release') }}">
|
||||
{{ _("Create release") }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL("packages.setup_releases") }}">
|
||||
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL('packages.setup_releases') }}">
|
||||
{{ _("Set up releases") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{{ _("You need to create a release before this package can be approved.") }}
|
||||
|
||||
{% if package.releases.count() == 0 %}
|
||||
{{ _("You need to create a release before this package can be approved.") }}
|
||||
{% else %}
|
||||
{{ _("Release is still importing, or has an error.") }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ _("A release is required before this package can be approved.") }}
|
||||
{% endif %}
|
||||
@@ -43,6 +48,10 @@
|
||||
{% elif package.state == package.state.READY_FOR_REVIEW and ("Other" in package.license.name or "Other" in package.media_license.name) %}
|
||||
{% set message = _("Please wait for the license to be added to CDB.") %}
|
||||
|
||||
{% elif package.state == package.state.READY_FOR_REVIEW and (package.provides | length) == 1 and package.provides[0].name != package.name %}
|
||||
{% set level = "danger" %}
|
||||
{% set message = _("Mod name does not match package name.") %}
|
||||
|
||||
{% else %}
|
||||
{% set level = "info" %}
|
||||
{% set message %}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
{% macro render_thread(thread, current_user) -%}
|
||||
{% macro render_reply(r, thread, current_user) -%}
|
||||
{% from "macros/reviews.html" import render_review_vote %}
|
||||
|
||||
{% from "macros/reviews.html" import render_review_vote %}
|
||||
|
||||
<ul class="comments mt-4 mb-0">
|
||||
{% for r in thread.replies %}
|
||||
<li class="row my-2 mx-0">
|
||||
<div class="col-md-1 p-1">
|
||||
<a href="{{ url_for('users.profile', username=r.author.username) }}">
|
||||
@@ -77,6 +74,43 @@
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro render_status_update(r, thread, current_user) -%}
|
||||
<li class="row my-2 mx-0 align-items-center">
|
||||
<div class="col-md-1 p-1">
|
||||
<a href="{{ url_for('users.profile', username=r.author.username) }}">
|
||||
<img class="img-fluid user-photo img-thumbnail img-thumbnail-1" src="{{ r.author.getProfilePicURL() }}">
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a class="author {{ r.author.rank.name }}"
|
||||
href="{{ url_for('users.profile', username=r.author.username) }}">
|
||||
{{ r.author.display_name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col pr-0 status-update">
|
||||
{{ r.comment | markdown }}
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a name="reply-{{ r.id }}" class="text-muted float-right"
|
||||
href="{{ r.get_url() }}">
|
||||
{{ r.created_at | datetime }}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro render_thread(thread, current_user, form=None) -%}
|
||||
<ul class="comments mt-4 mb-0">
|
||||
{% for r in thread.replies %}
|
||||
{% if r.is_status_update %}
|
||||
{{ render_status_update(r, thread, current_user) }}
|
||||
{% else %}
|
||||
{{ render_reply(r, thread, current_user) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -114,6 +148,13 @@
|
||||
{% endif %}
|
||||
<input class="btn btn-primary" type="submit" disabled value="Comment" />
|
||||
</div>
|
||||
{% elif form %}
|
||||
{% from "macros/forms.html" import render_field, render_submit_field %}
|
||||
<form method="post" action="{{ url_for('threads.view', id=thread.id)}}" class="card-body">
|
||||
{{ form.hidden_tag() }}
|
||||
{{ render_field(form.comment, fieldclass="form-control markdown", label="") }}
|
||||
{{ render_submit_field(form.submit) }}
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('threads.view', id=thread.id)}}" class="card-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
@@ -121,6 +162,11 @@
|
||||
<input class="btn btn-primary" type="submit" value="Comment" />
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if thread.private %}
|
||||
<p class="text-muted card-body my-0 pt-0">
|
||||
{{ _("You can add someone to a private thread by writing @username.") }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,8 +247,8 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if t.package %}
|
||||
<div class="col-md-2 text-muted text-right">
|
||||
<div class="col-md-2 text-muted text-right">
|
||||
{% if t.package %}
|
||||
<img
|
||||
class="img-fluid"
|
||||
style="max-height: 22px; max-width: 22px;"
|
||||
@@ -211,8 +257,8 @@
|
||||
<span class="pl-2">
|
||||
{{ t.package.title }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
|
||||
@@ -4,18 +4,21 @@
|
||||
{{ mpackage.name }} - {{ _("Meta Packages") }}
|
||||
{% endblock %}
|
||||
|
||||
{% from "macros/packagegridtile.html" import render_pkggrid %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ _("Meta Package \"%(name)s\"", name=mpackage.name) }}</h1>
|
||||
|
||||
<h2>{{ _("Provided By") }}</h2>
|
||||
|
||||
{% from "macros/packagegridtile.html" import render_pkggrid %}
|
||||
{{ render_pkggrid(mpackage.packages.filter_by(state="APPROVED").all()) }}
|
||||
<h3>{{ _("Games") }}</h3>
|
||||
{{ render_pkggrid(mpackage.packages.filter_by(type="GAME", state="APPROVED").all()) }}
|
||||
|
||||
<h3>{{ _("Mods") }}</h3>
|
||||
{{ render_pkggrid(mpackage.packages.filter_by(type="MOD", state="APPROVED").all()) }}
|
||||
|
||||
{% if similar_topics %}
|
||||
<p>
|
||||
{{ _("Unfortunately, this isn't on ContentDB yet! Here's some forum topic(s):") }}
|
||||
</p>
|
||||
<h3>{{ _("Forum Topics") }}</h3>
|
||||
<ul>
|
||||
{% for t in similar_topics %}
|
||||
<li>
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
pattern="[0-9]+",
|
||||
prefix="forum.minetest.net/viewtopic.php?t=",
|
||||
placeholder=_("Tip: paste in a forum topic URL")) }}
|
||||
{{ render_field(form.video_url, class_="pkg_meta", hint=_("YouTube videos will be shown in an embed.")) }}
|
||||
</fieldset>
|
||||
|
||||
<div class="pkg_meta mt-5">{{ render_submit_field(form.submit) }}</div>
|
||||
|
||||
57
app/templates/packages/game_hub.html
Normal file
57
app/templates/packages/game_hub.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ _("Community Hub") }} -
|
||||
{{ _('%(title)s by %(author)s', title=package.title, author=package.author.display_name) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block headextra %}
|
||||
<meta name="og:title" content="{{ self.title() }}"/>
|
||||
<meta name="og:description" content="{{ _('Mods for %(title)s', title=package.title) }}"/>
|
||||
<meta name="description" content="{{ _('Mods for %(title)s', title=package.title) }}"/>
|
||||
<meta name="og:url" content="{{ package.getURL('packages.game_hub', absolute=True) }}"/>
|
||||
{% if package.getMainScreenshotURL() %}
|
||||
<meta name="og:image" content="{{ package.getMainScreenshotURL(absolute=True) }}"/>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% from "macros/packagegridtile.html" import render_pkggrid %}
|
||||
|
||||
<h1 class="mb-5">
|
||||
{{ _("Community Hub") }} -
|
||||
<a href="{{ package.getURL('packages.view') }}">
|
||||
{{ _('%(title)s by %(author)s', title=package.title, author=package.author.display_name) }}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<a href="{{ url_for('packages.list_all', sort='approved_at', order='desc', game=package.getId()) }}" class="btn btn-secondary float-right">
|
||||
{{ _("See more") }}
|
||||
</a>
|
||||
<h2 class="my-3">{{ _("Recently Added") }}</h2>
|
||||
{{ render_pkggrid(new) }}
|
||||
|
||||
|
||||
<a href="{{ url_for('packages.list_all', sort='last_release', order='desc', game=package.getId()) }}" class="btn btn-secondary float-right">
|
||||
{{ _("See more") }}
|
||||
</a>
|
||||
<h2 class="my-3">{{ _("Recently Updated") }}</h2>
|
||||
{{ render_pkggrid(updated) }}
|
||||
|
||||
|
||||
<a href="{{ url_for('packages.list_all', type='mod', sort='score', order='desc', game=package.getId()) }}" class="btn btn-secondary float-right">
|
||||
{{ _("See more") }}
|
||||
</a>
|
||||
<h2 class="my-3">{{ _("Top Mods") }}</h2>
|
||||
{{ render_pkggrid(pop_mod) }}
|
||||
|
||||
|
||||
<a href="{{ url_for('packages.list_all', sort='reviews', order='desc', game=package.getId()) }}" class="btn btn-secondary float-right">
|
||||
{{ _("See more") }}
|
||||
</a>
|
||||
<h2 class="my-3">{{ _("Highest Reviewed") }}</h2>
|
||||
{{ render_pkggrid(high_reviewed) }}
|
||||
|
||||
{% endblock %}
|
||||
@@ -32,8 +32,6 @@
|
||||
<p class="mt-3">
|
||||
{{ _("Note: Min and max versions will be used to hide the package on
|
||||
platforms not within the range.") }}
|
||||
{{ _("You cannot select the oldest version for min or the newest version
|
||||
for max as this does not make sense - you can't predict the future.") }}
|
||||
<br />
|
||||
{{ _("Leave both as None if in doubt.") }}
|
||||
</p>
|
||||
|
||||
@@ -62,11 +62,6 @@
|
||||
|
||||
{{ _("You can <a href='/help/package_config/'>set this automatically</a> in the .conf of your package.") }}
|
||||
</p>
|
||||
<p>
|
||||
{{ _("You cannot select the oldest version for min or the newest version
|
||||
for max as this does not make sense - you can't predict the future.") }}
|
||||
</p>
|
||||
|
||||
<p class="mt-5">
|
||||
{{ render_submit_field(form.submit) }}
|
||||
</p>
|
||||
|
||||
@@ -82,11 +82,6 @@
|
||||
<br />
|
||||
{{ _("Leave both as None if in doubt.") }}
|
||||
</p>
|
||||
<p>
|
||||
{{ _("You cannot select the oldest version for min or the newest version
|
||||
for max as this does not make sense - you can't predict the future.") }}
|
||||
</p>
|
||||
|
||||
<p class="mt-5">
|
||||
{{ render_submit_field(form.submit) }}
|
||||
</p>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ _("Add a screenshot") }} | {{ package.title }}
|
||||
{{ _("Add a screenshot") }} - {{ package.title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ _("Add a screenshot") }}</h1>
|
||||
<p class="mb-4">
|
||||
{{ _("The recommended resolution is 1920x1080, and screenshots must be at least %(width)dx%(height)d.",
|
||||
width=920, height=517) }}
|
||||
</p>
|
||||
|
||||
{% from "macros/forms.html" import render_field, render_submit_field %}
|
||||
<form method="POST" action="" enctype="multipart/form-data">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
{% block content %}
|
||||
{% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
|
||||
<a href="{{ package.getURL("packages.create_screenshot") }}" class="btn btn-primary float-right">
|
||||
<a href="{{ package.getURL('packages.create_screenshot') }}" class="btn btn-primary float-right">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
{{ _("Add Image") }}
|
||||
</a>
|
||||
@@ -26,16 +26,34 @@
|
||||
<i class="fas fa-bars"></i>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<img class="img-fluid" style="max-height: 64px;"
|
||||
src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" />
|
||||
<img class="img-fluid" style="max-height: 64px;" src="{{ ss.getThumbnailURL() }}" />
|
||||
</div>
|
||||
<div class="col">
|
||||
{{ ss.title }}
|
||||
{% if not ss.approved %}
|
||||
<div class="text-muted">
|
||||
{{ _("Awaiting approval") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-1 text-muted">
|
||||
{{ ss.width }} x {{ ss.height }}
|
||||
{% if ss.is_low_res() %}
|
||||
{% if ss.is_very_small() %}
|
||||
<span class="badge badge-danger ml-3">
|
||||
{{ _("Way too small") }}
|
||||
</span>
|
||||
{% elif ss.is_too_small() %}
|
||||
<span class="badge badge-warning ml-3">
|
||||
{{ _("Too small") }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary ml-3">
|
||||
{{ _("Not HD") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not ss.approved %}
|
||||
<span class="ml-3">
|
||||
{{ _("Awaiting approval") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<form action="{{ ss.getDeleteURL() }}" method="POST" class="col-auto text-right" role="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
@@ -78,6 +96,11 @@
|
||||
|
||||
{{ render_submit_field(form.submit, tabindex=280) }}
|
||||
</form>
|
||||
|
||||
<h2>{{ _("Videos") }}</h2>
|
||||
<p>
|
||||
{{ _("You can set a video on the Edit Details page") }}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block scriptextra %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% set query=package.name %}
|
||||
{% set release = package.getDownloadRelease() %}
|
||||
|
||||
{% extends "base.html" %}
|
||||
|
||||
@@ -10,12 +11,17 @@
|
||||
<meta name="og:title" content="{{ package.title }}"/>
|
||||
<meta name="og:description" content="{{ package.short_desc }}"/>
|
||||
<meta name="description" content="{{ package.short_desc }}"/>
|
||||
<meta name="og:url" content="{{ package.getURL("packages.view", absolute=True) }}"/>
|
||||
<meta name="og:url" content="{{ package.getURL('packages.view', absolute=True) }}"/>
|
||||
{% if package.getMainScreenshotURL() %}
|
||||
<meta name="og:image" content="{{ package.getMainScreenshotURL(absolute=True) }}"/>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scriptextra %}
|
||||
<script async src="/static/video_embed.js"></script>
|
||||
<script async src="/static/gallery.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% macro render_license(license) %}
|
||||
{% if license.url %}
|
||||
<a href="{{ license.url }}">{{ license.name }}</a>
|
||||
@@ -24,6 +30,52 @@
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% block download_btn %}
|
||||
{% if release %}
|
||||
<a class="btn btn-block btn-lg btn-download" rel="nofollow" download="{{ release.getDownloadFileName() }}"
|
||||
href="{{ package.getURL('packages.download') }}">
|
||||
<div>
|
||||
{{ _("Download") }}
|
||||
</div>
|
||||
|
||||
{% if release and (release.min_rel or release.max_rel) %}
|
||||
<small class="count display-block">
|
||||
{% if release.min_rel and release.max_rel %}
|
||||
{{ _("Minetest %(min)s - %(max)s", min=release.min_rel.name, max=release.max_rel.name) }}
|
||||
{% elif release.min_rel %}
|
||||
{{ _("For Minetest %(min)s and above", min=release.min_rel.name) }}
|
||||
{% elif release.max_rel %}
|
||||
{{ _("Minetest %(max)s and below", max=release.max_rel.name) }}
|
||||
{% endif %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
{% if package.type == package.type.MOD %}
|
||||
{% set installing_url = "https://wiki.minetest.net/Installing_Mods" %}
|
||||
{% elif package.type == package.type.GAME %}
|
||||
{% set installing_url = "https://wiki.minetest.net/Games#Installing_games" %}
|
||||
{% elif package.type == package.type.TXP %}
|
||||
{% set installing_url = "https://wiki.minetest.net/Installing_Texture_Packs" %}
|
||||
{% else %}
|
||||
{{ 0 / 0 }}
|
||||
{% endif %}
|
||||
|
||||
<p class="text-center mt-1 mb-0">
|
||||
<a href="{{ installing_url }}">
|
||||
<small>
|
||||
<i class="fas fa-question-circle mr-1"></i>
|
||||
{{ _("How do I install this?") }}
|
||||
</small>
|
||||
</a>
|
||||
</p>
|
||||
{% else %}
|
||||
<i>
|
||||
{{ _("No downloads available") }}
|
||||
</i>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block container %}
|
||||
{% if not package.license.is_foss and not package.media_license.is_foss and package.type != package.type.TXP %}
|
||||
{% set package_warning=_("Non-free code and media") %}
|
||||
@@ -32,171 +84,41 @@
|
||||
{% elif not package.media_license.is_foss %}
|
||||
{% set package_warning=_("Non-free media") %}
|
||||
{% endif %}
|
||||
{% set release = package.getDownloadRelease() %}
|
||||
<style>
|
||||
.bg-banner {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 65vh;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
<main>
|
||||
{% set cover_image = package.cover_image.url or package.getMainScreenshotURL() %}
|
||||
<header class="jumbotron pb-3"
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7)), url('{{ cover_image }}');
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;">
|
||||
<div class="container">
|
||||
<div class="btn-group float-right mb-4">
|
||||
{% if package.checkPerm(current_user, "EDIT_PACKAGE") %}
|
||||
<a class="btn btn-primary" href="{{ package.getURL("packages.create_edit") }}">
|
||||
<i class="fas fa-pen mr-1"></i>
|
||||
{{ _("Edit") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if package.checkPerm(current_user, "MAKE_RELEASE") %}
|
||||
<a class="btn btn-primary" href="{{ package.getURL("packages.create_release") }}">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
{{ _("Release") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if package.checkPerm(current_user, "DELETE_PACKAGE") or package.checkPerm(current_user, "UNAPPROVE_PACKAGE") %}
|
||||
<a class="btn btn-danger" href="{{ package.getURL("packages.remove") }}">
|
||||
<i class="fas fa-trash mr-1"></i>
|
||||
{{ _("Remove") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h1 class="display-3">
|
||||
{% if cover_image %}
|
||||
<div style="position:relative;">
|
||||
<div class="bg-banner" style="background: linear-gradient(rgba(34, 34, 34, 0.7), rgba(34, 34, 34, 1)), url('{{ cover_image }}');
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<header class="container pt-3 mb-4">
|
||||
<div class="row align-items-center">
|
||||
<h1 class="col my-0 display-4">
|
||||
{{ package.title }}
|
||||
</h1>
|
||||
|
||||
<p class="lead">
|
||||
{{ package.short_desc }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{% if package.dev_state.name == "LOOKING_FOR_MAINTAINER" or package.dev_state.name == "DEPRECATED" %}
|
||||
<span class="badge badge-warning" title="{{ package.dev_state.get_desc() }}">
|
||||
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
|
||||
{{ package.dev_state.value }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if package_warning %}
|
||||
<a class="badge badge-danger" href="/help/non_free/">
|
||||
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
|
||||
{{ package_warning }}
|
||||
<div class="col-md-3 text-right">
|
||||
{% if package.type == package.type.GAME %}
|
||||
<a href="{{ package.getURL('packages.game_hub') }}" class="btn btn-lg btn-block btn-primary">
|
||||
{{ _("View content for game") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% for warning in package.content_warnings %}
|
||||
<a class="badge badge-warning" rel="nofollow" href="/help/content_flags/"
|
||||
title="{{ warning.description }}">
|
||||
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
|
||||
{{ warning.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if package.dev_state.name == "WIP" %}
|
||||
<span class="badge badge-info" title="{{ package.dev_state.get_desc() }}">
|
||||
<i class="fas fa-tools" style="margin-right: 0.3em;"></i>
|
||||
{{ _("Work in Progress") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% for t in package.tags %}
|
||||
<a class="badge badge-primary" rel="nofollow"
|
||||
title="{{ t.description or '' }}"
|
||||
href="{{ url_for('packages.list_all', tag=t.name) }}">
|
||||
{{ t.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
|
||||
<div class="info-row row" style="margin-top: 2rem;">
|
||||
<div class="btn-group-horizontal col">
|
||||
<a class="btn" href="{{ url_for('users.profile', username=package.author.username) }}" title="{{ _("Author") }}">
|
||||
<img src="{{ package.author.getProfilePicURL() }}" style="max-height: 1em; filter: none">
|
||||
<span class="count">
|
||||
{{ package.author.display_name }}
|
||||
</span>
|
||||
</a>
|
||||
{% if release %}
|
||||
<a class="btn" rel="nofollow" href="{{ package.getURL("packages.download") }}" title="{{ _("Downloads") }}"
|
||||
download="{{ release.getDownloadFileName() }}">
|
||||
<i class="fas fa-download"></i>
|
||||
<span class="count">{{ package.downloads }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="btn" href="{{ url_for('threads.list_all', pid=package.id) }}" title="{{ _("Threads") }}">
|
||||
<i class="fas fa-comment-alt"></i>
|
||||
<span class="count">{{ threads | length }}</span>
|
||||
</a>
|
||||
<a class="btn" href="#reviews" title="{{ _("Reviews") }}">
|
||||
<i class="fas fa-star-half-alt"></i>
|
||||
<span class="count">
|
||||
+{{ package.reviews | selectattr("recommends") | list | length }}
|
||||
/
|
||||
-{{ package.reviews | rejectattr("recommends") | list | length }}
|
||||
</span>
|
||||
</a>
|
||||
{% if package.website %}
|
||||
<a class="btn" href="{{ package.website }}">
|
||||
<i class="fas fa-globe-europe"></i>
|
||||
<span class="count">{{ _("Website") }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if package.repo %}
|
||||
<a class="btn" href="{{ package.repo }}">
|
||||
<i class="fas fa-code"></i>
|
||||
<span class="count">{{ _("Source") }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if package.forums %}
|
||||
<a class="btn" href="https://forum.minetest.net/viewtopic.php?t={{ package.forums }}">
|
||||
<i class="fas fa-comments"></i>
|
||||
<span class="count">{{ _("Forums") }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if package.issueTracker %}
|
||||
<a class="btn" href="{{ package.issueTracker }}">
|
||||
<i class="fas fa-bug"></i>
|
||||
<span class="count">{{ _("Issue Tracker") }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if release and (release.min_rel or release.max_rel) %}
|
||||
<div class="btn col-md-auto">
|
||||
<img src="https://www.minetest.net/media/icon.svg" style="max-height: 1.2em;">
|
||||
<span class="count">
|
||||
{% if release.min_rel and release.max_rel %}
|
||||
{{ _("%(min)s - %(max)s", min=release.min_rel.name, max=release.max_rel.name) }}
|
||||
{% elif release.min_rel %}
|
||||
{{ _("%(min)s and above", min=release.min_rel.name) }}
|
||||
{% elif release.max_rel %}
|
||||
{{ _("%(max)s and below", max=release.max_rel.name) }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="btn-group btn-group-horizontal col-md-auto">
|
||||
{% if release %}
|
||||
<a class="btn btn-download" rel="nofollow" download="{{ release.getDownloadFileName() }}"
|
||||
href="{{ package.getURL("packages.download") }}">
|
||||
{{ _("Download") }}
|
||||
</a>
|
||||
{% if package.type == package.type.MOD %}
|
||||
{% set installing_url = "https://wiki.minetest.net/Installing_Mods" %}
|
||||
{% elif package.type == package.type.GAME %}
|
||||
{% set installing_url = "https://wiki.minetest.net/Games#Installing_games" %}
|
||||
{% elif package.type == package.type.TXP %}
|
||||
{% set installing_url = "https://wiki.minetest.net/Installing_Texture_Packs" %}
|
||||
{% else %}
|
||||
{{ 0 / 0 }}
|
||||
{% endif %}
|
||||
|
||||
|
||||
<a href="{{ installing_url }}" class="btn btn-download">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<i>
|
||||
{{ _("No downloads available") }}
|
||||
</i>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -211,7 +133,7 @@
|
||||
<h2>{% if review_thread.private %}🔒{% endif %} {{ review_thread.title }}</h2>
|
||||
{% if review_thread.private %}
|
||||
<p><i>
|
||||
{{ _("This thread is only visible to the package owner and users of Approver rank or above.") }}
|
||||
{{ _("This thread is only visible to its creator, package maintainers, users of Approver rank or above, and @mentioned users.") }}
|
||||
</i></p>
|
||||
{% endif %}
|
||||
|
||||
@@ -222,40 +144,134 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="container mt-4">
|
||||
<section class="container mb-4">
|
||||
<div class="row">
|
||||
<div class="col-md-9" style="padding-right: 45px;">
|
||||
{% set screenshots = package.screenshots.all() %}
|
||||
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
|
||||
{% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
|
||||
<a href="{{ package.getURL("packages.screenshots") }}" class="btn btn-primary float-right">
|
||||
<i class="fas fa-images mr-1"></i>
|
||||
{% set screenshots = package.screenshots.all() %}
|
||||
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") or package.video_url %}
|
||||
<div id="packageGallery" class="col-md-9 carousel slide my-0" data-ride="carousel" data-interval="7500">
|
||||
<div class="carousel-inner">
|
||||
{% for ss in screenshots %}
|
||||
<div class="carousel-item {% if loop.index == 1 %}active{% endif %}">
|
||||
<a href="{{ ss.url }}">
|
||||
<div class="embed-responsive embed-responsive-16by9">
|
||||
<img class="embed-responsive-item" src="{{ ss.url }}"
|
||||
alt="{{ ss.title }}">
|
||||
</div>
|
||||
<div class="carousel-caption text-shadow">
|
||||
<h3 class="my-0">
|
||||
{{ ss.title }}
|
||||
</h3>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<a class="carousel-control-prev" href="#packageGallery" role="button" data-slide="prev">
|
||||
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
|
||||
<span class="sr-only">{{ _("Previous") }}</span>
|
||||
</a>
|
||||
<a class="carousel-control-next" href="#packageGallery" role="button" data-slide="next">
|
||||
<span class="carousel-control-next-icon" aria-hidden="true"></span>
|
||||
<span class="sr-only">{{ _("Next") }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<ol class="gallery-thumbnails">
|
||||
{% if package.video_url %}
|
||||
<li>
|
||||
<a href="{{ package.video_url }}" class="video-embed">
|
||||
<i class="fas fa-play"></i>
|
||||
<div class="label">
|
||||
<i class="fas fa-external-link-square-alt"></i>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
|
||||
{% for ss in screenshots %}
|
||||
<li data-target="#packageGallery" data-slide-to="{{ loop.index - 1 }}" {% if loop.index == 1 %}class="active"{% endif %}>
|
||||
<img src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" />
|
||||
{% if not ss.approved %}
|
||||
<span class="badge bg-dark badge-tr">{{ _("Awaiting review") }}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<a href="{{ package.getURL('packages.create_screenshot') }}">
|
||||
<i class="fas fa-plus screenshot-add"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% else %}
|
||||
<div class="col">
|
||||
{% endif %}
|
||||
<div class="btn-group btn-group-sm mb-3">
|
||||
{% if package.checkPerm(current_user, "EDIT_PACKAGE") %}
|
||||
<a class="btn btn-primary" href="{{ package.getURL('packages.create_edit') }}">
|
||||
<i class="fas fa-pen mr-1"></i>
|
||||
{{ _("Edit") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if package.checkPerm(current_user, "MAKE_RELEASE") %}
|
||||
<a class="btn btn-primary" href="{{ package.getURL('packages.create_release') }}">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
{{ _("Release") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if package.checkPerm(current_user, "DELETE_PACKAGE") or package.checkPerm(current_user, "UNAPPROVE_PACKAGE") %}
|
||||
<a class="btn btn-danger" href="{{ package.getURL('packages.remove') }}">
|
||||
<i class="fas fa-trash mr-1"></i>
|
||||
{{ _("Remove") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="lead">
|
||||
{{ package.short_desc }}
|
||||
</p>
|
||||
<p>
|
||||
{% if package.dev_state.name == "LOOKING_FOR_MAINTAINER" or package.dev_state.name == "DEPRECATED" %}
|
||||
<span class="badge badge-warning" title="{{ package.dev_state.get_desc() }}">
|
||||
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
|
||||
{{ package.dev_state.value }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if package_warning %}
|
||||
<a class="badge badge-danger" href="/help/non_free/">
|
||||
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
|
||||
{{ package_warning }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% for warning in package.content_warnings %}
|
||||
<a class="badge badge-warning" rel="nofollow" href="/help/content_flags/"
|
||||
title="{{ warning.description }}">
|
||||
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
|
||||
{{ warning.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if package.dev_state.name == "WIP" %}
|
||||
<span class="badge badge-info" title="{{ package.dev_state.get_desc() }}">
|
||||
<i class="fas fa-tools" style="margin-right: 0.3em;"></i>
|
||||
{{ _("Work in Progress") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% for t in package.tags %}
|
||||
<a class="badge badge-primary" rel="nofollow"
|
||||
title="{{ t.description or '' }}"
|
||||
href="{{ url_for('packages.list_all', tag=t.name) }}">
|
||||
{{ t.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ul class="screenshot_list">
|
||||
{% for ss in screenshots %}
|
||||
{% if ss.approved or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
|
||||
<li>
|
||||
<a href="{{ ss.url }}" class="position-relative">
|
||||
<img src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" />
|
||||
{% if not ss.approved %}
|
||||
<span class="badge bg-dark badge-tr">{{ _("Awaiting review") }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li>
|
||||
<a href="{{ package.getURL("packages.create_screenshot") }}">
|
||||
<i class="fas fa-plus screenshot-add"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<section class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-9" style="padding-right: 45px;">
|
||||
{% if package.desc %}
|
||||
<article class="markdown panel mb-5">
|
||||
{{ package.desc | markdown }}
|
||||
@@ -268,7 +284,7 @@
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if has_review %}
|
||||
<p>
|
||||
<a class="btn btn-primary" href="{{ package.getURL("packages.review") }}">
|
||||
<a class="btn btn-primary" href="{{ package.getURL('packages.review') }}">
|
||||
{{ _("Edit Review") }}
|
||||
</a>
|
||||
</p>
|
||||
@@ -294,9 +310,20 @@
|
||||
{% from "macros/packagegridtile.html" import render_pkggrid %}
|
||||
{{ render_pkggrid(packages_uses) }}
|
||||
{% endif %}
|
||||
|
||||
{% if false %}
|
||||
<h2>{{ _("Content") }}</h2>
|
||||
<a href="{{ package.getURL('packages.game_hub') }}" class="btn btn-lg btn-primary">
|
||||
{{ _("View content for game") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<aside class="col-md-3 info-sidebar">
|
||||
<div class="mb-4">
|
||||
{{ self.download_btn() }}
|
||||
</div>
|
||||
|
||||
{% if package.checkPerm(current_user, "MAKE_RELEASE") and package.update_config and package.update_config.outdated_at %}
|
||||
{% set config = package.update_config %}
|
||||
<div class="alert alert-warning">
|
||||
@@ -387,6 +414,26 @@
|
||||
</dl>
|
||||
{% endif %}
|
||||
|
||||
{% if false %}
|
||||
<h3>{{ _("Compatible Games") }}</h3>
|
||||
<div style="max-height: 300px; overflow: hidden auto;">
|
||||
{% for support in package.getSortedSupportedGames() %}
|
||||
<a class="badge badge-secondary"
|
||||
href="{{ support.game.getURL('packages.view') }}">
|
||||
{{ _("%(title)s by %(display_name)s",
|
||||
title=support.game.title, display_name=support.game.author.display_name) }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ _("No specific game is required") }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<p class="text-muted small mt-2 mb-0">
|
||||
{{ _("This is an experimental feature.") }}
|
||||
{{ _("Supported games are determined by an algorithm, and may not be correct.") }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>
|
||||
{{ _("Information") }}
|
||||
</h3>
|
||||
@@ -482,7 +529,7 @@
|
||||
{% if package.approved and current_user != package.author %}
|
||||
|
|
||||
{% endif %}
|
||||
<a href="{{ package.getURL("packages.audit") }}">
|
||||
<a href="{{ package.getURL('packages.audit') }}">
|
||||
{{ _("See audit log") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
{{ _("Only the admin will be able to see who made the report.") }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="alert alert-info">
|
||||
{{ _("Found a bug? Please report on the package's issue tracker or in a thread instead.") }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
|
||||
{% block title %}
|
||||
{{ _("Threads") }}
|
||||
{% if package %}
|
||||
- {{ package.title }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if current_user.is_authenticated and package %}
|
||||
<a href="{{ url_for('threads.new', pid=package and package.id) }}" class="btn btn-primary float-right">{{ _("New Thread") }}</a>
|
||||
{% endif %}
|
||||
<h1>{{ self.title() }}</h1>
|
||||
|
||||
{% from "macros/pagination.html" import render_pagination %}
|
||||
|
||||
@@ -35,10 +35,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ render_checkbox_field(form.private, class_="my-3") }}
|
||||
<p>
|
||||
{{ _("Only you, the package author, and users of Approver rank and above can read private threads.") }}
|
||||
</p>
|
||||
{% if allow_private_change %}
|
||||
{{ render_checkbox_field(form.private, class_="my-3") }}
|
||||
{% elif form.private.data %}
|
||||
<p>
|
||||
Private.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if allow_private_change or form.private.data %}
|
||||
<p>
|
||||
{{ _("Only you, the package author, and users of Approver rank and above can read private threads.") }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{{ render_submit_field(form.submit) }}
|
||||
</form>
|
||||
|
||||
@@ -36,10 +36,16 @@
|
||||
<input type="submit" class="btn btn-primary" value="{{ _('Subscribe') }}" />
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if thread and thread.checkPerm(current_user, "DELETE_THREAD") %}
|
||||
{% if thread.checkPerm(current_user, "DELETE_THREAD") %}
|
||||
<a href="{{ url_for('threads.delete_thread', id=thread.id) }}" class="float-right mr-2 btn btn-danger">{{ _('Delete') }}</a>
|
||||
{% endif %}
|
||||
{% if thread and thread.checkPerm(current_user, "LOCK_THREAD") %}
|
||||
{% if thread.review and thread.review.checkPerm(current_user, "DELETE_REVIEW") and current_user.username != thread.review.author.username %}
|
||||
<form method="post" action="{{ thread.review.getDeleteURL() }}" class="float-right mr-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="submit" class="btn btn-danger" value="{{ _('Convert to Thread') }}" />
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if thread.checkPerm(current_user, "LOCK_THREAD") %}
|
||||
{% if thread.locked %}
|
||||
<form method="post" action="{{ url_for('threads.set_lock', id=thread.id, lock=0) }}" class="float-right mr-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
@@ -75,16 +81,35 @@
|
||||
|
||||
{% if thread.package %}
|
||||
<p>
|
||||
{{ _("Package") }}: <a href="{{ thread.package.getURL("packages.view") }}">{{ thread.package.title }}</a>
|
||||
{{ _("Package") }}: <a href="{{ thread.package.getURL('packages.view') }}">{{ thread.package.title }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if thread.private %}
|
||||
<i>
|
||||
{{ _("This thread is only visible to its creator, the package owner, and users of Approver rank or above.") }}
|
||||
</i>
|
||||
<aside class="row">
|
||||
<div class="col-md-9">
|
||||
<i>
|
||||
{{ _("This thread is only visible to its creator, package maintainers, users of Approver rank or above, and @mentioned users.") }}
|
||||
</i>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="d-flex flex-row justify-content-end flex-wrap align-items-center" style="gap: 0.5em;">
|
||||
<span class="text-muted mr-2" title="{{ _('This thread is visible to the following users') }}">
|
||||
{{ _("Visible to:") }}
|
||||
</span>
|
||||
{% for viewer in thread.get_visible_to() %}
|
||||
<a href="{{ url_for('users.profile', username=viewer.username) }}" title="{{ viewer.display_name }}">
|
||||
<img style="max-height: 2em;" src="{{ viewer.getProfilePicURL() }}" alt="{{ viewer.display_name }}" />
|
||||
</a>
|
||||
{% endfor %}
|
||||
<a href="{{ url_for('users.list_all') }}" title="{{ _('Plus approvers and editors') }}">
|
||||
+ <i class="fas fa-user-check"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{% endif %}
|
||||
|
||||
{% from "macros/threads.html" import render_thread %}
|
||||
{{ render_thread(thread, current_user) }}
|
||||
{{ render_thread(thread, current_user, form) }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -123,22 +123,22 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if total_to_tag != 0 %}
|
||||
<h2 class="mt-5">{{ _("Tag Packages") }}</h2>
|
||||
|
||||
<h2 class="mt-5">{{ _("Tag Packages") }}</h2>
|
||||
<p>
|
||||
{{ _("%(total_to_tag)d / %(total_packages)d packages don't have any tags.",
|
||||
total_to_tag=total_to_tag, total_packages=total_packages) }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ _("%(total_to_tag)d / %(total_packages)d packages don't have any tags.",
|
||||
total_to_tag=total_to_tag, total_packages=total_packages) }}
|
||||
</p>
|
||||
|
||||
<div class="progress my-4">
|
||||
{% set perc = 100 * (total_packages - total_to_tag) / total_packages %}
|
||||
<div class="progress-bar bg-success" role="progressbar"
|
||||
style="width: {{ perc }}%" aria-valuenow="{{ perc }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
|
||||
<a class="btn btn-primary" href="{{ url_for('todo.tags') }}">{{ _("View Tags") }}</a>
|
||||
<div class="progress my-4">
|
||||
{% set perc = 100 * (total_packages - total_to_tag) / total_packages %}
|
||||
<div class="progress-bar bg-success" role="progressbar"
|
||||
style="width: {{ perc }}%" aria-valuenow="{{ perc }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
|
||||
<a class="btn btn-primary" href="{{ url_for('todo.tags') }}">{{ _("View Tags") }}</a>
|
||||
{% endif %}
|
||||
|
||||
{% if unfulfilled_meta_packages %}
|
||||
<h2 class="mt-5">
|
||||
@@ -185,4 +185,17 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="mt-5"></div>
|
||||
{% if current_user.rank.atLeast(current_user.rank.MODERATOR) %}
|
||||
<a class="btn btn-secondary float-right" href="{{ url_for('admin.audit') }}">
|
||||
{{ _("View All") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<h2>{{ _("Recent Actions") }}</h2>
|
||||
|
||||
{% from "macros/audit_log.html" import render_audit_log %}
|
||||
{{ render_audit_log(audit_log, current_user) }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ _("Unapproved Packages Needing Action") }}</h2>
|
||||
<div class="list-group mt-3 mb-5">
|
||||
{% for package in unapproved_packages %}
|
||||
@@ -53,21 +54,75 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
<h2>{{ _("Potentially Outdated Packages") }}</h2>
|
||||
<p class="alert alert-info">
|
||||
{{ _("New: Git Update Detection has been set up on all packages to send notifications.") }}<br />
|
||||
{{ _("Consider changing the update settings to create releases automatically instead.") }}
|
||||
</p>
|
||||
<p>
|
||||
{{ _("Instead of marking packages as outdated, you can automatically create releases when New Commits or New Tags are pushed to Git by clicking 'Update Settings'.") }}
|
||||
{% if outdated_packages %}
|
||||
{{ _("To remove a package from below, create a release or change the update settings.") }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% from "macros/todo.html" import render_outdated_packages %}
|
||||
{{ render_outdated_packages(outdated_packages, current_user) }}
|
||||
|
||||
|
||||
<div class="mt-5"></div>
|
||||
<h2 id="small-screenshots">{{ _("Small Screenshots") }}</h2>
|
||||
{% if packages_with_small_screenshots %}
|
||||
<p>
|
||||
{{ _("These packages have screenshots that are too small, and should be replaced.") }}
|
||||
{{ _("Red and orange are screenshots below the limit, and grey screenshots are below the recommended resolution.") }}
|
||||
{{ _("The recommended resolution is 1920x1080, and screenshots must be at least %(width)dx%(height)d.",
|
||||
width=920, height=517) }}
|
||||
|
||||
<span class="badge badge-danger ml-3">
|
||||
{{ _("Way too small") }}
|
||||
</span>
|
||||
<span class="badge badge-warning">
|
||||
{{ _("Too small") }}
|
||||
</span>
|
||||
<span class="badge badge-secondary">
|
||||
{{ _("Not HD") }}
|
||||
</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="list-group mt-3 mb-5">
|
||||
{% for package in packages_with_small_screenshots %}
|
||||
<a class="list-group-item list-group-item-action" href="{{ package.getURL('packages.screenshots') }}">
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-muted" style="min-width: 200px;">
|
||||
<img
|
||||
class="img-fluid"
|
||||
style="max-height: 22px; max-width: 22px;"
|
||||
src="{{ package.getThumbnailOrPlaceholder() }}" />
|
||||
|
||||
<span class="pl-2">
|
||||
{{ package.title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-sm">
|
||||
{% for ss in package.screenshots %}
|
||||
{% if ss.is_low_res() %}
|
||||
{% if ss.is_very_small() %}
|
||||
{% set badge_color = "badge-danger" %}
|
||||
{% elif ss.is_too_small() %}
|
||||
{% set badge_color = "badge-warning" %}
|
||||
{% else %}
|
||||
{% set badge_color = "badge-secondary" %}
|
||||
{% endif %}
|
||||
<span class="badge {{ badge_color }} ml-2" title="{{ ss.title }}">
|
||||
{{ ss.width }} x {{ ss.height }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="text-muted">{{ _("Nothing to do :)") }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
<a class="btn btn-secondary float-right" href="{{ url_for('todo.tags', author=user.username) }}">
|
||||
{{_ ("See All") }}</a>
|
||||
<h2>{{ _("Packages Without Tags") }}</h2>
|
||||
|
||||
@@ -41,13 +41,37 @@
|
||||
|
||||
{% if not user.rank.atLeast(current_user.rank) %}
|
||||
<h3>{{ _("Ban") }}</h3>
|
||||
{% if user.rank.name == "BANNED" %}
|
||||
{% if user.ban %}
|
||||
<p>
|
||||
Banned.
|
||||
Banned by {{ user.ban.banned_by.display_name }} at {{ user.ban.created_at | full_datetime }}
|
||||
{% if user.ban.expires_at %}
|
||||
until {{ user.ban.expires_at | date }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<blockquote>
|
||||
{{ user.ban.message }}
|
||||
</blockquote>
|
||||
<form method="POST" action="{{ url_for('users.modtools_unban', username=user.username) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="submit" value="{{ _('Unban') }}" class="btn btn-primary" />
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="{{ url_for('users.modtools_ban', username=user.username) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="form-group">
|
||||
<label for="message">{{ _("Message") }}</label>
|
||||
<input id="message" class="form-control" type="text" name="message" required minlength="5">
|
||||
<small class="form-text text-muted">
|
||||
{{ _("Message to display to banned user") }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="expires_at">{{ _("Expires At") }}</label>
|
||||
<input id="expires_at" class="form-control" type="date" name="expires_at">
|
||||
<small class="form-text text-muted">
|
||||
{{ _("Expiry date. Leave blank for permanent ban") }}
|
||||
</small>
|
||||
</div>
|
||||
<input type="submit" value="{{ _('Ban') }}" class="btn btn-danger" />
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<a class="btn" href="{{ url_for('packages.list_all', author=user.username) }}">
|
||||
<i class="fas fa-box"></i>
|
||||
<span class="count">
|
||||
<strong>{{ user.packages.count() }}</strong>
|
||||
<strong>{{ user.packages.filter_by(state='APPROVED').count() }}</strong>
|
||||
{{ _("packages") }}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
</strong>.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
{{ _("ContentDB will no longer be able to send "forget password" and other essential system emails.
|
||||
Consider editing your email notification preferences instead.") }}
|
||||
{{ _('ContentDB will no longer be able to send "forget password" and other essential system emails.
|
||||
Consider editing your email notification preferences instead.') }}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
26
app/templates/zipgrep/search.html
Normal file
26
app/templates/zipgrep/search.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ _("Search in Package Releases") }}
|
||||
{% endblock %}
|
||||
|
||||
{% block query_hint %}
|
||||
<a href="https://www.digitalocean.com/community/tutorials/using-grep-regular-expressions-to-search-for-text-patterns-in-linux#extended-regular-expressions">
|
||||
POSIX Extended Regular Expressions
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ self.title() }}</h1>
|
||||
{% from "macros/forms.html" import render_field, render_submit_field %}
|
||||
<form action="" method="POST" class="form" role="form">
|
||||
{{ form.hidden_tag() }}
|
||||
{{ render_field(form.query, hint=self.query_hint()) }}
|
||||
{{ render_field(form.file_filter, hint="Supports wildcards and regex") }}
|
||||
{{ render_submit_field(form.submit, tabindex=180) }}
|
||||
</form>
|
||||
|
||||
<p class="mt-5">
|
||||
For more information, see <a href="https://linux.die.net/man/1/zipgrep">ZipGrep's man page</a>.
|
||||
</p>
|
||||
{% endblock %}
|
||||
47
app/templates/zipgrep/view_results.html
Normal file
47
app/templates/zipgrep/view_results.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ _("'%(query)s' - Search Package Releases", query=query) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a class="btn btn-secondary float-right" href="{{ url_for('zipgrep.zipgrep_search') }}">New Query</a>
|
||||
<h1>{{ _("Search in Package Releases") }}</h1>
|
||||
<h2>{{ query }}</h2>
|
||||
|
||||
<p class="text-muted">
|
||||
Found in {{ matches | count }} package(s).
|
||||
</p>
|
||||
|
||||
<div class="list-group">
|
||||
{% for match in matches %}
|
||||
<div class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col-sm-2 text-muted">
|
||||
<img
|
||||
class="img-fluid"
|
||||
src="{{ match.package.getThumbnailOrPlaceholder() }}" />
|
||||
|
||||
<div class="mt-2">
|
||||
<a href="{{ match.package.getURL('packages.view') }}">
|
||||
{{ match.package.title }}
|
||||
</a>
|
||||
by {{ match.package.author.display_name }}
|
||||
</div>
|
||||
|
||||
<p class="mt-4">
|
||||
{{ match.lines.split("\n") | select | list | count }} match(es)
|
||||
</p>
|
||||
|
||||
<a class="mt-4 btn btn-secondary" href="{{ match.package.getDownloadRelease().getDownloadURL() }}">
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-10">
|
||||
<pre style="max-height: 300px;" class="m-0">{{ match.lines }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -53,7 +53,7 @@ def test_register(client):
|
||||
|
||||
rv = register(client, "££££!!!", "Test User", "password", "test@example.com", "13")
|
||||
assert b"invalid-feedback" in rv.data
|
||||
assert b"Only a-zA-Z0-9._ allowed</p>" in rv.data
|
||||
assert b"Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed</p>" in rv.data
|
||||
|
||||
|
||||
def test_register_flow(client):
|
||||
|
||||
13
app/tests/unit/test_url.py
Normal file
13
app/tests/unit/test_url.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from app.utils.url import clean_youtube_url
|
||||
|
||||
|
||||
def test_clean_youtube_url():
|
||||
assert clean_youtube_url(
|
||||
"https://www.youtube.com/watch?v=AABBCC") == "https://www.youtube.com/watch?v=AABBCC"
|
||||
assert clean_youtube_url(
|
||||
"https://www.youtube.com/watch?v=boGcB4H5-WA&other=1") == "https://www.youtube.com/watch?v=boGcB4H5-WA"
|
||||
assert clean_youtube_url("https://www.youtube.com/watch?kk=boGcB4H5-WA&other=1") is None
|
||||
assert clean_youtube_url("https://www.bob.com/watch?v=AABBCC") is None
|
||||
|
||||
assert clean_youtube_url("https://youtu.be/boGcB4H5-WA") == "https://www.youtube.com/watch?v=boGcB4H5-WA"
|
||||
assert clean_youtube_url("https://youtu.be/boGcB4H5-WA?this=1") == "https://www.youtube.com/watch?v=boGcB4H5-WA"
|
||||
@@ -14,13 +14,12 @@
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import re
|
||||
import secrets
|
||||
|
||||
from .flask import *
|
||||
from .models import *
|
||||
from .user import *
|
||||
import re
|
||||
|
||||
|
||||
YESES = ["yes", "true", "1", "on"]
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ def abs_url_samesite(path):
|
||||
return urlunparse(base._replace(path=path))
|
||||
|
||||
def url_current(abs=False):
|
||||
if request.args is None or request.view_args is None:
|
||||
return None
|
||||
|
||||
args = MultiDict(request.args)
|
||||
dargs = dict(args.lists())
|
||||
dargs.update(request.view_args)
|
||||
|
||||
24
app/utils/image.py
Normal file
24
app/utils/image.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2022 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from typing import Tuple
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def get_image_size(path: str) -> Tuple[int,int]:
|
||||
im = Image.open(path)
|
||||
return im.size
|
||||
@@ -18,8 +18,7 @@
|
||||
from functools import wraps
|
||||
from flask import abort, redirect, url_for, request
|
||||
from flask_login import current_user
|
||||
from app.models import User, NotificationType, Package, UserRank, Notification, db, AuditSeverity, AuditLogEntry, \
|
||||
ThreadReply, Thread, PackageState, PackageType, PackageAlias
|
||||
from app.models import User, NotificationType, Package, UserRank, Notification, db, AuditSeverity, AuditLogEntry, ThreadReply, Thread, PackageState, PackageType, PackageAlias
|
||||
|
||||
|
||||
def getPackageByInfo(author, name):
|
||||
@@ -40,15 +39,14 @@ def is_package_page(f):
|
||||
if not ("author" in kwargs and "name" in kwargs):
|
||||
abort(400)
|
||||
|
||||
author = kwargs.pop("author")
|
||||
name = kwargs.pop("name")
|
||||
author = kwargs["author"]
|
||||
name = kwargs["name"]
|
||||
|
||||
package = getPackageByInfo(author, name)
|
||||
if package is None:
|
||||
package = getPackageByInfo(author, name + "_game")
|
||||
if package and package.type == PackageType.GAME:
|
||||
args = dict(kwargs)
|
||||
args["author"] = author
|
||||
args["name"] = name + "_game"
|
||||
return redirect(url_for(request.endpoint, **args))
|
||||
|
||||
@@ -61,6 +59,8 @@ def is_package_page(f):
|
||||
|
||||
abort(404)
|
||||
|
||||
del kwargs["author"]
|
||||
del kwargs["name"]
|
||||
return f(package=package, *args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
46
app/utils/url.py
Normal file
46
app/utils/url.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2022 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import urllib.parse as urlparse
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
|
||||
def url_set_query(url: str, params: Dict[str, str]) -> str:
|
||||
url_parts = list(urlparse.urlparse(url))
|
||||
query = dict(urlparse.parse_qsl(url_parts[4]))
|
||||
query.update(params)
|
||||
|
||||
url_parts[4] = urlparse.urlencode(query)
|
||||
return urlparse.urlunparse(url_parts)
|
||||
|
||||
|
||||
def url_get_query(parsed_url: urlparse.ParseResult) -> Dict[str, List[str]]:
|
||||
return urlparse.parse_qs(parsed_url.query)
|
||||
|
||||
|
||||
def clean_youtube_url(url: str) -> Optional[str]:
|
||||
parsed = urlparse.urlparse(url)
|
||||
print(parsed)
|
||||
if (parsed.netloc == "www.youtube.com" or parsed.netloc == "youtube.com") and parsed.path == "/watch":
|
||||
print(url_get_query(parsed))
|
||||
video_id = url_get_query(parsed).get("v", [None])[0]
|
||||
if video_id:
|
||||
return url_set_query("https://www.youtube.com/watch", {"v": video_id})
|
||||
|
||||
elif parsed.netloc == "youtu.be":
|
||||
return url_set_query("https://www.youtube.com/watch", {"v": parsed.path[1:]})
|
||||
|
||||
return None
|
||||
@@ -5,7 +5,7 @@ BASE_URL = "http://" + SERVER_NAME
|
||||
SECRET_KEY = ""
|
||||
WTF_CSRF_SECRET_KEY = ""
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = "postgres://contentdb:password@db:5432/contentdb"
|
||||
SQLALCHEMY_DATABASE_URI = "postgresql://contentdb:password@db:5432/contentdb"
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
GITHUB_CLIENT_ID = ""
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
- config.env
|
||||
|
||||
redis:
|
||||
image: 'redis:3.0-alpine'
|
||||
image: 'redis:6.2-alpine'
|
||||
command: redis-server
|
||||
volumes:
|
||||
- './data/redis:/data'
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
|
||||
worker:
|
||||
build: .
|
||||
command: celery -A app.tasks.celery worker
|
||||
command: celery -A app.tasks.celery worker --concurrency 1
|
||||
env_file:
|
||||
- config.env
|
||||
environment:
|
||||
|
||||
105
docs/dev_intro.md
Normal file
105
docs/dev_intro.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Developer Introduction
|
||||
|
||||
## Overview
|
||||
|
||||
ContentDB is a Python [Flask](https://flask.palletsprojects.com/en/2.0.x/) webservice.
|
||||
There's a PostgreSQL database, manipulated using the [SQLAlchemy ORM](https://docs.sqlalchemy.org/en/14/).
|
||||
|
||||
When a user makes a request, Python Flask will direct the request to a *route* in an *blueprint*.
|
||||
A [blueprint](https://flask.palletsprojects.com/en/2.0.x/blueprints/) is a Flask construct to hold a set of routes.
|
||||
Routes are implemented using Python, and likely to respond by using database *models* and rendering HTML *templates*.
|
||||
|
||||
Routes may also use functions in the `app/logic/` module, which is a directory containing reusable functions. This
|
||||
allows the API, background tasks, and the front-end to reuse code.
|
||||
|
||||
To avoid blocking web requests, background tasks run as
|
||||
[Celery](https://docs.celeryproject.org/en/stable/getting-started/introduction.html) tasks.
|
||||
|
||||
|
||||
## Locations
|
||||
|
||||
### The App
|
||||
|
||||
The `app` directory contains the Python Flask application.
|
||||
|
||||
* `blueprints` contains all the Python code behind each endpoint / route.
|
||||
* `templates` contains all the HTML templates used to generate responses. Each directory in here matches a directory in blueprints.
|
||||
* `models` contains all the database table classes. ContentDB uses [SQLAlchemy](https://docs.sqlalchemy.org/en/14/) to interact with PostgreSQL.
|
||||
* `flatpages` contains all the markdown user documentation, including `/help/`.
|
||||
* `public` contains files that should be added to the web server unedited. Examples include CSS libraries, images, and JS scripts.
|
||||
* `scss` contains the stylesheet files, that are compiled into CSS.
|
||||
* `tasks` contains the background tasks executed by [Celery](https://docs.celeryproject.org/en/stable/getting-started/introduction.html).
|
||||
* `logic` is a collection of reusable functions. For example, shared code to create a release or edit a package is here.
|
||||
* `tests` contains the Unit Tests and UI tests.
|
||||
* `utils` contain generic Python utilities, for example common code to manage Flask requests.
|
||||
|
||||
There are also a number of Python files in the `app` directory. The most important one is `querybuilder.py`,
|
||||
which is used to generate SQLAlachemy queries for packages and topics.
|
||||
|
||||
### Supporting directories
|
||||
|
||||
* `migrations` contains code to manage database updates.
|
||||
* `translations` contains user-maintained translations / locales.
|
||||
* `utils` contains bash scripts to aid development and deployment.
|
||||
|
||||
|
||||
## How to find stuff
|
||||
|
||||
Generally, you want to start by finding the endpoint and then seeing the code it calls.
|
||||
|
||||
Endpoints are sensibly organised in `app/blueprints`.
|
||||
|
||||
You can also use a file search. For example, to find the package edit endpoint, search for `"/packages/<author>/<name>/edit/"`.
|
||||
|
||||
|
||||
## Users and Permissions
|
||||
|
||||
Many routes need to check whether a user can do a particular thing. Rather than hard coding this,
|
||||
models tend to have a `checkPerm` function which takes a user and a `Permission`.
|
||||
|
||||
A permission may be something like `Permission.EDIT_PACKAGE` or `Permission.DELETE_THREAD`.
|
||||
|
||||
```bash
|
||||
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
|
||||
abort(403)
|
||||
```
|
||||
|
||||
|
||||
## Translations
|
||||
|
||||
ContentDB uses [Flask-Babel](https://flask-babel.tkte.ch/) for translation. All strings need to be tagged using
|
||||
a gettext function.
|
||||
|
||||
### Translating templates (HTML)
|
||||
|
||||
```html
|
||||
<div class="something" title="{{ _('This is translatable now') }}">
|
||||
{{ _("Please remember to do something related to this page or something") }}
|
||||
</div>
|
||||
```
|
||||
|
||||
With parameters:
|
||||
|
||||
```html
|
||||
<p>
|
||||
{{ _("Hello %(username)s, you have %(count)d new messages", username=username, count=count) }}
|
||||
</p>
|
||||
```
|
||||
|
||||
See <https://pythonhosted.org/Flask-Babel/#flask.ext.babel.Babel.localeselector> and
|
||||
<https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-xiv-i18n-and-l10n>.
|
||||
|
||||
### Translating Python
|
||||
|
||||
If the text is within a request, then you can use gettext like so:
|
||||
|
||||
```py
|
||||
flash(gettext("Some error message"), "danger")
|
||||
```
|
||||
|
||||
If the text is global, for example as part of a python class, then you need to use lazy_gettext:
|
||||
|
||||
```py
|
||||
class PackageForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title (Human-readable)"), [InputRequired(), Length(1, 100)])
|
||||
```
|
||||
@@ -54,3 +54,5 @@ To hot/live update CDB whilst it is running, use:
|
||||
./utils/reload.sh
|
||||
|
||||
This will only work with python code and templates, it won't update tasks or config.
|
||||
|
||||
Now consider reading the [Developer Introduction](dev_intro.md).
|
||||
|
||||
25
migrations/versions/011e42c52d21_.py
Normal file
25
migrations/versions/011e42c52d21_.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 011e42c52d21
|
||||
Revises: 6e57b2b4dcdf
|
||||
Create Date: 2022-01-25 18:48:46.367409
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '011e42c52d21'
|
||||
down_revision = '6e57b2b4dcdf'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('package', sa.Column('video_url', sa.String(length=200), nullable=True))
|
||||
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('package', 'video_url')
|
||||
33
migrations/versions/01f8d5de29e1_.py
Normal file
33
migrations/versions/01f8d5de29e1_.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 01f8d5de29e1
|
||||
Revises: e571b3498f9e
|
||||
Create Date: 2022-02-13 10:12:20.150232
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '01f8d5de29e1'
|
||||
down_revision = 'e571b3498f9e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table('user_ban',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('message', sa.UnicodeText(), nullable=False),
|
||||
sa.Column('banned_by_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['banned_by_id'], ['user.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('user_id')
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('user_ban')
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user