Compare commits
1 Commits
oauth_scop
...
package_ga
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2866589109 |
@@ -1,4 +1,4 @@
|
||||
FROM python:3.10.11
|
||||
FROM python:3.10
|
||||
|
||||
RUN groupadd -g 5123 cdb && \
|
||||
useradd -r -u 5123 -g cdb cdb
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ContentDB
|
||||
# Content Database
|
||||

|
||||
|
||||
A content database for Minetest mods, games, and more.\
|
||||
Content database for Minetest mods, games, and more.\
|
||||
Developed by rubenwardy, license AGPLv3.0+.
|
||||
|
||||
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
|
||||
@@ -36,7 +36,7 @@ See [Developer Intro](docs/dev_intro.md) for an overview of the code organisatio
|
||||
|
||||
* (optional) Install the [Docker extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker)
|
||||
* Install the [Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python)
|
||||
* Click no to installing pylint (we don't want it to be installed outside a virtual env)
|
||||
* Click no to installing pylint (we don't want it to be installed outside of a virtual env)
|
||||
* Set up a virtual env
|
||||
* Replace `psycopg2` with `psycopg2_binary` in requirements.txt (because postgresql won't be installed on the system)
|
||||
* `python3 -m venv env`
|
||||
|
||||
@@ -15,18 +15,16 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import redis
|
||||
|
||||
from flask import redirect, url_for, render_template, flash, request, Flask, send_from_directory, make_response
|
||||
from flask_babel import Babel, gettext
|
||||
from flask_flatpages import FlatPages
|
||||
from flask_github import GitHub
|
||||
from flask import *
|
||||
from flask_gravatar import Gravatar
|
||||
from flask_login import logout_user, current_user, LoginManager
|
||||
from flask_mail import Mail
|
||||
from flask_github import GitHub
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
from flask_flatpages import FlatPages
|
||||
from flask_babel import Babel, gettext
|
||||
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")
|
||||
@@ -38,30 +36,24 @@ app.config["BABEL_TRANSLATION_DIRECTORIES"] = "../translations"
|
||||
app.config["LANGUAGES"] = {
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"es": "Español",
|
||||
"fr": "Français",
|
||||
"id": "Bahasa Indonesia",
|
||||
"it": "Italiano",
|
||||
"ms": "Bahasa Melayu",
|
||||
"pl": "Język Polski",
|
||||
"ru": "русский язык",
|
||||
"sk": "Slovenčina",
|
||||
"sv": "Svenska",
|
||||
"tr": "Türkçe",
|
||||
"uk": "Українська",
|
||||
"vi": "tiếng Việt",
|
||||
"zh_Hans": "汉语",
|
||||
}
|
||||
|
||||
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
|
||||
|
||||
redis_client = redis.Redis.from_url(app.config["REDIS_URL"])
|
||||
r = redis.Redis.from_url(app.config["REDIS_URL"])
|
||||
|
||||
github = GitHub(app)
|
||||
csrf = CSRFProtect(app)
|
||||
mail = Mail(app)
|
||||
pages = FlatPages(app)
|
||||
babel = Babel()
|
||||
babel = Babel(app)
|
||||
gravatar = Gravatar(app,
|
||||
size=64,
|
||||
rating="g",
|
||||
@@ -126,17 +118,17 @@ def check_for_ban():
|
||||
logout_user()
|
||||
return redirect(url_for("users.login"))
|
||||
elif current_user.rank == models.UserRank.NOT_JOINED:
|
||||
current_user.rank = models.UserRank.NEW_MEMBER
|
||||
current_user.rank = models.UserRank.MEMBER
|
||||
models.db.session.commit()
|
||||
|
||||
|
||||
from .utils import clear_notifications, is_safe_url, create_session
|
||||
from .utils import clearNotifications, is_safe_url
|
||||
|
||||
|
||||
@app.before_request
|
||||
def check_for_notifications():
|
||||
if current_user.is_authenticated:
|
||||
clear_notifications(request.path)
|
||||
clearNotifications(request.path)
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
@@ -149,6 +141,7 @@ def server_error(e):
|
||||
return render_template("500.html"), 500
|
||||
|
||||
|
||||
@babel.localeselector
|
||||
def get_locale():
|
||||
if not request:
|
||||
return None
|
||||
@@ -163,18 +156,16 @@ def get_locale():
|
||||
locale = request.accept_languages.best_match(locales)
|
||||
|
||||
if locale and current_user.is_authenticated:
|
||||
with create_session() as new_session:
|
||||
new_session.query(models.User) \
|
||||
.filter(models.User.username == current_user.username) \
|
||||
.update({"locale": locale})
|
||||
new_session.commit()
|
||||
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
|
||||
|
||||
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
|
||||
@app.route("/set-locale/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def set_locale():
|
||||
|
||||
@@ -1,22 +1,4 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-21 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 importlib
|
||||
import os
|
||||
|
||||
import os, importlib
|
||||
|
||||
def create_blueprints(app):
|
||||
dir = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
@@ -14,20 +14,24 @@
|
||||
# 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 os
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
import requests
|
||||
from celery import group, uuid
|
||||
from flask import redirect, url_for, flash, current_app
|
||||
from celery import group
|
||||
from flask import redirect, url_for, flash, current_app, jsonify
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.logic.game_support import GameSupportResolver
|
||||
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \
|
||||
NotificationType, PackageUpdateConfig, License, UserRank, PackageType
|
||||
NotificationType, PackageUpdateConfig, License, UserRank, PackageType, PackageGameSupport
|
||||
from app.tasks.emails import send_pending_digests
|
||||
from app.tasks.forumtasks import import_topic_list, check_all_forum_accounts
|
||||
from app.tasks.importtasks import import_repo_screenshot, check_zip_release, check_for_updates, update_all_game_support
|
||||
from app.utils import add_notification, get_system_user
|
||||
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 = {}
|
||||
|
||||
@@ -52,19 +56,66 @@ def del_stuck_releases():
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Check all releases (postReleaseCheckUpdate)")
|
||||
def check_releases():
|
||||
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
|
||||
|
||||
tasks = []
|
||||
for release in releases:
|
||||
tasks.append(checkZipRelease.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
while not result.ready():
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
|
||||
@action("Check latest release of all packages (postReleaseCheckUpdate)")
|
||||
def reimport_packages():
|
||||
tasks = []
|
||||
for package in Package.query.filter(Package.state != PackageState.DELETED).all():
|
||||
release = package.releases.first()
|
||||
if release:
|
||||
tasks.append(checkZipRelease.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
while not result.ready():
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
|
||||
@action("Import forum topic list")
|
||||
def import_topic_list():
|
||||
task = import_topic_list.delay()
|
||||
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 = check_all_forum_accounts.delay()
|
||||
task = checkAllForumAccounts.delay()
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
|
||||
|
||||
|
||||
@action("Delete unused uploads")
|
||||
@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.is_(None)) \
|
||||
.all()
|
||||
for package in packages:
|
||||
importRepoScreenshot.delay(package.id)
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Remove unused uploads")
|
||||
def clean_uploads():
|
||||
upload_dir = current_app.config['UPLOAD_DIR']
|
||||
|
||||
@@ -78,9 +129,8 @@ def clean_uploads():
|
||||
|
||||
release_urls = get_filenames_from_column(PackageRelease.url)
|
||||
screenshot_urls = get_filenames_from_column(PackageScreenshot.url)
|
||||
pp_urls = get_filenames_from_column(User.profile_pic)
|
||||
|
||||
db_urls = release_urls.union(screenshot_urls).union(pp_urls)
|
||||
db_urls = release_urls.union(screenshot_urls)
|
||||
unreachable = existing_uploads.difference(db_urls)
|
||||
|
||||
import sys
|
||||
@@ -98,14 +148,27 @@ def clean_uploads():
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("Delete unused mod names")
|
||||
def del_mod_names():
|
||||
@action("Delete unused metapackages")
|
||||
def del_meta_packages():
|
||||
query = MetaPackage.query.filter(~MetaPackage.dependencies.any(), ~MetaPackage.packages.any())
|
||||
count = query.count()
|
||||
query.delete(synchronize_session=False)
|
||||
db.session.commit()
|
||||
|
||||
flash("Deleted " + str(count) + " unused mod names", "success")
|
||||
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)
|
||||
count = query.count()
|
||||
for pkg in query.all():
|
||||
pkg.review_thread = None
|
||||
db.session.delete(pkg)
|
||||
db.session.commit()
|
||||
|
||||
flash("Deleted {} soft deleted packages packages".format(count), "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@@ -129,22 +192,23 @@ def _package_list(packages: List[str]):
|
||||
|
||||
@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)) \
|
||||
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:
|
||||
packages_list = packages_list[0:(100-54-1)] + "…"
|
||||
|
||||
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
f"Did you forget? {packages_list} {havent} been submitted for review yet",
|
||||
url_for('todo.view_user', username=user.username))
|
||||
url_for('todo.view_user', username=user.username))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@@ -155,16 +219,16 @@ def remind_outdated():
|
||||
system_user = get_system_user()
|
||||
for user in users:
|
||||
packages = db.session.query(Package.title).filter(
|
||||
Package.maintainers.contains(user),
|
||||
Package.maintainers.any(User.id==user.id),
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
||||
.all()
|
||||
|
||||
packages = [pkg[0] for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
|
||||
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
f"The following packages may be outdated: {packages_list}",
|
||||
url_for('todo.view_user', username=user.username))
|
||||
url_for('todo.view_user', username=user.username))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@@ -201,16 +265,17 @@ def import_licenses():
|
||||
licenses = r.json()["licenses"]
|
||||
|
||||
existing_licenses = {}
|
||||
for license_data in License.query.all():
|
||||
assert license_data.name not in renames.keys()
|
||||
existing_licenses[license_data.name.lower()] = license_data
|
||||
for license in License.query.all():
|
||||
assert license.name not in renames.keys()
|
||||
existing_licenses[license.name.lower()] = license
|
||||
|
||||
for license_data in licenses:
|
||||
obj = existing_licenses.get(license_data["licenseId"].lower())
|
||||
for license in licenses:
|
||||
obj = existing_licenses.get(license["licenseId"].lower())
|
||||
if obj:
|
||||
obj.url = license_data["reference"]
|
||||
elif license_data.get("isOsiApproved") and license_data.get("isFsfLibre") and not license_data["isDeprecatedLicenseId"]:
|
||||
obj = License(license_data["licenseId"], True, license_data["reference"])
|
||||
obj.url = license["reference"]
|
||||
elif license.get("isOsiApproved") and license.get("isFsfLibre") and \
|
||||
not license["isDeprecatedLicenseId"]:
|
||||
obj = License(license["licenseId"], True, license["reference"])
|
||||
db.session.add(obj)
|
||||
|
||||
db.session.commit()
|
||||
@@ -228,12 +293,12 @@ def delete_inactive_users():
|
||||
@action("Send Video URL notification")
|
||||
def remind_video_url():
|
||||
users = User.query.filter(User.maintained_packages.any(
|
||||
and_(Package.video_url == None, Package.type == PackageType.GAME, Package.state == PackageState.APPROVED)))
|
||||
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.contains(user)),
|
||||
Package.video_url == None,
|
||||
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()
|
||||
@@ -241,109 +306,33 @@ def remind_video_url():
|
||||
packages = [pkg[0] for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
|
||||
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
f"You should add a video to {packages_list}",
|
||||
url_for('users.profile', username=user.username))
|
||||
url_for('users.profile', username=user.username))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Send missing game support notifications")
|
||||
def remind_missing_game_support():
|
||||
users = User.query.filter(
|
||||
User.maintained_packages.any(and_(
|
||||
Package.state != PackageState.DELETED,
|
||||
Package.type.in_([PackageType.MOD, PackageType.TXP]),
|
||||
~Package.supported_games.any(),
|
||||
Package.supports_all_games == False))).all()
|
||||
@action("Update screenshot sizes")
|
||||
def update_screenshot_sizes():
|
||||
import sys
|
||||
|
||||
system_user = get_system_user()
|
||||
for user in users:
|
||||
packages = db.session.query(Package.title).filter(
|
||||
Package.maintainers.contains(user),
|
||||
Package.state != PackageState.DELETED,
|
||||
Package.type.in_([PackageType.MOD, PackageType.TXP]),
|
||||
~Package.supported_games.any(),
|
||||
Package.supports_all_games == False) \
|
||||
.all()
|
||||
|
||||
packages = [pkg[0] for pkg in packages]
|
||||
packages_list = _package_list(packages)
|
||||
|
||||
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
||||
f"You need to confirm whether the following packages support all games: {packages_list}",
|
||||
url_for('todo.all_game_support', username=user.username))
|
||||
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():
|
||||
task_id = uuid()
|
||||
update_all_game_support.apply_async((), task_id=task_id)
|
||||
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
||||
resolver = GameSupportResolver()
|
||||
resolver.update_all()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@action("Send pending notif digests")
|
||||
def do_send_pending_digests():
|
||||
send_pending_digests.delay()
|
||||
|
||||
|
||||
@action("DANGER: Delete removed packages")
|
||||
def del_removed_packages():
|
||||
query = Package.query.filter_by(state=PackageState.DELETED)
|
||||
count = query.count()
|
||||
for pkg in query.all():
|
||||
pkg.review_thread = None
|
||||
db.session.delete(pkg)
|
||||
db.session.commit()
|
||||
|
||||
flash("Deleted {} soft deleted packages packages".format(count), "success")
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
|
||||
@action("DANGER: Check all releases (postReleaseCheckUpdate)")
|
||||
def check_releases():
|
||||
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
|
||||
|
||||
tasks = []
|
||||
for release in releases:
|
||||
tasks.append(check_zip_release.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
while not result.ready():
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
|
||||
@action("DANGER: Check latest release of all packages (postReleaseCheckUpdate)")
|
||||
def reimport_packages():
|
||||
tasks = []
|
||||
for package in Package.query.filter(Package.state != PackageState.DELETED).all():
|
||||
release = package.releases.first()
|
||||
if release:
|
||||
tasks.append(check_zip_release.s(release.id, release.file_path))
|
||||
|
||||
result = group(tasks).apply_async()
|
||||
|
||||
while not result.ready():
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
|
||||
|
||||
@action("DANGER: 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) \
|
||||
.all()
|
||||
for package in packages:
|
||||
import_repo_screenshot.delay(package.id)
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
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 StringField, SubmitField, BooleanField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
from app.utils import rank_required, add_audit_log, add_notification, get_system_user, nonempty_or_none
|
||||
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
|
||||
from .actions import actions
|
||||
from app.models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType, PackageAlias
|
||||
from ...models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType
|
||||
|
||||
|
||||
@bp.route("/admin/", methods=["GET", "POST"])
|
||||
@@ -30,7 +30,17 @@ from app.models import UserRank, Package, db, PackageState, User, AuditSeverity,
|
||||
def admin_page():
|
||||
if request.method == "POST":
|
||||
action = request.form["action"]
|
||||
if action in actions:
|
||||
|
||||
if action == "restore":
|
||||
package = Package.query.get(request.form["package"])
|
||||
if package is None:
|
||||
flash("Unknown package", "danger")
|
||||
else:
|
||||
package.state = PackageState.READY_FOR_REVIEW
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
|
||||
elif action in actions:
|
||||
ret = actions[action]["func"]()
|
||||
if ret:
|
||||
return ret
|
||||
@@ -75,11 +85,11 @@ class SendNotificationForm(FlaskForm):
|
||||
def send_bulk_notification():
|
||||
form = SendNotificationForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user,
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
||||
"Sent bulk notification", url_for("admin.admin_page"), None, form.title.data)
|
||||
|
||||
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all()
|
||||
add_notification(users, get_system_user(), NotificationType.OTHER, form.title.data, form.url.data, None)
|
||||
addNotification(users, get_system_user(), NotificationType.OTHER, form.title.data, form.url.data, None)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("admin.admin_page"))
|
||||
@@ -105,11 +115,11 @@ def restore():
|
||||
else:
|
||||
package.state = target
|
||||
|
||||
add_audit_log(AuditSeverity.EDITOR, current_user, f"Restored package to state {target.value}",
|
||||
package.get_url("packages.view"), package)
|
||||
addAuditLog(AuditSeverity.EDITOR, current_user, f"Restored package to state {target.value}",
|
||||
package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
deleted_packages = Package.query \
|
||||
.filter(Package.state == PackageState.DELETED) \
|
||||
@@ -118,64 +128,3 @@ def restore():
|
||||
.all()
|
||||
|
||||
return render_template("admin/restore.html", deleted_packages=deleted_packages)
|
||||
|
||||
|
||||
class TransferPackageForm(FlaskForm):
|
||||
old_username = StringField("Old Username", [InputRequired()])
|
||||
new_username = StringField("New Username", [InputRequired()])
|
||||
package = StringField("Package", [Optional()])
|
||||
remove_maintainer = BooleanField("Remove current owner from maintainers")
|
||||
submit = SubmitField("Transfer")
|
||||
|
||||
|
||||
def perform_transfer(form: TransferPackageForm):
|
||||
query = Package.query.filter(Package.author.has(username=form.old_username.data))
|
||||
if nonempty_or_none(form.package.data):
|
||||
query = query.filter_by(name=form.package.data)
|
||||
|
||||
packages = query.all()
|
||||
if len(packages) == 0:
|
||||
flash("Unable to find package(s)", "danger")
|
||||
return
|
||||
|
||||
new_user = User.query.filter_by(username=form.new_username.data).first()
|
||||
if new_user is None:
|
||||
flash("Unable to find new user", "danger")
|
||||
return
|
||||
|
||||
names = [x.name for x in packages]
|
||||
already_existing = Package.query.filter(Package.author_id == new_user.id, Package.name.in_(names)).all()
|
||||
if len(already_existing) > 0:
|
||||
existing_names = [x.name for x in already_existing]
|
||||
flash("Unable to transfer packages as names exist at destination: " + ", ".join(existing_names), "danger")
|
||||
return
|
||||
|
||||
for package in packages:
|
||||
if form.remove_maintainer.data:
|
||||
package.maintainers.remove(package.author)
|
||||
package.author = new_user
|
||||
package.maintainers.append(new_user)
|
||||
package.aliases.append(PackageAlias(form.old_username.data, package.name))
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user,
|
||||
f"Transferred {form.old_username.data}/{package.name} to {form.new_username.data}",
|
||||
package.get_url("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash("Transferred " + ", ".join([x.name for x in packages]), "success")
|
||||
|
||||
return redirect(url_for("admin.transfer"))
|
||||
|
||||
|
||||
@bp.route("/admin/transfer/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def transfer():
|
||||
form = TransferPackageForm(formdata=request.form)
|
||||
if form.validate_on_submit():
|
||||
ret = perform_transfer(form)
|
||||
if ret is not None:
|
||||
return ret
|
||||
|
||||
# Process GET or invalid POST
|
||||
return render_template("admin/transfer.html", form=form)
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import render_template, request, abort
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app.models import db, AuditLogEntry, UserRank, User, Permission
|
||||
from app.models import db, AuditLogEntry, UserRank, User
|
||||
from app.utils import rank_required, get_int_or_abort
|
||||
|
||||
from . import bp
|
||||
@@ -37,15 +35,12 @@ def audit():
|
||||
abort(404)
|
||||
query = query.filter_by(causer=user)
|
||||
|
||||
pagination = query.paginate(page=page, per_page=num)
|
||||
pagination = query.paginate(page, num, True)
|
||||
return render_template("admin/audit.html", log=pagination.items, pagination=pagination)
|
||||
|
||||
|
||||
@bp.route("/admin/audit/<int:id_>/")
|
||||
@login_required
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def audit_view(id_):
|
||||
entry: AuditLogEntry = AuditLogEntry.query.get_or_404(id_)
|
||||
if not entry.check_perm(current_user, Permission.VIEW_AUDIT_DESCRIPTION):
|
||||
abort(403)
|
||||
|
||||
entry = AuditLogEntry.query.get(id_)
|
||||
return render_template("admin/audit_view.html", entry=entry)
|
||||
|
||||
@@ -22,9 +22,9 @@ from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app.markdown import render_markdown
|
||||
from app.tasks.emails import send_user_email, send_bulk_email as task_send_bulk
|
||||
from app.utils import rank_required, add_audit_log
|
||||
from app.utils import rank_required, addAuditLog
|
||||
from . import bp
|
||||
from app.models import UserRank, User, AuditSeverity
|
||||
from ...models import UserRank, User, AuditSeverity
|
||||
|
||||
|
||||
class SendEmailForm(FlaskForm):
|
||||
@@ -49,12 +49,12 @@ def send_single_email():
|
||||
|
||||
form = SendEmailForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user,
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
||||
"Sent email to {}".format(user.display_name), url_for("users.profile", username=username))
|
||||
|
||||
text = form.text.data
|
||||
html = render_markdown(text)
|
||||
task = send_user_email.delay(user.email, user.locale or "en", 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)
|
||||
@@ -65,7 +65,7 @@ def send_single_email():
|
||||
def send_bulk_email():
|
||||
form = SendEmailForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user,
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
||||
"Sent bulk email", url_for("admin.admin_page"), None, form.text.data)
|
||||
|
||||
text = form.text.data
|
||||
|
||||
@@ -16,14 +16,13 @@
|
||||
|
||||
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField, SubmitField, URLField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
|
||||
from app.utils import rank_required, nonempty_or_none, add_audit_log
|
||||
from app.utils import rank_required, nonEmptyOrNone
|
||||
from . import bp
|
||||
from app.models import UserRank, License, db, AuditSeverity
|
||||
from ...models import UserRank, License, db
|
||||
|
||||
|
||||
@bp.route("/licenses/")
|
||||
@@ -35,7 +34,7 @@ def license_list():
|
||||
class LicenseForm(FlaskForm):
|
||||
name = StringField("Name", [InputRequired(), Length(3, 100)])
|
||||
is_foss = BooleanField("Is FOSS")
|
||||
url = URLField("URL", [Optional()], filters=[nonempty_or_none])
|
||||
url = URLField("URL", [Optional], filters=[nonEmptyOrNone])
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@@ -57,15 +56,9 @@ def create_edit_license(name=None):
|
||||
license = License(form.name.data)
|
||||
db.session.add(license)
|
||||
flash("Created license " + form.name.data, "success")
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Created license {license.name}",
|
||||
url_for("admin.license_list"))
|
||||
else:
|
||||
flash("Updated license " + form.name.data, "success")
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Edited license {license.name}",
|
||||
url_for("admin.license_list"))
|
||||
|
||||
form.populate_obj(license)
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.license_list"))
|
||||
|
||||
@@ -22,8 +22,7 @@ from wtforms import StringField, TextAreaField, BooleanField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
||||
|
||||
from . import bp
|
||||
from app.models import Permission, Tag, db, AuditSeverity
|
||||
from app.utils import add_audit_log
|
||||
from ...models import Permission, Tag, db
|
||||
|
||||
|
||||
@bp.route("/tags/")
|
||||
@@ -45,8 +44,8 @@ def tag_list():
|
||||
class TagForm(FlaskForm):
|
||||
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")
|
||||
|
||||
|
||||
@@ -60,24 +59,19 @@ def create_edit_tag(name=None):
|
||||
if tag is None:
|
||||
abort(404)
|
||||
|
||||
if not Permission.check_perm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG):
|
||||
if not Permission.checkPerm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG):
|
||||
abort(403)
|
||||
|
||||
form = TagForm(obj=tag)
|
||||
form = TagForm( obj=tag)
|
||||
if form.validate_on_submit():
|
||||
if tag is None:
|
||||
tag = Tag(form.title.data)
|
||||
tag.description = form.description.data
|
||||
tag.is_protected = form.is_protected.data
|
||||
db.session.add(tag)
|
||||
|
||||
add_audit_log(AuditSeverity.EDITOR, current_user, f"Created tag {tag.name}",
|
||||
url_for("admin.create_edit_tag", name=tag.name))
|
||||
else:
|
||||
form.populate_obj(tag)
|
||||
|
||||
add_audit_log(AuditSeverity.EDITOR, current_user, f"Edited tag {tag.name}",
|
||||
url_for("admin.create_edit_tag", name=tag.name))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if Permission.EDIT_TAGS.check(current_user):
|
||||
|
||||
@@ -16,21 +16,19 @@
|
||||
|
||||
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app.utils import rank_required, add_audit_log
|
||||
from app.utils import rank_required
|
||||
from . import bp
|
||||
from app.models import UserRank, MinetestRelease, db, AuditSeverity
|
||||
from ...models import UserRank, MinetestRelease, db
|
||||
|
||||
|
||||
@bp.route("/versions/")
|
||||
@rank_required(UserRank.MODERATOR)
|
||||
def version_list():
|
||||
return render_template("admin/versions/list.html",
|
||||
versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
|
||||
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
|
||||
|
||||
|
||||
class VersionForm(FlaskForm):
|
||||
@@ -55,15 +53,9 @@ def create_edit_version(name=None):
|
||||
version = MinetestRelease(form.name.data)
|
||||
db.session.add(version)
|
||||
flash("Created version " + form.name.data, "success")
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Created version {version.name}",
|
||||
url_for("admin.license_list"))
|
||||
else:
|
||||
flash("Updated version " + form.name.data, "success")
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Edited version {version.name}",
|
||||
url_for("admin.version_list"))
|
||||
|
||||
form.populate_obj(version)
|
||||
db.session.commit()
|
||||
return redirect(url_for("admin.version_list"))
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import redirect, render_template, abort, url_for, request
|
||||
from flask import redirect, render_template, abort, url_for, request, flash
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
||||
|
||||
from app.utils import rank_required
|
||||
from . import bp
|
||||
from app.models import UserRank, ContentWarning, db
|
||||
from ...models import UserRank, ContentWarning, db
|
||||
|
||||
|
||||
@bp.route("/admin/warnings/")
|
||||
|
||||
@@ -15,35 +15,31 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
from functools import wraps
|
||||
from typing import List
|
||||
|
||||
import flask_sqlalchemy
|
||||
from flask import request, jsonify, current_app, Response
|
||||
from flask import request, jsonify, current_app
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from app import csrf
|
||||
from app.logic.graphs import get_package_stats, get_package_stats_for_user, get_all_package_stats
|
||||
from app.markdown import render_markdown
|
||||
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, \
|
||||
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread, Collection, \
|
||||
PackageAlias
|
||||
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread
|
||||
from app.querybuilder import QueryBuilder
|
||||
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, is_yes, get_request_date
|
||||
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, api_set_cover_image
|
||||
from app.utils.minetest_hypertext import html_to_minetest
|
||||
from functools import wraps
|
||||
|
||||
|
||||
def cors_allowed(f):
|
||||
@wraps(f)
|
||||
def inner(*args, **kwargs):
|
||||
res: Response = f(*args, **kwargs)
|
||||
res = f(*args, **kwargs)
|
||||
res.headers["Access-Control-Allow-Origin"] = "*"
|
||||
res.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||
res.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
||||
@@ -51,65 +47,26 @@ def cors_allowed(f):
|
||||
return inner
|
||||
|
||||
|
||||
def cached(max_age: int):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def inner(*args, **kwargs):
|
||||
res: Response = f(*args, **kwargs)
|
||||
res.cache_control.max_age = max_age
|
||||
return res
|
||||
return inner
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@bp.route("/api/packages/")
|
||||
@cors_allowed
|
||||
@cached(300)
|
||||
def packages():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.build_package_query()
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildPackageQuery()
|
||||
|
||||
if request.args.get("fmt") == "keys":
|
||||
return jsonify([pkg.as_key_dict() for pkg in query.all()])
|
||||
return jsonify([package.getAsDictionaryKey() for package in query.all()])
|
||||
|
||||
pkgs = qb.convert_to_dictionary(query.all())
|
||||
pkgs = qb.convertToDictionary(query.all())
|
||||
if "engine_version" in request.args or "protocol_version" in request.args:
|
||||
pkgs = [pkg for pkg in pkgs if pkg.get("release")]
|
||||
|
||||
# Promote featured packages
|
||||
if "sort" not in request.args and \
|
||||
"order" not in request.args and \
|
||||
"q" not in request.args and \
|
||||
"limit" not in request.args:
|
||||
featured_lut = set()
|
||||
featured = qb.convert_to_dictionary(query.filter(
|
||||
Package.collections.any(and_(Collection.name == "featured", Collection.author.has(username="ContentDB")))).all())
|
||||
for pkg in featured:
|
||||
featured_lut.add(f"{pkg['author']}/{pkg['name']}")
|
||||
pkg["short_description"] = "Featured. " + pkg["short_description"]
|
||||
|
||||
not_featured = [pkg for pkg in pkgs if f"{pkg['author']}/{pkg['name']}" not in featured_lut]
|
||||
pkgs = featured + not_featured
|
||||
|
||||
pkgs = [package for package in pkgs if package.get("release")]
|
||||
return jsonify(pkgs)
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def package_view(package):
|
||||
return jsonify(package.as_dict(current_app.config["BASE_URL"]))
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/hypertext/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def package_hypertext(package):
|
||||
formspec_version = request.args["formspec_version"]
|
||||
include_images = is_yes(request.args.get("include_images", "true"))
|
||||
html = render_markdown(package.desc)
|
||||
return jsonify(html_to_minetest(html, formspec_version, include_images))
|
||||
def package(package):
|
||||
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/", methods=["PUT"])
|
||||
@@ -125,12 +82,12 @@ def edit_package(token, package):
|
||||
|
||||
|
||||
def resolve_package_deps(out, package, only_hard, depth=1):
|
||||
id_ = package.get_id()
|
||||
if id_ in out:
|
||||
id = package.getId()
|
||||
if id in out:
|
||||
return
|
||||
|
||||
ret = []
|
||||
out[id_] = ret
|
||||
out[id] = ret
|
||||
|
||||
if package.type != PackageType.MOD:
|
||||
return
|
||||
@@ -141,12 +98,12 @@ def resolve_package_deps(out, package, only_hard, depth=1):
|
||||
|
||||
if dep.package:
|
||||
name = dep.package.name
|
||||
fulfilled_by = [ dep.package.get_id() ]
|
||||
fulfilled_by = [ dep.package.getId() ]
|
||||
resolve_package_deps(out, dep.package, only_hard, depth)
|
||||
|
||||
elif dep.meta_package:
|
||||
name = dep.meta_package.name
|
||||
fulfilled_by = [ pkg.get_id() for pkg in dep.meta_package.packages if pkg.state == PackageState.APPROVED]
|
||||
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 \
|
||||
@@ -179,9 +136,9 @@ def package_dependencies(package):
|
||||
@bp.route("/api/topics/")
|
||||
@cors_allowed
|
||||
def topics():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.build_topic_query(show_added=True)
|
||||
return jsonify([t.as_dict() for t in query.all()])
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildTopicQuery(show_added=True)
|
||||
return jsonify([t.getAsDictionary() for t in query.all()])
|
||||
|
||||
|
||||
@bp.route("/api/topic_discard/", methods=["POST"])
|
||||
@@ -193,13 +150,13 @@ def topic_set_discard():
|
||||
error(400, "Missing topic ID or discard bool")
|
||||
|
||||
topic = ForumTopic.query.get(tid)
|
||||
if not topic.check_perm(current_user, Permission.TOPIC_DISCARD):
|
||||
if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
|
||||
error(403, "Permission denied, need: TOPIC_DISCARD")
|
||||
|
||||
topic.discarded = discard == "true"
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(topic.as_dict())
|
||||
return jsonify(topic.getAsDictionary())
|
||||
|
||||
|
||||
@bp.route("/api/whoami/")
|
||||
@@ -212,20 +169,6 @@ def whoami(token):
|
||||
return jsonify({ "is_authenticated": True, "username": token.owner.username })
|
||||
|
||||
|
||||
@bp.route("/api/delete-token/", methods=["DELETE"])
|
||||
@csrf.exempt
|
||||
@is_api_authd
|
||||
@cors_allowed
|
||||
def api_delete_token(token):
|
||||
if token is None:
|
||||
error(404, "Token not found")
|
||||
|
||||
db.session.delete(token)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@bp.route("/api/markdown/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def markdown():
|
||||
@@ -250,16 +193,16 @@ def list_all_releases():
|
||||
if maintainer is None:
|
||||
error(404, "Maintainer not found")
|
||||
query = query.join(Package)
|
||||
query = query.filter(Package.maintainers.contains(maintainer))
|
||||
query = query.filter(Package.maintainers.any(id=maintainer.id))
|
||||
|
||||
return jsonify([ rel.as_long_dict() for rel in query.limit(30).all() ])
|
||||
return jsonify([ rel.getLongAsDictionary() for rel in query.limit(30).all() ])
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/releases/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def list_releases(package):
|
||||
return jsonify([ rel.as_dict() for rel in package.releases.all() ])
|
||||
return jsonify([ rel.getAsDictionary() for rel in package.releases.all() ])
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/releases/new/", methods=["POST"])
|
||||
@@ -271,14 +214,10 @@ def create_release(token, package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.check_perm(token.owner, Permission.APPROVE_RELEASE):
|
||||
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
|
||||
error(403, "You do not have the permission to approve releases")
|
||||
|
||||
if request.headers.get("Content-Type") == "application/json":
|
||||
data = request.json
|
||||
else:
|
||||
data = request.form
|
||||
|
||||
data = request.json or request.form
|
||||
if "title" not in data:
|
||||
error(400, "Title is required in the POST data")
|
||||
|
||||
@@ -305,12 +244,12 @@ def create_release(token, package):
|
||||
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
def release_view(package: Package, id: int):
|
||||
def release(package: Package, id: int):
|
||||
release = PackageRelease.query.get(id)
|
||||
if release is None or release.package != package:
|
||||
error(404, "Release not found")
|
||||
|
||||
return jsonify(release.as_dict())
|
||||
return jsonify(release.getAsDictionary())
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/", methods=["DELETE"])
|
||||
@@ -326,10 +265,10 @@ def delete_release(token: APIToken, package: Package, id: int):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not token.can_operate_on_package(package):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
if not release.check_perm(token.owner, Permission.DELETE_RELEASE):
|
||||
if not release.checkPerm(token.owner, Permission.DELETE_RELEASE):
|
||||
error(403, "Unable to delete the release, make sure there's a newer release available")
|
||||
|
||||
db.session.delete(release)
|
||||
@@ -343,7 +282,7 @@ def delete_release(token: APIToken, package: Package, id: int):
|
||||
@cors_allowed
|
||||
def list_screenshots(package):
|
||||
screenshots = package.screenshots.all()
|
||||
return jsonify([ss.as_dict(current_app.config["BASE_URL"]) for ss in screenshots])
|
||||
return jsonify([ss.getAsDictionary(current_app.config["BASE_URL"]) for ss in screenshots])
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/new/", methods=["POST"])
|
||||
@@ -355,7 +294,7 @@ def create_screenshot(token: APIToken, package: Package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to create screenshots")
|
||||
|
||||
data = request.form
|
||||
@@ -366,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, is_yes(data.get("is_cover_image")))
|
||||
return api_create_screenshot(token, package, data["title"], file, isYes(data.get("is_cover_image")))
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
|
||||
@@ -377,7 +316,7 @@ def screenshot(package, id):
|
||||
if ss is None or ss.package != package:
|
||||
error(404, "Screenshot not found")
|
||||
|
||||
return jsonify(ss.as_dict(current_app.config["BASE_URL"]))
|
||||
return jsonify(ss.getAsDictionary(current_app.config["BASE_URL"]))
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/", methods=["DELETE"])
|
||||
@@ -393,10 +332,10 @@ def delete_screenshot(token: APIToken, package: Package, id: int):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to delete screenshots")
|
||||
|
||||
if not token.can_operate_on_package(package):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
if package.cover_image == ss:
|
||||
@@ -418,10 +357,10 @@ def order_screenshots(token: APIToken, package: Package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to change screenshots")
|
||||
|
||||
if not token.can_operate_on_package(package):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
json = request.json
|
||||
@@ -440,10 +379,10 @@ def set_cover_image(token: APIToken, package: Package):
|
||||
if not token:
|
||||
error(401, "Authentication needed")
|
||||
|
||||
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
||||
error(403, "You do not have the permission to change screenshots")
|
||||
|
||||
if not token.can_operate_on_package(package):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
json = request.json
|
||||
@@ -458,37 +397,29 @@ def set_cover_image(token: APIToken, package: Package):
|
||||
@cors_allowed
|
||||
def list_reviews(package):
|
||||
reviews = package.reviews
|
||||
return jsonify([review.as_dict() for review in reviews])
|
||||
return jsonify([review.getAsDictionary() for review in reviews])
|
||||
|
||||
|
||||
@bp.route("/api/reviews/")
|
||||
@cors_allowed
|
||||
def list_all_reviews():
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(get_int_or_abort(request.args.get("n"), 100), 200)
|
||||
num = min(get_int_or_abort(request.args.get("n"), 100), 100)
|
||||
|
||||
query = PackageReview.query
|
||||
query = query.options(joinedload(PackageReview.author), joinedload(PackageReview.package))
|
||||
|
||||
if "for_user" in request.args:
|
||||
query = query.filter(PackageReview.package.has(Package.author.has(username=request.args["for_user"])))
|
||||
|
||||
if "author" in request.args:
|
||||
if request.args.get("author"):
|
||||
query = query.filter(PackageReview.author.has(User.username == request.args.get("author")))
|
||||
|
||||
if "is_positive" in request.args:
|
||||
if is_yes(request.args.get("is_positive")):
|
||||
query = query.filter(PackageReview.rating > 3)
|
||||
else:
|
||||
query = query.filter(PackageReview.rating <= 3)
|
||||
if request.args.get("is_positive"):
|
||||
query = query.filter(PackageReview.recommends == isYes(request.args.get("is_positive")))
|
||||
|
||||
q = request.args.get("q")
|
||||
if q:
|
||||
query = query.filter(PackageReview.thread.has(Thread.title.ilike(f"%{q}%")))
|
||||
|
||||
query = query.order_by(db.desc(PackageReview.created_at))
|
||||
|
||||
pagination: flask_sqlalchemy.Pagination = query.paginate(page=page, per_page=num)
|
||||
pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
|
||||
return jsonify({
|
||||
"page": pagination.page,
|
||||
"per_page": pagination.per_page,
|
||||
@@ -498,67 +429,48 @@ def list_all_reviews():
|
||||
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
|
||||
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
|
||||
},
|
||||
"items": [review.as_dict(True) for review in pagination.items],
|
||||
"items": [review.getAsDictionary(True) for review in pagination.items],
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/stats/")
|
||||
@is_package_page
|
||||
@cors_allowed
|
||||
@cached(300)
|
||||
def package_stats(package: Package):
|
||||
start = get_request_date("start")
|
||||
end = get_request_date("end")
|
||||
return jsonify(get_package_stats(package, start, end))
|
||||
|
||||
|
||||
@bp.route("/api/package_stats/")
|
||||
@cors_allowed
|
||||
@cached(900)
|
||||
def all_package_stats():
|
||||
return jsonify(get_all_package_stats())
|
||||
|
||||
|
||||
@bp.route("/api/scores/")
|
||||
@cors_allowed
|
||||
@cached(300)
|
||||
def package_scores():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.build_package_query()
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.buildPackageQuery()
|
||||
|
||||
pkgs = [package.as_score_dict() for package in query.all()]
|
||||
pkgs = [package.getScoreDict() for package in query.all()]
|
||||
return jsonify(pkgs)
|
||||
|
||||
|
||||
@bp.route("/api/tags/")
|
||||
@cors_allowed
|
||||
def tags():
|
||||
return jsonify([tag.as_dict() for tag in Tag.query.all() ])
|
||||
return jsonify([tag.getAsDictionary() for tag in Tag.query.all() ])
|
||||
|
||||
|
||||
@bp.route("/api/content_warnings/")
|
||||
@cors_allowed
|
||||
def content_warnings():
|
||||
return jsonify([warning.as_dict() for warning in ContentWarning.query.all() ])
|
||||
return jsonify([warning.getAsDictionary() for warning in ContentWarning.query.all() ])
|
||||
|
||||
|
||||
@bp.route("/api/licenses/")
|
||||
@cors_allowed
|
||||
def licenses():
|
||||
all_licenses = License.query.order_by(db.asc(License.name)).all()
|
||||
return jsonify([{"name": license.name, "is_foss": license.is_foss} for license in all_licenses])
|
||||
return jsonify([ { "name": license.name, "is_foss": license.is_foss } \
|
||||
for license in License.query.order_by(db.asc(License.name)).all() ])
|
||||
|
||||
|
||||
@bp.route("/api/homepage/")
|
||||
@cors_allowed
|
||||
def homepage():
|
||||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
count = query.count()
|
||||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
count = query.count()
|
||||
|
||||
spotlight = query.filter(
|
||||
Package.collections.any(and_(Collection.name == "spotlight", Collection.author.has(username="ContentDB")))) \
|
||||
.order_by(func.random()).limit(6).all()
|
||||
new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
|
||||
featured = query.filter(Package.tags.any(name="featured")).order_by(
|
||||
func.random()).limit(6).all()
|
||||
new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
|
||||
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
|
||||
pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(8).all()
|
||||
pop_txp = query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(8).all()
|
||||
@@ -574,19 +486,19 @@ def homepage():
|
||||
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
|
||||
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
|
||||
|
||||
def map_packages(packages: List[Package]):
|
||||
return [pkg.as_short_dict(current_app.config["BASE_URL"]) for pkg in packages]
|
||||
def mapPackages(packages: List[Package]):
|
||||
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
|
||||
|
||||
return jsonify({
|
||||
"count": count,
|
||||
"downloads": downloads,
|
||||
"spotlight": map_packages(spotlight),
|
||||
"new": map_packages(new),
|
||||
"updated": map_packages(updated),
|
||||
"pop_mod": map_packages(pop_mod),
|
||||
"pop_txp": map_packages(pop_txp),
|
||||
"pop_game": map_packages(pop_gam),
|
||||
"high_reviewed": map_packages(high_reviewed)
|
||||
"featured": mapPackages(featured),
|
||||
"new": mapPackages(new),
|
||||
"updated": mapPackages(updated),
|
||||
"pop_mod": mapPackages(pop_mod),
|
||||
"pop_txp": mapPackages(pop_txp),
|
||||
"pop_game": mapPackages(pop_gam),
|
||||
"high_reviewed": mapPackages(high_reviewed)
|
||||
})
|
||||
|
||||
|
||||
@@ -595,13 +507,15 @@ def homepage():
|
||||
def welcome_v1():
|
||||
featured = Package.query \
|
||||
.filter(Package.type == PackageType.GAME, Package.state == PackageState.APPROVED,
|
||||
Package.collections.any(
|
||||
and_(Collection.name == "featured", Collection.author.has(username="ContentDB")))) \
|
||||
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.as_short_dict(current_app.config["BASE_URL"]) for pkg in packages]
|
||||
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
|
||||
|
||||
return jsonify({
|
||||
"featured": map_packages(featured),
|
||||
@@ -618,21 +532,21 @@ def versions():
|
||||
if rel is None:
|
||||
error(404, "No releases found")
|
||||
|
||||
return jsonify(rel.as_dict())
|
||||
return jsonify(rel.getAsDictionary())
|
||||
|
||||
return jsonify([rel.as_dict() \
|
||||
for rel in MinetestRelease.query.all() if rel.get_actual() is not None])
|
||||
return jsonify([rel.getAsDictionary() \
|
||||
for rel in MinetestRelease.query.all() if rel.getActual() is not None])
|
||||
|
||||
|
||||
@bp.route("/api/dependencies/")
|
||||
@cors_allowed
|
||||
def all_deps():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.build_package_query()
|
||||
query = qb.buildPackageQuery()
|
||||
|
||||
def format_pkg(pkg: Package):
|
||||
return {
|
||||
"type": pkg.type.to_name(),
|
||||
"type": pkg.type.toName(),
|
||||
"author": pkg.author.username,
|
||||
"name": pkg.name,
|
||||
"provides": [x.name for x in pkg.provides],
|
||||
@@ -642,7 +556,7 @@ def all_deps():
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(get_int_or_abort(request.args.get("n"), 100), 300)
|
||||
pagination: flask_sqlalchemy.Pagination = query.paginate(page=page, per_page=num)
|
||||
pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
|
||||
return jsonify({
|
||||
"page": pagination.page,
|
||||
"per_page": pagination.per_page,
|
||||
@@ -654,247 +568,3 @@ def all_deps():
|
||||
},
|
||||
"items": [format_pkg(pkg) for pkg in pagination.items],
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/users/<username>/")
|
||||
@cors_allowed
|
||||
def user_view(username: str):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
error(404, "User not found")
|
||||
|
||||
return jsonify(user.get_dict())
|
||||
|
||||
|
||||
@bp.route("/api/users/<username>/stats/")
|
||||
@cors_allowed
|
||||
def user_stats(username: str):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
error(404, "User not found")
|
||||
|
||||
start = get_request_date("start")
|
||||
end = get_request_date("end")
|
||||
return jsonify(get_package_stats_for_user(user, start, end))
|
||||
|
||||
|
||||
@bp.route("/api/cdb_schema/")
|
||||
@cors_allowed
|
||||
def json_schema():
|
||||
tags = Tag.query.all()
|
||||
warnings = ContentWarning.query.all()
|
||||
licenses = License.query.order_by(db.asc(License.name)).all()
|
||||
return jsonify({
|
||||
"title": "CDB Config",
|
||||
"description": "Package Configuration",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"license": {
|
||||
"enum": [license.name for license in licenses],
|
||||
"enumDescriptions": [license.is_foss and "FOSS" or "NON-FOSS" for license in licenses]
|
||||
},
|
||||
},
|
||||
"properties": {
|
||||
"type": {
|
||||
"description": "Package Type",
|
||||
"enum": ["MOD", "GAME", "TXP"],
|
||||
"enumDescriptions": ["Mod", "Game", "Texture Pack"]
|
||||
},
|
||||
"title": {
|
||||
"description": "Human-readable title",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Technical name (needs permission if already approved).",
|
||||
"type": "string",
|
||||
"pattern": "^[a-z_]+$"
|
||||
},
|
||||
"short_description": {
|
||||
"description": "Package Short Description",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"dev_state": {
|
||||
"description": "Development State",
|
||||
"enum": [
|
||||
"WIP",
|
||||
"BETA",
|
||||
"ACTIVELY_DEVELOPED",
|
||||
"MAINTENANCE_ONLY",
|
||||
"AS_IS",
|
||||
"DEPRECATED",
|
||||
"LOOKING_FOR_MAINTAINER"
|
||||
]
|
||||
},
|
||||
"tags": {
|
||||
"description": "Package Tags",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [tag.name for tag in tags],
|
||||
"enumDescriptions": [tag.title for tag in tags]
|
||||
},
|
||||
"uniqueItems": True,
|
||||
},
|
||||
"content_warnings": {
|
||||
"description": "Package Content Warnings",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [warning.name for warning in warnings],
|
||||
"enumDescriptions": [warning.title for warning in warnings]
|
||||
},
|
||||
"uniqueItems": True,
|
||||
},
|
||||
"license": {
|
||||
"description": "Package License",
|
||||
"$ref": "#/$defs/license"
|
||||
},
|
||||
"media_license": {
|
||||
"description": "Package Media License",
|
||||
"$ref": "#/$defs/license"
|
||||
},
|
||||
"long_description": {
|
||||
"description": "Package Long Description",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"repo": {
|
||||
"description": "Git Repository URL",
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"website": {
|
||||
"description": "Website URL",
|
||||
"type": ["string", "null"],
|
||||
"format": "uri"
|
||||
},
|
||||
"issue_tracker": {
|
||||
"description": "Issue Tracker URL",
|
||||
"type": ["string", "null"],
|
||||
"format": "uri"
|
||||
},
|
||||
"forums": {
|
||||
"description": "Forum Topic ID",
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 0
|
||||
},
|
||||
"video_url": {
|
||||
"description": "URL to a Video",
|
||||
"type": ["string", "null"],
|
||||
"format": "uri"
|
||||
},
|
||||
"donate_url": {
|
||||
"description": "URL to a donation page",
|
||||
"type": ["string", "null"],
|
||||
"format": "uri"
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/hypertext/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
@cors_allowed
|
||||
def hypertext():
|
||||
formspec_version = request.args["formspec_version"]
|
||||
include_images = is_yes(request.args.get("include_images", "true"))
|
||||
|
||||
html = request.data.decode("utf-8")
|
||||
if request.content_type == "text/markdown":
|
||||
html = render_markdown(html)
|
||||
|
||||
return jsonify(html_to_minetest(html, formspec_version, include_images))
|
||||
|
||||
|
||||
@bp.route("/api/collections/")
|
||||
@cors_allowed
|
||||
def collection_list():
|
||||
if "author" in request.args:
|
||||
user = User.query.filter_by(username=request.args["author"]).one_or_404()
|
||||
query = user.collections
|
||||
else:
|
||||
query = Collection.query.order_by(db.asc(Collection.title))
|
||||
|
||||
if "package" in request.args:
|
||||
id_ = request.args["package"]
|
||||
package = Package.get_by_key(id_)
|
||||
if package is None:
|
||||
error(404, f"Package {id_} not found")
|
||||
|
||||
query = query.filter(Collection.packages.contains(package))
|
||||
|
||||
collections = [x.as_short_dict() for x in query.all() if not x.private]
|
||||
return jsonify(collections)
|
||||
|
||||
|
||||
@bp.route("/api/collections/<author>/<name>/")
|
||||
@cors_allowed
|
||||
def collection_view(author, name):
|
||||
collection = Collection.query \
|
||||
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||
.one_or_404()
|
||||
|
||||
if not collection.check_perm(current_user, Permission.VIEW_COLLECTION):
|
||||
error(404, "Collection not found")
|
||||
|
||||
items = collection.items
|
||||
if collection.check_perm(current_user, Permission.EDIT_COLLECTION):
|
||||
items = [x for x in items if x.package.check_perm(current_user, Permission.VIEW_PACKAGE)]
|
||||
|
||||
ret = collection.as_dict()
|
||||
ret["items"] = [x.as_dict() for x in items]
|
||||
return jsonify(ret)
|
||||
|
||||
|
||||
@bp.route("/api/updates/")
|
||||
def updates():
|
||||
protocol_version = get_int_or_abort(request.args.get("protocol_version"))
|
||||
minetest_version = request.args.get("engine_version")
|
||||
if protocol_version or minetest_version:
|
||||
version = MinetestRelease.get(minetest_version, protocol_version)
|
||||
else:
|
||||
version = None
|
||||
|
||||
# Subquery to get the latest release for each package
|
||||
latest_release_query = (db.session.query(
|
||||
PackageRelease.package_id,
|
||||
func.max(PackageRelease.id).label('max_release_id'))
|
||||
.select_from(PackageRelease)
|
||||
.filter(PackageRelease.approved == True))
|
||||
|
||||
if version:
|
||||
latest_release_query = (latest_release_query
|
||||
.filter(or_(PackageRelease.min_rel_id == None,
|
||||
PackageRelease.min_rel_id <= version.id))
|
||||
.filter(or_(PackageRelease.max_rel_id == None,
|
||||
PackageRelease.max_rel_id >= version.id)))
|
||||
|
||||
latest_release_subquery = (
|
||||
latest_release_query
|
||||
.group_by(PackageRelease.package_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# Get package id and latest release
|
||||
query = (db.session.query(User.username, Package.name, latest_release_subquery.c.max_release_id)
|
||||
.select_from(Package)
|
||||
.join(User, Package.author)
|
||||
.join(latest_release_subquery, Package.id == latest_release_subquery.c.package_id)
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
.all())
|
||||
|
||||
ret = {}
|
||||
for author_username, package_name, release_id in query:
|
||||
ret[f"{author_username}/{package_name}"] = release_id
|
||||
|
||||
# Get aliases
|
||||
aliases = (db.session.query(PackageAlias.author, PackageAlias.name, User.username, Package.name)
|
||||
.select_from(PackageAlias)
|
||||
.join(Package, PackageAlias.package)
|
||||
.join(User, Package.author)
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
.all())
|
||||
|
||||
for old_author, old_name, new_author, new_name in aliases:
|
||||
new_release = ret.get(f"{new_author}/{new_name}")
|
||||
if new_release is not None:
|
||||
ret[f"{old_author}/{old_name}"] = new_release
|
||||
|
||||
return jsonify(ret)
|
||||
|
||||
@@ -26,7 +26,6 @@ from app.models import APIToken, Package, MinetestRelease, PackageScreenshot
|
||||
def error(code: int, msg: str):
|
||||
abort(make_response(jsonify({ "success": False, "error": msg }), code))
|
||||
|
||||
|
||||
# Catches LogicErrors and aborts with JSON error
|
||||
def guard(f):
|
||||
def ret(*args, **kwargs):
|
||||
@@ -40,7 +39,7 @@ def guard(f):
|
||||
|
||||
def api_create_vcs_release(token: APIToken, package: Package, title: str, ref: str,
|
||||
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API"):
|
||||
if not token.can_operate_on_package(package):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
reason += ", token=" + token.name
|
||||
@@ -50,13 +49,13 @@ def api_create_vcs_release(token: APIToken, package: Package, title: str, ref: s
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"task": url_for("tasks.check", id=rel.task_id),
|
||||
"release": rel.as_dict()
|
||||
"release": rel.getAsDictionary()
|
||||
})
|
||||
|
||||
|
||||
def api_create_zip_release(token: APIToken, package: Package, title: str, file,
|
||||
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API", commit_hash: str = None):
|
||||
if not token.can_operate_on_package(package):
|
||||
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API", commit_hash:str=None):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
reason += ", token=" + token.name
|
||||
@@ -66,12 +65,12 @@ def api_create_zip_release(token: APIToken, package: Package, title: str, file,
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"task": url_for("tasks.check", id=rel.task_id),
|
||||
"release": rel.as_dict()
|
||||
"release": rel.getAsDictionary()
|
||||
})
|
||||
|
||||
|
||||
def api_create_screenshot(token: APIToken, package: Package, title: str, file, is_cover_image: bool, reason="API"):
|
||||
if not token.can_operate_on_package(package):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
reason += ", token=" + token.name
|
||||
@@ -80,12 +79,12 @@ def api_create_screenshot(token: APIToken, package: Package, title: str, file, i
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"screenshot": ss.as_dict()
|
||||
"screenshot": ss.getAsDictionary()
|
||||
})
|
||||
|
||||
|
||||
def api_order_screenshots(token: APIToken, package: Package, order: [any]):
|
||||
if not token.can_operate_on_package(package):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
guard(do_order_screenshots)(token.owner, package, order)
|
||||
@@ -96,7 +95,7 @@ def api_order_screenshots(token: APIToken, package: Package, order: [any]):
|
||||
|
||||
|
||||
def api_set_cover_image(token: APIToken, package: Package, cover_image):
|
||||
if not token.can_operate_on_package(package):
|
||||
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)
|
||||
@@ -107,7 +106,7 @@ def api_set_cover_image(token: APIToken, package: Package, cover_image):
|
||||
|
||||
|
||||
def api_edit_package(token: APIToken, package: Package, data: dict, reason: str = "API"):
|
||||
if not token.can_operate_on_package(package):
|
||||
if not token.canOperateOnPackage(package):
|
||||
error(403, "API token does not have access to the package")
|
||||
|
||||
reason += ", token=" + token.name
|
||||
@@ -116,5 +115,5 @@ def api_edit_package(token: APIToken, package: Package, data: dict, reason: str
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"package": package.as_dict(current_app.config["BASE_URL"])
|
||||
"package": package.getAsDictionary(current_app.config["BASE_URL"])
|
||||
})
|
||||
|
||||
@@ -19,12 +19,12 @@ from flask import render_template, redirect, request, session, url_for, abort
|
||||
from flask_babel import lazy_gettext
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from wtforms import *
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.models import db, User, APIToken, Permission
|
||||
from app.utils import random_string
|
||||
from app.models import db, User, APIToken, Package, Permission
|
||||
from app.utils import randomString
|
||||
from . import bp
|
||||
from ..users.settings import get_setting_tabs
|
||||
|
||||
@@ -49,7 +49,7 @@ def list_tokens(username):
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CREATE_TOKEN):
|
||||
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
return render_template("api/list_tokens.html", user=user, tabs=get_setting_tabs(user), current_tab="api_tokens")
|
||||
@@ -63,7 +63,7 @@ def create_edit_token(username, id=None):
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CREATE_TOKEN):
|
||||
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
is_new = id is None
|
||||
@@ -72,8 +72,10 @@ def create_edit_token(username, id=None):
|
||||
access_token = None
|
||||
if not is_new:
|
||||
token = APIToken.query.get(id)
|
||||
if token is None or token.owner != user:
|
||||
if token is None:
|
||||
abort(404)
|
||||
elif token.owner != user:
|
||||
abort(403)
|
||||
|
||||
access_token = session.pop("token_" + str(token.id), None)
|
||||
|
||||
@@ -83,12 +85,12 @@ def create_edit_token(username, id=None):
|
||||
if form.validate_on_submit():
|
||||
if is_new:
|
||||
token = APIToken()
|
||||
db.session.add(token)
|
||||
token.owner = user
|
||||
token.access_token = random_string(32)
|
||||
token.access_token = randomString(32)
|
||||
|
||||
form.populate_obj(token)
|
||||
db.session.commit()
|
||||
db.session.add(token)
|
||||
db.session.commit() # save
|
||||
|
||||
if is_new:
|
||||
# Store token so it can be shown in the edit page
|
||||
@@ -106,7 +108,7 @@ def reset_token(username, id):
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CREATE_TOKEN):
|
||||
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
token = APIToken.query.get(id)
|
||||
@@ -115,7 +117,7 @@ def reset_token(username, id):
|
||||
elif token.owner != user:
|
||||
abort(403)
|
||||
|
||||
token.access_token = random_string(32)
|
||||
token.access_token = randomString(32)
|
||||
|
||||
db.session.commit() # save
|
||||
|
||||
@@ -132,9 +134,11 @@ def delete_token(username, id):
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CREATE_TOKEN):
|
||||
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
|
||||
abort(403)
|
||||
|
||||
is_new = id is None
|
||||
|
||||
token = APIToken.query.get(id)
|
||||
if token is None:
|
||||
abort(404)
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2023 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 re
|
||||
import typing
|
||||
|
||||
from flask import Blueprint, request, redirect, render_template, flash, abort, url_for
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField, SubmitField, FieldList, HiddenField, TextAreaField
|
||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
||||
|
||||
from app.models import Collection, db, Package, Permission, CollectionPackage, User, UserRank, AuditSeverity
|
||||
from app.utils import nonempty_or_none
|
||||
from app.utils.models import is_package_page, add_audit_log, create_session
|
||||
|
||||
bp = Blueprint("collections", __name__)
|
||||
|
||||
|
||||
regex_invalid_chars = re.compile("[^a-z0-9_]")
|
||||
|
||||
|
||||
@bp.route("/collections/")
|
||||
@bp.route("/collections/<author>/")
|
||||
def list_all(author=None):
|
||||
if author:
|
||||
user = User.query.filter_by(username=author).one_or_404()
|
||||
query = user.collections
|
||||
else:
|
||||
user = None
|
||||
query = Collection.query.filter(Collection.items.any()).order_by(db.asc(Collection.title))
|
||||
|
||||
if "package" in request.args:
|
||||
package = Package.get_by_key(request.args["package"])
|
||||
if package is None:
|
||||
abort(404)
|
||||
|
||||
query = query.filter(Collection.packages.contains(package))
|
||||
|
||||
collections = [x for x in query.all() if x.check_perm(current_user, Permission.VIEW_COLLECTION)]
|
||||
return render_template("collections/list.html",
|
||||
user=user, collections=collections,
|
||||
noindex=len(collections) == 0)
|
||||
|
||||
|
||||
@bp.route("/collections/<author>/<name>/")
|
||||
def view(author, name):
|
||||
collection = Collection.query \
|
||||
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||
.one_or_404()
|
||||
|
||||
if not collection.check_perm(current_user, Permission.VIEW_COLLECTION):
|
||||
abort(404)
|
||||
|
||||
items = collection.items
|
||||
if collection.check_perm(current_user, Permission.EDIT_COLLECTION):
|
||||
items = [x for x in items if x.package.check_perm(current_user, Permission.VIEW_PACKAGE)]
|
||||
|
||||
return render_template("collections/view.html", collection=collection, items=items)
|
||||
|
||||
|
||||
class CollectionForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3, 100)])
|
||||
name = StringField("URL", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0,
|
||||
"Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
short_description = StringField(lazy_gettext("Short Description"), [Optional(), Length(0, 200)])
|
||||
long_description = TextAreaField(lazy_gettext("Page Content"), [Optional()], filters=[nonempty_or_none])
|
||||
private = BooleanField(lazy_gettext("Private"))
|
||||
descriptions = FieldList(
|
||||
StringField(lazy_gettext("Short Description"), [Optional(), Length(0, 500)], filters=[nonempty_or_none]),
|
||||
min_entries=0)
|
||||
package_ids = FieldList(HiddenField(), min_entries=0)
|
||||
package_removed = FieldList(HiddenField(), min_entries=0)
|
||||
order = HiddenField()
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/collections/new/", methods=["GET", "POST"])
|
||||
@bp.route("/collections/<author>/<name>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_edit(author=None, name=None):
|
||||
collection: typing.Optional[Collection] = None
|
||||
if author is not None and name is not None:
|
||||
collection = Collection.query \
|
||||
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||
.one_or_404()
|
||||
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION):
|
||||
abort(403)
|
||||
elif "author" in request.args:
|
||||
author = request.args["author"]
|
||||
if author != current_user.username and not current_user.rank.at_least(UserRank.EDITOR):
|
||||
abort(403)
|
||||
|
||||
if author is None:
|
||||
author = current_user
|
||||
else:
|
||||
author = User.query.filter_by(username=author).one()
|
||||
|
||||
form = CollectionForm(formdata=request.form, obj=collection)
|
||||
|
||||
initial_packages = []
|
||||
if "package" in request.args:
|
||||
for package_id in request.args.getlist("package"):
|
||||
package = Package.get_by_key(package_id)
|
||||
if package:
|
||||
initial_packages.append(package)
|
||||
|
||||
if request.method == "GET":
|
||||
# HACK: fix bug in wtforms
|
||||
form.private.data = collection.private if collection else False
|
||||
if collection:
|
||||
for item in collection.items:
|
||||
form.descriptions.append_entry(item.description)
|
||||
form.package_ids.append_entry(item.package.get_id())
|
||||
form.package_removed.append_entry("0")
|
||||
else:
|
||||
form.name = None
|
||||
|
||||
if form.validate_on_submit():
|
||||
ret = handle_create_edit(collection, form, initial_packages, author)
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
return render_template("collections/create_edit.html",
|
||||
collection=collection, form=form)
|
||||
|
||||
|
||||
def handle_create_edit(collection: Collection, form: CollectionForm,
|
||||
initial_packages: typing.List[Package], author: User):
|
||||
|
||||
severity = AuditSeverity.NORMAL if author == current_user else AuditSeverity.EDITOR
|
||||
name = form.name.data if collection else regex_invalid_chars.sub("", form.title.data.lower().replace(" ", "_"))
|
||||
|
||||
if collection is None or name != collection.name:
|
||||
if Collection.query \
|
||||
.filter(Collection.name == name, Collection.author == author) \
|
||||
.count() > 0:
|
||||
flash(gettext("A collection with a similar title already exists"), "danger")
|
||||
return
|
||||
|
||||
if Package.query \
|
||||
.filter(Package.name == name, Package.author == author) \
|
||||
.count() > 0:
|
||||
flash(gettext("Unable to create collection as a package with that name already exists"), "danger")
|
||||
return
|
||||
|
||||
if collection is None:
|
||||
collection = Collection()
|
||||
collection.author = author
|
||||
form.populate_obj(collection)
|
||||
collection.name = name
|
||||
db.session.add(collection)
|
||||
|
||||
for package in initial_packages:
|
||||
link = CollectionPackage()
|
||||
link.package = package
|
||||
link.collection = collection
|
||||
link.order = len(collection.items)
|
||||
db.session.add(link)
|
||||
|
||||
add_audit_log(severity, current_user,
|
||||
f"Created collection {collection.author.username}/{collection.name}",
|
||||
collection.get_url("collections.view"), None)
|
||||
|
||||
else:
|
||||
form.populate_obj(collection)
|
||||
collection.name = name
|
||||
|
||||
link_lookup = {}
|
||||
for link in collection.items:
|
||||
link_lookup[link.package.get_id()] = link
|
||||
|
||||
for i, package_id in enumerate(form.package_ids):
|
||||
link = link_lookup.get(package_id.data)
|
||||
to_delete = form.package_removed[i].data == "1"
|
||||
if link is None:
|
||||
if to_delete:
|
||||
continue
|
||||
|
||||
package = Package.get_by_key(package_id.data)
|
||||
if package is None:
|
||||
abort(400)
|
||||
|
||||
link = CollectionPackage()
|
||||
link.package = package
|
||||
link.collection = collection
|
||||
link.description = form.descriptions[i].data
|
||||
link_lookup[link.package.get_id()] = link
|
||||
db.session.add(link)
|
||||
elif to_delete:
|
||||
db.session.delete(link)
|
||||
else:
|
||||
link.description = form.descriptions[i].data
|
||||
|
||||
for i, package_id in enumerate(form.order.data.split(",")):
|
||||
if package_id != "":
|
||||
link_lookup[package_id].order = i + 1
|
||||
|
||||
add_audit_log(severity, current_user,
|
||||
f"Edited collection {collection.author.username}/{collection.name}",
|
||||
collection.get_url("collections.view"), None)
|
||||
|
||||
db.session.commit()
|
||||
return redirect(collection.get_url("collections.view"))
|
||||
|
||||
|
||||
@bp.route("/collections/<author>/<name>/delete/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def delete(author, name):
|
||||
collection = Collection.query \
|
||||
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||
.one_or_404()
|
||||
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION):
|
||||
abort(403)
|
||||
|
||||
if request.method == "POST":
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user,
|
||||
f"Deleted collection {collection.author.username}/{collection.name}",
|
||||
collection.get_url("collections.view"), None)
|
||||
|
||||
db.session.delete(collection)
|
||||
db.session.commit()
|
||||
return redirect(url_for("collections.list_all", author=author))
|
||||
|
||||
return render_template("collections/delete.html", collection=collection)
|
||||
|
||||
|
||||
def toggle_package(collection: Collection, package: Package):
|
||||
severity = AuditSeverity.NORMAL if collection.author == current_user else AuditSeverity.EDITOR
|
||||
|
||||
author = User.query.get(collection.author_id) if collection.author is None else collection.author
|
||||
|
||||
if package in collection.packages:
|
||||
CollectionPackage.query \
|
||||
.filter(CollectionPackage.collection == collection, CollectionPackage.package == package) \
|
||||
.delete(synchronize_session=False)
|
||||
add_audit_log(severity, current_user,
|
||||
f"Removed {package.get_id()} from collection {author.username}/{collection.name}",
|
||||
collection.get_url("collections.view"), None)
|
||||
db.session.commit()
|
||||
return False
|
||||
else:
|
||||
link = CollectionPackage()
|
||||
link.package = package
|
||||
link.collection = collection
|
||||
link.order = len(collection.items)
|
||||
db.session.add(link)
|
||||
add_audit_log(severity, current_user,
|
||||
f"Added {package.get_id()} to collection {author.username}/{collection.name}",
|
||||
collection.get_url("collections.view"), None)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
|
||||
def get_or_create_favorites(session):
|
||||
collection = Collection.query.filter(Collection.name == "favorites", Collection.author == current_user).first()
|
||||
if collection is None:
|
||||
is_new = True
|
||||
collection = Collection()
|
||||
collection.title = "Favorites"
|
||||
collection.name = "favorites"
|
||||
collection.short_description = "My favorites"
|
||||
collection.author_id = current_user.id
|
||||
session.add(collection)
|
||||
else:
|
||||
is_new = False
|
||||
|
||||
return collection, is_new
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/add-to/", methods=["GET", "POST"])
|
||||
@is_package_page
|
||||
@login_required
|
||||
def package_add(package):
|
||||
with create_session() as new_session:
|
||||
collection, is_new = get_or_create_favorites(new_session)
|
||||
if is_new:
|
||||
new_session.commit()
|
||||
|
||||
if request.method == "POST":
|
||||
collection_id = request.form["collection"]
|
||||
collection = Collection.query.get(collection_id)
|
||||
if collection is None:
|
||||
abort(404)
|
||||
|
||||
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION):
|
||||
abort(403)
|
||||
|
||||
if toggle_package(collection, package):
|
||||
flash(gettext("Added package to collection"), "success")
|
||||
else:
|
||||
flash(gettext("Removed package from collection"), "success")
|
||||
|
||||
return redirect(package.get_url("collections.package_add"))
|
||||
|
||||
collections = current_user.collections.all()
|
||||
if current_user.rank.at_least(UserRank.EDITOR) and current_user.username != "ContentDB":
|
||||
collections.extend(Collection.query.filter(Collection.author.has(username="ContentDB")).all())
|
||||
|
||||
return render_template("collections/package_add_to.html", package=package, collections=collections)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/favorite/", methods=["POST"])
|
||||
@is_package_page
|
||||
@login_required
|
||||
def package_toggle_favorite(package):
|
||||
collection, _is_new = get_or_create_favorites(db.session)
|
||||
|
||||
if toggle_package(collection, package):
|
||||
msg = gettext("Added package to favorites collection")
|
||||
if not collection.private:
|
||||
msg += " " + gettext("(Public, change from Profile > My Collections)")
|
||||
|
||||
flash(msg, "success")
|
||||
else:
|
||||
flash(gettext("Removed package from favorites collection"), "success")
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
|
||||
@bp.route("/collections/<author>/<name>/clone/", methods=["POST"])
|
||||
@login_required
|
||||
def clone(author, name):
|
||||
old_collection: typing.Optional[Collection] = Collection.query \
|
||||
.filter(Collection.name == name, Collection.author.has(username=author)) \
|
||||
.one_or_404()
|
||||
|
||||
index = 0
|
||||
new_name = name
|
||||
new_title = old_collection.title
|
||||
while True:
|
||||
if Collection.query \
|
||||
.filter(Collection.name == new_name, Collection.author == current_user) \
|
||||
.count() == 0:
|
||||
break
|
||||
|
||||
index += 1
|
||||
new_name = f"{name}_{index}"
|
||||
new_title = f"{old_collection.title} ({index})"
|
||||
|
||||
collection = Collection()
|
||||
collection.title = new_title
|
||||
collection.author = current_user
|
||||
collection.short_description = old_collection.short_description
|
||||
collection.name = new_name
|
||||
collection.private = True
|
||||
db.session.add(collection)
|
||||
|
||||
for item in old_collection.items:
|
||||
new_item = CollectionPackage()
|
||||
new_item.package = item.package
|
||||
new_item.collection = collection
|
||||
new_item.description = item.description
|
||||
new_item.order = item.order
|
||||
db.session.add(new_item)
|
||||
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user,
|
||||
f"Created collection {collection.name} from {old_collection.author.username}/{old_collection.name} ",
|
||||
collection.get_url("collections.view"), None)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(collection.get_url("collections.view"))
|
||||
@@ -1,49 +0,0 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2023 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 Blueprint, render_template
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.models import User, Package, PackageState, db, License, PackageReview, Collection
|
||||
|
||||
bp = Blueprint("donate", __name__)
|
||||
|
||||
|
||||
@bp.route("/donate/")
|
||||
def donate():
|
||||
reviewed_packages = None
|
||||
if current_user.is_authenticated:
|
||||
reviewed_packages = Package.query.filter(
|
||||
Package.state == PackageState.APPROVED,
|
||||
or_(Package.reviews.any(and_(PackageReview.author_id == current_user.id, PackageReview.rating >= 3)),
|
||||
Package.collections.any(and_(Collection.author_id == current_user.id, Collection.name == "favorites"))),
|
||||
or_(Package.donate_url.isnot(None), Package.author.has(User.donate_url.isnot(None)))
|
||||
).order_by(db.asc(Package.title)).all()
|
||||
|
||||
query = Package.query.filter(
|
||||
Package.license.has(License.is_foss == True),
|
||||
Package.media_license.has(License.is_foss == True),
|
||||
Package.state == PackageState.APPROVED,
|
||||
or_(Package.donate_url.isnot(None), Package.author.has(User.donate_url.isnot(None)))
|
||||
).order_by(db.desc(Package.score))
|
||||
|
||||
packages_count = query.count()
|
||||
top_packages = query.limit(40).all()
|
||||
|
||||
return render_template("donate/index.html",
|
||||
reviewed_packages=reviewed_packages, top_packages=top_packages, packages_count=packages_count)
|
||||
@@ -14,7 +14,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/>.
|
||||
|
||||
from flask import Blueprint, abort
|
||||
from flask import Blueprint
|
||||
from flask_babel import gettext
|
||||
|
||||
bp = Blueprint("github", __name__)
|
||||
@@ -23,20 +23,14 @@ from flask import redirect, url_for, request, flash, jsonify, current_app
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import func, or_, and_
|
||||
from app import github, csrf
|
||||
from app.models import db, User, APIToken, Package, Permission, AuditSeverity, PackageState
|
||||
from app.utils import abs_url_for, add_audit_log, login_user_set_active, is_safe_url
|
||||
from app.models import db, User, APIToken, Package, Permission, AuditSeverity
|
||||
from app.utils import abs_url_for, addAuditLog, login_user_set_active
|
||||
from app.blueprints.api.support import error, api_create_vcs_release
|
||||
import hmac, requests
|
||||
|
||||
|
||||
@bp.route("/github/start/")
|
||||
def start():
|
||||
next = request.args.get("next")
|
||||
if next and not is_safe_url(next):
|
||||
abort(400)
|
||||
|
||||
return github.authorize("", redirect_uri=abs_url_for("github.callback", next=next))
|
||||
|
||||
return github.authorize("", redirect_uri=abs_url_for("github.callback"))
|
||||
|
||||
@bp.route("/github/view/")
|
||||
def view_permissions():
|
||||
@@ -44,28 +38,20 @@ def view_permissions():
|
||||
current_app.config["GITHUB_CLIENT_ID"]
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@bp.route("/github/callback/")
|
||||
@github.authorized_handler
|
||||
def callback(oauth_token):
|
||||
next_url = request.args.get("next")
|
||||
if oauth_token is None:
|
||||
flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger")
|
||||
return redirect(url_for("users.login"))
|
||||
|
||||
next = request.args.get("next")
|
||||
if next and not is_safe_url(next):
|
||||
abort(400)
|
||||
|
||||
redirect_to = next
|
||||
if redirect_to is None:
|
||||
redirect_to = url_for("homepage.home")
|
||||
|
||||
# Get GitGub username
|
||||
# Get Github username
|
||||
url = "https://api.github.com/user"
|
||||
r = requests.get(url, headers={"Authorization": "token " + oauth_token})
|
||||
username = r.json()["login"]
|
||||
|
||||
# Get user by GitHub username
|
||||
# Get user by github username
|
||||
userByGithub = User.query.filter(func.lower(User.github_username) == func.lower(username)).first()
|
||||
|
||||
# If logged in, connect
|
||||
@@ -74,10 +60,10 @@ def callback(oauth_token):
|
||||
current_user.github_username = username
|
||||
db.session.commit()
|
||||
flash(gettext("Linked GitHub to account"), "success")
|
||||
return redirect(redirect_to)
|
||||
return redirect(url_for("homepage.home"))
|
||||
else:
|
||||
flash(gettext("GitHub account is already associated with another user"), "danger")
|
||||
return redirect(redirect_to)
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
# If not logged in, log in
|
||||
else:
|
||||
@@ -85,12 +71,12 @@ def callback(oauth_token):
|
||||
flash(gettext("Unable to find an account for that GitHub user"), "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
|
||||
ret = login_user_set_active(userByGithub, next, remember=True)
|
||||
ret = login_user_set_active(userByGithub, remember=True)
|
||||
if ret is None:
|
||||
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger")
|
||||
return redirect(url_for("users.login"))
|
||||
|
||||
add_audit_log(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
|
||||
addAuditLog(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
|
||||
url_for("users.profile", username=userByGithub.username))
|
||||
db.session.commit()
|
||||
return ret
|
||||
@@ -103,8 +89,7 @@ def webhook():
|
||||
|
||||
# Get package
|
||||
github_url = "github.com/" + json["repository"]["full_name"]
|
||||
package = Package.query.filter(
|
||||
Package.repo.ilike("%{}%".format(github_url)), Package.state != PackageState.DELETED).first()
|
||||
package = Package.query.filter(Package.repo.ilike("%{}%".format(github_url))).first()
|
||||
if package is None:
|
||||
return error(400, "Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(github_url))
|
||||
|
||||
@@ -137,7 +122,7 @@ def webhook():
|
||||
if actual_token is None:
|
||||
return error(403, "Invalid authentication, couldn't validate API token")
|
||||
|
||||
if not package.check_perm(actual_token.owner, Permission.APPROVE_RELEASE):
|
||||
if not package.checkPerm(actual_token.owner, Permission.APPROVE_RELEASE):
|
||||
return error(403, "You do not have the permission to approve releases")
|
||||
|
||||
#
|
||||
|
||||
@@ -19,7 +19,7 @@ from flask import Blueprint, request, jsonify
|
||||
bp = Blueprint("gitlab", __name__)
|
||||
|
||||
from app import csrf
|
||||
from app.models import Package, APIToken, Permission, PackageState
|
||||
from app.models import Package, APIToken, Permission
|
||||
from app.blueprints.api.support import error, api_create_vcs_release
|
||||
|
||||
|
||||
@@ -28,8 +28,7 @@ def webhook_impl():
|
||||
|
||||
# Get package
|
||||
gitlab_url = json["project"]["web_url"].replace("https://", "").replace("http://", "")
|
||||
package = Package.query.filter(
|
||||
Package.repo.ilike("%{}%".format(gitlab_url)), Package.state != PackageState.DELETED).first()
|
||||
package = Package.query.filter(Package.repo.ilike("%{}%".format(gitlab_url))).first()
|
||||
if package is None:
|
||||
return error(400,
|
||||
"Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(gitlab_url))
|
||||
@@ -43,7 +42,7 @@ def webhook_impl():
|
||||
if token is None:
|
||||
return error(403, "Invalid authentication")
|
||||
|
||||
if not package.check_perm(token.owner, Permission.APPROVE_RELEASE):
|
||||
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
|
||||
return error(403, "You do not have the permission to approve releases")
|
||||
|
||||
#
|
||||
@@ -65,7 +64,7 @@ def webhook_impl():
|
||||
title = ref.replace("refs/tags/", "")
|
||||
else:
|
||||
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
|
||||
.format(event or "null"))
|
||||
.format(event or "null"))
|
||||
|
||||
#
|
||||
# Perform release
|
||||
|
||||
@@ -1,86 +1,44 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-23 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 Blueprint, render_template, redirect
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.models import Package, PackageReview, Thread, User, PackageState, db, PackageType, PackageRelease, Tags, Tag, \
|
||||
Collection
|
||||
|
||||
bp = Blueprint("homepage", __name__)
|
||||
|
||||
from sqlalchemy.orm import joinedload, subqueryload
|
||||
from app.models import *
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
|
||||
PKGS_PER_ROW = 4
|
||||
|
||||
|
||||
@bp.route("/gamejam/")
|
||||
def gamejam():
|
||||
return redirect("https://forum.minetest.net/viewtopic.php?t=28802")
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def home():
|
||||
def package_load(query):
|
||||
def join(query):
|
||||
return query.options(
|
||||
joinedload(Package.author),
|
||||
subqueryload(Package.main_screenshot),
|
||||
subqueryload(Package.cover_image),
|
||||
joinedload(Package.license),
|
||||
joinedload(Package.media_license))
|
||||
|
||||
def review_load(query):
|
||||
return query.options(
|
||||
joinedload(PackageReview.author),
|
||||
joinedload(PackageReview.thread).subqueryload(Thread.first_reply),
|
||||
joinedload(PackageReview.package).joinedload(Package.author).load_only(User.username, User.display_name),
|
||||
joinedload(PackageReview.package).load_only(Package.title, Package.name).subqueryload(Package.main_screenshot))
|
||||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
count = query.count()
|
||||
|
||||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
||||
count = query.count()
|
||||
featured = query.filter(Package.tags.any(name="featured")).order_by(func.random()).limit(6).all()
|
||||
|
||||
spotlight_pkgs = query.filter(
|
||||
Package.collections.any(and_(Collection.name == "spotlight", Collection.author.has(username="ContentDB")))) \
|
||||
.order_by(func.random()).limit(6).all()
|
||||
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()
|
||||
|
||||
new = package_load(query.order_by(db.desc(Package.approved_at))).limit(PKGS_PER_ROW).all()
|
||||
pop_mod = package_load(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(2*PKGS_PER_ROW).all()
|
||||
pop_gam = package_load(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(2*PKGS_PER_ROW).all()
|
||||
pop_txp = package_load(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(2*PKGS_PER_ROW).all()
|
||||
high_reviewed = package_load(query.order_by(db.desc(Package.score - Package.score_downloads))) \
|
||||
.filter(Package.reviews.any()).limit(PKGS_PER_ROW).all()
|
||||
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
|
||||
.filter_by(state=PackageState.APPROVED) \
|
||||
.order_by(db.desc(PackageRelease.releaseDate)) \
|
||||
.limit(20).all()
|
||||
updated = updated[:4]
|
||||
|
||||
updated = package_load(db.session.query(Package).select_from(PackageRelease).join(Package)
|
||||
.filter_by(state=PackageState.APPROVED)
|
||||
.order_by(db.desc(PackageRelease.releaseDate))
|
||||
.limit(20)).all()
|
||||
updated = updated[:PKGS_PER_ROW]
|
||||
|
||||
reviews = review_load(PackageReview.query.filter(PackageReview.rating > 3)
|
||||
.order_by(db.desc(PackageReview.created_at))).limit(5).all()
|
||||
reviews = PackageReview.query.filter_by(recommends=True).order_by(db.desc(PackageReview.created_at)).limit(5).all()
|
||||
|
||||
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
|
||||
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
|
||||
|
||||
tags = db.session.query(func.count(Tags.c.tag_id), Tag) \
|
||||
.select_from(Tag).outerjoin(Tags).join(Package).filter(Package.state == PackageState.APPROVED)\
|
||||
.group_by(Tag.id).order_by(db.asc(Tag.title)).all()
|
||||
.select_from(Tag).outerjoin(Tags).group_by(Tag.id).order_by(db.asc(Tag.title)).all()
|
||||
|
||||
return render_template("index.html", count=count, downloads=downloads, tags=tags, spotlight_pkgs=spotlight_pkgs,
|
||||
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam, high_reviewed=high_reviewed,
|
||||
reviews=reviews)
|
||||
return render_template("index.html", count=count, downloads=downloads, tags=tags, featured=featured,
|
||||
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam, high_reviewed=high_reviewed, reviews=reviews)
|
||||
|
||||
@@ -14,31 +14,27 @@
|
||||
# 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 Blueprint, redirect, render_template, abort
|
||||
|
||||
from flask import *
|
||||
from sqlalchemy import func
|
||||
from app.models import MetaPackage, Package, db, Dependency, PackageState, ForumTopic
|
||||
|
||||
bp = Blueprint("modnames", __name__)
|
||||
bp = Blueprint("metapackages", __name__)
|
||||
|
||||
|
||||
@bp.route("/metapackages/<path:path>")
|
||||
def mp_redirect(path):
|
||||
return redirect("/modnames/" + path)
|
||||
|
||||
|
||||
@bp.route("/modnames/")
|
||||
@bp.route("/metapackages/")
|
||||
def list_all():
|
||||
modnames = db.session.query(MetaPackage, func.count(Package.id)) \
|
||||
mpackages = db.session.query(MetaPackage, func.count(Package.id)) \
|
||||
.select_from(MetaPackage).outerjoin(MetaPackage.packages) \
|
||||
.order_by(db.asc(MetaPackage.name)) \
|
||||
.group_by(MetaPackage.id).all()
|
||||
return render_template("modnames/list.html", modnames=modnames)
|
||||
return render_template("metapackages/list.html", mpackages=mpackages)
|
||||
|
||||
|
||||
@bp.route("/modnames/<name>/")
|
||||
@bp.route("/metapackages/<name>/")
|
||||
def view(name):
|
||||
modname = MetaPackage.query.filter_by(name=name).first()
|
||||
if modname is None:
|
||||
mpackage = MetaPackage.query.filter_by(name=name).first()
|
||||
if mpackage is None:
|
||||
abort(404)
|
||||
|
||||
dependers = db.session.query(Package) \
|
||||
@@ -63,6 +59,6 @@ def view(name):
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||
.all()
|
||||
|
||||
return render_template("modnames/view.html", modname=modname,
|
||||
return render_template("metapackages/view.html", mpackage=mpackage,
|
||||
dependers=dependers, optional_dependers=optional_dependers,
|
||||
similar_topics=similar_topics)
|
||||
@@ -17,12 +17,10 @@
|
||||
from flask import Blueprint, make_response
|
||||
from sqlalchemy.sql.expression import func
|
||||
|
||||
from app.models import Package, db, User, UserRank, PackageState, PackageReview, ThreadReply, Collection
|
||||
from app.rediscache import get_key
|
||||
from app.models import Package, db, User, UserRank, PackageState
|
||||
|
||||
bp = Blueprint("metrics", __name__)
|
||||
|
||||
|
||||
def generate_metrics(full=False):
|
||||
def write_single_stat(name, help, type, value):
|
||||
fmt = "# HELP {name} {help}\n# TYPE {name} {type}\n{name} {value}\n\n"
|
||||
@@ -33,6 +31,7 @@ def generate_metrics(full=False):
|
||||
pieces = [key + "=" + str(val) for key, val in labels.items()]
|
||||
return ",".join(pieces)
|
||||
|
||||
|
||||
def write_array_stat(name, help, type, data):
|
||||
ret = "# HELP {name} {help}\n# TYPE {name} {type}\n" \
|
||||
.format(name=name, help=help, type=type)
|
||||
@@ -49,18 +48,11 @@ def generate_metrics(full=False):
|
||||
|
||||
packages = Package.query.filter_by(state=PackageState.APPROVED).count()
|
||||
users = User.query.filter(User.rank != UserRank.NOT_JOINED).count()
|
||||
reviews = PackageReview.query.count()
|
||||
comments = ThreadReply.query.count()
|
||||
collections = Collection.query.count()
|
||||
|
||||
ret = ""
|
||||
ret += write_single_stat("contentdb_packages", "Total packages", "gauge", packages)
|
||||
ret += write_single_stat("contentdb_users", "Number of registered users", "gauge", users)
|
||||
ret += write_single_stat("contentdb_downloads", "Total downloads", "gauge", downloads)
|
||||
ret += write_single_stat("contentdb_emails", "Number of emails sent", "counter", int(get_key("emails_sent", "0")))
|
||||
ret += write_single_stat("contentdb_reviews", "Number of reviews", "gauge", reviews)
|
||||
ret += write_single_stat("contentdb_comments", "Number of comments", "gauge", comments)
|
||||
ret += write_single_stat("contentdb_collections", "Number of collections", "gauge", collections)
|
||||
|
||||
if full:
|
||||
scores = Package.query.join(User).with_entities(User.username, Package.name, Package.score) \
|
||||
@@ -75,7 +67,6 @@ def generate_metrics(full=False):
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@bp.route("/metrics")
|
||||
def metrics():
|
||||
response = make_response(generate_metrics(), 200)
|
||||
|
||||
@@ -14,6 +14,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/>.
|
||||
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import or_, desc
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2023 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
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, abort, make_response, flash
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, URLField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app import csrf
|
||||
from app.blueprints.users.settings import get_setting_tabs
|
||||
from app.models import db, OAuthClient, User, Permission, APIToken, AuditSeverity, UserRank
|
||||
from app.utils import random_string, add_audit_log
|
||||
|
||||
bp = Blueprint("oauth", __name__)
|
||||
|
||||
|
||||
def build_redirect_url(url: str, code: str, state: Optional[str]):
|
||||
params = {"code": code}
|
||||
if state is not None:
|
||||
params["state"] = state
|
||||
url_parts = list(urlparse.urlparse(url))
|
||||
query = dict(urlparse.parse_qsl(url_parts[4]))
|
||||
query.update(params)
|
||||
url_parts[4] = urlencode(query)
|
||||
return urlparse.urlunparse(url_parts)
|
||||
|
||||
|
||||
@bp.route("/oauth/authorize/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def oauth_start():
|
||||
response_type = request.args.get("response_type", "code")
|
||||
if response_type != "code":
|
||||
return "Unsupported response_type, only code is supported", 400
|
||||
|
||||
client_id = request.args.get("client_id")
|
||||
if client_id is None:
|
||||
return "Missing client_id", 400
|
||||
|
||||
redirect_uri = request.args.get("redirect_uri")
|
||||
if redirect_uri is None:
|
||||
return "Missing redirect_uri", 400
|
||||
|
||||
client = OAuthClient.query.get_or_404(client_id)
|
||||
if client.redirect_url != redirect_uri:
|
||||
return "redirect_uri does not match client", 400
|
||||
|
||||
if not client.approved and client.owner != current_user:
|
||||
abort(404)
|
||||
|
||||
valid_scopes = {"user:email", "package", "package:release", "package:screenshot"}
|
||||
scope = request.args.get("scope", "")
|
||||
scopes = [x.strip() for x in scope.split(",")]
|
||||
scopes = set([x for x in scopes if x != ""])
|
||||
unknown_scopes = scopes - valid_scopes
|
||||
if unknown_scopes:
|
||||
return f"Unknown scopes: {', '.join(unknown_scopes)}", 400
|
||||
|
||||
state = request.args.get("state")
|
||||
|
||||
token = APIToken.query.filter(APIToken.client == client, APIToken.owner == current_user).first()
|
||||
if token and not (scopes - token.get_scopes()):
|
||||
token.access_token = random_string(32)
|
||||
token.auth_code = random_string(32)
|
||||
db.session.commit()
|
||||
return redirect(build_redirect_url(client.redirect_url, token.auth_code, state))
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form["action"]
|
||||
if action == "cancel":
|
||||
return redirect(client.redirect_url)
|
||||
|
||||
elif action == "authorize":
|
||||
if token is None:
|
||||
token = APIToken()
|
||||
token.name = f"Token for {client.title} by {client.owner.username}"
|
||||
token.owner = current_user
|
||||
token.client = client
|
||||
|
||||
token.access_token = random_string(32)
|
||||
assert client is not None
|
||||
token.auth_code = random_string(32)
|
||||
db.session.add(token)
|
||||
|
||||
token.set_scopes(scopes)
|
||||
|
||||
add_audit_log(AuditSeverity.USER, current_user,
|
||||
f"Granted \"{scope}\" to OAuth2 application \"{client.title}\" by {client.owner.username} [{client_id}] ",
|
||||
url_for("users.profile", username=current_user.username))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(build_redirect_url(client.redirect_url, token.auth_code, state))
|
||||
|
||||
scopes_info = []
|
||||
if not scopes:
|
||||
scopes_info.append({
|
||||
"icon": "globe-europe",
|
||||
"title": "Public data only",
|
||||
"description": "Read-only access to your public data",
|
||||
})
|
||||
|
||||
if "user:email" in scopes:
|
||||
scopes_info.append({
|
||||
"icon": "user",
|
||||
"title": gettext("Personal data"),
|
||||
"description": gettext("Email address (read-only)"),
|
||||
})
|
||||
|
||||
if ("package" in scopes or
|
||||
"package:release" in scopes or
|
||||
"package:screenshot" in scopes):
|
||||
if "package" in scopes:
|
||||
msg = gettext("Ability to edit packages and their releases, screenshots, and related data")
|
||||
elif "package:release" in scopes and "package:screenshot" in scopes:
|
||||
msg = gettext("Ability to create and edit releases and screenshots")
|
||||
elif "package:release" in scopes:
|
||||
msg = gettext("Ability to create and edit releases")
|
||||
elif "package:screenshot" in scopes:
|
||||
msg = gettext("Ability to create and edit screenshots")
|
||||
else:
|
||||
assert False, "This should never happen"
|
||||
|
||||
scopes_info.append({
|
||||
"icon": "pen",
|
||||
"title": gettext("Packages"),
|
||||
"description": msg,
|
||||
})
|
||||
|
||||
return render_template("oauth/authorize.html", client=client, scopes=scopes_info)
|
||||
|
||||
|
||||
def error(code: int, msg: str):
|
||||
abort(make_response(jsonify({"success": False, "error": msg}), code))
|
||||
|
||||
|
||||
@bp.route("/oauth/token/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def oauth_grant():
|
||||
form = request.form
|
||||
|
||||
grant_type = request.args.get("grant_type", "authorization_code")
|
||||
if grant_type != "authorization_code":
|
||||
error(400, "Unsupported grant_type, only authorization_code is supported")
|
||||
|
||||
client_id = form.get("client_id")
|
||||
if client_id is None:
|
||||
error(400, "Missing client_id")
|
||||
|
||||
client_secret = form.get("client_secret")
|
||||
if client_secret is None:
|
||||
error(400, "Missing client_secret")
|
||||
|
||||
code = form.get("code")
|
||||
if code is None:
|
||||
error(400, "Missing code")
|
||||
|
||||
client = OAuthClient.query.filter_by(id=client_id, secret=client_secret).first()
|
||||
if client is None:
|
||||
error(400, "client_id and/or client_secret is incorrect")
|
||||
|
||||
token = APIToken.query.filter_by(auth_code=code).first()
|
||||
if token is None or token.client != client:
|
||||
error(400, "Incorrect code. It may have already been redeemed")
|
||||
|
||||
token.auth_code = None
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
"access_token": token.access_token,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/user/apps/")
|
||||
@login_required
|
||||
def list_clients_redirect():
|
||||
return redirect(url_for("oauth.list_clients", username=current_user.username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/apps/")
|
||||
@login_required
|
||||
def list_clients(username):
|
||||
user = User.query.filter_by(username=username).first_or_404()
|
||||
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
abort(403)
|
||||
|
||||
return render_template("oauth/list_clients.html", user=user, tabs=get_setting_tabs(user), current_tab="oauth_clients")
|
||||
|
||||
|
||||
class OAuthClientForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(5, 30)])
|
||||
redirect_url = URLField(lazy_gettext("Redirect URL"), [InputRequired(), Length(5, 123)])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/apps/new/", methods=["GET", "POST"])
|
||||
@bp.route("/users/<username>/apps/<id_>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_edit_client(username, id_=None):
|
||||
user = User.query.filter_by(username=username).first_or_404()
|
||||
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
abort(403)
|
||||
|
||||
is_new = id_ is None
|
||||
client = None
|
||||
if id_ is not None:
|
||||
client = OAuthClient.query.get_or_404(id_)
|
||||
if client.owner != user:
|
||||
abort(404)
|
||||
|
||||
form = OAuthClientForm(formdata=request.form, obj=client)
|
||||
if form.validate_on_submit():
|
||||
if is_new:
|
||||
client = OAuthClient()
|
||||
db.session.add(client)
|
||||
client.owner = user
|
||||
client.id = random_string(24)
|
||||
client.secret = random_string(32)
|
||||
client.approved = current_user.rank.at_least(UserRank.EDITOR)
|
||||
|
||||
form.populate_obj(client)
|
||||
|
||||
verb = "Created" if is_new else "Edited"
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user,
|
||||
f"{verb} OAuth2 application {client.title} by {client.owner.username} [{client.id}]",
|
||||
url_for("oauth.create_edit_client", username=client.owner.username, id_=client.id))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("oauth.create_edit_client", username=username, id_=client.id))
|
||||
|
||||
return render_template("oauth/create_edit.html", user=user, form=form, client=client)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/apps/<id_>/delete/", methods=["POST"])
|
||||
@login_required
|
||||
def delete_client(username, id_):
|
||||
user = User.query.filter_by(username=username).first_or_404()
|
||||
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
abort(403)
|
||||
|
||||
client = OAuthClient.query.get(id_)
|
||||
if client is None or client.owner != user:
|
||||
abort(404)
|
||||
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user,
|
||||
f"Deleted OAuth2 application {client.title} by {client.owner.username} [{client.id}]",
|
||||
url_for("users.profile", username=current_user.username))
|
||||
|
||||
db.session.delete(client)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("oauth.list_clients", username=username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/apps/<id_>/revoke-all/", methods=["POST"])
|
||||
@login_required
|
||||
def revoke_all(username, id_):
|
||||
user = User.query.filter_by(username=username).first_or_404()
|
||||
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
abort(403)
|
||||
|
||||
client = OAuthClient.query.get(id_)
|
||||
if client is None or client.owner != user:
|
||||
abort(404)
|
||||
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user,
|
||||
f"Revoked all user tokens for OAuth2 application {client.title} by {client.owner.username} [{client.id}]",
|
||||
url_for("oauth.create_edit_client", username=client.owner.username, id_=client.id))
|
||||
|
||||
client.tokens = []
|
||||
db.session.commit()
|
||||
|
||||
flash(gettext("Revoked all user tokens"), "success")
|
||||
|
||||
return redirect(url_for("oauth.create_edit_client", username=client.owner.username, id_=client.id))
|
||||
@@ -17,66 +17,52 @@
|
||||
from flask import Blueprint
|
||||
from flask_babel import gettext
|
||||
|
||||
from app.models import User, Package, Permission, PackageType
|
||||
from app.models import User, Package, Permission
|
||||
|
||||
bp = Blueprint("packages", __name__)
|
||||
|
||||
|
||||
def get_package_tabs(user: User, package: Package):
|
||||
if package is None or not package.check_perm(user, Permission.EDIT_PACKAGE):
|
||||
if package is None or not package.checkPerm(user, Permission.EDIT_PACKAGE):
|
||||
return []
|
||||
|
||||
retval = [
|
||||
return [
|
||||
{
|
||||
"id": "edit",
|
||||
"title": gettext("Edit Details"),
|
||||
"url": package.get_url("packages.create_edit")
|
||||
"url": package.getURL("packages.create_edit")
|
||||
},
|
||||
{
|
||||
"id": "releases",
|
||||
"title": gettext("Releases"),
|
||||
"url": package.get_url("packages.list_releases")
|
||||
"url": package.getURL("packages.list_releases")
|
||||
},
|
||||
{
|
||||
"id": "screenshots",
|
||||
"title": gettext("Screenshots"),
|
||||
"url": package.get_url("packages.screenshots")
|
||||
"url": package.getURL("packages.screenshots")
|
||||
},
|
||||
{
|
||||
"id": "maintainers",
|
||||
"title": gettext("Maintainers"),
|
||||
"url": package.get_url("packages.edit_maintainers")
|
||||
"url": package.getURL("packages.edit_maintainers")
|
||||
},
|
||||
{
|
||||
"id": "audit",
|
||||
"title": gettext("Audit Log"),
|
||||
"url": package.get_url("packages.audit")
|
||||
},
|
||||
{
|
||||
"id": "stats",
|
||||
"title": gettext("Statistics"),
|
||||
"url": package.get_url("packages.statistics")
|
||||
"url": package.getURL("packages.audit")
|
||||
},
|
||||
{
|
||||
"id": "share",
|
||||
"title": gettext("Share and Badges"),
|
||||
"url": package.get_url("packages.share")
|
||||
"url": package.getURL("packages.share")
|
||||
},
|
||||
{
|
||||
"id": "remove",
|
||||
"title": gettext("Remove / Unpublish"),
|
||||
"url": package.get_url("packages.remove")
|
||||
"title": gettext("Remove"),
|
||||
"url": package.getURL("packages.remove")
|
||||
}
|
||||
]
|
||||
|
||||
if package.type == PackageType.MOD or package.type == PackageType.TXP:
|
||||
retval.insert(1, {
|
||||
"id": "game_support",
|
||||
"title": gettext("Supported Games"),
|
||||
"url": package.get_url("packages.game_support")
|
||||
})
|
||||
|
||||
return retval
|
||||
|
||||
|
||||
from . import packages, screenshots, releases, reviews, game_hub
|
||||
|
||||
@@ -19,7 +19,7 @@ from sqlalchemy.orm import joinedload
|
||||
|
||||
from . import bp
|
||||
from app.utils import is_package_page
|
||||
from app.models import Package, PackageType, PackageState, db, PackageRelease
|
||||
from ...models import Package, PackageType, PackageState, db, PackageRelease
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/hub/")
|
||||
@@ -33,21 +33,22 @@ def game_hub(package: Package):
|
||||
joinedload(Package.license),
|
||||
joinedload(Package.media_license))
|
||||
|
||||
query = Package.query.filter(Package.supported_games.any(game=package, supports=True), Package.state==PackageState.APPROVED)
|
||||
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, supports=True), Package.state==PackageState.APPROVED) \
|
||||
.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,
|
||||
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam,
|
||||
high_reviewed=high_reviewed)
|
||||
|
||||
@@ -13,44 +13,35 @@
|
||||
#
|
||||
# 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
|
||||
import typing
|
||||
from urllib.parse import quote as urlescape
|
||||
|
||||
from celery import uuid
|
||||
from flask import render_template, make_response, request, redirect, flash, url_for, abort
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
from flask_login import login_required, current_user
|
||||
from flask import render_template
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
from flask_wtf import FlaskForm
|
||||
from jinja2.utils import markupsafe
|
||||
from sqlalchemy import func, or_, and_
|
||||
from flask_login import login_required
|
||||
from jinja2 import Markup
|
||||
from sqlalchemy import or_, func, and_
|
||||
from sqlalchemy.orm import joinedload, subqueryload
|
||||
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, BooleanField
|
||||
from wtforms.validators import InputRequired, Length, Regexp, DataRequired, Optional, URL, NumberRange, ValidationError
|
||||
from wtforms import *
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.packages import do_edit_package
|
||||
from app.querybuilder import QueryBuilder
|
||||
from app.rediscache import has_key, set_key
|
||||
from app.tasks.importtasks import import_repo_screenshot, check_zip_release
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
from app.logic.game_support import GameSupportResolver
|
||||
|
||||
from app.tasks.importtasks import importRepoScreenshot
|
||||
from app.utils import *
|
||||
from . import bp, get_package_tabs
|
||||
from app.models import Package, Tag, db, User, Tags, PackageState, Permission, PackageType, MetaPackage, ForumTopic, \
|
||||
Dependency, Thread, UserRank, PackageReview, PackageDevState, ContentWarning, License, AuditSeverity, \
|
||||
PackageScreenshot, NotificationType, AuditLogEntry, PackageAlias, PackageProvides, PackageGameSupport, \
|
||||
PackageDailyStats, Collection
|
||||
from app.utils import is_user_bot, get_int_or_abort, is_package_page, abs_url_for, add_audit_log, get_package_by_info, \
|
||||
add_notification, get_system_user, rank_required, get_games_from_csv, get_daterange_options
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.packages import do_edit_package
|
||||
from app.models.packages import PackageProvides
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
|
||||
|
||||
@bp.route("/packages/")
|
||||
def list_all():
|
||||
qb = QueryBuilder(request.args)
|
||||
query = qb.build_package_query()
|
||||
query = qb.buildPackageQuery()
|
||||
title = qb.title
|
||||
|
||||
query = query.options(
|
||||
@@ -76,15 +67,15 @@ def list_all():
|
||||
if qb.lucky:
|
||||
package = query.first()
|
||||
if package:
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
topic = qb.build_topic_query().first()
|
||||
topic = qb.buildTopicQuery().first()
|
||||
if qb.search and topic:
|
||||
return redirect("https://forum.minetest.net/viewtopic.php?t=" + str(topic.topic_id))
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
||||
query = query.paginate(page=page, per_page=num)
|
||||
query = query.paginate(page, num, True)
|
||||
|
||||
search = request.args.get("q")
|
||||
type_name = request.args.get("type")
|
||||
@@ -100,42 +91,33 @@ def list_all():
|
||||
topics = None
|
||||
if qb.search and not query.has_next:
|
||||
qb.show_discarded = True
|
||||
topics = qb.build_topic_query().all()
|
||||
topics = qb.buildTopicQuery().all()
|
||||
|
||||
tags_query = db.session.query(func.count(Tags.c.tag_id), Tag) \
|
||||
.select_from(Tag).join(Tags).join(Package).filter(Package.state==PackageState.APPROVED) \
|
||||
.group_by(Tag.id).order_by(db.asc(Tag.title))
|
||||
tags = qb.filter_package_query(tags_query).all()
|
||||
.select_from(Tag).join(Tags).join(Package).group_by(Tag.id).order_by(db.asc(Tag.title))
|
||||
tags = qb.filterPackageQuery(tags_query).all()
|
||||
|
||||
selected_tags = set(qb.tags)
|
||||
|
||||
return render_template("packages/list.html",
|
||||
query_hint=title, packages=query.items, pagination=query,
|
||||
query=search, tags=tags, selected_tags=selected_tags, type=type_name,
|
||||
authors=authors, packages_count=query.total, topics=topics, noindex=qb.noindex)
|
||||
authors=authors, packages_count=query.total, topics=topics)
|
||||
|
||||
|
||||
def get_releases(package):
|
||||
if package.check_perm(current_user, Permission.MAKE_RELEASE):
|
||||
def getReleases(package):
|
||||
if package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||
return package.releases.limit(5)
|
||||
else:
|
||||
return package.releases.filter_by(approved=True).limit(5)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/")
|
||||
def user_redirect(author):
|
||||
return redirect(url_for("users.profile", username=author))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/")
|
||||
@is_package_page
|
||||
def view(package):
|
||||
if not package.check_perm(current_user, Permission.VIEW_PACKAGE):
|
||||
return render_template("packages/gone.html", package=package), 403
|
||||
|
||||
show_similar = not package.approved and (
|
||||
current_user in package.maintainers or
|
||||
package.check_perm(current_user, Permission.APPROVE_NEW))
|
||||
package.checkPerm(current_user, Permission.APPROVE_NEW))
|
||||
|
||||
conflicting_modnames = None
|
||||
if show_similar and package.type != PackageType.TXP:
|
||||
@@ -163,10 +145,10 @@ def view(package):
|
||||
Dependency.meta_package_id.in_([p.id for p in package.provides]))) \
|
||||
.order_by(db.desc(Package.score)).limit(6).all()
|
||||
|
||||
releases = get_releases(package)
|
||||
releases = getReleases(package)
|
||||
|
||||
review_thread = package.review_thread
|
||||
if review_thread is not None and not review_thread.check_perm(current_user, Permission.SEE_THREAD):
|
||||
if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD):
|
||||
review_thread = None
|
||||
|
||||
topic_error = None
|
||||
@@ -187,26 +169,20 @@ def view(package):
|
||||
|
||||
topic_error = "<br />".join(errors)
|
||||
|
||||
|
||||
threads = Thread.query.filter_by(package_id=package.id, review_id=None)
|
||||
if not current_user.is_authenticated:
|
||||
threads = threads.filter_by(private=False)
|
||||
elif not current_user.rank.at_least(UserRank.APPROVER) and not current_user == package.author:
|
||||
elif not current_user.rank.atLeast(UserRank.APPROVER) and not current_user == package.author:
|
||||
threads = threads.filter(or_(Thread.private == False, Thread.author == current_user))
|
||||
|
||||
has_review = current_user.is_authenticated and \
|
||||
PackageReview.query.filter_by(package=package, author=current_user).count() > 0
|
||||
|
||||
is_favorited = current_user.is_authenticated and \
|
||||
Collection.query.filter(
|
||||
Collection.author == current_user,
|
||||
Collection.packages.contains(package),
|
||||
Collection.name == "favorites").count() > 0
|
||||
has_review = current_user.is_authenticated and PackageReview.query.filter_by(package=package, author=current_user).count() > 0
|
||||
|
||||
return render_template("packages/view.html",
|
||||
package=package, releases=releases, packages_uses=packages_uses,
|
||||
conflicting_modnames=conflicting_modnames,
|
||||
review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl,
|
||||
threads=threads.all(), has_review=has_review, is_favorited=is_favorited)
|
||||
threads=threads.all(), has_review=has_review)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/shields/<type>/")
|
||||
@@ -216,11 +192,11 @@ def shield(package, type):
|
||||
url = "https://img.shields.io/static/v1?label=ContentDB&message={}&color={}" \
|
||||
.format(urlescape(package.title), urlescape("#375a7f"))
|
||||
elif type == "downloads":
|
||||
api_url = abs_url_for("api.package_view", author=package.author.username, name=package.name)
|
||||
#api_url = abs_url_for("api.package", author=package.author.username, name=package.name)
|
||||
api_url = "https://content.minetest.net" + url_for("api.package", author=package.author.username, name=package.name)
|
||||
url = "https://img.shields.io/badge/dynamic/json?color={}&label=ContentDB&query=downloads&suffix=+downloads&url={}" \
|
||||
.format(urlescape("#375a7f"), urlescape(api_url))
|
||||
else:
|
||||
from flask import abort
|
||||
abort(404)
|
||||
|
||||
return redirect(url)
|
||||
@@ -229,17 +205,17 @@ def shield(package, type):
|
||||
@bp.route("/packages/<author>/<name>/download/")
|
||||
@is_package_page
|
||||
def download(package):
|
||||
release = package.get_download_release()
|
||||
release = package.getDownloadRelease()
|
||||
|
||||
if release is None:
|
||||
if "application/zip" in request.accept_mimetypes and \
|
||||
"text/html" not in request.accept_mimetypes:
|
||||
not "text/html" in request.accept_mimetypes:
|
||||
return "", 204
|
||||
else:
|
||||
flash(gettext("No download available."), "danger")
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
else:
|
||||
return redirect(release.get_download_url())
|
||||
return redirect(release.getDownloadURL())
|
||||
|
||||
|
||||
def makeLabel(obj):
|
||||
@@ -267,40 +243,27 @@ class PackageForm(FlaskForm):
|
||||
repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
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])
|
||||
donate_url = StringField(lazy_gettext("Donate 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 validate_name(self, field):
|
||||
if field.data == "_game":
|
||||
raise ValidationError(lazy_gettext("_game is not an allowed name"))
|
||||
|
||||
|
||||
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()
|
||||
package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
|
||||
if package is not None:
|
||||
if package.state == PackageState.DELETED:
|
||||
flash(
|
||||
gettext("Package already exists, but is removed. Please contact ContentDB staff to restore the package"),
|
||||
"danger")
|
||||
package.review_thread_id = None
|
||||
db.session.delete(package)
|
||||
else:
|
||||
flash(markupsafe.Markup(
|
||||
f"<a class='btn btn-sm btn-danger float-end' href='{package.get_url('packages.view')}'>View</a>" +
|
||||
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
|
||||
|
||||
if Collection.query \
|
||||
.filter(Collection.name == form.name.data, Collection.author == author) \
|
||||
.count() > 0:
|
||||
flash(gettext("A collection with a similar name already exists"), "danger")
|
||||
return
|
||||
return None
|
||||
|
||||
package = Package()
|
||||
db.session.add(package)
|
||||
package.author = author
|
||||
package.maintainers.append(author)
|
||||
wasNew = True
|
||||
@@ -322,21 +285,20 @@ def handle_create_edit(package: typing.Optional[Package], form: PackageForm, aut
|
||||
"issueTracker": form.issueTracker.data,
|
||||
"forums": form.forums.data,
|
||||
"video_url": form.video_url.data,
|
||||
"donate_url": form.donate_url.data,
|
||||
})
|
||||
|
||||
if wasNew:
|
||||
msg = f"Created package {author.username}/{form.name.data}"
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user, msg, package.get_url("packages.view"), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
if wasNew and package.repo is not None:
|
||||
import_repo_screenshot.delay(package.id)
|
||||
importRepoScreenshot.delay(package.id)
|
||||
|
||||
next_url = package.get_url("packages.view")
|
||||
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.get_url("packages.setup_releases")
|
||||
next_url = package.getURL("packages.setup_releases")
|
||||
|
||||
return redirect(next_url)
|
||||
except LogicError as e:
|
||||
@@ -359,16 +321,16 @@ def create_edit(author=None, name=None):
|
||||
flash(gettext("Unable to find that user"), "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
if not author.check_perm(current_user, Permission.CHANGE_AUTHOR):
|
||||
if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
|
||||
flash(gettext("Permission denied"), "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
else:
|
||||
package = get_package_by_info(author, name)
|
||||
package = getPackageByInfo(author, name)
|
||||
if package is None:
|
||||
abort(404)
|
||||
if not package.check_perm(current_user, Permission.EDIT_PACKAGE):
|
||||
return redirect(package.get_url("packages.view"))
|
||||
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
author = package.author
|
||||
|
||||
@@ -403,7 +365,7 @@ def create_edit(author=None, name=None):
|
||||
return render_template("packages/create_edit.html", package=package,
|
||||
form=form, author=author, enable_wizard=enableWizard,
|
||||
packages=package_query.all(),
|
||||
modnames=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all(),
|
||||
mpackages=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all(),
|
||||
tabs=get_package_tabs(current_user, package), current_tab="edit")
|
||||
|
||||
|
||||
@@ -415,18 +377,17 @@ def move_to_state(package):
|
||||
if state is None:
|
||||
abort(400)
|
||||
|
||||
if not package.can_move_to_state(current_user, state):
|
||||
if not package.canMoveToState(current_user, state):
|
||||
flash(gettext("You don't have permission to do that"), "danger")
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
package.state = state
|
||||
msg = "Marked {} as {}".format(package.title, state.value)
|
||||
|
||||
if state == PackageState.APPROVED:
|
||||
if not package.approved_at:
|
||||
post_discord_webhook.delay(package.author.display_name,
|
||||
"New package {}".format(package.get_url("packages.view", absolute=True)), False,
|
||||
package.title, package.short_desc, package.get_thumb_url(2, True))
|
||||
post_discord_webhook.delay(package.author.username,
|
||||
"New package {}".format(package.getURL("packages.view", absolute=True)), False)
|
||||
package.approved_at = datetime.datetime.now()
|
||||
|
||||
screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all()
|
||||
@@ -435,24 +396,23 @@ def move_to_state(package):
|
||||
|
||||
msg = "Approved {}".format(package.title)
|
||||
elif state == PackageState.READY_FOR_REVIEW:
|
||||
post_discord_webhook.delay(package.author.display_name,
|
||||
"Ready for Review: {}".format(package.get_url("packages.view", absolute=True)), True,
|
||||
package.title, package.short_desc, package.get_thumb_url(2, True))
|
||||
post_discord_webhook.delay(package.author.username,
|
||||
"Ready for Review: {}".format(package.getURL("packages.view", absolute=True)), True)
|
||||
|
||||
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.get_url("packages.view"), package)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package)
|
||||
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
|
||||
add_audit_log(severity, current_user, msg, package.get_url("packages.view"), package)
|
||||
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if package.state == PackageState.CHANGES_NEEDED:
|
||||
flash(gettext("Please comment what changes are needed in the approval thread"), "warning")
|
||||
if package.review_thread:
|
||||
return redirect(package.review_thread.get_view_url())
|
||||
return redirect(package.review_thread.getViewURL())
|
||||
else:
|
||||
return redirect(url_for('threads.new', pid=package.id, title='Package approval comments'))
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
|
||||
@@ -466,45 +426,37 @@ def remove(package):
|
||||
reason = request.form.get("reason") or "?"
|
||||
|
||||
if "delete" in request.form:
|
||||
if not package.check_perm(current_user, Permission.DELETE_PACKAGE):
|
||||
if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
|
||||
flash(gettext("You don't have permission to do that"), "danger")
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
package.state = PackageState.DELETED
|
||||
|
||||
url = url_for("users.profile", username=package.author.username)
|
||||
msg = "Deleted {}, reason={}".format(package.title, reason)
|
||||
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
|
||||
add_audit_log(AuditSeverity.EDITOR, current_user, msg, url, package)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
|
||||
addAuditLog(AuditSeverity.EDITOR, current_user, msg, url, package)
|
||||
db.session.commit()
|
||||
|
||||
post_discord_webhook.delay(current_user.username,
|
||||
f"Deleted package {package.author.username}/{package.name} with reason '{reason}'",
|
||||
True, package.title, package.short_desc, package.get_thumb_url(2, True))
|
||||
|
||||
flash(gettext("Deleted package"), "success")
|
||||
|
||||
return redirect(url)
|
||||
elif "unapprove" in request.form:
|
||||
if not package.check_perm(current_user, Permission.UNAPPROVE_PACKAGE):
|
||||
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
|
||||
flash(gettext("You don't have permission to do that"), "danger")
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
package.state = PackageState.WIP
|
||||
|
||||
msg = "Unapproved {}, reason={}".format(package.title, reason)
|
||||
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.get_url("packages.view"), package)
|
||||
add_audit_log(AuditSeverity.EDITOR, current_user, msg, package.get_url("packages.view"), package)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package)
|
||||
addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
post_discord_webhook.delay(current_user.username,
|
||||
"Unapproved package with reason {}\n\n{}".format(reason, package.get_url("packages.view", absolute=True)), True,
|
||||
package.title, package.short_desc, package.get_thumb_url(2, True))
|
||||
|
||||
flash(gettext("Unapproved package"), "success")
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
else:
|
||||
abort(400)
|
||||
|
||||
@@ -519,9 +471,9 @@ class PackageMaintainersForm(FlaskForm):
|
||||
@login_required
|
||||
@is_package_page
|
||||
def edit_maintainers(package):
|
||||
if not package.check_perm(current_user, Permission.EDIT_MAINTAINERS):
|
||||
if not package.checkPerm(current_user, Permission.EDIT_MAINTAINERS):
|
||||
flash(gettext("You don't have permission to edit maintainers"), "danger")
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
form = PackageMaintainersForm(formdata=request.form)
|
||||
if request.method == "GET":
|
||||
@@ -537,13 +489,13 @@ def edit_maintainers(package):
|
||||
if not user in package.maintainers:
|
||||
if thread:
|
||||
thread.watchers.append(user)
|
||||
add_notification(user, current_user, NotificationType.MAINTAINER,
|
||||
"Added you as a maintainer of {}".format(package.title), package.get_url("packages.view"), package)
|
||||
addNotification(user, current_user, NotificationType.MAINTAINER,
|
||||
"Added you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
|
||||
|
||||
for user in package.maintainers:
|
||||
if user != package.author and not user in users:
|
||||
add_notification(user, current_user, NotificationType.MAINTAINER,
|
||||
"Removed you as a maintainer of {}".format(package.title), package.get_url("packages.view"), package)
|
||||
addNotification(user, current_user, NotificationType.MAINTAINER,
|
||||
"Removed you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
|
||||
|
||||
package.maintainers.clear()
|
||||
package.maintainers.extend(users)
|
||||
@@ -551,13 +503,13 @@ def edit_maintainers(package):
|
||||
package.maintainers.append(package.author)
|
||||
|
||||
msg = "Edited {} maintainers".format(package.title)
|
||||
add_notification(package.author, current_user, NotificationType.MAINTAINER, msg, package.get_url("packages.view"), package)
|
||||
addNotification(package.author, current_user, NotificationType.MAINTAINER, msg, package.getURL("packages.view"), package)
|
||||
severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION
|
||||
add_audit_log(severity, current_user, msg, package.get_url("packages.view"), package)
|
||||
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).order_by(db.asc(User.username)).all()
|
||||
|
||||
@@ -578,20 +530,20 @@ def remove_self_maintainers(package):
|
||||
else:
|
||||
package.maintainers.remove(current_user)
|
||||
|
||||
add_notification(package.author, current_user, NotificationType.MAINTAINER,
|
||||
"Removed themself as a maintainer of {}".format(package.title), package.get_url("packages.view"), package)
|
||||
addNotification(package.author, current_user, NotificationType.MAINTAINER,
|
||||
"Removed themself as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/audit/")
|
||||
@login_required
|
||||
@is_package_page
|
||||
def audit(package):
|
||||
if not (package.check_perm(current_user, Permission.EDIT_PACKAGE) or
|
||||
package.check_perm(current_user, Permission.APPROVE_NEW)):
|
||||
if not (package.checkPerm(current_user, Permission.EDIT_PACKAGE) or
|
||||
package.checkPerm(current_user, Permission.APPROVE_NEW)):
|
||||
abort(403)
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
@@ -599,7 +551,7 @@ def audit(package):
|
||||
|
||||
query = package.audit_log_entries.order_by(db.desc(AuditLogEntry.created_at))
|
||||
|
||||
pagination = query.paginate(page=page, per_page=num)
|
||||
pagination = query.paginate(page, num, True)
|
||||
return render_template("packages/audit.html", log=pagination.items, pagination=pagination,
|
||||
package=package, tabs=get_package_tabs(current_user, package), current_tab="audit")
|
||||
|
||||
@@ -612,7 +564,7 @@ class PackageAliasForm(FlaskForm):
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/aliases/")
|
||||
@rank_required(UserRank.ADMIN)
|
||||
@rank_required(UserRank.EDITOR)
|
||||
@is_package_page
|
||||
def alias_list(package: Package):
|
||||
return render_template("packages/alias_list.html", package=package)
|
||||
@@ -620,7 +572,7 @@ def alias_list(package: Package):
|
||||
|
||||
@bp.route("/packages/<author>/<name>/aliases/new/", methods=["GET", "POST"])
|
||||
@bp.route("/packages/<author>/<name>/aliases/<int:alias_id>/", methods=["GET", "POST"])
|
||||
@rank_required(UserRank.ADMIN)
|
||||
@rank_required(UserRank.EDITOR)
|
||||
@is_package_page
|
||||
def alias_create_edit(package: Package, alias_id: int = None):
|
||||
alias = None
|
||||
@@ -639,7 +591,7 @@ def alias_create_edit(package: Package, alias_id: int = None):
|
||||
form.populate_obj(alias)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.get_url("packages.alias_list"))
|
||||
return redirect(package.getURL("packages.alias_list"))
|
||||
|
||||
return render_template("packages/alias_create_edit.html", package=package, form=form)
|
||||
|
||||
@@ -656,10 +608,10 @@ def share(package):
|
||||
@is_package_page
|
||||
def similar(package):
|
||||
packages_modnames = {}
|
||||
for mname in package.provides:
|
||||
packages_modnames[mname] = Package.query.filter(Package.id != package.id,
|
||||
for metapackage in package.provides:
|
||||
packages_modnames[metapackage] = Package.query.filter(Package.id != package.id,
|
||||
Package.state != PackageState.DELETED) \
|
||||
.filter(Package.provides.any(PackageProvides.c.metapackage_id == mname.id)) \
|
||||
.filter(Package.provides.any(PackageProvides.c.metapackage_id == metapackage.id)) \
|
||||
.order_by(db.desc(Package.score)) \
|
||||
.all()
|
||||
|
||||
@@ -672,151 +624,3 @@ def similar(package):
|
||||
|
||||
return render_template("packages/similar.html", package=package,
|
||||
packages_modnames=packages_modnames, similar_topics=similar_topics)
|
||||
|
||||
|
||||
class GameSupportForm(FlaskForm):
|
||||
enable_support_detection = BooleanField(lazy_gettext("Enable support detection based on dependencies (recommended)"), [Optional()])
|
||||
supported = StringField(lazy_gettext("Supported games"), [Optional()])
|
||||
unsupported = StringField(lazy_gettext("Unsupported games"), [Optional()])
|
||||
supports_all_games = BooleanField(lazy_gettext("Supports all games (unless stated) / is game independent"), [Optional()])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/support/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def game_support(package):
|
||||
if package.type != PackageType.MOD and package.type != PackageType.TXP:
|
||||
abort(404)
|
||||
|
||||
can_edit = package.check_perm(current_user, Permission.EDIT_PACKAGE)
|
||||
if not (can_edit or package.check_perm(current_user, Permission.APPROVE_NEW)):
|
||||
abort(403)
|
||||
|
||||
if package.releases.count() == 0:
|
||||
flash(gettext("You need at least one release before you can edit game support"), "danger")
|
||||
return redirect(package.get_url('packages.create_release' if package.update_config else 'packages.setup_releases'))
|
||||
|
||||
if package.type == PackageType.MOD and len(package.provides) == 0:
|
||||
flash(gettext("Mod(pack) needs to contain at least one mod. Please create a new release"), "danger")
|
||||
return redirect(package.get_url('packages.list_releases'))
|
||||
|
||||
force_game_detection = package.supported_games.filter(and_(
|
||||
PackageGameSupport.confidence > 1, PackageGameSupport.supports == True)).count() == 0
|
||||
|
||||
can_support_all_games = package.type != PackageType.TXP and \
|
||||
package.supported_games.filter(and_(
|
||||
PackageGameSupport.confidence == 1, PackageGameSupport.supports == True)).count() == 0
|
||||
|
||||
can_override = can_edit
|
||||
|
||||
form = GameSupportForm() if can_edit else None
|
||||
if form and request.method == "GET":
|
||||
form.enable_support_detection.data = package.enable_game_support_detection
|
||||
form.supports_all_games.data = package.supports_all_games and can_support_all_games
|
||||
|
||||
if can_override:
|
||||
manual_supported_games = package.supported_games.filter_by(confidence=11).all()
|
||||
form.supported.data = ", ".join([x.game.name for x in manual_supported_games if x.supports])
|
||||
form.unsupported.data = ", ".join([x.game.name for x in manual_supported_games if not x.supports])
|
||||
else:
|
||||
form.supported = None
|
||||
form.unsupported = None
|
||||
|
||||
if form and form.validate_on_submit():
|
||||
detect_update_needed = False
|
||||
|
||||
if can_override:
|
||||
try:
|
||||
resolver = GameSupportResolver(db.session)
|
||||
|
||||
game_is_supported = {}
|
||||
for game in get_games_from_csv(db.session, form.supported.data or ""):
|
||||
game_is_supported[game.id] = True
|
||||
for game in get_games_from_csv(db.session, form.unsupported.data or ""):
|
||||
game_is_supported[game.id] = False
|
||||
resolver.set_supported(package, game_is_supported, 11)
|
||||
detect_update_needed = True
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
||||
next_url = package.get_url("packages.game_support")
|
||||
|
||||
enable_support_detection = form.enable_support_detection.data or force_game_detection
|
||||
if enable_support_detection != package.enable_game_support_detection:
|
||||
package.enable_game_support_detection = enable_support_detection
|
||||
if package.enable_game_support_detection:
|
||||
detect_update_needed = True
|
||||
else:
|
||||
package.supported_games.filter_by(confidence=1).delete()
|
||||
|
||||
if can_support_all_games:
|
||||
package.supports_all_games = form.supports_all_games.data
|
||||
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user, "Edited game support", package.get_url("packages.game_support"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if detect_update_needed:
|
||||
release = package.releases.first()
|
||||
if release:
|
||||
task_id = uuid()
|
||||
check_zip_release.apply_async((release.id, release.file_path), task_id=task_id)
|
||||
next_url = url_for("tasks.check", id=task_id, r=next_url)
|
||||
|
||||
return redirect(next_url)
|
||||
|
||||
all_game_support = package.supported_games.all()
|
||||
all_game_support.sort(key=lambda x: -x.game.score)
|
||||
supported_games_list: typing.List[str] = [x.game.name for x in all_game_support if x.supports]
|
||||
if package.supports_all_games:
|
||||
supported_games_list.insert(0, "*")
|
||||
supported_games = ", ".join(supported_games_list)
|
||||
unsupported_games = ", ".join([x.game.name for x in all_game_support if not x.supports])
|
||||
|
||||
mod_conf_lines = ""
|
||||
if supported_games:
|
||||
mod_conf_lines += f"supported_games = {supported_games}"
|
||||
if unsupported_games:
|
||||
mod_conf_lines += f"\nunsupported_games = {unsupported_games}"
|
||||
|
||||
return render_template("packages/game_support.html", package=package, form=form,
|
||||
mod_conf_lines=mod_conf_lines, force_game_detection=force_game_detection,
|
||||
can_support_all_games=can_support_all_games, tabs=get_package_tabs(current_user, package),
|
||||
current_tab="game_support")
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/stats/")
|
||||
@is_package_page
|
||||
def statistics(package):
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
|
||||
return render_template("packages/stats.html",
|
||||
package=package, tabs=get_package_tabs(current_user, package), current_tab="stats",
|
||||
start=start, end=end, options=get_daterange_options(), noindex=start or end)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/stats.csv")
|
||||
@is_package_page
|
||||
def stats_csv(package):
|
||||
stats: typing.List[PackageDailyStats] = package.daily_stats.order_by(db.asc(PackageDailyStats.date)).all()
|
||||
|
||||
columns = ["platform_minetest", "platform_other", "reason_new",
|
||||
"reason_dependency", "reason_update"]
|
||||
|
||||
result = "Date, " + ", ".join(columns) + "\n"
|
||||
|
||||
for stat in stats:
|
||||
stat: PackageDailyStats
|
||||
result += stat.date.isoformat()
|
||||
for i, key in enumerate(columns):
|
||||
result += ", " + str(getattr(stat, key))
|
||||
result += "\n"
|
||||
|
||||
date = datetime.datetime.utcnow().date()
|
||||
|
||||
res = make_response(result, 200)
|
||||
res.headers["Content-Disposition"] = f"attachment; filename={package.author.username}_{package.name}_stats_{date.isoformat()}.csv"
|
||||
res.headers["Content-type"] = "text/csv"
|
||||
return res
|
||||
|
||||
@@ -14,20 +14,19 @@
|
||||
# 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, request, redirect, flash, url_for, abort
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from flask import *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
from flask_login import login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, BooleanField, RadioField, FileField
|
||||
from wtforms.validators import InputRequired, Length, Optional
|
||||
from wtforms import *
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
|
||||
from app.models import Package, db, User, PackageState, Permission, UserRank, PackageDailyStats, MinetestRelease, \
|
||||
PackageRelease, PackageUpdateTrigger, PackageUpdateConfig
|
||||
from app.rediscache import has_key, set_key, make_download_key
|
||||
from app.tasks.importtasks import check_update_config
|
||||
from app.utils import is_user_bot, is_package_page, nonempty_or_none
|
||||
from app.utils import *
|
||||
from . import bp, get_package_tabs
|
||||
|
||||
|
||||
@@ -50,11 +49,11 @@ def get_mt_releases(is_max):
|
||||
|
||||
|
||||
class CreatePackageReleaseForm(FlaskForm):
|
||||
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)
|
||||
file_upload = FileField(lazy_gettext("File Upload"))
|
||||
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
|
||||
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"))
|
||||
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
|
||||
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)
|
||||
@@ -77,8 +76,8 @@ class EditPackageReleaseForm(FlaskForm):
|
||||
@login_required
|
||||
@is_package_page
|
||||
def create_release(package):
|
||||
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
|
||||
return redirect(package.get_url("packages.view"))
|
||||
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = CreatePackageReleaseForm()
|
||||
@@ -95,18 +94,18 @@ def create_release(package):
|
||||
try:
|
||||
if form["uploadOpt"].data == "vcs":
|
||||
rel = do_create_vcs_release(current_user, package, form.title.data,
|
||||
form.vcsLabel.data, form.min_rel.data.get_actual(), form.max_rel.data.get_actual())
|
||||
form.vcsLabel.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
|
||||
else:
|
||||
rel = do_create_zip_release(current_user, package, form.title.data,
|
||||
form.file_upload.data, form.min_rel.data.get_actual(), form.max_rel.data.get_actual())
|
||||
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.get_edit_url()))
|
||||
form.fileUpload.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
|
||||
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL()))
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
||||
return render_template("packages/release_new.html", package=package, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/<int:id>/download/")
|
||||
@bp.route("/packages/<author>/<name>/releases/<id>/download/")
|
||||
@is_package_page
|
||||
def download_release(package, id):
|
||||
release = PackageRelease.query.get(id)
|
||||
@@ -115,19 +114,11 @@ def download_release(package, id):
|
||||
|
||||
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
|
||||
if ip is not None and not is_user_bot():
|
||||
is_minetest = (request.headers.get("User-Agent") or "").startswith("Minetest")
|
||||
reason = request.args.get("reason")
|
||||
PackageDailyStats.update(package, is_minetest, reason)
|
||||
|
||||
key = make_download_key(ip, release.package)
|
||||
if not has_key(key):
|
||||
set_key(key, "true")
|
||||
|
||||
bonus = 0
|
||||
if reason == "new":
|
||||
bonus = 1
|
||||
elif reason == "dependency" or reason == "update":
|
||||
bonus = 0.5
|
||||
bonus = 1
|
||||
|
||||
PackageRelease.query.filter_by(id=release.id).update({
|
||||
"downloads": PackageRelease.downloads + 1
|
||||
@@ -139,12 +130,12 @@ def download_release(package, id):
|
||||
"score": Package.score + bonus
|
||||
})
|
||||
|
||||
db.session.commit()
|
||||
db.session.commit()
|
||||
|
||||
return redirect(release.url)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/<int:id>/", methods=["GET", "POST"])
|
||||
@bp.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def edit_release(package, id):
|
||||
@@ -152,10 +143,10 @@ def edit_release(package, id):
|
||||
if release is None or release.package != package:
|
||||
abort(404)
|
||||
|
||||
canEdit = package.check_perm(current_user, Permission.MAKE_RELEASE)
|
||||
canApprove = release.check_perm(current_user, Permission.APPROVE_RELEASE)
|
||||
canEdit = package.checkPerm(current_user, Permission.MAKE_RELEASE)
|
||||
canApprove = release.checkPerm(current_user, Permission.APPROVE_RELEASE)
|
||||
if not (canEdit or canApprove):
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = EditPackageReleaseForm(formdata=request.form, obj=release)
|
||||
@@ -167,10 +158,10 @@ def edit_release(package, id):
|
||||
if form.validate_on_submit():
|
||||
if canEdit:
|
||||
release.title = form["title"].data
|
||||
release.min_rel = form["min_rel"].data.get_actual()
|
||||
release.max_rel = form["max_rel"].data.get_actual()
|
||||
release.min_rel = form["min_rel"].data.getActual()
|
||||
release.max_rel = form["max_rel"].data.getActual()
|
||||
|
||||
if package.check_perm(current_user, Permission.CHANGE_RELEASE_URL):
|
||||
if package.checkPerm(current_user, Permission.CHANGE_RELEASE_URL):
|
||||
release.url = form["url"].data
|
||||
release.task_id = form["task_id"].data
|
||||
if release.task_id is not None:
|
||||
@@ -182,7 +173,7 @@ def edit_release(package, id):
|
||||
release.approved = False
|
||||
|
||||
db.session.commit()
|
||||
return redirect(package.get_url("packages.list_releases"))
|
||||
return redirect(package.getURL("packages.list_releases"))
|
||||
|
||||
return render_template("packages/release_edit.html", package=package, release=release, form=form)
|
||||
|
||||
@@ -203,8 +194,8 @@ class BulkReleaseForm(FlaskForm):
|
||||
@login_required
|
||||
@is_package_page
|
||||
def bulk_change_release(package):
|
||||
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
|
||||
return redirect(package.get_url("packages.view"))
|
||||
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = BulkReleaseForm()
|
||||
@@ -216,18 +207,18 @@ def bulk_change_release(package):
|
||||
|
||||
for release in package.releases.all():
|
||||
if form["set_min"].data and (not only_change_none or release.min_rel is None):
|
||||
release.min_rel = form["min_rel"].data.get_actual()
|
||||
release.min_rel = form["min_rel"].data.getActual()
|
||||
if form["set_max"].data and (not only_change_none or release.max_rel is None):
|
||||
release.max_rel = form["max_rel"].data.get_actual()
|
||||
release.max_rel = form["max_rel"].data.getActual()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.get_url("packages.list_releases"))
|
||||
return redirect(package.getURL("packages.list_releases"))
|
||||
|
||||
return render_template("packages/release_bulk_change.html", package=package, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/releases/<int:id>/delete/", methods=["POST"])
|
||||
@bp.route("/packages/<author>/<name>/releases/<id>/delete/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def delete_release(package, id):
|
||||
@@ -235,13 +226,13 @@ def delete_release(package, id):
|
||||
if release is None or release.package != package:
|
||||
abort(404)
|
||||
|
||||
if not release.check_perm(current_user, Permission.DELETE_RELEASE):
|
||||
return redirect(package.get_url("packages.list_releases"))
|
||||
if not release.checkPerm(current_user, Permission.DELETE_RELEASE):
|
||||
return redirect(package.getURL("packages.list_releases"))
|
||||
|
||||
db.session.delete(release)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
|
||||
class PackageUpdateConfigFrom(FlaskForm):
|
||||
@@ -263,7 +254,7 @@ def set_update_config(package, form):
|
||||
db.session.add(package.update_config)
|
||||
|
||||
form.populate_obj(package.update_config)
|
||||
package.update_config.ref = nonempty_or_none(form.ref.data)
|
||||
package.update_config.ref = nonEmptyOrNone(form.ref.data)
|
||||
package.update_config.make_release = form.action.data == "make_release"
|
||||
|
||||
if package.update_config.trigger == PackageUpdateTrigger.COMMIT:
|
||||
@@ -289,12 +280,12 @@ def set_update_config(package, form):
|
||||
@login_required
|
||||
@is_package_page
|
||||
def update_config(package):
|
||||
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
|
||||
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||
abort(403)
|
||||
|
||||
if not package.repo:
|
||||
flash(gettext("Please add a Git repository URL in order to set up automatic releases"), "danger")
|
||||
return redirect(package.get_url("packages.create_edit"))
|
||||
return redirect(package.getURL("packages.create_edit"))
|
||||
|
||||
form = PackageUpdateConfigFrom(obj=package.update_config)
|
||||
if request.method == "GET":
|
||||
@@ -318,9 +309,9 @@ def update_config(package):
|
||||
|
||||
if not form.disable.data and package.releases.count() == 0:
|
||||
flash(gettext("Now, please create an initial release"), "success")
|
||||
return redirect(package.get_url("packages.create_release"))
|
||||
return redirect(package.getURL("packages.create_release"))
|
||||
|
||||
return redirect(package.get_url("packages.list_releases"))
|
||||
return redirect(package.getURL("packages.list_releases"))
|
||||
|
||||
return render_template("packages/update_config.html", package=package, form=form)
|
||||
|
||||
@@ -329,11 +320,11 @@ def update_config(package):
|
||||
@login_required
|
||||
@is_package_page
|
||||
def setup_releases(package):
|
||||
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
|
||||
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||
abort(403)
|
||||
|
||||
if package.update_config:
|
||||
return redirect(package.get_url("packages.update_config"))
|
||||
return redirect(package.getURL("packages.update_config"))
|
||||
|
||||
return render_template("packages/release_wizard.html", package=package)
|
||||
|
||||
@@ -349,7 +340,7 @@ def bulk_update_config(username=None):
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR):
|
||||
if current_user != user and not current_user.rank.atLeast(UserRank.EDITOR):
|
||||
abort(403)
|
||||
|
||||
form = PackageUpdateConfigFrom()
|
||||
|
||||
@@ -13,23 +13,21 @@
|
||||
#
|
||||
# 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 collections import namedtuple
|
||||
|
||||
import typing
|
||||
from flask import render_template, request, redirect, flash, url_for, abort, jsonify
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
|
||||
from . import bp
|
||||
|
||||
from flask import *
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, SubmitField, RadioField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
|
||||
Permission, AuditSeverity, PackageState
|
||||
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
|
||||
from app.utils import is_package_page, add_notification, get_int_or_abort, is_yes, is_safe_url, rank_required, \
|
||||
add_audit_log, has_blocked_domains, should_return_json
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route("/reviews/")
|
||||
@@ -37,17 +35,16 @@ def list_reviews():
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
||||
|
||||
pagination = PackageReview.query.order_by(db.desc(PackageReview.created_at)).paginate(page=page, per_page=num)
|
||||
pagination = PackageReview.query.order_by(db.desc(PackageReview.created_at)).paginate(page, num, True)
|
||||
return render_template("packages/reviews_list.html", pagination=pagination, reviews=pagination.items)
|
||||
|
||||
|
||||
class ReviewForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3, 100)])
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
|
||||
rating = RadioField(lazy_gettext("Rating"), [InputRequired()],
|
||||
choices=[("5", lazy_gettext("Yes")), ("3", lazy_gettext("Neutral")), ("1", lazy_gettext("No"))])
|
||||
btn_submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
recommends = RadioField(lazy_gettext("Private"), [InputRequired()],
|
||||
choices=[("yes", lazy_gettext("Yes")), ("no", lazy_gettext("No"))])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
@bp.route("/packages/<author>/<name>/review/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@@ -55,13 +52,10 @@ class ReviewForm(FlaskForm):
|
||||
def review(package):
|
||||
if current_user in package.maintainers:
|
||||
flash(gettext("You can't review your own package!"), "danger")
|
||||
return redirect(package.get_url("packages.view"))
|
||||
|
||||
if package.state != PackageState.APPROVED:
|
||||
abort(404)
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
review = PackageReview.query.filter_by(package=package, author=current_user).first()
|
||||
can_review = review is not None or current_user.can_review_ratelimit()
|
||||
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")
|
||||
@@ -71,69 +65,66 @@ def review(package):
|
||||
# Set default values
|
||||
if request.method == "GET" and review:
|
||||
form.title.data = review.thread.title
|
||||
form.rating.data = str(review.rating)
|
||||
form.comment.data = review.thread.first_reply.comment
|
||||
form.recommends.data = "yes" if review.recommends else "no"
|
||||
form.comment.data = review.thread.replies[0].comment
|
||||
|
||||
# Validate and submit
|
||||
elif can_review and form.validate_on_submit():
|
||||
if has_blocked_domains(form.comment.data, current_user.username, f"review of {package.get_id()}"):
|
||||
flash(gettext("Linking to blocked sites is not allowed"), "danger")
|
||||
was_new = False
|
||||
if not review:
|
||||
was_new = True
|
||||
review = PackageReview()
|
||||
review.package = package
|
||||
review.author = current_user
|
||||
db.session.add(review)
|
||||
|
||||
review.recommends = form.recommends.data == "yes"
|
||||
|
||||
thread = review.thread
|
||||
if not thread:
|
||||
thread = Thread()
|
||||
thread.author = current_user
|
||||
thread.private = False
|
||||
thread.package = package
|
||||
thread.review = review
|
||||
db.session.add(thread)
|
||||
|
||||
thread.watchers.append(current_user)
|
||||
|
||||
reply = ThreadReply()
|
||||
reply.thread = thread
|
||||
reply.author = current_user
|
||||
reply.comment = form.comment.data
|
||||
db.session.add(reply)
|
||||
|
||||
thread.replies.append(reply)
|
||||
else:
|
||||
was_new = False
|
||||
if not review:
|
||||
was_new = True
|
||||
review = PackageReview()
|
||||
review.package = package
|
||||
review.author = current_user
|
||||
db.session.add(review)
|
||||
reply = thread.replies[0]
|
||||
reply.comment = form.comment.data
|
||||
|
||||
review.rating = int(form.rating.data)
|
||||
thread.title = form.title.data
|
||||
|
||||
thread = review.thread
|
||||
if not thread:
|
||||
thread = Thread()
|
||||
thread.author = current_user
|
||||
thread.private = False
|
||||
thread.package = package
|
||||
thread.review = review
|
||||
db.session.add(thread)
|
||||
db.session.commit()
|
||||
|
||||
thread.watchers.append(current_user)
|
||||
package.recalcScore()
|
||||
|
||||
reply = ThreadReply()
|
||||
reply.thread = thread
|
||||
reply.author = current_user
|
||||
reply.comment = form.comment.data
|
||||
db.session.add(reply)
|
||||
if was_new:
|
||||
notif_msg = "New review '{}'".format(form.title.data)
|
||||
type = NotificationType.NEW_REVIEW
|
||||
else:
|
||||
notif_msg = "Updated review '{}'".format(form.title.data)
|
||||
type = NotificationType.OTHER
|
||||
|
||||
thread.replies.append(reply)
|
||||
else:
|
||||
reply = thread.first_reply
|
||||
reply.comment = form.comment.data
|
||||
addNotification(package.maintainers, current_user, type, notif_msg,
|
||||
url_for("threads.view", id=thread.id), package)
|
||||
|
||||
thread.title = form.title.data
|
||||
if was_new:
|
||||
post_discord_webhook.delay(thread.author.username,
|
||||
"Reviewed {}: {}".format(package.title, thread.getViewURL(absolute=True)), False)
|
||||
|
||||
db.session.commit()
|
||||
db.session.commit()
|
||||
|
||||
package.recalculate_score()
|
||||
|
||||
if was_new:
|
||||
notif_msg = "New review '{}'".format(form.title.data)
|
||||
type = NotificationType.NEW_REVIEW
|
||||
else:
|
||||
notif_msg = "Updated review '{}'".format(form.title.data)
|
||||
type = NotificationType.OTHER
|
||||
|
||||
add_notification(package.maintainers, current_user, type, notif_msg,
|
||||
url_for("threads.view", id=thread.id), package)
|
||||
|
||||
if was_new:
|
||||
post_discord_webhook.delay(thread.author.display_name,
|
||||
"Reviewed {}: {}".format(package.title, thread.get_view_url(absolute=True)), False)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
return render_template("packages/review_create_edit.html",
|
||||
form=form, package=package, review=review)
|
||||
@@ -149,7 +140,7 @@ def delete_review(package, reviewer):
|
||||
if review is None or review.package != package:
|
||||
abort(404)
|
||||
|
||||
if not review.check_perm(current_user, Permission.DELETE_REVIEW):
|
||||
if not review.checkPerm(current_user, Permission.DELETE_REVIEW):
|
||||
abort(403)
|
||||
|
||||
thread = review.thread
|
||||
@@ -164,33 +155,35 @@ def delete_review(package, reviewer):
|
||||
thread.review = None
|
||||
|
||||
msg = "Converted review by {} to thread".format(review.author.display_name)
|
||||
add_audit_log(AuditSeverity.MODERATION if current_user.username != reviewer else AuditSeverity.NORMAL,
|
||||
current_user, msg, thread.get_view_url(), thread.package)
|
||||
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)
|
||||
add_notification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
|
||||
addNotification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
|
||||
|
||||
db.session.delete(review)
|
||||
|
||||
package.recalculate_score()
|
||||
package.recalcScore()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
|
||||
def handle_review_vote(package: Package, review_id: int) -> typing.Optional[str]:
|
||||
def handle_review_vote(package: Package, review_id: int):
|
||||
if current_user in package.maintainers:
|
||||
return gettext("You can't vote on the reviews on your own package!")
|
||||
flash(gettext("You can't vote on the reviews on your own package!"), "danger")
|
||||
return
|
||||
|
||||
review: PackageReview = PackageReview.query.get(review_id)
|
||||
if review is None or review.package != package:
|
||||
abort(404)
|
||||
|
||||
if review.author == current_user:
|
||||
return gettext("You can't vote on your own reviews!")
|
||||
flash(gettext("You can't vote on your own reviews!"), "danger")
|
||||
return
|
||||
|
||||
is_positive = is_yes(request.form["is_positive"])
|
||||
is_positive = isYes(request.form["is_positive"])
|
||||
|
||||
vote = PackageReviewVote.query.filter_by(review=review, user=current_user).first()
|
||||
if vote is None:
|
||||
@@ -212,21 +205,14 @@ def handle_review_vote(package: Package, review_id: int) -> typing.Optional[str]
|
||||
@login_required
|
||||
@is_package_page
|
||||
def review_vote(package, review_id):
|
||||
msg = handle_review_vote(package, review_id)
|
||||
if should_return_json():
|
||||
if msg:
|
||||
return jsonify({"success": False, "error": msg}), 403
|
||||
else:
|
||||
return jsonify({"success": True})
|
||||
|
||||
if msg:
|
||||
flash(msg, "danger")
|
||||
handle_review_vote(package, review_id)
|
||||
|
||||
next_url = request.args.get("r")
|
||||
if next_url and is_safe_url(next_url):
|
||||
return redirect(next_url)
|
||||
else:
|
||||
return redirect(review.thread.get_view_url())
|
||||
return redirect(review.thread.getViewURL())
|
||||
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/review-votes/")
|
||||
@@ -235,7 +221,7 @@ def review_vote(package, review_id):
|
||||
def review_votes(package):
|
||||
user_biases = {}
|
||||
for review in package.reviews:
|
||||
review_sign = review.as_weight()
|
||||
review_sign = 1 if review.recommends else -1
|
||||
for vote in review.votes:
|
||||
user_biases[vote.user.username] = user_biases.get(vote.user.username, [0, 0])
|
||||
vote_sign = 1 if vote.is_positive else -1
|
||||
@@ -255,5 +241,5 @@ def review_votes(package):
|
||||
|
||||
user_biases_info.sort(key=lambda x: -abs(x.balance))
|
||||
|
||||
return render_template("packages/review_votes.html", package=package, reviews=package.reviews,
|
||||
return render_template("packages/review_votes.html", form=form, package=package, reviews=package.reviews,
|
||||
user_biases=user_biases_info)
|
||||
|
||||
@@ -14,24 +14,24 @@
|
||||
# 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, request, redirect, flash, url_for, abort
|
||||
from flask_babel import lazy_gettext, gettext
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, BooleanField, FileField
|
||||
from wtforms.validators import InputRequired, Length, DataRequired, Optional
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
|
||||
from flask import *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_login import login_required
|
||||
from wtforms import *
|
||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.utils import *
|
||||
from . import bp, get_package_tabs
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.screenshots import do_create_screenshot, do_order_screenshots
|
||||
from . import bp, get_package_tabs
|
||||
from app.models import Permission, db, PackageScreenshot
|
||||
from app.utils import is_package_page
|
||||
|
||||
|
||||
class CreateScreenshotForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
|
||||
file_upload = FileField(lazy_gettext("File Upload"), [InputRequired()])
|
||||
fileUpload = FileField(lazy_gettext("File Upload"), [InputRequired()])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@@ -50,8 +50,11 @@ class EditPackageScreenshotsForm(FlaskForm):
|
||||
@login_required
|
||||
@is_package_page
|
||||
def screenshots(package):
|
||||
if not package.check_perm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
return redirect(package.get_url("packages.view"))
|
||||
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
if package.screenshots.count() == 0:
|
||||
return redirect(package.getURL("packages.create_screenshot"))
|
||||
|
||||
form = EditPackageScreenshotsForm(obj=package)
|
||||
form.cover_image.query = package.screenshots
|
||||
@@ -61,7 +64,7 @@ def screenshots(package):
|
||||
if order:
|
||||
try:
|
||||
do_order_screenshots(current_user, package, order.split(","))
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
except LogicError as e:
|
||||
flash(e.message, "danger")
|
||||
|
||||
@@ -77,22 +80,22 @@ def screenshots(package):
|
||||
@login_required
|
||||
@is_package_page
|
||||
def create_screenshot(package):
|
||||
if not package.check_perm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
return redirect(package.get_url("packages.view"))
|
||||
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
return redirect(package.getURL("packages.view"))
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = CreateScreenshotForm()
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
do_create_screenshot(current_user, package, form.title.data, form.file_upload.data, False)
|
||||
return redirect(package.get_url("packages.screenshots"))
|
||||
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")
|
||||
|
||||
return render_template("packages/screenshot_new.html", package=package, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/screenshots/<int:id>/edit/", methods=["GET", "POST"])
|
||||
@bp.route("/packages/<author>/<name>/screenshots/<id>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def edit_screenshot(package, id):
|
||||
@@ -100,31 +103,31 @@ def edit_screenshot(package, id):
|
||||
if screenshot is None or screenshot.package != package:
|
||||
abort(404)
|
||||
|
||||
can_edit = package.check_perm(current_user, Permission.ADD_SCREENSHOTS)
|
||||
can_approve = package.check_perm(current_user, Permission.APPROVE_SCREENSHOT)
|
||||
if not (can_edit or can_approve):
|
||||
return redirect(package.get_url("packages.screenshots"))
|
||||
canEdit = package.checkPerm(current_user, Permission.ADD_SCREENSHOTS)
|
||||
canApprove = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
|
||||
if not (canEdit or canApprove):
|
||||
return redirect(package.getURL("packages.screenshots"))
|
||||
|
||||
# Initial form class from post data and default data
|
||||
form = EditScreenshotForm(obj=screenshot)
|
||||
if form.validate_on_submit():
|
||||
was_approved = screenshot.approved
|
||||
wasApproved = screenshot.approved
|
||||
|
||||
if can_edit:
|
||||
if canEdit:
|
||||
screenshot.title = form["title"].data or "Untitled"
|
||||
|
||||
if can_approve:
|
||||
if canApprove:
|
||||
screenshot.approved = form["approved"].data
|
||||
else:
|
||||
screenshot.approved = was_approved
|
||||
screenshot.approved = wasApproved
|
||||
|
||||
db.session.commit()
|
||||
return redirect(package.get_url("packages.screenshots"))
|
||||
return redirect(package.getURL("packages.screenshots"))
|
||||
|
||||
return render_template("packages/screenshot_edit.html", package=package, screenshot=screenshot, form=form)
|
||||
|
||||
|
||||
@bp.route("/packages/<author>/<name>/screenshots/<int:id>/delete/", methods=["POST"])
|
||||
@bp.route("/packages/<author>/<name>/screenshots/<id>/delete/", methods=["POST"])
|
||||
@login_required
|
||||
@is_package_page
|
||||
def delete_screenshot(package, id):
|
||||
@@ -132,7 +135,7 @@ def delete_screenshot(package, id):
|
||||
if screenshot is None or screenshot.package != package:
|
||||
abort(404)
|
||||
|
||||
if not package.check_perm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
|
||||
flash(gettext("Permission denied"), "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
@@ -143,4 +146,4 @@ def delete_screenshot(package, id):
|
||||
db.session.delete(screenshot)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(package.get_url("packages.screenshots"))
|
||||
return redirect(package.getURL("packages.screenshots"))
|
||||
|
||||
@@ -14,7 +14,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/>.
|
||||
|
||||
from flask import Blueprint, request, render_template, url_for, abort
|
||||
from flask import Blueprint, request, render_template, url_for
|
||||
from flask_babel import lazy_gettext
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
@@ -25,7 +25,7 @@ from wtforms.validators import InputRequired, Length
|
||||
from app.models import User, UserRank
|
||||
from app.tasks.emails import send_user_email
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
from app.utils import is_no, abs_url_samesite
|
||||
from app.utils import isNo, abs_url_samesite
|
||||
|
||||
bp = Blueprint("report", __name__)
|
||||
|
||||
@@ -37,17 +37,14 @@ class ReportForm(FlaskForm):
|
||||
|
||||
@bp.route("/report/", methods=["GET", "POST"])
|
||||
def report():
|
||||
is_anon = not current_user.is_authenticated or not is_no(request.args.get("anon"))
|
||||
is_anon = not current_user.is_authenticated or not isNo(request.args.get("anon"))
|
||||
|
||||
url = request.args.get("url")
|
||||
if url:
|
||||
if url.startswith("/report/"):
|
||||
abort(404)
|
||||
|
||||
url = abs_url_samesite(url)
|
||||
|
||||
form = ReportForm(formdata=request.form) if current_user.is_authenticated else None
|
||||
if form and form.validate_on_submit():
|
||||
form = ReportForm(formdata=request.form)
|
||||
if form.validate_on_submit():
|
||||
if current_user.is_authenticated:
|
||||
user_info = f"{current_user.username}"
|
||||
else:
|
||||
@@ -64,4 +61,4 @@ def report():
|
||||
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home")))
|
||||
|
||||
return render_template("report/index.html", form=form, url=url, is_anon=is_anon, noindex=url is not None)
|
||||
return render_template("report/index.html", form=form, url=url, is_anon=is_anon)
|
||||
|
||||
@@ -14,14 +14,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/>.
|
||||
|
||||
from flask import Blueprint, jsonify, url_for, request, redirect, render_template
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from flask import *
|
||||
from flask_login import login_required
|
||||
|
||||
from app import csrf
|
||||
from app.models import UserRank
|
||||
from app.tasks import celery
|
||||
from app.tasks.importtasks import get_meta
|
||||
from app.utils import should_return_json
|
||||
from app.tasks.importtasks import getMeta
|
||||
from app.utils import *
|
||||
|
||||
bp = Blueprint("tasks", __name__)
|
||||
|
||||
@@ -30,10 +30,9 @@ bp = Blueprint("tasks", __name__)
|
||||
@bp.route("/tasks/getmeta/new/", methods=["POST"])
|
||||
@login_required
|
||||
def start_getmeta():
|
||||
from flask import request
|
||||
author = request.args.get("author")
|
||||
author = current_user.forums_username if author is None else author
|
||||
aresult = get_meta.delay(request.args.get("url"), author)
|
||||
aresult = getMeta.delay(request.args.get("url"), author)
|
||||
return jsonify({
|
||||
"poll_url": url_for("tasks.check", id=aresult.id),
|
||||
})
|
||||
@@ -52,7 +51,7 @@ def check(id):
|
||||
'status': status,
|
||||
}
|
||||
|
||||
if current_user.is_authenticated and current_user.rank.at_least(UserRank.ADMIN):
|
||||
if current_user.is_authenticated and current_user.rank.atLeast(UserRank.ADMIN):
|
||||
info["error"] = str(traceback)
|
||||
elif str(result)[1:12] == "TaskError: ":
|
||||
info["error"] = str(result)[12:-1]
|
||||
@@ -65,7 +64,7 @@ def check(id):
|
||||
'result': result,
|
||||
}
|
||||
|
||||
if should_return_json():
|
||||
if shouldReturnJson():
|
||||
return jsonify(info)
|
||||
else:
|
||||
r = request.args.get("r")
|
||||
|
||||
@@ -13,11 +13,8 @@
|
||||
#
|
||||
# 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 Blueprint, request, render_template, abort, flash, redirect, url_for
|
||||
from flask import *
|
||||
from flask_babel import gettext, lazy_gettext
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
|
||||
from app.markdown import get_user_mentions, render_markdown
|
||||
from app.tasks.webhooktasks import post_discord_webhook
|
||||
@@ -25,12 +22,11 @@ from app.tasks.webhooktasks import post_discord_webhook
|
||||
bp = Blueprint("threads", __name__)
|
||||
|
||||
from flask_login import current_user, login_required
|
||||
from app.models import Package, db, User, Permission, Thread, UserRank, AuditSeverity, \
|
||||
NotificationType, ThreadReply
|
||||
from app.utils import add_notification, is_yes, add_audit_log, get_system_user, has_blocked_domains
|
||||
from app.models import *
|
||||
from app.utils import addNotification, isYes, addAuditLog, get_system_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, SubmitField, BooleanField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
from app.utils import get_int_or_abort
|
||||
|
||||
|
||||
@@ -44,7 +40,7 @@ def list_all():
|
||||
pid = request.args.get("pid")
|
||||
if pid:
|
||||
pid = get_int_or_abort(pid)
|
||||
package = Package.query.get_or_404(pid)
|
||||
package = Package.query.get(pid)
|
||||
query = query.filter_by(package=package)
|
||||
|
||||
query = query.filter_by(review_id=None)
|
||||
@@ -54,17 +50,16 @@ def list_all():
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
||||
|
||||
pagination = query.paginate(page=page, per_page=num)
|
||||
pagination = query.paginate(page, num, True)
|
||||
|
||||
return render_template("threads/list.html", pagination=pagination, threads=pagination.items,
|
||||
package=package, noindex=pid)
|
||||
return render_template("threads/list.html", pagination=pagination, threads=pagination.items, package=package)
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
|
||||
@login_required
|
||||
def subscribe(id):
|
||||
thread = Thread.query.get(id)
|
||||
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
|
||||
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
|
||||
abort(404)
|
||||
|
||||
if current_user in thread.watchers:
|
||||
@@ -74,14 +69,14 @@ def subscribe(id):
|
||||
thread.watchers.append(current_user)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/unsubscribe/", methods=["POST"])
|
||||
@login_required
|
||||
def unsubscribe(id):
|
||||
thread = Thread.query.get(id)
|
||||
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
|
||||
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
|
||||
abort(404)
|
||||
|
||||
if current_user in thread.watchers:
|
||||
@@ -91,20 +86,21 @@ def unsubscribe(id):
|
||||
else:
|
||||
flash(gettext("Already not subscribed!"), "success")
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/set-lock/", methods=["POST"])
|
||||
@login_required
|
||||
def set_lock(id):
|
||||
thread = Thread.query.get(id)
|
||||
if thread is None or not thread.check_perm(current_user, Permission.LOCK_THREAD):
|
||||
if thread is None or not thread.checkPerm(current_user, Permission.LOCK_THREAD):
|
||||
abort(404)
|
||||
|
||||
thread.locked = is_yes(request.args.get("lock"))
|
||||
thread.locked = isYes(request.args.get("lock"))
|
||||
if thread.locked is None:
|
||||
abort(400)
|
||||
|
||||
msg = None
|
||||
if thread.locked:
|
||||
msg = "Locked thread '{}'".format(thread.title)
|
||||
flash(gettext("Locked thread"), "success")
|
||||
@@ -112,19 +108,19 @@ def set_lock(id):
|
||||
msg = "Unlocked thread '{}'".format(thread.title)
|
||||
flash(gettext("Unlocked thread"), "success")
|
||||
|
||||
add_notification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.get_view_url(), thread.package)
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, msg, thread.get_view_url(), thread.package)
|
||||
addNotification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/delete/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def delete_thread(id):
|
||||
thread = Thread.query.get(id)
|
||||
if thread is None or not thread.check_perm(current_user, Permission.DELETE_THREAD):
|
||||
if thread is None or not thread.checkPerm(current_user, Permission.DELETE_THREAD):
|
||||
abort(404)
|
||||
|
||||
if request.method == "GET":
|
||||
@@ -136,7 +132,7 @@ def delete_thread(id):
|
||||
|
||||
db.session.delete(thread)
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None, thread.package, summary)
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None, thread.package, summary)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@@ -158,28 +154,28 @@ def delete_reply(id):
|
||||
if reply is None or reply.thread != thread:
|
||||
abort(404)
|
||||
|
||||
if thread.first_reply == reply:
|
||||
if thread.replies[0] == reply:
|
||||
flash(gettext("Cannot delete thread opening post!"), "danger")
|
||||
return redirect(thread.get_view_url())
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
if not reply.check_perm(current_user, Permission.DELETE_REPLY):
|
||||
if not reply.checkPerm(current_user, Permission.DELETE_REPLY):
|
||||
abort(403)
|
||||
|
||||
if request.method == "GET":
|
||||
return render_template("threads/delete_reply.html", thread=thread, reply=reply)
|
||||
|
||||
msg = "Deleted reply by {}".format(reply.author.display_name)
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, msg, thread.get_view_url(), thread.package, reply.comment)
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package, reply.comment)
|
||||
|
||||
db.session.delete(reply)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
|
||||
class CommentForm(FlaskForm):
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(2, 2000)])
|
||||
btn_submit = SubmitField(lazy_gettext("Comment"))
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
|
||||
submit = SubmitField(lazy_gettext("Comment"))
|
||||
|
||||
|
||||
@bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"])
|
||||
@@ -193,29 +189,27 @@ def edit_reply(id):
|
||||
if reply_id is None:
|
||||
abort(404)
|
||||
|
||||
reply: ThreadReply = ThreadReply.query.get(reply_id)
|
||||
reply = ThreadReply.query.get(reply_id)
|
||||
if reply is None or reply.thread != thread:
|
||||
abort(404)
|
||||
|
||||
if not reply.check_perm(current_user, Permission.EDIT_REPLY):
|
||||
if not reply.checkPerm(current_user, Permission.EDIT_REPLY):
|
||||
abort(403)
|
||||
|
||||
form = CommentForm(formdata=request.form, obj=reply)
|
||||
if form.validate_on_submit():
|
||||
comment = form.comment.data
|
||||
if has_blocked_domains(comment, current_user.username, f"edit to reply {reply.get_url(True)}"):
|
||||
flash(gettext("Linking to blocked sites is not allowed"), "danger")
|
||||
else:
|
||||
msg = "Edited reply by {}".format(reply.author.display_name)
|
||||
severity = AuditSeverity.NORMAL if current_user == reply.author else AuditSeverity.MODERATION
|
||||
add_notification(reply.author, current_user, NotificationType.OTHER, msg, thread.get_view_url(), thread.package)
|
||||
add_audit_log(severity, current_user, msg, thread.get_view_url(), thread.package, reply.comment)
|
||||
|
||||
reply.comment = comment
|
||||
msg = "Edited reply by {}".format(reply.author.display_name)
|
||||
severity = AuditSeverity.NORMAL if current_user == reply.author else AuditSeverity.MODERATION
|
||||
addNotification(reply.author, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
|
||||
addAuditLog(severity, current_user, msg, thread.getViewURL(), thread.package, reply.comment)
|
||||
|
||||
db.session.commit()
|
||||
reply.comment = comment
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
return render_template("threads/edit_reply.html", thread=thread, reply=reply, form=form)
|
||||
|
||||
@@ -223,22 +217,18 @@ def edit_reply(id):
|
||||
@bp.route("/threads/<int:id>/", methods=["GET", "POST"])
|
||||
def view(id):
|
||||
thread: Thread = Thread.query.get(id)
|
||||
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
|
||||
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
|
||||
abort(404)
|
||||
|
||||
form = CommentForm(formdata=request.form) if thread.check_perm(current_user, Permission.COMMENT_THREAD) else None
|
||||
form = CommentForm(formdata=request.form) if thread.checkPerm(current_user, Permission.COMMENT_THREAD) else None
|
||||
|
||||
# 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.can_comment_ratelimit():
|
||||
if not current_user.canCommentRL():
|
||||
flash(gettext("Please wait before commenting again"), "danger")
|
||||
return redirect(thread.get_view_url())
|
||||
|
||||
if has_blocked_domains(comment, current_user.username, f"reply to {thread.get_view_url(True)}"):
|
||||
flash(gettext("Linking to blocked sites is not allowed"), "danger")
|
||||
return render_template("threads/view.html", thread=thread, form=form)
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
reply = ThreadReply()
|
||||
reply.author = current_user
|
||||
@@ -255,24 +245,24 @@ def view(id):
|
||||
continue
|
||||
|
||||
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
|
||||
add_notification(mentioned, current_user, NotificationType.THREAD_REPLY,
|
||||
msg, thread.get_view_url(), thread.package)
|
||||
addNotification(mentioned, current_user, NotificationType.THREAD_REPLY,
|
||||
msg, thread.getViewURL(), thread.package)
|
||||
|
||||
thread.watchers.append(mentioned)
|
||||
|
||||
msg = "New comment on '{}'".format(thread.title)
|
||||
add_notification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.get_view_url(), thread.package)
|
||||
addNotification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.getViewURL(), thread.package)
|
||||
|
||||
if thread.author == get_system_user():
|
||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
||||
add_notification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
|
||||
thread.get_view_url(), thread.package)
|
||||
post_discord_webhook.delay(current_user.display_name,
|
||||
"Replied to bot messages: {}".format(thread.get_view_url(absolute=True)), True)
|
||||
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)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
return render_template("threads/view.html", thread=thread, form=form)
|
||||
|
||||
@@ -281,7 +271,7 @@ class ThreadForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
|
||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
|
||||
private = BooleanField(lazy_gettext("Private"))
|
||||
btn_submit = SubmitField(lazy_gettext("Open Thread"))
|
||||
submit = SubmitField(lazy_gettext("Open Thread"))
|
||||
|
||||
|
||||
@bp.route("/threads/new/", methods=["GET", "POST"])
|
||||
@@ -296,16 +286,14 @@ def new():
|
||||
abort(404)
|
||||
|
||||
def_is_private = request.args.get("private") or False
|
||||
if package is None and not current_user.rank.at_least(UserRank.APPROVER):
|
||||
if package is None and not current_user.rank.atLeast(UserRank.APPROVER):
|
||||
abort(404)
|
||||
|
||||
allow_private_change = not package or package.approved
|
||||
is_review_thread = package and not package.approved
|
||||
allow_private_change = not is_review_thread
|
||||
if is_review_thread:
|
||||
def_is_private = True
|
||||
|
||||
# Check that user can make the thread
|
||||
if package and not package.check_perm(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"))
|
||||
|
||||
@@ -313,13 +301,13 @@ def new():
|
||||
elif is_review_thread and package.review_thread is not None:
|
||||
# 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.get_view_url(), code=307)
|
||||
return redirect(package.review_thread.getViewURL(), code=307)
|
||||
|
||||
elif not current_user.can_open_thread_ratelimit():
|
||||
elif not current_user.canOpenThreadRL():
|
||||
flash(gettext("Please wait before opening another thread"), "danger")
|
||||
|
||||
if package:
|
||||
return redirect(package.get_url("packages.view"))
|
||||
return redirect(package.getURL("packages.view"))
|
||||
else:
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
@@ -330,58 +318,56 @@ def new():
|
||||
|
||||
# Validate and submit
|
||||
elif form.validate_on_submit():
|
||||
if has_blocked_domains(form.comment.data, current_user.username, f"new thread"):
|
||||
flash(gettext("Linking to blocked sites is not allowed"), "danger")
|
||||
else:
|
||||
thread = Thread()
|
||||
thread.author = current_user
|
||||
thread.title = form.title.data
|
||||
thread.private = form.private.data if allow_private_change else def_is_private
|
||||
thread.package = package
|
||||
db.session.add(thread)
|
||||
thread = Thread()
|
||||
thread.author = current_user
|
||||
thread.title = form.title.data
|
||||
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 and package.author != current_user:
|
||||
thread.watchers.append(package.author)
|
||||
thread.watchers.append(current_user)
|
||||
if package and package.author != current_user:
|
||||
thread.watchers.append(package.author)
|
||||
|
||||
reply = ThreadReply()
|
||||
reply.thread = thread
|
||||
reply.author = current_user
|
||||
reply.comment = form.comment.data
|
||||
db.session.add(reply)
|
||||
reply = ThreadReply()
|
||||
reply.thread = thread
|
||||
reply.author = current_user
|
||||
reply.comment = form.comment.data
|
||||
db.session.add(reply)
|
||||
|
||||
thread.replies.append(reply)
|
||||
thread.replies.append(reply)
|
||||
|
||||
db.session.commit()
|
||||
db.session.commit()
|
||||
|
||||
if is_review_thread:
|
||||
package.review_thread = thread
|
||||
if is_review_thread:
|
||||
package.review_thread = thread
|
||||
|
||||
for mentioned_username in get_user_mentions(render_markdown(form.comment.data)):
|
||||
mentioned = User.query.filter_by(username=mentioned_username).first()
|
||||
if mentioned is None:
|
||||
continue
|
||||
for mentioned_username in get_user_mentions(render_markdown(form.comment.data)):
|
||||
mentioned = User.query.filter_by(username=mentioned_username).first()
|
||||
if mentioned is None:
|
||||
continue
|
||||
|
||||
msg = "Mentioned by {} in new thread '{}'".format(current_user.display_name, thread.title)
|
||||
add_notification(mentioned, current_user, NotificationType.NEW_THREAD,
|
||||
msg, thread.get_view_url(), thread.package)
|
||||
msg = "Mentioned by {} in new thread '{}'".format(current_user.display_name, thread.title)
|
||||
addNotification(mentioned, current_user, NotificationType.NEW_THREAD,
|
||||
msg, thread.getViewURL(), thread.package)
|
||||
|
||||
thread.watchers.append(mentioned)
|
||||
thread.watchers.append(mentioned)
|
||||
|
||||
notif_msg = "New thread '{}'".format(thread.title)
|
||||
if package is not None:
|
||||
add_notification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.get_view_url(), package)
|
||||
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)
|
||||
|
||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
||||
add_notification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.get_view_url(), package)
|
||||
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.display_name,
|
||||
"Opened approval thread: {}".format(thread.get_view_url(absolute=True)), True)
|
||||
if is_review_thread:
|
||||
post_discord_webhook.delay(current_user.username,
|
||||
"Opened approval thread: {}".format(thread.getViewURL(absolute=True)), True)
|
||||
|
||||
db.session.commit()
|
||||
db.session.commit()
|
||||
|
||||
return redirect(thread.getViewURL())
|
||||
|
||||
return redirect(thread.get_view_url())
|
||||
|
||||
return render_template("threads/new.html", form=form, allow_private_change=allow_private_change, package=package)
|
||||
|
||||
@@ -392,16 +378,4 @@ def user_comments(username):
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = min(40, get_int_or_abort(request.args.get("n"), 40))
|
||||
|
||||
# Filter replies the current user can see
|
||||
query = ThreadReply.query.options(selectinload(ThreadReply.thread)).filter_by(author=user)
|
||||
only_public = False
|
||||
if current_user != user and not (current_user.is_authenticated and current_user.rank.at_least(UserRank.APPROVER)):
|
||||
query = query.filter(ThreadReply.thread.has(private=False))
|
||||
only_public = True
|
||||
|
||||
pagination = query.order_by(db.desc(ThreadReply.created_at)).paginate(page=page, per_page=num)
|
||||
|
||||
return render_template("threads/user_comments.html", user=user, pagination=pagination, only_public=only_public)
|
||||
return render_template("threads/user_comments.html", user=user, replies=user.replies)
|
||||
|
||||
@@ -22,7 +22,7 @@ bp = Blueprint("thumbnails", __name__)
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
ALLOWED_RESOLUTIONS=[(100,67), (270,180), (350,233), (1100,520)]
|
||||
ALLOWED_RESOLUTIONS=[(100,67), (270,180), (350,233)]
|
||||
|
||||
def mkdir(path):
|
||||
assert path != "" and path is not None
|
||||
@@ -68,6 +68,7 @@ def resize_and_crop(img_path, modified_path, size):
|
||||
def make_thumbnail(img, level):
|
||||
if level > len(ALLOWED_RESOLUTIONS) or level <= 0:
|
||||
abort(403)
|
||||
|
||||
w, h = ALLOWED_RESOLUTIONS[level - 1]
|
||||
|
||||
upload_dir = current_app.config["UPLOAD_DIR"]
|
||||
|
||||
@@ -14,9 +14,257 @@
|
||||
# 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 *
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from flask import Blueprint
|
||||
from app.models import *
|
||||
from app.querybuilder import QueryBuilder
|
||||
from app.utils import get_int_or_abort, addNotification, addAuditLog, isYes
|
||||
from app.tasks.importtasks import makeVCSRelease
|
||||
|
||||
bp = Blueprint("todo", __name__)
|
||||
|
||||
from . import editor, user
|
||||
@bp.route("/todo/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def view_editor():
|
||||
canApproveNew = Permission.APPROVE_NEW.check(current_user)
|
||||
canApproveRel = Permission.APPROVE_RELEASE.check(current_user)
|
||||
canApproveScn = Permission.APPROVE_SCREENSHOT.check(current_user)
|
||||
|
||||
packages = None
|
||||
wip_packages = None
|
||||
if canApproveNew:
|
||||
packages = Package.query.filter_by(state=PackageState.READY_FOR_REVIEW) \
|
||||
.order_by(db.desc(Package.created_at)).all()
|
||||
wip_packages = Package.query.filter(or_(Package.state==PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)) \
|
||||
.order_by(db.desc(Package.created_at)).all()
|
||||
|
||||
releases = None
|
||||
if canApproveRel:
|
||||
releases = PackageRelease.query.filter_by(approved=False).all()
|
||||
|
||||
screenshots = None
|
||||
if canApproveScn:
|
||||
screenshots = PackageScreenshot.query.filter_by(approved=False).all()
|
||||
|
||||
if not canApproveNew and not canApproveRel and not canApproveScn:
|
||||
abort(403)
|
||||
|
||||
if request.method == "POST":
|
||||
if request.form["action"] == "screenshots_approve_all":
|
||||
if not canApproveScn:
|
||||
abort(403)
|
||||
|
||||
PackageScreenshot.query.update({ "approved": True })
|
||||
db.session.commit()
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
else:
|
||||
abort(400)
|
||||
|
||||
license_needed = Package.query \
|
||||
.filter(Package.state.in_([PackageState.READY_FOR_REVIEW, PackageState.APPROVED])) \
|
||||
.filter(or_(Package.license.has(License.name.like("Other %")),
|
||||
Package.media_license.has(License.name.like("Other %")))) \
|
||||
.all()
|
||||
|
||||
total_packages = Package.query.filter_by(state=PackageState.APPROVED).count()
|
||||
total_to_tag = Package.query.filter_by(state=PackageState.APPROVED, tags=None).count()
|
||||
|
||||
unfulfilled_meta_packages = MetaPackage.query \
|
||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
||||
.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, audit_log=audit_log)
|
||||
|
||||
|
||||
@bp.route("/todo/topics/")
|
||||
@login_required
|
||||
def topics():
|
||||
qb = QueryBuilder(request.args)
|
||||
qb.setSortIfNone("date")
|
||||
query = qb.buildTopicQuery()
|
||||
|
||||
tmp_q = ForumTopic.query
|
||||
if not qb.show_discarded:
|
||||
tmp_q = tmp_q.filter_by(discarded=False)
|
||||
total = tmp_q.count()
|
||||
topic_count = query.count()
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = get_int_or_abort(request.args.get("n"), 100)
|
||||
if num > 100 and not current_user.rank.atLeast(UserRank.APPROVER):
|
||||
num = 100
|
||||
|
||||
query = query.paginate(page, num, True)
|
||||
next_url = url_for("todo.topics", page=query.next_num, query=qb.search,
|
||||
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
|
||||
if query.has_next else None
|
||||
prev_url = url_for("todo.topics", page=query.prev_num, query=qb.search,
|
||||
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
|
||||
if query.has_prev else None
|
||||
|
||||
return render_template("todo/topics.html", current_tab="topics", topics=query.items, total=total,
|
||||
topic_count=topic_count, query=qb.search, show_discarded=qb.show_discarded,
|
||||
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages,
|
||||
n=num, sort_by=qb.order_by)
|
||||
|
||||
|
||||
@bp.route("/todo/tags/")
|
||||
@login_required
|
||||
def tags():
|
||||
qb = QueryBuilder(request.args)
|
||||
qb.setSortIfNone("score", "desc")
|
||||
query = qb.buildPackageQuery()
|
||||
|
||||
only_no_tags = isYes(request.args.get("no_tags"))
|
||||
if only_no_tags:
|
||||
query = query.filter(Package.tags==None)
|
||||
|
||||
tags = Tag.query.order_by(db.asc(Tag.title)).all()
|
||||
|
||||
return render_template("todo/tags.html", current_tab="tags", packages=query.all(), \
|
||||
tags=tags, only_no_tags=only_no_tags)
|
||||
|
||||
|
||||
@bp.route("/user/tags/")
|
||||
def tags_user():
|
||||
return redirect(url_for('todo.tags', author=current_user.username))
|
||||
|
||||
|
||||
@bp.route("/todo/metapackages/")
|
||||
@login_required
|
||||
def metapackages():
|
||||
mpackages = MetaPackage.query \
|
||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
||||
.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)
|
||||
|
||||
|
||||
@bp.route("/user/todo/")
|
||||
@bp.route("/users/<username>/todo/")
|
||||
@login_required
|
||||
def view_user(username=None):
|
||||
if username is None:
|
||||
return redirect(url_for("todo.view_user", username=current_user.username))
|
||||
|
||||
user : User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if current_user != user and not current_user.rank.atLeast(UserRank.APPROVER):
|
||||
abort(403)
|
||||
|
||||
unapproved_packages = user.packages \
|
||||
.filter(or_(Package.state == PackageState.WIP,
|
||||
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))) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
topics_to_add = ForumTopic.query \
|
||||
.filter_by(author_id=user.id) \
|
||||
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||
.all()
|
||||
|
||||
needs_tags = user.maintained_packages \
|
||||
.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,
|
||||
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"])
|
||||
@login_required
|
||||
def apply_all_updates(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if current_user != user and not current_user.rank.atLeast(UserRank.EDITOR):
|
||||
abort(403)
|
||||
|
||||
outdated_packages = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED,
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
for package in outdated_packages:
|
||||
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||
continue
|
||||
|
||||
if package.releases.filter(or_(PackageRelease.task_id.isnot(None),
|
||||
PackageRelease.commit_hash==package.update_config.last_commit)).count() > 0:
|
||||
continue
|
||||
|
||||
title = package.update_config.get_title()
|
||||
ref = package.update_config.get_ref()
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = package
|
||||
rel.title = title
|
||||
rel.url = ""
|
||||
rel.task_id = uuid()
|
||||
db.session.add(rel)
|
||||
db.session.commit()
|
||||
|
||||
makeVCSRelease.apply_async((rel.id, ref),
|
||||
task_id=rel.task_id)
|
||||
|
||||
msg = "Created release {} (Applied all Git Update Detection)".format(rel.title)
|
||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg,
|
||||
rel.getURL("packages.create_edit"), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getURL("packages.view"), package)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("todo.view_user", username=username))
|
||||
|
||||
|
||||
@bp.route("/todo/outdated/")
|
||||
@login_required
|
||||
def outdated():
|
||||
is_mtm_only = isYes(request.args.get("mtm"))
|
||||
|
||||
query = db.session.query(Package).select_from(PackageUpdateConfig) \
|
||||
.filter(PackageUpdateConfig.outdated_at.isnot(None)) \
|
||||
.join(PackageUpdateConfig.package) \
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
|
||||
if is_mtm_only:
|
||||
query = query.filter(Package.repo.ilike("%github.com/minetest-mods/%"))
|
||||
|
||||
sort_by = request.args.get("sort")
|
||||
if sort_by == "date":
|
||||
query = query.order_by(db.desc(PackageUpdateConfig.outdated_at))
|
||||
else:
|
||||
sort_by = "score"
|
||||
query = query.order_by(db.desc(Package.score))
|
||||
|
||||
return render_template("todo/outdated.html", current_tab="outdated",
|
||||
outdated_packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only)
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-23 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 redirect, url_for, abort, render_template, request
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.models import Package, PackageState, PackageScreenshot, PackageUpdateConfig, ForumTopic, db, \
|
||||
PackageRelease, Permission, UserRank, License, MetaPackage, Dependency, AuditLogEntry, Tag, MinetestRelease
|
||||
from app.querybuilder import QueryBuilder
|
||||
from app.utils import get_int_or_abort, is_yes
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route("/todo/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def view_editor():
|
||||
can_approve_new = Permission.APPROVE_NEW.check(current_user)
|
||||
can_approve_rel = Permission.APPROVE_RELEASE.check(current_user)
|
||||
can_approve_scn = Permission.APPROVE_SCREENSHOT.check(current_user)
|
||||
|
||||
packages = None
|
||||
wip_packages = None
|
||||
if can_approve_new:
|
||||
packages = Package.query.filter_by(state=PackageState.READY_FOR_REVIEW) \
|
||||
.order_by(db.desc(Package.created_at)).all()
|
||||
wip_packages = Package.query \
|
||||
.filter(or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)) \
|
||||
.order_by(db.desc(Package.created_at)).all()
|
||||
|
||||
releases = None
|
||||
if can_approve_rel:
|
||||
releases = PackageRelease.query.filter_by(approved=False).all()
|
||||
|
||||
screenshots = None
|
||||
if can_approve_scn:
|
||||
screenshots = PackageScreenshot.query.filter_by(approved=False).all()
|
||||
|
||||
if not can_approve_new and not can_approve_rel and not can_approve_scn:
|
||||
abort(403)
|
||||
|
||||
if request.method == "POST":
|
||||
if request.form["action"] == "screenshots_approve_all":
|
||||
if not can_approve_scn:
|
||||
abort(403)
|
||||
|
||||
PackageScreenshot.query.update({"approved": True})
|
||||
db.session.commit()
|
||||
return redirect(url_for("todo.view_editor"))
|
||||
else:
|
||||
abort(400)
|
||||
|
||||
license_needed = Package.query \
|
||||
.filter(Package.state.in_([PackageState.READY_FOR_REVIEW, PackageState.APPROVED])) \
|
||||
.filter(or_(Package.license.has(License.name.like("Other %")),
|
||||
Package.media_license.has(License.name.like("Other %")))) \
|
||||
.all()
|
||||
|
||||
total_packages = Package.query.filter_by(state=PackageState.APPROVED).count()
|
||||
total_to_tag = Package.query.filter_by(state=PackageState.APPROVED, tags=None).count()
|
||||
|
||||
unfulfilled_meta_packages = MetaPackage.query \
|
||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
||||
.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,
|
||||
can_approve_new=can_approve_new, can_approve_rel=can_approve_rel, can_approve_scn=can_approve_scn,
|
||||
license_needed=license_needed, total_packages=total_packages, total_to_tag=total_to_tag,
|
||||
unfulfilled_meta_packages=unfulfilled_meta_packages, audit_log=audit_log)
|
||||
|
||||
|
||||
@bp.route("/todo/topics/")
|
||||
@login_required
|
||||
def topics():
|
||||
qb = QueryBuilder(request.args)
|
||||
qb.set_sort_if_none("date")
|
||||
query = qb.build_topic_query()
|
||||
|
||||
tmp_q = ForumTopic.query
|
||||
if not qb.show_discarded:
|
||||
tmp_q = tmp_q.filter_by(discarded=False)
|
||||
total = tmp_q.count()
|
||||
topic_count = query.count()
|
||||
|
||||
page = get_int_or_abort(request.args.get("page"), 1)
|
||||
num = get_int_or_abort(request.args.get("n"), 100)
|
||||
if num > 100 and not current_user.rank.at_least(UserRank.APPROVER):
|
||||
num = 100
|
||||
|
||||
query = query.paginate(page=page, per_page=num)
|
||||
next_url = url_for("todo.topics", page=query.next_num, query=qb.search,
|
||||
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
|
||||
if query.has_next else None
|
||||
prev_url = url_for("todo.topics", page=query.prev_num, query=qb.search,
|
||||
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
|
||||
if query.has_prev else None
|
||||
|
||||
return render_template("todo/topics.html", current_tab="topics", topics=query.items, total=total,
|
||||
topic_count=topic_count, query=qb.search, show_discarded=qb.show_discarded,
|
||||
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages,
|
||||
n=num, sort_by=qb.order_by)
|
||||
|
||||
|
||||
@bp.route("/todo/tags/")
|
||||
@login_required
|
||||
def tags():
|
||||
qb = QueryBuilder(request.args)
|
||||
qb.set_sort_if_none("score", "desc")
|
||||
query = qb.build_package_query()
|
||||
|
||||
only_no_tags = is_yes(request.args.get("no_tags"))
|
||||
if only_no_tags:
|
||||
query = query.filter(Package.tags == None)
|
||||
|
||||
tags = Tag.query.order_by(db.asc(Tag.title)).all()
|
||||
|
||||
return render_template("todo/tags.html", current_tab="tags", packages=query.all(),
|
||||
tags=tags, only_no_tags=only_no_tags)
|
||||
|
||||
|
||||
@bp.route("/todo/modnames/")
|
||||
@login_required
|
||||
def modnames():
|
||||
mnames = MetaPackage.query \
|
||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
||||
.filter(MetaPackage.dependencies.any(Dependency.depender.has(state=PackageState.APPROVED), optional=False)) \
|
||||
.order_by(db.asc(MetaPackage.name)).all()
|
||||
|
||||
return render_template("todo/modnames.html", modnames=mnames)
|
||||
|
||||
|
||||
@bp.route("/todo/outdated/")
|
||||
@login_required
|
||||
def outdated():
|
||||
is_mtm_only = is_yes(request.args.get("mtm"))
|
||||
|
||||
query = db.session.query(Package).select_from(PackageUpdateConfig) \
|
||||
.filter(PackageUpdateConfig.outdated_at.isnot(None)) \
|
||||
.join(PackageUpdateConfig.package) \
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
|
||||
if is_mtm_only:
|
||||
query = query.filter(Package.repo.ilike("%github.com/minetest-mods/%"))
|
||||
|
||||
sort_by = request.args.get("sort")
|
||||
if sort_by == "date":
|
||||
query = query.order_by(db.desc(PackageUpdateConfig.outdated_at))
|
||||
else:
|
||||
sort_by = "score"
|
||||
query = query.order_by(db.desc(Package.score))
|
||||
|
||||
return render_template("todo/outdated.html", current_tab="outdated",
|
||||
outdated_packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only)
|
||||
|
||||
|
||||
@bp.route("/todo/screenshots/")
|
||||
@login_required
|
||||
def screenshots():
|
||||
is_mtm_only = is_yes(request.args.get("mtm"))
|
||||
|
||||
query = db.session.query(Package) \
|
||||
.filter(~Package.screenshots.any()) \
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
|
||||
if is_mtm_only:
|
||||
query = query.filter(Package.repo.ilike("%github.com/minetest-mods/%"))
|
||||
|
||||
sort_by = request.args.get("sort")
|
||||
if sort_by == "date":
|
||||
query = query.order_by(db.desc(Package.approved_at))
|
||||
else:
|
||||
sort_by = "score"
|
||||
query = query.order_by(db.desc(Package.score))
|
||||
|
||||
return render_template("todo/screenshots.html", current_tab="screenshots",
|
||||
packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only)
|
||||
|
||||
|
||||
@bp.route("/todo/mtver_support/")
|
||||
@login_required
|
||||
def mtver_support():
|
||||
is_mtm_only = is_yes(request.args.get("mtm"))
|
||||
|
||||
current_stable = MinetestRelease.query.filter(~MinetestRelease.name.like("%-dev")).order_by(db.desc(MinetestRelease.id)).first()
|
||||
|
||||
query = db.session.query(Package) \
|
||||
.filter(~Package.releases.any(or_(PackageRelease.max_rel==None, PackageRelease.max_rel == current_stable))) \
|
||||
.filter(Package.state == PackageState.APPROVED)
|
||||
|
||||
if is_mtm_only:
|
||||
query = query.filter(Package.repo.ilike("%github.com/minetest-mods/%"))
|
||||
|
||||
sort_by = request.args.get("sort")
|
||||
if sort_by == "date":
|
||||
query = query.order_by(db.desc(Package.approved_at))
|
||||
else:
|
||||
sort_by = "score"
|
||||
query = query.order_by(db.desc(Package.score))
|
||||
|
||||
return render_template("todo/mtver_support.html", current_tab="screenshots",
|
||||
packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only, current_stable=current_stable)
|
||||
@@ -1,193 +0,0 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2018-23 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 redirect, url_for, abort, render_template, flash
|
||||
from flask_babel import gettext
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from app.models import User, Package, PackageState, PackageScreenshot, PackageUpdateConfig, ForumTopic, db, \
|
||||
PackageRelease, Permission, NotificationType, AuditSeverity, UserRank, PackageType
|
||||
from app.tasks.importtasks import make_vcs_release
|
||||
from app.utils import add_notification, add_audit_log
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route("/user/tags/")
|
||||
def tags_user():
|
||||
return redirect(url_for('todo.tags', author=current_user.username))
|
||||
|
||||
|
||||
@bp.route("/user/todo/")
|
||||
@bp.route("/users/<username>/todo/")
|
||||
@login_required
|
||||
def view_user(username=None):
|
||||
if username is None:
|
||||
return redirect(url_for("todo.view_user", username=current_user.username))
|
||||
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.APPROVER):
|
||||
abort(403)
|
||||
|
||||
unapproved_packages = user.packages \
|
||||
.filter(or_(Package.state == PackageState.WIP,
|
||||
Package.state == PackageState.CHANGES_NEEDED)) \
|
||||
.order_by(db.asc(Package.created_at)).all()
|
||||
|
||||
outdated_packages = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED,
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
missing_game_support = user.maintained_packages.filter(
|
||||
Package.state != PackageState.DELETED,
|
||||
Package.type.in_([PackageType.MOD, PackageType.TXP]),
|
||||
~Package.supported_games.any(),
|
||||
Package.supports_all_games == False) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
packages_with_no_screenshots = user.maintained_packages.filter(
|
||||
~Package.screenshots.any(), Package.state == PackageState.APPROVED).all()
|
||||
|
||||
packages_with_small_screenshots = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED,
|
||||
Package.screenshots.any(and_(PackageScreenshot.width < PackageScreenshot.SOFT_MIN_SIZE[0],
|
||||
PackageScreenshot.height < PackageScreenshot.SOFT_MIN_SIZE[1]))) \
|
||||
.all()
|
||||
|
||||
topics_to_add = ForumTopic.query \
|
||||
.filter_by(author_id=user.id) \
|
||||
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
|
||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||
.all()
|
||||
|
||||
needs_tags = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED, ~Package.tags.any()) \
|
||||
.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,
|
||||
missing_game_support=missing_game_support, needs_tags=needs_tags, topics_to_add=topics_to_add,
|
||||
packages_with_no_screenshots=packages_with_no_screenshots,
|
||||
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"])
|
||||
@login_required
|
||||
def apply_all_updates(username):
|
||||
user: User = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR):
|
||||
abort(403)
|
||||
|
||||
outdated_packages = user.maintained_packages \
|
||||
.filter(Package.state != PackageState.DELETED,
|
||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
for package in outdated_packages:
|
||||
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
|
||||
continue
|
||||
|
||||
if package.releases.filter(or_(PackageRelease.task_id.isnot(None),
|
||||
PackageRelease.commit_hash == package.update_config.last_commit)).count() > 0:
|
||||
continue
|
||||
|
||||
title = package.update_config.get_title()
|
||||
ref = package.update_config.get_ref()
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = package
|
||||
rel.title = title
|
||||
rel.url = ""
|
||||
rel.task_id = uuid()
|
||||
db.session.add(rel)
|
||||
db.session.commit()
|
||||
|
||||
make_vcs_release.apply_async((rel.id, ref),
|
||||
task_id=rel.task_id)
|
||||
|
||||
msg = "Created release {} (Applied all Git Update Detection)".format(rel.title)
|
||||
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg,
|
||||
package.get_url("packages.create_edit"), package)
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user, msg, package.get_url("packages.view"), package)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("todo.view_user", username=username))
|
||||
|
||||
|
||||
@bp.route("/user/game_support/")
|
||||
@bp.route("/users/<username>/game_support/")
|
||||
@login_required
|
||||
def all_game_support(username=None):
|
||||
if username is None:
|
||||
return redirect(url_for("todo.all_game_support", username=current_user.username))
|
||||
|
||||
user: User = User.query.filter_by(username=username).one_or_404()
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR):
|
||||
abort(403)
|
||||
|
||||
packages = user.maintained_packages.filter(
|
||||
Package.state != PackageState.DELETED,
|
||||
Package.type.in_([PackageType.MOD, PackageType.TXP])) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
bulk_support_names = db.session.query(Package.title) \
|
||||
.select_from(Package).filter(
|
||||
Package.maintainers.contains(user),
|
||||
Package.state != PackageState.DELETED,
|
||||
Package.type.in_([PackageType.MOD, PackageType.TXP]),
|
||||
~Package.supported_games.any(),
|
||||
Package.supports_all_games == False) \
|
||||
.order_by(db.asc(Package.title)).all()
|
||||
|
||||
bulk_support_names = ", ".join([x[0] for x in bulk_support_names])
|
||||
|
||||
return render_template("todo/game_support.html", user=user, packages=packages, bulk_support_names=bulk_support_names)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/confirm_supports_all_games/", methods=["POST"])
|
||||
@login_required
|
||||
def confirm_supports_all_games(username=None):
|
||||
user: User = User.query.filter_by(username=username).one_or_404()
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR):
|
||||
abort(403)
|
||||
|
||||
packages = user.maintained_packages.filter(
|
||||
Package.state != PackageState.DELETED,
|
||||
Package.type.in_([PackageType.MOD, PackageType.TXP]),
|
||||
~Package.supported_games.any(),
|
||||
Package.supports_all_games == False) \
|
||||
.all()
|
||||
|
||||
for package in packages:
|
||||
package.supports_all_games = True
|
||||
db.session.merge(package)
|
||||
|
||||
add_audit_log(AuditSeverity.NORMAL, current_user, "Enabled 'Supports all games' (bulk)",
|
||||
package.get_url("packages.game_support"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(gettext("Done"), "success")
|
||||
return redirect(url_for("todo.all_game_support", username=current_user.username))
|
||||
@@ -14,22 +14,23 @@
|
||||
# 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 redirect, abort, render_template, flash, request, url_for
|
||||
from flask_babel import gettext, get_locale, lazy_gettext
|
||||
|
||||
from flask import *
|
||||
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_
|
||||
from wtforms import StringField, SubmitField, BooleanField, PasswordField, validators
|
||||
from wtforms.validators import InputRequired, Length, Regexp, DataRequired, Optional, Email, EqualTo
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.models import *
|
||||
from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email
|
||||
from app.utils import random_string, make_flask_login_password, is_safe_url, check_password_hash, add_audit_log, \
|
||||
nonempty_or_none, post_login, is_username_valid
|
||||
from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, \
|
||||
nonEmptyOrNone, post_login, is_username_valid
|
||||
from passlib.pwd import genphrase
|
||||
|
||||
from . import bp
|
||||
from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \
|
||||
UserEmailVerification
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
@@ -46,6 +47,7 @@ def handle_login(form):
|
||||
else:
|
||||
flash(err, "danger")
|
||||
|
||||
|
||||
username = form.username.data.strip()
|
||||
user = User.query.filter(or_(User.username == username, User.email == username)).first()
|
||||
if user is None:
|
||||
@@ -58,8 +60,8 @@ def handle_login(form):
|
||||
flash(gettext("You need to confirm the registration email"), "danger")
|
||||
return
|
||||
|
||||
add_audit_log(AuditSeverity.USER, user, "Logged in using password",
|
||||
url_for("users.profile", username=user.username))
|
||||
addAuditLog(AuditSeverity.USER, user, "Logged in using password",
|
||||
url_for("users.profile", username=user.username))
|
||||
db.session.commit()
|
||||
|
||||
if not login_user(user, remember=form.remember_me.data):
|
||||
@@ -71,11 +73,11 @@ def handle_login(form):
|
||||
|
||||
@bp.route("/user/login/", methods=["GET", "POST"])
|
||||
def login():
|
||||
next = request.args.get("next")
|
||||
if next and not is_safe_url(next):
|
||||
abort(400)
|
||||
|
||||
if current_user.is_authenticated:
|
||||
next = request.args.get("next")
|
||||
if next and not is_safe_url(next):
|
||||
abort(400)
|
||||
|
||||
return redirect(next or url_for("homepage.home"))
|
||||
|
||||
form = LoginForm(request.form)
|
||||
@@ -87,7 +89,8 @@ def login():
|
||||
if request.method == "GET":
|
||||
form.remember_me.data = True
|
||||
|
||||
return render_template("users/login.html", form=form, next=next)
|
||||
|
||||
return render_template("users/login.html", form=form)
|
||||
|
||||
|
||||
@bp.route("/user/logout/", methods=["GET", "POST"])
|
||||
@@ -97,12 +100,12 @@ def logout():
|
||||
|
||||
|
||||
class RegisterForm(FlaskForm):
|
||||
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[nonempty_or_none])
|
||||
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 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(12, 100)])
|
||||
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
|
||||
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()])
|
||||
agree = BooleanField(lazy_gettext("I agree"), [DataRequired()])
|
||||
submit = SubmitField(lazy_gettext("Register"))
|
||||
@@ -154,10 +157,10 @@ def handle_register(form):
|
||||
user.display_name = form.display_name.data
|
||||
db.session.add(user)
|
||||
|
||||
add_audit_log(AuditSeverity.USER, user, "Registered with email, display name=" + user.display_name,
|
||||
url_for("users.profile", username=user.username))
|
||||
addAuditLog(AuditSeverity.USER, user, "Registered with email, display name=" + user.display_name,
|
||||
url_for("users.profile", username=user.username))
|
||||
|
||||
token = random_string(32)
|
||||
token = randomString(32)
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = user
|
||||
@@ -179,14 +182,14 @@ def register():
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
return render_template("users/register.html", form=form)
|
||||
return render_template("users/register.html", form=form,
|
||||
suggested_password=genphrase(entropy=52, wordset="bip39"))
|
||||
|
||||
|
||||
class ForgotPasswordForm(FlaskForm):
|
||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
||||
submit = SubmitField(lazy_gettext("Reset Password"))
|
||||
|
||||
|
||||
@bp.route("/user/forgot-password/", methods=["GET", "POST"])
|
||||
def forgot_password():
|
||||
form = ForgotPasswordForm(request.form)
|
||||
@@ -194,10 +197,10 @@ def forgot_password():
|
||||
email = form.email.data
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if user:
|
||||
token = random_string(32)
|
||||
token = randomString(32)
|
||||
|
||||
add_audit_log(AuditSeverity.USER, user, "(Anonymous) requested a password reset",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
addAuditLog(AuditSeverity.USER, user, "(Anonymous) requested a password reset",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = user
|
||||
@@ -220,16 +223,15 @@ def forgot_password():
|
||||
|
||||
class SetPasswordForm(FlaskForm):
|
||||
email = StringField(lazy_gettext("Email"), [Optional(), Email()])
|
||||
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(12, 100)])
|
||||
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(12, 100),
|
||||
EqualTo('password', message=lazy_gettext('Passwords must match'))])
|
||||
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
|
||||
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100),
|
||||
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
class ChangePasswordForm(FlaskForm):
|
||||
old_password = PasswordField(lazy_gettext("Old password"), [InputRequired(), Length(6, 100)])
|
||||
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(12, 100)])
|
||||
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(12, 100),
|
||||
old_password = PasswordField(lazy_gettext("Old password"), [InputRequired(), Length(8, 100)])
|
||||
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
|
||||
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100),
|
||||
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
@@ -241,13 +243,13 @@ def handle_set_password(form):
|
||||
flash(gettext("Passwords do not match"), "danger")
|
||||
return
|
||||
|
||||
add_audit_log(AuditSeverity.USER, current_user, "Changed their password", url_for("users.profile", username=current_user.username))
|
||||
addAuditLog(AuditSeverity.USER, current_user, "Changed their password", url_for("users.profile", username=current_user.username))
|
||||
|
||||
current_user.password = make_flask_login_password(form.password.data)
|
||||
|
||||
if hasattr(form, "email"):
|
||||
new_email = nonempty_or_none(form.email.data)
|
||||
if new_email and new_email != current_user.email:
|
||||
newEmail = nonEmptyOrNone(form.email.data)
|
||||
if newEmail and newEmail != current_user.email:
|
||||
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
|
||||
flash(gettext(u"That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
|
||||
return
|
||||
@@ -258,12 +260,12 @@ def handle_set_password(form):
|
||||
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:
|
||||
token = random_string(32)
|
||||
token = randomString(32)
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = current_user
|
||||
ver.token = token
|
||||
ver.email = new_email
|
||||
ver.email = newEmail
|
||||
db.session.add(ver)
|
||||
db.session.commit()
|
||||
|
||||
@@ -290,7 +292,8 @@ def change_password():
|
||||
else:
|
||||
flash(gettext("Old password is incorrect"), "danger")
|
||||
|
||||
return render_template("users/change_set_password.html", form=form)
|
||||
return render_template("users/change_set_password.html", form=form,
|
||||
suggested_password=genphrase(entropy=52, wordset="bip39"))
|
||||
|
||||
|
||||
@bp.route("/user/set-password/", methods=["GET", "POST"])
|
||||
@@ -308,7 +311,8 @@ def set_password():
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
return render_template("users/change_set_password.html", form=form)
|
||||
return render_template("users/change_set_password.html", form=form, optional=request.args.get("optional"),
|
||||
suggested_password=genphrase(entropy=52, wordset="bip39"))
|
||||
|
||||
|
||||
@bp.route("/user/verify/")
|
||||
@@ -329,8 +333,8 @@ def verify_email():
|
||||
|
||||
user = ver.user
|
||||
|
||||
add_audit_log(AuditSeverity.USER, user, "Confirmed their email",
|
||||
url_for("users.profile", username=user.username))
|
||||
addAuditLog(AuditSeverity.USER, user, "Confirmed their email",
|
||||
url_for("users.profile", username=user.username))
|
||||
|
||||
was_activating = not user.is_active
|
||||
|
||||
@@ -383,7 +387,7 @@ def unsubscribe_verify():
|
||||
sub = EmailSubscription(email)
|
||||
db.session.add(sub)
|
||||
|
||||
sub.token = random_string(32)
|
||||
sub.token = randomString(32)
|
||||
db.session.commit()
|
||||
send_unsubscribe_verify.delay(form.email.data, get_locale().language)
|
||||
|
||||
|
||||
@@ -13,15 +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/>.
|
||||
|
||||
from flask_babel import gettext
|
||||
|
||||
from . import bp
|
||||
from flask import redirect, render_template, session, request, flash, url_for
|
||||
from app.models import db, User, UserRank
|
||||
from app.utils import random_string, login_user_set_active, is_username_valid
|
||||
from app.tasks.forumtasks import check_forum_account
|
||||
from app.utils.phpbbparser import get_profile
|
||||
from app.utils import randomString, login_user_set_active, is_username_valid
|
||||
from app.tasks.forumtasks import checkForumAccount
|
||||
from app.utils.phpbbparser import getProfile
|
||||
|
||||
|
||||
@bp.route("/user/claim/", methods=["GET", "POST"])
|
||||
@@ -42,7 +41,7 @@ def claim_forums():
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user and user.rank.at_least(UserRank.NEW_MEMBER):
|
||||
if user and user.rank.atLeast(UserRank.NEW_MEMBER):
|
||||
flash(gettext("User has already been claimed"), "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
elif method == "github":
|
||||
@@ -55,27 +54,28 @@ def claim_forums():
|
||||
if "forum_token" in session:
|
||||
token = session["forum_token"]
|
||||
else:
|
||||
token = random_string(12)
|
||||
token = randomString(12)
|
||||
session["forum_token"] = token
|
||||
|
||||
if request.method == "POST":
|
||||
ctype = request.form.get("claim_type")
|
||||
ctype = request.form.get("claim_type")
|
||||
username = request.form.get("username")
|
||||
|
||||
if not is_username_valid(username):
|
||||
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 = check_forum_account.delay(username)
|
||||
task = checkForumAccount.delay(username)
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim_forums", username=username, method="github")))
|
||||
elif ctype == "forum":
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user is not None and user.rank.at_least(UserRank.NEW_MEMBER):
|
||||
if user is not None and user.rank.atLeast(UserRank.NEW_MEMBER):
|
||||
flash(gettext("That user has already been claimed!"), "danger")
|
||||
return redirect(url_for("users.claim_forums"))
|
||||
|
||||
# Get signature
|
||||
sig = None
|
||||
try:
|
||||
profile = get_profile("https://forum.minetest.net", username)
|
||||
profile = getProfile("https://forum.minetest.net", username)
|
||||
sig = profile.signature if profile else None
|
||||
except IOError as e:
|
||||
if hasattr(e, 'message'):
|
||||
|
||||
@@ -15,17 +15,15 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
from typing import Optional, Tuple, List
|
||||
from typing import Optional
|
||||
|
||||
from flask import redirect, url_for, abort, render_template, request
|
||||
from flask import *
|
||||
from flask_babel import gettext
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import func, text
|
||||
|
||||
from app.models import User, db, Package, PackageReview, PackageState, PackageType, UserRank
|
||||
from app.utils import get_daterange_options
|
||||
from app.tasks.forumtasks import check_forum_account
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.models import *
|
||||
from app.tasks.forumtasks import checkForumAccount
|
||||
from . import bp
|
||||
|
||||
|
||||
@@ -218,7 +216,7 @@ def profile(username):
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not current_user.is_authenticated or (user != current_user and not current_user.can_access_todo_list()):
|
||||
if not current_user.is_authenticated or (user != current_user and not current_user.canAccessTodoList()):
|
||||
packages = user.packages.filter_by(state=PackageState.APPROVED)
|
||||
maintained_packages = user.maintained_packages.filter_by(state=PackageState.APPROVED)
|
||||
else:
|
||||
@@ -237,40 +235,20 @@ def profile(username):
|
||||
medals_unlocked=unlocked, medals_locked=locked)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/check-forums/", methods=["POST"])
|
||||
@bp.route("/users/<username>/check/", methods=["POST"])
|
||||
@login_required
|
||||
def user_check_forums(username):
|
||||
def user_check(username):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
if current_user != user and not current_user.rank.at_least(UserRank.MODERATOR):
|
||||
if current_user != user and not current_user.rank.atLeast(UserRank.MODERATOR):
|
||||
abort(403)
|
||||
|
||||
if user.forums_username is None:
|
||||
abort(404)
|
||||
|
||||
task = check_forum_account.delay(user.forums_username, force_replace_pic=True)
|
||||
task = checkForumAccount.delay(user.forums_username)
|
||||
next_url = url_for("users.profile", username=username)
|
||||
|
||||
return redirect(url_for("tasks.check", id=task.id, r=next_url))
|
||||
|
||||
|
||||
@bp.route("/user/stats/")
|
||||
@login_required
|
||||
def statistics_redirect():
|
||||
return redirect(url_for("users.statistics", username=current_user.username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/stats/")
|
||||
def statistics(username):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
downloads = db.session.query(func.sum(Package.downloads)).filter(Package.author==user).one()
|
||||
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
return render_template("users/stats.html", user=user, downloads=downloads[0],
|
||||
start=start, end=end, options=get_daterange_options(), noindex=start or end)
|
||||
|
||||
@@ -1,31 +1,14 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2023 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 redirect, abort, render_template, request, flash, url_for
|
||||
from flask_babel import gettext, get_locale, lazy_gettext
|
||||
from flask import *
|
||||
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_
|
||||
from wtforms import StringField, SubmitField, BooleanField, SelectField
|
||||
from wtforms.validators import Length, Optional, Email, URL
|
||||
from wtforms import *
|
||||
from wtforms.validators import *
|
||||
|
||||
from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \
|
||||
UserEmailVerification, Permission, NotificationType, UserBan
|
||||
from app.models import *
|
||||
from app.utils import nonEmptyOrNone, addAuditLog, randomString, rank_required
|
||||
from app.tasks.emails import send_verify_email
|
||||
from app.utils import nonempty_or_none, add_audit_log, random_string, rank_required, has_blocked_domains
|
||||
from . import bp
|
||||
|
||||
|
||||
@@ -53,14 +36,7 @@ def get_setting_tabs(user):
|
||||
},
|
||||
]
|
||||
|
||||
if user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
ret.append({
|
||||
"id": "oauth_clients",
|
||||
"title": gettext("OAuth2 Applications"),
|
||||
"url": url_for("oauth.list_clients", username=user.username)
|
||||
})
|
||||
|
||||
if current_user.rank.at_least(UserRank.MODERATOR):
|
||||
if current_user.rank.atLeast(UserRank.MODERATOR):
|
||||
ret.append({
|
||||
"id": "modtools",
|
||||
"title": gettext("Moderator Tools"),
|
||||
@@ -71,49 +47,41 @@ def get_setting_tabs(user):
|
||||
|
||||
|
||||
class UserProfileForm(FlaskForm):
|
||||
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[lambda x: nonempty_or_none(x.strip())])
|
||||
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[lambda x: nonEmptyOrNone(x)])
|
||||
website_url = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
donate_url = StringField(lazy_gettext("Donation URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
def handle_profile_edit(form: UserProfileForm, user: User, username: str):
|
||||
def handle_profile_edit(form, user, username):
|
||||
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
||||
add_audit_log(severity, current_user, "Edited {}'s profile".format(user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
|
||||
display_name = form.display_name.data or user.username
|
||||
if user.check_perm(current_user, Permission.CHANGE_DISPLAY_NAME) and \
|
||||
user.display_name != display_name:
|
||||
addAuditLog(severity, current_user, "Edited {}'s profile".format(user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
|
||||
if user.checkPerm(current_user, Permission.CHANGE_DISPLAY_NAME) and \
|
||||
user.display_name != form.display_name.data:
|
||||
if User.query.filter(User.id != user.id,
|
||||
or_(User.username == display_name,
|
||||
User.display_name.ilike(display_name))).count() > 0:
|
||||
or_(User.username == form.display_name.data,
|
||||
User.display_name.ilike(form.display_name.data))).count() > 0:
|
||||
flash(gettext("A user already has that name"), "danger")
|
||||
return None
|
||||
|
||||
|
||||
alias_by_name = PackageAlias.query.filter(or_(
|
||||
PackageAlias.author == display_name)).first()
|
||||
PackageAlias.author == form.display_name.data)).first()
|
||||
if alias_by_name:
|
||||
flash(gettext("A user already has that name"), "danger")
|
||||
return
|
||||
|
||||
user.display_name = display_name
|
||||
user.display_name = form.display_name.data
|
||||
|
||||
severity = AuditSeverity.USER if current_user == user else AuditSeverity.MODERATION
|
||||
add_audit_log(severity, current_user, "Changed display name of {} to {}"
|
||||
.format(user.username, user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
addAuditLog(severity, current_user, "Changed display name of {} to {}"
|
||||
.format(user.username, user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
|
||||
if user.check_perm(current_user, Permission.CHANGE_PROFILE_URLS):
|
||||
if has_blocked_domains(form.website_url.data, current_user.username, f"{user.username}'s website_url") or \
|
||||
has_blocked_domains(form.donate_url.data, current_user.username, f"{user.username}'s donate_url"):
|
||||
flash(gettext("Linking to blocked sites is not allowed"), "danger")
|
||||
return
|
||||
|
||||
user.website_url = form.website_url.data
|
||||
user.donate_url = form.donate_url.data
|
||||
if user.checkPerm(current_user, Permission.CHANGE_PROFILE_URLS):
|
||||
user.website_url = form["website_url"].data
|
||||
user.donate_url = form["donate_url"].data
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@@ -148,7 +116,7 @@ def make_settings_form():
|
||||
}
|
||||
|
||||
for notificationType in NotificationType:
|
||||
key = "pref_" + notificationType.to_name()
|
||||
key = "pref_" + notificationType.toName()
|
||||
attrs[key] = BooleanField("")
|
||||
attrs[key + "_digest"] = BooleanField("")
|
||||
|
||||
@@ -159,27 +127,27 @@ SettingsForm = make_settings_form()
|
||||
|
||||
def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new, form):
|
||||
for notificationType in NotificationType:
|
||||
field_email = getattr(form, "pref_" + notificationType.to_name()).data
|
||||
field_digest = getattr(form, "pref_" + notificationType.to_name() + "_digest").data or field_email
|
||||
field_email = getattr(form, "pref_" + notificationType.toName()).data
|
||||
field_digest = getattr(form, "pref_" + notificationType.toName() + "_digest").data or field_email
|
||||
prefs.set_can_email(notificationType, field_email)
|
||||
prefs.set_can_digest(notificationType, field_digest)
|
||||
|
||||
if is_new:
|
||||
db.session.add(prefs)
|
||||
|
||||
if user.check_perm(current_user, Permission.CHANGE_EMAIL):
|
||||
if user.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
||||
newEmail = form.email.data
|
||||
if newEmail and newEmail != user.email and newEmail.strip() != "":
|
||||
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
|
||||
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
|
||||
return
|
||||
|
||||
token = random_string(32)
|
||||
token = randomString(32)
|
||||
|
||||
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
||||
|
||||
msg = "Changed email of {}".format(user.display_name)
|
||||
add_audit_log(severity, current_user, msg, url_for("users.profile", username=user.username))
|
||||
addAuditLog(severity, current_user, msg, url_for("users.profile", username=user.username))
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = user
|
||||
@@ -206,7 +174,7 @@ def email_notifications(username=None):
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CHANGE_EMAIL):
|
||||
if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
||||
abort(403)
|
||||
|
||||
is_new = False
|
||||
@@ -219,8 +187,8 @@ def email_notifications(username=None):
|
||||
types = []
|
||||
for notificationType in NotificationType:
|
||||
types.append(notificationType)
|
||||
data["pref_" + notificationType.to_name()] = prefs.get_can_email(notificationType)
|
||||
data["pref_" + notificationType.to_name() + "_digest"] = prefs.get_can_digest(notificationType)
|
||||
data["pref_" + notificationType.toName()] = prefs.get_can_email(notificationType)
|
||||
data["pref_" + notificationType.toName() + "_digest"] = prefs.get_can_digest(notificationType)
|
||||
|
||||
data["email"] = user.email
|
||||
|
||||
@@ -252,37 +220,34 @@ def delete(username):
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if user.rank.at_least(UserRank.MODERATOR):
|
||||
if user.rank.atLeast(UserRank.MODERATOR):
|
||||
flash(gettext("Users with moderator rank or above cannot be deleted"), "danger")
|
||||
return redirect(url_for("users.account", username=username))
|
||||
|
||||
if request.method == "GET":
|
||||
return render_template("users/delete.html", user=user, can_delete=user.can_delete())
|
||||
|
||||
if "delete" in request.form and (user.can_delete() or current_user.rank.at_least(UserRank.ADMIN)):
|
||||
if "delete" in request.form and (user.can_delete() or current_user.rank.atLeast(UserRank.ADMIN)):
|
||||
msg = "Deleted user {}".format(user.username)
|
||||
flash(msg, "success")
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None)
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
|
||||
|
||||
if current_user.rank.at_least(UserRank.ADMIN):
|
||||
if current_user.rank.atLeast(UserRank.ADMIN):
|
||||
for pkg in user.packages.all():
|
||||
pkg.review_thread = None
|
||||
db.session.delete(pkg)
|
||||
|
||||
db.session.delete(user)
|
||||
elif "deactivate" in request.form:
|
||||
for reply in user.replies.all():
|
||||
db.session.delete(reply)
|
||||
user.replies.delete()
|
||||
for thread in user.threads.all():
|
||||
db.session.delete(thread)
|
||||
user.email = None
|
||||
|
||||
if user.rank != UserRank.BANNED:
|
||||
user.rank = UserRank.NOT_JOINED
|
||||
user.rank = UserRank.NOT_JOINED
|
||||
|
||||
msg = "Deactivated user {}".format(user.username)
|
||||
flash(msg, "success")
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None)
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
|
||||
else:
|
||||
assert False
|
||||
|
||||
@@ -311,17 +276,17 @@ def modtools(username):
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CHANGE_EMAIL):
|
||||
if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
||||
abort(403)
|
||||
|
||||
form = ModToolsForm(obj=user)
|
||||
if form.validate_on_submit():
|
||||
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
||||
add_audit_log(severity, current_user, "Edited {}'s account".format(user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
addAuditLog(severity, current_user, "Edited {}'s account".format(user.display_name),
|
||||
url_for("users.profile", username=username))
|
||||
|
||||
# Copy form fields to user_profile fields
|
||||
if user.check_perm(current_user, Permission.CHANGE_USERNAMES):
|
||||
if user.checkPerm(current_user, Permission.CHANGE_USERNAMES):
|
||||
if user.username != form.username.data:
|
||||
for package in user.packages:
|
||||
alias = PackageAlias(user.username, package.name)
|
||||
@@ -331,17 +296,17 @@ def modtools(username):
|
||||
user.username = form.username.data
|
||||
|
||||
user.display_name = form.display_name.data
|
||||
user.forums_username = nonempty_or_none(form.forums_username.data)
|
||||
user.github_username = nonempty_or_none(form.github_username.data)
|
||||
user.forums_username = nonEmptyOrNone(form.forums_username.data)
|
||||
user.github_username = nonEmptyOrNone(form.github_username.data)
|
||||
|
||||
if user.check_perm(current_user, Permission.CHANGE_RANK):
|
||||
new_rank = form["rank"].data
|
||||
if current_user.rank.at_least(new_rank):
|
||||
if new_rank != user.rank:
|
||||
if user.checkPerm(current_user, Permission.CHANGE_RANK):
|
||||
newRank = form["rank"].data
|
||||
if current_user.rank.atLeast(newRank):
|
||||
if newRank != user.rank:
|
||||
user.rank = form["rank"].data
|
||||
msg = "Set rank of {} to {}".format(user.display_name, user.rank.get_title())
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, msg,
|
||||
url_for("users.profile", username=username))
|
||||
msg = "Set rank of {} to {}".format(user.display_name, user.rank.getTitle())
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg,
|
||||
url_for("users.profile", username=username))
|
||||
else:
|
||||
flash(gettext("Can't promote a user to a rank higher than yourself!"), "danger")
|
||||
|
||||
@@ -359,15 +324,15 @@ def modtools_set_email(username):
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CHANGE_EMAIL):
|
||||
if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
||||
abort(403)
|
||||
|
||||
user.email = request.form["email"]
|
||||
user.is_active = False
|
||||
|
||||
token = random_string(32)
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Set email and sent a password reset on {user.username}",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
token = randomString(32)
|
||||
addAuditLog(AuditSeverity.MODERATION, current_user, f"Set email and sent a password reset on {user.username}",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
|
||||
ver = UserEmailVerification()
|
||||
ver.user = user
|
||||
@@ -390,7 +355,7 @@ def modtools_ban(username):
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CHANGE_RANK):
|
||||
if not user.checkPerm(current_user, Permission.CHANGE_RANK):
|
||||
abort(403)
|
||||
|
||||
message = request.form["message"]
|
||||
@@ -405,8 +370,8 @@ def modtools_ban(username):
|
||||
else:
|
||||
user.rank = UserRank.BANNED
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Banned {user.username}, expires {user.ban.expires_at or '-'}, message: {message}",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
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")
|
||||
@@ -420,7 +385,7 @@ def modtools_unban(username):
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if not user.check_perm(current_user, Permission.CHANGE_RANK):
|
||||
if not user.checkPerm(current_user, Permission.CHANGE_RANK):
|
||||
abort(403)
|
||||
|
||||
if user.ban:
|
||||
@@ -429,8 +394,8 @@ def modtools_unban(username):
|
||||
if user.rank == UserRank.BANNED:
|
||||
user.rank = UserRank.MEMBER
|
||||
|
||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Unbanned {user.username}",
|
||||
url_for("users.profile", username=user.username), None)
|
||||
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")
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
|
||||
|
||||
from celery import uuid
|
||||
from flask import Blueprint, render_template, redirect, request, abort, url_for
|
||||
from flask_babel import lazy_gettext
|
||||
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
|
||||
@@ -27,19 +26,19 @@ from app.utils import rank_required
|
||||
|
||||
bp = Blueprint("zipgrep", __name__)
|
||||
|
||||
from app.models import UserRank, Package
|
||||
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(1, 100)])
|
||||
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.EDITOR)
|
||||
@rank_required(UserRank.ADMIN)
|
||||
def zipgrep_search():
|
||||
form = SearchForm(request.form)
|
||||
if form.validate_on_submit():
|
||||
|
||||
@@ -1,23 +1,4 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 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 datetime
|
||||
|
||||
from .models import User, UserRank, MinetestRelease, Tag, License, Notification, NotificationType, Package, \
|
||||
PackageState, PackageType, PackageRelease, MetaPackage, Dependency
|
||||
from .models import *
|
||||
from .utils import make_flask_login_password
|
||||
|
||||
|
||||
@@ -44,17 +25,17 @@ def populate(session):
|
||||
|
||||
tags = {}
|
||||
for tag in ["Inventory", "Mapgen", "Building",
|
||||
"Mobs and NPCs", "Tools", "Player effects",
|
||||
"Environment", "Transport", "Maintenance", "Plants and farming",
|
||||
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer"]:
|
||||
"Mobs and NPCs", "Tools", "Player effects",
|
||||
"Environment", "Transport", "Maintenance", "Plants and farming",
|
||||
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer", "Featured"]:
|
||||
row = Tag(tag)
|
||||
tags[row.name] = row
|
||||
session.add(row)
|
||||
|
||||
licenses = {}
|
||||
for license in ["GPLv2.1", "GPLv3", "LGPLv2.1", "LGPLv3", "AGPLv2.1", "AGPLv3",
|
||||
"Apache", "BSD 3-Clause", "BSD 2-Clause", "CC0", "CC-BY-SA",
|
||||
"CC-BY", "MIT", "ZLib", "Other (Free)"]:
|
||||
"Apache", "BSD 3-Clause", "BSD 2-Clause", "CC0", "CC-BY-SA",
|
||||
"CC-BY", "MIT", "ZLib", "Other (Free)"]:
|
||||
row = License(license)
|
||||
licenses[row.name] = row
|
||||
session.add(row)
|
||||
@@ -70,6 +51,7 @@ def populate_test_data(session):
|
||||
tags = { x.name : x for x in Tag.query.all() }
|
||||
admin_user = User.query.filter_by(rank=UserRank.ADMIN).first()
|
||||
v4 = MinetestRelease.query.filter_by(protocol=32).first()
|
||||
v50 = MinetestRelease.query.filter_by(protocol=37).first()
|
||||
v51 = MinetestRelease.query.filter_by(protocol=38).first()
|
||||
|
||||
ez = User("Shara")
|
||||
@@ -86,6 +68,7 @@ def populate_test_data(session):
|
||||
jeija.forums_username = "Jeija"
|
||||
session.add(jeija)
|
||||
|
||||
|
||||
mod = Package()
|
||||
mod.state = PackageState.APPROVED
|
||||
mod.name = "alpha"
|
||||
@@ -378,7 +361,7 @@ Uses the CTF PvP Engine.
|
||||
mod.name = "pixelbox"
|
||||
mod.title = "PixelBOX Reloaded"
|
||||
mod.license = licenses["CC0"]
|
||||
mod.media_license = licenses["CC0"]
|
||||
mod.media_license = licenses["MIT"]
|
||||
mod.type = PackageType.TXP
|
||||
mod.author = admin_user
|
||||
mod.forums = 14132
|
||||
@@ -397,6 +380,7 @@ Uses the CTF PvP Engine.
|
||||
|
||||
metas = {}
|
||||
for package in Package.query.filter_by(type=PackageType.MOD).all():
|
||||
meta = None
|
||||
try:
|
||||
meta = metas[package.name]
|
||||
except KeyError:
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
title: About ContentDB
|
||||
description: Information about ContentDB's development, history, and more
|
||||
toc: False
|
||||
|
||||
## Development
|
||||
|
||||
ContentDB was created by [rubenwardy](https://rubenwardy.com/) in 2018, he was lucky enough to have the time available
|
||||
as it was submitted as university coursework. To learn about the history and development of ContentDB,
|
||||
[see the blog post](https://blog.rubenwardy.com/2022/03/24/contentdb/).
|
||||
|
||||
ContentDB is open source software, licensed under AGPLv3.0.
|
||||
|
||||
<a href="https://github.com/minetest/contentdb/" class="btn btn-primary me-1">Source code</a>
|
||||
<a href="https://github.com/minetest/contentdb/issues/" class="btn btn-secondary me-1">Issue tracker</a>
|
||||
<a href="https://rubenwardy.com/contact/" class="btn btn-secondary me-1">Contact admin</a>
|
||||
<a href="https://monitor.rubenwardy.com/d/3ELzFy3Wz/contentdb" class="btn btn-secondary">Stats / monitoring</a>
|
||||
|
||||
## Why was ContentDB created?
|
||||
|
||||
Before ContentDB, users had to manually install mods and games by unzipping their files into a directory. This is
|
||||
poor user experience, especially for first-time users.
|
||||
|
||||
ContentDB isn't just about supporting the in-game content downloader; it's common for technical users to find
|
||||
and review packages using the ContentDB website, but install using Git rather than the in-game installer.
|
||||
**ContentDB's purpose is to be a well-formatted source of information about mods, games,
|
||||
and texture packs for Minetest**.
|
||||
|
||||
## How do I learn how to make mods and games for Minetest?
|
||||
|
||||
You should read
|
||||
[the official Minetest Modding Book](https://rubenwardy.com/minetest_modding_book/)
|
||||
for a guide to making mods and games using Minetest.
|
||||
|
||||
## How can I support / donate to ContentDB?
|
||||
|
||||
You can donate to rubenwardy to cover ContentDB's costs and support future
|
||||
development.
|
||||
|
||||
<a href="https://rubenwardy.com/donate/" class="btn btn-primary me-1">Donate</a>
|
||||
@@ -2,38 +2,29 @@ title: Help
|
||||
toc: False
|
||||
|
||||
|
||||
## Rules
|
||||
|
||||
* [Rules](/rules/)
|
||||
* [Package Inclusion Policy and Guidance](/policy_and_guidance/)
|
||||
|
||||
## General Help
|
||||
|
||||
* [Frequently Asked Questions](faq/)
|
||||
* [Installing content](installing/)
|
||||
* [Content Ratings and Flags](content_flags/)
|
||||
* [Non-free Licenses](non_free/)
|
||||
* [Why WTFPL is a terrible license](wtfpl/)
|
||||
* [Ranks and Permissions](ranks_permissions/)
|
||||
* [Contact Us](contact_us/)
|
||||
* [Top Packages Algorithm](top_packages/)
|
||||
* [Featured Packages](featured/)
|
||||
* [Frequently Asked Questions](faq)
|
||||
* [Content Ratings and Flags](content_flags)
|
||||
* [Non-free Licenses](non_free)
|
||||
* [Why WTFPL is a terrible license](wtfpl)
|
||||
* [Ranks and Permissions](ranks_permissions)
|
||||
* [Contact Us](contact_us)
|
||||
* [Top Packages Algorithm](top_packages)
|
||||
* [Featured Packages](featured)
|
||||
|
||||
## Help for Package Authors
|
||||
|
||||
* [Package Inclusion Policy and Guidance](/policy_and_guidance/)
|
||||
* [Copyright Guide](copyright/)
|
||||
* [Git Update Detection](update_config/)
|
||||
* [Creating Releases using Webhooks](release_webhooks/)
|
||||
* [Package Configuration and Releases Guide](package_config/)
|
||||
* [Supported Games](game_support/)
|
||||
* [Git Update Detection](update_config)
|
||||
* [Creating Releases using Webhooks](release_webhooks)
|
||||
* [Package Configuration and Releases Guide](package_config)
|
||||
|
||||
## Help for Specific User Ranks
|
||||
|
||||
* [Editors](editors/)
|
||||
* [Editors](editors)
|
||||
|
||||
## APIs
|
||||
|
||||
* [API](api/)
|
||||
* [OAuth2 Applications](oauth/)
|
||||
* [Prometheus Metrics](metrics/)
|
||||
* [API](api)
|
||||
* [Prometheus Metrics](metrics)
|
||||
|
||||
@@ -8,7 +8,7 @@ title: API
|
||||
|
||||
## Responses and Error Handling
|
||||
|
||||
If there is an error, the response will be JSON similar to the following with a non-200 status code:
|
||||
If there is an error, the response will be JSON similar to the following with a non-200 status code:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -26,7 +26,7 @@ often other keys with information. For example:
|
||||
{
|
||||
"success": true,
|
||||
"release": {
|
||||
/* same as returned by a GET */
|
||||
/* same as returned by a GET */
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -39,7 +39,7 @@ the number of items is specified using `num`
|
||||
|
||||
The response will be a dictionary with the following keys:
|
||||
|
||||
* `page`: page number, integer from 1 to max
|
||||
* `page`: page number, integer from 1 to max
|
||||
* `per_page`: number of items per page, same as `n`
|
||||
* `page_count`: number of pages
|
||||
* `total`: total number of results
|
||||
@@ -55,7 +55,7 @@ Not all endpoints require authentication, but it is done using Bearer tokens:
|
||||
|
||||
```bash
|
||||
curl https://content.minetest.net/api/whoami/ \
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
```
|
||||
|
||||
Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
|
||||
@@ -64,13 +64,6 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
|
||||
* `is_authenticated`: True on successful API authentication
|
||||
* `username`: Username of the user authenticated as, null otherwise.
|
||||
* 4xx status codes will be thrown on unsupported authentication type, invalid access token, or other errors.
|
||||
* DELETE `/api/delete-token/`: Deletes the currently used token.
|
||||
|
||||
```bash
|
||||
# Logout
|
||||
curl -X DELETE https://content.minetest.net/api/delete-token/ \
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
```
|
||||
|
||||
|
||||
## Packages
|
||||
@@ -90,65 +83,31 @@ curl -X DELETE https://content.minetest.net/api/delete-token/ \
|
||||
* `tags`: List of [tag](#tags) names.
|
||||
* `content_warnings`: List of [content warning](#content-warnings) names.
|
||||
* `license`: A [license](#licenses) name.
|
||||
* `media_license`: A [license](#licenses) name.
|
||||
* `media_license`: A [license](#licenses) name.
|
||||
* `long_description`: Long markdown description.
|
||||
* `repo`: Git repo URL.
|
||||
* `website`: Website URL.
|
||||
* `issue_tracker`: Issue tracker URL.
|
||||
* `forums`: forum topic ID.
|
||||
* `video_url`: URL to a video.
|
||||
* `donate_url`: URL to a donation page.
|
||||
* `game_support`: Array of game support information objects. Not currently documented, as subject to change.
|
||||
* GET `/api/packages/<author>/<name>/hypertext/`
|
||||
* Converts the long description to [Minetest Markup Language](https://github.com/minetest/minetest/blob/master/doc/lua_api.md#markup-language)
|
||||
to be used in a `hypertext` formspec element.
|
||||
* Query arguments:
|
||||
* `formspec_version`: Required, maximum supported formspec version.
|
||||
* `include_images`: Optional, defaults to true.
|
||||
* Returns JSON dictionary with following key:
|
||||
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
|
||||
* `body`: markup for long description.
|
||||
* `links`: dictionary of anchor name to link URL.
|
||||
* `images`: dictionary of img name to image URL
|
||||
* `image_tooltips`: dictionary of img name to tooltip text.
|
||||
* GET `/api/packages/<username>/<name>/dependencies/`
|
||||
* Returns dependencies, with suggested candidates
|
||||
* Returns dependencies, with suggested candidates
|
||||
* If query argument `only_hard` is present, only hard deps will be returned.
|
||||
* GET `/api/dependencies/`
|
||||
* Returns `provides` and raw dependencies for all packages.
|
||||
* Supports [Package Queries](#package-queries)
|
||||
* [Paginated result](#paginated-results), max 300 results per page
|
||||
* Each item in `items` will be a dictionary with the following keys:
|
||||
* `type`: One of `GAME`, `MOD`, `TXP`.
|
||||
* `type`: One of `GAME`, `MOD`, `TXP`.
|
||||
* `author`: Username of the package author.
|
||||
* `name`: Package name.
|
||||
* `provides`: List of technical mod names inside the package.
|
||||
* `depends`: List of hard dependencies.
|
||||
* Each dep will either be a modname dependency (`name`), or a
|
||||
* Each dep will either be a metapackage dependency (`name`), or a
|
||||
package dependency (`author/name`).
|
||||
* `optional_depends`: list of optional dependencies
|
||||
* Same as above.
|
||||
* GET `/api/packages/<username>/<name>/stats/`
|
||||
* Returns daily stats for package, or null if there is no data.
|
||||
* Daily date is done based on the UTC timezone.
|
||||
* EXPERIMENTAL. This API may change without warning.
|
||||
* Query args:
|
||||
* `start`: start date, inclusive. Optional. Default: 2022-10-01. UTC.
|
||||
* `end`: end date, inclusive. Optional. Default: today. UTC.
|
||||
* An object with the following keys:
|
||||
* `start`: start date, inclusive. Ex: 2022-10-22. M
|
||||
* `end`: end date, inclusive. Ex: 2022-11-05.
|
||||
* `platform_minetest`: list of integers per day.
|
||||
* `platform_other`: list of integers per day.
|
||||
* `reason_new`: list of integers per day.
|
||||
* `reason_dependency`: list of integers per day.
|
||||
* `reason_update`: list of integers per day.
|
||||
* GET `/api/package_stats/`
|
||||
* Returns last 30 days of daily stats for _all_ packages.
|
||||
* An object with the following keys:
|
||||
* `start`: start date, inclusive. Ex: 2022-10-22.
|
||||
* `end`: end date, inclusive. Ex: 2022-11-05.
|
||||
* `package_downloads`: map from package key to list of download integers.
|
||||
|
||||
You can download a package by building one of the two URLs:
|
||||
|
||||
@@ -164,7 +123,7 @@ Examples:
|
||||
curl -X PUT https://content.minetest.net/api/packages/username/name/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
-d '{ "title": "Foo bar", "tags": ["pvp", "survival"], "license": "MIT" }'
|
||||
|
||||
|
||||
# Remove website URL
|
||||
curl -X PUT https://content.minetest.net/api/packages/username/name/ \
|
||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
||||
@@ -183,7 +142,6 @@ Supported query parameters:
|
||||
* `q`: Query string.
|
||||
* `author`: Filter by author.
|
||||
* `tag`: Filter by tags.
|
||||
* `game`: Filter by [Game Support](/help/game_support/), ex: `Wuzzy/mineclone2`. (experimental, doesn't show items that support every game currently).
|
||||
* `random`: When present, enable random ordering and ignore `sort`.
|
||||
* `limit`: Return at most `limit` packages.
|
||||
* `hide`: Hide content based on [Content Flags](/help/content_flags/).
|
||||
@@ -191,14 +149,14 @@ Supported query parameters:
|
||||
* `order`: Sort ascending (`asc`) or descending (`desc`).
|
||||
* `protocol_version`: Only show packages supported by this Minetest protocol version.
|
||||
* `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`.
|
||||
* `fmt`: How the response is formatted.
|
||||
* `fmt`: How the response is formated.
|
||||
* `keys`: author/name only.
|
||||
* `short`: stuff needed for the Minetest client.
|
||||
* `short`: stuff needed for the Minetest client.
|
||||
|
||||
|
||||
### Releases
|
||||
## Releases
|
||||
|
||||
* GET `/api/releases/` (List)
|
||||
* GET `/api/releases/` (List)
|
||||
* Limited to 30 most recent releases.
|
||||
* Optional arguments:
|
||||
* `author`: Filter by author
|
||||
@@ -216,11 +174,6 @@ Supported query parameters:
|
||||
* `author`: author username
|
||||
* `name`: technical name
|
||||
* `type`: `mod`, `game`, or `txp`
|
||||
* GET `/api/updates/` (Look-up table)
|
||||
* Returns a look-up table from package key (`author/name`) to latest release id
|
||||
* Query arguments
|
||||
* `protocol_version`: Only show packages supported by this Minetest protocol version.
|
||||
* `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`.
|
||||
* GET `/api/packages/<username>/<name>/releases/` (List)
|
||||
* Returns array of release dictionaries, see above, but without package info.
|
||||
* GET `/api/packages/<username>/<name>/releases/<id>/` (Read)
|
||||
@@ -231,14 +184,14 @@ Supported query parameters:
|
||||
* For Git release creation:
|
||||
* `method`: must be `git`.
|
||||
* `ref`: (Optional) git reference, eg: `master`.
|
||||
* For zip upload release creation:
|
||||
* For zip upload release creation:
|
||||
* `file`: multipart file to upload, like `<input type="file" name="file">`.
|
||||
* `commit`: (Optional) Source Git commit hash, for informational purposes.
|
||||
* You can set min and max Minetest Versions [using the content's .conf file](/help/package_config/).
|
||||
* DELETE `/api/packages/<username>/<name>/releases/<id>/` (Delete)
|
||||
* Requires authentication.
|
||||
* Deletes release.
|
||||
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
@@ -259,11 +212,11 @@ curl -X POST https://content.minetest.net/api/packages/username/name/releases/ne
|
||||
|
||||
# Delete release
|
||||
curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/3/ \
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
```
|
||||
|
||||
|
||||
### Screenshots
|
||||
## Screenshots
|
||||
|
||||
* GET `/api/packages/<username>/<name>/screenshots/` (List)
|
||||
* Returns array of screenshot dictionaries with keys:
|
||||
@@ -302,7 +255,7 @@ 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" \
|
||||
@@ -310,13 +263,13 @@ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots
|
||||
|
||||
# Delete screenshot
|
||||
curl -X DELETE https://content.minetest.net/api/packages/username/name/screenshots/3/ \
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
|
||||
# Reorder screenshots
|
||||
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" \
|
||||
@@ -324,14 +277,13 @@ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots
|
||||
```
|
||||
|
||||
|
||||
### Reviews
|
||||
## Reviews
|
||||
|
||||
* GET `/api/packages/<username>/<name>/reviews/` (List)
|
||||
* Returns array of review dictionaries with keys:
|
||||
* `user`: dictionary with `display_name` and `username`.
|
||||
* `title`: review title
|
||||
* `title`: review title
|
||||
* `comment`: the text
|
||||
* `rating`: 1 for negative, 3 for neutral, 5 for positive
|
||||
* `is_positive`: boolean
|
||||
* `created_at`: iso timestamp
|
||||
* `votes`: dictionary with `helpful` and `unhelpful`,
|
||||
@@ -340,31 +292,28 @@ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots
|
||||
* [Paginated result](#paginated-results)
|
||||
* `items`: array of review dictionaries, like above
|
||||
* Each review also has a `package` dictionary with `type`, `author` and `name`
|
||||
* Ordered by created at, newest to oldest.
|
||||
* Query arguments:
|
||||
* `page`: page number, integer from 1 to max
|
||||
* `n`: number of results per page, max 200
|
||||
* `n`: number of results per page, max 100
|
||||
* `author`: filter by review author username
|
||||
* `for_user`: filter by package author
|
||||
* `rating`: 1 for negative, 3 for neutral, 5 for positive
|
||||
* `is_positive`: true or false. Default: null
|
||||
* `q`: filter by title (case-insensitive, no fulltext search)
|
||||
* `q`: filter by title (case insensitive, no fulltext search)
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"comment": "This is a really good mod!",
|
||||
"created_at": "2021-11-24T16:18:33.764084",
|
||||
"is_positive": true,
|
||||
"title": "Really good",
|
||||
"comment": "This is a really good mod!",
|
||||
"created_at": "2021-11-24T16:18:33.764084",
|
||||
"is_positive": true,
|
||||
"title": "Really good",
|
||||
"user": {
|
||||
"display_name": "rubenwardy",
|
||||
"display_name": "rubenwardy",
|
||||
"username": "rubenwardy"
|
||||
},
|
||||
},
|
||||
"votes": {
|
||||
"helpful": 0,
|
||||
"helpful": 0,
|
||||
"unhelpful": 0
|
||||
}
|
||||
}
|
||||
@@ -372,39 +321,6 @@ Example:
|
||||
```
|
||||
|
||||
|
||||
## Users
|
||||
|
||||
* GET `/api/users/<username>/`
|
||||
* `username`
|
||||
* `display_name`: human-readable name to be displayed in GUIs.
|
||||
* `rank`: ContentDB [rank](/help/ranks_permissions/).
|
||||
* `profile_pic_url`: URL to profile picture, or null.
|
||||
* `website_url`: URL to website, or null.
|
||||
* `donate_url`: URL to donate page, or null.
|
||||
* `connections`: object
|
||||
* `github`: GitHub username, or null.
|
||||
* `forums`: forums username, or null.
|
||||
* `links`: object
|
||||
* `api_packages`: URL to API to list this user's packages.
|
||||
* `profile`: URL to the HTML profile page.
|
||||
* GET `/api/users/<username>/stats/`
|
||||
* Returns daily stats for the user's packages, or null if there is no data.
|
||||
* Daily date is done based on the UTC timezone.
|
||||
* EXPERIMENTAL. This API may change without warning.
|
||||
* Query args:
|
||||
* `start`: start date, inclusive. Optional. Default: 2022-10-01. UTC.
|
||||
* `end`: end date, inclusive. Optional. Default: today. UTC.
|
||||
* A table with the following keys:
|
||||
* `from`: start date, inclusive. Ex: 2022-10-22.
|
||||
* `end`: end date, inclusive. Ex: 2022-11-05.
|
||||
* `package_downloads`: map of package title to list of integers per day.
|
||||
* `platform_minetest`: list of integers per day.
|
||||
* `platform_other`: list of integers per day.
|
||||
* `reason_new`: list of integers per day.
|
||||
* `reason_dependency`: list of integers per day.
|
||||
* `reason_update`: list of integers per day.
|
||||
|
||||
|
||||
## Topics
|
||||
|
||||
* GET `/api/topics/` ([View](/api/topics/))
|
||||
@@ -425,36 +341,6 @@ Supported query parameters:
|
||||
* `show_discarded`: Show topics marked as discarded.
|
||||
* `limit`: Return at most `limit` topics.
|
||||
|
||||
|
||||
## Collections
|
||||
|
||||
* GET `/api/collections/`
|
||||
* Query args:
|
||||
* `author`: collection author username.
|
||||
* `package`: collections that contain the package.
|
||||
* Returns JSON array of collection entries:
|
||||
* `author`: author username.
|
||||
* `name`: collection name.
|
||||
* `title`
|
||||
* `short_description`
|
||||
* `created_at`: creation time in iso format.
|
||||
* `private`: whether collection is private, boolean.
|
||||
* `package_count`: number of packages, integer.
|
||||
* GET `/api/collections/<username>/<name>/`
|
||||
* Returns JSON object for collection:
|
||||
* `author`: author username.
|
||||
* `name`: collection name.
|
||||
* `title`
|
||||
* `short_description`
|
||||
* `long_description`
|
||||
* `created_at`: creation time in iso format.
|
||||
* `private`: whether collection is private, boolean.
|
||||
* `items`: array of item objects:
|
||||
* `package`: short info about the package.
|
||||
* `description`: custom short description.
|
||||
* `created_at`: when the package was added to the collection.
|
||||
* `order`: integer.
|
||||
|
||||
## Types
|
||||
|
||||
### Tags
|
||||
@@ -463,8 +349,9 @@ Supported query parameters:
|
||||
* `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
|
||||
|
||||
* GET `/api/content_warnings/` ([View](/api/content_warnings/)): List of:
|
||||
@@ -509,20 +396,3 @@ Supported query parameters:
|
||||
* `high_reviewed`: highest reviewed
|
||||
* GET `/api/welcome/v1/` ([View](/api/welcome/v1/)) - in-menu welcome dialog. Experimental (may change without warning)
|
||||
* `featured`: featured games
|
||||
* GET `/api/cdb_schema/` ([View](/api/cdb_schema/))
|
||||
* Get JSON Schema of `.cdb.json`, including licenses, tags and content warnings.
|
||||
* See [JSON Schema Reference](https://json-schema.org/).
|
||||
* POST `/api/hypertext/`
|
||||
* Converts HTML or Markdown to [Minetest Markup Language](https://github.com/minetest/minetest/blob/master/doc/lua_api.md#markup-language)
|
||||
to be used in a `hypertext` formspec element.
|
||||
* Post data: HTML or Markdown as plain text.
|
||||
* Content-Type: `text/html` or `text/markdown`.
|
||||
* Query arguments:
|
||||
* `formspec_version`: Required, maximum supported formspec version. Ie: 6
|
||||
* `include_images`: Optional, defaults to true.
|
||||
* Returns JSON dictionary with following key:
|
||||
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
|
||||
* `body`: markup for long description.
|
||||
* `links`: dictionary of anchor name to link URL.
|
||||
* `images`: dictionary of img name to image URL
|
||||
* `image_tooltips`: dictionary of img name to tooltip text.
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
title: Copyright Guide
|
||||
|
||||
## Why should I care?
|
||||
|
||||
Falling foul of copyright law can put you and ContentDB into legal trouble. Receiving a Cease and Desist, DMCA notice,
|
||||
or a Court Summons isn't pleasant for anyone, and can turn out to be very expensive. This page contains some
|
||||
guidance on how to ensure your content is clearly licensed and attributed to avoid these issues.
|
||||
|
||||
Additionally, ContentDB and the forums both have some
|
||||
[requirements on the licenses](/policy_and_guidance/#41-allowed-licenses) you are allowed to use. Both require
|
||||
[free distribution and modification](/help/non_free/), allowing us to remain an open community where people can fork
|
||||
and remix each other's content. To this end, you need to make sure your content is clearly licensed.
|
||||
|
||||
**As always, we are not lawyers and this does not constitute legal advice.**
|
||||
|
||||
|
||||
## What do I need to do?
|
||||
|
||||
### Follow the licenses
|
||||
|
||||
Make sure you understand the licenses for anything you copy into your content.
|
||||
[TL;DR Legal](https://tldrlegal.com/license/mit-license) is a good resource for quickly understanding
|
||||
licenses, although you should actually read the text as well.
|
||||
|
||||
If you use code from other sources (such as mods or games), you'll need to make sure you follow
|
||||
their license. A common one is attribution, you should do this by adding a comment next to the
|
||||
code and crediting the author in your LICENSE file.
|
||||
|
||||
It's sometimes fine to copy trivial/small amounts of code under fair use, but this
|
||||
is a bit of a grey area. It's better to understand the solution and rewrite it yourself.
|
||||
|
||||
### List the sources of your media
|
||||
|
||||
It's a good idea to create a list of all the media you used in your package, as it allows
|
||||
you to keep track of where the media came from. Media includes textures, 3d models,
|
||||
sounds, and more.
|
||||
|
||||
You should have the following information:
|
||||
|
||||
* File name (as found in your package)
|
||||
* Author name
|
||||
* License
|
||||
* Source (URL to the webpage, mod name, website name)
|
||||
|
||||
It's common to do this in README.md or LICENSE.md like so:
|
||||
|
||||
```md
|
||||
* conquer_arrow_*.png from [Simple Shooter](https://github.com/stujones11/shooter) by Stuart Jones, CC0 1.0.
|
||||
* conquer_arrow.b3d from [Simple Shooter](https://github.com/stujones11/shooter) by Stuart Jones, CC-BY-SA 3.0.
|
||||
* conquer_arrow_head.png from MTG, CC-BY-SA 3.0.
|
||||
* health_*.png from [Gauges](https://content.minetest.net/packages/Calinou/gauges/) by Calinou, CC0.
|
||||
```
|
||||
|
||||
if you have a lot of media, then you can split it up by author like so:
|
||||
|
||||
```md
|
||||
[Kenney](https://www.kenney.nl/assets/voxel-pack), CC0:
|
||||
|
||||
* mymod_fence.png
|
||||
|
||||
John Green, CC BY-SA 4.0 from [OpenGameArt](https://opengameart.org/content/tiny-16-basic):
|
||||
|
||||
* mymod_texture.png
|
||||
* mymod_another.png
|
||||
|
||||
Your Name, CC BY-SA 4.0:
|
||||
|
||||
* mymod_texture_i_made.png
|
||||
```
|
||||
|
||||
|
||||
## Where can I get freely licensed media?
|
||||
|
||||
* [OpenGameArt](https://opengameart.org/) - everything
|
||||
* [Kenney game assets](https://www.kenney.nl/assets) - everything
|
||||
* [Free Sound](https://freesound.org/) - sounds
|
||||
* [PolyHaven](https://polyhaven.com/) - 3d models and textures.
|
||||
* Other Minetest mods/games
|
||||
|
||||
Don't assume the author has correctly licensed their work.
|
||||
Make sure they have clearly indicated the source in a list [like above](#list-the-sources-of-your-media).
|
||||
If they didn't make it, then go to the actual source to check the license.
|
||||
|
||||
|
||||
## Common Situations
|
||||
|
||||
### I made it myself, using X as a guide
|
||||
|
||||
Copying by hand is still copying, the law doesn't distinguish this from copy+paste.
|
||||
Make your own art without copying colors or patterns from existing games/art.
|
||||
|
||||
If you need a good set of colors, see [LOSPEC](https://lospec.com/palette-list).
|
||||
|
||||
### I got it from Google Images / Search / the Internet
|
||||
|
||||
You do not have permission to use things unless you are given permission to do so by the author.
|
||||
No license is exactly the same as "Copyright © All Rights Reserved".
|
||||
To use on ContentDB or the forums, you must also be given a clear license.
|
||||
|
||||
Try searching with "creative commons" in the search term, and then clicking through to the page
|
||||
and looking for a license. Make sure the source looks trustworthy, as there are a lot of websites
|
||||
that rip off art and give an incorrect license. But it might be better to use a trusted source directly, see
|
||||
[the section above](#where-can-i-get-freely-licensed-media) for a list.
|
||||
|
||||
### I have permission from the author
|
||||
|
||||
You'll also need to make sure that the author gives you an explicit license for it, such as CC BY-SA 4.0.
|
||||
Permission for *you* to use it doesn't mean that *everyone* has permission to use it. A license outlines the terms of
|
||||
the permission, making things clearer and less vague.
|
||||
|
||||
### The author said it's free for anyone to use, is that enough?
|
||||
|
||||
No, you need an explicit license like CC0 or CC BY-SA 4.0. ContentDB does not allow custom licenses
|
||||
or public domain.
|
||||
|
||||
### I used an AI
|
||||
|
||||
Errrr. This is a legally untested area, we highly recommend that **you don't use AI art/code** in packages
|
||||
for that reason.
|
||||
|
||||
For now, we haven't banned AI art/code from ContentDB. Make sure to clearly include it in your package's
|
||||
credit list (include the name of the AI tool used).
|
||||
|
||||
Check the tools terms and conditions to see if there are any constraints on use. It looks
|
||||
like AI-generated art and code isn't copyrightable by itself, but the tool's T&Cs may still
|
||||
impose conditions.
|
||||
|
||||
AI art/code may regurgitate copyrighted things. Make sure that you don't include the
|
||||
names of any copyrighted materials in your AI prompts, such as names of games or artists.
|
||||
|
||||
## What does ContentDB do?
|
||||
|
||||
The package authors and maintainers are responsible for the licenses and copyright of packages on ContentDB.
|
||||
ContentDB editors will check packages to make sure the package page's license matches up with the list of licenses
|
||||
inside the package download, but do not investigate each piece of media or line of code.
|
||||
|
||||
If a copyright violation is reported to us, we will unlist the package and contact the author/maintainers.
|
||||
Once the problem has been fixed, the package can be restored.
|
||||
|
||||
|
||||
## Where can I get help?
|
||||
|
||||
[Join](https://www.minetest.net/get-involved/) IRC, Matrix, or Discord to ask for help.
|
||||
In Discord, there are the #assets or #contentdb channels. In IRC or Matrix, you can just ask in the main channels.
|
||||
|
||||
If your package is already on ContentDB, you can open a thread.
|
||||
@@ -15,9 +15,8 @@ Editors should make sure they are familiar with the
|
||||
|
||||
## ContentDB is not a curated platform
|
||||
|
||||
It's important to note that ContentDB isn't a curated platform - a mod doesn't need to be good
|
||||
to be accepted, but there are some minimum requirements when it comes to usefulness and other things.
|
||||
See 2.2 in the [Policy and Guidance](/policy_and_guidance/).
|
||||
It's important to note that ContentDB isn't a curated platform, but it also does have some
|
||||
requirements on minimum usefulness. See 2.2 in the [Policy and Guidance](/policy_and_guidance/).
|
||||
|
||||
## Editor Work Queue
|
||||
|
||||
@@ -33,26 +32,3 @@ The [Editor Work Queue](/todo/) and related pages contain useful information for
|
||||
Editors currently receive notifications for any new thread opened on a package, so that they
|
||||
know when a user is asking for help. These notifications are shown separately in the notifications
|
||||
interface, and can be configured separately in Emails and Notifications.
|
||||
|
||||
## Crash Course to being an Editor
|
||||
|
||||
The [Package Inclusion Policy and Guidance](/policy_and_guidance/) is our go-to resource for making decisions in
|
||||
changes needed, similar to how lua_api.txt is the doc for modders to consult.
|
||||
|
||||
In the [Editor console](/todo/), the two most important tabs are the Editor Work Queue and the Forum
|
||||
Topics tab. Primarily you will be focusing on the Editor Work Queue tab, where a list of packages to review is.
|
||||
|
||||
When you have some free time, feel free to scroll through the Forum Topics page and import mods into ContentDB.
|
||||
But don't import a mod if it's broken, outdated, not that useful, or not worth importing - click Discard instead.
|
||||
|
||||
A simplified process for reviewing a package is as follows:
|
||||
|
||||
1. scan the package image if present for any obvious closed source assets.
|
||||
2. if right to a name warning is present, check its validity and if the package meets
|
||||
the exceptions.
|
||||
3. if the forums topic missing warning is present, feel free to check it, but it's
|
||||
usually incorrect.
|
||||
4. check source, etc links to make sure they work and are correct.
|
||||
5. verify that the package has license file that matches what is on the contentdb fields
|
||||
6. verify that all assets and code are licensed correctly
|
||||
7. if the above steps pass, approve the package, else request changes needed from the author
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
title: Frequently Asked Questions
|
||||
description: FAQ about using ContentDB
|
||||
|
||||
## Users and Logins
|
||||
|
||||
@@ -12,7 +11,7 @@ be done using a GitHub account or a random string in your forum account signatur
|
||||
|
||||
If you don't, then you can just sign up using an email address and password.
|
||||
|
||||
GitHub can only be used to log in, not to register.
|
||||
GitHub can only be used to login, not to register.
|
||||
|
||||
<a class="btn btn-primary" href="/user/claim/">Register</a>
|
||||
|
||||
@@ -22,16 +21,13 @@ GitHub can only be used to log in, not to register.
|
||||
There are a number of reasons this may have happened:
|
||||
|
||||
* Incorrect email address entered.
|
||||
* Temporary problem with ContentDB.
|
||||
* Temporary problem with ContentDB.
|
||||
* Email has been unsubscribed.
|
||||
|
||||
**When creating an account by email:**
|
||||
If the email doesn't arrive after registering by email, then you'll need to
|
||||
try registering again in 12 hours. Unconfirmed accounts are deleted after 12 hours.
|
||||
If the email doesn't arrive after registering by email, then you'll need to try registering again in 12 hours.
|
||||
Unconfirmed accounts are deleted after 12 hours.
|
||||
|
||||
**When changing your email (or it was set after a forum-based registration)**:
|
||||
then you can just set a new email in
|
||||
[Settings > Email and Notifications](/user/settings/email/).
|
||||
If the email verification was sent using the Email settings tab, then you can just set a new email.
|
||||
|
||||
If you have previously unsubscribed this email, then ContentDB is completely prevented from sending emails to that
|
||||
address. You'll need to use a different email address, or [contact rubenwardy](https://rubenwardy.com/contact/) to
|
||||
@@ -44,21 +40,11 @@ remove your email from the blacklist.
|
||||
|
||||
There are a number of methods:
|
||||
|
||||
* [Git Update Detection](/help/update_config/): ContentDB will check your Git repo daily, and create updates or send you notifications.
|
||||
* [Webhooks](/help/release_webhooks/): you can configure your Git host to send a webhook to ContentDB, and create an update immediately.
|
||||
* the [API](/help/api/): This is especially powerful when combined with CI/CD and other API endpoints.
|
||||
|
||||
### How do I learn how to make mods and games for Minetest?
|
||||
|
||||
You should read
|
||||
[the official Minetest Modding Book](https://rubenwardy.com/minetest_modding_book/)
|
||||
for a guide to making mods and games using Minetest.
|
||||
|
||||
### How do I install something from here?
|
||||
|
||||
See [Installing content](/help/installing/).
|
||||
* [Git Update Detection](update_config): ContentDB will check your Git repo daily, and create updates or send you notifications.
|
||||
* [Webhooks](release_webhooks): you can configure your Git host to send a webhook to ContentDB, and create an update immediately.
|
||||
* the [API](api): This is especially powerful when combined with CI/CD and other API endpoints.
|
||||
|
||||
|
||||
## How do I get help?
|
||||
|
||||
Please [contact rubenwardy](https://rubenwardy.com/contact/).
|
||||
Please [contact rubenwardy](https://rubenwardy.com/contact/).
|
||||
@@ -1,56 +0,0 @@
|
||||
title: Supported Games
|
||||
|
||||
<p class="alert alert-warning">
|
||||
This feature is experimental
|
||||
</p>
|
||||
|
||||
## Why?
|
||||
|
||||
The supported/compatible games feature allows mods to specify the games that
|
||||
they work with, which improves user experience.
|
||||
|
||||
|
||||
## Support sources
|
||||
|
||||
### mod.conf / texture_pack.conf
|
||||
|
||||
You can use `supported_games` to specify games that your mod/modpack/texture
|
||||
pack is compatible with.
|
||||
|
||||
You can use `unsupported_games` to specify games that your package doesn't work
|
||||
with, which is useful for overriding ContentDB's automatic detection.
|
||||
|
||||
Both of these are comma-separated lists of game technical ids. Any `_game`
|
||||
suffixes are ignored, just like in Minetest.
|
||||
|
||||
supported_games = minetest_game, repixture
|
||||
unsupported_games = lordofthetest, nodecore, whynot
|
||||
|
||||
If your package supports all games by default, you can put "*" in
|
||||
supported_games. You can still use unsupported_games to mark games as
|
||||
unsupported. You can also specify games that you've tested in supported_games.
|
||||
|
||||
# Should work with all games but I've only tested using Minetest Game:
|
||||
supported_games = *, minetest_game
|
||||
|
||||
# But doesn't work in capturetheflag
|
||||
unsupported_game = capturetheflag
|
||||
|
||||
### Dependencies
|
||||
|
||||
ContentDB will analyse hard dependencies and work out which games a mod
|
||||
supports.
|
||||
|
||||
This uses a recursive algorithm that works out whether a dependency can be
|
||||
installed independently, or if it requires a certain game.
|
||||
|
||||
### On ContentDB
|
||||
|
||||
You can define supported games on ContentDB, but using .conf is recommended
|
||||
instead.
|
||||
|
||||
|
||||
## Combining all the sources
|
||||
|
||||
.conf will override anything ContentDB detects. The manual override on ContentDB
|
||||
overrides .conf and dependencies.
|
||||
@@ -1,89 +0,0 @@
|
||||
title: How to install mods, games, and texture packs
|
||||
description: A guide to installing mods, games, and texture packs in Minetest.
|
||||
|
||||
## Installing from the main menu (recommended)
|
||||
|
||||
### Install
|
||||
|
||||
1. Open the mainmenu
|
||||
2. Go to the Content tab and click "Browse online content".
|
||||
If you don't see this, then you need to update Minetest to v5.
|
||||
3. Search for the package you want to install, and click "Install".
|
||||
4. When installing a mod, you may be shown a dialog about dependencies here.
|
||||
Make sure the base game dropdown box is correct, and then click "Install".
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-6">
|
||||
<figure>
|
||||
<a href="/static/installing_content_tab.png">
|
||||
<img class="w-100" src="/static/installing_content_tab.png" alt="Screenshot of the content tab in minetest">
|
||||
</a>
|
||||
<figcaption class="text-muted ps-1">
|
||||
1. Click Browser Online Content in the content tab.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<figure>
|
||||
<a href="/static/installing_cdb_dialog.png">
|
||||
<img class="w-100" src="/static/installing_cdb_dialog.png" alt="Screenshot of the content tab in minetest">
|
||||
</a>
|
||||
<figcaption class="text-muted ps-1">
|
||||
2. Search for the package and click "Install".
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Troubleshooting:
|
||||
|
||||
* I can't find it in the ContentDB dialog (Browse online content)
|
||||
* Make sure that you're on the latest version of Minetest.
|
||||
* Are you using Android? Packages with content warnings are hidden by default on android,
|
||||
you can show them by removing `android_default` from the `contentdb_flag_blacklist` setting.
|
||||
* Does the webpage show "Non-free" warnings? Non-free content is hidden by default from all clients,
|
||||
you can show them by removing `nonfree` from the `contentdb_flag_blacklist` setting.
|
||||
* It says "required dependencies could not be found"
|
||||
* Make sure you're using the correct "Base Game". A lot of packages only work with certain games, you can look
|
||||
at "Compatible Games" on the web page to see which.
|
||||
|
||||
### Enable in Select Mods
|
||||
|
||||
1. Mods: Enable the content using "Select Mods" when selecting a world.
|
||||
2. Games: choose a game when making a world.
|
||||
3. Texture packs: Content > Select pack > Click enable.
|
||||
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-6">
|
||||
<figure>
|
||||
<a href="/static/installing_select_mods.png">
|
||||
<img class="w-100" src="/static/installing_select_mods.png" alt="Screenshot of Select Mods in Minetest">
|
||||
</a>
|
||||
<figcaption class="text-muted ps-1">
|
||||
Enable mods using the Select Mods dialog.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Installing using the command line
|
||||
|
||||
### Git clone
|
||||
|
||||
1. Install git
|
||||
2. Find the package on ContentDB and copy "source" link.
|
||||
3. Find the user data directory.
|
||||
In 5.4.0 and above, you can click "Open user data directory" in the Credits tab.
|
||||
Otherwise:
|
||||
* Windows: whereever you extracted or installed Minetest to.
|
||||
* Linux: usually `~/.minetest/`
|
||||
4. Open or create the folder for the type of content (`mods`, `games`, or `textures`)
|
||||
5. Git clone there
|
||||
6. For mods, make sure to install any required dependencies.
|
||||
|
||||
### Enable
|
||||
|
||||
* Mods: Edit world.mt in the world's folder to contain `load_file_MODNAME = true`
|
||||
* Games: Use `--game` or edit game_id in world.mt.
|
||||
* Texture packs: change the `texture_path` setting to the texture pack absolute path.
|
||||
@@ -55,7 +55,7 @@ Here's a quick summary related to Minetest content:
|
||||
Non-free packages are hidden in the client by default, partly in order to comply
|
||||
with the rules of various Linux distributions.
|
||||
|
||||
Users can opt in to showing non-free software, if they wish:
|
||||
Users can opt-in to showing non-free software, if they wish:
|
||||
|
||||
1. In the main menu, go to Settings > All settings
|
||||
2. Search for "ContentDB Flag Blacklist".
|
||||
@@ -66,8 +66,8 @@ Users can opt in to showing non-free software, if they wish:
|
||||
<figcaption class="figure-caption">Screenshot of the ContentDB Flag Blacklist setting</figcaption>
|
||||
</figure>
|
||||
|
||||
The [`platform_default` flag](/help/content_flags/) is used to control what content
|
||||
each platforms shows. It doesn't hide anything on Desktop, but hides all mature
|
||||
content on Android. You may wish to remove all text from that setting completely,
|
||||
leaving it blank. See [Content Warnings](/help/content_flags/#content-warnings)
|
||||
for information on mature content.
|
||||
In the future, [the `platform_default` flag](/help/content_flags/) will be used to control what content
|
||||
each platforms shows - Android is significantly stricter about mature content.
|
||||
You may wish to remove all text from that setting completely, leaving it blank,
|
||||
if you wish to view all content when this happens. Currently, [mature content is
|
||||
not permitted on ContentDB](/policy_and_guidance/).
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
title: OAuth2 API
|
||||
|
||||
<p class="alert alert-warning">
|
||||
The OAuth2 applications API is currently experimental, and may break without notice.
|
||||
</p>
|
||||
|
||||
ContentDB allows you to create an OAuth2 Application and obtain access tokens
|
||||
for users.
|
||||
|
||||
|
||||
## Create an OAuth2 Client
|
||||
|
||||
Go to Settings > [OAuth2 Applications](/user/apps/) > Create
|
||||
|
||||
|
||||
## Obtaining access tokens
|
||||
|
||||
ContentDB supports the Authorization Code OAuth2 method.
|
||||
|
||||
### Authorize
|
||||
|
||||
Get the user to open the following URL in a web browser:
|
||||
|
||||
```
|
||||
https://content.minetest.net/oauth/authorize/
|
||||
?response_type=code
|
||||
&client_id={CLIENT_ID}
|
||||
&redirect_uri={REDIRECT_URL}
|
||||
```
|
||||
|
||||
The redirect_url must much the value set in your oauth client. Make sure to URL encode it.
|
||||
ContentDB also supports `state`.
|
||||
|
||||
Afterwards, the user will be redirected to your callback URL.
|
||||
If the user accepts the authorization, you'll receive an authorization code (`code`).
|
||||
Otherwise, the redirect_url will not be modified.
|
||||
|
||||
For example, with `REDIRECT_URL` set as `https://example.com/callback/`:
|
||||
|
||||
* If the user accepts: `https://example.com/callback/?code=abcdef`
|
||||
* If the user cancels: `https://example.com/callback/`
|
||||
|
||||
### Exchange auth code for access token
|
||||
|
||||
Next, you'll need to exchange the auth for an access token.
|
||||
|
||||
Do this by making a POST request to the `/oauth/token/` API:
|
||||
|
||||
```bash
|
||||
curl -X POST https://content.minetest.net/oauth/token/ \
|
||||
-F grant_type=authorization_code \
|
||||
-F client_id="CLIENT_ID" \
|
||||
-F client_secret="CLIENT_SECRET" \
|
||||
-F code="abcdef"
|
||||
```
|
||||
|
||||
<p class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>
|
||||
You should make this request on a server to prevent the user
|
||||
from getting access to your client secret.
|
||||
</p>
|
||||
|
||||
If successful, you'll receive:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "access_token",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
If there's an error, you'll receive a standard API error message:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "The error message"
|
||||
}
|
||||
```
|
||||
|
||||
Possible errors:
|
||||
|
||||
* Unsupported grant_type, only authorization_code is supported
|
||||
* Missing client_id
|
||||
* Missing client_secret
|
||||
* Missing code
|
||||
* client_id and/or client_secret is incorrect
|
||||
* Incorrect code. It may have already been redeemed
|
||||
|
||||
### Check access token
|
||||
|
||||
Next, you should check the access token works by getting the user information:
|
||||
|
||||
```bash
|
||||
curl https://content.minetest.net/api/whoami/ \
|
||||
-H "Authorization: Bearer YOURTOKEN"
|
||||
```
|
||||
|
||||
|
||||
## Scopes
|
||||
|
||||
* (no scope) - public data only
|
||||
* `user:email`: read user email
|
||||
* `package`: write access to packages
|
||||
* `package:release`: create and delete releases
|
||||
* `package:screenshot`: create, edit, delete screenshots
|
||||
@@ -19,26 +19,15 @@ The filename of the `.conf` file depends on the content type:
|
||||
* `game.conf` for games.
|
||||
* `texture_pack.conf` for texture packs.
|
||||
|
||||
The `.conf` uses a key-value format, separated using equals.
|
||||
|
||||
Here's a simple example of `mod.conf`, `modpack.conf`, or `texture_pack.conf`:
|
||||
The `.conf` uses a key-value format, separated using equals. Here's a simple example:
|
||||
|
||||
name = mymod
|
||||
title = My Mod
|
||||
description = A short description to show in the client.
|
||||
|
||||
Here's a simple example of `game.conf`:
|
||||
|
||||
title = My Game
|
||||
description = A short description to show in the client.
|
||||
|
||||
Note that you should not specify `name` in game.conf.
|
||||
|
||||
### Understood values
|
||||
|
||||
ContentDB understands the following information:
|
||||
|
||||
* `title` - A human-readable title.
|
||||
* `description` - A short description to show in the client.
|
||||
* `depends` - Comma-separated hard dependencies.
|
||||
* `optional_depends` - Comma-separated soft dependencies.
|
||||
@@ -48,8 +37,6 @@ ContentDB understands the following information:
|
||||
and for mods only:
|
||||
|
||||
* `name` - the mod technical name.
|
||||
* `supported_games` - List of supported game technical names.
|
||||
* `unsupported_games` - List of not supported game technical names. Useful to override game support detection.
|
||||
|
||||
|
||||
## .cdb.json
|
||||
@@ -75,9 +62,8 @@ It should be a JSON dictionary with one or more of the following optional keys:
|
||||
* `issue_tracker`: Issue tracker URL.
|
||||
* `forums`: forum topic ID.
|
||||
* `video_url`: URL to a video.
|
||||
* `donate_url`: URL to a donation page.
|
||||
|
||||
Use `null` or `[]` to unset fields where relevant.
|
||||
Use `null` to unset fields where relevant.
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ title: Ranks and Permissions
|
||||
|
||||
## Overview
|
||||
|
||||
* **New Members** - mostly untrusted, cannot change package metadata or publish releases without approval.
|
||||
* **Members** - Trusted to change the metadata of their own packages', but cannot approve their own packages.
|
||||
* **New Members** - mostly untrusted, cannot change package meta data or publish releases without approval.
|
||||
* **Members** - Trusted to change the meta data of their own packages', but cannot approve their own packages.
|
||||
* **Trusted Members** - Same as above, but can approve their own releases.
|
||||
* **Approvers** - Responsible for approving new packages, screenshots, and releases.
|
||||
* **Editors** - Same as above, and can edit any package or release.
|
||||
@@ -266,7 +266,7 @@ title: Ranks and Permissions
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Create Token</td>
|
||||
<td>✓</td> <!-- new -->
|
||||
<td></td> <!-- new -->
|
||||
<td></td>
|
||||
<td>✓</td> <!-- member -->
|
||||
<td></td>
|
||||
|
||||
@@ -6,7 +6,7 @@ A webhook is a notification from one service to another. Put simply, a webhook
|
||||
is used to notify ContentDB that the git repository has changed.
|
||||
|
||||
ContentDB offers the ability to automatically create releases using webhooks
|
||||
from either GitHub or GitLab. If you're not using either of those services,
|
||||
from either Github or Gitlab. If you're not using either of those services,
|
||||
you can also use the [API](../api) to create releases.
|
||||
|
||||
ContentDB also offers the ability to poll a Git repo and check for updates
|
||||
|
||||
@@ -6,17 +6,17 @@ toc: False
|
||||
|
||||
Please reconsider the choice of WTFPL as a license.
|
||||
|
||||
<script src="/static/libs/jquery.min.js"></script>
|
||||
<script>
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
var params = new URLSearchParams(location.search);
|
||||
var r = params.get("r");
|
||||
if (r) {
|
||||
var r = params.get("r");
|
||||
if (r)
|
||||
document.write("<a class='alert_right button' href='" + r + "'>Okay</a>");
|
||||
} else {
|
||||
document.getElementById("warning").style.display = "none";
|
||||
}
|
||||
else
|
||||
$("#warning").hide();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ If in doubt at what this means, [contact us by raising a report](/report/).
|
||||
Mature content is permitted providing that it is labelled correctly.
|
||||
See [Content Flags](/help/content_flags/).
|
||||
|
||||
The submission of malware is strictly prohibited. This includes software that
|
||||
does not do as it advertises, for example, if it posts telemetry without stating
|
||||
clearly that it does in the package meta.
|
||||
|
||||
### 2.2. State of Completion
|
||||
|
||||
ContentDB should only currently contain playable content - content which is
|
||||
@@ -87,8 +91,7 @@ reimplementation of the mod that owns the name.
|
||||
### 4.1. Allowed Licenses
|
||||
|
||||
Please ensure that you correctly credit any resources (code, assets, or otherwise)
|
||||
that you have used in your package. For help on doing copyright correctly, see
|
||||
the [Copyright help page](/help/copyright/).
|
||||
that you have used in your package.
|
||||
|
||||
**The use of licenses that do not allow derivatives or redistribution is not
|
||||
permitted. This includes CC-ND (No-Derivatives) and lots of closed source licenses.
|
||||
@@ -98,8 +101,7 @@ of the content on servers or singleplayer is also not permitted.**
|
||||
However, closed sourced licenses are allowed if they allow the above.
|
||||
|
||||
If the license you use is not on the list then please select "Other", and we'll
|
||||
get around to adding it. We tend to reject custom/untested licenses, and
|
||||
reserve the right to decide whether a license should be included.
|
||||
get around to adding it.
|
||||
|
||||
Please note that the definitions of "free" and "non-free" is the same as that
|
||||
of the [Free Software Foundation](https://www.gnu.org/philosophy/free-sw.en.html).
|
||||
@@ -127,7 +129,7 @@ Public domain is not a valid license in many countries, please use CC0 or MIT in
|
||||
|
||||
## 5. Promotions and Advertisements (inc. asking for donations)
|
||||
|
||||
You may not place any promotions or advertisements in any metadata including
|
||||
You may not place any promotions or advertisements in any meta data including
|
||||
screenshots. This includes asking for donations, promoting online shops,
|
||||
or linking to personal websites and social media. Please instead use the
|
||||
fields provided on your user profile page to place links to websites and
|
||||
@@ -187,16 +189,6 @@ Doing so may result in temporary or permanent suspension from ContentDB.
|
||||
6. **Screenshots should be of reasonable dimensions.** We recommend using 1920x1080.
|
||||
|
||||
|
||||
## 8. Security
|
||||
|
||||
The submission of malware is strictly prohibited. This includes software that
|
||||
does not do as it advertises, for example, if it posts telemetry without stating
|
||||
clearly that it does in the package meta.
|
||||
|
||||
Packages must not ask that users disable mod security (`secure.enable_security`).
|
||||
Instead, they should use the insecure environment API.
|
||||
|
||||
|
||||
## 9. Reporting Violations
|
||||
## 8. Reporting Violations
|
||||
|
||||
Please click "Report" on the package page.
|
||||
|
||||
@@ -79,7 +79,7 @@ requested. See below.
|
||||
|
||||
## Removal Requests
|
||||
|
||||
Please [raise a report](/report/?anon=0) if you
|
||||
Please [raise a report](https://content.minetest.net/report/?anon=0) if you
|
||||
wish to remove your personal information.
|
||||
|
||||
ContentDB keeps a record of each username and forum topic on the forums,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
title: Rules
|
||||
|
||||
The following are the rules for user behaviour on ContentDB, including reviews,
|
||||
threads, comments, and profiles. For packages, see the
|
||||
[Package Inclusion Policy](/policy_and_guidance/).
|
||||
|
||||
1. **Be respectful:** attacks towards any person or group, slurs,
|
||||
trolling/baiting, and other toxic behavior are not welcome.
|
||||
2. **Assume good faith:** communication over the Internet is hard, try to assume
|
||||
good faith when eg: responding to reviews.
|
||||
3. **No sexual content** and ensure you keep discussion appropriate given the
|
||||
package's [content warnings](/help/content_flags/).
|
||||
|
||||
You can report things by clicking [report](/report/) in the footer of pages you
|
||||
want to report.
|
||||
@@ -16,12 +16,11 @@
|
||||
|
||||
|
||||
import sys
|
||||
from typing import List, Dict
|
||||
|
||||
import sqlalchemy.orm
|
||||
from typing import List, Dict, Optional, Iterator, Iterable
|
||||
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.models import Package, MetaPackage, PackageType, PackageState, PackageGameSupport
|
||||
from app.models import Package, MetaPackage, PackageType, PackageState, PackageGameSupport, db
|
||||
|
||||
"""
|
||||
get_game_support(package):
|
||||
@@ -34,7 +33,7 @@ get_game_support(package):
|
||||
return support
|
||||
|
||||
get_meta_package_support(meta):
|
||||
for package implementing mod name:
|
||||
for package implementing meta package:
|
||||
support = support OR get_game_support(package)
|
||||
|
||||
return support
|
||||
@@ -50,35 +49,58 @@ minetest_game_mods = {
|
||||
|
||||
|
||||
mtg_mod_blacklist = {
|
||||
"pacman", "tutorial", "runorfall", "realtest_mt5", "mevo", "xaenvironment",
|
||||
"survivethedays", "holidayhorrors",
|
||||
"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:
|
||||
session: sqlalchemy.orm.Session
|
||||
checked_packages = set()
|
||||
checked_modnames = set()
|
||||
resolved_packages: Dict[int, set[int]] = {}
|
||||
resolved_modnames: Dict[int, set[int]] = {}
|
||||
checked_metapackages = set()
|
||||
resolved_packages: Dict[str, PackageSet] = {}
|
||||
resolved_metapackages: Dict[str, PackageSet] = {}
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def resolve_for_meta_package(self, meta: MetaPackage, history: List[str]) -> set[int]:
|
||||
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_modnames:
|
||||
return self.resolved_modnames.get(key)
|
||||
if key in self.resolved_metapackages:
|
||||
return self.resolved_metapackages.get(key)
|
||||
|
||||
if key in self.checked_modnames:
|
||||
if key in self.checked_metapackages:
|
||||
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
|
||||
return set()
|
||||
return PackageSet()
|
||||
|
||||
self.checked_modnames.add(key)
|
||||
self.checked_metapackages.add(key)
|
||||
|
||||
retval = set()
|
||||
retval = PackageSet()
|
||||
|
||||
for package in meta.packages:
|
||||
if package.state != PackageState.APPROVED:
|
||||
@@ -89,37 +111,39 @@ class GameSupportResolver:
|
||||
|
||||
ret = self.resolve(package, history)
|
||||
if len(ret) == 0:
|
||||
retval = set()
|
||||
retval = PackageSet()
|
||||
break
|
||||
|
||||
retval.update(ret)
|
||||
|
||||
self.resolved_modnames[key] = retval
|
||||
self.resolved_metapackages[key] = retval
|
||||
return retval
|
||||
|
||||
def resolve(self, package: Package, history: List[str]) -> set[int]:
|
||||
key: int = package.id
|
||||
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(package.get_id())
|
||||
history.append(key)
|
||||
|
||||
if package.type == PackageType.GAME:
|
||||
return {package.id}
|
||||
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 set()
|
||||
return PackageSet()
|
||||
|
||||
self.checked_packages.add(key)
|
||||
|
||||
if package.type != PackageType.MOD:
|
||||
raise LogicError(500, "Got non-mod")
|
||||
|
||||
retval = set()
|
||||
retval = PackageSet()
|
||||
|
||||
for dep in package.dependencies.filter_by(optional=False).all():
|
||||
ret = self.resolve_for_meta_package(dep.meta_package, history)
|
||||
@@ -135,43 +159,30 @@ class GameSupportResolver:
|
||||
self.resolved_packages[key] = retval
|
||||
return retval
|
||||
|
||||
def init_all(self) -> None:
|
||||
for package in self.session.query(Package).filter(Package.type == PackageType.MOD, Package.state != PackageState.DELETED).all():
|
||||
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_id in retval:
|
||||
game = self.session.query(Package).get(game_id)
|
||||
support = PackageGameSupport(package, game, 1, True)
|
||||
self.session.add(support)
|
||||
|
||||
"""
|
||||
Update game supported package on a package, given the confidence.
|
||||
|
||||
Higher confidences outweigh lower ones.
|
||||
"""
|
||||
def set_supported(self, package: Package, game_is_supported: Dict[int, bool], confidence: int):
|
||||
previous_supported: Dict[int, PackageGameSupport] = {}
|
||||
for support in package.supported_games.all():
|
||||
previous_supported[support.game.id] = support
|
||||
|
||||
for game_id, supports in game_is_supported.items():
|
||||
game = self.session.query(Package).get(game_id)
|
||||
lookup = previous_supported.pop(game_id, None)
|
||||
if lookup is None:
|
||||
support = PackageGameSupport(package, game, confidence, supports)
|
||||
self.session.add(support)
|
||||
elif lookup.confidence <= confidence:
|
||||
lookup.supports = supports
|
||||
lookup.confidence = confidence
|
||||
|
||||
for game, support in previous_supported.items():
|
||||
if support.confidence == confidence:
|
||||
self.session.delete(support)
|
||||
for game in retval:
|
||||
support = PackageGameSupport(package, game)
|
||||
db.session.add(support)
|
||||
|
||||
def update(self, package: Package) -> None:
|
||||
game_is_supported = {}
|
||||
if package.enable_game_support_detection:
|
||||
retval = self.resolve(package, [])
|
||||
for game_id in retval:
|
||||
game_is_supported[game_id] = True
|
||||
previous_supported: Dict[str, PackageGameSupport] = {}
|
||||
for support in package.supported_games.all():
|
||||
previous_supported[support.game.getId()] = support
|
||||
|
||||
self.set_supported(package, game_is_supported, 1)
|
||||
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)
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 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 datetime
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.models import User, Package, PackageDailyStats, db, PackageState
|
||||
from sqlalchemy import func
|
||||
|
||||
|
||||
def daterange(start_date, end_date):
|
||||
for n in range(int((end_date - start_date).days) + 1):
|
||||
yield start_date + timedelta(n)
|
||||
|
||||
|
||||
keys = ["platform_minetest", "platform_other", "reason_new",
|
||||
"reason_dependency", "reason_update"]
|
||||
|
||||
|
||||
def flatten_data(stats):
|
||||
start_date = stats[0].date
|
||||
end_date = stats[-1].date
|
||||
result = {
|
||||
"start": start_date.isoformat(),
|
||||
"end": end_date.isoformat(),
|
||||
}
|
||||
|
||||
for key in keys:
|
||||
result[key] = []
|
||||
|
||||
i = 0
|
||||
for date in daterange(start_date, end_date):
|
||||
stat = stats[i]
|
||||
if stat.date == date:
|
||||
for key in keys:
|
||||
result[key].append(getattr(stat, key))
|
||||
|
||||
i += 1
|
||||
else:
|
||||
for key in keys:
|
||||
result[key].append(0)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_package_stats(package: Package, start_date: Optional[datetime.date], end_date: Optional[datetime.date]):
|
||||
query = package.daily_stats.order_by(db.asc(PackageDailyStats.date))
|
||||
if start_date:
|
||||
query = query.filter(PackageDailyStats.date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(PackageDailyStats.date <= end_date)
|
||||
|
||||
stats = query.all()
|
||||
if len(stats) == 0:
|
||||
return None
|
||||
|
||||
return flatten_data(stats)
|
||||
|
||||
|
||||
def get_package_stats_for_user(user: User, start_date: Optional[datetime.date], end_date: Optional[datetime.date]):
|
||||
query = db.session \
|
||||
.query(PackageDailyStats.date,
|
||||
func.sum(PackageDailyStats.platform_minetest).label("platform_minetest"),
|
||||
func.sum(PackageDailyStats.platform_other).label("platform_other"),
|
||||
func.sum(PackageDailyStats.reason_new).label("reason_new"),
|
||||
func.sum(PackageDailyStats.reason_dependency).label("reason_dependency"),
|
||||
func.sum(PackageDailyStats.reason_update).label("reason_update")) \
|
||||
.filter(PackageDailyStats.package.has(author_id=user.id))
|
||||
|
||||
if start_date:
|
||||
query = query.filter(PackageDailyStats.date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(PackageDailyStats.date <= end_date)
|
||||
|
||||
stats = query.order_by(db.asc(PackageDailyStats.date)) \
|
||||
.group_by(PackageDailyStats.date) \
|
||||
.all()
|
||||
if len(stats) == 0:
|
||||
return None
|
||||
|
||||
results = flatten_data(stats)
|
||||
results["package_downloads"] = get_package_overview_for_user(user, stats[0].date, stats[-1].date)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_package_overview_for_user(user: Optional[User], start_date: datetime.date, end_date: datetime.date):
|
||||
query = db.session \
|
||||
.query(PackageDailyStats.package_id, PackageDailyStats.date,
|
||||
(PackageDailyStats.platform_minetest + PackageDailyStats.platform_other).label("downloads"))
|
||||
|
||||
if user:
|
||||
query = query.filter(PackageDailyStats.package.has(author_id=user.id))
|
||||
|
||||
all_stats = query \
|
||||
.filter(PackageDailyStats.package.has(state=PackageState.APPROVED),
|
||||
PackageDailyStats.date >= start_date, PackageDailyStats.date <= end_date) \
|
||||
.order_by(db.asc(PackageDailyStats.package_id), db.asc(PackageDailyStats.date)) \
|
||||
.all()
|
||||
|
||||
stats_by_package = {}
|
||||
for stat in all_stats:
|
||||
bucket = stats_by_package.get(stat.package_id, [])
|
||||
stats_by_package[stat.package_id] = bucket
|
||||
|
||||
bucket.append(stat)
|
||||
|
||||
package_title_by_id = {}
|
||||
pkg_query = user.packages if user else Package.query
|
||||
for package in pkg_query.filter_by(state=PackageState.APPROVED).all():
|
||||
if user:
|
||||
package_title_by_id[package.id] = package.title
|
||||
else:
|
||||
package_title_by_id[package.id] = package.get_id()
|
||||
|
||||
result = {}
|
||||
|
||||
for package_id, stats in stats_by_package.items():
|
||||
i = 0
|
||||
row = []
|
||||
result[package_title_by_id[package_id]] = row
|
||||
for date in daterange(start_date, end_date):
|
||||
if i >= len(stats):
|
||||
row.append(0)
|
||||
continue
|
||||
|
||||
stat = stats[i]
|
||||
if stat.date == date:
|
||||
row.append(stat.downloads)
|
||||
i += 1
|
||||
elif stat.date > date:
|
||||
row.append(0)
|
||||
else:
|
||||
raise Exception(f"Invalid logic, expected stat {stat.date} to be later than {date}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_all_package_stats(start_date: Optional[datetime.date] = None, end_date: Optional[datetime.date] = None):
|
||||
now_date = datetime.datetime.utcnow().date()
|
||||
if end_date is None or end_date > now_date:
|
||||
end_date = now_date
|
||||
|
||||
min_start_date = (datetime.datetime.utcnow() - datetime.timedelta(days=29)).date()
|
||||
if start_date is None or start_date < min_start_date:
|
||||
start_date = min_start_date
|
||||
|
||||
return {
|
||||
"start": start_date.isoformat(),
|
||||
"end": end_date.isoformat(),
|
||||
"package_downloads": get_package_overview_for_user(None, start_date, end_date),
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 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 collections import namedtuple
|
||||
from typing import List
|
||||
|
||||
from flask_babel import lazy_gettext
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
from app.models import Package, PackageType, PackageState, PackageRelease
|
||||
|
||||
|
||||
ValidationError = namedtuple("ValidationError", "status message")
|
||||
|
||||
|
||||
def validate_package_for_approval(package: Package) -> List[ValidationError]:
|
||||
retval: List[ValidationError] = []
|
||||
|
||||
normalised_name = package.getNormalisedName()
|
||||
if package.type != PackageType.MOD and Package.query.filter(
|
||||
and_(Package.state == PackageState.APPROVED,
|
||||
or_(Package.name == normalised_name,
|
||||
Package.name == normalised_name + "_game"))).count() > 0:
|
||||
retval.append(("danger", lazy_gettext("A package already exists with this name. Please see Policy and Guidance 3")))
|
||||
|
||||
if package.releases.filter(PackageRelease.task_id == None).count() == 0:
|
||||
retval.append(("danger", lazy_gettext("A release is required before this package can be approved.")))
|
||||
# Don't bother validating any more until we have a release
|
||||
return retval
|
||||
|
||||
missing_deps = package.get_missing_hard_dependencies_query().all()
|
||||
if len(missing_deps) > 0:
|
||||
retval.append(("danger", lazy_gettext(
|
||||
"The following hard dependencies need to be added to ContentDB first: %(deps)s", deps=missing_deps)))
|
||||
|
||||
if (package.type == package.type.GAME or package.type == package.type.TXP) and \
|
||||
package.screenshots.count() == 0:
|
||||
retval.append(("danger", lazy_gettext("You need to add at least one screenshot.")))
|
||||
|
||||
if "Other" in package.license.name or "Other" in package.media_license.name:
|
||||
retval.append(("info", lazy_gettext("Please wait for the license to be added to CDB.")))
|
||||
|
||||
return retval
|
||||
@@ -14,21 +14,19 @@
|
||||
# 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 re
|
||||
import typing
|
||||
|
||||
import re
|
||||
import validators
|
||||
from flask_babel import lazy_gettext, LazyString
|
||||
from flask_babel import lazy_gettext
|
||||
|
||||
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 add_audit_log, has_blocked_domains, diff_dictionaries, describe_difference
|
||||
from app.utils import addAuditLog
|
||||
from app.utils.url import clean_youtube_url
|
||||
|
||||
|
||||
def check(cond: bool, msg: typing.Union[str, LazyString]):
|
||||
def check(cond: bool, msg: str):
|
||||
if not cond:
|
||||
raise LogicError(400, msg)
|
||||
|
||||
@@ -65,7 +63,6 @@ ALLOWED_FIELDS = {
|
||||
"issueTracker": str,
|
||||
"forums": int,
|
||||
"video_url": str,
|
||||
"donate_url": str,
|
||||
}
|
||||
|
||||
ALIASES = {
|
||||
@@ -108,28 +105,19 @@ 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.check_perm(user, Permission.EDIT_PACKAGE):
|
||||
if not package.checkPerm(user, Permission.EDIT_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.check_perm(user, Permission.CHANGE_NAME):
|
||||
not package.checkPerm(user, Permission.CHANGE_NAME):
|
||||
raise LogicError(403, lazy_gettext("You don't have permission to change the package name"))
|
||||
|
||||
before_dict = None
|
||||
if not was_new:
|
||||
before_dict = package.as_dict("/")
|
||||
|
||||
for alias, to in ALIASES.items():
|
||||
if alias in data:
|
||||
data[to] = data[alias]
|
||||
|
||||
validate(data)
|
||||
|
||||
for field in ["short_desc", "desc", "website", "issueTracker", "repo", "video_url", "donate_url"]:
|
||||
if field in data and has_blocked_domains(data[field], user.username,
|
||||
f"{field} of {package.get_id()}"):
|
||||
raise LogicError(403, lazy_gettext("Linking to blocked sites is not allowed"))
|
||||
|
||||
if "type" in data:
|
||||
data["type"] = PackageType.coerce(data["type"])
|
||||
|
||||
@@ -148,7 +136,7 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
||||
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", "video_url", "donate_url"]:
|
||||
"repo", "website", "issueTracker", "forums", "video_url"]:
|
||||
if key in data:
|
||||
setattr(package, key, data[key])
|
||||
|
||||
@@ -162,7 +150,7 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
||||
if "tags" in data:
|
||||
old_tags = list(package.tags)
|
||||
package.tags.clear()
|
||||
for tag_id in (data["tags"] or []):
|
||||
for tag_id in data["tags"]:
|
||||
if is_int(tag_id):
|
||||
tag = Tag.query.get(tag_id)
|
||||
else:
|
||||
@@ -170,11 +158,22 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
||||
if tag is None:
|
||||
raise LogicError(400, "Unknown tag: " + tag_id)
|
||||
|
||||
if not was_web and tag.is_protected:
|
||||
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))
|
||||
|
||||
package.tags.append(tag)
|
||||
|
||||
if not was_web:
|
||||
for tag in old_tags:
|
||||
if tag.is_protected:
|
||||
package.tags.append(tag)
|
||||
|
||||
if "content_warnings" in data:
|
||||
package.content_warnings.clear()
|
||||
for warning_id in (data["content_warnings"] or []):
|
||||
for warning_id in data["content_warnings"]:
|
||||
if is_int(warning_id):
|
||||
package.content_warnings.append(ContentWarning.query.get(warning_id))
|
||||
else:
|
||||
@@ -184,20 +183,13 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
||||
package.content_warnings.append(warning)
|
||||
|
||||
if not was_new:
|
||||
after_dict = package.as_dict("/")
|
||||
diff = diff_dictionaries(before_dict, after_dict)
|
||||
|
||||
if reason is None:
|
||||
msg = "Edited {}".format(package.title)
|
||||
else:
|
||||
msg = "Edited {} ({})".format(package.title, reason)
|
||||
|
||||
diff_desc = describe_difference(diff, 100 - len(msg) - 3) if diff else None
|
||||
if diff_desc:
|
||||
msg += " [" + diff_desc + "]"
|
||||
|
||||
severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR
|
||||
add_audit_log(severity, user, msg, package.get_url("packages.view"), package, json.dumps(diff, indent=4))
|
||||
addAuditLog(severity, user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
# 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
|
||||
import re
|
||||
|
||||
import datetime, re
|
||||
|
||||
from celery import uuid
|
||||
from flask_babel import lazy_gettext
|
||||
@@ -23,12 +23,12 @@ from flask_babel import lazy_gettext
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.logic.uploads import upload_file
|
||||
from app.models import PackageRelease, db, Permission, User, Package, MinetestRelease
|
||||
from app.tasks.importtasks import make_vcs_release, check_zip_release
|
||||
from app.utils import AuditSeverity, add_audit_log, nonempty_or_none
|
||||
from app.tasks.importtasks import makeVCSRelease, checkZipRelease
|
||||
from app.utils import AuditSeverity, addAuditLog, nonEmptyOrNone
|
||||
|
||||
|
||||
def check_can_create_release(user: User, package: Package):
|
||||
if not package.check_perm(user, Permission.MAKE_RELEASE):
|
||||
if not package.checkPerm(user, Permission.MAKE_RELEASE):
|
||||
raise LogicError(403, lazy_gettext("You don't have permission to make releases"))
|
||||
|
||||
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
|
||||
@@ -54,11 +54,11 @@ def do_create_vcs_release(user: User, package: Package, title: str, ref: str,
|
||||
msg = "Created release {}".format(rel.title)
|
||||
else:
|
||||
msg = "Created release {} ({})".format(rel.title, reason)
|
||||
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
make_vcs_release.apply_async((rel.id, nonempty_or_none(ref)), task_id=rel.task_id)
|
||||
makeVCSRelease.apply_async((rel.id, nonEmptyOrNone(ref)), task_id=rel.task_id)
|
||||
|
||||
return rel
|
||||
|
||||
@@ -89,10 +89,10 @@ def do_create_zip_release(user: User, package: Package, title: str, file,
|
||||
msg = "Created release {}".format(rel.title)
|
||||
else:
|
||||
msg = "Created release {} ({})".format(rel.title, reason)
|
||||
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
check_zip_release.apply_async((rel.id, uploaded_path), task_id=rel.task_id)
|
||||
checkZipRelease.apply_async((rel.id, uploaded_path), task_id=rel.task_id)
|
||||
|
||||
return rel
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
from app.models import APIToken
|
||||
|
||||
|
||||
class Scope:
|
||||
def copy_to_token(self, token: APIToken):
|
||||
pass
|
||||
@@ -1,19 +1,3 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 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 datetime, json
|
||||
|
||||
from flask_babel import lazy_gettext
|
||||
@@ -21,7 +5,7 @@ from flask_babel import lazy_gettext
|
||||
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 add_notification, add_audit_log
|
||||
from app.utils import addNotification, addAuditLog
|
||||
from app.utils.image import get_image_size
|
||||
|
||||
|
||||
@@ -42,7 +26,7 @@ def do_create_screenshot(user: User, package: Package, title: str, file, is_cove
|
||||
ss.package = package
|
||||
ss.title = title or "Untitled"
|
||||
ss.url = uploaded_url
|
||||
ss.approved = package.check_perm(user, Permission.APPROVE_SCREENSHOT)
|
||||
ss.approved = package.checkPerm(user, Permission.APPROVE_SCREENSHOT)
|
||||
ss.order = counter
|
||||
ss.width, ss.height = get_image_size(uploaded_path)
|
||||
|
||||
@@ -58,8 +42,8 @@ def do_create_screenshot(user: User, package: Package, title: str, file, is_cove
|
||||
else:
|
||||
msg = "Created screenshot {} ({})".format(ss.title, reason)
|
||||
|
||||
add_notification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.get_url("packages.view"), package)
|
||||
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package)
|
||||
addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.getURL("packages.view"), package)
|
||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@@ -80,9 +64,9 @@ def do_order_screenshots(_user: User, package: Package, order: [any]):
|
||||
try:
|
||||
lookup[int(ss_id)].order = counter
|
||||
counter += 1
|
||||
except KeyError:
|
||||
except KeyError as e:
|
||||
raise LogicError(400, "Unable to find screenshot with id={}".format(ss_id))
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError) as e:
|
||||
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(ss_id)))
|
||||
|
||||
db.session.commit()
|
||||
@@ -91,7 +75,7 @@ def do_order_screenshots(_user: User, package: Package, order: [any]):
|
||||
def do_set_cover_image(_user: User, package: Package, cover_image):
|
||||
try:
|
||||
cover_image = int(cover_image)
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError) as e:
|
||||
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(cover_image)))
|
||||
|
||||
for screenshot in package.screenshots.all():
|
||||
|
||||
@@ -14,52 +14,49 @@
|
||||
# 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 imghdr
|
||||
import os
|
||||
|
||||
from flask_babel import lazy_gettext
|
||||
|
||||
from app import app
|
||||
from app.logic.LogicError import LogicError
|
||||
from app.utils import random_string
|
||||
from app.models import *
|
||||
from app.utils import randomString
|
||||
|
||||
|
||||
def get_extension(filename):
|
||||
return filename.rsplit(".", 1)[1].lower() if "." in filename else None
|
||||
|
||||
|
||||
ALLOWED_IMAGES = {"jpeg", "png"}
|
||||
|
||||
|
||||
def is_allowed_image(data):
|
||||
def isAllowedImage(data):
|
||||
return imghdr.what(None, data) in ALLOWED_IMAGES
|
||||
|
||||
|
||||
def upload_file(file, file_type, file_type_desc):
|
||||
def upload_file(file, fileType, fileTypeDesc):
|
||||
if not file or file is None or file.filename == "":
|
||||
raise LogicError(400, "Expected file")
|
||||
|
||||
assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist"
|
||||
|
||||
is_image = False
|
||||
if file_type == "image":
|
||||
allowed_extensions = ["jpg", "jpeg", "png"]
|
||||
is_image = True
|
||||
elif file_type == "zip":
|
||||
allowed_extensions = ["zip"]
|
||||
isImage = False
|
||||
if fileType == "image":
|
||||
allowedExtensions = ["jpg", "jpeg", "png"]
|
||||
isImage = True
|
||||
elif fileType == "zip":
|
||||
allowedExtensions = ["zip"]
|
||||
else:
|
||||
raise Exception("Invalid fileType")
|
||||
|
||||
ext = get_extension(file.filename)
|
||||
if ext is None or ext not in allowed_extensions:
|
||||
raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=file_type_desc))
|
||||
if ext is None or not ext in allowedExtensions:
|
||||
raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=fileTypeDesc))
|
||||
|
||||
if is_image and not is_allowed_image(file.stream.read()):
|
||||
if isImage and not isAllowedImage(file.stream.read()):
|
||||
raise LogicError(400, lazy_gettext("Uploaded image isn't actually an image"))
|
||||
|
||||
file.stream.seek(0)
|
||||
|
||||
filename = random_string(10) + "." + ext
|
||||
filename = randomString(10) + "." + ext
|
||||
filepath = os.path.join(app.config["UPLOAD_DIR"], filename)
|
||||
file.save(filepath)
|
||||
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 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 logging
|
||||
|
||||
from app.tasks.emails import send_user_email
|
||||
@@ -25,7 +9,6 @@ def _has_newline(line):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_bad_subject(subject):
|
||||
"""Copied from: flask_mail.py class Message def has_bad_headers"""
|
||||
if _has_newline(subject):
|
||||
@@ -49,11 +32,9 @@ class FlaskMailSubjectFormatter(logging.Formatter):
|
||||
s = self.formatMessage(record)
|
||||
return s
|
||||
|
||||
|
||||
class FlaskMailTextFormatter(logging.Formatter):
|
||||
pass
|
||||
|
||||
|
||||
class FlaskMailHTMLFormatter(logging.Formatter):
|
||||
def formatException(self, exc_info):
|
||||
formatted_exception = logging.Handler.formatException(self, exc_info)
|
||||
@@ -83,7 +64,7 @@ class FlaskMailHandler(logging.Handler):
|
||||
def getSubject(self, record):
|
||||
fmt = FlaskMailSubjectFormatter(self.subject_template)
|
||||
subject = fmt.format(record)
|
||||
# Since templates can cause header problems, and we rather have an incomplete email then an error, we fix this
|
||||
# Since templates can cause header problems, and we rather have a incomplete email then an error, we fix this
|
||||
if _is_bad_subject(subject):
|
||||
subject="FlaskMailHandler log-entry from ContentDB [original subject is replaced, because it would result in a bad header]"
|
||||
return subject
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 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 functools import partial
|
||||
|
||||
import bleach
|
||||
@@ -21,12 +5,10 @@ from bleach import Cleaner
|
||||
from bleach.linkifier import LinkifyFilter
|
||||
from bs4 import BeautifulSoup
|
||||
from markdown import Markdown
|
||||
from flask import url_for
|
||||
from jinja2.utils import markupsafe
|
||||
from flask import Markup, url_for
|
||||
from markdown.extensions import Extension
|
||||
from markdown.inlinepatterns import SimpleTagInlineProcessor
|
||||
from markdown.inlinepatterns import Pattern
|
||||
from markdown.extensions.codehilite import CodeHiliteExtension
|
||||
from xml.etree import ElementTree
|
||||
|
||||
# Based on
|
||||
@@ -34,7 +16,7 @@ from xml.etree import ElementTree
|
||||
#
|
||||
# License: MIT
|
||||
|
||||
ALLOWED_TAGS = {
|
||||
ALLOWED_TAGS = [
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "hr",
|
||||
"ul", "ol", "li",
|
||||
"p",
|
||||
@@ -48,7 +30,7 @@ ALLOWED_TAGS = {
|
||||
"img",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"div", "span", "del", "s",
|
||||
}
|
||||
]
|
||||
|
||||
ALLOWED_CSS = [
|
||||
"highlight", "codehilite",
|
||||
@@ -76,19 +58,11 @@ ALLOWED_ATTRIBUTES = {
|
||||
"span": allow_class,
|
||||
}
|
||||
|
||||
ALLOWED_PROTOCOLS = {"http", "https", "mailto"}
|
||||
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
|
||||
|
||||
md = None
|
||||
|
||||
|
||||
def linker_callback(attrs, new=False):
|
||||
if new:
|
||||
text = attrs.get("_text")
|
||||
if not (text.startswith("http://") or text.startswith("https://")):
|
||||
return None
|
||||
return attrs
|
||||
|
||||
|
||||
def render_markdown(source):
|
||||
html = md.convert(source)
|
||||
|
||||
@@ -96,9 +70,7 @@ def render_markdown(source):
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
protocols=ALLOWED_PROTOCOLS,
|
||||
filters=[partial(LinkifyFilter,
|
||||
callbacks=[linker_callback] + bleach.linkifier.DEFAULT_CALLBACKS,
|
||||
skip_tags={"pre", "code"})])
|
||||
filters=[partial(LinkifyFilter, callbacks=bleach.linkifier.DEFAULT_CALLBACKS)])
|
||||
return cleaner.clean(html)
|
||||
|
||||
|
||||
@@ -156,10 +128,13 @@ class MentionExtension(Extension):
|
||||
md.inlinePatterns.register(MentionPattern(self.getConfigs(), md), "mention", 20)
|
||||
|
||||
|
||||
MARKDOWN_EXTENSIONS = ["fenced_code", "tables", CodeHiliteExtension(guess_lang=False), "toc", DelInsExtension(), MentionExtension()]
|
||||
MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "codehilite", "toc", DelInsExtension(), MentionExtension()]
|
||||
MARKDOWN_EXTENSION_CONFIG = {
|
||||
"fenced_code": {},
|
||||
"tables": {}
|
||||
"tables": {},
|
||||
"codehilite": {
|
||||
"guess_lang": False,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,11 +143,11 @@ def init_markdown(app):
|
||||
|
||||
md = Markdown(extensions=MARKDOWN_EXTENSIONS,
|
||||
extension_configs=MARKDOWN_EXTENSION_CONFIG,
|
||||
output_format="html")
|
||||
output_format="html5")
|
||||
|
||||
@app.template_filter()
|
||||
def markdown(source):
|
||||
return markupsafe.Markup(render_markdown(source))
|
||||
return Markup(render_markdown(source))
|
||||
|
||||
|
||||
def get_headings(html: str):
|
||||
|
||||
@@ -31,7 +31,6 @@ make_searchable(db.metadata)
|
||||
from .packages import *
|
||||
from .users import *
|
||||
from .threads import *
|
||||
from .collections import *
|
||||
|
||||
|
||||
class APIToken(db.Model):
|
||||
@@ -48,44 +47,7 @@ class APIToken(db.Model):
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
||||
package = db.relationship("Package", foreign_keys=[package_id], back_populates="tokens")
|
||||
|
||||
client_id = db.Column(db.String(24), db.ForeignKey("oauth_client.id"), nullable=True)
|
||||
client = db.relationship("OAuthClient", foreign_keys=[client_id], back_populates="tokens")
|
||||
auth_code = db.Column(db.String(34), unique=True, nullable=True)
|
||||
|
||||
scope_user_email = db.Column(db.Boolean, nullable=False, default=False)
|
||||
scope_package = db.Column(db.Boolean, nullable=False, default=False)
|
||||
scope_package_release = db.Column(db.Boolean, nullable=False, default=False)
|
||||
scope_package_screenshot = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
def get_scopes(self) -> set[str]:
|
||||
ret = set()
|
||||
if self.scope_user_email:
|
||||
ret.add("user:email")
|
||||
if self.scope_package:
|
||||
ret.add("package")
|
||||
if self.scope_package_release:
|
||||
ret.add("package:release")
|
||||
if self.scope_package_screenshot:
|
||||
ret.add("package:screenshot")
|
||||
return ret
|
||||
|
||||
def set_scopes(self, v: set[str]):
|
||||
def pop(key: str):
|
||||
if key in v:
|
||||
v.remove(key)
|
||||
return True
|
||||
|
||||
self.scope_user_email = pop("user:email")
|
||||
self.scope_package = pop("package")
|
||||
self.scope_package_release = pop("package:release") or self.scope_package
|
||||
self.scope_package_screenshot = pop("package:screenshot") or self.scope_package
|
||||
return v
|
||||
|
||||
def can_operate_on_package(self, package):
|
||||
if (self.client is not None and
|
||||
not (self.scope_package or self.scope_package_release or self.scope_package_screenshot)):
|
||||
return False
|
||||
|
||||
def canOperateOnPackage(self, package):
|
||||
if self.package and self.package != package:
|
||||
return False
|
||||
|
||||
@@ -101,12 +63,12 @@ class AuditSeverity(enum.Enum):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_title(self):
|
||||
def getTitle(self):
|
||||
return self.name.replace("_", " ").title()
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(choice, choice.get_title()) for choice in cls]
|
||||
return [(choice, choice.getTitle()) for choice in cls]
|
||||
|
||||
@classmethod
|
||||
def coerce(cls, item):
|
||||
@@ -133,8 +95,6 @@ class AuditLogEntry(db.Model):
|
||||
|
||||
def __init__(self, causer, severity, title, url, package=None, description=None):
|
||||
if len(title) > 100:
|
||||
if description is None:
|
||||
description = title[99:]
|
||||
title = title[:99] + "…"
|
||||
|
||||
self.causer = causer
|
||||
@@ -144,20 +104,6 @@ class AuditLogEntry(db.Model):
|
||||
self.package = package
|
||||
self.description = description
|
||||
|
||||
def check_perm(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 AuditLogEntry.check_perm()")
|
||||
|
||||
if perm == Permission.VIEW_AUDIT_DESCRIPTION:
|
||||
return user.rank.at_least(UserRank.APPROVER if self.package is not None else UserRank.MODERATOR)
|
||||
else:
|
||||
raise Exception("Permission {} is not related to audit log entries".format(perm.name))
|
||||
|
||||
|
||||
REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com",
|
||||
"minetest.net", "dropboxusercontent.com", "4shared.com",
|
||||
@@ -184,7 +130,7 @@ class ForumTopic(db.Model):
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
def get_repo_url(self):
|
||||
def getRepoURL(self):
|
||||
if self.link is None:
|
||||
return None
|
||||
|
||||
@@ -194,11 +140,11 @@ class ForumTopic(db.Model):
|
||||
|
||||
return self.link.replace("repo.or.cz/w/", "repo.or.cz/")
|
||||
|
||||
def as_dict(self):
|
||||
def getAsDictionary(self):
|
||||
return {
|
||||
"author": self.author.username,
|
||||
"name": self.name,
|
||||
"type": self.type.to_name(),
|
||||
"type": self.type.toName(),
|
||||
"title": self.title,
|
||||
"id": self.topic_id,
|
||||
"link": self.link,
|
||||
@@ -209,17 +155,17 @@ class ForumTopic(db.Model):
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
|
||||
def check_perm(self, user, perm):
|
||||
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 ForumTopic.check_perm()")
|
||||
raise Exception("Unknown permission given to ForumTopic.checkPerm()")
|
||||
|
||||
if perm == Permission.TOPIC_DISCARD:
|
||||
return self.author == user or user.rank.at_least(UserRank.EDITOR)
|
||||
return self.author == user or user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
else:
|
||||
raise Exception("Permission {} is not related to topics".format(perm.name))
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2023 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 datetime
|
||||
|
||||
from flask import url_for, current_app
|
||||
|
||||
from . import db, Permission, User, UserRank
|
||||
|
||||
|
||||
class CollectionPackage(db.Model):
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), primary_key=True)
|
||||
package = db.relationship("Package", foreign_keys=[package_id])
|
||||
|
||||
collection_id = db.Column(db.Integer, db.ForeignKey("collection.id"), primary_key=True)
|
||||
collection = db.relationship("Collection", back_populates="items", foreign_keys=[collection_id])
|
||||
|
||||
order = db.Column(db.Integer, nullable=False, default=0)
|
||||
description = db.Column(db.String, nullable=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
collection_description_nonempty = db.CheckConstraint("description = NULL OR description != ''")
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"package": self.package.as_short_dict(current_app.config["BASE_URL"]),
|
||||
"order": self.order,
|
||||
"description": self.description,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class Collection(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
author = db.relationship("User", back_populates="collections", foreign_keys=[author_id])
|
||||
|
||||
name = db.Column(db.Unicode(100), nullable=False)
|
||||
title = db.Column(db.Unicode(100), nullable=False)
|
||||
short_description = db.Column(db.Unicode(200), nullable=False)
|
||||
long_description = db.Column(db.UnicodeText, nullable=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
private = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
packages = db.relationship("Package", secondary=CollectionPackage.__table__, backref="collections")
|
||||
items = db.relationship("CollectionPackage", back_populates="collection", order_by=db.asc("order"),
|
||||
cascade="all, delete, delete-orphan")
|
||||
|
||||
collection_name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$' AND name != '_game'")
|
||||
__table_args__ = (db.UniqueConstraint("author_id", "name", name="_collection_uc"),)
|
||||
|
||||
def get_url(self, endpoint, **kwargs):
|
||||
return url_for(endpoint, author=self.author.username, name=self.name, **kwargs)
|
||||
|
||||
def as_short_dict(self):
|
||||
return {
|
||||
"author": self.author.username,
|
||||
"name": self.name,
|
||||
"title": self.title,
|
||||
"short_description": self.short_description,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"private": self.private,
|
||||
"package_count": len(self.packages)
|
||||
}
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"author": self.author.username,
|
||||
"name": self.name,
|
||||
"title": self.title,
|
||||
"short_description": self.short_description,
|
||||
"long_description": self.long_description,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"private": self.private,
|
||||
}
|
||||
|
||||
def check_perm(self, user: User, perm):
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to Collection.check_perm()")
|
||||
|
||||
if not user.is_authenticated:
|
||||
return perm == Permission.VIEW_COLLECTION and not self.private
|
||||
|
||||
can_view = not self.private or self.author == user or user.rank.at_least(UserRank.MODERATOR)
|
||||
if perm == Permission.VIEW_COLLECTION:
|
||||
return can_view
|
||||
elif perm == Permission.EDIT_COLLECTION:
|
||||
return can_view and (self.author == user or user.rank.at_least(UserRank.EDITOR))
|
||||
else:
|
||||
raise Exception("Permission {} is not related to collections".format(perm.name))
|
||||
@@ -21,14 +21,12 @@ import enum
|
||||
from flask import url_for
|
||||
from flask_babel import lazy_gettext
|
||||
from flask_sqlalchemy import BaseQuery
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy_searchable import SearchQueryMixin
|
||||
from sqlalchemy_utils.types import TSVectorType
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from . import db
|
||||
from .users import Permission, UserRank, User
|
||||
from app import app
|
||||
from .. import app
|
||||
|
||||
|
||||
class PackageQuery(BaseQuery, SearchQueryMixin):
|
||||
@@ -55,7 +53,7 @@ class PackageType(enum.Enum):
|
||||
GAME = "Game"
|
||||
TXP = "Texture Pack"
|
||||
|
||||
def to_name(self):
|
||||
def toName(self):
|
||||
return self.name.lower()
|
||||
|
||||
def __str__(self):
|
||||
@@ -104,7 +102,7 @@ class PackageDevState(enum.Enum):
|
||||
DEPRECATED = "Deprecated"
|
||||
LOOKING_FOR_MAINTAINER = "Looking for Maintainer"
|
||||
|
||||
def to_name(self):
|
||||
def toName(self):
|
||||
return self.name.lower()
|
||||
|
||||
def __str__(self):
|
||||
@@ -161,7 +159,7 @@ class PackageState(enum.Enum):
|
||||
APPROVED = "Approved"
|
||||
DELETED = "Deleted"
|
||||
|
||||
def to_name(self):
|
||||
def toName(self):
|
||||
return self.name.lower()
|
||||
|
||||
def verb(self):
|
||||
@@ -215,6 +213,30 @@ PACKAGE_STATE_FLOW = {
|
||||
}
|
||||
|
||||
|
||||
class PackagePropertyKey(enum.Enum):
|
||||
name = "Name"
|
||||
title = "Title"
|
||||
short_desc = "Short Description"
|
||||
desc = "Description"
|
||||
type = "Type"
|
||||
license = "License"
|
||||
media_license = "Media License"
|
||||
tags = "Tags"
|
||||
provides = "Provides"
|
||||
repo = "Repository"
|
||||
website = "Website"
|
||||
issueTracker = "Issue Tracker"
|
||||
forums = "Forum Topic ID"
|
||||
|
||||
def convert(self, value):
|
||||
if self == PackagePropertyKey.tags:
|
||||
return ",".join([t.title for t in value])
|
||||
elif self == PackagePropertyKey.provides:
|
||||
return ",".join([t.name for t in value])
|
||||
else:
|
||||
return str(value)
|
||||
|
||||
|
||||
PackageProvides = db.Table("provides",
|
||||
db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True),
|
||||
db.Column("metapackage_id", db.Integer, db.ForeignKey("meta_package.id"), primary_key=True)
|
||||
@@ -272,7 +294,7 @@ class Dependency(db.Model):
|
||||
else:
|
||||
raise Exception("Either meta or package must be given, but not both!")
|
||||
|
||||
def get_name(self):
|
||||
def getName(self):
|
||||
if self.meta_package:
|
||||
return self.meta_package.name
|
||||
elif self.package:
|
||||
@@ -336,11 +358,9 @@ class PackageGameSupport(db.Model):
|
||||
|
||||
__table_args__ = (db.UniqueConstraint("game_id", "package_id", name="_package_game_support_uc"),)
|
||||
|
||||
def __init__(self, package, game, confidence, supports):
|
||||
def __init__(self, package, game):
|
||||
self.package = package
|
||||
self.game = game
|
||||
self.confidence = confidence
|
||||
self.supports = supports
|
||||
|
||||
|
||||
class Package(db.Model):
|
||||
@@ -360,20 +380,16 @@ class Package(db.Model):
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
approved_at = db.Column(db.DateTime, nullable=True, default=None)
|
||||
|
||||
name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$' AND name != '_game'")
|
||||
name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'")
|
||||
|
||||
search_vector = db.Column(TSVectorType("name", "title", "short_desc", "desc",
|
||||
weights={ "name": "A", "title": "B", "short_desc": "C" }))
|
||||
|
||||
__table_args__ = (db.UniqueConstraint("author_id", "name", name="_package_uc"),)
|
||||
weights={ "name": "A", "title": "B", "short_desc": "C", "desc": "D" }))
|
||||
|
||||
license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
|
||||
license = db.relationship("License", foreign_keys=[license_id])
|
||||
media_license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
|
||||
media_license = db.relationship("License", foreign_keys=[media_license_id])
|
||||
|
||||
ck_license_txp = db.CheckConstraint("type != 'TXP' OR license_id = media_license_id")
|
||||
|
||||
state = db.Column(db.Enum(PackageState), nullable=False, default=PackageState.WIP)
|
||||
dev_state = db.Column(db.Enum(PackageDevState), nullable=True, default=None)
|
||||
|
||||
@@ -389,22 +405,12 @@ class Package(db.Model):
|
||||
review_thread = db.relationship("Thread", uselist=False, foreign_keys=[review_thread_id],
|
||||
back_populates="is_review_thread", post_update=True)
|
||||
|
||||
# Supports all games by default, may have unsupported games
|
||||
supports_all_games = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
# 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)
|
||||
donate_url = db.Column(db.String(200), nullable=True, default=None)
|
||||
|
||||
@property
|
||||
def donate_url_actual(self):
|
||||
return self.donate_url or self.author.donate_url
|
||||
|
||||
enable_game_support_detection = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
provides = db.relationship("MetaPackage", secondary=PackageProvides, order_by=db.asc("name"), back_populates="packages")
|
||||
|
||||
@@ -457,9 +463,6 @@ class Package(db.Model):
|
||||
aliases = db.relationship("PackageAlias", foreign_keys="PackageAlias.package_id",
|
||||
back_populates="package", cascade="all, delete, delete-orphan")
|
||||
|
||||
daily_stats = db.relationship("PackageDailyStats", foreign_keys="PackageDailyStats.package_id",
|
||||
back_populates="package", cascade="all, delete, delete-orphan", lazy="dynamic")
|
||||
|
||||
def __init__(self, package=None):
|
||||
if package is None:
|
||||
return
|
||||
@@ -470,68 +473,55 @@ class Package(db.Model):
|
||||
|
||||
self.maintainers.append(self.author)
|
||||
|
||||
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
|
||||
|
||||
name = parts[1]
|
||||
if name.endswith("_game"):
|
||||
name = name[:-5]
|
||||
return Package.query.filter(Package.name == parts[1], Package.author.has(username=parts[0])).first()
|
||||
|
||||
return Package.query.filter(
|
||||
or_(Package.name == name, Package.name == name + "_game"),
|
||||
Package.author.has(username=parts[0])).first()
|
||||
|
||||
def get_id(self):
|
||||
def getId(self):
|
||||
return "{}/{}".format(self.author.username, self.name)
|
||||
|
||||
def get_sorted_dependencies(self, is_hard=None):
|
||||
def getIsFOSS(self):
|
||||
return self.license.is_foss and self.media_license.is_foss
|
||||
|
||||
def getSortedDependencies(self, is_hard=None):
|
||||
query = self.dependencies
|
||||
if is_hard is not None:
|
||||
query = query.filter_by(optional=not is_hard)
|
||||
|
||||
deps = query.all()
|
||||
deps.sort(key=lambda x: x.get_name())
|
||||
deps.sort(key = lambda x: x.getName())
|
||||
return deps
|
||||
|
||||
def get_sorted_hard_dependencies(self):
|
||||
return self.get_sorted_dependencies(True)
|
||||
def getSortedHardDependencies(self):
|
||||
return self.getSortedDependencies(True)
|
||||
|
||||
def get_sorted_optional_dependencies(self):
|
||||
return self.get_sorted_dependencies(False)
|
||||
def getSortedOptionalDependencies(self):
|
||||
return self.getSortedDependencies(False)
|
||||
|
||||
def get_sorted_game_support(self):
|
||||
query = self.supported_games.filter(PackageGameSupport.game.has(state=PackageState.APPROVED))
|
||||
|
||||
supported = query.all()
|
||||
supported.sort(key=lambda x: -(x.game.score + 100000*x.confidence))
|
||||
def getSortedSupportedGames(self):
|
||||
supported = self.supported_games.all()
|
||||
supported.sort(key=lambda x: -x.game.score)
|
||||
return supported
|
||||
|
||||
def get_sorted_game_support_pair(self):
|
||||
supported = self.get_sorted_game_support()
|
||||
return [
|
||||
[x for x in supported if x.supports],
|
||||
[x for x in supported if not x.supports],
|
||||
]
|
||||
|
||||
def has_game_support_confirmed(self):
|
||||
return self.supports_all_games or \
|
||||
self.supported_games.filter(PackageGameSupport.confidence > 1).count() > 0
|
||||
|
||||
def as_key_dict(self):
|
||||
def getAsDictionaryKey(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"author": self.author.username,
|
||||
"type": self.type.to_name(),
|
||||
"type": self.type.toName(),
|
||||
}
|
||||
|
||||
def as_short_dict(self, base_url, version=None, release_id=None, no_load=False):
|
||||
tnurl = self.get_thumb_url(1)
|
||||
def getAsDictionaryShort(self, base_url, version=None, release_id=None, no_load=False):
|
||||
tnurl = self.getThumbnailURL(1)
|
||||
|
||||
if release_id is None and no_load == False:
|
||||
release = self.get_download_release(version=version)
|
||||
release = self.getDownloadRelease(version=version)
|
||||
release_id = release and release.id
|
||||
|
||||
short_desc = self.short_desc
|
||||
@@ -543,10 +533,10 @@ class Package(db.Model):
|
||||
"title": self.title,
|
||||
"author": self.author.username,
|
||||
"short_description": short_desc,
|
||||
"type": self.type.to_name(),
|
||||
"type": self.type.toName(),
|
||||
"release": release_id,
|
||||
"thumbnail": (base_url + tnurl) if tnurl is not None else None,
|
||||
"aliases": [alias.as_dict() for alias in self.aliases],
|
||||
"aliases": [ alias.getAsDictionary() for alias in self.aliases ],
|
||||
}
|
||||
|
||||
if not ret["aliases"]:
|
||||
@@ -554,9 +544,9 @@ class Package(db.Model):
|
||||
|
||||
return ret
|
||||
|
||||
def as_dict(self, base_url, version=None):
|
||||
tnurl = self.get_thumb_url(1)
|
||||
release = self.get_download_release(version=version)
|
||||
def getAsDictionary(self, base_url, version=None):
|
||||
tnurl = self.getThumbnailURL(1)
|
||||
release = self.getDownloadRelease(version=version)
|
||||
return {
|
||||
"author": self.author.username,
|
||||
"maintainers": [x.username for x in self.maintainers],
|
||||
@@ -568,7 +558,7 @@ class Package(db.Model):
|
||||
"title": self.title,
|
||||
"short_description": self.short_desc,
|
||||
"long_description": self.desc,
|
||||
"type": self.type.to_name(),
|
||||
"type": self.type.toName(),
|
||||
"created_at": self.created_at.isoformat(),
|
||||
|
||||
"license": self.license.name,
|
||||
@@ -579,16 +569,15 @@ class Package(db.Model):
|
||||
"issue_tracker": self.issueTracker,
|
||||
"forums": self.forums,
|
||||
"video_url": self.video_url,
|
||||
"donate_url": self.donate_url_actual,
|
||||
|
||||
"tags": sorted([x.name for x in self.tags]),
|
||||
"content_warnings": sorted([x.name for x in self.content_warnings]),
|
||||
"tags": [x.name for x in self.tags],
|
||||
"content_warnings": [x.name for x in self.content_warnings],
|
||||
|
||||
"provides": sorted([x.name for x in self.provides]),
|
||||
"provides": [x.name for x in self.provides],
|
||||
"thumbnail": (base_url + tnurl) if tnurl is not None else None,
|
||||
"screenshots": [base_url + ss.url for ss in self.screenshots],
|
||||
|
||||
"url": base_url + self.get_url("packages.download"),
|
||||
"url": base_url + self.getURL("packages.download"),
|
||||
"release": release and release.id,
|
||||
|
||||
"score": round(self.score * 10) / 10,
|
||||
@@ -598,172 +587,176 @@ class Package(db.Model):
|
||||
{
|
||||
"supports": support.supports,
|
||||
"confidence": support.confidence,
|
||||
"game": support.game.as_short_dict(base_url, version)
|
||||
"game": support.game.getAsDictionaryShort(base_url, version)
|
||||
} for support in self.supported_games.all()
|
||||
]
|
||||
}
|
||||
|
||||
def get_thumb_or_placeholder(self, level=2):
|
||||
return self.get_thumb_url(level) or "/static/placeholder.png"
|
||||
def getThumbnailOrPlaceholder(self, level=2):
|
||||
return self.getThumbnailURL(level) or "/static/placeholder.png"
|
||||
|
||||
def get_thumb_url(self, level=2, abs=False):
|
||||
def getThumbnailURL(self, level=2):
|
||||
screenshot = self.main_screenshot
|
||||
url = screenshot.get_thumb_url(level) if screenshot is not None else None
|
||||
if abs:
|
||||
return screenshot.getThumbnailURL(level) if screenshot is not None else None
|
||||
|
||||
def getMainScreenshotURL(self, absolute=False):
|
||||
screenshot = self.main_screenshot
|
||||
if screenshot is None:
|
||||
return None
|
||||
|
||||
if absolute:
|
||||
from app.utils import abs_url
|
||||
return abs_url(url)
|
||||
return abs_url(screenshot.url)
|
||||
else:
|
||||
return url
|
||||
return screenshot.url
|
||||
|
||||
def get_cover_image_url(self):
|
||||
screenshot = self.cover_image or self.main_screenshot
|
||||
return screenshot and screenshot.get_thumb_url(4)
|
||||
|
||||
def get_url(self, endpoint, absolute=False, **kwargs):
|
||||
def getURL(self, endpoint, absolute=False, **kwargs):
|
||||
if absolute:
|
||||
from app.utils import abs_url_for
|
||||
return abs_url_for(endpoint, author=self.author.username, name=self.name, **kwargs)
|
||||
else:
|
||||
return url_for(endpoint, author=self.author.username, name=self.name, **kwargs)
|
||||
|
||||
def get_shield_url(self, type):
|
||||
def getShieldURL(self, type):
|
||||
from app.utils import abs_url_for
|
||||
return abs_url_for("packages.shield",
|
||||
author=self.author.username, name=self.name, type=type)
|
||||
|
||||
def make_shield(self, type):
|
||||
def makeShield(self, type):
|
||||
return "[]({})" \
|
||||
.format(self.get_shield_url(type), self.get_url("packages.view", True))
|
||||
.format(self.getShieldURL(type), self.getURL("packages.view", True))
|
||||
|
||||
def get_set_state_url(self, state):
|
||||
def getSetStateURL(self, state):
|
||||
if type(state) == str:
|
||||
state = PackageState[state]
|
||||
elif type(state) != PackageState:
|
||||
raise Exception("Unknown state given to Package.can_move_to_state()")
|
||||
raise Exception("Unknown state given to Package.canMoveToState()")
|
||||
|
||||
return url_for("packages.move_to_state",
|
||||
author=self.author.username, name=self.name, state=state.name.lower())
|
||||
|
||||
def get_download_release(self, version=None):
|
||||
def getDownloadRelease(self, version=None):
|
||||
for rel in self.releases:
|
||||
if rel.approved and (version is None or
|
||||
((rel.min_rel is None or rel.min_rel_id <= version.id) and
|
||||
(rel.max_rel is None or rel.max_rel_id >= version.id))):
|
||||
(rel.max_rel is None or rel.max_rel_id >= version.id))):
|
||||
return rel
|
||||
|
||||
return None
|
||||
|
||||
def check_perm(self, user, perm):
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to Package.check_perm()")
|
||||
|
||||
if perm == Permission.VIEW_PACKAGE:
|
||||
return self.state == PackageState.APPROVED or self.check_perm(user, Permission.EDIT_PACKAGE)
|
||||
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
is_owner = user == self.author
|
||||
is_maintainer = is_owner or user.rank.at_least(UserRank.EDITOR) or user in self.maintainers
|
||||
is_approver = user.rank.at_least(UserRank.APPROVER)
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to Package.checkPerm()")
|
||||
|
||||
isOwner = user == self.author
|
||||
isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers
|
||||
isApprover = user.rank.atLeast(UserRank.APPROVER)
|
||||
|
||||
if perm == Permission.CREATE_THREAD:
|
||||
return user.rank.at_least(UserRank.NEW_MEMBER)
|
||||
return user.rank.atLeast(UserRank.MEMBER)
|
||||
|
||||
# Members can edit their own packages, and editors can edit any packages
|
||||
elif perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS:
|
||||
return is_maintainer
|
||||
return isMaintainer
|
||||
|
||||
elif perm == Permission.EDIT_PACKAGE:
|
||||
return is_maintainer and user.rank.at_least(UserRank.NEW_MEMBER)
|
||||
return isMaintainer and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
|
||||
|
||||
elif perm == Permission.APPROVE_RELEASE:
|
||||
return (is_maintainer or is_approver) and user.rank.at_least(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
|
||||
return (isMaintainer or isApprover) and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
|
||||
|
||||
# Anyone can change the package name when not approved
|
||||
# Anyone can change the package name when not approved, but only editors when approved
|
||||
elif perm == Permission.CHANGE_NAME:
|
||||
return not self.approved
|
||||
return not self.approved or user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
# Editors can change authors and approve new packages
|
||||
elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR:
|
||||
return is_approver
|
||||
return isApprover
|
||||
|
||||
elif perm == Permission.APPROVE_SCREENSHOT:
|
||||
return (is_maintainer or is_approver) and \
|
||||
user.rank.at_least(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
|
||||
return (isMaintainer or isApprover) and \
|
||||
user.rank.atLeast(UserRank.TRUSTED_MEMBER if self.approved else UserRank.NEW_MEMBER)
|
||||
|
||||
elif perm == Permission.EDIT_MAINTAINERS or perm == Permission.DELETE_PACKAGE:
|
||||
return is_owner or user.rank.at_least(UserRank.EDITOR)
|
||||
return isOwner or user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
elif perm == Permission.UNAPPROVE_PACKAGE:
|
||||
return is_owner or user.rank.at_least(UserRank.APPROVER)
|
||||
return isOwner or user.rank.atLeast(UserRank.APPROVER)
|
||||
|
||||
elif perm == Permission.CHANGE_RELEASE_URL:
|
||||
return user.rank.at_least(UserRank.MODERATOR)
|
||||
return user.rank.atLeast(UserRank.MODERATOR)
|
||||
|
||||
else:
|
||||
raise Exception("Permission {} is not related to packages".format(perm.name))
|
||||
|
||||
def get_missing_hard_dependencies_query(self):
|
||||
def getMissingHardDependenciesQuery(self):
|
||||
return MetaPackage.query \
|
||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
||||
.filter(MetaPackage.dependencies.any(optional=False, depender=self)) \
|
||||
.order_by(db.asc(MetaPackage.name))
|
||||
|
||||
def get_missing_hard_dependencies(self):
|
||||
return [mp.name for mp in self.get_missing_hard_dependencies_query().all()]
|
||||
def getMissingHardDependencies(self):
|
||||
return [mp.name for mp in self.getMissingHardDependenciesQuery().all()]
|
||||
|
||||
def can_move_to_state(self, user, state):
|
||||
def canMoveToState(self, user, state):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if type(state) == str:
|
||||
state = PackageState[state]
|
||||
elif type(state) != PackageState:
|
||||
raise Exception("Unknown state given to Package.can_move_to_state()")
|
||||
raise Exception("Unknown state given to Package.canMoveToState()")
|
||||
|
||||
if state not in PACKAGE_STATE_FLOW[self.state]:
|
||||
return False
|
||||
|
||||
if state == PackageState.READY_FOR_REVIEW or state == PackageState.APPROVED:
|
||||
if state == PackageState.APPROVED and not self.check_perm(user, Permission.APPROVE_NEW):
|
||||
if state == PackageState.APPROVED and not self.checkPerm(user, Permission.APPROVE_NEW):
|
||||
return False
|
||||
|
||||
if not (self.check_perm(user, Permission.APPROVE_NEW) or self.check_perm(user, Permission.EDIT_PACKAGE)):
|
||||
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):
|
||||
return False
|
||||
|
||||
if self.get_missing_hard_dependencies_query().count() > 0:
|
||||
provides = self.provides
|
||||
if state == PackageState.APPROVED and len(provides) == 1 and provides[0].name != self.name:
|
||||
return False
|
||||
|
||||
needs_screenshot = \
|
||||
(self.type == self.type.GAME or self.type == self.type.TXP) and self.screenshots.count() == 0
|
||||
if self.getMissingHardDependenciesQuery().count() > 0:
|
||||
return False
|
||||
|
||||
return self.releases.filter(PackageRelease.task_id==None).count() > 0 and not needs_screenshot
|
||||
needsScreenshot = \
|
||||
(self.type == self.type.GAME or self.type == self.type.TXP) and \
|
||||
self.screenshots.count() == 0
|
||||
|
||||
return self.releases.filter(PackageRelease.task_id.is_(None)).count() > 0 and not needsScreenshot
|
||||
|
||||
elif state == PackageState.CHANGES_NEEDED:
|
||||
return self.check_perm(user, Permission.APPROVE_NEW)
|
||||
return self.checkPerm(user, Permission.APPROVE_NEW)
|
||||
|
||||
elif state == PackageState.WIP:
|
||||
return self.check_perm(user, Permission.EDIT_PACKAGE) and \
|
||||
(user in self.maintainers or user.rank.at_least(UserRank.ADMIN))
|
||||
return self.checkPerm(user, Permission.EDIT_PACKAGE) and \
|
||||
(user in self.maintainers or user.rank.atLeast(UserRank.ADMIN))
|
||||
|
||||
return True
|
||||
|
||||
def get_next_states(self, user):
|
||||
def getNextStates(self, user):
|
||||
states = []
|
||||
|
||||
for state in PackageState:
|
||||
if self.can_move_to_state(user, state):
|
||||
if self.canMoveToState(user, state):
|
||||
states.append(state)
|
||||
|
||||
return states
|
||||
|
||||
def as_score_dict(self):
|
||||
def getScoreDict(self):
|
||||
return {
|
||||
"author": self.author.username,
|
||||
"name": self.name,
|
||||
@@ -773,24 +766,16 @@ class Package(db.Model):
|
||||
"downloads": self.downloads
|
||||
}
|
||||
|
||||
def recalculate_score(self):
|
||||
review_scores = [ 100 * r.as_weight() for r in self.reviews ]
|
||||
def recalcScore(self):
|
||||
review_scores = [ 100 * r.asSign() for r in self.reviews ]
|
||||
self.score = self.score_downloads + sum(review_scores)
|
||||
|
||||
def get_conf_file_name(self):
|
||||
if self.type == PackageType.MOD:
|
||||
return "mod.conf"
|
||||
elif self.type == PackageType.TXP:
|
||||
return "texture_pack.conf"
|
||||
elif self.type == PackageType.GAME:
|
||||
return "game.conf"
|
||||
|
||||
|
||||
class MetaPackage(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
dependencies = db.relationship("Dependency", back_populates="meta_package", lazy="dynamic")
|
||||
packages = db.relationship("Package", lazy="dynamic", back_populates="provides", secondary=PackageProvides)
|
||||
packages = db.relationship("Package", lazy="dynamic", back_populates="provides", secondary=PackageProvides)
|
||||
|
||||
mp_name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'")
|
||||
|
||||
@@ -854,7 +839,7 @@ class ContentWarning(db.Model):
|
||||
regex = re.compile("[^a-z_]")
|
||||
self.name = regex.sub("", self.title.lower().replace(" ", "_"))
|
||||
|
||||
def as_dict(self):
|
||||
def getAsDictionary(self):
|
||||
description = self.description if self.description != "" else None
|
||||
return { "name": self.name, "title": self.title, "description": description }
|
||||
|
||||
@@ -867,6 +852,7 @@ class Tag(db.Model):
|
||||
backgroundColor = db.Column(db.String(6), nullable=False)
|
||||
textColor = db.Column(db.String(6), nullable=False)
|
||||
views = db.Column(db.Integer, nullable=False, default=0)
|
||||
is_protected = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
packages = db.relationship("Package", back_populates="tags", secondary=Tags)
|
||||
|
||||
@@ -879,12 +865,13 @@ class Tag(db.Model):
|
||||
regex = re.compile("[^a-z_]")
|
||||
self.name = regex.sub("", self.title.lower().replace(" ", "_"))
|
||||
|
||||
def as_dict(self):
|
||||
def getAsDictionary(self):
|
||||
description = self.description if self.description != "" else None
|
||||
return {
|
||||
"name": self.name,
|
||||
"title": self.title,
|
||||
"description": description,
|
||||
"is_protected": self.is_protected,
|
||||
"views": self.views,
|
||||
}
|
||||
|
||||
@@ -898,10 +885,10 @@ class MinetestRelease(db.Model):
|
||||
self.name = name
|
||||
self.protocol = protocol
|
||||
|
||||
def get_actual(self):
|
||||
def getActual(self):
|
||||
return None if self.name == "None" else self
|
||||
|
||||
def as_dict(self):
|
||||
def getAsDictionary(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"protocol_version": self.protocol,
|
||||
@@ -958,7 +945,7 @@ class PackageRelease(db.Model):
|
||||
def file_path(self):
|
||||
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||
|
||||
def as_dict(self):
|
||||
def getAsDictionary(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
@@ -966,11 +953,11 @@ class PackageRelease(db.Model):
|
||||
"release_date": self.releaseDate.isoformat(),
|
||||
"commit": self.commit_hash,
|
||||
"downloads": self.downloads,
|
||||
"min_minetest_version": self.min_rel and self.min_rel.as_dict(),
|
||||
"max_minetest_version": self.max_rel and self.max_rel.as_dict(),
|
||||
"min_minetest_version": self.min_rel and self.min_rel.getAsDictionary(),
|
||||
"max_minetest_version": self.max_rel and self.max_rel.getAsDictionary(),
|
||||
}
|
||||
|
||||
def as_long_dict(self):
|
||||
def getLongAsDictionary(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
@@ -978,24 +965,24 @@ class PackageRelease(db.Model):
|
||||
"release_date": self.releaseDate.isoformat(),
|
||||
"commit": self.commit_hash,
|
||||
"downloads": self.downloads,
|
||||
"min_minetest_version": self.min_rel and self.min_rel.as_dict(),
|
||||
"max_minetest_version": self.max_rel and self.max_rel.as_dict(),
|
||||
"package": self.package.as_key_dict()
|
||||
"min_minetest_version": self.min_rel and self.min_rel.getAsDictionary(),
|
||||
"max_minetest_version": self.max_rel and self.max_rel.getAsDictionary(),
|
||||
"package": self.package.getAsDictionaryKey()
|
||||
}
|
||||
|
||||
def get_edit_url(self):
|
||||
def getEditURL(self):
|
||||
return url_for("packages.edit_release",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
id=self.id)
|
||||
|
||||
def get_delete_url(self):
|
||||
def getDeleteURL(self):
|
||||
return url_for("packages.delete_release",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
id=self.id)
|
||||
|
||||
def get_download_url(self):
|
||||
def getDownloadURL(self):
|
||||
return url_for("packages.download_release",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
@@ -1004,11 +991,11 @@ class PackageRelease(db.Model):
|
||||
def __init__(self):
|
||||
self.releaseDate = datetime.datetime.now()
|
||||
|
||||
def get_download_filename(self):
|
||||
def getDownloadFileName(self):
|
||||
return f"{self.package.name}_{self.id}.zip"
|
||||
|
||||
def approve(self, user):
|
||||
if not self.check_perm(user, Permission.APPROVE_RELEASE):
|
||||
if not self.checkPerm(user, Permission.APPROVE_RELEASE):
|
||||
return False
|
||||
|
||||
if self.approved:
|
||||
@@ -1024,22 +1011,22 @@ class PackageRelease(db.Model):
|
||||
|
||||
return True
|
||||
|
||||
def check_perm(self, user, perm):
|
||||
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 PackageRelease.check_perm()")
|
||||
raise Exception("Unknown permission given to PackageRelease.checkPerm()")
|
||||
|
||||
is_maintainer = user == self.package.author or user in self.package.maintainers
|
||||
isMaintainer = user == self.package.author or user in self.package.maintainers
|
||||
|
||||
if perm == Permission.DELETE_RELEASE:
|
||||
if user.rank.at_least(UserRank.ADMIN):
|
||||
if user.rank.atLeast(UserRank.ADMIN):
|
||||
return True
|
||||
|
||||
if not (is_maintainer or user.rank.at_least(UserRank.EDITOR)):
|
||||
if not (isMaintainer or user.rank.atLeast(UserRank.EDITOR)):
|
||||
return False
|
||||
|
||||
if not self.package.approved or self.task_id is not None:
|
||||
@@ -1051,8 +1038,8 @@ class PackageRelease(db.Model):
|
||||
|
||||
return count > 0
|
||||
elif perm == Permission.APPROVE_RELEASE:
|
||||
return user.rank.at_least(UserRank.APPROVER) or \
|
||||
(is_maintainer and user.rank.at_least(
|
||||
return user.rank.atLeast(UserRank.APPROVER) or \
|
||||
(isMaintainer and user.rank.atLeast(
|
||||
UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER))
|
||||
else:
|
||||
raise Exception("Permission {} is not related to releases".format(perm.name))
|
||||
@@ -1089,22 +1076,22 @@ class PackageScreenshot(db.Model):
|
||||
def file_path(self):
|
||||
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||
|
||||
def get_edit_url(self):
|
||||
def getEditURL(self):
|
||||
return url_for("packages.edit_screenshot",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
id=self.id)
|
||||
|
||||
def get_delete_url(self):
|
||||
def getDeleteURL(self):
|
||||
return url_for("packages.delete_screenshot",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
id=self.id)
|
||||
|
||||
def get_thumb_url(self, level=2):
|
||||
def getThumbnailURL(self, level=2):
|
||||
return self.url.replace("/uploads/", "/thumbnails/{:d}/".format(level))
|
||||
|
||||
def as_dict(self, base_url=""):
|
||||
def getAsDictionary(self, base_url=""):
|
||||
return {
|
||||
"id": self.id,
|
||||
"order": self.order,
|
||||
@@ -1122,7 +1109,7 @@ class PackageUpdateTrigger(enum.Enum):
|
||||
COMMIT = "New Commit"
|
||||
TAG = "New Tag"
|
||||
|
||||
def to_name(self):
|
||||
def toName(self):
|
||||
return self.name.lower()
|
||||
|
||||
def __str__(self):
|
||||
@@ -1185,7 +1172,7 @@ class PackageUpdateConfig(db.Model):
|
||||
return self.last_tag or self.last_commit
|
||||
|
||||
def get_create_release_url(self):
|
||||
return self.package.get_url("packages.create_release", title=self.get_title(), ref=self.get_ref())
|
||||
return self.package.getURL("packages.create_release", title=self.get_title(), ref=self.get_ref())
|
||||
|
||||
|
||||
class PackageAlias(db.Model):
|
||||
@@ -1201,56 +1188,9 @@ class PackageAlias(db.Model):
|
||||
self.author = author
|
||||
self.name = name
|
||||
|
||||
def get_edit_url(self):
|
||||
def getEditURL(self):
|
||||
return url_for("packages.alias_create_edit", author=self.package.author.username,
|
||||
name=self.package.name, alias_id=self.id)
|
||||
|
||||
def as_dict(self):
|
||||
def getAsDictionary(self):
|
||||
return f"{self.author}/{self.name}"
|
||||
|
||||
|
||||
class PackageDailyStats(db.Model):
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), primary_key=True)
|
||||
package = db.relationship("Package", back_populates="daily_stats", foreign_keys=[package_id])
|
||||
date = db.Column(db.Date, primary_key=True)
|
||||
|
||||
platform_minetest = db.Column(db.Integer, nullable=False, default=0)
|
||||
platform_other = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
reason_new = db.Column(db.Integer, nullable=False, default=0)
|
||||
reason_dependency = db.Column(db.Integer, nullable=False, default=0)
|
||||
reason_update = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
@staticmethod
|
||||
def update(package: Package, is_minetest: bool, reason: str):
|
||||
date = datetime.datetime.utcnow().date()
|
||||
|
||||
to_update = dict()
|
||||
kwargs = {
|
||||
"package_id": package.id, "date": date
|
||||
}
|
||||
|
||||
field_platform = "platform_minetest" if is_minetest else "platform_other"
|
||||
to_update[field_platform] = getattr(PackageDailyStats, field_platform) + 1
|
||||
kwargs[field_platform] = 1
|
||||
|
||||
field_reason = None
|
||||
if reason == "new":
|
||||
field_reason = "reason_new"
|
||||
elif reason == "dependency":
|
||||
field_reason = "reason_dependency"
|
||||
elif reason == "update":
|
||||
field_reason = "reason_update"
|
||||
|
||||
if field_reason:
|
||||
to_update[field_reason] = getattr(PackageDailyStats, field_reason) + 1
|
||||
kwargs[field_reason] = 1
|
||||
|
||||
stmt = insert(PackageDailyStats).values(**kwargs)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[PackageDailyStats.package_id, PackageDailyStats.date],
|
||||
set_=to_update
|
||||
)
|
||||
|
||||
conn = db.session.connection()
|
||||
conn.execute(stmt)
|
||||
|
||||
@@ -55,58 +55,54 @@ class Thread(db.Model):
|
||||
|
||||
watchers = db.relationship("User", secondary=watchers, backref="watching")
|
||||
|
||||
first_reply = db.relationship("ThreadReply", uselist=False, foreign_keys="ThreadReply.thread_id",
|
||||
lazy=True, order_by=db.asc("id"), viewonly=True,
|
||||
primaryjoin="Thread.id==ThreadReply.thread_id")
|
||||
|
||||
def get_description(self):
|
||||
comment = self.first_reply.comment.replace("\r\n", " ").replace("\n", " ").replace(" ", " ")
|
||||
comment = self.replies[0].comment.replace("\r\n", " ").replace("\n", " ").replace(" ", " ")
|
||||
if len(comment) > 100:
|
||||
return comment[:97] + "..."
|
||||
else:
|
||||
return comment
|
||||
|
||||
def get_view_url(self, absolute=False):
|
||||
def getViewURL(self, absolute=False):
|
||||
if absolute:
|
||||
from app.utils import abs_url_for
|
||||
from ..utils import abs_url_for
|
||||
return abs_url_for("threads.view", id=self.id)
|
||||
else:
|
||||
return url_for("threads.view", id=self.id, _external=False)
|
||||
|
||||
def get_subscribe_url(self):
|
||||
def getSubscribeURL(self):
|
||||
return url_for("threads.subscribe", id=self.id)
|
||||
|
||||
def get_unsubscribe_url(self):
|
||||
def getUnsubscribeURL(self):
|
||||
return url_for("threads.unsubscribe", id=self.id)
|
||||
|
||||
def check_perm(self, user, perm):
|
||||
def checkPerm(self, user, perm):
|
||||
if not user.is_authenticated:
|
||||
return perm == Permission.SEE_THREAD and not self.private
|
||||
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
raise Exception("Unknown permission given to Thread.check_perm()")
|
||||
|
||||
if not user.is_authenticated:
|
||||
return perm == Permission.SEE_THREAD and not self.private
|
||||
raise Exception("Unknown permission given to Thread.checkPerm()")
|
||||
|
||||
isMaintainer = user == self.author or (self.package is not None and self.package.author == user)
|
||||
if self.package:
|
||||
isMaintainer = isMaintainer or user in self.package.maintainers
|
||||
|
||||
canSee = not self.private or isMaintainer or user.rank.at_least(UserRank.APPROVER) or user in self.watchers
|
||||
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.APPROVER) or user in self.watchers
|
||||
|
||||
if perm == Permission.SEE_THREAD:
|
||||
return canSee
|
||||
|
||||
elif perm == Permission.COMMENT_THREAD:
|
||||
return canSee and (not self.locked or user.rank.at_least(UserRank.MODERATOR))
|
||||
return canSee and (not self.locked or user.rank.atLeast(UserRank.MODERATOR))
|
||||
|
||||
elif perm == Permission.LOCK_THREAD:
|
||||
return user.rank.at_least(UserRank.MODERATOR)
|
||||
return user.rank.atLeast(UserRank.MODERATOR)
|
||||
|
||||
elif perm == Permission.DELETE_THREAD:
|
||||
from app.utils.models import get_system_user
|
||||
return (self.author == get_system_user() and self.package and
|
||||
user in self.package.maintainers) or user.rank.at_least(UserRank.MODERATOR)
|
||||
user in self.package.maintainers) or user.rank.atLeast(UserRank.MODERATOR)
|
||||
|
||||
else:
|
||||
raise Exception("Permission {} is not related to threads".format(perm.name))
|
||||
@@ -144,23 +140,23 @@ class ThreadReply(db.Model):
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
def get_url(self, absolute=False):
|
||||
return self.thread.get_view_url(absolute) + "#reply-" + str(self.id)
|
||||
def get_url(self):
|
||||
return url_for('threads.view', id=self.thread.id) + "#reply-" + str(self.id)
|
||||
|
||||
def check_perm(self, user, perm):
|
||||
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 ThreadReply.check_perm()")
|
||||
raise Exception("Unknown permission given to ThreadReply.checkPerm()")
|
||||
|
||||
if perm == Permission.EDIT_REPLY:
|
||||
return user.rank.at_least(UserRank.NEW_MEMBER if user == self.author else UserRank.MODERATOR) and not self.thread.locked
|
||||
return user.rank.atLeast(UserRank.MEMBER if user == self.author else UserRank.MODERATOR) and not self.thread.locked
|
||||
|
||||
elif perm == Permission.DELETE_REPLY:
|
||||
return user.rank.at_least(UserRank.MODERATOR) and self.thread.first_reply != self
|
||||
return user.rank.atLeast(UserRank.MODERATOR) and self.thread.replies[0] != self
|
||||
|
||||
else:
|
||||
raise Exception("Permission {} is not related to threads".format(perm.name))
|
||||
@@ -177,7 +173,7 @@ class PackageReview(db.Model):
|
||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
author = db.relationship("User", foreign_keys=[author_id], back_populates="reviews")
|
||||
|
||||
rating = db.Column(db.Integer, nullable=False)
|
||||
recommends = db.Column(db.Boolean, nullable=False)
|
||||
|
||||
thread = db.relationship("Thread", uselist=False, back_populates="review")
|
||||
votes = db.relationship("PackageReviewVote", back_populates="review", cascade="all, delete, delete-orphan")
|
||||
@@ -191,13 +187,10 @@ class PackageReview(db.Model):
|
||||
user_vote = next(filter(lambda vote: vote.user == current_user, votes), None)
|
||||
return pos, neg, user_vote.is_positive if user_vote else None
|
||||
|
||||
def as_dict(self, include_package=False):
|
||||
from app.utils import abs_url_for
|
||||
|
||||
def getAsDictionary(self, include_package=False):
|
||||
pos, neg, _user = self.get_totals()
|
||||
ret = {
|
||||
"is_positive": self.rating > 3,
|
||||
"rating": self.rating,
|
||||
"is_positive": self.recommends,
|
||||
"user": {
|
||||
"username": self.author.username,
|
||||
"display_name": self.author.display_name,
|
||||
@@ -208,32 +201,25 @@ class PackageReview(db.Model):
|
||||
"unhelpful": neg,
|
||||
},
|
||||
"title": self.thread.title,
|
||||
"comment": self.thread.first_reply.comment,
|
||||
"thread": {
|
||||
"id": self.thread.id,
|
||||
"url": abs_url_for("threads.view", id=self.thread.id),
|
||||
},
|
||||
"comment": self.thread.replies[0].comment,
|
||||
}
|
||||
if include_package:
|
||||
ret["package"] = self.package.as_key_dict()
|
||||
ret["package"] = self.package.getAsDictionaryKey()
|
||||
return ret
|
||||
|
||||
def as_weight(self):
|
||||
"""
|
||||
From (1, 5) to (-1 to 1)
|
||||
"""
|
||||
return (self.rating - 3.0) / 2.0
|
||||
def asSign(self):
|
||||
return 1 if self.recommends else -1
|
||||
|
||||
def get_edit_url(self):
|
||||
return self.package.get_url("packages.review")
|
||||
def getEditURL(self):
|
||||
return self.package.getURL("packages.review")
|
||||
|
||||
def get_delete_url(self):
|
||||
def getDeleteURL(self):
|
||||
return url_for("packages.delete_review",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
reviewer=self.author.username)
|
||||
|
||||
def get_vote_url(self, next_url=None):
|
||||
def getVoteUrl(self, next_url=None):
|
||||
return url_for("packages.review_vote",
|
||||
author=self.package.author.username,
|
||||
name=self.package.name,
|
||||
@@ -244,17 +230,17 @@ class PackageReview(db.Model):
|
||||
(pos, neg, _) = self.get_totals()
|
||||
self.score = 3 * (pos - neg) + 1
|
||||
|
||||
def check_perm(self, user, perm):
|
||||
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.check_perm()")
|
||||
raise Exception("Unknown permission given to PackageReview.checkPerm()")
|
||||
|
||||
if perm == Permission.DELETE_REVIEW:
|
||||
return user == self.author or user.rank.at_least(UserRank.MODERATOR)
|
||||
return user == self.author or user.rank.atLeast(UserRank.MODERATOR)
|
||||
else:
|
||||
raise Exception("Permission {} is not related to reviews".format(perm.name))
|
||||
|
||||
|
||||
@@ -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/>.
|
||||
|
||||
|
||||
import datetime
|
||||
import enum
|
||||
|
||||
from flask import url_for
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy import desc, text
|
||||
|
||||
@@ -37,13 +37,13 @@ class UserRank(enum.Enum):
|
||||
MODERATOR = 8
|
||||
ADMIN = 9
|
||||
|
||||
def at_least(self, min):
|
||||
def atLeast(self, min):
|
||||
return self.value >= min.value
|
||||
|
||||
def get_title(self):
|
||||
def getTitle(self):
|
||||
return self.name.replace("_", " ").title()
|
||||
|
||||
def to_name(self):
|
||||
def toName(self):
|
||||
return self.name.lower()
|
||||
|
||||
def __str__(self):
|
||||
@@ -51,7 +51,7 @@ class UserRank(enum.Enum):
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(choice, choice.get_title()) for choice in cls]
|
||||
return [(choice, choice.getTitle()) for choice in cls]
|
||||
|
||||
@classmethod
|
||||
def coerce(cls, item):
|
||||
@@ -59,7 +59,6 @@ class UserRank(enum.Enum):
|
||||
|
||||
|
||||
class Permission(enum.Enum):
|
||||
VIEW_PACKAGE = "VIEW_PACKAGE"
|
||||
EDIT_PACKAGE = "EDIT_PACKAGE"
|
||||
DELETE_PACKAGE = "DELETE_PACKAGE"
|
||||
CHANGE_AUTHOR = "CHANGE_AUTHOR"
|
||||
@@ -90,13 +89,9 @@ class Permission(enum.Enum):
|
||||
DELETE_REVIEW = "DELETE_REVIEW"
|
||||
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
|
||||
CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME"
|
||||
VIEW_AUDIT_DESCRIPTION = "VIEW_AUDIT_DESCRIPTION"
|
||||
EDIT_COLLECTION = "EDIT_COLLECTION"
|
||||
VIEW_COLLECTION = "VIEW_COLLECTION"
|
||||
CREATE_OAUTH_CLIENT = "CREATE_OAUTH_CLIENT"
|
||||
|
||||
# Only return true if the permission is valid for *all* contexts
|
||||
# See Package.check_perm for package-specific contexts
|
||||
# See Package.checkPerm for package-specific contexts
|
||||
def check(self, user):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
@@ -105,16 +100,16 @@ class Permission(enum.Enum):
|
||||
self == Permission.APPROVE_RELEASE or \
|
||||
self == Permission.APPROVE_SCREENSHOT or \
|
||||
self == Permission.SEE_THREAD:
|
||||
return user.rank.at_least(UserRank.APPROVER)
|
||||
return user.rank.atLeast(UserRank.APPROVER)
|
||||
|
||||
elif self == Permission.EDIT_TAGS or self == Permission.CREATE_TAG:
|
||||
return user.rank.at_least(UserRank.EDITOR)
|
||||
return user.rank.atLeast(UserRank.EDITOR)
|
||||
|
||||
else:
|
||||
raise Exception("Non-global permission checked globally. Use Package.check_perm or User.check_perm instead.")
|
||||
raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
|
||||
|
||||
@staticmethod
|
||||
def check_perm(user, perm):
|
||||
def checkPerm(user, perm):
|
||||
if type(perm) == str:
|
||||
perm = Permission[perm]
|
||||
elif type(perm) != Permission:
|
||||
@@ -166,17 +161,17 @@ class User(db.Model, UserMixin):
|
||||
|
||||
# Content
|
||||
notifications = db.relationship("Notification", foreign_keys="Notification.user_id",
|
||||
order_by=desc(text("Notification.created_at")), back_populates="user", cascade="all, delete, delete-orphan")
|
||||
order_by=desc(text("Notification.created_at")), back_populates="user", cascade="all, delete, delete-orphan")
|
||||
caused_notifications = db.relationship("Notification", foreign_keys="Notification.causer_id",
|
||||
back_populates="causer", cascade="all, delete, delete-orphan", lazy="dynamic")
|
||||
back_populates="causer", cascade="all, delete, delete-orphan", lazy="dynamic")
|
||||
notification_preferences = db.relationship("UserNotificationPreferences", uselist=False, back_populates="user",
|
||||
cascade="all, delete, delete-orphan")
|
||||
cascade="all, delete, delete-orphan")
|
||||
|
||||
email_verifications = db.relationship("UserEmailVerification", foreign_keys="UserEmailVerification.user_id",
|
||||
back_populates="user", cascade="all, delete, delete-orphan", lazy="dynamic")
|
||||
back_populates="user", cascade="all, delete, delete-orphan", lazy="dynamic")
|
||||
|
||||
audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.causer_id", back_populates="causer",
|
||||
order_by=desc("audit_log_entry_created_at"), lazy="dynamic")
|
||||
order_by=desc("audit_log_entry_created_at"), lazy="dynamic")
|
||||
|
||||
maintained_packages = db.relationship("Package", lazy="dynamic", secondary="maintainers", order_by=db.asc("package_title"))
|
||||
|
||||
@@ -187,30 +182,9 @@ class User(db.Model, UserMixin):
|
||||
threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
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")
|
||||
collections = db.relationship("Collection", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.asc("title"))
|
||||
clients = db.relationship("OAuthClient", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
|
||||
ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False)
|
||||
|
||||
def get_dict(self):
|
||||
from app.utils.flask import abs_url_for
|
||||
return {
|
||||
"username": self.username,
|
||||
"display_name": self.display_name,
|
||||
"rank": self.rank.name.lower(),
|
||||
"profile_pic_url": self.profile_pic,
|
||||
"website_url": self.website_url,
|
||||
"donate_url": self.donate_url,
|
||||
"connections": {
|
||||
"github": self.github_username,
|
||||
"forums": self.forums_username,
|
||||
},
|
||||
"links": {
|
||||
"api_packages": abs_url_for("api.packages", author=self.username),
|
||||
"profile": abs_url_for("users.profile", username=self.username),
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, username=None, active=False, email=None, password=None):
|
||||
self.username = username
|
||||
self.display_name = username
|
||||
@@ -219,10 +193,14 @@ class User(db.Model, UserMixin):
|
||||
self.password = password
|
||||
self.rank = UserRank.NOT_JOINED
|
||||
|
||||
def can_access_todo_list(self):
|
||||
return Permission.APPROVE_NEW.check(self) or Permission.APPROVE_RELEASE.check(self)
|
||||
def canAccessTodoList(self):
|
||||
return Permission.APPROVE_NEW.check(self) or \
|
||||
Permission.APPROVE_RELEASE.check(self)
|
||||
|
||||
def get_profile_pic_url(self):
|
||||
def isClaimed(self):
|
||||
return self.rank.atLeast(UserRank.NEW_MEMBER)
|
||||
|
||||
def getProfilePicURL(self):
|
||||
if self.profile_pic:
|
||||
return self.profile_pic
|
||||
elif self.rank == UserRank.BOT:
|
||||
@@ -230,79 +208,75 @@ class User(db.Model, UserMixin):
|
||||
else:
|
||||
return gravatar(self.email or f"{self.username}@content.minetest.net")
|
||||
|
||||
def check_perm(self, user, perm):
|
||||
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 User.check_perm()")
|
||||
raise Exception("Unknown permission given to User.checkPerm()")
|
||||
|
||||
# Members can edit their own packages, and editors can edit any packages
|
||||
if perm == Permission.CHANGE_AUTHOR:
|
||||
return user.rank.at_least(UserRank.EDITOR)
|
||||
return user.rank.atLeast(UserRank.EDITOR)
|
||||
elif perm == Permission.CHANGE_USERNAMES:
|
||||
return user.rank.at_least(UserRank.MODERATOR)
|
||||
return user.rank.atLeast(UserRank.MODERATOR)
|
||||
elif perm == Permission.CHANGE_RANK:
|
||||
return user.rank.at_least(UserRank.MODERATOR) and not self.rank.at_least(user.rank)
|
||||
return user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank)
|
||||
elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS:
|
||||
return user == self or (user.rank.at_least(UserRank.MODERATOR) and not self.rank.at_least(user.rank))
|
||||
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank))
|
||||
elif perm == Permission.CHANGE_DISPLAY_NAME:
|
||||
return user.rank.at_least(UserRank.NEW_MEMBER if user == self else UserRank.MODERATOR)
|
||||
elif perm == Permission.CREATE_TOKEN or perm == Permission.CREATE_OAUTH_CLIENT:
|
||||
return user.rank.atLeast(UserRank.MEMBER if user == self else UserRank.MODERATOR)
|
||||
elif perm == Permission.CREATE_TOKEN:
|
||||
if user == self:
|
||||
return user.rank.at_least(UserRank.NEW_MEMBER)
|
||||
return user.rank.atLeast(UserRank.MEMBER)
|
||||
else:
|
||||
return user.rank.at_least(UserRank.MODERATOR) and user.rank.at_least(self.rank)
|
||||
return user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank)
|
||||
else:
|
||||
raise Exception("Permission {} is not related to users".format(perm.name))
|
||||
|
||||
def can_comment_ratelimit(self):
|
||||
def canCommentRL(self):
|
||||
from app.models import ThreadReply
|
||||
|
||||
factor = 1
|
||||
if self.rank.at_least(UserRank.ADMIN):
|
||||
if self.rank.atLeast(UserRank.ADMIN):
|
||||
return True
|
||||
elif self.rank.at_least(UserRank.TRUSTED_MEMBER):
|
||||
factor = 3
|
||||
elif self.rank.at_least(UserRank.MEMBER):
|
||||
factor = 2
|
||||
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
|
||||
factor *= 2
|
||||
|
||||
one_min_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
|
||||
if ThreadReply.query.filter_by(author=self) \
|
||||
.filter(ThreadReply.created_at > one_min_ago).count() >= 2 * factor:
|
||||
.filter(ThreadReply.created_at > one_min_ago).count() >= 3 * factor:
|
||||
return False
|
||||
|
||||
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
|
||||
if ThreadReply.query.filter_by(author=self) \
|
||||
.filter(ThreadReply.created_at > hour_ago).count() >= 10 * factor:
|
||||
.filter(ThreadReply.created_at > hour_ago).count() >= 20 * factor:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def can_open_thread_ratelimit(self):
|
||||
def canOpenThreadRL(self):
|
||||
from app.models import Thread
|
||||
|
||||
factor = 1
|
||||
if self.rank.at_least(UserRank.ADMIN):
|
||||
if self.rank.atLeast(UserRank.ADMIN):
|
||||
return True
|
||||
elif self.rank.at_least(UserRank.TRUSTED_MEMBER):
|
||||
factor = 5
|
||||
elif self.rank.at_least(UserRank.MEMBER):
|
||||
factor = 2
|
||||
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
|
||||
factor *= 5
|
||||
|
||||
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
|
||||
return Thread.query.filter_by(author=self)\
|
||||
.filter(Thread.created_at > hour_ago).count() < 2 * factor
|
||||
return Thread.query.filter_by(author=self) \
|
||||
.filter(Thread.created_at > hour_ago).count() < 2 * factor
|
||||
|
||||
def can_review_ratelimit(self):
|
||||
def canReviewRL(self):
|
||||
from app.models import PackageReview
|
||||
|
||||
factor = 1
|
||||
if self.rank.at_least(UserRank.ADMIN):
|
||||
if self.rank.atLeast(UserRank.ADMIN):
|
||||
return True
|
||||
elif self.rank.at_least(UserRank.TRUSTED_MEMBER):
|
||||
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
|
||||
factor *= 5
|
||||
|
||||
five_mins_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=5)
|
||||
@@ -312,7 +286,8 @@ class User(db.Model, UserMixin):
|
||||
|
||||
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
|
||||
.filter(PackageReview.created_at > hour_ago).count() < 10 * factor
|
||||
|
||||
|
||||
def __eq__(self, other):
|
||||
if other is None:
|
||||
@@ -325,15 +300,13 @@ class User(db.Model, UserMixin):
|
||||
return self.id == other.id
|
||||
|
||||
def can_see_edit_profile(self, current_user):
|
||||
return self.check_perm(current_user, Permission.CHANGE_USERNAMES) or \
|
||||
self.check_perm(current_user, Permission.CHANGE_EMAIL) or \
|
||||
self.check_perm(current_user, Permission.CHANGE_RANK)
|
||||
return self.checkPerm(current_user, Permission.CHANGE_USERNAMES) or \
|
||||
self.checkPerm(current_user, Permission.CHANGE_EMAIL) or \
|
||||
self.checkPerm(current_user, Permission.CHANGE_RANK)
|
||||
|
||||
def can_delete(self):
|
||||
from app.models import ForumTopic
|
||||
return self.packages.count() == 0 and \
|
||||
ForumTopic.query.filter_by(author=self).count() == 0 and \
|
||||
self.rank != UserRank.BANNED
|
||||
return self.packages.count() == 0 and ForumTopic.query.filter_by(author=self).count() == 0
|
||||
|
||||
|
||||
class UserEmailVerification(db.Model):
|
||||
@@ -359,7 +332,7 @@ class EmailSubscription(db.Model):
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
from app.utils import abs_url_for
|
||||
from ..utils import abs_url_for
|
||||
return abs_url_for('users.unsubscribe', token=self.token)
|
||||
|
||||
|
||||
@@ -395,10 +368,10 @@ class NotificationType(enum.Enum):
|
||||
OTHER = 0
|
||||
|
||||
|
||||
def get_title(self):
|
||||
def getTitle(self):
|
||||
return self.name.replace("_", " ").title()
|
||||
|
||||
def to_name(self):
|
||||
def toName(self):
|
||||
return self.name.lower()
|
||||
|
||||
def get_description(self):
|
||||
@@ -433,7 +406,7 @@ class NotificationType(enum.Enum):
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(choice, choice.get_title()) for choice in cls]
|
||||
return [(choice, choice.getTitle()) for choice in cls]
|
||||
|
||||
@classmethod
|
||||
def coerce(cls, item):
|
||||
@@ -515,21 +488,21 @@ class UserNotificationPreferences(db.Model):
|
||||
self.pref_other = 0
|
||||
|
||||
def get_can_email(self, notification_type):
|
||||
return getattr(self, "pref_" + notification_type.to_name()) == 2
|
||||
return getattr(self, "pref_" + notification_type.toName()) == 2
|
||||
|
||||
def set_can_email(self, notification_type, value):
|
||||
value = 2 if value else 0
|
||||
setattr(self, "pref_" + notification_type.to_name(), value)
|
||||
setattr(self, "pref_" + notification_type.toName(), value)
|
||||
|
||||
def get_can_digest(self, notification_type):
|
||||
return getattr(self, "pref_" + notification_type.to_name()) >= 1
|
||||
return getattr(self, "pref_" + notification_type.toName()) >= 1
|
||||
|
||||
def set_can_digest(self, notification_type, value):
|
||||
if self.get_can_email(notification_type):
|
||||
return
|
||||
|
||||
value = 1 if value else 0
|
||||
setattr(self, "pref_" + notification_type.to_name(), value)
|
||||
setattr(self, "pref_" + notification_type.toName(), value)
|
||||
|
||||
|
||||
class UserBan(db.Model):
|
||||
@@ -548,22 +521,3 @@ class UserBan(db.Model):
|
||||
@property
|
||||
def has_expired(self):
|
||||
return self.expires_at and datetime.datetime.now() > self.expires_at
|
||||
|
||||
|
||||
class OAuthClient(db.Model):
|
||||
__tablename__ = "oauth_client"
|
||||
|
||||
id = db.Column(db.String(24), primary_key=True)
|
||||
title = db.Column(db.String(64), unique=True, nullable=False)
|
||||
description = db.Column(db.String(300), nullable=True)
|
||||
secret = db.Column(db.String(32), nullable=False)
|
||||
redirect_url = db.Column(db.String(128), nullable=False)
|
||||
approved = db.Column(db.Boolean, nullable=False, default=False)
|
||||
verified = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
owner = db.relationship("User", foreign_keys=[owner_id], back_populates="clients")
|
||||
|
||||
tokens = db.relationship("APIToken", back_populates="client", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /packages/*/*/download/
|
||||
Disallow: /packages/*/*/releases/*/download/
|
||||
Disallow: /report/
|
||||
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;
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,211 +0,0 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
"use strict";
|
||||
|
||||
|
||||
function updateOrder() {
|
||||
const elements = [...document.querySelector(".sortable").children];
|
||||
const ids = elements
|
||||
.filter(x => !x.classList.contains("d-none"))
|
||||
.map(x => x.dataset.id)
|
||||
.filter(x => x);
|
||||
|
||||
document.querySelector("input[name='order']").value = ids.join(",");
|
||||
}
|
||||
|
||||
|
||||
function removePackage(card) {
|
||||
const message = document.getElementById("confirm_delete").innerText.trim();
|
||||
const title = card.querySelector("h5 a").innerText.trim();
|
||||
if (!confirm(message.replace("{title}", title))) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.querySelector("input[name^=package_removed]").value = "1";
|
||||
card.classList.add("d-none");
|
||||
onPackageQueryUpdate();
|
||||
updateOrder();
|
||||
}
|
||||
|
||||
|
||||
function restorePackage(id) {
|
||||
const idElement = document.querySelector(`[value='${id}']`);
|
||||
if (!idElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const card = idElement.parentNode.parentNode.parentNode.parentNode;
|
||||
console.assert(card.classList.contains("card"));
|
||||
|
||||
card.classList.remove("d-none");
|
||||
card.querySelector("input[name^=package_removed]").value = "0";
|
||||
card.scrollIntoView();
|
||||
onPackageQueryUpdate();
|
||||
updateOrder();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
function getAddedPackages() {
|
||||
const ids = document.querySelectorAll("#package_list > article:not(.d-none) input[name^=package_ids]");
|
||||
return [...ids].map(x => x.value);
|
||||
}
|
||||
|
||||
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
|
||||
function addPackage(pkg) {
|
||||
document.getElementById("add_package").value = "";
|
||||
document.getElementById("add_package_results").innerHTML = "";
|
||||
|
||||
const id = `${pkg.author}/${pkg.name}`;
|
||||
if (restorePackage(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextId = document.querySelectorAll("input[name^=package_ids-]").length;
|
||||
const url = `/packages/${id}/`;
|
||||
const temp = document.createElement("div");
|
||||
temp.innerHTML = `
|
||||
<article class="card my-3" data-id="${escapeHtml(id)}">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-auto text-muted pe-2">
|
||||
<i class="fas fa-bars"></i>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button class="btn btn-sm btn-danger remove-package float-end" type="button" aria-label="Remove">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<h5>
|
||||
<a href="${escapeHtml(url)}" target="_blank">
|
||||
${escapeHtml(pkg.title)} by ${escapeHtml(pkg.author)}
|
||||
</a>
|
||||
</h5>
|
||||
<p class="text-muted">
|
||||
${escapeHtml(pkg.short_description)}
|
||||
</p>
|
||||
<input id="package_ids-${nextId}" name="package_ids-${nextId}" type="hidden" value="${id}">
|
||||
<input id="package_removed-${nextId}" name="package_removed-${nextId}" type="hidden" value="0">
|
||||
<div>
|
||||
<label for="descriptions-${nextId}" class="form-label">Short Description</label>
|
||||
<input class="form-control" id="descriptions-${nextId}" maxlength="500" minlength="0"
|
||||
name="descriptions-${nextId}" type="text" value="">
|
||||
<small class="form-text text-muted">You can replace the description with your own</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
|
||||
const card = temp.children[0];
|
||||
document.getElementById("package_list").appendChild(card);
|
||||
card.scrollIntoView();
|
||||
|
||||
const button = card.querySelector(".btn-danger");
|
||||
button.addEventListener("click", () => removePackage(card));
|
||||
|
||||
updateOrder();
|
||||
}
|
||||
|
||||
|
||||
function updateResults(packages) {
|
||||
const results = document.getElementById("add_package_results");
|
||||
results.innerHTML = "";
|
||||
document.getElementById("add_package_empty").style.display = packages.length === 0 ? "block" : "none";
|
||||
|
||||
const alreadyAdded = getAddedPackages();
|
||||
packages.slice(0, 5).forEach(pkg => {
|
||||
const result = document.createElement("a");
|
||||
result.classList.add("list-group-item");
|
||||
result.classList.add("list-group-item-action");
|
||||
result.innerText = `${pkg.title} by ${pkg.author}`;
|
||||
if (alreadyAdded.includes(`${pkg.author}/${pkg.name}`)) {
|
||||
result.classList.add("active");
|
||||
result.innerHTML = "<i class='fas fa-check me-3 text-success'></i>" + result.innerHTML;
|
||||
}
|
||||
result.addEventListener("click", () => addPackage(pkg));
|
||||
results.appendChild(result);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
let currentRequestId;
|
||||
|
||||
async function fetchPackagesAndUpdateResults(query) {
|
||||
const requestId = Math.random() * 1000000;
|
||||
currentRequestId = requestId;
|
||||
if (query === "") {
|
||||
updateResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL("/api/packages/", window.location.origin);
|
||||
url.searchParams.set("q", query);
|
||||
const resp = await fetch(url.toString());
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packages = await resp.json();
|
||||
if (currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateResults(packages);
|
||||
}
|
||||
|
||||
|
||||
let timeoutHandle;
|
||||
function onPackageQueryUpdate() {
|
||||
const query = document.getElementById("add_package").value.trim();
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
timeoutHandle = setTimeout(
|
||||
() => fetchPackagesAndUpdateResults(query).catch(console.error),
|
||||
200);
|
||||
}
|
||||
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
document.querySelectorAll(".remove-package").forEach(button => {
|
||||
const card = button.parentNode.parentNode.parentNode.parentNode;
|
||||
console.assert(card.classList.contains("card"));
|
||||
|
||||
const field = card.querySelector("input[name^=package_removed]");
|
||||
|
||||
// Reloading/validation errors will cause this to be 1 at load
|
||||
if (field && field.value === "1") {
|
||||
card.classList.add("d-none");
|
||||
} else {
|
||||
button.addEventListener("click", () => removePackage(card));
|
||||
}
|
||||
});
|
||||
|
||||
const addPackageQuery = document.getElementById("add_package");
|
||||
addPackageQuery.value = "";
|
||||
addPackageQuery.classList.remove("d-none");
|
||||
addPackageQuery.addEventListener("input", onPackageQueryUpdate);
|
||||
addPackageQuery.addEventListener('keydown',(e)=>{
|
||||
if (e.key === "Enter") {
|
||||
onPackageQueryUpdate();
|
||||
e.preventDefault();
|
||||
}
|
||||
})
|
||||
|
||||
updateOrder();
|
||||
$(".sortable").sortable({
|
||||
update: updateOrder,
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
// @author recluse4615
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
"use strict";
|
||||
|
||||
const galleryCarousel = new bootstrap.Carousel(document.getElementById("galleryCarousel"));
|
||||
document.querySelectorAll(".gallery-image").forEach(el => {
|
||||
el.addEventListener("click", function(e) {
|
||||
galleryCarousel.to(el.dataset.bsSlideTo);
|
||||
e.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
"use strict";
|
||||
|
||||
const labelColor = "#bbb";
|
||||
const annotationColor = "#bbb";
|
||||
const annotationLabelBgColor = "#444";
|
||||
const gridColor = "#333";
|
||||
|
||||
|
||||
const chartColors = [
|
||||
"#7eb26d",
|
||||
"#eab839",
|
||||
"#6ed0e0",
|
||||
"#e24d42",
|
||||
"#1f78c1",
|
||||
"#ba43a9",
|
||||
];
|
||||
|
||||
|
||||
const annotationNov5 = {
|
||||
type: "line",
|
||||
borderColor: annotationColor,
|
||||
borderWidth: 1,
|
||||
click: function({chart, element}) {
|
||||
document.location = "https://blog.rubenwardy.com/2022/12/08/contentdb-youtuber-finds-minetest/";
|
||||
},
|
||||
label: {
|
||||
backgroundColor: annotationLabelBgColor,
|
||||
content: "YouTube Video 🡕",
|
||||
display: true,
|
||||
position: "end",
|
||||
color: "#00bc8c",
|
||||
rotation: "auto",
|
||||
backgroundShadowColor: "rgba(0, 0, 0, 0.4)",
|
||||
shadowBlur: 3,
|
||||
},
|
||||
scaleID: "x",
|
||||
value: "2022-11-05",
|
||||
};
|
||||
|
||||
|
||||
function hexToRgb(hex) {
|
||||
var bigint = parseInt(hex, 16);
|
||||
var r = (bigint >> 16) & 255;
|
||||
var g = (bigint >> 8) & 255;
|
||||
var b = bigint & 255;
|
||||
|
||||
return r + "," + g + "," + b;
|
||||
}
|
||||
|
||||
|
||||
function sum(list) {
|
||||
return list.reduce((acc, x) => acc + x, 0);
|
||||
}
|
||||
|
||||
|
||||
const chartColorsBg = chartColors.map(color => `rgba(${hexToRgb(color.slice(1))}, 0.2)`);
|
||||
|
||||
const SECONDS_IN_A_DAY = 1000 * 3600 * 24;
|
||||
|
||||
|
||||
function format_message(id, values) {
|
||||
let format = document.getElementById(id).textContent;
|
||||
values.forEach((value, i) => {
|
||||
format = format.replace("$" + (i + 1), value);
|
||||
})
|
||||
return format;
|
||||
}
|
||||
|
||||
|
||||
function add_summary_card(title, icon, value, extra) {
|
||||
const ele = document.createElement("div");
|
||||
ele.innerHTML = `
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body align-items-center text-center">
|
||||
<div class="mt-0 mb-3">
|
||||
<i class="fas fa-${icon} me-1"></i>
|
||||
<span class="summary-title"></span>
|
||||
</div>
|
||||
<div class="my-0 h4">
|
||||
<span class="summary-value"></span>
|
||||
<small class="text-muted ms-2 summary-extra"></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
ele.querySelector(".summary-title").textContent = title;
|
||||
ele.querySelector(".summary-value").textContent = value;
|
||||
ele.querySelector(".summary-extra").textContent = extra;
|
||||
|
||||
document.getElementById("stats-summaries").appendChild(ele.children[0]);
|
||||
}
|
||||
|
||||
async function load_data() {
|
||||
const root = document.getElementById("stats-root");
|
||||
const source = root.getAttribute("data-source");
|
||||
const is_range = root.getAttribute("data-is-range") == "true";
|
||||
const response = await fetch(source);
|
||||
const json = await response.json();
|
||||
|
||||
document.getElementById("loading").style.display = "none";
|
||||
|
||||
if (json == null) {
|
||||
document.getElementById("empty-view").style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
const startDate = new Date(json.start);
|
||||
const endDate = new Date(json.end);
|
||||
const numberOfDays = Math.round((endDate.valueOf() - startDate.valueOf()) / SECONDS_IN_A_DAY) + 1;
|
||||
const dates = [...Array(numberOfDays)].map((_, i) => {
|
||||
const date = new Date(startDate.valueOf() + i*SECONDS_IN_A_DAY);
|
||||
return date.toISOString().split("T")[0];
|
||||
});
|
||||
|
||||
if (!is_range) {
|
||||
if (json.platform_minetest.length >= 30) {
|
||||
const total30 = sum(json.platform_minetest.slice(-30)) + sum(json.platform_other.slice(-30));
|
||||
add_summary_card(format_message("downloads-30days", []), "download", total30,
|
||||
format_message("downloads-per-day", [ (total30 / 30).toFixed(0) ]));
|
||||
}
|
||||
|
||||
const total7 = sum(json.platform_minetest.slice(-7)) + sum(json.platform_other.slice(-7));
|
||||
add_summary_card(format_message("downloads-7days", []), "download", total7,
|
||||
format_message("downloads-per-day", [ (total7 / 7).toFixed(0) ]));
|
||||
} else {
|
||||
const total = sum(json.platform_minetest) + sum(json.platform_other);
|
||||
const days = Math.max(json.platform_minetest.length, json.platform_other.length);
|
||||
const title = format_message("downloads-range", [ json.start, json.end ]);
|
||||
add_summary_card(title, "download", total,
|
||||
format_message("downloads-per-day", [ (total / days).toFixed(0) ]));
|
||||
}
|
||||
|
||||
const jsonOther = json.platform_minetest.map((value, i) =>
|
||||
value + json.platform_other[i]
|
||||
- json.reason_new[i] - json.reason_dependency[i]
|
||||
- json.reason_update[i]);
|
||||
|
||||
root.style.display = "block";
|
||||
|
||||
function getData(list) {
|
||||
return list.map((value, i) => ({ x: dates[i], y: value }));
|
||||
}
|
||||
|
||||
const annotations = {};
|
||||
if (new Date(json.start) < new Date("2022-11-05")) {
|
||||
annotations.annotationNov5 = annotationNov5;
|
||||
}
|
||||
|
||||
if (json.package_downloads) {
|
||||
const packageRecentDownloads = Object.fromEntries(Object.entries(json.package_downloads)
|
||||
.map(([label, values]) => [label, sum(values.slice(-30))]));
|
||||
|
||||
document.getElementById("downloads-by-package").classList.remove("d-none");
|
||||
const ctx = document.getElementById("chart-packages").getContext("2d");
|
||||
|
||||
const data = {
|
||||
datasets: Object.entries(json.package_downloads)
|
||||
.sort((a, b) => packageRecentDownloads[a[0]] - packageRecentDownloads[b[0]])
|
||||
.map(([label, values]) => ({ label, data: getData(values) })),
|
||||
};
|
||||
setup_chart(ctx, data, annotations);
|
||||
}
|
||||
|
||||
{
|
||||
const ctx = document.getElementById("chart-platform").getContext("2d");
|
||||
const data = {
|
||||
datasets: [
|
||||
{ label: "Web / other", data: getData(json.platform_other) },
|
||||
{ label: "Minetest", data: getData(json.platform_minetest) },
|
||||
],
|
||||
};
|
||||
setup_chart(ctx, data, annotations);
|
||||
}
|
||||
|
||||
{
|
||||
const ctx = document.getElementById("chart-reason").getContext("2d");
|
||||
const data = {
|
||||
datasets: [
|
||||
{ label: "Other / Unknown", data: getData(jsonOther) },
|
||||
{ label: "Update", data: getData(json.reason_update) },
|
||||
{ label: "Dependency", data: getData(json.reason_dependency) },
|
||||
{ label: "New Install", data: getData(json.reason_new) },
|
||||
],
|
||||
};
|
||||
setup_chart(ctx, data, annotations);
|
||||
}
|
||||
|
||||
{
|
||||
const ctx = document.getElementById("chart-reason-pie").getContext("2d");
|
||||
const data = {
|
||||
labels: [
|
||||
"New Install",
|
||||
"Dependency",
|
||||
"Update",
|
||||
"Other / Unknown",
|
||||
],
|
||||
datasets: [{
|
||||
label: "My First Dataset",
|
||||
data: [
|
||||
sum(json.reason_new),
|
||||
sum(json.reason_dependency),
|
||||
sum(json.reason_update),
|
||||
sum(jsonOther),
|
||||
],
|
||||
backgroundColor: chartColors,
|
||||
hoverOffset: 4,
|
||||
borderWidth: 0,
|
||||
}]
|
||||
};
|
||||
const config = {
|
||||
type: "doughnut",
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: labelColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
new Chart(ctx, config);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function setup_chart(ctx, data, annotations) {
|
||||
data.datasets = data.datasets.map((set, i) => {
|
||||
const colorIdx = (data.datasets.length - i - 1) % chartColors.length;
|
||||
return {
|
||||
fill: true,
|
||||
backgroundColor: chartColorsBg[colorIdx],
|
||||
borderColor: chartColors[colorIdx],
|
||||
pointBackgroundColor: chartColors[colorIdx],
|
||||
...set,
|
||||
};
|
||||
});
|
||||
|
||||
const config = {
|
||||
type: "line",
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
mode: "index"
|
||||
},
|
||||
|
||||
legend: {
|
||||
reverse: true,
|
||||
labels: {
|
||||
color: labelColor,
|
||||
}
|
||||
},
|
||||
|
||||
annotation: {
|
||||
annotations,
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: "nearest",
|
||||
axis: "x",
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
time: {
|
||||
// min: start,
|
||||
// max: end,
|
||||
unit: "day",
|
||||
},
|
||||
ticks: {
|
||||
color: labelColor,
|
||||
},
|
||||
grid: {
|
||||
color: gridColor,
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
min: 0,
|
||||
precision: 0,
|
||||
ticks: {
|
||||
color: labelColor,
|
||||
},
|
||||
grid: {
|
||||
color: gridColor,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
new Chart(ctx, config);
|
||||
}
|
||||
|
||||
|
||||
window.addEventListener("load", load_data);
|
||||
@@ -1,74 +0,0 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
"use strict";
|
||||
|
||||
function hide(sel) {
|
||||
document.querySelectorAll(sel).forEach(x => x.classList.add("d-none"));
|
||||
}
|
||||
|
||||
function show(sel) {
|
||||
document.querySelectorAll(sel).forEach(x => x.classList.remove("d-none"));
|
||||
}
|
||||
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
function finish() {
|
||||
hide(".pkg_wiz_1");
|
||||
hide(".pkg_wiz_2");
|
||||
show(".pkg_repo");
|
||||
show(".pkg_meta");
|
||||
}
|
||||
|
||||
hide(".pkg_meta");
|
||||
show(".pkg_wiz_1");
|
||||
|
||||
document.getElementById("pkg_wiz_1_skip").addEventListener("click", finish);
|
||||
document.getElementById("pkg_wiz_1_next").addEventListener("click", () => {
|
||||
const repoURL = document.getElementById("repo").value;
|
||||
if (repoURL.trim() !== "") {
|
||||
hide(".pkg_wiz_1");
|
||||
show(".pkg_wiz_2");
|
||||
hide(".pkg_repo");
|
||||
|
||||
function setField(sel, value) {
|
||||
if (value && value !== "") {
|
||||
const ele = document.querySelector(sel);
|
||||
ele.value = value;
|
||||
ele.dispatchEvent(new Event("change"));
|
||||
|
||||
// EasyMDE doesn't always refresh the codemirror correctly
|
||||
if (ele.easy_mde) {
|
||||
setTimeout(() => {
|
||||
ele.easy_mde.value(value);
|
||||
ele.easy_mde.codemirror.refresh()
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
performTask("/tasks/getmeta/new/?url=" + encodeURI(repoURL)).then(function(result) {
|
||||
setField("#name", result.name);
|
||||
setField("#title", result.title);
|
||||
setField("#repo", result.repo || repoURL);
|
||||
setField("#issueTracker", result.issueTracker);
|
||||
setField("#desc", result.desc);
|
||||
setField("#short_desc", result.short_desc);
|
||||
setField("#forums", result.forums);
|
||||
if (result.type && result.type.length > 2) {
|
||||
setField("[name='type']", result.type);
|
||||
}
|
||||
|
||||
finish();
|
||||
}).catch(function(e) {
|
||||
alert(e);
|
||||
show(".pkg_wiz_1");
|
||||
hide(".pkg_wiz_2");
|
||||
show(".pkg_repo");
|
||||
// finish()
|
||||
});
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
"use strict";
|
||||
|
||||
function hide(sel) {
|
||||
document.querySelectorAll(sel).forEach(x => x.classList.add("d-none"));
|
||||
}
|
||||
|
||||
function show(sel) {
|
||||
document.querySelectorAll(sel).forEach(x => x.classList.remove("d-none"));
|
||||
}
|
||||
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
const typeEle = document.getElementById("type");
|
||||
typeEle.addEventListener("change", () => {
|
||||
show(".not_mod, .not_game, .not_txp");
|
||||
hide(".not_" + typeEle.value.toLowerCase());
|
||||
})
|
||||
show(".not_mod, .not_game, .not_txp");
|
||||
hide(".not_" + typeEle.value.toLowerCase());
|
||||
|
||||
const forumsField = document.getElementById("forums");
|
||||
forumsField.addEventListener("paste", function(e) {
|
||||
try {
|
||||
const pasteData = e.clipboardData.getData('text');
|
||||
const url = new URL(pasteData);
|
||||
if (url.hostname === "forum.minetest.net") {
|
||||
forumsField.value = url.searchParams.get("t");
|
||||
e.preventDefault();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Not a URL");
|
||||
}
|
||||
});
|
||||
|
||||
const openForums = document.getElementById("forums-button");
|
||||
openForums.addEventListener("click", () => {
|
||||
window.open("https://forum.minetest.net/viewtopic.php?t=" + forumsField.value, "_blank");
|
||||
});
|
||||
|
||||
let hint = null;
|
||||
function showHint(ele, text) {
|
||||
if (hint) {
|
||||
hint.remove();
|
||||
}
|
||||
|
||||
hint = document.createElement("div");
|
||||
hint.classList.add("alert");
|
||||
hint.classList.add("alert-warning");
|
||||
hint.classList.add("my-1");
|
||||
hint.innerHTML = text;
|
||||
|
||||
ele.parentNode.appendChild(hint);
|
||||
}
|
||||
|
||||
let hint_mtmods = `Tip:
|
||||
Don't include <i>Minetest</i>, <i>mod</i>, or <i>modpack</i> anywhere in the short description.
|
||||
It is unnecessary and wastes characters.`;
|
||||
|
||||
let hint_thegame = `Tip:
|
||||
It's obvious that this adds something to Minetest,
|
||||
there's no need to use phrases such as \"adds X to the game\".`;
|
||||
|
||||
const shortDescField = document.getElementById("short_desc");
|
||||
|
||||
function handleShortDescChange() {
|
||||
const val = shortDescField.value.toLowerCase();
|
||||
if (val.indexOf("minetest") >= 0 || val.indexOf("mod") >= 0 ||
|
||||
val.indexOf("modpack") >= 0 || val.indexOf("mod pack") >= 0) {
|
||||
showHint(shortDescField, hint_mtmods);
|
||||
} else if (val.indexOf("the game") >= 0) {
|
||||
showHint(shortDescField, hint_thegame);
|
||||
} else if (hint) {
|
||||
hint.remove();
|
||||
hint = null;
|
||||
}
|
||||
}
|
||||
|
||||
shortDescField.addEventListener("change", handleShortDescChange);
|
||||
shortDescField.addEventListener("paste", handleShortDescChange);
|
||||
shortDescField.addEventListener("keyup", handleShortDescChange);
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
"use strict";
|
||||
|
||||
|
||||
async function getJSON(url, method) {
|
||||
const response = await fetch(new Request(url, {
|
||||
method: method || "get",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
},
|
||||
}));
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
|
||||
function sleep(interval) {
|
||||
return new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
|
||||
async function pollTask(poll_url, disableTimeout) {
|
||||
let tries = 0;
|
||||
|
||||
while (true) {
|
||||
tries++;
|
||||
if (!disableTimeout && tries > 30) {
|
||||
throw "timeout";
|
||||
} else {
|
||||
const interval = Math.min(tries * 100, 1000);
|
||||
console.log("Polling task in " + interval + "ms");
|
||||
await sleep(interval);
|
||||
}
|
||||
|
||||
let res = undefined;
|
||||
try {
|
||||
res = await getJSON(poll_url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
if (res && res.status === "SUCCESS") {
|
||||
console.log("Got result")
|
||||
return res.result;
|
||||
} else if (res && (res.status === "FAILURE" || res.status === "REVOKED")) {
|
||||
throw res.error ?? "Unknown server error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function performTask(url) {
|
||||
const startResult = await getJSON(url, "post");
|
||||
console.log(startResult);
|
||||
|
||||
if (typeof startResult.poll_url == "string") {
|
||||
return await pollTask(startResult.poll_url);
|
||||
} else {
|
||||
throw "Start task didn't return string!";
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
"use strict";
|
||||
|
||||
function getVoteCount(button) {
|
||||
const badge = button.querySelector(".badge");
|
||||
return badge ? parseInt(badge.textContent) : 0;
|
||||
}
|
||||
|
||||
function setVoteCount(button, count) {
|
||||
let badge = button.querySelector(".badge");
|
||||
if (count == 0) {
|
||||
if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!badge) {
|
||||
badge = document.createElement("span")
|
||||
badge.classList.add("badge");
|
||||
badge.classList.add("bg-light");
|
||||
badge.classList.add("text-dark");
|
||||
badge.classList.add("ms-1");
|
||||
button.appendChild(badge);
|
||||
}
|
||||
|
||||
badge.textContent = count.toString();
|
||||
}
|
||||
|
||||
|
||||
async function submitForm(form, is_helpful) {
|
||||
const data = new URLSearchParams();
|
||||
for (const pair of new FormData(form)) {
|
||||
data.append(pair[0], pair[1]);
|
||||
}
|
||||
data.set("is_positive", is_helpful ? "yes" : "no");
|
||||
|
||||
const res = await fetch(form.getAttribute("action"), {
|
||||
method: "post",
|
||||
body: data,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
alert(json.error ?? "Unknown server error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function setButtonSelected(ele, isSelected) {
|
||||
if (isSelected) {
|
||||
ele.classList.add("btn-primary");
|
||||
ele.classList.remove("btn-secondary");
|
||||
} else {
|
||||
ele.classList.add("btn-secondary");
|
||||
ele.classList.remove("btn-primary");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
document.querySelectorAll(".review-helpful-vote").forEach((helpful_form) => {
|
||||
const yes = helpful_form.querySelector("button[name='is_positive'][value='yes']");
|
||||
const no = helpful_form.querySelector("button[name='is_positive'][value='no']");
|
||||
|
||||
function setVote(is_helpful) {
|
||||
const selected = is_helpful ? yes : no;
|
||||
const not_selected = is_helpful ? no : yes;
|
||||
|
||||
if (not_selected.classList.contains("btn-primary")) {
|
||||
setVoteCount(not_selected, Math.max(getVoteCount(not_selected) - 1, 0));
|
||||
setButtonSelected(not_selected, false);
|
||||
}
|
||||
|
||||
if (selected.classList.contains("btn-secondary")) {
|
||||
setVoteCount(selected, getVoteCount(selected) + 1);
|
||||
setButtonSelected(selected, true);
|
||||
} else if (selected.classList.contains("btn-primary")) {
|
||||
setVoteCount(selected, Math.max(getVoteCount(selected) - 1, 0));
|
||||
setButtonSelected(selected, false);
|
||||
}
|
||||
|
||||
submitForm(helpful_form, is_helpful).catch(console.error);
|
||||
}
|
||||
|
||||
yes.addEventListener("click", (e) => {
|
||||
setVote(true);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
no.addEventListener("click", (e) => {
|
||||
setVote(false)
|
||||
e.preventDefault();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
"use strict";
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
function setup_toggle(type) {
|
||||
const toggle = document.getElementById("set_" + type);
|
||||
|
||||
function on_change() {
|
||||
const rel = document.getElementById(type + "_rel");
|
||||
if (toggle.checked) {
|
||||
rel.parentElement.style.opacity = "1";
|
||||
} else {
|
||||
// $("#" + type + "_rel").attr("disabled", "disabled");
|
||||
rel.parentElement.style.opacity = "0.4";
|
||||
rel.value = document.querySelector(`#${type}_rel option:first-child`).value;
|
||||
rel.dispatchEvent(new Event("change"));
|
||||
}
|
||||
}
|
||||
|
||||
toggle.addEventListener("change", on_change);
|
||||
on_change();
|
||||
}
|
||||
|
||||
setup_toggle("min");
|
||||
setup_toggle("max");
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
"use strict";
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
const min = document.getElementById("min_rel");
|
||||
const max = document.getElementById("max_rel");
|
||||
const none = parseInt(document.querySelector("#min_rel option:first-child").value);
|
||||
const warning = document.getElementById("minmax_warning");
|
||||
|
||||
function ver_check() {
|
||||
const minv = parseInt(min.value);
|
||||
const maxv = parseInt(max.value);
|
||||
if (minv != none && maxv != none && minv > maxv) {
|
||||
warning.style.display = "block";
|
||||
} else {
|
||||
warning.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
min.addEventListener("change", ver_check);
|
||||
max.addEventListener("change", ver_check);
|
||||
ver_check();
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
"use strict";
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
function check_opt() {
|
||||
if (document.querySelector("input[name='uploadOpt']:checked").value === "vcs") {
|
||||
document.getElementById("file_upload").parentElement.classList.add("d-none");
|
||||
document.getElementById("vcsLabel").parentElement.classList.remove("d-none");
|
||||
} else {
|
||||
document.getElementById("file_upload").parentElement.classList.remove("d-none");
|
||||
document.getElementById("vcsLabel").parentElement.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll("input[name='uploadOpt']").forEach(x => x.addEventListener("change", check_opt));
|
||||
check_opt();
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
"use strict";
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
function update() {
|
||||
const elements = [...document.querySelector(".sortable").children];
|
||||
const ids = elements.map(x => x.dataset.id).filter(x => x);
|
||||
document.querySelector("input[name='order']").value = ids.join(",");
|
||||
}
|
||||
|
||||
update();
|
||||
$(".sortable").sortable({
|
||||
update: update
|
||||
});
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
// @author rubenwardy
|
||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||
|
||||
"use strict";
|
||||
|
||||
document.querySelectorAll(".topic-discard").forEach(ele => ele.addEventListener("click", (e) => {
|
||||
const row = ele.parentNode.parentNode;
|
||||
const tid = ele.getAttribute("data-tid");
|
||||
const discard = !row.classList.contains("discardtopic");
|
||||
fetch(new Request("/api/topic_discard/?tid=" + tid +
|
||||
"&discard=" + (discard ? "true" : "false"), {
|
||||
method: "post",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"X-CSRFToken": csrf_token,
|
||||
},
|
||||
})).then(function(response) {
|
||||
response.text().then(function(txt) {
|
||||
if (JSON.parse(txt).discarded) {
|
||||
row.classList.add("discardtopic");
|
||||
ele.classList.remove("btn-danger");
|
||||
ele.classList.add("btn-success");
|
||||
ele.innerText = "Show";
|
||||
} else {
|
||||
row.classList.remove("discardtopic");
|
||||
ele.classList.remove("btn-success");
|
||||
ele.classList.add("btn-danger");
|
||||
ele.innerText = "Discard";
|
||||
}
|
||||
}).catch(console.error);
|
||||
}).catch(console.error);
|
||||
}));
|
||||
16
app/public/static/libs/bootstrap.min.css
vendored
16
app/public/static/libs/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
8
app/public/static/libs/bootstrap.min.js
vendored
8
app/public/static/libs/bootstrap.min.js
vendored
File diff suppressed because one or more lines are too long
13
app/public/static/libs/chart.min.js
vendored
13
app/public/static/libs/chart.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
6
app/public/static/libs/jquery-ui.min.css
vendored
6
app/public/static/libs/jquery-ui.min.css
vendored
File diff suppressed because one or more lines are too long
4
app/public/static/libs/jquery-ui.min.js
vendored
4
app/public/static/libs/jquery-ui.min.js
vendored
File diff suppressed because one or more lines are too long
4
app/public/static/libs/jquery.min.js
vendored
4
app/public/static/libs/jquery.min.js
vendored
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user