Compare commits
1 Commits
master
...
view-stats
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26d44ba357 |
@@ -1,7 +1,7 @@
|
|||||||
# ContentDB
|
# ContentDB
|
||||||

|

|
||||||
|
|
||||||
A content database for Luanti mods, games, and more.\
|
A content database for Minetest mods, games, and more.\
|
||||||
Developed by rubenwardy, license AGPLv3.0+.
|
Developed by rubenwardy, license AGPLv3.0+.
|
||||||
|
|
||||||
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
|
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
|
||||||
@@ -82,7 +82,7 @@ Package "1" --> "*" Release
|
|||||||
Package "1" --> "*" Dependency
|
Package "1" --> "*" Dependency
|
||||||
Package "1" --> "*" Tag
|
Package "1" --> "*" Tag
|
||||||
Package "1" --> "*" MetaPackage : provides
|
Package "1" --> "*" MetaPackage : provides
|
||||||
Release --> LuantiVersion
|
Release --> MinetestVersion
|
||||||
Package --> License
|
Package --> License
|
||||||
Dependency --> Package
|
Dependency --> Package
|
||||||
Dependency --> MetaPackage
|
Dependency --> MetaPackage
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ import redis
|
|||||||
from flask import redirect, url_for, render_template, flash, request, Flask, send_from_directory, make_response, render_template_string
|
from flask import redirect, url_for, render_template, flash, request, Flask, send_from_directory, make_response, render_template_string
|
||||||
from flask_babel import Babel, gettext
|
from flask_babel import Babel, gettext
|
||||||
from flask_flatpages import FlatPages
|
from flask_flatpages import FlatPages
|
||||||
|
from flask_flatpages.utils import pygmented_markdown
|
||||||
from flask_github import GitHub
|
from flask_github import GitHub
|
||||||
from flask_login import logout_user, current_user, LoginManager
|
from flask_login import logout_user, current_user, LoginManager
|
||||||
from flask_mail import Mail
|
from flask_mail import Mail
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
|
||||||
from app.markdown import init_markdown, render_markdown
|
from app.markdown import init_markdown, MARKDOWN_EXTENSIONS, MARKDOWN_EXTENSION_CONFIG
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||||
@@ -66,11 +67,13 @@ app = Flask(__name__, static_folder="public/static")
|
|||||||
def my_flatpage_renderer(text):
|
def my_flatpage_renderer(text):
|
||||||
# Render with jinja first
|
# Render with jinja first
|
||||||
prerendered_body = render_template_string(text)
|
prerendered_body = render_template_string(text)
|
||||||
return render_markdown(prerendered_body, clean=False)
|
return pygmented_markdown(prerendered_body, flatpages=pages)
|
||||||
|
|
||||||
|
|
||||||
app.config["FLATPAGES_ROOT"] = "flatpages"
|
app.config["FLATPAGES_ROOT"] = "flatpages"
|
||||||
app.config["FLATPAGES_EXTENSION"] = ".md"
|
app.config["FLATPAGES_EXTENSION"] = ".md"
|
||||||
|
app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = MARKDOWN_EXTENSIONS
|
||||||
|
app.config["FLATPAGES_EXTENSION_CONFIG"] = MARKDOWN_EXTENSION_CONFIG
|
||||||
app.config["FLATPAGES_HTML_RENDERER"] = my_flatpage_renderer
|
app.config["FLATPAGES_HTML_RENDERER"] = my_flatpage_renderer
|
||||||
app.config["WTF_CSRF_TIME_LIMIT"] = None
|
app.config["WTF_CSRF_TIME_LIMIT"] = None
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ pgettext("tags", "For mods created for the Discord \"Weekly Challenges\" modding
|
|||||||
# NOTE: tags: title for less_than_px
|
# NOTE: tags: title for less_than_px
|
||||||
pgettext("tags", "<16px")
|
pgettext("tags", "<16px")
|
||||||
# NOTE: tags: description for less_than_px
|
# NOTE: tags: description for less_than_px
|
||||||
pgettext("tags", "For less than 16px texture packs ")
|
pgettext("tags", "Less than 16px")
|
||||||
# NOTE: tags: title for library
|
# NOTE: tags: title for library
|
||||||
pgettext("tags", "API / Library")
|
pgettext("tags", "API / Library")
|
||||||
# NOTE: tags: description for library
|
# NOTE: tags: description for library
|
||||||
|
|||||||
@@ -20,16 +20,16 @@ from typing import List
|
|||||||
import requests
|
import requests
|
||||||
from celery import group, uuid
|
from celery import group, uuid
|
||||||
from flask import redirect, url_for, flash, current_app
|
from flask import redirect, url_for, flash, current_app
|
||||||
from sqlalchemy import or_, and_, not_, func
|
from sqlalchemy import or_, and_
|
||||||
|
|
||||||
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \
|
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \
|
||||||
NotificationType, PackageUpdateConfig, License, UserRank, PackageType, Thread, AuditLogEntry, ReportAttachment
|
NotificationType, PackageUpdateConfig, License, UserRank, PackageType, Thread, AuditLogEntry
|
||||||
from app.tasks.emails import send_pending_digests
|
from app.tasks.emails import send_pending_digests
|
||||||
from app.tasks.forumtasks import import_topic_list, check_all_forum_accounts
|
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.tasks.importtasks import import_repo_screenshot, check_zip_release, check_for_updates, update_all_game_support, \
|
||||||
import_languages, check_all_zip_files
|
import_languages, check_all_zip_files
|
||||||
from app.tasks.usertasks import import_github_user_ids, do_delete_likely_spammers
|
from app.tasks.usertasks import import_github_user_ids
|
||||||
from app.tasks.pkgtasks import notify_about_git_forum_links, clear_removed_packages, check_package_for_broken_links, update_file_size_bytes
|
from app.tasks.pkgtasks import notify_about_git_forum_links, clear_removed_packages, check_package_for_broken_links
|
||||||
from app.utils import add_notification, get_system_user
|
from app.utils import add_notification, get_system_user
|
||||||
|
|
||||||
actions = {}
|
actions = {}
|
||||||
@@ -68,10 +68,9 @@ def clean_uploads():
|
|||||||
|
|
||||||
release_urls = get_filenames_from_column(PackageRelease.url)
|
release_urls = get_filenames_from_column(PackageRelease.url)
|
||||||
screenshot_urls = get_filenames_from_column(PackageScreenshot.url)
|
screenshot_urls = get_filenames_from_column(PackageScreenshot.url)
|
||||||
attachment_urls = get_filenames_from_column(ReportAttachment.url)
|
|
||||||
pp_urls = get_filenames_from_column(User.profile_pic)
|
pp_urls = get_filenames_from_column(User.profile_pic)
|
||||||
|
|
||||||
db_urls = release_urls.union(screenshot_urls).union(pp_urls).union(attachment_urls)
|
db_urls = release_urls.union(screenshot_urls).union(pp_urls)
|
||||||
unreachable = existing_uploads.difference(db_urls)
|
unreachable = existing_uploads.difference(db_urls)
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@@ -323,13 +322,6 @@ def do_check_all_zip_files():
|
|||||||
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
||||||
|
|
||||||
|
|
||||||
@action("Update file_size_bytes")
|
|
||||||
def do_update_file_size_bytes():
|
|
||||||
task_id = uuid()
|
|
||||||
update_file_size_bytes.apply_async((), task_id=task_id)
|
|
||||||
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
|
||||||
|
|
||||||
|
|
||||||
@action("DANGER: Delete less popular removed packages")
|
@action("DANGER: Delete less popular removed packages")
|
||||||
def del_less_popular_removed_packages():
|
def del_less_popular_removed_packages():
|
||||||
task_id = uuid()
|
task_id = uuid()
|
||||||
@@ -425,10 +417,3 @@ def delete_empty_threads():
|
|||||||
def check_for_broken_links():
|
def check_for_broken_links():
|
||||||
for package in Package.query.filter_by(state=PackageState.APPROVED).all():
|
for package in Package.query.filter_by(state=PackageState.APPROVED).all():
|
||||||
check_package_for_broken_links.delay(package.id)
|
check_package_for_broken_links.delay(package.id)
|
||||||
|
|
||||||
|
|
||||||
@action("DANGER: Delete likely spammers")
|
|
||||||
def delete_likely_spammers():
|
|
||||||
task_id = uuid()
|
|
||||||
do_delete_likely_spammers.apply_async((), task_id=task_id)
|
|
||||||
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
|
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ from wtforms import StringField, SubmitField, BooleanField
|
|||||||
from wtforms.validators import InputRequired, Length, Optional
|
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 app.utils import rank_required, add_audit_log, add_notification, get_system_user, nonempty_or_none, \
|
||||||
get_int_or_abort
|
get_int_or_abort
|
||||||
from sqlalchemy import func
|
|
||||||
from . import bp
|
from . import bp
|
||||||
from .actions import actions
|
from .actions import actions
|
||||||
from app.models import UserRank, Package, db, PackageState, PackageRelease, PackageScreenshot, User, AuditSeverity, NotificationType, PackageAlias
|
from app.models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType, PackageAlias
|
||||||
from ...querybuilder import QueryBuilder
|
from ...querybuilder import QueryBuilder
|
||||||
|
|
||||||
|
|
||||||
@@ -183,17 +182,6 @@ def transfer():
|
|||||||
return render_template("admin/transfer.html", form=form)
|
return render_template("admin/transfer.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
def sum_file_sizes(clazz):
|
|
||||||
ret = {}
|
|
||||||
for entry in (db.session
|
|
||||||
.query(clazz.package_id, func.sum(clazz.file_size_bytes))
|
|
||||||
.select_from(clazz)
|
|
||||||
.group_by(clazz.package_id)
|
|
||||||
.all()):
|
|
||||||
ret[entry[0]] = entry[1]
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/storage/")
|
@bp.route("/admin/storage/")
|
||||||
@rank_required(UserRank.EDITOR)
|
@rank_required(UserRank.EDITOR)
|
||||||
def storage():
|
def storage():
|
||||||
@@ -204,20 +192,15 @@ def storage():
|
|||||||
show_all = len(packages) < 100
|
show_all = len(packages) < 100
|
||||||
min_size = get_int_or_abort(request.args.get("min_size"), 0 if show_all else 50)
|
min_size = get_int_or_abort(request.args.get("min_size"), 0 if show_all else 50)
|
||||||
|
|
||||||
package_size_releases = sum_file_sizes(PackageRelease)
|
|
||||||
package_size_screenshots = sum_file_sizes(PackageScreenshot)
|
|
||||||
|
|
||||||
data = []
|
data = []
|
||||||
for package in packages:
|
for package in packages:
|
||||||
size_releases = package_size_releases.get(package.id, 0)
|
size_releases = sum([x.file_size_bytes for x in package.releases])
|
||||||
size_screenshots = package_size_screenshots.get(package.id, 0)
|
size_screenshots = sum([x.file_size_bytes for x in package.screenshots])
|
||||||
size_total = size_releases + size_screenshots
|
|
||||||
if size_total < min_size * 1024 * 1024:
|
|
||||||
continue
|
|
||||||
|
|
||||||
latest_release = package.releases.first()
|
latest_release = package.releases.first()
|
||||||
size_latest = latest_release.file_size_bytes if latest_release else 0
|
size_latest = latest_release.file_size_bytes if latest_release else 0
|
||||||
data.append([package, size_total, size_releases, size_screenshots, size_latest])
|
size_total = size_releases + size_screenshots
|
||||||
|
if size_total > min_size*1024*1024:
|
||||||
|
data.append([package, size_total, size_releases, size_screenshots, size_latest])
|
||||||
|
|
||||||
data.sort(key=lambda x: x[1], reverse=True)
|
data.sort(key=lambda x: x[1], reverse=True)
|
||||||
return render_template("admin/storage.html", data=data)
|
return render_template("admin/storage.html", data=data)
|
||||||
|
|||||||
@@ -15,11 +15,7 @@
|
|||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from flask import render_template, request, abort
|
from flask import render_template, request, abort
|
||||||
from flask_babel import lazy_gettext
|
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from wtforms import StringField, SubmitField
|
|
||||||
from wtforms.validators import Optional, Length
|
|
||||||
|
|
||||||
from app.models import db, AuditLogEntry, UserRank, User, Permission
|
from app.models import db, AuditLogEntry, UserRank, User, Permission
|
||||||
from app.utils import rank_required, get_int_or_abort
|
from app.utils import rank_required, get_int_or_abort
|
||||||
@@ -27,40 +23,26 @@ from app.utils import rank_required, get_int_or_abort
|
|||||||
from . import bp
|
from . import bp
|
||||||
|
|
||||||
|
|
||||||
class AuditForm(FlaskForm):
|
|
||||||
username = StringField(lazy_gettext("Username"), [Optional(), Length(0, 25)])
|
|
||||||
q = StringField(lazy_gettext("Query"), [Optional(), Length(0, 300)])
|
|
||||||
url = StringField(lazy_gettext("URL"), [Optional(), Length(0, 300)])
|
|
||||||
submit = SubmitField(lazy_gettext("Search"), name=None)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/audit/")
|
@bp.route("/admin/audit/")
|
||||||
@rank_required(UserRank.APPROVER)
|
@rank_required(UserRank.MODERATOR)
|
||||||
def audit():
|
def audit():
|
||||||
page = get_int_or_abort(request.args.get("page"), 1)
|
page = get_int_or_abort(request.args.get("page"), 1)
|
||||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
||||||
|
|
||||||
query = AuditLogEntry.query.order_by(db.desc(AuditLogEntry.created_at))
|
query = AuditLogEntry.query.order_by(db.desc(AuditLogEntry.created_at))
|
||||||
|
|
||||||
form = AuditForm(request.args)
|
if "username" in request.args:
|
||||||
username = form.username.data
|
user = User.query.filter_by(username=request.args.get("username")).first()
|
||||||
q = form.q.data
|
if not user:
|
||||||
url = form.url.data
|
abort(404)
|
||||||
if username:
|
|
||||||
user = User.query.filter_by(username=username).first_or_404()
|
|
||||||
query = query.filter_by(causer=user)
|
query = query.filter_by(causer=user)
|
||||||
|
|
||||||
if q:
|
if "q" in request.args:
|
||||||
|
q = request.args["q"]
|
||||||
query = query.filter(AuditLogEntry.title.ilike(f"%{q}%"))
|
query = query.filter(AuditLogEntry.title.ilike(f"%{q}%"))
|
||||||
|
|
||||||
if url:
|
|
||||||
query = query.filter(AuditLogEntry.url.ilike(f"%{url}%"))
|
|
||||||
|
|
||||||
if not current_user.rank.at_least(UserRank.MODERATOR):
|
|
||||||
query = query.filter(AuditLogEntry.package)
|
|
||||||
|
|
||||||
pagination = query.paginate(page=page, per_page=num)
|
pagination = query.paginate(page=page, per_page=num)
|
||||||
return render_template("admin/audit.html", log=pagination.items, pagination=pagination, form=form)
|
return render_template("admin/audit.html", log=pagination.items, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/audit/<int:id_>/")
|
@bp.route("/admin/audit/<int:id_>/")
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ from wtforms.validators import InputRequired, Length
|
|||||||
|
|
||||||
from app.utils import rank_required, add_audit_log
|
from app.utils import rank_required, add_audit_log
|
||||||
from . import bp
|
from . import bp
|
||||||
from app.models import UserRank, LuantiRelease, db, AuditSeverity
|
from app.models import UserRank, MinetestRelease, db, AuditSeverity
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/versions/")
|
@bp.route("/versions/")
|
||||||
@rank_required(UserRank.MODERATOR)
|
@rank_required(UserRank.MODERATOR)
|
||||||
def version_list():
|
def version_list():
|
||||||
return render_template("admin/versions/list.html",
|
return render_template("admin/versions/list.html",
|
||||||
versions=LuantiRelease.query.order_by(db.asc(LuantiRelease.id)).all())
|
versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
|
||||||
|
|
||||||
|
|
||||||
class VersionForm(FlaskForm):
|
class VersionForm(FlaskForm):
|
||||||
@@ -45,14 +45,14 @@ class VersionForm(FlaskForm):
|
|||||||
def create_edit_version(name=None):
|
def create_edit_version(name=None):
|
||||||
version = None
|
version = None
|
||||||
if name is not None:
|
if name is not None:
|
||||||
version = LuantiRelease.query.filter_by(name=name).first()
|
version = MinetestRelease.query.filter_by(name=name).first()
|
||||||
if version is None:
|
if version is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
form = VersionForm(formdata=request.form, obj=version)
|
form = VersionForm(formdata=request.form, obj=version)
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
if version is None:
|
if version is None:
|
||||||
version = LuantiRelease(form.name.data)
|
version = MinetestRelease(form.name.data)
|
||||||
db.session.add(version)
|
db.session.add(version)
|
||||||
flash("Created version " + form.name.data, "success")
|
flash("Created version " + form.name.data, "success")
|
||||||
|
|
||||||
|
|||||||
@@ -29,16 +29,17 @@ from app import csrf
|
|||||||
from app.logic.graphs import get_package_stats, get_package_stats_for_user, get_all_package_stats
|
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.markdown import render_markdown
|
||||||
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, \
|
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, \
|
||||||
LuantiRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread, Collection, \
|
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread, Collection, \
|
||||||
PackageAlias, Language
|
PackageAlias, Language, PackageDailyStats
|
||||||
from app.querybuilder import QueryBuilder
|
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, cached, \
|
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, is_yes, get_request_date, cached, \
|
||||||
cors_allowed
|
cors_allowed
|
||||||
from app.utils.luanti_hypertext import html_to_luanti, package_info_as_hypertext, package_reviews_as_hypertext
|
from app.utils.minetest_hypertext import html_to_minetest, package_info_as_hypertext, package_reviews_as_hypertext
|
||||||
from . import bp
|
from . import bp
|
||||||
from .auth import is_api_authd
|
from .auth import is_api_authd
|
||||||
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
|
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
|
api_order_screenshots, api_edit_package, api_set_cover_image
|
||||||
|
from ...rediscache import make_view_key, set_temp_key, has_key
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/")
|
@bp.route("/api/packages/")
|
||||||
@@ -99,10 +100,18 @@ def package_view(package):
|
|||||||
@is_package_page
|
@is_package_page
|
||||||
@cors_allowed
|
@cors_allowed
|
||||||
def package_view_client(package: Package):
|
def package_view_client(package: Package):
|
||||||
|
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
|
||||||
|
if ip is not None and (request.headers.get("User-Agent") or "").startswith("Minetest"):
|
||||||
|
key = make_view_key(ip, package)
|
||||||
|
if not has_key(key):
|
||||||
|
set_temp_key(key, "true")
|
||||||
|
PackageDailyStats.notify_view(package)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
protocol_version = request.args.get("protocol_version")
|
protocol_version = request.args.get("protocol_version")
|
||||||
engine_version = request.args.get("engine_version")
|
engine_version = request.args.get("engine_version")
|
||||||
if protocol_version or engine_version:
|
if protocol_version or engine_version:
|
||||||
version = LuantiRelease.get(engine_version, get_int_or_abort(protocol_version))
|
version = MinetestRelease.get(engine_version, get_int_or_abort(protocol_version))
|
||||||
else:
|
else:
|
||||||
version = None
|
version = None
|
||||||
|
|
||||||
@@ -116,7 +125,7 @@ def package_view_client(package: Package):
|
|||||||
page_url = package.get_url("packages.view", absolute=True)
|
page_url = package.get_url("packages.view", absolute=True)
|
||||||
if data["long_description"] is not None:
|
if data["long_description"] is not None:
|
||||||
html = render_markdown(data["long_description"])
|
html = render_markdown(data["long_description"])
|
||||||
data["long_description"] = html_to_luanti(html, page_url, formspec_version, include_images)
|
data["long_description"] = html_to_minetest(html, page_url, formspec_version, include_images)
|
||||||
|
|
||||||
data["info_hypertext"] = package_info_as_hypertext(package, formspec_version)
|
data["info_hypertext"] = package_info_as_hypertext(package, formspec_version)
|
||||||
|
|
||||||
@@ -153,7 +162,7 @@ def package_hypertext(package):
|
|||||||
include_images = is_yes(request.args.get("include_images", "true"))
|
include_images = is_yes(request.args.get("include_images", "true"))
|
||||||
html = render_markdown(package.desc if package.desc else "")
|
html = render_markdown(package.desc if package.desc else "")
|
||||||
page_url = package.get_url("packages.view", absolute=True)
|
page_url = package.get_url("packages.view", absolute=True)
|
||||||
return jsonify(html_to_luanti(html, page_url, formspec_version, include_images))
|
return jsonify(html_to_minetest(html, page_url, formspec_version, include_images))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/", methods=["PUT"])
|
@bp.route("/api/packages/<author>/<name>/", methods=["PUT"])
|
||||||
@@ -636,14 +645,14 @@ def versions():
|
|||||||
protocol_version = request.args.get("protocol_version")
|
protocol_version = request.args.get("protocol_version")
|
||||||
engine_version = request.args.get("engine_version")
|
engine_version = request.args.get("engine_version")
|
||||||
if protocol_version or engine_version:
|
if protocol_version or engine_version:
|
||||||
rel = LuantiRelease.get(engine_version, get_int_or_abort(protocol_version))
|
rel = MinetestRelease.get(engine_version, get_int_or_abort(protocol_version))
|
||||||
if rel is None:
|
if rel is None:
|
||||||
error(404, "No releases found")
|
error(404, "No releases found")
|
||||||
|
|
||||||
return jsonify(rel.as_dict())
|
return jsonify(rel.as_dict())
|
||||||
|
|
||||||
return jsonify([rel.as_dict() \
|
return jsonify([rel.as_dict() \
|
||||||
for rel in LuantiRelease.query.all() if rel.get_actual() is not None])
|
for rel in MinetestRelease.query.all() if rel.get_actual() is not None])
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/languages/")
|
@bp.route("/api/languages/")
|
||||||
@@ -835,7 +844,7 @@ def hypertext():
|
|||||||
if request.content_type == "text/markdown":
|
if request.content_type == "text/markdown":
|
||||||
html = render_markdown(html)
|
html = render_markdown(html)
|
||||||
|
|
||||||
return jsonify(html_to_luanti(html, "", formspec_version, include_images))
|
return jsonify(html_to_minetest(html, "", formspec_version, include_images))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/collections/")
|
@bp.route("/api/collections/")
|
||||||
@@ -886,9 +895,9 @@ def collection_view(token, author, name):
|
|||||||
@cached(300)
|
@cached(300)
|
||||||
def updates():
|
def updates():
|
||||||
protocol_version = get_int_or_abort(request.args.get("protocol_version"))
|
protocol_version = get_int_or_abort(request.args.get("protocol_version"))
|
||||||
engine_version = request.args.get("engine_version")
|
minetest_version = request.args.get("engine_version")
|
||||||
if protocol_version or engine_version:
|
if protocol_version or minetest_version:
|
||||||
version = LuantiRelease.get(engine_version, protocol_version)
|
version = MinetestRelease.get(minetest_version, protocol_version)
|
||||||
else:
|
else:
|
||||||
version = None
|
version = None
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from flask import jsonify, abort, make_response, url_for, current_app
|
|||||||
from app.logic.packages import do_edit_package
|
from app.logic.packages import do_edit_package
|
||||||
from app.logic.releases import LogicError, do_create_vcs_release, do_create_zip_release
|
from app.logic.releases import LogicError, do_create_vcs_release, do_create_zip_release
|
||||||
from app.logic.screenshots import do_create_screenshot, do_order_screenshots, do_set_cover_image
|
from app.logic.screenshots import do_create_screenshot, do_order_screenshots, do_set_cover_image
|
||||||
from app.models import APIToken, Package, LuantiRelease, PackageScreenshot
|
from app.models import APIToken, Package, MinetestRelease, PackageScreenshot
|
||||||
|
|
||||||
|
|
||||||
def error(code: int, msg: str):
|
def error(code: int, msg: str):
|
||||||
@@ -39,7 +39,7 @@ def guard(f):
|
|||||||
|
|
||||||
|
|
||||||
def api_create_vcs_release(token: APIToken, package: Package, name: str, title: Optional[str], release_notes: Optional[str], ref: str,
|
def api_create_vcs_release(token: APIToken, package: Package, name: str, title: Optional[str], release_notes: Optional[str], ref: str,
|
||||||
min_v: LuantiRelease = None, max_v: LuantiRelease = None, reason="API"):
|
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API"):
|
||||||
if not token.can_operate_on_package(package):
|
if not token.can_operate_on_package(package):
|
||||||
error(403, "API token does not have access to the package")
|
error(403, "API token does not have access to the package")
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ def api_create_vcs_release(token: APIToken, package: Package, name: str, title:
|
|||||||
|
|
||||||
|
|
||||||
def api_create_zip_release(token: APIToken, package: Package, name: str, title: Optional[str], release_notes: Optional[str], file,
|
def api_create_zip_release(token: APIToken, package: Package, name: str, title: Optional[str], release_notes: Optional[str], file,
|
||||||
min_v: LuantiRelease = None, max_v: LuantiRelease = None, reason="API", commit_hash: str = None):
|
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API", commit_hash: str = None):
|
||||||
if not token.can_operate_on_package(package):
|
if not token.can_operate_on_package(package):
|
||||||
error(403, "API token does not have access to the package")
|
error(403, "API token does not have access to the package")
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from sqlalchemy.sql.expression import func
|
|||||||
|
|
||||||
PKGS_PER_ROW = 4
|
PKGS_PER_ROW = 4
|
||||||
|
|
||||||
# GAMEJAM_BANNER = "https://jam.luanti.org/img/banner.png"
|
# GAMEJAM_BANNER = "https://jam.minetest.net/img/banner.png"
|
||||||
#
|
#
|
||||||
# class GameJam:
|
# class GameJam:
|
||||||
# cover_image = type("", (), dict(url=GAMEJAM_BANNER))()
|
# cover_image = type("", (), dict(url=GAMEJAM_BANNER))()
|
||||||
@@ -40,7 +40,7 @@ PKGS_PER_ROW = 4
|
|||||||
# def get_url(self, _name):
|
# def get_url(self, _name):
|
||||||
# return "/gamejam/"
|
# return "/gamejam/"
|
||||||
#
|
#
|
||||||
# title = "Luanti Game Jam 2023: \"Unexpected\""
|
# title = "Minetest Game Jam 2023: \"Unexpected\""
|
||||||
# author = None
|
# author = None
|
||||||
#
|
#
|
||||||
# short_desc = "The game jam has finished! It's now up to the community to play and rate the games."
|
# short_desc = "The game jam has finished! It's now up to the community to play and rate the games."
|
||||||
@@ -51,7 +51,7 @@ PKGS_PER_ROW = 4
|
|||||||
|
|
||||||
@bp.route("/gamejam/")
|
@bp.route("/gamejam/")
|
||||||
def gamejam():
|
def gamejam():
|
||||||
return redirect("https://jam.luanti.org/")
|
return redirect("https://jam.minetest.net/")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from wtforms.validators import Optional
|
|||||||
from wtforms_sqlalchemy.fields import QuerySelectMultipleField, QuerySelectField
|
from wtforms_sqlalchemy.fields import QuerySelectMultipleField, QuerySelectField
|
||||||
|
|
||||||
from . import bp
|
from . import bp
|
||||||
from ...models import PackageType, Tag, db, ContentWarning, License, Language, LuantiRelease, Package, PackageState
|
from ...models import PackageType, Tag, db, ContentWarning, License, Language, MinetestRelease, Package, PackageState
|
||||||
|
|
||||||
|
|
||||||
def make_label(obj: Tag | ContentWarning):
|
def make_label(obj: Tag | ContentWarning):
|
||||||
@@ -75,7 +75,7 @@ class AdvancedSearchForm(FlaskForm):
|
|||||||
get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
||||||
hide = SelectMultipleField(lazy_gettext("Hide Tags and Content Warnings"), [Optional()])
|
hide = SelectMultipleField(lazy_gettext("Hide Tags and Content Warnings"), [Optional()])
|
||||||
engine_version = QuerySelectField(lazy_gettext("Luanti Version"),
|
engine_version = QuerySelectField(lazy_gettext("Luanti Version"),
|
||||||
query_factory=lambda: LuantiRelease.query.order_by(db.asc(LuantiRelease.id)),
|
query_factory=lambda: MinetestRelease.query.order_by(db.asc(MinetestRelease.id)),
|
||||||
allow_blank=True, blank_value="",
|
allow_blank=True, blank_value="",
|
||||||
get_pk=lambda a: a.value, get_label=lambda a: a.name)
|
get_pk=lambda a: a.value, get_label=lambda a: a.name)
|
||||||
sort = SelectField(lazy_gettext("Sort by"), [Optional()], choices=[
|
sort = SelectField(lazy_gettext("Sort by"), [Optional()], choices=[
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ class PackageForm(FlaskForm):
|
|||||||
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
|
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
|
||||||
short_desc = StringField(lazy_gettext("Short Description (Plaintext)"), [InputRequired(), Length(1,200)])
|
short_desc = StringField(lazy_gettext("Short Description (Plaintext)"), [InputRequired(), Length(1,200)])
|
||||||
|
|
||||||
dev_state = SelectField(lazy_gettext("Maintenance State"), [DataRequired()], choices=PackageDevState.choices(with_none=True), coerce=PackageDevState.coerce)
|
dev_state = SelectField(lazy_gettext("Maintenance State"), [InputRequired()], choices=PackageDevState.choices(with_none=True), coerce=PackageDevState.coerce)
|
||||||
|
|
||||||
tags = QuerySelectMultipleField(lazy_gettext('Tags'), query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=make_label)
|
tags = QuerySelectMultipleField(lazy_gettext('Tags'), query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=make_label)
|
||||||
content_warnings = QuerySelectMultipleField(lazy_gettext('Content Warnings'), query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=make_label)
|
content_warnings = QuerySelectMultipleField(lazy_gettext('Content Warnings'), query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=make_label)
|
||||||
@@ -306,6 +306,10 @@ def handle_create_edit(package: typing.Optional[Package], form: PackageForm, aut
|
|||||||
"translation_url": form.translation_url.data,
|
"translation_url": form.translation_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)
|
||||||
|
|
||||||
if wasNew and package.repo is not None:
|
if wasNew and package.repo is not None:
|
||||||
import_repo_screenshot.delay(package.id)
|
import_repo_screenshot.delay(package.id)
|
||||||
|
|
||||||
@@ -318,14 +322,13 @@ def handle_create_edit(package: typing.Optional[Package], form: PackageForm, aut
|
|||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
except LogicError as e:
|
except LogicError as e:
|
||||||
flash(e.message, "danger")
|
flash(e.message, "danger")
|
||||||
db.session.rollback()
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/new/", methods=["GET", "POST"])
|
@bp.route("/packages/new/", methods=["GET", "POST"])
|
||||||
@bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
|
@bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def create_edit(author=None, name=None):
|
def create_edit(author=None, name=None):
|
||||||
if current_user.email is None and not current_user.rank.at_least(UserRank.ADMIN):
|
if current_user.email is None:
|
||||||
flash(gettext("You must add an email address to your account and confirm it before you can manage packages"), "danger")
|
flash(gettext("You must add an email address to your account and confirm it before you can manage packages"), "danger")
|
||||||
return redirect(url_for("users.email_notifications"))
|
return redirect(url_for("users.email_notifications"))
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import render_template, request, redirect, flash, url_for, abort
|
from flask import render_template, request, redirect, flash, url_for, abort
|
||||||
@@ -25,12 +26,13 @@ from wtforms.validators import InputRequired, Length, Optional
|
|||||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||||
|
|
||||||
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
|
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, LuantiRelease, \
|
from app.models import Package, db, User, PackageState, Permission, UserRank, PackageDailyStats, MinetestRelease, \
|
||||||
PackageRelease, PackageUpdateTrigger, PackageUpdateConfig
|
PackageRelease, PackageUpdateTrigger, PackageUpdateConfig
|
||||||
from app.rediscache import has_key, set_temp_key, make_download_key
|
from app.rediscache import has_key, set_temp_key, make_download_key
|
||||||
from app.tasks.importtasks import check_update_config
|
from app.tasks.importtasks import check_update_config
|
||||||
from app.utils import is_user_bot, is_package_page, nonempty_or_none, normalize_line_endings
|
from app.utils import is_user_bot, is_package_page, nonempty_or_none, normalize_line_endings
|
||||||
from . import bp, get_package_tabs
|
from . import bp, get_package_tabs
|
||||||
|
from app.utils.version import is_minetest_v510
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/releases/", methods=["GET", "POST"])
|
@bp.route("/packages/<author>/<name>/releases/", methods=["GET", "POST"])
|
||||||
@@ -42,11 +44,11 @@ def list_releases(package):
|
|||||||
|
|
||||||
|
|
||||||
def get_mt_releases(is_max):
|
def get_mt_releases(is_max):
|
||||||
query = LuantiRelease.query.order_by(db.asc(LuantiRelease.id))
|
query = MinetestRelease.query.order_by(db.asc(MinetestRelease.id))
|
||||||
if is_max:
|
if is_max:
|
||||||
query = query.limit(query.count() - 1)
|
query = query.limit(query.count() - 1)
|
||||||
else:
|
else:
|
||||||
query = query.filter(LuantiRelease.name != "0.4.17")
|
query = query.filter(MinetestRelease.name != "0.4.17")
|
||||||
|
|
||||||
return query
|
return query
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ class EditPackageReleaseForm(FlaskForm):
|
|||||||
@login_required
|
@login_required
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def create_release(package):
|
def create_release(package):
|
||||||
if current_user.email is None and not current_user.rank.at_least(UserRank.ADMIN):
|
if current_user.email is None:
|
||||||
flash(gettext("You must add an email address to your account and confirm it before you can manage packages"), "danger")
|
flash(gettext("You must add an email address to your account and confirm it before you can manage packages"), "danger")
|
||||||
return redirect(url_for("users.email_notifications"))
|
return redirect(url_for("users.email_notifications"))
|
||||||
|
|
||||||
@@ -128,9 +130,10 @@ def download_release(package, id):
|
|||||||
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
|
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
|
||||||
if ip is not None and not is_user_bot():
|
if ip is not None and not is_user_bot():
|
||||||
user_agent = request.headers.get("User-Agent") or ""
|
user_agent = request.headers.get("User-Agent") or ""
|
||||||
is_luanti = user_agent.startswith("Luanti") or user_agent.startswith("Minetest")
|
is_minetest = user_agent.startswith("Luanti") or user_agent.startswith("Minetest")
|
||||||
|
is_v510 = is_minetest and is_minetest_v510(request.headers.get("User-Agent"))
|
||||||
reason = request.args.get("reason")
|
reason = request.args.get("reason")
|
||||||
PackageDailyStats.update(package, is_luanti, reason)
|
PackageDailyStats.notify_download(package, is_minetest, is_v510, reason)
|
||||||
|
|
||||||
key = make_download_key(ip, release.package)
|
key = make_download_key(ip, release.package)
|
||||||
if not has_key(key):
|
if not has_key(key):
|
||||||
|
|||||||
@@ -19,9 +19,8 @@ from flask import render_template, request, redirect, flash, url_for, abort
|
|||||||
from flask_babel import lazy_gettext, gettext
|
from flask_babel import lazy_gettext, gettext
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from flask_wtf.file import FileRequired
|
|
||||||
from wtforms import StringField, SubmitField, BooleanField, FileField
|
from wtforms import StringField, SubmitField, BooleanField, FileField
|
||||||
from wtforms.validators import Length, DataRequired, Optional
|
from wtforms.validators import InputRequired, Length, DataRequired, Optional
|
||||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||||
|
|
||||||
from app.logic.LogicError import LogicError
|
from app.logic.LogicError import LogicError
|
||||||
@@ -33,7 +32,7 @@ from app.utils import is_package_page
|
|||||||
|
|
||||||
class CreateScreenshotForm(FlaskForm):
|
class CreateScreenshotForm(FlaskForm):
|
||||||
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
|
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
|
||||||
file_upload = FileField(lazy_gettext("File Upload"), [FileRequired()])
|
file_upload = FileField(lazy_gettext("File Upload"), [InputRequired()])
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
submit = SubmitField(lazy_gettext("Save"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,30 +14,24 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from flask import Blueprint, request, render_template, url_for, abort, flash
|
from flask import Blueprint, request, render_template, url_for, abort
|
||||||
from flask_babel import lazy_gettext
|
from flask_babel import lazy_gettext
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from werkzeug.utils import redirect
|
from werkzeug.utils import redirect
|
||||||
from wtforms import TextAreaField, SubmitField, URLField, StringField, SelectField, FileField
|
from wtforms import TextAreaField, SubmitField
|
||||||
from wtforms.validators import InputRequired, Length, Optional, DataRequired
|
from wtforms.validators import InputRequired, Length
|
||||||
|
|
||||||
from app.logic.uploads import upload_file
|
from app.models import User, UserRank
|
||||||
from app.models import User, UserRank, Report, db, AuditSeverity, ReportCategory, Thread, Permission, ReportAttachment
|
from app.tasks.emails import send_user_email
|
||||||
from app.tasks.webhooktasks import post_discord_webhook
|
from app.tasks.webhooktasks import post_discord_webhook
|
||||||
from app.utils import (is_no, abs_url_samesite, normalize_line_endings, rank_required, add_audit_log, abs_url_for,
|
from app.utils import is_no, abs_url_samesite, normalize_line_endings
|
||||||
random_string, add_replies)
|
|
||||||
|
|
||||||
bp = Blueprint("report", __name__)
|
bp = Blueprint("report", __name__)
|
||||||
|
|
||||||
|
|
||||||
class ReportForm(FlaskForm):
|
class ReportForm(FlaskForm):
|
||||||
category = SelectField(lazy_gettext("Category"), [DataRequired()], choices=ReportCategory.choices(with_none=True), coerce=ReportCategory.coerce)
|
message = TextAreaField(lazy_gettext("Message"), [InputRequired(), Length(10, 10000)], filters=[normalize_line_endings])
|
||||||
|
|
||||||
url = URLField(lazy_gettext("URL"), [Optional()])
|
|
||||||
title = StringField(lazy_gettext("Subject / Title"), [InputRequired(), Length(10, 300)])
|
|
||||||
message = TextAreaField(lazy_gettext("Message"), [Optional(), Length(0, 10000)], filters=[normalize_line_endings])
|
|
||||||
file_upload = FileField(lazy_gettext("Image Upload"), [Optional()])
|
|
||||||
submit = SubmitField(lazy_gettext("Report"))
|
submit = SubmitField(lazy_gettext("Report"))
|
||||||
|
|
||||||
|
|
||||||
@@ -52,131 +46,25 @@ def report():
|
|||||||
|
|
||||||
url = abs_url_samesite(url)
|
url = abs_url_samesite(url)
|
||||||
|
|
||||||
form = ReportForm() if current_user.is_authenticated else None
|
form = ReportForm(formdata=request.form) if current_user.is_authenticated else None
|
||||||
if form and request.method == "GET":
|
if form and request.method == "GET":
|
||||||
try:
|
form.message.data = request.args.get("message", "")
|
||||||
form.category.data = ReportCategory.coerce(request.args.get("category"))
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
form.url.data = url
|
|
||||||
form.title.data = request.args.get("title", "")
|
|
||||||
|
|
||||||
if form and form.validate_on_submit():
|
if form and form.validate_on_submit():
|
||||||
report = Report()
|
|
||||||
report.id = random_string(8)
|
|
||||||
report.user = current_user if current_user.is_authenticated else None
|
|
||||||
form.populate_obj(report)
|
|
||||||
|
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
thread = Thread()
|
user_info = f"{current_user.username}"
|
||||||
thread.title = f"Report: {form.title.data}"
|
|
||||||
thread.author = current_user
|
|
||||||
thread.private = True
|
|
||||||
thread.watchers.extend(User.query.filter(User.rank >= UserRank.MODERATOR).all())
|
|
||||||
db.session.add(thread)
|
|
||||||
db.session.flush()
|
|
||||||
|
|
||||||
report.thread = thread
|
|
||||||
|
|
||||||
add_replies(thread, current_user, f"**{report.category.title} report created**\n\n{form.message.data}")
|
|
||||||
else:
|
else:
|
||||||
ip_addr = request.headers.get("X-Forwarded-For") or request.remote_addr
|
user_info = request.headers.get("X-Forwarded-For") or request.remote_addr
|
||||||
report.message = ip_addr + "\n\n" + report.message
|
|
||||||
|
|
||||||
db.session.add(report)
|
text = f"{url}\n\n{form.message.data}"
|
||||||
db.session.flush()
|
|
||||||
|
|
||||||
if form.file_upload.data:
|
task = None
|
||||||
atmt = ReportAttachment()
|
for admin in User.query.filter_by(rank=UserRank.ADMIN).all():
|
||||||
report.attachments.add(atmt)
|
task = send_user_email.delay(admin.email, admin.locale or "en",
|
||||||
uploaded_url, _ = upload_file(form.file_upload.data, "image", lazy_gettext("a PNG, JPEG, or WebP image file"))
|
f"User report from {user_info}", text)
|
||||||
atmt.url = uploaded_url
|
|
||||||
db.session.add(atmt)
|
|
||||||
|
|
||||||
if current_user.is_authenticated:
|
post_discord_webhook.delay(None if is_anon else current_user.username, f"**New Report**\n{url}\n\n{form.message.data}", True)
|
||||||
add_audit_log(AuditSeverity.USER, current_user, f"New report: {report.title}",
|
|
||||||
url_for("report.view", rid=report.id))
|
|
||||||
|
|
||||||
db.session.commit()
|
return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home")))
|
||||||
|
|
||||||
abs_url = abs_url_for("report.view", rid=report.id)
|
return render_template("report/index.html", form=form, url=url, is_anon=is_anon, noindex=url is not None)
|
||||||
msg = f"**New Report**\nReport on `{report.url}`\n\n{report.title}\n\nView: {abs_url}"
|
|
||||||
post_discord_webhook.delay(None if is_anon else current_user.username, msg, True)
|
|
||||||
|
|
||||||
return redirect(url_for("report.report_received", rid=report.id))
|
|
||||||
|
|
||||||
return render_template("report/report.html", form=form, url=url, is_anon=is_anon, noindex=url is not None)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/report/received/")
|
|
||||||
def report_received():
|
|
||||||
rid = request.args.get("rid")
|
|
||||||
report = Report.query.get_or_404(rid)
|
|
||||||
return render_template("report/report_received.html", report=report)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/reports/")
|
|
||||||
@rank_required(UserRank.EDITOR)
|
|
||||||
def list_all():
|
|
||||||
reports = Report.query.order_by(db.asc(Report.is_resolved), db.asc(Report.created_at)).all()
|
|
||||||
return render_template("report/list.html", reports=reports)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/reports/<rid>/", methods=["GET", "POST"])
|
|
||||||
def view(rid: str):
|
|
||||||
report = Report.query.get_or_404(rid)
|
|
||||||
if not report.check_perm(current_user, Permission.SEE_REPORT):
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
if report.is_resolved:
|
|
||||||
if "reopen" in request.form:
|
|
||||||
report.is_resolved = False
|
|
||||||
url = url_for("report.view", rid=report.id)
|
|
||||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Reopened report \"{report.title}\"", url)
|
|
||||||
|
|
||||||
if report.thread:
|
|
||||||
add_replies(report.thread, current_user, f"Reopened report", is_status_update=True)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
else:
|
|
||||||
if "completed" in request.form:
|
|
||||||
outcome = "as completed"
|
|
||||||
elif "removed" in request.form:
|
|
||||||
outcome = "as content removed"
|
|
||||||
elif "invalid" in request.form:
|
|
||||||
outcome = "without action"
|
|
||||||
if report.thread:
|
|
||||||
flash("Make sure to comment why the report is invalid in the thread", "warning")
|
|
||||||
else:
|
|
||||||
abort(400)
|
|
||||||
|
|
||||||
report.is_resolved = True
|
|
||||||
url = url_for("report.view", rid=report.id)
|
|
||||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Report closed {outcome} \"{report.title}\"", url)
|
|
||||||
|
|
||||||
if report.thread:
|
|
||||||
add_replies(report.thread, current_user, f"Closed report {outcome}", is_status_update=True)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return render_template("report/view.html", report=report)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/reports/<rid>/edit/", methods=["GET", "POST"])
|
|
||||||
def edit(rid: str):
|
|
||||||
report = Report.query.get_or_404(rid)
|
|
||||||
if not report.check_perm(current_user, Permission.SEE_REPORT):
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
form = ReportForm(request.form, obj=report)
|
|
||||||
form.submit.label.text = lazy_gettext("Save")
|
|
||||||
if form.validate_on_submit():
|
|
||||||
form.populate_obj(report)
|
|
||||||
url = url_for("report.view", rid=report.id)
|
|
||||||
add_audit_log(AuditSeverity.MODERATION, current_user, f"Edited report \"{report.title}\"", url)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return redirect(url_for("report.view", rid=report.id))
|
|
||||||
|
|
||||||
return render_template("report/edit.html", report=report, form=form)
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from app.models import Package, db, User, Permission, Thread, UserRank, AuditSev
|
|||||||
from app.utils import add_notification, is_yes, add_audit_log, get_system_user, has_blocked_domains, \
|
from app.utils import add_notification, is_yes, add_audit_log, get_system_user, has_blocked_domains, \
|
||||||
normalize_line_endings
|
normalize_line_endings
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, TextAreaField, SubmitField
|
from wtforms import StringField, TextAreaField, SubmitField, BooleanField
|
||||||
from wtforms.validators import InputRequired, Length
|
from wtforms.validators import InputRequired, Length
|
||||||
from app.utils import get_int_or_abort
|
from app.utils import get_int_or_abort
|
||||||
|
|
||||||
@@ -254,9 +254,6 @@ def view(id):
|
|||||||
if mentioned is None:
|
if mentioned is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not thread.check_perm(mentioned, Permission.SEE_THREAD):
|
|
||||||
continue
|
|
||||||
|
|
||||||
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
|
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
|
||||||
add_notification(mentioned, current_user, NotificationType.THREAD_REPLY,
|
add_notification(mentioned, current_user, NotificationType.THREAD_REPLY,
|
||||||
msg, thread.get_view_url(), thread.package)
|
msg, thread.get_view_url(), thread.package)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from flask_login import current_user, login_required
|
|||||||
from sqlalchemy import or_, and_
|
from sqlalchemy import or_, and_
|
||||||
|
|
||||||
from app.models import Package, PackageState, PackageScreenshot, PackageUpdateConfig, ForumTopic, db, \
|
from app.models import Package, PackageState, PackageScreenshot, PackageUpdateConfig, ForumTopic, db, \
|
||||||
PackageRelease, Permission, UserRank, License, MetaPackage, Dependency, AuditLogEntry, Tag, LuantiRelease, Report
|
PackageRelease, Permission, UserRank, License, MetaPackage, Dependency, AuditLogEntry, Tag, MinetestRelease
|
||||||
from app.querybuilder import QueryBuilder
|
from app.querybuilder import QueryBuilder
|
||||||
from app.utils import get_int_or_abort, is_yes, rank_required
|
from app.utils import get_int_or_abort, is_yes, rank_required
|
||||||
from . import bp
|
from . import bp
|
||||||
@@ -83,13 +83,11 @@ def view_editor():
|
|||||||
.order_by(db.desc(AuditLogEntry.created_at)) \
|
.order_by(db.desc(AuditLogEntry.created_at)) \
|
||||||
.limit(20).all()
|
.limit(20).all()
|
||||||
|
|
||||||
reports = Report.query.filter_by(is_resolved=False).order_by(db.asc(Report.created_at)).all() if current_user.rank.at_least(UserRank.EDITOR) else None
|
|
||||||
|
|
||||||
return render_template("todo/editor.html", current_tab="editor",
|
return render_template("todo/editor.html", current_tab="editor",
|
||||||
packages=packages, wip_packages=wip_packages, releases=releases, screenshots=screenshots,
|
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,
|
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,
|
license_needed=license_needed, total_packages=total_packages, total_to_tag=total_to_tag,
|
||||||
unfulfilled_meta_packages=unfulfilled_meta_packages, audit_log=audit_log, reports=reports)
|
unfulfilled_meta_packages=unfulfilled_meta_packages, audit_log=audit_log)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/todo/tags/")
|
@bp.route("/todo/tags/")
|
||||||
@@ -172,7 +170,7 @@ def screenshots():
|
|||||||
def mtver_support():
|
def mtver_support():
|
||||||
is_mtm_only = is_yes(request.args.get("mtm"))
|
is_mtm_only = is_yes(request.args.get("mtm"))
|
||||||
|
|
||||||
current_stable = LuantiRelease.query.filter(~LuantiRelease.name.like("%-dev")).order_by(db.desc(LuantiRelease.id)).first()
|
current_stable = MinetestRelease.query.filter(~MinetestRelease.name.like("%-dev")).order_by(db.desc(MinetestRelease.id)).first()
|
||||||
|
|
||||||
query = db.session.query(Package) \
|
query = db.session.query(Package) \
|
||||||
.filter(~Package.releases.any(or_(PackageRelease.max_rel==None, PackageRelease.max_rel == current_stable))) \
|
.filter(~Package.releases.any(or_(PackageRelease.max_rel==None, PackageRelease.max_rel == current_stable))) \
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ class RegisterForm(FlaskForm):
|
|||||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
||||||
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(12, 100)])
|
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(12, 100)])
|
||||||
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()])
|
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()])
|
||||||
first_name = StringField("First name", [])
|
|
||||||
submit = SubmitField(lazy_gettext("Register"))
|
submit = SubmitField(lazy_gettext("Register"))
|
||||||
|
|
||||||
|
|
||||||
@@ -118,8 +117,6 @@ def handle_register(form):
|
|||||||
return user
|
return user
|
||||||
elif user is None:
|
elif user is None:
|
||||||
return
|
return
|
||||||
elif form.first_name.data != "":
|
|
||||||
abort(500)
|
|
||||||
|
|
||||||
user.password = make_flask_login_password(form.password.data)
|
user.password = make_flask_login_password(form.password.data)
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from app.models import Package, APIToken, Permission, PackageState
|
|||||||
|
|
||||||
|
|
||||||
def get_packages_for_vcs_and_token(token: APIToken, repo_url: str) -> list[Package]:
|
def get_packages_for_vcs_and_token(token: APIToken, repo_url: str) -> list[Package]:
|
||||||
repo_url = repo_url.replace("https://", "").replace("http://", "").lower()
|
|
||||||
if token.package:
|
if token.package:
|
||||||
packages = [token.package]
|
packages = [token.package]
|
||||||
if not token.package.check_perm(token.owner, Permission.APPROVE_RELEASE):
|
if not token.package.check_perm(token.owner, Permission.APPROVE_RELEASE):
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def webhook_impl():
|
|||||||
if token is None:
|
if token is None:
|
||||||
return error(403, "Invalid authentication")
|
return error(403, "Invalid authentication")
|
||||||
|
|
||||||
packages = get_packages_for_vcs_and_token(token, json["project"]["web_url"])
|
packages = get_packages_for_vcs_and_token(token, json["project"]["web_url"].replace("https://", "").replace("http://", ""))
|
||||||
for package in packages:
|
for package in packages:
|
||||||
#
|
#
|
||||||
# Check event
|
# Check event
|
||||||
|
|||||||
@@ -14,27 +14,27 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from celery import uuid
|
from celery import uuid
|
||||||
from flask import Blueprint, render_template, redirect, request, abort, url_for
|
from flask import Blueprint, render_template, redirect, request, abort, url_for
|
||||||
from flask_babel import lazy_gettext
|
from flask_babel import lazy_gettext
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, BooleanField, SubmitField, SelectMultipleField
|
from wtforms import StringField, BooleanField, SubmitField
|
||||||
from wtforms.validators import InputRequired, Length, Optional
|
from wtforms.validators import InputRequired, Length
|
||||||
|
|
||||||
from app.tasks import celery
|
from app.tasks import celery
|
||||||
from app.utils import rank_required
|
from app.utils import rank_required
|
||||||
|
|
||||||
bp = Blueprint("zipgrep", __name__)
|
bp = Blueprint("zipgrep", __name__)
|
||||||
|
|
||||||
from app.models import UserRank, Package, PackageType
|
from app.models import UserRank, Package
|
||||||
from app.tasks.zipgrep import search_in_releases
|
from app.tasks.zipgrep import search_in_releases
|
||||||
|
|
||||||
|
|
||||||
class SearchForm(FlaskForm):
|
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(1, 100)])
|
||||||
file_filter = StringField(lazy_gettext("File filter"), [InputRequired(), Length(1, 100)], default="*.lua")
|
file_filter = StringField(lazy_gettext("File filter"), [InputRequired(), Length(1, 100)], default="*.lua")
|
||||||
type = SelectMultipleField(lazy_gettext("Type"), [Optional()],
|
remember_me = BooleanField(lazy_gettext("Remember me"), default=True)
|
||||||
choices=PackageType.choices(), coerce=PackageType.coerce)
|
|
||||||
submit = SubmitField(lazy_gettext("Search"))
|
submit = SubmitField(lazy_gettext("Search"))
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ def zipgrep_search():
|
|||||||
form = SearchForm(request.form)
|
form = SearchForm(request.form)
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
task_id = uuid()
|
task_id = uuid()
|
||||||
search_in_releases.apply_async((form.query.data, form.file_filter.data, [x.name for x in form.type.data]), task_id=task_id)
|
search_in_releases.apply_async((form.query.data, form.file_filter.data), task_id=task_id)
|
||||||
result_url = url_for("zipgrep.view_results", id=task_id)
|
result_url = url_for("zipgrep.view_results", id=task_id)
|
||||||
return redirect(url_for("tasks.check", id=task_id, r=result_url))
|
return redirect(url_for("tasks.check", id=task_id, r=result_url))
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from .models import User, UserRank, LuantiRelease, Tag, License, Notification, NotificationType, Package, \
|
from .models import User, UserRank, MinetestRelease, Tag, License, Notification, NotificationType, Package, \
|
||||||
PackageState, PackageType, PackageRelease, MetaPackage, Dependency
|
PackageState, PackageType, PackageRelease, MetaPackage, Dependency
|
||||||
from .utils import make_flask_login_password
|
from .utils import make_flask_login_password
|
||||||
|
|
||||||
@@ -35,12 +35,12 @@ def populate(session):
|
|||||||
system_user.rank = UserRank.BOT
|
system_user.rank = UserRank.BOT
|
||||||
session.add(system_user)
|
session.add(system_user)
|
||||||
|
|
||||||
session.add(LuantiRelease("None", 0))
|
session.add(MinetestRelease("None", 0))
|
||||||
session.add(LuantiRelease("0.4.16/17", 32))
|
session.add(MinetestRelease("0.4.16/17", 32))
|
||||||
session.add(LuantiRelease("5.0", 37))
|
session.add(MinetestRelease("5.0", 37))
|
||||||
session.add(LuantiRelease("5.1", 38))
|
session.add(MinetestRelease("5.1", 38))
|
||||||
session.add(LuantiRelease("5.2", 39))
|
session.add(MinetestRelease("5.2", 39))
|
||||||
session.add(LuantiRelease("5.3", 39))
|
session.add(MinetestRelease("5.3", 39))
|
||||||
|
|
||||||
tags = {}
|
tags = {}
|
||||||
for tag in ["Inventory", "Mapgen", "Building",
|
for tag in ["Inventory", "Mapgen", "Building",
|
||||||
@@ -69,8 +69,8 @@ def populate_test_data(session):
|
|||||||
licenses = { x.name : x for x in License.query.all() }
|
licenses = { x.name : x for x in License.query.all() }
|
||||||
tags = { x.name : x for x in Tag.query.all() }
|
tags = { x.name : x for x in Tag.query.all() }
|
||||||
admin_user = User.query.filter_by(rank=UserRank.ADMIN).first()
|
admin_user = User.query.filter_by(rank=UserRank.ADMIN).first()
|
||||||
v4 = LuantiRelease.query.filter_by(protocol=32).first()
|
v4 = MinetestRelease.query.filter_by(protocol=32).first()
|
||||||
v51 = LuantiRelease.query.filter_by(protocol=38).first()
|
v51 = MinetestRelease.query.filter_by(protocol=38).first()
|
||||||
|
|
||||||
ez = User("Shara")
|
ez = User("Shara")
|
||||||
ez.github_username = "Ezhh"
|
ez.github_username = "Ezhh"
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ as it was submitted as university coursework. To learn about the history and dev
|
|||||||
|
|
||||||
ContentDB is open source software, licensed under AGPLv3.0.
|
ContentDB is open source software, licensed under AGPLv3.0.
|
||||||
|
|
||||||
<a href="https://github.com/luanti-org/contentdb/" class="btn btn-primary me-1">Source code</a>
|
<a href="https://github.com/minetest/contentdb/" class="btn btn-primary me-1">Source code</a>
|
||||||
<a href="https://github.com/luanti-org/contentdb/issues/" class="btn btn-secondary me-1">Issue tracker</a>
|
<a href="https://github.com/minetest/contentdb/issues/" class="btn btn-secondary me-1">Issue tracker</a>
|
||||||
<a href="{{ admin_contact_url }}" class="btn btn-secondary me-1">Contact admin</a>
|
<a href="{{ admin_contact_url }}" class="btn btn-secondary me-1">Contact admin</a>
|
||||||
{% if monitoring_url -%}
|
{% if monitoring_url -%}
|
||||||
<a href="{{ monitoring_url }}" class="btn btn-secondary">Stats / monitoring</a>
|
<a href="{{ monitoring_url }}" class="btn btn-secondary">Stats / monitoring</a>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: API
|
|||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
* [How the Luanti client uses the API](https://github.com/luanti-org/contentdb/blob/master/docs/luanti_client.md)
|
* [How the Luanti client uses the API](https://github.com/minetest/contentdb/blob/master/docs/minetest_client.md)
|
||||||
|
|
||||||
|
|
||||||
## Responses and Error Handling
|
## Responses and Error Handling
|
||||||
@@ -131,7 +131,7 @@ curl -X DELETE https://content.luanti.org/api/delete-token/ \
|
|||||||
* `<neutral>` with a thumbs up icon.
|
* `<neutral>` with a thumbs up icon.
|
||||||
* `<thumbsdown>` with a thumbs up icon.
|
* `<thumbsdown>` with a thumbs up icon.
|
||||||
* GET `/api/packages/<author>/<name>/hypertext/`
|
* GET `/api/packages/<author>/<name>/hypertext/`
|
||||||
* Converts the long description to [Luanti Markup Language](https://github.com/luanti-org/luanti/blob/master/doc/lua_api.md#markup-language)
|
* Converts the long description to [Luanti Markup Language](https://github.com/minetest/minetest/blob/master/doc/lua_api.md#markup-language)
|
||||||
to be used in a `hypertext` formspec element.
|
to be used in a `hypertext` formspec element.
|
||||||
* Query arguments:
|
* Query arguments:
|
||||||
* `formspec_version`: Required, maximum supported formspec version.
|
* `formspec_version`: Required, maximum supported formspec version.
|
||||||
@@ -174,6 +174,7 @@ curl -X DELETE https://content.luanti.org/api/delete-token/ \
|
|||||||
* `reason_new`: list of integers per day.
|
* `reason_new`: list of integers per day.
|
||||||
* `reason_dependency`: list of integers per day.
|
* `reason_dependency`: list of integers per day.
|
||||||
* `reason_update`: list of integers per day.
|
* `reason_update`: list of integers per day.
|
||||||
|
* `views_minetest`: list of integers per day.
|
||||||
* GET `/api/package_stats/`
|
* GET `/api/package_stats/`
|
||||||
* Returns last 30 days of daily stats for _all_ packages.
|
* Returns last 30 days of daily stats for _all_ packages.
|
||||||
* An object with the following keys:
|
* An object with the following keys:
|
||||||
@@ -454,6 +455,7 @@ Example:
|
|||||||
* `reason_new`: list of integers per day.
|
* `reason_new`: list of integers per day.
|
||||||
* `reason_dependency`: list of integers per day.
|
* `reason_dependency`: list of integers per day.
|
||||||
* `reason_update`: list of integers per day.
|
* `reason_update`: list of integers per day.
|
||||||
|
* `views_minetest`: list of integers per day.
|
||||||
|
|
||||||
|
|
||||||
## Topics
|
## Topics
|
||||||
@@ -577,7 +579,7 @@ Supported query parameters:
|
|||||||
* Get JSON Schema of `.cdb.json`, including licenses, tags and content warnings.
|
* Get JSON Schema of `.cdb.json`, including licenses, tags and content warnings.
|
||||||
* See [JSON Schema Reference](https://json-schema.org/).
|
* See [JSON Schema Reference](https://json-schema.org/).
|
||||||
* POST `/api/hypertext/`
|
* POST `/api/hypertext/`
|
||||||
* Converts HTML or Markdown to [Luanti Markup Language](https://github.com/luanti-org/luanti/blob/master/doc/lua_api.md#markup-language)
|
* Converts HTML or Markdown to [Luanti Markup Language](https://github.com/minetest/minetest/blob/master/doc/lua_api.md#markup-language)
|
||||||
to be used in a `hypertext` formspec element.
|
to be used in a `hypertext` formspec element.
|
||||||
* Post data: HTML or Markdown as plain text.
|
* Post data: HTML or Markdown as plain text.
|
||||||
* Content-Type: `text/html` or `text/markdown`.
|
* Content-Type: `text/html` or `text/markdown`.
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ can also translate your ContentDB page. See Edit Package > Translation for more
|
|||||||
<a class="btn btn-primary me-2" href="https://rubenwardy.com/minetest_modding_book/en/quality/translations.html">
|
<a class="btn btn-primary me-2" href="https://rubenwardy.com/minetest_modding_book/en/quality/translations.html">
|
||||||
{{ _("Translation - Luanti Modding Book") }}
|
{{ _("Translation - Luanti Modding Book") }}
|
||||||
</a>
|
</a>
|
||||||
<a class="btn btn-primary" href="https://api.luanti.org/translations/#translating-content-meta">
|
<a class="btn btn-primary" href="https://api.minetest.net/translations/#translating-content-meta">
|
||||||
{{ _("Translating content meta - lua_api.md") }}
|
{{ _("Translating content meta - lua_api.md") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ permanent bans.
|
|||||||
|
|
||||||
## Where can I get help?
|
## Where can I get help?
|
||||||
|
|
||||||
[Join](https://www.luanti.org/get-involved/) IRC, Matrix, or Discord to ask for 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.
|
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.
|
If your package is already on ContentDB, you can open a thread.
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ downloaded from that IP.
|
|||||||
You can see all scores using the [scores REST API](/api/scores/), or by
|
You can see all scores using the [scores REST API](/api/scores/), or by
|
||||||
using the [Prometheus metrics](/help/metrics/) endpoint.
|
using the [Prometheus metrics](/help/metrics/) endpoint.
|
||||||
|
|
||||||
Consider [suggesting improvements](https://github.com/luanti-org/contentdb/issues/new?assignees=&labels=Policy&template=policy.md&title=).
|
Consider [suggesting improvements](https://github.com/minetest/contentdb/issues/new?assignees=&labels=Policy&template=policy.md&title=).
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
title: WTFPL is a terrible license
|
title: WTFPL is a terrible license
|
||||||
toc: False
|
toc: False
|
||||||
|
|
||||||
|
<div id="warning" class="alert alert-warning">
|
||||||
|
<span class="icon_message"></span>
|
||||||
|
|
||||||
|
Please reconsider the choice of WTFPL as a license.
|
||||||
|
|
||||||
|
<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) {
|
||||||
|
document.write("<a class='alert_right button' href='" + r + "'>Okay</a>");
|
||||||
|
} else {
|
||||||
|
document.getElementById("warning").style.display = "none";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
|
||||||
The use of WTFPL as a license is discouraged for multiple reasons.
|
The use of WTFPL as a license is discouraged for multiple reasons.
|
||||||
|
|
||||||
* **No Warranty disclaimer:** This could open you up to being sued.<sup>[1]</sup>
|
* **No Warranty disclaimer:** This could open you up to being sued.<sup>[1]</sup>
|
||||||
@@ -18,4 +37,4 @@ license, saying:<sup>[3]</sup>
|
|||||||
|
|
||||||
1. [WTFPL is harmful to software developers](https://cubicspot.blogspot.com/2017/04/wtfpl-is-harmful-to-software-developers.html)
|
1. [WTFPL is harmful to software developers](https://cubicspot.blogspot.com/2017/04/wtfpl-is-harmful-to-software-developers.html)
|
||||||
2. [FSF](https://www.gnu.org/licenses/license-list.en.html)
|
2. [FSF](https://www.gnu.org/licenses/license-list.en.html)
|
||||||
3. [OSI](https://opensource.org/meeting-minutes/minutes20090304)
|
3. [OSI](https://opensource.org/minutes20090304)
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
title: Package Inclusion Policy and Guidance
|
title: Package Inclusion Policy and Guidance
|
||||||
|
|
||||||
|
## 0. Overview
|
||||||
|
|
||||||
|
ContentDB is for the community, and as such listings should be useful to the
|
||||||
|
community. To help with this, there are a few rules to improve the quality of
|
||||||
|
the listings and to combat abuse.
|
||||||
|
|
||||||
|
* **No inappropriate content.** <sup>2.1</sup>
|
||||||
|
* **Content must be playable/useful, but not necessarily finished.** <sup>2.2</sup>
|
||||||
|
* **Don't use the name of another mod unless your mod is a fork or reimplementation.** <sup>3</sup>
|
||||||
|
* **Licenses must allow derivatives, redistribution, and must not discriminate.** <sup>4</sup>
|
||||||
|
* **Don't put promotions or advertisements in any package metadata.** <sup>5</sup>
|
||||||
|
* **Don't manipulate package placement using reviews or downloads.** <sup>6</sup>
|
||||||
|
* **Screenshots must not be misleading.** <sup>7</sup>
|
||||||
|
* **The ContentDB admin reserves the right to remove packages for any reason**,
|
||||||
|
including ones not covered by this document, and to ban users who abuse
|
||||||
|
this service. <sup>1</sup>
|
||||||
|
|
||||||
|
|
||||||
## 1. General
|
## 1. General
|
||||||
|
|
||||||
@@ -9,53 +26,35 @@ including ones not covered by this document, and to ban users who abuse this ser
|
|||||||
|
|
||||||
## 2. Accepted Content
|
## 2. Accepted Content
|
||||||
|
|
||||||
### 2.1. Mature Content
|
### 2.1. Acceptable Content
|
||||||
|
|
||||||
See the [Terms of Service](/terms/) for a full list of prohibited content.
|
Sexually-orientated content is not permitted.
|
||||||
|
If in doubt at what this means, [contact us by raising a report](/report/).
|
||||||
|
|
||||||
Other mature content is permitted providing that it is labelled with the applicable
|
Content which depicts or encourages the use of illegal drugs (under the laws of the United Kingdom) is not permitted.
|
||||||
[content warning](/help/content_flags/).
|
|
||||||
|
|
||||||
### 2.2. Useful Content / State of Completion
|
Mature content is permitted providing that it is labelled correctly.
|
||||||
|
See [Content Flags](/help/content_flags/).
|
||||||
|
|
||||||
ContentDB is for playable and useful content - content which is sufficiently
|
### 2.2. State of Completion
|
||||||
complete to be useful to end-users.
|
|
||||||
|
|
||||||
It's fine to add stuff which is still a Work in Progress (WIP) as long as it
|
ContentDB should only currently contain playable content - content which is
|
||||||
adds sufficient value. You must make sure to mark Work in Progress stuff as
|
sufficiently complete to be useful to end-users. It's fine to add stuff which is
|
||||||
such in the "maintenance status" dropdown, as this will help advise players.
|
still a Work in Progress (WIP) as long as it adds sufficient value; Note that
|
||||||
|
this doesn't mean that you should add a thing you started working on yesterday,
|
||||||
|
it's worth adding all the basic stuff to make your package useful.
|
||||||
|
|
||||||
|
You should make sure to mark Work in Progress stuff as such in the "maintenance
|
||||||
|
status" column, as this will help advise players.
|
||||||
|
|
||||||
Adding non-player facing mods, such as libraries and server tools, is perfectly
|
Adding non-player facing mods, such as libraries and server tools, is perfectly
|
||||||
fine and encouraged. ContentDB isn't just for player-facing things and adding
|
fine and encouraged. ContentDB isn't just for player-facing things, and adding
|
||||||
libraries allows Luanti to automatically install dependencies.
|
libraries allows them to be installed when a mod depends on it.
|
||||||
|
|
||||||
### 2.3. Language
|
|
||||||
|
|
||||||
We require packages to be in English with (optional) client-side translations for
|
|
||||||
other languages. This is because Luanti currently requires English as the base language
|
|
||||||
([Issue to change this](https://github.com/luanti-org/luanti/issues/6503)).
|
|
||||||
|
|
||||||
Your package's title and short description must be in English. You can use client-side
|
|
||||||
translations to [translate content meta](https://api.luanti.org/translations/#translating-content-meta).
|
|
||||||
|
|
||||||
### 2.4. Attempt to contribute before forking
|
|
||||||
|
|
||||||
You should attempt to contribute upstream before forking a package. If you choose
|
|
||||||
to fork, you should have a justification (different objectives, maintainer is unavailable, etc).
|
|
||||||
You should use a different title and make it clear in the long description what the
|
|
||||||
benefit of your fork is over the original package.
|
|
||||||
|
|
||||||
### 2.5. Copyright and trademarks
|
|
||||||
|
|
||||||
Your package must not violate copyright or trademarks. You should avoid the use of
|
|
||||||
trademarks in the package's title or short description. If you do use a trademark,
|
|
||||||
ensure that you phrase it in a way that does not imply official association or
|
|
||||||
endorsement.
|
|
||||||
|
|
||||||
|
|
||||||
## 3. Technical Names
|
## 3. Technical Names
|
||||||
|
|
||||||
### 3.1. Right to a Name
|
### 3.1 Right to a name
|
||||||
|
|
||||||
A package uses a name when it has that name or contains a mod that uses that name.
|
A package uses a name when it has that name or contains a mod that uses that name.
|
||||||
|
|
||||||
@@ -73,46 +72,23 @@ to change the name of the package, or your package won't be accepted.
|
|||||||
|
|
||||||
We reserve the right to issue exceptions for this where we feel necessary.
|
We reserve the right to issue exceptions for this where we feel necessary.
|
||||||
|
|
||||||
### 3.2. Forks and Reimplementations
|
### 3.2. Mod Forks and Reimplementations
|
||||||
|
|
||||||
An exception to the above is that mods are allowed to have the same name as a
|
An exception to the above is that mods are allowed to have the same name as a
|
||||||
mod if it's a fork of that mod (or a close reimplementation). In real terms, it
|
mod if it's a fork of that mod (or a close reimplementation). In real terms, it
|
||||||
must be possible to use the new mod as a drop-in replacement.
|
should be possible to use the new mod as a drop-in replacement.
|
||||||
|
|
||||||
We reserve the right to decide whether a mod counts as a fork or
|
We reserve the right to decide whether a mod counts as a fork or
|
||||||
reimplementation of the mod that owns the name.
|
reimplementation of the mod that owns the name.
|
||||||
|
|
||||||
### 3.3. Game Mod Namespacing
|
|
||||||
|
|
||||||
New mods introduced by a game must have a unique common prefix to avoid conflicts with
|
|
||||||
other games and standalone mods. For example, the NodeCore game's first-party mods all
|
|
||||||
start with `nc_`: `nc_api`, `nc_doors`.
|
|
||||||
|
|
||||||
You may include existing or standard mods in your game without renaming them to use the
|
|
||||||
namespace. For example, NodeCore could include the `awards` mod without needing to rename it.
|
|
||||||
|
|
||||||
Standalone mods may not use a game's namespace unless they have been given permission by
|
|
||||||
the game's author.
|
|
||||||
|
|
||||||
The exception given by 3.2 also applies to game namespaces - you may use another game's
|
|
||||||
prefix if your game is a fork.
|
|
||||||
|
|
||||||
|
|
||||||
## 4. Licenses
|
## 4. Licenses
|
||||||
|
|
||||||
### 4.1. License file
|
### 4.1. Allowed Licenses
|
||||||
|
|
||||||
You must have a LICENSE, LICENSE.txt, or LICENSE.md file describing the licensing of your package.
|
|
||||||
Please ensure that you correctly credit any resources (code, assets, or otherwise)
|
Please ensure that you correctly credit any resources (code, assets, or otherwise)
|
||||||
that you have used in your package.
|
that you have used in your package. For help on doing copyright correctly, see
|
||||||
|
the [Copyright help page](/help/copyright/).
|
||||||
You may use lowercase or include a suffix in the filename (ie: `license-code.txt`). If
|
|
||||||
you are making a game or modpack, your top level license file may just be a summary or
|
|
||||||
refer to the license files of individual components.
|
|
||||||
|
|
||||||
For help on doing copyright correctly, see the [Copyright help page](/help/copyright/).
|
|
||||||
|
|
||||||
### 4.2. Allowed Licenses
|
|
||||||
|
|
||||||
**The use of licenses that do not allow derivatives or redistribution is not
|
**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.
|
permitted. This includes CC-ND (No-Derivatives) and lots of closed source licenses.
|
||||||
@@ -122,13 +98,13 @@ of the content on servers or singleplayer is also not permitted.**
|
|||||||
However, closed sourced licenses are allowed if they allow the above.
|
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
|
If the license you use is not on the list then please select "Other", and we'll
|
||||||
get around to adding it. We reject custom/untested licenses and reserve the right
|
get around to adding it. We tend to reject custom/untested licenses, and
|
||||||
to decide whether a license should be included.
|
reserve the right to decide whether a license should be included.
|
||||||
|
|
||||||
Please note that the definitions of "free" and "non-free" is the same as that
|
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).
|
of the [Free Software Foundation](https://www.gnu.org/philosophy/free-sw.en.html).
|
||||||
|
|
||||||
### 4.3. Recommended Licenses
|
### 4.2. Recommended Licenses
|
||||||
|
|
||||||
It is highly recommended that you use a Free and Open Source software (FOSS)
|
It is highly recommended that you use a Free and Open Source software (FOSS)
|
||||||
license. FOSS licenses result in a sharing community and will increase the
|
license. FOSS licenses result in a sharing community and will increase the
|
||||||
@@ -176,14 +152,10 @@ Doing so may result in temporary or permanent suspension from ContentDB.
|
|||||||
|
|
||||||
## 7. Screenshots
|
## 7. Screenshots
|
||||||
|
|
||||||
1. We require all packages to have at least one screenshot. For packages without visual
|
1. **Screenshots must not violate copyright.** You should have the rights to the
|
||||||
content, we recommend making a symbolic image with icons, graphics, or text to depict
|
screenshot.
|
||||||
the package.
|
|
||||||
|
|
||||||
2. **Screenshots must not violate copyright.** This means don't just copy images
|
2. **Screenshots must depict the actual content of the package in some way, and
|
||||||
from Google search, see [the copyright guide](/help/copyright/).
|
|
||||||
|
|
||||||
3. **Screenshots must depict the actual content of the package in some way, and
|
|
||||||
not be misleading.**
|
not be misleading.**
|
||||||
|
|
||||||
Do not use idealized mockups or blender concept renders if they do not
|
Do not use idealized mockups or blender concept renders if they do not
|
||||||
@@ -199,9 +171,20 @@ Doing so may result in temporary or permanent suspension from ContentDB.
|
|||||||
will look like in a typical/realistic game scene, but should be "in the
|
will look like in a typical/realistic game scene, but should be "in the
|
||||||
background" only as far as possible.
|
background" only as far as possible.
|
||||||
|
|
||||||
4. **Screenshots must only contain content appropriate for the Content Warnings of
|
3. **Screenshots must only contain content appropriate for the Content Warnings of
|
||||||
the package.**
|
the package.**
|
||||||
|
|
||||||
|
4. **Screenshots should be MOSTLY in-game screenshots, if applicable.** Some
|
||||||
|
alterations on in-game screenshots are okay, such as collages, added text,
|
||||||
|
some reasonable compositing.
|
||||||
|
|
||||||
|
Don't just use one of the textures from the package; show it in-situ as it
|
||||||
|
actually looks in the game.
|
||||||
|
|
||||||
|
5. **Packages should have a screenshot when reasonably applicable.**
|
||||||
|
|
||||||
|
6. **Screenshots should be of reasonable dimensions.** We recommend using 1920x1080.
|
||||||
|
|
||||||
|
|
||||||
## 8. Security
|
## 8. Security
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ title: Privacy Policy
|
|||||||
---
|
---
|
||||||
|
|
||||||
Last Updated: 2024-04-30
|
Last Updated: 2024-04-30
|
||||||
([View updates](https://github.com/luanti-org/contentdb/commits/master/app/flatpages/privacy_policy.md))
|
([View updates](https://github.com/minetest/contentdb/commits/master/app/flatpages/privacy_policy.md))
|
||||||
|
|
||||||
## What Information is Collected
|
## What Information is Collected
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ def _get_approval_statistics(entries: list[AuditLogEntry], start_date: Optional[
|
|||||||
(end_date is None or entry.created_at <= end_date)))
|
(end_date is None or entry.created_at <= end_date)))
|
||||||
info.is_in_range = info.is_in_range or is_in_range
|
info.is_in_range = info.is_in_range or is_in_range
|
||||||
|
|
||||||
new_state = get_state(entry.title.replace("…", "") + (entry.description or ""))
|
new_state = get_state(entry.title)
|
||||||
if new_state == info.state:
|
if new_state == info.state:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,12 @@ class GameSupport:
|
|||||||
|
|
||||||
def _get_supported_games(self, package: GSPackage, visited: list[str]) -> Optional[set[str]]:
|
def _get_supported_games(self, package: GSPackage, visited: list[str]) -> Optional[set[str]]:
|
||||||
if package.id_ in visited:
|
if package.id_ in visited:
|
||||||
|
# first_idx = visited.index(package.id_)
|
||||||
|
# visited = visited[first_idx:]
|
||||||
|
# err = f"Dependency cycle detected: {' -> '.join(visited)} -> {package.id_}"
|
||||||
|
# for id_ in visited:
|
||||||
|
# package2 = self.get(id_)
|
||||||
|
# package2.add_error(err)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if package.type == PackageType.GAME:
|
if package.type == PackageType.GAME:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def daterange(start_date, end_date):
|
|||||||
|
|
||||||
|
|
||||||
keys = ["platform_minetest", "platform_other", "reason_new",
|
keys = ["platform_minetest", "platform_other", "reason_new",
|
||||||
"reason_dependency", "reason_update"]
|
"reason_dependency", "reason_update", "views_minetest"]
|
||||||
|
|
||||||
|
|
||||||
def flatten_data(stats):
|
def flatten_data(stats):
|
||||||
@@ -78,7 +78,8 @@ def get_package_stats_for_user(user: User, start_date: Optional[datetime.date],
|
|||||||
func.sum(PackageDailyStats.platform_other).label("platform_other"),
|
func.sum(PackageDailyStats.platform_other).label("platform_other"),
|
||||||
func.sum(PackageDailyStats.reason_new).label("reason_new"),
|
func.sum(PackageDailyStats.reason_new).label("reason_new"),
|
||||||
func.sum(PackageDailyStats.reason_dependency).label("reason_dependency"),
|
func.sum(PackageDailyStats.reason_dependency).label("reason_dependency"),
|
||||||
func.sum(PackageDailyStats.reason_update).label("reason_update")) \
|
func.sum(PackageDailyStats.reason_update).label("reason_update"),
|
||||||
|
func.sum(PackageDailyStats.views_minetest).label("views_minetest")) \
|
||||||
.filter(PackageDailyStats.package.has(author_id=user.id))
|
.filter(PackageDailyStats.package.has(author_id=user.id))
|
||||||
|
|
||||||
if start_date:
|
if start_date:
|
||||||
|
|||||||
@@ -46,9 +46,6 @@ class PackageValidationNote:
|
|||||||
self.buttons.append((url, label))
|
self.buttons.append((url, label))
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return str(self.message)
|
|
||||||
|
|
||||||
|
|
||||||
def is_package_name_taken(normalised_name: str) -> bool:
|
def is_package_name_taken(normalised_name: str) -> bool:
|
||||||
return Package.query.filter(
|
return Package.query.filter(
|
||||||
@@ -110,7 +107,8 @@ def validate_package_for_approval(package: Package) -> List[PackageValidationNot
|
|||||||
# Don't bother validating any more until we have a release
|
# Don't bother validating any more until we have a release
|
||||||
return retval
|
return retval
|
||||||
|
|
||||||
if package.screenshots.count() == 0:
|
if (package.type == PackageType.GAME or package.type == PackageType.TXP) and \
|
||||||
|
package.screenshots.count() == 0:
|
||||||
danger(lazy_gettext("You need to add at least one screenshot."))
|
danger(lazy_gettext("You need to add at least one screenshot."))
|
||||||
|
|
||||||
missing_deps = package.get_missing_hard_dependencies_query().all()
|
missing_deps = package.get_missing_hard_dependencies_query().all()
|
||||||
|
|||||||
@@ -69,19 +69,6 @@ ALLOWED_FIELDS = {
|
|||||||
"translation_url": str,
|
"translation_url": str,
|
||||||
}
|
}
|
||||||
|
|
||||||
NULLABLE = {
|
|
||||||
"tags",
|
|
||||||
"content_warnings",
|
|
||||||
"repo",
|
|
||||||
"website",
|
|
||||||
"issue_tracker",
|
|
||||||
"issueTracker",
|
|
||||||
"forums",
|
|
||||||
"video_url",
|
|
||||||
"donate_url",
|
|
||||||
"translation_url",
|
|
||||||
}
|
|
||||||
|
|
||||||
ALIASES = {
|
ALIASES = {
|
||||||
"short_description": "short_desc",
|
"short_description": "short_desc",
|
||||||
"issue_tracker": "issueTracker",
|
"issue_tracker": "issueTracker",
|
||||||
@@ -99,13 +86,11 @@ def is_int(val):
|
|||||||
|
|
||||||
def validate(data: dict):
|
def validate(data: dict):
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if value is None:
|
if value is not None:
|
||||||
check(key in NULLABLE, f"{key} must not be null")
|
|
||||||
else:
|
|
||||||
typ = ALLOWED_FIELDS.get(key)
|
typ = ALLOWED_FIELDS.get(key)
|
||||||
check(typ is not None, f"{key} is not a known field")
|
check(typ is not None, key + " is not a known field")
|
||||||
if typ != AnyType:
|
if typ != AnyType:
|
||||||
check(isinstance(value, typ), f"{key} must be a " + typ.__name__)
|
check(isinstance(value, typ), key + " must be a " + typ.__name__)
|
||||||
|
|
||||||
if "name" in data:
|
if "name" in data:
|
||||||
name = data["name"]
|
name = data["name"]
|
||||||
@@ -117,8 +102,8 @@ def validate(data: dict):
|
|||||||
value = data.get(key)
|
value = data.get(key)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
check(value.startswith("http://") or value.startswith("https://"),
|
check(value.startswith("http://") or value.startswith("https://"),
|
||||||
f"{key} must start with http:// or https://")
|
key + " must start with http:// or https://")
|
||||||
check(validators.url(value), f"{key} must be a valid URL")
|
check(validators.url(value), key + " must be a valid URL")
|
||||||
|
|
||||||
|
|
||||||
def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict,
|
def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict,
|
||||||
@@ -136,9 +121,6 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
|||||||
|
|
||||||
for alias, to in ALIASES.items():
|
for alias, to in ALIASES.items():
|
||||||
if alias in data:
|
if alias in data:
|
||||||
if to in data and data[to] != data[alias]:
|
|
||||||
raise LogicError(403, f"Aliased field ({alias}) does not match new field ({to})")
|
|
||||||
|
|
||||||
data[to] = data[alias]
|
data[to] = data[alias]
|
||||||
|
|
||||||
validate(data)
|
validate(data)
|
||||||
@@ -187,6 +169,7 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
|||||||
package.provides.append(m)
|
package.provides.append(m)
|
||||||
|
|
||||||
if "tags" in data:
|
if "tags" in data:
|
||||||
|
old_tags = list(package.tags)
|
||||||
package.tags.clear()
|
package.tags.clear()
|
||||||
for tag_id in (data["tags"] or []):
|
for tag_id in (data["tags"] or []):
|
||||||
if is_int(tag_id):
|
if is_int(tag_id):
|
||||||
@@ -210,10 +193,7 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
|
|||||||
package.content_warnings.append(warning)
|
package.content_warnings.append(warning)
|
||||||
|
|
||||||
was_modified = was_new
|
was_modified = was_new
|
||||||
if was_new:
|
if not was_new:
|
||||||
msg = f"Created package {package.author.username}/{package.name}"
|
|
||||||
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package)
|
|
||||||
else:
|
|
||||||
after_dict = package.as_dict("/")
|
after_dict = package.as_dict("/")
|
||||||
diff = diff_dictionaries(before_dict, after_dict)
|
diff = diff_dictionaries(before_dict, after_dict)
|
||||||
was_modified = len(diff) > 0
|
was_modified = len(diff) > 0
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from flask_babel import lazy_gettext
|
|||||||
|
|
||||||
from app.logic.LogicError import LogicError
|
from app.logic.LogicError import LogicError
|
||||||
from app.logic.uploads import upload_file
|
from app.logic.uploads import upload_file
|
||||||
from app.models import PackageRelease, db, Permission, User, Package, LuantiRelease
|
from app.models import PackageRelease, db, Permission, User, Package, MinetestRelease
|
||||||
from app.tasks.importtasks import make_vcs_release, check_zip_release
|
from app.tasks.importtasks import make_vcs_release, check_zip_release
|
||||||
from app.utils import AuditSeverity, add_audit_log, nonempty_or_none, normalize_line_endings
|
from app.utils import AuditSeverity, add_audit_log, nonempty_or_none, normalize_line_endings
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ def check_can_create_release(user: User, package: Package, name: str):
|
|||||||
|
|
||||||
|
|
||||||
def do_create_vcs_release(user: User, package: Package, name: str, title: Optional[str], release_notes: Optional[str], ref: str,
|
def do_create_vcs_release(user: User, package: Package, name: str, title: Optional[str], release_notes: Optional[str], ref: str,
|
||||||
min_v: LuantiRelease = None, max_v: LuantiRelease = None, reason: str = None):
|
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None):
|
||||||
check_can_create_release(user, package, name)
|
check_can_create_release(user, package, name)
|
||||||
|
|
||||||
rel = PackageRelease()
|
rel = PackageRelease()
|
||||||
@@ -70,7 +70,7 @@ def do_create_vcs_release(user: User, package: Package, name: str, title: Option
|
|||||||
|
|
||||||
|
|
||||||
def do_create_zip_release(user: User, package: Package, name: str, title: Optional[str], release_notes: Optional[str], file,
|
def do_create_zip_release(user: User, package: Package, name: str, title: Optional[str], release_notes: Optional[str], file,
|
||||||
min_v: LuantiRelease = None, max_v: LuantiRelease = None, reason: str = None,
|
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None,
|
||||||
commit_hash: str = None):
|
commit_hash: str = None):
|
||||||
check_can_create_release(user, package, name)
|
check_can_create_release(user, package, name)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import imghdr
|
import imghdr
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask_babel import lazy_gettext, LazyString
|
from flask_babel import lazy_gettext
|
||||||
|
|
||||||
from app import app
|
from app import app
|
||||||
from app.logic.LogicError import LogicError
|
from app.logic.LogicError import LogicError
|
||||||
@@ -35,7 +35,7 @@ def is_allowed_image(data):
|
|||||||
return imghdr.what(None, data) in ALLOWED_IMAGES
|
return imghdr.what(None, data) in ALLOWED_IMAGES
|
||||||
|
|
||||||
|
|
||||||
def upload_file(file, file_type: str, file_type_desc: LazyString | str, length: int=10):
|
def upload_file(file, file_type, file_type_desc):
|
||||||
if not file or file is None or file.filename == "":
|
if not file or file is None or file.filename == "":
|
||||||
raise LogicError(400, "Expected file")
|
raise LogicError(400, "Expected file")
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ def upload_file(file, file_type: str, file_type_desc: LazyString | str, length:
|
|||||||
|
|
||||||
file.stream.seek(0)
|
file.stream.seek(0)
|
||||||
|
|
||||||
filename = random_string(length) + "." + ext
|
filename = random_string(10) + "." + ext
|
||||||
filepath = os.path.join(app.config["UPLOAD_DIR"], filename)
|
filepath = os.path.join(app.config["UPLOAD_DIR"], filename)
|
||||||
file.save(filepath)
|
file.save(filepath)
|
||||||
|
|
||||||
|
|||||||
214
app/markdown.py
Normal file
214
app/markdown.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# 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
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import bleach
|
||||||
|
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 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
|
||||||
|
# https://github.com/Wenzil/mdx_bleach/blob/master/mdx_bleach/whitelist.py
|
||||||
|
#
|
||||||
|
# License: MIT
|
||||||
|
|
||||||
|
ALLOWED_TAGS = {
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6", "hr",
|
||||||
|
"ul", "ol", "li",
|
||||||
|
"p",
|
||||||
|
"br",
|
||||||
|
"pre",
|
||||||
|
"code",
|
||||||
|
"blockquote",
|
||||||
|
"strong",
|
||||||
|
"em",
|
||||||
|
"a",
|
||||||
|
"img",
|
||||||
|
"table", "thead", "tbody", "tr", "th", "td",
|
||||||
|
"div", "span", "del", "s",
|
||||||
|
"details",
|
||||||
|
"summary",
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOWED_CSS = [
|
||||||
|
"highlight", "codehilite",
|
||||||
|
"hll", "c", "err", "g", "k", "l", "n", "o", "x", "p", "ch", "cm", "cp", "cpf", "c1", "cs",
|
||||||
|
"gd", "ge", "gr", "gh", "gi", "go", "gp", "gs", "gu", "gt", "kc", "kd", "kn", "kp", "kr",
|
||||||
|
"kt", "ld", "m", "s", "na", "nb", "nc", "no", "nd", "ni", "ne", "nf", "nl", "nn", "nx",
|
||||||
|
"py", "nt", "nv", "ow", "w", "mb", "mf", "mh", "mi", "mo", "sa", "sb", "sc", "dl", "sd",
|
||||||
|
"s2", "se", "sh", "si", "sx", "sr", "s1", "ss", "bp", "fm", "vc", "vg", "vi", "vm", "il",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def allow_class(_tag, name, value):
|
||||||
|
return name == "class" and value in ALLOWED_CSS
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_ATTRIBUTES = {
|
||||||
|
"h1": ["id"],
|
||||||
|
"h2": ["id"],
|
||||||
|
"h3": ["id"],
|
||||||
|
"h4": ["id"],
|
||||||
|
"a": ["href", "title", "data-username"],
|
||||||
|
"img": ["src", "title", "alt"],
|
||||||
|
"code": allow_class,
|
||||||
|
"div": allow_class,
|
||||||
|
"span": allow_class,
|
||||||
|
"table": ["id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
cleaner = Cleaner(
|
||||||
|
tags=ALLOWED_TAGS,
|
||||||
|
attributes=ALLOWED_ATTRIBUTES,
|
||||||
|
protocols=ALLOWED_PROTOCOLS,
|
||||||
|
filters=[partial(LinkifyFilter,
|
||||||
|
callbacks=[linker_callback] + bleach.linkifier.DEFAULT_CALLBACKS,
|
||||||
|
skip_tags={"pre", "code"})])
|
||||||
|
return cleaner.clean(html)
|
||||||
|
|
||||||
|
|
||||||
|
class DelInsExtension(Extension):
|
||||||
|
def extendMarkdown(self, md):
|
||||||
|
del_proc = SimpleTagInlineProcessor(r"(\~\~)(.+?)(\~\~)", "del")
|
||||||
|
md.inlinePatterns.register(del_proc, "del", 200)
|
||||||
|
|
||||||
|
ins_proc = SimpleTagInlineProcessor(r"(\+\+)(.+?)(\+\+)", "ins")
|
||||||
|
md.inlinePatterns.register(ins_proc, "ins", 200)
|
||||||
|
|
||||||
|
|
||||||
|
RE_PARTS = dict(
|
||||||
|
USER=r"[A-Za-z0-9._-]*\b",
|
||||||
|
REPO=r"[A-Za-z0-9_]+\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MentionPattern(Pattern):
|
||||||
|
ANCESTOR_EXCLUDES = ("a",)
|
||||||
|
|
||||||
|
def __init__(self, config, md):
|
||||||
|
MENTION_RE = r"(@({USER})(?:\/({REPO}))?)".format(**RE_PARTS)
|
||||||
|
super(MentionPattern, self).__init__(MENTION_RE, md)
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def handleMatch(self, m):
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
label = m.group(2)
|
||||||
|
user = m.group(3)
|
||||||
|
package_name = m.group(4)
|
||||||
|
if package_name:
|
||||||
|
el = ElementTree.Element("a")
|
||||||
|
el.text = label
|
||||||
|
el.set("href", url_for("packages.view", author=user, name=package_name))
|
||||||
|
return el
|
||||||
|
else:
|
||||||
|
if User.query.filter_by(username=user).count() == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
el = ElementTree.Element("a")
|
||||||
|
el.text = label
|
||||||
|
el.set("href", url_for("users.profile", username=user))
|
||||||
|
el.set("data-username", user)
|
||||||
|
return el
|
||||||
|
|
||||||
|
|
||||||
|
class MentionExtension(Extension):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(MentionExtension, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def extendMarkdown(self, md):
|
||||||
|
md.ESCAPED_CHARS.append("@")
|
||||||
|
md.inlinePatterns.register(MentionPattern(self.getConfigs(), md), "mention", 20)
|
||||||
|
|
||||||
|
|
||||||
|
MARKDOWN_EXTENSIONS = ["fenced_code", "tables", CodeHiliteExtension(guess_lang=False), "toc", DelInsExtension(), MentionExtension()]
|
||||||
|
MARKDOWN_EXTENSION_CONFIG = {
|
||||||
|
"fenced_code": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def init_markdown(app):
|
||||||
|
global md
|
||||||
|
|
||||||
|
md = Markdown(extensions=MARKDOWN_EXTENSIONS,
|
||||||
|
extension_configs=MARKDOWN_EXTENSION_CONFIG,
|
||||||
|
output_format="html")
|
||||||
|
|
||||||
|
@app.template_filter()
|
||||||
|
def markdown(source):
|
||||||
|
return markupsafe.Markup(render_markdown(source))
|
||||||
|
|
||||||
|
|
||||||
|
def get_headings(html: str):
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
headings = soup.find_all(["h1", "h2", "h3"])
|
||||||
|
|
||||||
|
root = []
|
||||||
|
stack = []
|
||||||
|
for heading in headings:
|
||||||
|
this = {"link": heading.get("id") or "", "text": heading.text, "children": []}
|
||||||
|
this_level = int(heading.name[1:]) - 1
|
||||||
|
|
||||||
|
while this_level <= len(stack):
|
||||||
|
stack.pop()
|
||||||
|
|
||||||
|
if len(stack) > 0:
|
||||||
|
stack[-1]["children"].append(this)
|
||||||
|
else:
|
||||||
|
root.append(this)
|
||||||
|
|
||||||
|
stack.append(this)
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_mentions(html: str) -> set:
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
links = soup.select("a[data-username]")
|
||||||
|
return set([x.get("data-username") for x in links])
|
||||||
|
|
||||||
|
|
||||||
|
def get_links(html: str, url: str) -> set:
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
links = soup.select("a[href]")
|
||||||
|
return set([urljoin(url, x.get("href")) for x in links])
|
||||||
@@ -1,112 +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 typing import Sequence
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from jinja2.utils import markupsafe
|
|
||||||
from markdown_it import MarkdownIt
|
|
||||||
from markdown_it.common.utils import unescapeAll, escapeHtml
|
|
||||||
from markdown_it.token import Token
|
|
||||||
from markdown_it.presets import gfm_like
|
|
||||||
from mdit_py_plugins.anchors import anchors_plugin
|
|
||||||
from pygments import highlight
|
|
||||||
from pygments.lexers import get_lexer_by_name
|
|
||||||
from pygments.util import ClassNotFound
|
|
||||||
from pygments.formatters.html import HtmlFormatter
|
|
||||||
|
|
||||||
from .cleaner import clean_html
|
|
||||||
from .mention import init_mention
|
|
||||||
|
|
||||||
|
|
||||||
def highlight_code(code, name, attrs):
|
|
||||||
try:
|
|
||||||
lexer = get_lexer_by_name(name)
|
|
||||||
except ClassNotFound:
|
|
||||||
return None
|
|
||||||
|
|
||||||
formatter = HtmlFormatter()
|
|
||||||
|
|
||||||
return highlight(code, lexer, formatter)
|
|
||||||
|
|
||||||
|
|
||||||
def render_code(self, tokens: Sequence[Token], idx, options, env):
|
|
||||||
token = tokens[idx]
|
|
||||||
info = unescapeAll(token.info).strip() if token.info else ""
|
|
||||||
langName = info.split(maxsplit=1)[0] if info else ""
|
|
||||||
|
|
||||||
if options.highlight:
|
|
||||||
return options.highlight(
|
|
||||||
token.content, langName, ""
|
|
||||||
) or f"<pre><code>{escapeHtml(token.content)}</code></pre>"
|
|
||||||
|
|
||||||
return f"<pre><code>{escapeHtml(token.content)}</code></pre>"
|
|
||||||
|
|
||||||
|
|
||||||
gfm_like.make()
|
|
||||||
md = MarkdownIt("gfm-like", {"highlight": highlight_code})
|
|
||||||
md.use(anchors_plugin, permalink=True, permalinkSymbol="🔗", max_level=6)
|
|
||||||
md.add_render_rule("fence", render_code)
|
|
||||||
init_mention(md)
|
|
||||||
|
|
||||||
|
|
||||||
def render_markdown(source, clean=True):
|
|
||||||
html = md.render(source)
|
|
||||||
if clean:
|
|
||||||
return clean_html(html)
|
|
||||||
else:
|
|
||||||
return html
|
|
||||||
|
|
||||||
|
|
||||||
def init_markdown(app):
|
|
||||||
@app.template_filter()
|
|
||||||
def markdown(source):
|
|
||||||
return markupsafe.Markup(render_markdown(source))
|
|
||||||
|
|
||||||
|
|
||||||
def get_headings(html: str):
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
headings = soup.find_all(["h1", "h2", "h3"])
|
|
||||||
|
|
||||||
root = []
|
|
||||||
stack = []
|
|
||||||
for heading in headings:
|
|
||||||
text = heading.find(text=True, recursive=False)
|
|
||||||
this = {"link": heading.get("id") or "", "text": text, "children": []}
|
|
||||||
this_level = int(heading.name[1:]) - 1
|
|
||||||
|
|
||||||
while this_level <= len(stack):
|
|
||||||
stack.pop()
|
|
||||||
|
|
||||||
if len(stack) > 0:
|
|
||||||
stack[-1]["children"].append(this)
|
|
||||||
else:
|
|
||||||
root.append(this)
|
|
||||||
|
|
||||||
stack.append(this)
|
|
||||||
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_mentions(html: str) -> set:
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
links = soup.select("a[data-username]")
|
|
||||||
return set([x.get("data-username") for x in links])
|
|
||||||
|
|
||||||
|
|
||||||
def get_links(html: str) -> set:
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
links = soup.select("a[href]")
|
|
||||||
return set([x.get("href") for x in links])
|
|
||||||
@@ -1,97 +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 functools import partial
|
|
||||||
from bleach import Cleaner
|
|
||||||
from bleach.linkifier import LinkifyFilter, DEFAULT_CALLBACKS
|
|
||||||
|
|
||||||
|
|
||||||
# Based on
|
|
||||||
# https://github.com/Wenzil/mdx_bleach/blob/master/mdx_bleach/whitelist.py
|
|
||||||
#
|
|
||||||
# License: MIT
|
|
||||||
|
|
||||||
ALLOWED_TAGS = {
|
|
||||||
"h1", "h2", "h3", "h4", "h5", "h6", "hr",
|
|
||||||
"ul", "ol", "li",
|
|
||||||
"p",
|
|
||||||
"br",
|
|
||||||
"pre",
|
|
||||||
"code",
|
|
||||||
"blockquote",
|
|
||||||
"strong",
|
|
||||||
"em",
|
|
||||||
"a",
|
|
||||||
"img",
|
|
||||||
"table", "thead", "tbody", "tr", "th", "td",
|
|
||||||
"div", "span", "del", "s",
|
|
||||||
"details",
|
|
||||||
"summary",
|
|
||||||
"sup",
|
|
||||||
}
|
|
||||||
|
|
||||||
ALLOWED_CSS = [
|
|
||||||
"highlight", "codehilite",
|
|
||||||
"hll", "c", "err", "g", "k", "l", "n", "o", "x", "p", "ch", "cm", "cp", "cpf", "c1", "cs",
|
|
||||||
"gd", "ge", "gr", "gh", "gi", "go", "gp", "gs", "gu", "gt", "kc", "kd", "kn", "kp", "kr",
|
|
||||||
"kt", "ld", "m", "s", "na", "nb", "nc", "no", "nd", "ni", "ne", "nf", "nl", "nn", "nx",
|
|
||||||
"py", "nt", "nv", "ow", "w", "mb", "mf", "mh", "mi", "mo", "sa", "sb", "sc", "dl", "sd",
|
|
||||||
"s2", "se", "sh", "si", "sx", "sr", "s1", "ss", "bp", "fm", "vc", "vg", "vi", "vm", "il",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def allow_class(_tag, name, value):
|
|
||||||
return name == "class" and value in ALLOWED_CSS
|
|
||||||
|
|
||||||
|
|
||||||
def allow_a(_tag, name, value):
|
|
||||||
return name in ["href", "title", "data-username"] or (name == "class" and value == "header-anchor")
|
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_ATTRIBUTES = {
|
|
||||||
"h1": ["id"],
|
|
||||||
"h2": ["id"],
|
|
||||||
"h3": ["id"],
|
|
||||||
"h4": ["id"],
|
|
||||||
"a": allow_a,
|
|
||||||
"img": ["src", "title", "alt"],
|
|
||||||
"code": allow_class,
|
|
||||||
"div": allow_class,
|
|
||||||
"span": allow_class,
|
|
||||||
"table": ["id"],
|
|
||||||
}
|
|
||||||
|
|
||||||
ALLOWED_PROTOCOLS = {"http", "https", "mailto"}
|
|
||||||
|
|
||||||
|
|
||||||
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 clean_html(html: str):
|
|
||||||
cleaner = Cleaner(
|
|
||||||
tags=ALLOWED_TAGS,
|
|
||||||
attributes=ALLOWED_ATTRIBUTES,
|
|
||||||
protocols=ALLOWED_PROTOCOLS,
|
|
||||||
filters=[partial(LinkifyFilter,
|
|
||||||
callbacks=[linker_callback] + DEFAULT_CALLBACKS,
|
|
||||||
skip_tags={"pre", "code"})])
|
|
||||||
return cleaner.clean(html)
|
|
||||||
@@ -1,109 +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 re
|
|
||||||
|
|
||||||
from flask import url_for
|
|
||||||
from markdown_it import MarkdownIt
|
|
||||||
from markdown_it.token import Token
|
|
||||||
from markdown_it.rules_core.state_core import StateCore
|
|
||||||
from typing import Sequence, List
|
|
||||||
|
|
||||||
|
|
||||||
def render_user_mention(self, tokens: Sequence[Token], idx, options, env):
|
|
||||||
token = tokens[idx]
|
|
||||||
username = token.content
|
|
||||||
url = url_for("users.profile", username=username)
|
|
||||||
return f"<a href=\"{url}\" data-username=\"{username}\">@{username}</a>"
|
|
||||||
|
|
||||||
|
|
||||||
def render_package_mention(self, tokens: Sequence[Token], idx, options, env):
|
|
||||||
token = tokens[idx]
|
|
||||||
username = token.content
|
|
||||||
name = token.attrs["name"]
|
|
||||||
url = url_for("packages.view", author=username, name=name)
|
|
||||||
return f"<a href=\"{url}\">@{username}/{name}</a>"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_mentions(state: StateCore):
|
|
||||||
for block_token in state.tokens:
|
|
||||||
if block_token.type != "inline" or block_token.children is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
link_depth = 0
|
|
||||||
html_link_depth = 0
|
|
||||||
|
|
||||||
children = []
|
|
||||||
for token in block_token.children:
|
|
||||||
if token.type == "link_open":
|
|
||||||
link_depth += 1
|
|
||||||
elif token.type == "link_close":
|
|
||||||
link_depth -= 1
|
|
||||||
elif token.type == "html_inline":
|
|
||||||
# is link open / close?
|
|
||||||
pass
|
|
||||||
|
|
||||||
if link_depth > 0 or html_link_depth > 0 or token.type != "text":
|
|
||||||
children.append(token)
|
|
||||||
else:
|
|
||||||
children.extend(split_tokens(token, state))
|
|
||||||
|
|
||||||
block_token.children = children
|
|
||||||
|
|
||||||
|
|
||||||
RE_PARTS = dict(
|
|
||||||
USER=r"[A-Za-z0-9._-]*\b",
|
|
||||||
NAME=r"[A-Za-z0-9_]+\b"
|
|
||||||
)
|
|
||||||
MENTION_RE = r"(@({USER})(?:\/({NAME}))?)".format(**RE_PARTS)
|
|
||||||
|
|
||||||
|
|
||||||
def split_tokens(token: Token, state: StateCore) -> List[Token]:
|
|
||||||
tokens = []
|
|
||||||
content = token.content
|
|
||||||
pos = 0
|
|
||||||
for match in re.finditer(MENTION_RE, content):
|
|
||||||
username = match.group(2)
|
|
||||||
package_name = match.group(3)
|
|
||||||
(start, end) = match.span(0)
|
|
||||||
|
|
||||||
if start > pos:
|
|
||||||
token_text = Token("text", "", 0)
|
|
||||||
token_text.content = content[pos:start]
|
|
||||||
token_text.level = token.level
|
|
||||||
tokens.append(token_text)
|
|
||||||
|
|
||||||
mention = Token("package_mention" if package_name else "user_mention", "", 0)
|
|
||||||
mention.content = username
|
|
||||||
mention.attrSet("name", package_name)
|
|
||||||
mention.level = token.level
|
|
||||||
tokens.append(mention)
|
|
||||||
|
|
||||||
pos = end
|
|
||||||
|
|
||||||
if pos < len(content):
|
|
||||||
token_text = Token("text", "", 0)
|
|
||||||
token_text.content = content[pos:]
|
|
||||||
token_text.level = token.level
|
|
||||||
tokens.append(token_text)
|
|
||||||
|
|
||||||
return tokens
|
|
||||||
|
|
||||||
|
|
||||||
def init_mention(md: MarkdownIt):
|
|
||||||
md.add_render_rule("user_mention", render_user_mention, "html")
|
|
||||||
md.add_render_rule("package_mention", render_package_mention, "html")
|
|
||||||
md.core.ruler.after("inline", "mention", parse_mentions)
|
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from flask_babel import LazyString
|
|
||||||
|
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from sqlalchemy_searchable import make_searchable
|
from sqlalchemy_searchable import make_searchable
|
||||||
@@ -124,115 +125,13 @@ class AuditLogEntry(db.Model):
|
|||||||
raise Exception("Unknown permission given to AuditLogEntry.check_perm()")
|
raise Exception("Unknown permission given to AuditLogEntry.check_perm()")
|
||||||
|
|
||||||
if perm == Permission.VIEW_AUDIT_DESCRIPTION:
|
if perm == Permission.VIEW_AUDIT_DESCRIPTION:
|
||||||
return (self.package and user in self.package.maintainers) or user.rank.at_least(UserRank.APPROVER if self.package is not None else UserRank.MODERATOR)
|
return user.rank.at_least(UserRank.APPROVER if self.package is not None else UserRank.MODERATOR)
|
||||||
else:
|
else:
|
||||||
raise Exception("Permission {} is not related to audit log entries".format(perm.name))
|
raise Exception("Permission {} is not related to audit log entries".format(perm.name))
|
||||||
|
|
||||||
|
|
||||||
class ReportCategory(enum.Enum):
|
|
||||||
ACCOUNT_DELETION = "account_deletion"
|
|
||||||
COPYRIGHT = "copyright"
|
|
||||||
USER_CONDUCT = "user_conduct"
|
|
||||||
SPAM = "spam"
|
|
||||||
ILLEGAL_HARMFUL = "illegal_harmful"
|
|
||||||
REVIEW = "review"
|
|
||||||
APPEAL = "appeal"
|
|
||||||
OTHER = "other"
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def title(self) -> LazyString:
|
|
||||||
if self == ReportCategory.ACCOUNT_DELETION:
|
|
||||||
return lazy_gettext("Account deletion")
|
|
||||||
elif self == ReportCategory.COPYRIGHT:
|
|
||||||
return lazy_gettext("Copyright infringement / DMCA")
|
|
||||||
elif self == ReportCategory.USER_CONDUCT:
|
|
||||||
return lazy_gettext("User behaviour, bullying, or abuse")
|
|
||||||
elif self == ReportCategory.SPAM:
|
|
||||||
return lazy_gettext("Spam")
|
|
||||||
elif self == ReportCategory.ILLEGAL_HARMFUL:
|
|
||||||
return lazy_gettext("Illegal or harmful content")
|
|
||||||
elif self == ReportCategory.REVIEW:
|
|
||||||
return lazy_gettext("Outdated/invalid review")
|
|
||||||
elif self == ReportCategory.APPEAL:
|
|
||||||
return lazy_gettext("Appeal")
|
|
||||||
elif self == ReportCategory.OTHER:
|
|
||||||
return lazy_gettext("Other")
|
|
||||||
else:
|
|
||||||
raise Exception("Unknown report category")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get(cls, name):
|
|
||||||
try:
|
|
||||||
return ReportCategory[name.upper()]
|
|
||||||
except KeyError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def choices(cls, with_none):
|
|
||||||
ret = [(choice, choice.title) for choice in cls]
|
|
||||||
|
|
||||||
if with_none:
|
|
||||||
ret.insert(0, (None, ""))
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def coerce(cls, item):
|
|
||||||
if item is None or (isinstance(item, str) and item.upper() == "NONE"):
|
|
||||||
return None
|
|
||||||
return item if type(item) == ReportCategory else ReportCategory[item.upper()]
|
|
||||||
|
|
||||||
|
|
||||||
class Report(db.Model):
|
|
||||||
id = db.Column(db.String(24), primary_key=True)
|
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
|
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
|
||||||
user = db.relationship("User", foreign_keys=[user_id], back_populates="reports")
|
|
||||||
|
|
||||||
thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True)
|
|
||||||
thread = db.relationship("Thread", foreign_keys=[thread_id])
|
|
||||||
|
|
||||||
category = db.Column(db.Enum(ReportCategory), nullable=False)
|
|
||||||
url = db.Column(db.String, nullable=True)
|
|
||||||
title = db.Column(db.Unicode(300), nullable=False)
|
|
||||||
message = db.Column(db.UnicodeText, nullable=False)
|
|
||||||
|
|
||||||
is_resolved = db.Column(db.Boolean, nullable=False, default=False)
|
|
||||||
|
|
||||||
attachments = db.relationship("ReportAttachment", back_populates="report", lazy="dynamic", cascade="all, delete, delete-orphan")
|
|
||||||
|
|
||||||
def check_perm(self, user, perm):
|
|
||||||
if type(perm) == str:
|
|
||||||
perm = Permission[perm]
|
|
||||||
elif type(perm) != Permission:
|
|
||||||
raise Exception("Unknown permission given to Report.check_perm()")
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if perm == Permission.SEE_REPORT:
|
|
||||||
return user.rank.at_least(UserRank.EDITOR)
|
|
||||||
else:
|
|
||||||
raise Exception("Permission {} is not related to reports".format(perm.name))
|
|
||||||
|
|
||||||
|
|
||||||
class ReportAttachment(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
|
|
||||||
report_id = db.Column(db.String(24), db.ForeignKey("report.id"), nullable=False)
|
|
||||||
report = db.relationship("Report", foreign_keys=[report_id], back_populates="attachments")
|
|
||||||
|
|
||||||
url = db.Column(db.String(100), nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com",
|
REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com",
|
||||||
"minetest.net", "luanti.org", "dropboxusercontent.com", "4shared.com",
|
"minetest.net", "dropboxusercontent.com", "4shared.com",
|
||||||
"digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net",
|
"digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net",
|
||||||
"imageshack.com", "imgur.com"]
|
"imageshack.com", "imgur.com"]
|
||||||
|
|
||||||
|
|||||||
@@ -1043,7 +1043,7 @@ class Tag(db.Model):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class LuantiRelease(db.Model):
|
class MinetestRelease(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||||
protocol = db.Column(db.Integer, nullable=False, default=0)
|
protocol = db.Column(db.Integer, nullable=False, default=0)
|
||||||
@@ -1067,11 +1067,11 @@ class LuantiRelease(db.Model):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get(cls, version: typing.Optional[str], protocol_num: typing.Optional[str]) -> typing.Optional["LuantiRelease"]:
|
def get(cls, version: typing.Optional[str], protocol_num: typing.Optional[str]) -> typing.Optional["MinetestRelease"]:
|
||||||
if version:
|
if version:
|
||||||
parts = version.strip().split(".")
|
parts = version.strip().split(".")
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
query = LuantiRelease.query.filter(func.replace(LuantiRelease.name, "-dev", "") == "{}.{}".format(parts[0], parts[1]))
|
query = MinetestRelease.query.filter(func.replace(MinetestRelease.name, "-dev", "") == "{}.{}".format(parts[0], parts[1]))
|
||||||
if protocol_num:
|
if protocol_num:
|
||||||
query = query.filter_by(protocol=protocol_num)
|
query = query.filter_by(protocol=protocol_num)
|
||||||
|
|
||||||
@@ -1081,9 +1081,9 @@ class LuantiRelease(db.Model):
|
|||||||
|
|
||||||
if protocol_num:
|
if protocol_num:
|
||||||
# Find the closest matching release
|
# Find the closest matching release
|
||||||
return LuantiRelease.query.order_by(db.desc(LuantiRelease.protocol),
|
return MinetestRelease.query.order_by(db.desc(MinetestRelease.protocol),
|
||||||
db.desc(LuantiRelease.id)) \
|
db.desc(MinetestRelease.id)) \
|
||||||
.filter(LuantiRelease.protocol <= protocol_num).first()
|
.filter(MinetestRelease.protocol <= protocol_num).first()
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1103,7 +1103,6 @@ class PackageRelease(db.Model):
|
|||||||
commit_hash = db.Column(db.String(41), nullable=True, default=None)
|
commit_hash = db.Column(db.String(41), nullable=True, default=None)
|
||||||
downloads = db.Column(db.Integer, nullable=False, default=0)
|
downloads = db.Column(db.Integer, nullable=False, default=0)
|
||||||
release_notes = db.Column(db.UnicodeText, nullable=True, default=None)
|
release_notes = db.Column(db.UnicodeText, nullable=True, default=None)
|
||||||
file_size_bytes = db.Column(db.Integer, nullable=False, default=0)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def summary(self) -> str:
|
def summary(self) -> str:
|
||||||
@@ -1114,11 +1113,11 @@ class PackageRelease(db.Model):
|
|||||||
|
|
||||||
return self.release_notes.split("\n")[0]
|
return self.release_notes.split("\n")[0]
|
||||||
|
|
||||||
min_rel_id = db.Column(db.Integer, db.ForeignKey("luanti_release.id"), nullable=True, server_default=None)
|
min_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None)
|
||||||
min_rel = db.relationship("LuantiRelease", foreign_keys=[min_rel_id])
|
min_rel = db.relationship("MinetestRelease", foreign_keys=[min_rel_id])
|
||||||
|
|
||||||
max_rel_id = db.Column(db.Integer, db.ForeignKey("luanti_release.id"), nullable=True, server_default=None)
|
max_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None)
|
||||||
max_rel = db.relationship("LuantiRelease", foreign_keys=[max_rel_id])
|
max_rel = db.relationship("MinetestRelease", foreign_keys=[max_rel_id])
|
||||||
|
|
||||||
# If the release is approved, then the task_id must be null and the url must be present
|
# If the release is approved, then the task_id must be null and the url must be present
|
||||||
CK_approval_valid = db.CheckConstraint("not approved OR (task_id IS NULL AND (url = '') IS NOT FALSE)")
|
CK_approval_valid = db.CheckConstraint("not approved OR (task_id IS NULL AND (url = '') IS NOT FALSE)")
|
||||||
@@ -1127,14 +1126,14 @@ class PackageRelease(db.Model):
|
|||||||
def file_path(self):
|
def file_path(self):
|
||||||
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||||
|
|
||||||
def calculate_file_size_bytes(self):
|
@property
|
||||||
|
def file_size_bytes(self):
|
||||||
path = self.file_path
|
path = self.file_path
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
self.file_size_bytes = 0
|
return 0
|
||||||
return
|
|
||||||
|
|
||||||
file_stats = os.stat(path)
|
file_stats = os.stat(path)
|
||||||
self.file_size_bytes = file_stats.st_size
|
return file_stats.st_size
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def file_size(self):
|
def file_size(self):
|
||||||
@@ -1264,8 +1263,6 @@ class PackageScreenshot(db.Model):
|
|||||||
width = db.Column(db.Integer, nullable=False)
|
width = db.Column(db.Integer, nullable=False)
|
||||||
height = db.Column(db.Integer, nullable=False)
|
height = db.Column(db.Integer, nullable=False)
|
||||||
|
|
||||||
file_size_bytes = db.Column(db.Integer, nullable=False, default=0)
|
|
||||||
|
|
||||||
def is_very_small(self):
|
def is_very_small(self):
|
||||||
return self.width < 720 or self.height < 405
|
return self.width < 720 or self.height < 405
|
||||||
|
|
||||||
@@ -1279,14 +1276,14 @@ class PackageScreenshot(db.Model):
|
|||||||
def file_path(self):
|
def file_path(self):
|
||||||
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||||
|
|
||||||
def calculate_file_size_bytes(self):
|
@property
|
||||||
|
def file_size_bytes(self):
|
||||||
path = self.file_path
|
path = self.file_path
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
self.file_size_bytes = 0
|
return 0
|
||||||
return
|
|
||||||
|
|
||||||
file_stats = os.stat(path)
|
file_stats = os.stat(path)
|
||||||
self.file_size_bytes = file_stats.st_size
|
return file_stats.st_size
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def file_size(self):
|
def file_size(self):
|
||||||
@@ -1371,8 +1368,6 @@ class PackageUpdateConfig(db.Model):
|
|||||||
# Set to now when an outdated notification is sent. Set to None when a release is created
|
# Set to now when an outdated notification is sent. Set to None when a release is created
|
||||||
outdated_at = db.Column(db.DateTime, nullable=True, default=None)
|
outdated_at = db.Column(db.DateTime, nullable=True, default=None)
|
||||||
|
|
||||||
last_checked_at = db.Column(db.DateTime, nullable=True, default=None)
|
|
||||||
|
|
||||||
trigger = db.Column(db.Enum(PackageUpdateTrigger), nullable=False, default=PackageUpdateTrigger.COMMIT)
|
trigger = db.Column(db.Enum(PackageUpdateTrigger), nullable=False, default=PackageUpdateTrigger.COMMIT)
|
||||||
ref = db.Column(db.String(41), nullable=True, default=None)
|
ref = db.Column(db.String(41), nullable=True, default=None)
|
||||||
|
|
||||||
@@ -1442,8 +1437,11 @@ class PackageDailyStats(db.Model):
|
|||||||
reason_dependency = 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)
|
reason_update = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
views_minetest = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
v510 = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update(package: Package, is_luanti: bool, reason: str):
|
def notify_download(package: Package, is_minetest: bool, is_v510: bool, reason: str):
|
||||||
date = datetime.datetime.utcnow().date()
|
date = datetime.datetime.utcnow().date()
|
||||||
|
|
||||||
to_update = dict()
|
to_update = dict()
|
||||||
@@ -1451,7 +1449,7 @@ class PackageDailyStats(db.Model):
|
|||||||
"package_id": package.id, "date": date
|
"package_id": package.id, "date": date
|
||||||
}
|
}
|
||||||
|
|
||||||
field_platform = "platform_minetest" if is_luanti else "platform_other"
|
field_platform = "platform_minetest" if is_minetest else "platform_other"
|
||||||
to_update[field_platform] = getattr(PackageDailyStats, field_platform) + 1
|
to_update[field_platform] = getattr(PackageDailyStats, field_platform) + 1
|
||||||
kwargs[field_platform] = 1
|
kwargs[field_platform] = 1
|
||||||
|
|
||||||
@@ -1467,6 +1465,26 @@ class PackageDailyStats(db.Model):
|
|||||||
to_update[field_reason] = getattr(PackageDailyStats, field_reason) + 1
|
to_update[field_reason] = getattr(PackageDailyStats, field_reason) + 1
|
||||||
kwargs[field_reason] = 1
|
kwargs[field_reason] = 1
|
||||||
|
|
||||||
|
if is_v510:
|
||||||
|
to_update["v510"] = PackageDailyStats.v510 + 1
|
||||||
|
kwargs["v510"] = 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)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def notify_view(package: Package):
|
||||||
|
date = datetime.datetime.utcnow().date()
|
||||||
|
|
||||||
|
to_update = {"views_minetest": PackageDailyStats.views_minetest + 1}
|
||||||
|
kwargs = {"package_id": package.id, "date": date, "views_minetest": 1}
|
||||||
|
|
||||||
stmt = insert(PackageDailyStats).values(**kwargs)
|
stmt = insert(PackageDailyStats).values(**kwargs)
|
||||||
stmt = stmt.on_conflict_do_update(
|
stmt = stmt.on_conflict_do_update(
|
||||||
index_elements=[PackageDailyStats.package_id, PackageDailyStats.date],
|
index_elements=[PackageDailyStats.package_id, PackageDailyStats.date],
|
||||||
|
|||||||
@@ -57,8 +57,6 @@ class Thread(db.Model):
|
|||||||
|
|
||||||
watchers = db.relationship("User", secondary=watchers, backref="watching")
|
watchers = db.relationship("User", secondary=watchers, backref="watching")
|
||||||
|
|
||||||
report = db.relationship("Report", foreign_keys="Report.thread_id", back_populates="thread", lazy="dynamic")
|
|
||||||
|
|
||||||
first_reply = db.relationship("ThreadReply", uselist=False, foreign_keys="ThreadReply.thread_id",
|
first_reply = db.relationship("ThreadReply", uselist=False, foreign_keys="ThreadReply.thread_id",
|
||||||
lazy=True, order_by=db.asc("id"), viewonly=True,
|
lazy=True, order_by=db.asc("id"), viewonly=True,
|
||||||
primaryjoin="Thread.id==ThreadReply.thread_id")
|
primaryjoin="Thread.id==ThreadReply.thread_id")
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ class Permission(enum.Enum):
|
|||||||
CHANGE_USERNAMES = "CHANGE_USERNAMES"
|
CHANGE_USERNAMES = "CHANGE_USERNAMES"
|
||||||
CHANGE_RANK = "CHANGE_RANK"
|
CHANGE_RANK = "CHANGE_RANK"
|
||||||
CHANGE_EMAIL = "CHANGE_EMAIL"
|
CHANGE_EMAIL = "CHANGE_EMAIL"
|
||||||
LINK_TO_WEBSITE = "LINK_TO_WEBSITE"
|
|
||||||
SEE_THREAD = "SEE_THREAD"
|
SEE_THREAD = "SEE_THREAD"
|
||||||
CREATE_THREAD = "CREATE_THREAD"
|
CREATE_THREAD = "CREATE_THREAD"
|
||||||
COMMENT_THREAD = "COMMENT_THREAD"
|
COMMENT_THREAD = "COMMENT_THREAD"
|
||||||
@@ -115,7 +114,6 @@ class Permission(enum.Enum):
|
|||||||
EDIT_COLLECTION = "EDIT_COLLECTION"
|
EDIT_COLLECTION = "EDIT_COLLECTION"
|
||||||
VIEW_COLLECTION = "VIEW_COLLECTION"
|
VIEW_COLLECTION = "VIEW_COLLECTION"
|
||||||
CREATE_OAUTH_CLIENT = "CREATE_OAUTH_CLIENT"
|
CREATE_OAUTH_CLIENT = "CREATE_OAUTH_CLIENT"
|
||||||
SEE_REPORT = "SEE_REPORT"
|
|
||||||
|
|
||||||
# Only return true if the permission is valid for *all* contexts
|
# Only return true if the permission is valid for *all* contexts
|
||||||
# See Package.check_perm for package-specific contexts
|
# See Package.check_perm for package-specific contexts
|
||||||
@@ -212,7 +210,6 @@ class User(db.Model, UserMixin):
|
|||||||
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
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"))
|
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")
|
clients = db.relationship("OAuthClient", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||||
reports = db.relationship("Report", back_populates="user", lazy="dynamic", cascade="all")
|
|
||||||
|
|
||||||
ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False)
|
ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False)
|
||||||
|
|
||||||
@@ -290,8 +287,6 @@ class User(db.Model, UserMixin):
|
|||||||
return user.rank.at_least(UserRank.NEW_MEMBER)
|
return user.rank.at_least(UserRank.NEW_MEMBER)
|
||||||
else:
|
else:
|
||||||
return user.rank.at_least(UserRank.MODERATOR) and user.rank.at_least(self.rank)
|
return user.rank.at_least(UserRank.MODERATOR) and user.rank.at_least(self.rank)
|
||||||
elif perm == Permission.LINK_TO_WEBSITE:
|
|
||||||
return user.rank.at_least(UserRank.MEMBER)
|
|
||||||
else:
|
else:
|
||||||
raise Exception("Permission {} is not related to users".format(perm.name))
|
raise Exception("Permission {} is not related to users".format(perm.name))
|
||||||
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "v1.0.0",
|
|
||||||
"entity": {
|
|
||||||
"type": "organisation",
|
|
||||||
"role": "maintainer",
|
|
||||||
"name": "Luanti",
|
|
||||||
"email": "rw@rubenwardy.com",
|
|
||||||
"description": "Luanti (formerly Minetest) is an open-source voxel game creation platform",
|
|
||||||
"webpageUrl": {
|
|
||||||
"url": "https://www.luanti.org"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"projects": [
|
|
||||||
{
|
|
||||||
"guid": "luanti",
|
|
||||||
"name": "Luanti",
|
|
||||||
"description": "Luanti (formerly Minetest) is an open-source voxel game creation platform",
|
|
||||||
"webpageUrl": {
|
|
||||||
"url": "https://www.luanti.org"
|
|
||||||
},
|
|
||||||
"repositoryUrl": {
|
|
||||||
"url": "https://github.com/luanti-org/luanti"
|
|
||||||
},
|
|
||||||
"licenses": [
|
|
||||||
"spdx:LGPL-2.1",
|
|
||||||
"spdx:CC-BY-SA-3.0",
|
|
||||||
"spdx:MIT",
|
|
||||||
"spdx:Apache-2.0"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"lua",
|
|
||||||
"voxel",
|
|
||||||
"game"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"guid": "contentdb",
|
|
||||||
"name": "Luanti ContentDB",
|
|
||||||
"description": "A content database for Luanti mods, games, and more.",
|
|
||||||
"webpageUrl": {
|
|
||||||
"url": "https://content.luanti.org/about/"
|
|
||||||
},
|
|
||||||
"repositoryUrl": {
|
|
||||||
"url": "https://github.com/luanti-org/contentdb"
|
|
||||||
},
|
|
||||||
"licenses": [
|
|
||||||
"spdx:AGPL-3.0",
|
|
||||||
"spdx:CC-BY-SA-4.0"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"python",
|
|
||||||
"flask",
|
|
||||||
"luanti",
|
|
||||||
"minetest"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"channels": [
|
|
||||||
{
|
|
||||||
"guid": "open-collective",
|
|
||||||
"type": "other",
|
|
||||||
"address": "https://opencollective.com/luanti",
|
|
||||||
"description": "Recurring and one-time donations to Luanti"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"plans": [
|
|
||||||
{
|
|
||||||
"guid": "oc-eur-backer",
|
|
||||||
"status": "active",
|
|
||||||
"name": "Luanti backer",
|
|
||||||
"description": "Become a backer for €5 per month and help Luanti development",
|
|
||||||
"amount": 5,
|
|
||||||
"currency": "EUR",
|
|
||||||
"frequency": "monthly",
|
|
||||||
"channels": [
|
|
||||||
"open-collective"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"guid": "oc-eur-supporter",
|
|
||||||
"status": "active",
|
|
||||||
"name": "Luanti supporter",
|
|
||||||
"description": "Become a supporter for €5 per month and help Luanti development",
|
|
||||||
"amount": 100,
|
|
||||||
"currency": "EUR",
|
|
||||||
"frequency": "monthly",
|
|
||||||
"channels": [
|
|
||||||
"open-collective"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"guid": "oc-eur-custom",
|
|
||||||
"status": "active",
|
|
||||||
"name": "Luanti custom one-off",
|
|
||||||
"description": "You may donate any amount you're comfortable with",
|
|
||||||
"amount": 0,
|
|
||||||
"currency": "EUR",
|
|
||||||
"frequency": "one-time",
|
|
||||||
"channels": [
|
|
||||||
"open-collective"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"guid": "fosdem",
|
|
||||||
"status": "active",
|
|
||||||
"name": "FOSDEM",
|
|
||||||
"description": "It costs us €3000 to attend FOSDEM",
|
|
||||||
"amount": 3000,
|
|
||||||
"currency": "EUR",
|
|
||||||
"frequency": "one-time",
|
|
||||||
"channels": [
|
|
||||||
"open-collective"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"history": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -228,6 +228,16 @@ async function load_data() {
|
|||||||
};
|
};
|
||||||
new Chart(ctx, config);
|
new Chart(ctx, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const ctx = document.getElementById("chart-views").getContext("2d");
|
||||||
|
const data = {
|
||||||
|
datasets: [
|
||||||
|
{ label: "Luanti", data: getData(json.views_minetest) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
setup_chart(ctx, data, annotations);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,8 @@ window.addEventListener("load", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupHints("short_desc", {
|
setupHints("short_desc", {
|
||||||
"short_desc_mods": (val) => val.indexOf("luanti") >= 0 || val.indexOf("minetest") >= 0 ||
|
"short_desc_mods": (val) => val.indexOf("minetest") >= 0 || val.indexOf("mod") >= 0 ||
|
||||||
val.indexOf("mod") >= 0 || val.indexOf("modpack") >= 0 ||
|
val.indexOf("modpack") >= 0 || val.indexOf("mod pack") >= 0,
|
||||||
val.indexOf("mod pack") >= 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setupHints("desc", {
|
setupHints("desc", {
|
||||||
@@ -86,8 +85,7 @@ window.addEventListener("load", () => {
|
|||||||
"desc_page_topic": (val) => {
|
"desc_page_topic": (val) => {
|
||||||
const topicId = document.getElementById("forums").value;
|
const topicId = document.getElementById("forums").value;
|
||||||
const r = new RegExp(`forum\\.minetest\\.net\\/viewtopic\\.php\\?[a-z0-9=&]*t=${topicId}`);
|
const r = new RegExp(`forum\\.minetest\\.net\\/viewtopic\\.php\\?[a-z0-9=&]*t=${topicId}`);
|
||||||
const r2 = new RegExp(`forum\\.luanti\\.org\\/viewtopic\\.php\\?[a-z0-9=&]*t=${topicId}`);
|
return topicId && r.test(val);
|
||||||
return topicId && (r.test(val) || r2.test(val));
|
|
||||||
},
|
},
|
||||||
"desc_page_repo": (val) => {
|
"desc_page_repo": (val) => {
|
||||||
const repoUrl = document.getElementById("repo").value.replace(".git", "");
|
const repoUrl = document.getElementById("repo").value.replace(".git", "");
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function sleep(interval) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function pollTask(poll_url, disableTimeout, onProgress) {
|
async function pollTask(poll_url, disableTimeout) {
|
||||||
let tries = 0;
|
let tries = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -42,10 +42,6 @@ async function pollTask(poll_url, disableTimeout, onProgress) {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res && res.status) {
|
|
||||||
onProgress?.(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res && res.status === "SUCCESS") {
|
if (res && res.status === "SUCCESS") {
|
||||||
console.log("Got result")
|
console.log("Got result")
|
||||||
return res.result;
|
return res.result;
|
||||||
@@ -66,41 +62,3 @@ async function performTask(url) {
|
|||||||
throw "Start task didn't return string!";
|
throw "Start task didn't return string!";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("load", () => {
|
|
||||||
const taskId = document.querySelector("[data-task-id]")?.getAttribute("data-task-id");
|
|
||||||
if (taskId) {
|
|
||||||
const progress = document.getElementById("progress");
|
|
||||||
|
|
||||||
function onProgress(res) {
|
|
||||||
let status = res.status.toLowerCase();
|
|
||||||
if (status === "progress") {
|
|
||||||
progress.classList.remove("d-none");
|
|
||||||
const bar = progress.children[0];
|
|
||||||
|
|
||||||
const {current, total, running} = res.result;
|
|
||||||
const perc = Math.min(Math.max(100 * current / total, 0), 100);
|
|
||||||
bar.style.width = `${perc}%`;
|
|
||||||
bar.setAttribute("aria-valuenow", current);
|
|
||||||
bar.setAttribute("aria-valuemax", total);
|
|
||||||
|
|
||||||
const packages = (running ?? []).map(x => `${x.author}/${x.name}`).join(", ");
|
|
||||||
document.getElementById("status").textContent = `Status: in progress (${current} / ${total})\n\n${packages}`;
|
|
||||||
} else {
|
|
||||||
progress.classList.add("d-none");
|
|
||||||
|
|
||||||
if (status === "pending") {
|
|
||||||
status = "pending or unknown";
|
|
||||||
}
|
|
||||||
document.getElementById("status").textContent = `Status: ${status}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pollTask(`/tasks/${taskId}/`, true, onProgress)
|
|
||||||
.then(function() { location.reload() })
|
|
||||||
.catch(function(e) {
|
|
||||||
console.error(e);
|
|
||||||
location.reload();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from sqlalchemy.orm import subqueryload
|
|||||||
from sqlalchemy.sql.expression import func
|
from sqlalchemy.sql.expression import func
|
||||||
from sqlalchemy_searchable import search
|
from sqlalchemy_searchable import search
|
||||||
|
|
||||||
from .models import db, PackageType, Package, ForumTopic, License, LuantiRelease, PackageRelease, User, Tag, \
|
from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease, User, Tag, \
|
||||||
ContentWarning, PackageState, PackageDevState
|
ContentWarning, PackageState, PackageDevState
|
||||||
from .utils import is_yes, get_int_or_abort
|
from .utils import is_yes, get_int_or_abort
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ class QueryBuilder:
|
|||||||
hide_wip: bool
|
hide_wip: bool
|
||||||
hide_nonfree: bool
|
hide_nonfree: bool
|
||||||
show_added: bool
|
show_added: bool
|
||||||
version: Optional[LuantiRelease]
|
version: Optional[MinetestRelease]
|
||||||
has_lang: Optional[str]
|
has_lang: Optional[str]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -163,12 +163,12 @@ class QueryBuilder:
|
|||||||
self.author = args.get("author")
|
self.author = args.get("author")
|
||||||
|
|
||||||
protocol_version = get_int_or_abort(args.get("protocol_version"))
|
protocol_version = get_int_or_abort(args.get("protocol_version"))
|
||||||
engine_version = args.get("engine_version")
|
minetest_version = args.get("engine_version")
|
||||||
if engine_version == "":
|
if minetest_version == "":
|
||||||
engine_version = None
|
minetest_version = None
|
||||||
|
|
||||||
if protocol_version or engine_version:
|
if protocol_version or minetest_version:
|
||||||
self.version = LuantiRelease.get(engine_version, protocol_version)
|
self.version = MinetestRelease.get(minetest_version, protocol_version)
|
||||||
else:
|
else:
|
||||||
self.version = None
|
self.version = None
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from . import redis_client
|
from . import redis_client
|
||||||
|
from .models import Package
|
||||||
|
|
||||||
# This file acts as a facade between the rest of the code and redis,
|
# This file acts as a facade between the rest of the code and redis,
|
||||||
# and also means that the rest of the code avoids knowing about `app`
|
# and also means that the rest of the code avoids knowing about `app`
|
||||||
@@ -23,10 +24,14 @@ from . import redis_client
|
|||||||
EXPIRY_TIME_S = 2*7*24*60*60 # 2 weeks
|
EXPIRY_TIME_S = 2*7*24*60*60 # 2 weeks
|
||||||
|
|
||||||
|
|
||||||
def make_download_key(ip, package):
|
def make_download_key(ip: str, package: Package):
|
||||||
return "{}/{}/{}".format(ip, package.author.username, package.name)
|
return "{}/{}/{}".format(ip, package.author.username, package.name)
|
||||||
|
|
||||||
|
|
||||||
|
def make_view_key(ip: str, package: Package):
|
||||||
|
return "view/{}/{}/{}".format(ip, package.author.username, package.name)
|
||||||
|
|
||||||
|
|
||||||
def set_temp_key(key, v):
|
def set_temp_key(key, v):
|
||||||
redis_client.set(key, v, ex=EXPIRY_TIME_S)
|
redis_client.set(key, v, ex=EXPIRY_TIME_S)
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,3 @@ blockquote {
|
|||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 1rem !important;
|
margin-bottom: 1rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[name="first_name"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -51,26 +51,6 @@ h3 {
|
|||||||
letter-spacing: .05em
|
letter-spacing: .05em
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
&::after {
|
|
||||||
display: block;
|
|
||||||
content: "";
|
|
||||||
clear: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-anchor {
|
|
||||||
transition: opacity 0.15s ease-in-out;
|
|
||||||
opacity: 0.25;
|
|
||||||
margin: 0 0 0 0.25em;
|
|
||||||
font-size: 60%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .header-anchor {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.badge-notify {
|
.badge-notify {
|
||||||
background:yellow; /* #00bc8c;*/
|
background:yellow; /* #00bc8c;*/
|
||||||
color: black;
|
color: black;
|
||||||
|
|||||||
@@ -92,12 +92,3 @@
|
|||||||
max-height: 1em;
|
max-height: 1em;
|
||||||
filter: none !important;
|
filter: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-notes-body {
|
|
||||||
max-height: 20em;
|
|
||||||
overflow: hidden auto;
|
|
||||||
|
|
||||||
> *:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class FlaskCelery(Celery):
|
|||||||
|
|
||||||
def make_celery(app):
|
def make_celery(app):
|
||||||
celery = FlaskCelery(app.import_name, backend=app.config['CELERY_RESULT_BACKEND'],
|
celery = FlaskCelery(app.import_name, backend=app.config['CELERY_RESULT_BACKEND'],
|
||||||
broker=app.config['CELERY_BROKER_URL'], task_track_started=True)
|
broker=app.config['CELERY_BROKER_URL'])
|
||||||
|
|
||||||
celery.init_app(app)
|
celery.init_app(app)
|
||||||
return celery
|
return celery
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ from sqlalchemy import and_
|
|||||||
from sqlalchemy.dialects.postgresql import insert
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
|
||||||
from app.models import AuditSeverity, db, NotificationType, PackageRelease, MetaPackage, Dependency, PackageType, \
|
from app.models import AuditSeverity, db, NotificationType, PackageRelease, MetaPackage, Dependency, PackageType, \
|
||||||
LuantiRelease, Package, PackageState, PackageScreenshot, PackageUpdateTrigger, PackageUpdateConfig, \
|
MinetestRelease, Package, PackageState, PackageScreenshot, PackageUpdateTrigger, PackageUpdateConfig, \
|
||||||
PackageGameSupport, PackageTranslation, Language
|
PackageGameSupport, PackageTranslation, Language
|
||||||
from app.tasks import celery, TaskError
|
from app.tasks import celery, TaskError
|
||||||
from app.utils import random_string, post_bot_message, add_system_notification, add_system_audit_log, \
|
from app.utils import random_string, post_bot_message, add_system_notification, add_system_audit_log, \
|
||||||
get_games_from_list, add_audit_log
|
get_games_from_list, add_audit_log
|
||||||
from app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir, get_release_notes
|
from app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir, get_release_notes
|
||||||
from .luanticheck import build_tree, LuantiCheckError, ContentType, PackageTreeNode
|
from .minetestcheck import build_tree, MinetestCheckError, ContentType, PackageTreeNode
|
||||||
from .webhooktasks import post_discord_webhook
|
from .webhooktasks import post_discord_webhook
|
||||||
from app import app
|
from app import app
|
||||||
from app.logic.LogicError import LogicError
|
from app.logic.LogicError import LogicError
|
||||||
@@ -51,7 +51,7 @@ def get_meta(urlstr, author):
|
|||||||
with clone_repo(urlstr, recursive=True) as repo:
|
with clone_repo(urlstr, recursive=True) as repo:
|
||||||
try:
|
try:
|
||||||
tree = build_tree(repo.working_tree_dir, author=author, repo=urlstr)
|
tree = build_tree(repo.working_tree_dir, author=author, repo=urlstr)
|
||||||
except LuantiCheckError as err:
|
except MinetestCheckError as err:
|
||||||
raise TaskError(str(err))
|
raise TaskError(str(err))
|
||||||
|
|
||||||
result = {"name": tree.name, "type": tree.type.name}
|
result = {"name": tree.name, "type": tree.type.name}
|
||||||
@@ -71,6 +71,8 @@ def get_meta(urlstr, author):
|
|||||||
data = json.loads(f.read())
|
data = json.loads(f.read())
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
result[key] = value
|
result[key] = value
|
||||||
|
except LogicError as e:
|
||||||
|
raise TaskError(e.message)
|
||||||
except JSONDecodeError as e:
|
except JSONDecodeError as e:
|
||||||
raise TaskError("Whilst reading .cdb.json: " + str(e))
|
raise TaskError("Whilst reading .cdb.json: " + str(e))
|
||||||
except IOError:
|
except IOError:
|
||||||
@@ -113,9 +115,7 @@ def post_release_check_update(self, release: PackageRelease, path):
|
|||||||
author=release.package.author.username, name=release.package.name)
|
author=release.package.author.username, name=release.package.name)
|
||||||
|
|
||||||
if tree.name is not None and release.package.name != tree.name and tree.type == ContentType.MOD:
|
if tree.name is not None and release.package.name != tree.name and tree.type == ContentType.MOD:
|
||||||
raise LuantiCheckError(f"Package name ({release.package.name}) does not match the name of the content in "
|
raise MinetestCheckError(f"Expected {tree.relative} to have technical name {release.package.name}, instead has name {tree.name}")
|
||||||
f"the release ({tree.name}). Either change the package name on ContentDB or the "
|
|
||||||
f"name in the .conf of the content. Then make a new release")
|
|
||||||
|
|
||||||
cache = {}
|
cache = {}
|
||||||
def get_meta_packages(names):
|
def get_meta_packages(names):
|
||||||
@@ -124,9 +124,6 @@ def post_release_check_update(self, release: PackageRelease, path):
|
|||||||
provides = tree.get_mod_names()
|
provides = tree.get_mod_names()
|
||||||
|
|
||||||
package = release.package
|
package = release.package
|
||||||
if not package.approved:
|
|
||||||
tree.check_for_legacy_files()
|
|
||||||
|
|
||||||
old_provided_names = set([x.name for x in package.provides])
|
old_provided_names = set([x.name for x in package.provides])
|
||||||
package.provides.clear()
|
package.provides.clear()
|
||||||
|
|
||||||
@@ -167,10 +164,10 @@ def post_release_check_update(self, release: PackageRelease, path):
|
|||||||
# Raise error on unresolved game dependencies
|
# Raise error on unresolved game dependencies
|
||||||
if package.type == PackageType.GAME and len(depends) > 0:
|
if package.type == PackageType.GAME and len(depends) > 0:
|
||||||
deps = ", ".join(depends)
|
deps = ", ".join(depends)
|
||||||
raise LuantiCheckError("Game has unresolved hard dependencies: " + deps)
|
raise MinetestCheckError("Game has unresolved hard dependencies: " + deps)
|
||||||
|
|
||||||
if package.state != PackageState.APPROVED and tree.find_license_file() is None:
|
if package.state != PackageState.APPROVED and tree.find_license_file() is None:
|
||||||
raise LuantiCheckError(
|
raise MinetestCheckError(
|
||||||
"You need to add a LICENSE.txt/.md or COPYING file to your package. See the 'Copyright Guide' for more info")
|
"You need to add a LICENSE.txt/.md or COPYING file to your package. See the 'Copyright Guide' for more info")
|
||||||
|
|
||||||
# Add dependencies
|
# Add dependencies
|
||||||
@@ -185,17 +182,17 @@ def post_release_check_update(self, release: PackageRelease, path):
|
|||||||
|
|
||||||
# Update min/max
|
# Update min/max
|
||||||
if tree.meta.get("min_minetest_version"):
|
if tree.meta.get("min_minetest_version"):
|
||||||
release.min_rel = LuantiRelease.get(tree.meta["min_minetest_version"], None)
|
release.min_rel = MinetestRelease.get(tree.meta["min_minetest_version"], None)
|
||||||
|
|
||||||
if tree.meta.get("max_minetest_version"):
|
if tree.meta.get("max_minetest_version"):
|
||||||
release.max_rel = LuantiRelease.get(tree.meta["max_minetest_version"], None)
|
release.max_rel = MinetestRelease.get(tree.meta["max_minetest_version"], None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(tree.baseDir, ".cdb.json"), "r") as f:
|
with open(os.path.join(tree.baseDir, ".cdb.json"), "r") as f:
|
||||||
data = json.loads(f.read())
|
data = json.loads(f.read())
|
||||||
do_edit_package(package.author, package, False, False, data, "Post release hook")
|
do_edit_package(package.author, package, False, False, data, "Post release hook")
|
||||||
except LogicError as e:
|
except LogicError as e:
|
||||||
raise TaskError("Whilst applying .cdb.json: " + e.message)
|
raise TaskError(e.message)
|
||||||
except JSONDecodeError as e:
|
except JSONDecodeError as e:
|
||||||
raise TaskError("Whilst reading .cdb.json: " + str(e))
|
raise TaskError("Whilst reading .cdb.json: " + str(e))
|
||||||
except IOError:
|
except IOError:
|
||||||
@@ -234,7 +231,7 @@ def post_release_check_update(self, release: PackageRelease, path):
|
|||||||
|
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
except (LuantiCheckError, TaskError, LogicError) as err:
|
except (MinetestCheckError, TaskError, LogicError) as err:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
error_message = err.value if hasattr(err, "value") else str(err)
|
error_message = err.value if hasattr(err, "value") else str(err)
|
||||||
@@ -337,7 +334,6 @@ def check_zip_release(self, id, path):
|
|||||||
post_release_check_update(self, release, temp)
|
post_release_check_update(self, release, temp)
|
||||||
|
|
||||||
release.task_id = None
|
release.task_id = None
|
||||||
release.calculate_file_size_bytes()
|
|
||||||
release.approve(release.package.author)
|
release.approve(release.package.author)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -379,7 +375,7 @@ def import_languages(self, id, path):
|
|||||||
strict=False)
|
strict=False)
|
||||||
update_translations(release.package, tree)
|
update_translations(release.package, tree)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except (LuantiCheckError, TaskError, LogicError) as err:
|
except (MinetestCheckError, TaskError, LogicError) as err:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
task_url = url_for('tasks.check', id=self.request.id)
|
task_url = url_for('tasks.check', id=self.request.id)
|
||||||
@@ -417,7 +413,6 @@ def make_vcs_release(self, id, branch):
|
|||||||
|
|
||||||
release.url = "/uploads/" + filename
|
release.url = "/uploads/" + filename
|
||||||
release.task_id = None
|
release.task_id = None
|
||||||
release.calculate_file_size_bytes()
|
|
||||||
release.approve(release.package.author)
|
release.approve(release.package.author)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -480,11 +475,13 @@ def check_update_config_impl(package):
|
|||||||
if config.last_commit == commit:
|
if config.last_commit == commit:
|
||||||
if tag and config.last_tag != tag:
|
if tag and config.last_tag != tag:
|
||||||
config.last_tag = tag
|
config.last_tag = tag
|
||||||
|
db.session.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
if not config.last_commit:
|
if not config.last_commit:
|
||||||
config.last_commit = commit
|
config.last_commit = commit
|
||||||
config.last_tag = tag
|
config.last_tag = tag
|
||||||
|
db.session.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
if package.releases.filter_by(commit_hash=commit).count() > 0:
|
if package.releases.filter_by(commit_hash=commit).count() > 0:
|
||||||
@@ -503,6 +500,8 @@ def check_update_config_impl(package):
|
|||||||
msg = "Created release {} (Git Update Detection)".format(rel.title)
|
msg = "Created release {} (Git Update Detection)".format(rel.title)
|
||||||
add_system_audit_log(AuditSeverity.NORMAL, msg, package.get_url("packages.view"), package)
|
add_system_audit_log(AuditSeverity.NORMAL, msg, package.get_url("packages.view"), package)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
make_vcs_release.apply_async((rel.id, commit), task_id=rel.task_id)
|
make_vcs_release.apply_async((rel.id, commit), task_id=rel.task_id)
|
||||||
|
|
||||||
elif config.outdated_at is None:
|
elif config.outdated_at is None:
|
||||||
@@ -529,9 +528,10 @@ def check_update_config_impl(package):
|
|||||||
|
|
||||||
config.last_commit = commit
|
config.last_commit = commit
|
||||||
config.last_tag = tag
|
config.last_tag = tag
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
@celery.task(bind=True, rate_limit="60/m")
|
@celery.task(bind=True)
|
||||||
def check_update_config(self, package_id):
|
def check_update_config(self, package_id):
|
||||||
package: Package = Package.query.get(package_id)
|
package: Package = Package.query.get(package_id)
|
||||||
if package is None:
|
if package is None:
|
||||||
@@ -542,9 +542,6 @@ def check_update_config(self, package_id):
|
|||||||
err = None
|
err = None
|
||||||
try:
|
try:
|
||||||
check_update_config_impl(package)
|
check_update_config_impl(package)
|
||||||
|
|
||||||
package.update_config.last_checked_at = datetime.datetime.now()
|
|
||||||
db.session.commit()
|
|
||||||
except GitCommandError as e:
|
except GitCommandError as e:
|
||||||
# This is needed to stop the backtrace being weird
|
# This is needed to stop the backtrace being weird
|
||||||
err = e.stderr
|
err = e.stderr
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
class LuantiCheckError(Exception):
|
class MinetestCheckError(Exception):
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
@@ -43,14 +43,14 @@ class ContentType(Enum):
|
|||||||
|
|
||||||
if self == ContentType.MOD:
|
if self == ContentType.MOD:
|
||||||
if not other.is_mod_like():
|
if not other.is_mod_like():
|
||||||
raise LuantiCheckError("Expected a mod or modpack, found " + other.value)
|
raise MinetestCheckError("Expected a mod or modpack, found " + other.value)
|
||||||
|
|
||||||
elif self == ContentType.TXP:
|
elif self == ContentType.TXP:
|
||||||
if other != ContentType.UNKNOWN and other != ContentType.TXP:
|
if other != ContentType.UNKNOWN and other != ContentType.TXP:
|
||||||
raise LuantiCheckError("expected a " + self.value + ", found a " + other.value)
|
raise MinetestCheckError("expected a " + self.value + ", found a " + other.value)
|
||||||
|
|
||||||
elif other != self:
|
elif other != self:
|
||||||
raise LuantiCheckError("Expected a " + self.value + ", found a " + other.value)
|
raise MinetestCheckError("Expected a " + self.value + ", found a " + other.value)
|
||||||
|
|
||||||
|
|
||||||
from .tree import PackageTreeNode, get_base_dir
|
from .tree import PackageTreeNode, get_base_dir
|
||||||
@@ -20,12 +20,12 @@ import re
|
|||||||
import glob
|
import glob
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from . import LuantiCheckError, ContentType
|
from . import MinetestCheckError, ContentType
|
||||||
from .config import parse_conf
|
from .config import parse_conf
|
||||||
from .translation import Translation, parse_tr
|
from .translation import Translation, parse_tr
|
||||||
|
|
||||||
basenamePattern = re.compile("^([a-z0-9_]+)$")
|
basenamePattern = re.compile("^([a-z0-9_]+)$")
|
||||||
licensePattern = re.compile("^licen[sc]e[^/.]*(\.(txt|md))?$", re.IGNORECASE)
|
licensePattern = re.compile("^(licen[sc]e|copying)(.[^/\n]+)?$", re.IGNORECASE)
|
||||||
|
|
||||||
DISALLOWED_NAMES = {
|
DISALLOWED_NAMES = {
|
||||||
"core", "minetest", "group", "table", "string", "lua", "luajit", "assert", "debug",
|
"core", "minetest", "group", "table", "string", "lua", "luajit", "assert", "debug",
|
||||||
@@ -73,10 +73,10 @@ def check_name_list(key: str, value: list[str], relative: str, allow_star: bool
|
|||||||
if dep == "*" and allow_star:
|
if dep == "*" and allow_star:
|
||||||
continue
|
continue
|
||||||
elif " " in dep:
|
elif " " in dep:
|
||||||
raise LuantiCheckError(
|
raise MinetestCheckError(
|
||||||
f"Invalid {key} name '{dep}' at {relative}, did you forget a comma?")
|
f"Invalid {key} name '{dep}' at {relative}, did you forget a comma?")
|
||||||
else:
|
else:
|
||||||
raise LuantiCheckError(
|
raise MinetestCheckError(
|
||||||
f"Invalid {key} name '{dep}' at {relative}, names must only contain a-z0-9_.")
|
f"Invalid {key} name '{dep}' at {relative}, names must only contain a-z0-9_.")
|
||||||
|
|
||||||
|
|
||||||
@@ -90,8 +90,6 @@ class PackageTreeNode:
|
|||||||
children: list
|
children: list
|
||||||
type: ContentType
|
type: ContentType
|
||||||
strict: bool
|
strict: bool
|
||||||
has_legacy_depends: bool
|
|
||||||
has_legacy_description: bool
|
|
||||||
|
|
||||||
def __init__(self, base_dir: str, relative: str,
|
def __init__(self, base_dir: str, relative: str,
|
||||||
author: Optional[str] = None,
|
author: Optional[str] = None,
|
||||||
@@ -105,8 +103,6 @@ class PackageTreeNode:
|
|||||||
self.meta = {}
|
self.meta = {}
|
||||||
self.children = []
|
self.children = []
|
||||||
self.strict = strict
|
self.strict = strict
|
||||||
self.has_legacy_depends = False
|
|
||||||
self.has_legacy_description = False
|
|
||||||
|
|
||||||
# Detect type
|
# Detect type
|
||||||
self.type = detect_type(base_dir)
|
self.type = detect_type(base_dir)
|
||||||
@@ -114,14 +110,14 @@ class PackageTreeNode:
|
|||||||
|
|
||||||
if self.type == ContentType.GAME:
|
if self.type == ContentType.GAME:
|
||||||
if not os.path.isdir(os.path.join(base_dir, "mods")):
|
if not os.path.isdir(os.path.join(base_dir, "mods")):
|
||||||
raise LuantiCheckError("Game at {} does not have a mods/ folder".format(self.relative))
|
raise MinetestCheckError("Game at {} does not have a mods/ folder".format(self.relative))
|
||||||
self._add_children_from_mod_dir("mods")
|
self._add_children_from_mod_dir("mods")
|
||||||
elif self.type == ContentType.MOD:
|
elif self.type == ContentType.MOD:
|
||||||
if self.name and not basenamePattern.match(self.name):
|
if self.name and not basenamePattern.match(self.name):
|
||||||
raise LuantiCheckError(f"Invalid base name for mod {self.name} at {self.relative}, names must only contain a-z0-9_.")
|
raise MinetestCheckError(f"Invalid base name for mod {self.name} at {self.relative}, names must only contain a-z0-9_.")
|
||||||
|
|
||||||
if self.name and self.name in DISALLOWED_NAMES:
|
if self.name and self.name in DISALLOWED_NAMES:
|
||||||
raise LuantiCheckError(f"Forbidden mod name '{self.name}' used at {self.relative}")
|
raise MinetestCheckError(f"Forbidden mod name '{self.name}' used at {self.relative}")
|
||||||
|
|
||||||
self._check_dir_casing(["textures", "media", "sounds", "models", "locale"])
|
self._check_dir_casing(["textures", "media", "sounds", "models", "locale"])
|
||||||
elif self.type == ContentType.MODPACK:
|
elif self.type == ContentType.MODPACK:
|
||||||
@@ -139,7 +135,7 @@ class PackageTreeNode:
|
|||||||
for dir in next(os.walk(self.baseDir))[1]:
|
for dir in next(os.walk(self.baseDir))[1]:
|
||||||
lowercase = dir.lower()
|
lowercase = dir.lower()
|
||||||
if lowercase != dir and lowercase in dirs:
|
if lowercase != dir and lowercase in dirs:
|
||||||
raise LuantiCheckError(f"Incorrect case, {dir} should be {lowercase} at {self.relative}{dir}")
|
raise MinetestCheckError(f"Incorrect case, {dir} should be {lowercase} at {self.relative}{dir}")
|
||||||
|
|
||||||
def get_readme_path(self):
|
def get_readme_path(self):
|
||||||
for filename in os.listdir(self.baseDir):
|
for filename in os.listdir(self.baseDir):
|
||||||
@@ -173,12 +169,12 @@ class PackageTreeNode:
|
|||||||
for key, value in conf.items():
|
for key, value in conf.items():
|
||||||
result[key] = value
|
result[key] = value
|
||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
raise LuantiCheckError("Error while reading {}: {}".format(meta_file_rel , e.msg))
|
raise MinetestCheckError("Error while reading {}: {}".format(meta_file_rel , e.msg))
|
||||||
except IOError:
|
except IOError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if self.strict and "release" in result:
|
if self.strict and "release" in result:
|
||||||
raise LuantiCheckError("{} should not contain 'release' key, as this is for use by ContentDB only.".format(meta_file_rel))
|
raise MinetestCheckError("{} should not contain 'release' key, as this is for use by ContentDB only.".format(meta_file_rel))
|
||||||
|
|
||||||
# description.txt
|
# description.txt
|
||||||
if "description" not in result:
|
if "description" not in result:
|
||||||
@@ -188,11 +184,6 @@ class PackageTreeNode:
|
|||||||
except IOError:
|
except IOError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if os.path.isfile(self.baseDir + "/depends.txt"):
|
|
||||||
self.has_legacy_depends = True
|
|
||||||
if os.path.isfile(self.baseDir + "/description.txt"):
|
|
||||||
self.has_legacy_description = True
|
|
||||||
|
|
||||||
# Read dependencies
|
# Read dependencies
|
||||||
if "depends" in result or "optional_depends" in result:
|
if "depends" in result or "optional_depends" in result:
|
||||||
result["depends"] = get_csv_line(result.get("depends"))
|
result["depends"] = get_csv_line(result.get("depends"))
|
||||||
@@ -269,11 +260,11 @@ class PackageTreeNode:
|
|||||||
if not entry.startswith('.') and os.path.isdir(path):
|
if not entry.startswith('.') and os.path.isdir(path):
|
||||||
child = PackageTreeNode(path, relative + entry + "/", name=entry, strict=self.strict)
|
child = PackageTreeNode(path, relative + entry + "/", name=entry, strict=self.strict)
|
||||||
if not child.type.is_mod_like():
|
if not child.type.is_mod_like():
|
||||||
raise LuantiCheckError("Expecting mod or modpack, found {} at {} inside {}" \
|
raise MinetestCheckError("Expecting mod or modpack, found {} at {} inside {}" \
|
||||||
.format(child.type.value, child.relative, self.type.value))
|
.format(child.type.value, child.relative, self.type.value))
|
||||||
|
|
||||||
if child.name is None:
|
if child.name is None:
|
||||||
raise LuantiCheckError("Missing base name for mod at {}".format(self.relative))
|
raise MinetestCheckError("Missing base name for mod at {}".format(self.relative))
|
||||||
|
|
||||||
self.children.append(child)
|
self.children.append(child)
|
||||||
|
|
||||||
@@ -313,16 +304,6 @@ class PackageTreeNode:
|
|||||||
def get(self, key: str, default=None):
|
def get(self, key: str, default=None):
|
||||||
return self.meta.get(key, default)
|
return self.meta.get(key, default)
|
||||||
|
|
||||||
def check_for_legacy_files(self):
|
|
||||||
if self.has_legacy_depends:
|
|
||||||
raise LuantiCheckError("Found depends.txt at {}. Delete this file and use depends in mod.conf instead" \
|
|
||||||
.format(self.relative))
|
|
||||||
if self.has_legacy_description:
|
|
||||||
raise LuantiCheckError("Found description.txt at {}. Delete this file and use description in {} instead" \
|
|
||||||
.format(self.relative, self.get_meta_file_name()))
|
|
||||||
for child in self.children:
|
|
||||||
child.check_for_legacy_files()
|
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
for child in self.children:
|
for child in self.children:
|
||||||
child.validate()
|
child.validate()
|
||||||
@@ -348,6 +329,6 @@ class PackageTreeNode:
|
|||||||
ret.append(parse_tr(name))
|
ret.append(parse_tr(name))
|
||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
relative_path = os.path.join(self.relative, os.path.relpath(name, self.baseDir))
|
relative_path = os.path.join(self.relative, os.path.relpath(name, self.baseDir))
|
||||||
raise LuantiCheckError(f"Syntax error whilst reading {relative_path}: {e}")
|
raise MinetestCheckError(f"Syntax error whilst reading {relative_path}: {e}")
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
@@ -19,7 +19,7 @@ import random
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from urllib.parse import urlparse, urljoin
|
from urllib.parse import urlparse
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -28,7 +28,7 @@ from app import app
|
|||||||
from sqlalchemy import or_, and_
|
from sqlalchemy import or_, and_
|
||||||
|
|
||||||
from app.markdown import get_links, render_markdown
|
from app.markdown import get_links, render_markdown
|
||||||
from app.models import db, Package, PackageState, PackageRelease, PackageScreenshot, AuditLogEntry, AuditSeverity
|
from app.models import Package, db, PackageState, AuditLogEntry, AuditSeverity
|
||||||
from app.tasks import celery, TaskError
|
from app.tasks import celery, TaskError
|
||||||
from app.utils import post_bot_message, post_to_approval_thread, get_system_user, add_audit_log
|
from app.utils import post_bot_message, post_to_approval_thread, get_system_user, add_audit_log
|
||||||
|
|
||||||
@@ -131,7 +131,6 @@ def _url_exists(url: str) -> str:
|
|||||||
def _check_for_dead_links(package: Package) -> dict[str, str]:
|
def _check_for_dead_links(package: Package) -> dict[str, str]:
|
||||||
ignored_urls = set(app.config.get("LINK_CHECKER_IGNORED_URLS", ""))
|
ignored_urls = set(app.config.get("LINK_CHECKER_IGNORED_URLS", ""))
|
||||||
|
|
||||||
base_url = package.get_url("packages.view", absolute=True)
|
|
||||||
links: set[Optional[str]] = {
|
links: set[Optional[str]] = {
|
||||||
package.repo,
|
package.repo,
|
||||||
package.website,
|
package.website,
|
||||||
@@ -143,7 +142,7 @@ def _check_for_dead_links(package: Package) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if package.desc:
|
if package.desc:
|
||||||
links.update(get_links(render_markdown(package.desc)))
|
links.update(get_links(render_markdown(package.desc), package.get_url("packages.view", absolute=True)))
|
||||||
|
|
||||||
print(f"Checking {package.title} ({len(links)} links) for broken links", file=sys.stderr)
|
print(f"Checking {package.title} ({len(links)} links) for broken links", file=sys.stderr)
|
||||||
|
|
||||||
@@ -153,15 +152,14 @@ def _check_for_dead_links(package: Package) -> dict[str, str]:
|
|||||||
if link is None:
|
if link is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
abs_link = urljoin(base_url, link)
|
url = urlparse(link)
|
||||||
url = urlparse(abs_link)
|
|
||||||
if url.scheme != "http" and url.scheme != "https":
|
if url.scheme != "http" and url.scheme != "https":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if url.hostname in ignored_urls:
|
if url.hostname in ignored_urls:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
res = _url_exists(abs_link)
|
res = _url_exists(link)
|
||||||
if res != "":
|
if res != "":
|
||||||
bad_urls[link] = res
|
bad_urls[link] = res
|
||||||
|
|
||||||
@@ -211,34 +209,3 @@ def check_package_for_broken_links(package_id: int):
|
|||||||
if msg:
|
if msg:
|
||||||
post_bot_message(package, "Broken links", msg)
|
post_bot_message(package, "Broken links", msg)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
@celery.task(bind=True)
|
|
||||||
def update_file_size_bytes(self):
|
|
||||||
releases = PackageRelease.query.filter_by(file_size_bytes=0).all()
|
|
||||||
screenshots = PackageScreenshot.query.filter_by(file_size_bytes=0).all()
|
|
||||||
total = len(releases) + len(screenshots)
|
|
||||||
self.update_state(state="PROGRESS", meta={
|
|
||||||
"current": 0,
|
|
||||||
"total": total,
|
|
||||||
})
|
|
||||||
|
|
||||||
for i, release in enumerate(releases):
|
|
||||||
release.calculate_file_size_bytes()
|
|
||||||
|
|
||||||
if i % 100 == 0:
|
|
||||||
self.update_state(state="PROGRESS", meta={
|
|
||||||
"current": i + 1,
|
|
||||||
"total": total,
|
|
||||||
})
|
|
||||||
|
|
||||||
for i, ss in enumerate(screenshots):
|
|
||||||
ss.calculate_file_size_bytes()
|
|
||||||
|
|
||||||
if i % 100 == 0:
|
|
||||||
self.update_state(state="PROGRESS", meta={
|
|
||||||
"current": i + len(releases) + 1,
|
|
||||||
"total": total,
|
|
||||||
})
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from sqlalchemy import or_, and_, not_, func
|
from sqlalchemy import or_, and_
|
||||||
|
|
||||||
from app import app
|
from app import app
|
||||||
from app.models import User, db, UserRank, ThreadReply, Package, NotificationType
|
from app.models import User, db, UserRank, ThreadReply, Package, NotificationType
|
||||||
@@ -149,37 +149,3 @@ def import_github_user_ids():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
print(f"Updated {count} users", file=sys.stderr)
|
print(f"Updated {count} users", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
@celery.task()
|
|
||||||
def do_delete_likely_spammers():
|
|
||||||
query = (User.query.filter(
|
|
||||||
and_(
|
|
||||||
User.rank == UserRank.NEW_MEMBER,
|
|
||||||
or_(
|
|
||||||
func.replace(User.website_url, ".", "").regexp_match(
|
|
||||||
func.concat("https?://[^/]*", User.username, ".*")),
|
|
||||||
),
|
|
||||||
or_(
|
|
||||||
User.website_url.ilike("%bet%"),
|
|
||||||
User.website_url.ilike("%win%"),
|
|
||||||
User.website_url.ilike("%88%"),
|
|
||||||
User.website_url.ilike("%luck%"),
|
|
||||||
User.website_url.ilike("%sport%"),
|
|
||||||
User.website_url.ilike("%lottery%"),
|
|
||||||
User.website_url.ilike("%casino%"),
|
|
||||||
User.website_url.ilike("%vip%"),
|
|
||||||
User.website_url.ilike("%assignment%"),
|
|
||||||
),
|
|
||||||
~User.packages.any(),
|
|
||||||
~User.replies.any(),
|
|
||||||
~User.reports.any(),
|
|
||||||
not_(or_(
|
|
||||||
User.website_url.ilike("%.github.io%"),
|
|
||||||
User.website_url.ilike("%.neocities.org%"),
|
|
||||||
)),
|
|
||||||
)))
|
|
||||||
|
|
||||||
for user in query.all():
|
|
||||||
db.session.delete(user)
|
|
||||||
db.session.commit()
|
|
||||||
|
|||||||
@@ -15,70 +15,45 @@
|
|||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
from subprocess import Popen, PIPE
|
||||||
from subprocess import Popen, PIPE, TimeoutExpired
|
from typing import Optional
|
||||||
from typing import Optional, List
|
|
||||||
|
|
||||||
from app.models import Package, PackageState, PackageRelease
|
from app.models import Package, PackageState, PackageRelease
|
||||||
from app.tasks import celery
|
from app.tasks import celery
|
||||||
|
|
||||||
|
|
||||||
@celery.task(bind=True)
|
@celery.task()
|
||||||
def search_in_releases(self, query: str, file_filter: str, types: List[str]):
|
def search_in_releases(query: str, file_filter: str):
|
||||||
pkg_query = Package.query.filter(Package.state == PackageState.APPROVED)
|
packages = list(Package.query.filter(Package.state == PackageState.APPROVED).all())
|
||||||
if len(types) > 0:
|
running = []
|
||||||
pkg_query = pkg_query.filter(Package.type.in_(types))
|
|
||||||
|
|
||||||
packages = list(pkg_query.all())
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
total = len(packages)
|
while len(packages) > 0 or len(running) > 0:
|
||||||
self.update_state(state="PROGRESS", meta={"current": 0, "total": total})
|
# Check running
|
||||||
|
for i in range(len(running) - 1, -1, -1):
|
||||||
while len(packages) > 0:
|
package: Package = running[i][0]
|
||||||
package = packages.pop()
|
handle: subprocess.Popen[str] = running[i][1]
|
||||||
release: Optional[PackageRelease] = package.get_download_release()
|
|
||||||
if release:
|
|
||||||
print(f"[Zipgrep] Checking {package.name}", file=sys.stderr)
|
|
||||||
self.update_state(state="PROGRESS", meta={
|
|
||||||
"current": total - len(packages),
|
|
||||||
"total": total,
|
|
||||||
"running": [package.as_key_dict()],
|
|
||||||
})
|
|
||||||
|
|
||||||
handle = Popen(["zipgrep", query, release.file_path, file_filter], stdout=PIPE, encoding="UTF-8")
|
|
||||||
|
|
||||||
try:
|
|
||||||
handle.wait(timeout=45)
|
|
||||||
except TimeoutExpired:
|
|
||||||
print(f"[Zipgrep] Timeout for {package.name}", file=sys.stderr)
|
|
||||||
handle.kill()
|
|
||||||
results.append({
|
|
||||||
"package": package.as_key_dict(),
|
|
||||||
"lines": "Error: timeout",
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
|
|
||||||
exit_code = handle.poll()
|
exit_code = handle.poll()
|
||||||
if exit_code is None:
|
if exit_code is None:
|
||||||
print(f"[Zipgrep] Timeout for {package.name}", file=sys.stderr)
|
continue
|
||||||
handle.kill()
|
|
||||||
results.append({
|
|
||||||
"package": package.as_key_dict(),
|
|
||||||
"lines": "Error: timeout",
|
|
||||||
})
|
|
||||||
elif exit_code == 0:
|
elif exit_code == 0:
|
||||||
print(f"[Zipgrep] Success for {package.name}", file=sys.stderr)
|
|
||||||
results.append({
|
results.append({
|
||||||
"package": package.as_key_dict(),
|
"package": package.as_key_dict(),
|
||||||
"lines": handle.stdout.read(),
|
"lines": handle.stdout.read(),
|
||||||
})
|
})
|
||||||
elif exit_code != 1:
|
|
||||||
print(f"[Zipgrep] Error {exit_code} for {package.name}", file=sys.stderr)
|
del running[i]
|
||||||
results.append({
|
|
||||||
"package": package.as_key_dict(),
|
# Create new
|
||||||
"lines": f"Error: exit {exit_code}",
|
while len(running) < 1 and len(packages) > 0:
|
||||||
})
|
package = packages.pop()
|
||||||
|
release: Optional[PackageRelease] = package.get_download_release()
|
||||||
|
if release:
|
||||||
|
handle = Popen(["zipgrep", query, release.file_path, file_filter], stdout=PIPE, encoding="UTF-8")
|
||||||
|
running.append([package, handle])
|
||||||
|
|
||||||
|
if len(running) > 0:
|
||||||
|
running[0][1].wait()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"query": query,
|
"query": query,
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ from flask_login import current_user
|
|||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
from . import app, utils
|
from . import app, utils
|
||||||
from app.markdown import get_headings
|
from .markdown import get_headings
|
||||||
from .models import Permission, Package, PackageState, PackageRelease
|
from .models import Permission, Package, PackageState, PackageRelease
|
||||||
from .utils import abs_url_for, url_set_query, url_set_anchor, url_current
|
from .utils import abs_url_for, url_set_query, url_set_anchor, url_current
|
||||||
from .utils.luanti_hypertext import normalize_whitespace as do_normalize_whitespace
|
from .utils.minetest_hypertext import normalize_whitespace as do_normalize_whitespace
|
||||||
|
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
|
|||||||
@@ -7,14 +7,6 @@ Audit Log
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Audit Log</h1>
|
<h1>Audit Log</h1>
|
||||||
|
|
||||||
{% from "macros/forms.html" import render_field, render_submit_field %}
|
|
||||||
<form method="GET" action="">
|
|
||||||
{{ render_field(form.username) }}
|
|
||||||
{{ render_field(form.q) }}
|
|
||||||
{{ render_field(form.url) }}
|
|
||||||
{{ render_submit_field(form.submit) }}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% from "macros/pagination.html" import render_pagination %}
|
{% from "macros/pagination.html" import render_pagination %}
|
||||||
{% from "macros/audit_log.html" import render_audit_log %}
|
{% from "macros/audit_log.html" import render_audit_log %}
|
||||||
|
|
||||||
|
|||||||
@@ -13,20 +13,14 @@
|
|||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for('users.list_all') }}">
|
<a class="list-group-item list-group-item-action" href="{{ url_for('users.list_all') }}">
|
||||||
<i class="fas fa-users me-2"></i>
|
<i class="fas fa-users me-2"></i>
|
||||||
{{ _("User list") }}
|
User list
|
||||||
</a>
|
</a>
|
||||||
{% if current_user.rank.at_least(current_user.rank.APPROVER) %}
|
{% if current_user.rank.at_least(current_user.rank.MODERATOR) %}
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.audit') }}">
|
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.audit') }}">
|
||||||
<i class="fas fa-user-clock me-2"></i>
|
<i class="fas fa-user-clock me-2"></i>
|
||||||
{{ _("Audit Log") }}
|
{{ _("Audit Log") }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.rank.at_least(current_user.rank.EDITOR) %}
|
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for('report.list_all') }}">
|
|
||||||
<i class="fas fa-user-clock me-2"></i>
|
|
||||||
Reports
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Packages</h3>
|
<h3>Packages</h3>
|
||||||
|
|||||||
@@ -18,16 +18,16 @@
|
|||||||
{{ _("Package") }}
|
{{ _("Package") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2 text-center">
|
<div class="col-2 text-center">
|
||||||
Latest release (MB)
|
Latest release / MB
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2 text-center">
|
<div class="col-2 text-center">
|
||||||
Releases (MB)
|
Releases / MB
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2 text-center">
|
<div class="col-2 text-center">
|
||||||
Screenshots (MB)
|
Screenshots / MB
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2 text-center">
|
<div class="col-2 text-center">
|
||||||
Total (MB)
|
Total / MB
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
{%- endif %}
|
{%- endif %}
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/static/libs/bootstrap.min.css?v=4">
|
<link rel="stylesheet" type="text/css" href="/static/libs/bootstrap.min.css?v=4">
|
||||||
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=59">
|
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=54">
|
||||||
<link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" />
|
<link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" />
|
||||||
|
|
||||||
{% if noindex -%}
|
{% if noindex -%}
|
||||||
@@ -274,7 +274,7 @@
|
|||||||
<li class="list-inline-item"><a href="{{ url_for('collections.list_all') }}">{{ _("Collections") }}</a></li>
|
<li class="list-inline-item"><a href="{{ url_for('collections.list_all') }}">{{ _("Collections") }}</a></li>
|
||||||
<li class="list-inline-item"><a href="{{ url_for('donate.donate') }}">{{ _("Support Creators") }}</a></li>
|
<li class="list-inline-item"><a href="{{ url_for('donate.donate') }}">{{ _("Support Creators") }}</a></li>
|
||||||
<li class="list-inline-item"><a href="{{ url_for('translate.translate') }}">{{ _("Translate Packages") }}</a></li>
|
<li class="list-inline-item"><a href="{{ url_for('translate.translate') }}">{{ _("Translate Packages") }}</a></li>
|
||||||
<li class="list-inline-item"><a href="https://github.com/luanti-org/contentdb">{{ _("Source Code") }}</a></li>
|
<li class="list-inline-item"><a href="https://github.com/minetest/contentdb">{{ _("Source Code") }}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('set_nonfree') }}" class="my-3">
|
<form method="POST" action="{{ url_for('set_nonfree') }}" class="my-3">
|
||||||
|
|||||||
@@ -39,25 +39,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="carousel-inner">
|
<div class="carousel-inner">
|
||||||
{% for package in spotlight_pkgs %}
|
{% for package in spotlight_pkgs %}
|
||||||
{% set meta = package.get_translated(load_desc=False) %}
|
|
||||||
{% set cover_image = package.get_cover_image_url() %}
|
{% set cover_image = package.get_cover_image_url() %}
|
||||||
{% set tags = package.tags | sort(attribute="views", reverse=True) %}
|
{% set tags = package.tags | sort(attribute="views", reverse=True) %}
|
||||||
<div class="carousel-item {% if loop.index == 1 %}active{% endif %}">
|
<div class="carousel-item {% if loop.index == 1 %}active{% endif %}">
|
||||||
<a href="{{ package.get_url('packages.view') }}">
|
<a href="{{ package.get_url('packages.view') }}">
|
||||||
<div class="ratio ratio-16x9">
|
<div class="ratio ratio-16x9">
|
||||||
<img src="{{ cover_image }}"
|
<img src="{{ cover_image }}"
|
||||||
alt="{{ _('%(title)s by %(author)s', title=meta.title, author=package.author.display_name) }}">
|
alt="{{ _('%(title)s by %(author)s', title=package.title, author=package.author.display_name) }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="carousel-caption text-shadow">
|
<div class="carousel-caption text-shadow">
|
||||||
<h3 class="mt-0 mb-3">
|
<h3 class="mt-0 mb-3">
|
||||||
{% if package.author %}
|
{% if package.author %}
|
||||||
{{ _('<strong>%(title)s</strong> by %(author)s', title=meta.title, author=package.author.display_name) }}
|
{{ _('<strong>%(title)s</strong> by %(author)s', title=package.title, author=package.author.display_name) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<strong>{{ meta.title }}</strong>
|
<strong>{{ package.title }}</strong>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
{{ meta.short_desc }}
|
{{ package.short_desc }}
|
||||||
</p>
|
</p>
|
||||||
{% if package.author %}
|
{% if package.author %}
|
||||||
<div class="d-none d-md-block">
|
<div class="d-none d-md-block">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<script src="/static/libs/chart.min.js"></script>
|
<script src="/static/libs/chart.min.js"></script>
|
||||||
<script src="/static/libs/chartjs-adapter-date-fns.bundle.min.js"></script>
|
<script src="/static/libs/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||||
<script src="/static/libs/chartjs-plugin-annotation.min.js"></script>
|
<script src="/static/libs/chartjs-plugin-annotation.min.js"></script>
|
||||||
<script src="/static/js/package_charts.js?v=2"></script>
|
<script src="/static/js/package_charts.js?v=3"></script>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
@@ -118,6 +118,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mt-5">{{ _("Views inside Luanti") }}</h3>
|
||||||
|
<p>
|
||||||
|
{{ _("Number of package page views inside the Luanti client. v5.10 and later only.") }}
|
||||||
|
</p>
|
||||||
|
<canvas id="chart-views" class="chart"></canvas>
|
||||||
|
|
||||||
<h3 style="margin-top: 6em;">{{ _("Need more stats?") }}</h3>
|
<h3 style="margin-top: 6em;">{{ _("Need more stats?") }}</h3>
|
||||||
<p>
|
<p>
|
||||||
{{ _("Check out the ContentDB Grafana dashboard for CDB-wide stats") }}
|
{{ _("Check out the ContentDB Grafana dashboard for CDB-wide stats") }}
|
||||||
|
|||||||
@@ -176,6 +176,11 @@
|
|||||||
<input class="btn btn-primary" name="btn_submit" type="submit" value="Comment" />
|
<input class="btn btn-primary" name="btn_submit" type="submit" value="Comment" />
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if thread.private %}
|
||||||
|
<p class="text-muted card-body my-0 pt-0">
|
||||||
|
{{ _("You can add someone to a private thread by writing @username.") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
{{ form_scripts() }}
|
{{ form_scripts() }}
|
||||||
{{ easymde_scripts() }}
|
{{ easymde_scripts() }}
|
||||||
{% if enable_wizard %}
|
{% if enable_wizard %}
|
||||||
<script src="/static/js/polltask.js?v=4"></script>
|
<script src="/static/js/polltask.js"></script>
|
||||||
<script src="/static/js/package_create.js"></script>
|
<script src="/static/js/package_create.js"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<script src="/static/js/package_edit.js?v=4"></script>
|
<script src="/static/js/package_edit.js?v=3"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ render_field(form.short_desc, class_="pkg_meta") }}
|
{{ render_field(form.short_desc, class_="pkg_meta") }}
|
||||||
<p class="form-text text-warning d-none" id="short_desc_mods">
|
<p class="form-text text-warning d-none" id="short_desc_mods">
|
||||||
{{ _("Tip: Don't include <i>Luanti</i>, <i>Minetest</i>, <i>mod</i>, or <i>modpack</i> anywhere in the short description. It is unnecessary and wastes characters.") }}
|
{{ _("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.") }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{{ render_field(form.dev_state, class_="pkg_meta", hint=_("Please choose 'Work in Progress' if your package is unstable, and shouldn't be recommended to all players")) }}
|
{{ render_field(form.dev_state, class_="pkg_meta", hint=_("Please choose 'Work in Progress' if your package is unstable, and shouldn't be recommended to all players")) }}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
pattern="[A-Za-z0-9/._-]+") }}
|
pattern="[A-Za-z0-9/._-]+") }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ render_field(form.file_upload, class_="mt-3", accept=".zip") }}
|
{{ render_field(form.file_upload, fieldclass="form-control-file", class_="mt-3", accept=".zip") }}
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{{ _("Take a look at the <a href='/help/package_config/'>Package Configuration and Releases Guide</a> for
|
{{ _("Take a look at the <a href='/help/package_config/'>Package Configuration and Releases Guide</a> for
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
{{ render_field(form.title) }}
|
{{ render_field(form.title) }}
|
||||||
{{ render_field(form.file_upload, accept="image/png,image/jpeg,image/webp") }}
|
{{ render_field(form.file_upload, fieldclass="form-control-file", accept="image/png,image/jpeg,image/webp") }}
|
||||||
{{ render_submit_field(form.submit) }}
|
{{ render_submit_field(form.submit) }}
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% set translations = package.translations.all() %}
|
{% set translations = package.translations.all() %}
|
||||||
{% set num = translations | length + 1 %}
|
{% set num = translations | length + 1 %}
|
||||||
|
|
||||||
<a class="btn btn-secondary float-end" href="https://api.luanti.org/translations/#translating-content-meta">
|
<a class="btn btn-secondary float-end" href="https://api.minetest.net/translations/#translating-content-meta">
|
||||||
{{ _("Help") }}
|
{{ _("Help") }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<a class="btn btn-primary me-2" href="https://rubenwardy.com/minetest_modding_book/en/quality/translations.html">
|
<a class="btn btn-primary me-2" href="https://rubenwardy.com/minetest_modding_book/en/quality/translations.html">
|
||||||
{{ _("Translation - Luanti Modding Book") }}
|
{{ _("Translation - Luanti Modding Book") }}
|
||||||
</a>
|
</a>
|
||||||
<a class="btn btn-primary" href="https://api.luanti.org/translations/#translating-content-meta">
|
<a class="btn btn-primary" href="https://api.minetest.net/translations/#translating-content-meta">
|
||||||
{{ _("Translating content meta - lua_api.md") }}
|
{{ _("Translating content meta - lua_api.md") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
{{ render_field(form.title) }}
|
{{ render_field(form.title) }}
|
||||||
{{ render_field(form.file_upload, accept="image/png,image/jpeg,image/webp") }}
|
{{ render_field(form.file_upload, fieldclass="form-control-file", accept="image/png,image/jpeg,image/webp") }}
|
||||||
{{ render_checkbox_field
|
{{ render_checkbox_field
|
||||||
{{ render_submit_field(form.submit) }}
|
{{ render_submit_field(form.submit) }}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
<h2>{% if review_thread.private %}🔒{% endif %} {{ review_thread.title }}</h2>
|
<h2>{% if review_thread.private %}🔒{% endif %} {{ review_thread.title }}</h2>
|
||||||
{% if review_thread.private %}
|
{% if review_thread.private %}
|
||||||
<p><i>
|
<p><i>
|
||||||
{{ _("This thread is only visible to its creator, package maintainers, and users of Approver rank or above.") }}
|
{{ _("This thread is only visible to its creator, package maintainers, users of Approver rank or above, and @mentioned users.") }}
|
||||||
</i></p>
|
</i></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -397,7 +397,7 @@
|
|||||||
<h3 id="release_notes" class="card-header">
|
<h3 id="release_notes" class="card-header">
|
||||||
{{ _("Release notes for %(title)s", title=release.title) }}
|
{{ _("Release notes for %(title)s", title=release.title) }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="card-body markdown release-notes-body">
|
<div class="card-body markdown">
|
||||||
{{ release.release_notes | markdown }}
|
{{ release.release_notes | markdown }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title -%}
|
|
||||||
Edit report
|
|
||||||
{%- endblock %}
|
|
||||||
|
|
||||||
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field, easymde_scripts %}
|
|
||||||
{% block scriptextra %}
|
|
||||||
{{ easymde_scripts() }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<h1>{{ self.title() }}</h1>
|
|
||||||
|
|
||||||
<form method="POST" action="" enctype="multipart/form-data">
|
|
||||||
{{ form.hidden_tag() }}
|
|
||||||
{{ render_field(form.category) }}
|
|
||||||
{{ render_field(form.url) }}
|
|
||||||
{{ render_field(form.title) }}
|
|
||||||
{{ render_field(form.message, class_="m-0", fieldclass="form-control markdown", data_enter_submit="1") }}
|
|
||||||
{{ render_submit_field(form.submit) }}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
50
app/templates/report/index.html
Normal file
50
app/templates/report/index.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title -%}
|
||||||
|
{{ _("Report") }}
|
||||||
|
{%- endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1>{{ _("Report") }}</h1>
|
||||||
|
|
||||||
|
{% if not form %}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{ _("Due to spam, we no longer accept reports from anonymous users on this form.") }}
|
||||||
|
{{ _("Please sign in or contact the admin using the link below.") }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{ url_for('users.login') }}" class="btn btn-primary me-2">Login</a>
|
||||||
|
<a href="{{ admin_contact_url }}" class="btn btn-secondary">Contact the admin</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %}
|
||||||
|
|
||||||
|
<form method="POST" action="" enctype="multipart/form-data">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
{% if url %}
|
||||||
|
<p>
|
||||||
|
URL: <code>{{ url }}</code>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{{ render_field(form.message, hint=_("What are you reporting? Why are you reporting it?")) }}
|
||||||
|
{{ render_submit_field(form.submit) }}
|
||||||
|
|
||||||
|
<p class="mt-5 text-muted">
|
||||||
|
{{ _("Reports will be shared with ContentDB staff.") }}
|
||||||
|
{% if is_anon %}
|
||||||
|
{{ _("Only the admin will be able to see who made the report.") }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="alert alert-info">
|
||||||
|
{{ _("Found a bug? Please report on the package's issue tracker or in a thread instead.") }}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title -%}
|
|
||||||
Reports
|
|
||||||
{%- endblock %}
|
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<h1>{{ self.title() }}</h1>
|
|
||||||
|
|
||||||
<nav class="list-group">
|
|
||||||
{% for report in reports %}
|
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for('report.view', rid=report.id) }}">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
{% if report.is_resolved %}
|
|
||||||
<span class="badge bg-secondary me-3">
|
|
||||||
Closed
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-info me-3">
|
|
||||||
Open
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
{{ report.title }}
|
|
||||||
{% if report.user %}
|
|
||||||
by {{ report.user.display_name }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
{{ report.created_at | timedelta }} ago
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<span>
|
|
||||||
No reports.
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title -%}
|
|
||||||
{{ _("Report") }}
|
|
||||||
{%- endblock %}
|
|
||||||
|
|
||||||
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field, easymde_scripts %}
|
|
||||||
{% block scriptextra %}
|
|
||||||
{{ easymde_scripts() }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<h1>{{ self.title() }}</h1>
|
|
||||||
|
|
||||||
{% if not form %}
|
|
||||||
|
|
||||||
<p>
|
|
||||||
{{ _("Due to spam, we no longer accept reports from anonymous users on this form.") }}
|
|
||||||
{{ _("Please sign in or contact the admin using the link below.") }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a href="{{ url_for('users.login') }}" class="btn btn-primary me-2">Login</a>
|
|
||||||
<a href="{{ admin_contact_url }}" class="btn btn-secondary">Contact the admin</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
|
|
||||||
<p class="text-muted">
|
|
||||||
{{ _("The full report will be visible to all ContentDB staff members, including editors and moderators.") }}
|
|
||||||
{{ _("If you are reporting something by another user, we may discuss the report with them but will not disclose who reported it.") }}
|
|
||||||
{{ _("If you are reporting something by an editor or moderator, then you should email the admin or a moderator directly to hide your identity.") }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form method="POST" action="" enctype="multipart/form-data">
|
|
||||||
{{ form.hidden_tag() }}
|
|
||||||
{{ render_field(form.category) }}
|
|
||||||
{{ render_field(form.url) }}
|
|
||||||
{{ render_field(form.title) }}
|
|
||||||
{{ render_field(form.message, class_="m-0", fieldclass="form-control markdown", data_enter_submit="1") }}
|
|
||||||
{{ render_field(form.file_upload, accept="image/png,image/jpeg,image/webp", hint=_("Optional, usually not required")) }}
|
|
||||||
{{ render_submit_field(form.submit) }}
|
|
||||||
<p class="alert alert-info mt-5">
|
|
||||||
{{ _("Found a bug? Please report on the package's issue tracker or in a thread instead.") }}
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title -%}
|
|
||||||
{{ _("We have received your report") }}
|
|
||||||
{%- endblock %}
|
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<h1>{{ self.title() }}</h1>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
{{ _("We aim to resolve your report quickly.") }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
{{ _("If the report is about illegal or harmful content, we aim to resolve within 48 hours.") }}
|
|
||||||
{{ _("If we find the content to be infringing, we will remove it and may warn or suspend the user.") }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% if report.thread %}
|
|
||||||
<p>
|
|
||||||
{{ _("A private thread has been created for this report. You can use it to communicate with ContentDB staff and receive updates about the report.") }}
|
|
||||||
</p>
|
|
||||||
{% else %}
|
|
||||||
<p>
|
|
||||||
{{ _("Due to limited resources, we may not contact you further about the report unless we need clarification.") }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{{ _("For future reference, use report id: %(report_id)s.", report_id=report.id) }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<p>
|
|
||||||
{% if report.thread %}
|
|
||||||
<a class="btn bg-primary btn-large me-2" href="{{ url_for('threads.view', id=report.thread.id) }}">{{ _("View thread") }}</a>
|
|
||||||
{% endif %}
|
|
||||||
<a class="btn bg-primary btn-large" href="{{ url_for('homepage.home') }}">{{ _("Back to home") }}</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title -%}
|
|
||||||
{{ report.title }}
|
|
||||||
{%- endblock %}
|
|
||||||
|
|
||||||
{% from "macros/forms.html" import render_field, render_submit_field, render_radio_field, easymde_scripts %}
|
|
||||||
{% block scriptextra %}
|
|
||||||
{{ easymde_scripts() }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
{% set url = url_for("report.view", rid=report.id) %}
|
|
||||||
<p class="float-end">
|
|
||||||
<a class="btn bg-secondary me-2" href="{{ url_for('admin.audit', url=url) }}">View audit log</a>
|
|
||||||
<a class="btn bg-secondary" href="{{ url_for('report.edit', rid=report.id) }}">{{ _("Edit") }}</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a class="btn bg-secondary" href="{{ url_for('report.list_all') }}">Back to reports</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h1>
|
|
||||||
{% if report.is_resolved %}
|
|
||||||
<span class="badge bg-secondary me-3">
|
|
||||||
Closed
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-info me-3">
|
|
||||||
Open
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
{{ self.title() }}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<article class="row">
|
|
||||||
<div class="col-md-9">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body markdown">
|
|
||||||
{{ report.message | markdown }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="col-md-3 info-sidebar">
|
|
||||||
<dl>
|
|
||||||
<dt>Category</dt>
|
|
||||||
<dd>
|
|
||||||
{{ report.category.title }}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
<dl>
|
|
||||||
<dt>URL</dt>
|
|
||||||
<dd>
|
|
||||||
<a href="{{ report.url }}">{{ report.url }}</a>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
<dl>
|
|
||||||
<dt>Created At</dt>
|
|
||||||
<dd>
|
|
||||||
{{ report.created_at | full_datetime }}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
<dl>
|
|
||||||
<dt>Reporter</dt>
|
|
||||||
<dd>
|
|
||||||
{% if report.user %}
|
|
||||||
<a href="{{ url_for('users.profile', username=report.user.username) }}">
|
|
||||||
{{ report.user.username }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
Anonymous
|
|
||||||
{% endif %}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</aside>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
{% if report.attachments %}
|
|
||||||
<article>
|
|
||||||
<h2>Attachments</h2>
|
|
||||||
<ul>
|
|
||||||
{% for attachment in report.attachments %}
|
|
||||||
<li><a href="{{ attachment.url }}">{{ attachment.url }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<article>
|
|
||||||
<h2>{% if report.is_resolved %}Reopen report{% else %}Close report{% endif %}</h2>
|
|
||||||
<form method="POST" action="">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
||||||
{% if report.is_resolved %}
|
|
||||||
<button type="submit" class="btn bg-primary" name="reopen" value="true">{{ _("Reopen") }}</button>
|
|
||||||
{% else %}
|
|
||||||
<button type="submit" class="btn bg-primary" name="completed" value="true">{{ _("Completed (action taken)") }}</button>
|
|
||||||
<button type="submit" class="btn bg-primary" name="removed" value="true">{{ _("Content removed") }}</button>
|
|
||||||
<button type="submit" class="btn bg-primary" name="invalid" value="true">{{ _("Invalid / close with no action") }}</button>
|
|
||||||
{% endif %}
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article>
|
|
||||||
<h2>Thread</h2>
|
|
||||||
{% if report.thread %}
|
|
||||||
{% from "macros/threads.html" import render_thread %}
|
|
||||||
{{ render_thread(report.thread, current_user, form=False) }}
|
|
||||||
{% else %}
|
|
||||||
<p>
|
|
||||||
No thread.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -10,17 +10,18 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{{ self.title() }}</h1>
|
<h1>{{ self.title() }}</h1>
|
||||||
<article data-task-id="{{ info.id }}">
|
|
||||||
<p id="status"></p>
|
|
||||||
<div id="progress" class="progress d-none">
|
|
||||||
<div class="progress-bar bg-info" role="progressbar" style="width: 50%;" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"></div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
{% if "error" in info or info.status == "FAILURE" or info.status == "REVOKED" %}
|
{% if "error" in info or info.status == "FAILURE" or info.status == "REVOKED" %}
|
||||||
<pre style="white-space: pre-wrap; word-wrap: break-word;">{{ info.error }}</pre>
|
<pre style="white-space: pre-wrap; word-wrap: break-word;">{{ info.error }}</pre>
|
||||||
{% else %}
|
{% else %}
|
||||||
<script src="/static/js/polltask.js?v=4"></script>
|
<script src="/static/js/polltask.js"></script>
|
||||||
|
<script>
|
||||||
|
// @author rubenwardy
|
||||||
|
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||||
|
pollTask("{{ url_for('tasks.check', id=info.id) }}", true)
|
||||||
|
.then(function() { location.reload() })
|
||||||
|
.catch(function() { location.reload() })
|
||||||
|
</script>
|
||||||
<noscript>
|
<noscript>
|
||||||
{{ _("Reload the page to check for updates.") }}
|
{{ _("Reload the page to check for updates.") }}
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
<aside class="row">
|
<aside class="row">
|
||||||
<div class="col-md-9">
|
<div class="col-md-9">
|
||||||
<i>
|
<i>
|
||||||
{{ _("This thread is only visible to its creator, package maintainers, and users of Approver rank or above.") }}
|
{{ _("This thread is only visible to its creator, package maintainers, users of Approver rank or above, and @mentioned users.") }}
|
||||||
</i>
|
</i>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -128,11 +128,6 @@
|
|||||||
</aside>
|
</aside>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% set report = thread.report.first() %}
|
|
||||||
{% if report and report.check_perm(current_user, "SEE_REPORT") %}
|
|
||||||
<a class="btn bg-primary btn-large" href="{{ url_for('report.view', rid=report.id) }}">View report page</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if thread.review and current_user == thread.package.author %}
|
{% if thread.review and current_user == thread.package.author %}
|
||||||
{% set flag %}
|
{% set flag %}
|
||||||
<i class="fas fa-flag mx-2"></i>
|
<i class="fas fa-flag mx-2"></i>
|
||||||
|
|||||||
@@ -5,28 +5,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if reports %}
|
|
||||||
<h2 class="mb-4">{{ _("Reports") }}</h2>
|
|
||||||
<nav class="list-group">
|
|
||||||
{% for report in reports %}
|
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for('report.view', rid=report.id) }}">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<span class="badge bg-secondary me-3">{{ report.category.title }}</span>
|
|
||||||
{{ report.title }}
|
|
||||||
{% if report.user %}
|
|
||||||
by {{ report.user.display_name }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
{{ report.created_at | timedelta }} ago
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<h2 class="mb-4">{{ _("Approval Queue") }}</h2>
|
<h2 class="mb-4">{{ _("Approval Queue") }}</h2>
|
||||||
{% if can_approve_scn and screenshots %}
|
{% if can_approve_scn and screenshots %}
|
||||||
<div class="card my-4">
|
<div class="card my-4">
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<a class="btn btn-secondary float-end" href="https://dev.luanti.org/Translation#Translating_mods_and_games">
|
<a class="btn btn-secondary float-end" href="https://dev.minetest.net/Translation#Translating_mods_and_games">
|
||||||
{{ _("How to translate a mod / game") }}
|
{{ _("How to translate a mod / game") }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
{{ _("Please raise a report to request account deletion.") }}
|
{{ _("Please raise a report to request account deletion.") }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a class="btn btn-secondary" href="{{ url_for('report.report', url=url_current(), title='Delete my account', category='account_deletion') }}">{{ _("Report") }}</a>
|
<a class="btn btn-secondary" href="{{ url_for('report.report', url=url_current(), message="Delete my account") }}">{{ _("Report") }}</a>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user.website_url and user.check_perm(user, "LINK_TO_WEBSITE") %}
|
{% if user.website_url %}
|
||||||
<a class="btn" href="{{ user.website_url }}" rel="ugc">
|
<a class="btn" href="{{ user.website_url }}" rel="ugc">
|
||||||
<i class="fas fa-globe-europe"></i>
|
<i class="fas fa-globe-europe"></i>
|
||||||
<span class="count">
|
<span class="count">
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user.donate_url and user.check_perm(user, "LINK_TO_WEBSITE") %}
|
{% if user.donate_url %}
|
||||||
<a class="btn" href="{{ user.donate_url }}" rel="ugc">
|
<a class="btn" href="{{ user.donate_url }}" rel="ugc">
|
||||||
<i class="fas fa-donate"></i>
|
<i class="fas fa-donate"></i>
|
||||||
<span class="count">
|
<span class="count">
|
||||||
|
|||||||
@@ -67,13 +67,6 @@
|
|||||||
{{ render_field(form.donate_url, tabindex=233) }}
|
{{ render_field(form.donate_url, tabindex=233) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not user.check_perm(user, "LINK_TO_WEBSITE") %}
|
|
||||||
<p>
|
|
||||||
{{ _("Website URLs will not be shown whilst you are a 'New Member'.") }}
|
|
||||||
{{ _("To become a full member, create a review, comment, or package and wait 7 days.") }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{{ render_submit_field(form.submit, tabindex=280) }}
|
{{ render_submit_field(form.submit, tabindex=280) }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -34,8 +34,6 @@
|
|||||||
</p>
|
</p>
|
||||||
{{ render_field(form.question, hint=_("Please prove that you are human")) }}
|
{{ render_field(form.question, hint=_("Please prove that you are human")) }}
|
||||||
|
|
||||||
<input class="form-control" id="first_name" name="first_name" type="text" value="">
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{{ _("By signing up, you agree to the <a href='/terms/' target='_blank'>Terms of Service</a> and <a href='/privacy_policy/' target='_blank'>Privacy Policy</a>.") }}
|
{{ _("By signing up, you agree to the <a href='/terms/' target='_blank'>Terms of Service</a> and <a href='/privacy_policy/' target='_blank'>Privacy Policy</a>.") }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
{{ render_field(form.query, hint=self.query_hint()) }}
|
{{ render_field(form.query, hint=self.query_hint()) }}
|
||||||
{{ render_field(form.file_filter, hint="Supports wildcards and regex") }}
|
{{ render_field(form.file_filter, hint="Supports wildcards and regex") }}
|
||||||
{{ render_field(form.type, hint=_("Use shift to select multiple. Leave selection empty to match any type.")) }}
|
|
||||||
{{ render_submit_field(form.submit, tabindex=180) }}
|
{{ render_submit_field(form.submit, tabindex=180) }}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import List, Tuple, Optional
|
from typing import List, Tuple, Optional
|
||||||
|
|
||||||
from app.default_data import populate_test_data
|
from app.default_data import populate_test_data
|
||||||
from app.models import db, License, PackageType, User, Package, PackageState, PackageRelease, LuantiRelease
|
from app.models import db, License, PackageType, User, Package, PackageState, PackageRelease, MinetestRelease
|
||||||
from .utils import parse_json, validate_package_list
|
from .utils import parse_json, validate_package_list
|
||||||
from .utils import client # noqa
|
from .utils import client # noqa
|
||||||
|
|
||||||
@@ -32,10 +32,10 @@ def make_package(name: str, versions: List[Tuple[Optional[str], Optional[str]]])
|
|||||||
rel.url = "https://github.com/ezhh/handholds/archive/master.zip"
|
rel.url = "https://github.com/ezhh/handholds/archive/master.zip"
|
||||||
|
|
||||||
if minv:
|
if minv:
|
||||||
rel.min_rel = LuantiRelease.query.filter_by(name=minv).first()
|
rel.min_rel = MinetestRelease.query.filter_by(name=minv).first()
|
||||||
assert rel.min_rel
|
assert rel.min_rel
|
||||||
if maxv:
|
if maxv:
|
||||||
rel.max_rel = LuantiRelease.query.filter_by(name=maxv).first()
|
rel.max_rel = MinetestRelease.query.filter_by(name=maxv).first()
|
||||||
assert rel.max_rel
|
assert rel.max_rel
|
||||||
|
|
||||||
rel.approved = True
|
rel.approved = True
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ class MockEntry:
|
|||||||
self.causer = causer
|
self.causer = causer
|
||||||
self.created_at = datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z")
|
self.created_at = datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z")
|
||||||
self.title = title
|
self.title = title
|
||||||
self.description = None
|
|
||||||
self.package = MockPackage(package_id)
|
self.package = MockPackage(package_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ def test_cycle():
|
|||||||
support.on_update(modA)
|
support.on_update(modA)
|
||||||
|
|
||||||
assert support.all_errors == {
|
assert support.all_errors == {
|
||||||
|
"author/mod_b: Dependency cycle detected: author/mod_a -> author/mod_b -> author/mod_a",
|
||||||
|
"author/mod_a: Dependency cycle detected: author/mod_a -> author/mod_b -> author/mod_a",
|
||||||
|
"author/mod_b: Dependency cycle detected: author/mod_b -> author/mod_a -> author/mod_b",
|
||||||
|
"author/mod_a: Dependency cycle detected: author/mod_b -> author/mod_a -> author/mod_b",
|
||||||
"author/mod_b: Unable to fulfill dependency mod_a",
|
"author/mod_b: Unable to fulfill dependency mod_a",
|
||||||
"author/mod_a: Unable to fulfill dependency mod_b"
|
"author/mod_a: Unable to fulfill dependency mod_b"
|
||||||
}
|
}
|
||||||
@@ -232,6 +236,8 @@ def test_cycle_fails_safely():
|
|||||||
"author/mod_b: Unable to fulfill dependency mod_c",
|
"author/mod_b: Unable to fulfill dependency mod_c",
|
||||||
"author/mod_d: Unable to fulfill dependency mod_b",
|
"author/mod_d: Unable to fulfill dependency mod_b",
|
||||||
"author/mod_c: Unable to fulfill dependency mod_b",
|
"author/mod_c: Unable to fulfill dependency mod_b",
|
||||||
|
"author/mod_c: Dependency cycle detected: author/mod_b -> author/mod_c -> author/mod_b",
|
||||||
|
"author/mod_b: Dependency cycle detected: author/mod_b -> author/mod_c -> author/mod_b"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -376,6 +382,10 @@ def test_update_cycle():
|
|||||||
support.on_update(game1)
|
support.on_update(game1)
|
||||||
|
|
||||||
assert support.all_errors == {
|
assert support.all_errors == {
|
||||||
|
"author/mod_c: Dependency cycle detected: author/mod_a -> author/mod_c -> author/mod_a",
|
||||||
|
"author/mod_a: Dependency cycle detected: author/mod_a -> author/mod_c -> author/mod_a",
|
||||||
|
"author/mod_c: Dependency cycle detected: author/mod_c -> author/mod_a -> author/mod_c",
|
||||||
|
"author/mod_a: Dependency cycle detected: author/mod_c -> author/mod_a -> author/mod_c",
|
||||||
"author/mod_a: Unable to fulfill dependency mod_c",
|
"author/mod_a: Unable to fulfill dependency mod_c",
|
||||||
"author/mod_c: Unable to fulfill dependency mod_a"
|
"author/mod_c: Unable to fulfill dependency mod_a"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ def test_missing_hard_deps(get_forum_topic):
|
|||||||
mock_package = MockPackageHelper(PackageType.MOD)
|
mock_package = MockPackageHelper(PackageType.MOD)
|
||||||
mock_package.add_release()
|
mock_package.add_release()
|
||||||
mock_package.add_missing_hard_deps()
|
mock_package.add_missing_hard_deps()
|
||||||
mock_package.add_screenshot()
|
|
||||||
|
|
||||||
topic = MagicMock()
|
topic = MagicMock()
|
||||||
topic.author = mock_package.package.author
|
topic.author = mock_package.package.author
|
||||||
@@ -104,7 +103,6 @@ def test_requires_multiple_issues():
|
|||||||
mock_package.add_release()
|
mock_package.add_release()
|
||||||
mock_package.set_license("Other", "Other")
|
mock_package.set_license("Other", "Other")
|
||||||
mock_package.set_no_game_support()
|
mock_package.set_no_game_support()
|
||||||
mock_package.add_screenshot()
|
|
||||||
|
|
||||||
notes = package_approval.validate_package_for_approval(mock_package.package)
|
notes = package_approval.validate_package_for_approval(mock_package.package)
|
||||||
assert len(notes) == 5
|
assert len(notes) == 5
|
||||||
@@ -122,7 +120,6 @@ def test_requires_multiple_issues():
|
|||||||
def test_forum_topic_author_mismatch(get_forum_topic):
|
def test_forum_topic_author_mismatch(get_forum_topic):
|
||||||
mock_package = MockPackageHelper()
|
mock_package = MockPackageHelper()
|
||||||
mock_package.add_release()
|
mock_package.add_release()
|
||||||
mock_package.add_screenshot()
|
|
||||||
|
|
||||||
topic = MagicMock()
|
topic = MagicMock()
|
||||||
get_forum_topic.return_value = topic
|
get_forum_topic.return_value = topic
|
||||||
@@ -139,7 +136,6 @@ def test_forum_topic_author_mismatch(get_forum_topic):
|
|||||||
def test_passes(get_forum_topic):
|
def test_passes(get_forum_topic):
|
||||||
mock_package = MockPackageHelper()
|
mock_package = MockPackageHelper()
|
||||||
mock_package.add_release()
|
mock_package.add_release()
|
||||||
mock_package.add_screenshot()
|
|
||||||
|
|
||||||
topic = MagicMock()
|
topic = MagicMock()
|
||||||
topic.author = mock_package.package.author
|
topic.author = mock_package.package.author
|
||||||
@@ -172,8 +168,8 @@ def test_games_txp_must_have_unique_name(get_forum_topic):
|
|||||||
@patch("app.logic.package_approval.get_conflicting_mod_names", MagicMock(return_value=set()))
|
@patch("app.logic.package_approval.get_conflicting_mod_names", MagicMock(return_value=set()))
|
||||||
@patch("app.logic.package_approval.count_packages_with_forum_topic", MagicMock(return_value=1))
|
@patch("app.logic.package_approval.count_packages_with_forum_topic", MagicMock(return_value=1))
|
||||||
@patch("app.logic.package_approval.get_forum_topic")
|
@patch("app.logic.package_approval.get_forum_topic")
|
||||||
def test_require_screenshots(get_forum_topic):
|
def test_games_txp_require_screenshots(get_forum_topic):
|
||||||
mock_package = MockPackageHelper(PackageType.MOD)
|
mock_package = MockPackageHelper(PackageType.GAME)
|
||||||
mock_package.add_release()
|
mock_package.add_release()
|
||||||
|
|
||||||
topic = MagicMock()
|
topic = MagicMock()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import os
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.tasks.luanticheck.translation import parse_tr
|
from app.tasks.minetestcheck.translation import parse_tr
|
||||||
|
|
||||||
|
|
||||||
def test_parses_tr():
|
def test_parses_tr():
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from app.utils.luanti_hypertext import html_to_luanti
|
from app.utils.minetest_hypertext import html_to_minetest
|
||||||
|
|
||||||
|
|
||||||
conquer_html = """
|
conquer_html = """
|
||||||
@@ -74,7 +74,7 @@ page_url = "https://example.com/a/b/"
|
|||||||
|
|
||||||
|
|
||||||
def test_conquer():
|
def test_conquer():
|
||||||
assert html_to_luanti(conquer_html, page_url)["body"].strip() == conquer_expected.strip()
|
assert html_to_minetest(conquer_html, page_url)["body"].strip() == conquer_expected.strip()
|
||||||
|
|
||||||
|
|
||||||
def test_images():
|
def test_images():
|
||||||
@@ -83,7 +83,7 @@ def test_images():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
expected = "<img name=image_0 width=128 height=128>"
|
expected = "<img name=image_0 width=128 height=128>"
|
||||||
result = html_to_luanti(html, page_url)
|
result = html_to_minetest(html, page_url)
|
||||||
assert result["body"].strip() == expected.strip()
|
assert result["body"].strip() == expected.strip()
|
||||||
assert len(result["images"]) == 1
|
assert len(result["images"]) == 1
|
||||||
assert result["images"]["image_0"] == "https://example.com/path/to/img.png"
|
assert result["images"]["image_0"] == "https://example.com/path/to/img.png"
|
||||||
@@ -95,7 +95,7 @@ def test_images_removed():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
expected = "<action name=image_0><u>Image: alt</u></action>"
|
expected = "<action name=image_0><u>Image: alt</u></action>"
|
||||||
result = html_to_luanti(html, page_url, 7, False)
|
result = html_to_minetest(html, page_url, 7, False)
|
||||||
assert result["body"].strip() == expected.strip()
|
assert result["body"].strip() == expected.strip()
|
||||||
assert len(result["images"]) == 0
|
assert len(result["images"]) == 0
|
||||||
assert result["links"]["image_0"] == "https://example.com/path/to/img.png"
|
assert result["links"]["image_0"] == "https://example.com/path/to/img.png"
|
||||||
@@ -112,7 +112,7 @@ def test_links_relative_absolute():
|
|||||||
"<action name=link_1><u>Absolute</u></action> " \
|
"<action name=link_1><u>Absolute</u></action> " \
|
||||||
"<action name=link_2><u>Other domain</u></action>"
|
"<action name=link_2><u>Other domain</u></action>"
|
||||||
|
|
||||||
result = html_to_luanti(html, page_url, 7, False)
|
result = html_to_minetest(html, page_url, 7, False)
|
||||||
assert result["body"].strip() == expected.strip()
|
assert result["body"].strip() == expected.strip()
|
||||||
assert result["links"]["link_0"] == "https://example.com/a/b/relative"
|
assert result["links"]["link_0"] == "https://example.com/a/b/relative"
|
||||||
assert result["links"]["link_1"] == "https://example.com/absolute"
|
assert result["links"]["link_1"] == "https://example.com/absolute"
|
||||||
@@ -134,7 +134,7 @@ def test_bullets():
|
|||||||
"<img name=blank.png width=32 height=1>• sub two\n\n" \
|
"<img name=blank.png width=32 height=1>• sub two\n\n" \
|
||||||
"<img name=blank.png width=16 height=1>• four\n"
|
"<img name=blank.png width=16 height=1>• four\n"
|
||||||
|
|
||||||
result = html_to_luanti(html, page_url)
|
result = html_to_minetest(html, page_url)
|
||||||
assert result["body"].strip() == expected.strip()
|
assert result["body"].strip() == expected.strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ def test_table():
|
|||||||
expected = "<action name=link_0><u>(view table in browser)</u></action>\n\n" \
|
expected = "<action name=link_0><u>(view table in browser)</u></action>\n\n" \
|
||||||
"<b>Heading</b>\n" \
|
"<b>Heading</b>\n" \
|
||||||
"<action name=link_1><u>(view table in browser)</u></action>"
|
"<action name=link_1><u>(view table in browser)</u></action>"
|
||||||
result = html_to_luanti(html, page_url)
|
result = html_to_minetest(html, page_url)
|
||||||
assert result["body"].strip() == expected.strip()
|
assert result["body"].strip() == expected.strip()
|
||||||
assert result["links"]["link_0"] == f"{page_url}#with-id"
|
assert result["links"]["link_0"] == f"{page_url}#with-id"
|
||||||
assert result["links"]["link_1"] == f"{page_url}#heading"
|
assert result["links"]["link_1"] == f"{page_url}#heading"
|
||||||
@@ -170,7 +170,7 @@ def test_inline():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
expected = "<b>One <i>two</i> three</b>"
|
expected = "<b>One <i>two</i> three</b>"
|
||||||
result = html_to_luanti(html, page_url)
|
result = html_to_minetest(html, page_url)
|
||||||
assert result["body"].strip() == expected.strip()
|
assert result["body"].strip() == expected.strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ def test_escape():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
expected = r"<b>One <i>t\\w\<o\></i> three</b>"
|
expected = r"<b>One <i>t\\w\<o\></i> three</b>"
|
||||||
result = html_to_luanti(html, page_url)
|
result = html_to_minetest(html, page_url)
|
||||||
assert result["body"].strip() == expected.strip()
|
assert result["body"].strip() == expected.strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -190,5 +190,5 @@ def test_unknown_attr():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
expected = r"<action name=link_0><u>link</u></action>"
|
expected = r"<action name=link_0><u>link</u></action>"
|
||||||
result = html_to_luanti(html, page_url)
|
result = html_to_minetest(html, page_url)
|
||||||
assert result["body"].strip() == expected.strip()
|
assert result["body"].strip() == expected.strip()
|
||||||
@@ -32,9 +32,8 @@ def test_web_is_not_bot():
|
|||||||
"Chrome/125.0.0.0 Safari/537.36").is_bot
|
"Chrome/125.0.0.0 Safari/537.36").is_bot
|
||||||
|
|
||||||
|
|
||||||
def test_luanti_is_not_bot():
|
def test_minetest_is_not_bot():
|
||||||
assert not user_agents.parse("Minetest/5.5.1 (Linux/4.14.193+-ab49821 aarch64)").is_bot
|
assert not user_agents.parse("Minetest/5.5.1 (Linux/4.14.193+-ab49821 aarch64)").is_bot
|
||||||
assert not user_agents.parse("Luanti/5.12.0 (Linux/4.14.193+-ab49821 aarch64)").is_bot
|
|
||||||
|
|
||||||
|
|
||||||
def test_crawlers_are_bots():
|
def test_crawlers_are_bots():
|
||||||
|
|||||||
30
app/tests/unit/utils/test_version.py
Normal file
30
app/tests/unit/utils/test_version.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# 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 app.utils.version import is_minetest_v510
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_minetest_v510():
|
||||||
|
assert not is_minetest_v510("Minetest/5.9.1 (Windows/10.0.22621 x86_64)")
|
||||||
|
assert not is_minetest_v510("Minetest/")
|
||||||
|
assert not is_minetest_v510("Minetest/5.9.1")
|
||||||
|
|
||||||
|
assert is_minetest_v510("Minetest/5.10.0")
|
||||||
|
assert is_minetest_v510("Minetest/5.10.1")
|
||||||
|
assert is_minetest_v510("Minetest/5.11.0")
|
||||||
|
assert is_minetest_v510("Minetest/5.10")
|
||||||
|
|
||||||
|
assert not is_minetest_v510("Minetest/6.12")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user