Compare commits

..

1 Commits

Author SHA1 Message Date
rubenwardy
27e2b64e41 WIP prototype oauth scopes 2023-11-01 01:07:23 +00:00
337 changed files with 42948 additions and 232106 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1,5 +1,4 @@
# These are supported funding model platforms # These are supported funding model platforms
liberapay: rubenwardy
patreon: rubenwardy patreon: rubenwardy
custom: [ "https://rubenwardy.com/donate/" ] custom: [ "https://rubenwardy.com/donate/" ]

7
.github/SECURITY.md vendored
View File

@@ -2,8 +2,8 @@
## Supported Versions ## Supported Versions
We only support the latest production version, deployed to <https://content.luanti.org>. We only support the latest production version, deployed to <https://content.minetest.net>.
This is usually the latest `master` commit. See the [releases page](https://github.com/minetest/contentdb/releases).
## Reporting a Vulnerability ## Reporting a Vulnerability
@@ -12,5 +12,8 @@ to give us time to fix them. You can do that by using one of the methods outline
* https://rubenwardy.com/contact/ * https://rubenwardy.com/contact/
Depending on severity, we will either create a private issue for the vulnerability
and release a security update, or give you permission to file the issue publicly.
For more information on the justification of this policy, see For more information on the justification of this policy, see
[Responsible Disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure). [Responsible Disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure).

View File

@@ -6,9 +6,7 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Install docker-compose - uses: actions/checkout@v2
run: sudo apt-get install -y docker-compose
- uses: actions/checkout@v4
- name: Copy config - name: Copy config
run: cp utils/ci/* . run: cp utils/ci/* .
- name: Build the Docker image - name: Build the Docker image

View File

@@ -1,20 +1,16 @@
FROM python:3.10.11-alpine FROM python:3.10.11
RUN addgroup --gid 5123 cdb && \ RUN groupadd -g 5123 cdb && \
adduser --uid 5123 -S cdb -G cdb useradd -r -u 5123 -g cdb cdb
WORKDIR /home/cdb WORKDIR /home/cdb
RUN \
apk add --no-cache postgresql-libs git bash unzip && \
apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev g++
RUN mkdir /var/cdb RUN mkdir /var/cdb
RUN chown -R cdb:cdb /var/cdb RUN chown -R cdb:cdb /var/cdb
COPY requirements.lock.txt requirements.lock.txt COPY requirements.lock.txt requirements.lock.txt
RUN pip install -r requirements.lock.txt && \ RUN pip install -r requirements.lock.txt
pip install gunicorn RUN pip install gunicorn
COPY utils utils COPY utils utils
COPY config.cfg config.cfg COPY config.cfg config.cfg

View File

@@ -1,7 +1,7 @@
# ContentDB # ContentDB
![Build Status](https://github.com/luanti-org/contentdb/actions/workflows/test.yml/badge.svg) ![Build Status](https://github.com/minetest/contentdb/actions/workflows/test.yml/badge.svg)
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.
@@ -29,9 +29,6 @@ See [Developer Intro](docs/dev_intro.md) for an overview of the code organisatio
# Create new migration # Create new migration
./utils/create_migration.sh ./utils/create_migration.sh
# Delete database
docker-compose down && sudo rm -rf data/db
``` ```
@@ -82,7 +79,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

View File

@@ -18,66 +18,25 @@ import datetime
import os import os
import redis 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
from flask_babel import Babel, gettext from flask_babel import Babel, gettext
from flask_flatpages import FlatPages from flask_flatpages import FlatPages
from flask_github import GitHub from flask_github import GitHub
from flask_gravatar import Gravatar
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
from sentry_sdk.integrations.flask import FlaskIntegration
if os.getenv("SENTRY_DSN"):
def before_send(event, hint):
from app.tasks import TaskError
if "exc_info" in hint:
exc_type, exc_value, tb = hint["exc_info"]
if isinstance(exc_value, TaskError):
return None
return event
environment = os.getenv("SENTRY_ENVIRONMENT")
assert environment is not None
sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN"),
environment=environment,
integrations=[FlaskIntegration()],
# Set traces_sample_rate to 1.0 to capture 100%
# of transactions for performance monitoring.
traces_sample_rate=0.1,
# Set profiles_sample_rate to 1.0 to profile 100%
# of sampled transactions.
# We recommend adjusting this value in production.
profiles_sample_rate=0.1,
before_send=before_send,
)
app = Flask(__name__, static_folder="public/static") app = Flask(__name__, static_folder="public/static")
def my_flatpage_renderer(text):
# Render with jinja first
prerendered_body = render_template_string(text)
return render_markdown(prerendered_body, clean=False)
app.config["FLATPAGES_ROOT"] = "flatpages" app.config["FLATPAGES_ROOT"] = "flatpages"
app.config["FLATPAGES_EXTENSION"] = ".md" app.config["FLATPAGES_EXTENSION"] = ".md"
app.config["FLATPAGES_HTML_RENDERER"] = my_flatpage_renderer app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = MARKDOWN_EXTENSIONS
app.config["WTF_CSRF_TIME_LIMIT"] = None app.config["FLATPAGES_EXTENSION_CONFIG"] = MARKDOWN_EXTENSION_CONFIG
app.config["BABEL_TRANSLATION_DIRECTORIES"] = "../translations" app.config["BABEL_TRANSLATION_DIRECTORIES"] = "../translations"
app.config["LANGUAGES"] = { app.config["LANGUAGES"] = {
"en": "English", "en": "English",
"cs": "čeština",
"de": "Deutsch", "de": "Deutsch",
"es": "Español", "es": "Español",
"fr": "Français", "fr": "Français",
@@ -88,18 +47,14 @@ app.config["LANGUAGES"] = {
"ru": "русский язык", "ru": "русский язык",
"sk": "Slovenčina", "sk": "Slovenčina",
"sv": "Svenska", "sv": "Svenska",
"ta": "தமிழ்",
"tr": "Türkçe", "tr": "Türkçe",
"uk": "Українська", "uk": "Українська",
"vi": "tiếng Việt", "vi": "tiếng Việt",
"zh_CN": "汉语", "zh_Hans": "汉语",
} }
app.config.from_pyfile(os.environ["FLASK_CONFIG"]) app.config.from_pyfile(os.environ["FLASK_CONFIG"])
if not app.config["ADMIN_CONTACT_URL"]:
raise Exception("Missing config property: ADMIN_CONTACT_URL")
redis_client = redis.Redis.from_url(app.config["REDIS_URL"]) redis_client = redis.Redis.from_url(app.config["REDIS_URL"])
github = GitHub(app) github = GitHub(app)
@@ -107,6 +62,14 @@ csrf = CSRFProtect(app)
mail = Mail(app) mail = Mail(app)
pages = FlatPages(app) pages = FlatPages(app)
babel = Babel() babel = Babel()
gravatar = Gravatar(app,
size=64,
rating="g",
default="retro",
force_default=False,
force_lower=False,
use_ssl=True,
base_url=None)
init_markdown(app) init_markdown(app)
login_manager = LoginManager() login_manager = LoginManager()
@@ -118,6 +81,11 @@ from .sass import init_app as sass
sass(app) sass(app)
if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
from .maillogger import build_handler
app.logger.addHandler(build_handler(app))
from . import models, template_filters from . import models, template_filters
@@ -150,7 +118,7 @@ def check_for_ban():
if current_user.rank == models.UserRank.BANNED: if current_user.rank == models.UserRank.BANNED:
current_user.rank = models.UserRank.MEMBER current_user.rank = models.UserRank.MEMBER
models.db.session.commit() models.db.session.commit()
elif current_user.is_banned: elif current_user.ban or current_user.rank == models.UserRank.BANNED:
if current_user.ban: if current_user.ban:
flash(gettext("Banned:") + " " + current_user.ban.message, "danger") flash(gettext("Banned:") + " " + current_user.ban.message, "danger")
else: else:
@@ -167,7 +135,8 @@ from .utils import clear_notifications, is_safe_url, create_session
@app.before_request @app.before_request
def check_for_notifications(): def check_for_notifications():
clear_notifications(request.path) if current_user.is_authenticated:
clear_notifications(request.path)
@app.errorhandler(404) @app.errorhandler(404)
@@ -223,23 +192,10 @@ def set_locale():
if locale: if locale:
expire_date = datetime.datetime.now() expire_date = datetime.datetime.now()
expire_date = expire_date + datetime.timedelta(days=5*365) expire_date = expire_date + datetime.timedelta(days=5*365)
resp.set_cookie("locale", locale, expires=expire_date, secure=True, samesite="Lax") resp.set_cookie("locale", locale, expires=expire_date)
if current_user.is_authenticated: if current_user.is_authenticated:
current_user.locale = locale current_user.locale = locale
models.db.session.commit() models.db.session.commit()
return resp return resp
@app.route("/set-nonfree/", methods=["POST"])
def set_nonfree():
resp = redirect(url_for("homepage.home"))
if request.cookies.get("hide_nonfree") == "1":
resp.set_cookie("hide_nonfree", "0", expires=0, secure=True, samesite="Lax")
else:
expire_date = datetime.datetime.now()
expire_date = expire_date + datetime.timedelta(days=5*365)
resp.set_cookie("hide_nonfree", "1", expires=expire_date, secure=True, samesite="Lax")
return resp

View File

@@ -1,252 +0,0 @@
# THIS FILE IS AUTOGENERATED: utils/extract_translations.py
from flask_babel import pgettext
# NOTE: tags: title for 128px
pgettext("tags", "128px+")
# NOTE: tags: description for 128px
pgettext("tags", "For 128px or higher texture packs")
# NOTE: tags: title for 16px
pgettext("tags", "16px")
# NOTE: tags: description for 16px
pgettext("tags", "For 16px texture packs")
# NOTE: tags: title for 32px
pgettext("tags", "32px")
# NOTE: tags: description for 32px
pgettext("tags", "For 32px texture packs")
# NOTE: tags: title for 64px
pgettext("tags", "64px")
# NOTE: tags: description for 64px
pgettext("tags", "For 64px texture packs")
# NOTE: tags: title for adventure__rpg
pgettext("tags", "Adventure / RPG")
# NOTE: tags: title for april_fools
pgettext("tags", "Joke")
# NOTE: tags: description for april_fools
pgettext("tags", "For humorous content, meant as a novelty or joke, not to be taken seriously, and that is not meant to be used seriously or long-term.")
# NOTE: tags: title for building
pgettext("tags", "Building")
# NOTE: tags: description for building
pgettext("tags", "Focuses on building, such as adding new materials or nodes")
# NOTE: tags: title for building_mechanics
pgettext("tags", "Building Mechanics and Tools")
# NOTE: tags: description for building_mechanics
pgettext("tags", "Adds game mechanics or tools that change how players build.")
# NOTE: tags: title for chat
pgettext("tags", "Chat / Commands")
# NOTE: tags: description for chat
pgettext("tags", "Focus on player chat/communication or console interaction.")
# NOTE: tags: title for commerce
pgettext("tags", "Commerce / Economy")
# NOTE: tags: description for commerce
pgettext("tags", "Related to economies, money, and trading")
# NOTE: tags: title for complex_installation
pgettext("tags", "Complex installation")
# NOTE: tags: description for complex_installation
pgettext("tags", "Requires futher installation steps, such as installing LuaRocks or editing the trusted mod setting")
# NOTE: tags: title for crafting
pgettext("tags", "Crafting")
# NOTE: tags: description for crafting
pgettext("tags", "Big changes to crafting gameplay")
# NOTE: tags: title for creative
pgettext("tags", "Creative")
# NOTE: tags: description for creative
pgettext("tags", "Written specifically or exclusively for use in creative mode. Adds content only available through a creative inventory, or provides tools that facilitate ingame creation and doesn't add difficulty or scarcity")
# NOTE: tags: title for custom_mapgen
pgettext("tags", "Custom mapgen")
# NOTE: tags: description for custom_mapgen
pgettext("tags", "Contains a completely custom mapgen implemented in Lua, usually requires worlds to be set to the 'singlenode' mapgen.")
# NOTE: tags: title for decorative
pgettext("tags", "Decorative")
# NOTE: tags: description for decorative
pgettext("tags", "Adds nodes with no other purpose than for use in building")
# NOTE: tags: title for developer_tools
pgettext("tags", "Developer Tools")
# NOTE: tags: description for developer_tools
pgettext("tags", "Tools for game and mod developers")
# NOTE: tags: title for education
pgettext("tags", "Education")
# NOTE: tags: description for education
pgettext("tags", "Either has educational value, or is a tool to help teachers ")
# NOTE: tags: title for environment
pgettext("tags", "Environment / Weather")
# NOTE: tags: description for environment
pgettext("tags", "Improves the world, adding weather, ambient sounds, or other environment mechanics")
# NOTE: tags: title for food
pgettext("tags", "Food / Drinks")
# NOTE: tags: title for gui
pgettext("tags", "GUI")
# NOTE: tags: description for gui
pgettext("tags", "For content whose main utility or features are provided within a GUI, on-screen menu, or similar")
# NOTE: tags: title for hud
pgettext("tags", "HUD")
# NOTE: tags: description for hud
pgettext("tags", "For mods that grant the player extra information in the HUD")
# NOTE: tags: title for inventory
pgettext("tags", "Inventory")
# NOTE: tags: description for inventory
pgettext("tags", "Changes the inventory GUI")
# NOTE: tags: title for jam_combat_mod
pgettext("tags", "Jam / Combat 2020")
# NOTE: tags: description for jam_combat_mod
pgettext("tags", "For mods created for the Discord \"Combat\" modding event in 2020")
# NOTE: tags: title for jam_game_2021
pgettext("tags", "Jam / Game 2021")
# NOTE: tags: description for jam_game_2021
pgettext("tags", "Entries to the 2021 Minetest Game Jam")
# NOTE: tags: title for jam_game_2022
pgettext("tags", " Jam / Game 2022")
# NOTE: tags: description for jam_game_2022
pgettext("tags", "Entries to the 2022 Minetest Game Jam ")
# NOTE: tags: title for jam_game_2023
pgettext("tags", "Jam / Game 2023")
# NOTE: tags: description for jam_game_2023
pgettext("tags", "Entries to the 2023 Minetest Game Jam ")
# NOTE: tags: title for jam_game_2024
pgettext("tags", "Jam / Game 2024")
# NOTE: tags: description for jam_game_2024
pgettext("tags", "Entries to the 2024 Luanti Game Jam")
# NOTE: tags: title for jam_weekly_2021
pgettext("tags", "Jam / Weekly Challenges 2021")
# NOTE: tags: description for jam_weekly_2021
pgettext("tags", "For mods created for the Discord \"Weekly Challenges\" modding event in 2021")
# NOTE: tags: title for less_than_px
pgettext("tags", "<16px")
# NOTE: tags: description for less_than_px
pgettext("tags", "For less than 16px texture packs ")
# NOTE: tags: title for library
pgettext("tags", "API / Library")
# NOTE: tags: description for library
pgettext("tags", "Primarily adds an API for other mods to use")
# NOTE: tags: title for magic
pgettext("tags", "Magic / Enchanting")
# NOTE: tags: title for mapgen
pgettext("tags", "Mapgen / Biomes / Decoration")
# NOTE: tags: description for mapgen
pgettext("tags", "New mapgen or changes mapgen")
# NOTE: tags: title for mini-game
pgettext("tags", "Mini-game")
# NOTE: tags: description for mini-game
pgettext("tags", "Adds a mini-game to be played within Luanti")
# NOTE: tags: title for mobs
pgettext("tags", "Mobs / Animals / NPCs")
# NOTE: tags: description for mobs
pgettext("tags", "Adds mobs, animals, and non-player characters")
# NOTE: tags: title for mtg
pgettext("tags", "Minetest Game improved")
# NOTE: tags: description for mtg
pgettext("tags", "Forks of Minetest Game")
# NOTE: tags: title for multiplayer
pgettext("tags", "Multiplayer-focused")
# NOTE: tags: description for multiplayer
pgettext("tags", "Can/should only be used in multiplayer")
# NOTE: tags: title for oneofakind__original
pgettext("tags", "One-of-a-kind / Original")
# NOTE: tags: description for oneofakind__original
pgettext("tags", "For games and such that are of their own kind, distinct and original in nature to others of the same category.")
# NOTE: tags: title for plants_and_farming
pgettext("tags", "Plants and Farming")
# NOTE: tags: description for plants_and_farming
pgettext("tags", "Adds new plants or other farmable resources.")
# NOTE: tags: title for player_effects
pgettext("tags", "Player Effects / Power Ups")
# NOTE: tags: description for player_effects
pgettext("tags", "For content that changes player effects, including physics, for example: speed, jump height or gravity.")
# NOTE: tags: title for puzzle
pgettext("tags", "Puzzle")
# NOTE: tags: description for puzzle
pgettext("tags", "Focus on puzzle solving instead of combat")
# NOTE: tags: title for pve
pgettext("tags", "Player vs Environment (PvE)")
# NOTE: tags: description for pve
pgettext("tags", "For content designed for one or more players that focus on combat against the world, mobs, or NPCs.")
# NOTE: tags: title for pvp
pgettext("tags", "Player vs Player (PvP)")
# NOTE: tags: description for pvp
pgettext("tags", "Designed to be played competitively against other players")
# NOTE: tags: title for seasonal
pgettext("tags", "Seasonal")
# NOTE: tags: description for seasonal
pgettext("tags", "For content generally themed around a certain season or holiday")
# NOTE: tags: title for server_tools
pgettext("tags", "Server Moderation and Tools")
# NOTE: tags: description for server_tools
pgettext("tags", "Helps with server maintenance and moderation")
# NOTE: tags: title for shooter
pgettext("tags", "Shooter")
# NOTE: tags: description for shooter
pgettext("tags", "First person shooters (FPS) and more")
# NOTE: tags: title for simulation
pgettext("tags", "Sims")
# NOTE: tags: description for simulation
pgettext("tags", "Mods and games that aim to simulate real life activity. Similar to SimCity/The Sims/OpenTTD/etc.")
# NOTE: tags: title for singleplayer
pgettext("tags", "Singleplayer-focused")
# NOTE: tags: description for singleplayer
pgettext("tags", "Content that can be played alone")
# NOTE: tags: title for skins
pgettext("tags", "Player customization / Skins")
# NOTE: tags: description for skins
pgettext("tags", "Allows the player to customize their character by changing the texture or adding accessories.")
# NOTE: tags: title for sound_music
pgettext("tags", "Sounds / Music")
# NOTE: tags: description for sound_music
pgettext("tags", "Focuses on or adds new sounds or musical things")
# NOTE: tags: title for sports
pgettext("tags", "Sports")
# NOTE: tags: title for storage
pgettext("tags", "Storage")
# NOTE: tags: description for storage
pgettext("tags", "Adds or improves item storage mechanics")
# NOTE: tags: title for strategy_rts
pgettext("tags", "Strategy / RTS")
# NOTE: tags: description for strategy_rts
pgettext("tags", "Games and mods with a heavy strategy component, whether real-time or turn-based")
# NOTE: tags: title for survival
pgettext("tags", "Survival")
# NOTE: tags: description for survival
pgettext("tags", "Written specifically for survival gameplay with a focus on game-balance, difficulty level, or resources available through crafting, mining, ...")
# NOTE: tags: title for technology
pgettext("tags", "Machines / Electronics")
# NOTE: tags: description for technology
pgettext("tags", "Adds machines useful in automation, tubes, or power.")
# NOTE: tags: title for tools
pgettext("tags", "Tools / Weapons / Armor")
# NOTE: tags: description for tools
pgettext("tags", "Adds or changes tools, weapons, and armor")
# NOTE: tags: title for transport
pgettext("tags", "Transport")
# NOTE: tags: description for transport
pgettext("tags", "Adds or changes transportation methods. Includes teleportation, vehicles, ridable mobs, transport infrastructure and thematic content")
# NOTE: tags: title for world_tools
pgettext("tags", "World Maintenance and Tools")
# NOTE: tags: description for world_tools
pgettext("tags", "Tools to manage the world")
# NOTE: content_warnings: title for alcohol_tobacco
pgettext("content_warnings", "Alcohol / Tobacco")
# NOTE: content_warnings: description for alcohol_tobacco
pgettext("content_warnings", "Contains alcohol and/or tobacco")
# NOTE: content_warnings: title for bad_language
pgettext("content_warnings", "Bad Language")
# NOTE: content_warnings: description for bad_language
pgettext("content_warnings", "Contains swearing")
# NOTE: content_warnings: title for drugs
pgettext("content_warnings", "Drugs")
# NOTE: content_warnings: description for drugs
pgettext("content_warnings", "Contains recreational drugs other than alcohol or tobacco")
# NOTE: content_warnings: title for gambling
pgettext("content_warnings", "Gambling")
# NOTE: content_warnings: description for gambling
pgettext("content_warnings", "Games of chance, gambling games, etc")
# NOTE: content_warnings: title for gore
pgettext("content_warnings", "Gore")
# NOTE: content_warnings: description for gore
pgettext("content_warnings", "Blood, etc")
# NOTE: content_warnings: title for horror
pgettext("content_warnings", "Fear / Horror")
# NOTE: content_warnings: description for horror
pgettext("content_warnings", "Shocking and scary content. May scare young children")
# NOTE: content_warnings: title for violence
pgettext("content_warnings", "Violence")
# NOTE: content_warnings: description for violence
pgettext("content_warnings", "Non-cartoon violence. May be towards fantasy or human-like characters")

View File

@@ -19,4 +19,4 @@ from flask import Blueprint
bp = Blueprint("admin", __name__) bp = Blueprint("admin", __name__)
from . import admin, audit, licenseseditor, tagseditor, versioneditor, warningseditor, languageseditor, email, approval_stats from . import admin, audit, licenseseditor, tagseditor, versioneditor, warningseditor, email

View File

@@ -13,23 +13,20 @@
# #
# 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 datetime
import os import os
from typing import List 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
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
from app.tasks.usertasks import import_github_user_ids, do_delete_likely_spammers
from app.tasks.pkgtasks import notify_about_git_forum_links, clear_removed_packages, check_package_for_broken_links, update_file_size_bytes
from app.utils import add_notification, get_system_user from app.utils import add_notification, get_system_user
actions = {} actions = {}
@@ -54,6 +51,19 @@ def del_stuck_releases():
db.session.commit() db.session.commit()
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))
@action("Import forum topic list")
def import_topic_list():
task = import_topic_list.delay()
return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
@action("Check all forum accounts")
def check_all_forum_accounts():
task = check_all_forum_accounts.delay()
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
@action("Delete unused uploads") @action("Delete unused uploads")
def clean_uploads(): def clean_uploads():
upload_dir = current_app.config['UPLOAD_DIR'] upload_dir = current_app.config['UPLOAD_DIR']
@@ -68,10 +78,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
@@ -100,29 +109,6 @@ def del_mod_names():
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))
@action("Recalc package scores")
def recalc_scores():
for package in Package.query.all():
package.recalculate_score()
db.session.commit()
flash("Recalculated package scores", "success")
return redirect(url_for("admin.admin_page"))
@action("Import forum topic list")
def do_import_topic_list():
task = import_topic_list.delay()
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
@action("Check all forum accounts")
def check_all_forum_accounts():
task = check_all_forum_accounts.delay()
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
@action("Run update configs") @action("Run update configs")
def run_update_config(): def run_update_config():
check_for_updates.delay() check_for_updates.delay()
@@ -135,9 +121,10 @@ def _package_list(packages: List[str]):
# Who needs translations? # Who needs translations?
if len(packages) >= 3: if len(packages) >= 3:
packages[len(packages) - 1] = "and " + packages[len(packages) - 1] packages[len(packages) - 1] = "and " + packages[len(packages) - 1]
return ", ".join(packages) packages_list = ", ".join(packages)
else: else:
return " and ".join(packages) packages_list = " and ".join(packages)
return packages_list
@action("Send WIP package notification") @action("Send WIP package notification")
@@ -146,12 +133,12 @@ def remind_wip():
Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED))) Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)))
system_user = get_system_user() system_user = get_system_user()
for user in users: for user in users:
packages = Package.query.filter( packages = db.session.query(Package.title).filter(
Package.author_id == user.id, Package.author_id == user.id,
or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)) \ or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)) \
.all() .all()
packages = [pkg.title for pkg in packages] packages = [pkg[0] for pkg in packages]
packages_list = _package_list(packages) packages_list = _package_list(packages)
havent = "haven't" if len(packages) > 1 else "hasn't" havent = "haven't" if len(packages) > 1 else "hasn't"
@@ -167,12 +154,12 @@ def remind_outdated():
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None)))) Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))))
system_user = get_system_user() system_user = get_system_user()
for user in users: for user in users:
packages = Package.query.filter( packages = db.session.query(Package.title).filter(
Package.maintainers.contains(user), Package.maintainers.contains(user),
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \ Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
.all() .all()
packages = [pkg.title for pkg in packages] packages = [pkg[0] for pkg in packages]
packages_list = _package_list(packages) packages_list = _package_list(packages)
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL, add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
@@ -244,15 +231,15 @@ def remind_video_url():
and_(Package.video_url == None, Package.type == PackageType.GAME, Package.state == PackageState.APPROVED))) and_(Package.video_url == None, Package.type == PackageType.GAME, Package.state == PackageState.APPROVED)))
system_user = get_system_user() system_user = get_system_user()
for user in users: for user in users:
packages = Package.query.filter( packages = db.session.query(Package.title).filter(
or_(Package.author == user, Package.maintainers.contains(user)), or_(Package.author == user, Package.maintainers.contains(user)),
Package.video_url == None, Package.video_url == None,
Package.type == PackageType.GAME, Package.type == PackageType.GAME,
Package.state == PackageState.APPROVED) \ Package.state == PackageState.APPROVED) \
.all() .all()
package_names = [pkg.title for pkg in packages] packages = [pkg[0] for pkg in packages]
packages_list = _package_list(package_names) packages_list = _package_list(packages)
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL, add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
f"You should add a video to {packages_list}", f"You should add a video to {packages_list}",
@@ -272,7 +259,7 @@ def remind_missing_game_support():
system_user = get_system_user() system_user = get_system_user()
for user in users: for user in users:
packages = Package.query.filter( packages = db.session.query(Package.title).filter(
Package.maintainers.contains(user), Package.maintainers.contains(user),
Package.state != PackageState.DELETED, Package.state != PackageState.DELETED,
Package.type.in_([PackageType.MOD, PackageType.TXP]), Package.type.in_([PackageType.MOD, PackageType.TXP]),
@@ -280,7 +267,7 @@ def remind_missing_game_support():
Package.supports_all_games == False) \ Package.supports_all_games == False) \
.all() .all()
packages = [pkg.title for pkg in packages] packages = [pkg[0] for pkg in packages]
packages_list = _package_list(packages) packages_list = _package_list(packages)
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL, add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
@@ -302,46 +289,17 @@ def do_send_pending_digests():
send_pending_digests.delay() send_pending_digests.delay()
@action("Import user ids from GitHub") @action("DANGER: Delete removed packages")
def do_import_github_user_ids():
task_id = uuid()
import_github_user_ids.apply_async((), task_id=task_id)
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
@action("Notify about links to git/forums instead of CDB")
def do_notify_git_forums_links():
task_id = uuid()
notify_about_git_forum_links.apply_async((), task_id=task_id)
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
@action("Check all zip files")
def do_check_all_zip_files():
task_id = uuid()
check_all_zip_files.apply_async((), task_id=task_id)
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")
def del_less_popular_removed_packages():
task_id = uuid()
clear_removed_packages.apply_async((False, ), task_id=task_id)
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
@action("DANGER: Delete all removed packages")
def del_removed_packages(): def del_removed_packages():
task_id = uuid() query = Package.query.filter_by(state=PackageState.DELETED)
clear_removed_packages.apply_async((True, ), task_id=task_id) count = query.count()
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page"))) for pkg in query.all():
pkg.review_thread = None
db.session.delete(pkg)
db.session.commit()
flash("Deleted {} soft deleted packages packages".format(count), "success")
return redirect(url_for("admin.admin_page"))
@action("DANGER: Check all releases (postReleaseCheckUpdate)") @action("DANGER: Check all releases (postReleaseCheckUpdate)")
@@ -364,7 +322,7 @@ def check_releases():
@action("DANGER: Check latest release of all packages (postReleaseCheckUpdate)") @action("DANGER: Check latest release of all packages (postReleaseCheckUpdate)")
def reimport_packages(): def reimport_packages():
tasks = [] tasks = []
for package in Package.query.filter(Package.state == PackageState.APPROVED).all(): for package in Package.query.filter(Package.state != PackageState.DELETED).all():
release = package.releases.first() release = package.releases.first()
if release: if release:
tasks.append(check_zip_release.s(release.id, release.file_path)) tasks.append(check_zip_release.s(release.id, release.file_path))
@@ -378,22 +336,6 @@ def reimport_packages():
return redirect(url_for("todo.view_editor")) return redirect(url_for("todo.view_editor"))
@action("DANGER: Import translations")
def reimport_translations():
tasks = []
for package in Package.query.filter(Package.state == PackageState.APPROVED).all():
release = package.releases.first()
if release:
tasks.append(import_languages.s(release.id, release.file_path))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
@action("DANGER: Import screenshots from Git") @action("DANGER: Import screenshots from Git")
def import_screenshots(): def import_screenshots():
packages = Package.query \ packages = Package.query \
@@ -405,30 +347,3 @@ def import_screenshots():
import_repo_screenshot.delay(package.id) import_repo_screenshot.delay(package.id)
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))
@action("DANGER: Delete empty threads")
def delete_empty_threads():
query = Thread.query.filter(~Thread.replies.any())
count = query.count()
for thread in query.all():
thread.watchers.clear()
db.session.delete(thread)
db.session.commit()
flash(f"Deleted {count} threads", "success")
return redirect(url_for("admin.admin_page"))
@action("DANGER: Check for broken links in all packages")
def check_for_broken_links():
for package in Package.query.filter_by(state=PackageState.APPROVED).all():
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")))

View File

@@ -19,19 +19,16 @@ from flask_login import current_user, login_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, BooleanField 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
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
@bp.route("/admin/", methods=["GET", "POST"]) @bp.route("/admin/", methods=["GET", "POST"])
@rank_required(UserRank.EDITOR) @rank_required(UserRank.ADMIN)
def admin_page(): def admin_page():
if request.method == "POST" and current_user.rank.at_least(UserRank.ADMIN): if request.method == "POST":
action = request.form["action"] action = request.form["action"]
if action in actions: if action in actions:
ret = actions[action]["func"]() ret = actions[action]["func"]()
@@ -41,7 +38,8 @@ def admin_page():
else: else:
flash("Unknown action: " + action, "danger") flash("Unknown action: " + action, "danger")
return render_template("admin/list.html", actions=actions) deleted_packages = Package.query.filter(Package.state == PackageState.DELETED).all()
return render_template("admin/list.html", deleted_packages=deleted_packages, actions=actions)
class SwitchUserForm(FlaskForm): class SwitchUserForm(FlaskForm):
@@ -54,7 +52,7 @@ class SwitchUserForm(FlaskForm):
def switch_user(): def switch_user():
form = SwitchUserForm(formdata=request.form) form = SwitchUserForm(formdata=request.form)
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first() user = User.query.filter_by(username=form["username"].data).first()
if user is None: if user is None:
flash("Unable to find user", "danger") flash("Unable to find user", "danger")
elif login_user(user): elif login_user(user):
@@ -181,43 +179,3 @@ def transfer():
# Process GET or invalid POST # Process GET or invalid POST
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/")
@rank_required(UserRank.EDITOR)
def storage():
qb = QueryBuilder(request.args, cookies=True)
qb.only_approved = False
packages = qb.build_package_query().all()
show_all = len(packages) < 100
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 = []
for package in packages:
size_releases = package_size_releases.get(package.id, 0)
size_screenshots = package_size_screenshots.get(package.id, 0)
size_total = size_releases + size_screenshots
if size_total < min_size * 1024 * 1024:
continue
latest_release = package.releases.first()
size_latest = latest_release.file_size_bytes if latest_release else 0
data.append([package, size_total, size_releases, size_screenshots, size_latest])
data.sort(key=lambda x: x[1], reverse=True)
return render_template("admin/storage.html", data=data)

View File

@@ -1,77 +0,0 @@
# ContentDB
# Copyright (C) 2024 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from flask import render_template, request, abort, redirect, url_for, jsonify
from . import bp
from app.logic.approval_stats import get_approval_statistics
from app.models import UserRank
from app.utils import rank_required
@bp.route("/admin/approval_stats/")
@rank_required(UserRank.APPROVER)
def approval_stats():
start = request.args.get("start")
end = request.args.get("end")
if start and end:
try:
start = datetime.datetime.fromisoformat(start)
end = datetime.datetime.fromisoformat(end)
except ValueError:
abort(400)
elif start:
return redirect(url_for("admin.approval_stats", start=start, end=datetime.datetime.utcnow().date().isoformat()))
elif end:
return redirect(url_for("admin.approval_stats", start="2020-07-01", end=end))
else:
end = datetime.datetime.utcnow()
start = end - datetime.timedelta(days=365)
stats = get_approval_statistics(start, end)
return render_template("admin/approval_stats.html", stats=stats, start=start, end=end)
@bp.route("/admin/approval_stats.json")
@rank_required(UserRank.APPROVER)
def approval_stats_json():
start = request.args.get("start")
end = request.args.get("end")
if start and end:
try:
start = datetime.datetime.fromisoformat(start)
end = datetime.datetime.fromisoformat(end)
except ValueError:
abort(400)
else:
end = datetime.datetime.utcnow()
start = end - datetime.timedelta(days=365)
stats = get_approval_statistics(start, end)
for key, value in stats.packages_info.items():
stats.packages_info[key] = value.__dict__()
return jsonify({
"start": start.isoformat(),
"end": end.isoformat(),
"editor_approvals": stats.editor_approvals,
"packages_info": stats.packages_info,
"turnaround_time": {
"avg": stats.avg_turnaround_time,
"max": stats.max_turnaround_time,
},
})

View File

@@ -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,22 @@ 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:
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_>/")

View File

@@ -22,14 +22,14 @@ from wtforms.validators import InputRequired, Length
from app.markdown import render_markdown from app.markdown import render_markdown
from app.tasks.emails import send_user_email, send_bulk_email as task_send_bulk from app.tasks.emails import send_user_email, send_bulk_email as task_send_bulk
from app.utils import rank_required, add_audit_log, normalize_line_endings from app.utils import rank_required, add_audit_log
from . import bp from . import bp
from app.models import UserRank, User, AuditSeverity from app.models import UserRank, User, AuditSeverity
class SendEmailForm(FlaskForm): class SendEmailForm(FlaskForm):
subject = StringField("Subject", [InputRequired(), Length(1, 300)]) subject = StringField("Subject", [InputRequired(), Length(1, 300)])
text = TextAreaField("Message", [InputRequired()], filters=[normalize_line_endings]) text = TextAreaField("Message", [InputRequired()])
submit = SubmitField("Send") submit = SubmitField("Send")

View File

@@ -1,73 +0,0 @@
# ContentDB
# Copyright (C) 2018-24 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import redirect, render_template, abort, url_for
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import InputRequired, Length, Optional
from app.models import db, AuditSeverity, UserRank, Language, Package, PackageState, PackageTranslation
from app.utils import add_audit_log, rank_required, normalize_line_endings
from . import bp
@bp.route("/admin/languages/")
@rank_required(UserRank.ADMIN)
def language_list():
at_least_one_count = db.session.query(PackageTranslation.package_id).group_by(PackageTranslation.package_id).count()
total_package_count = Package.query.filter_by(state=PackageState.APPROVED).count()
return render_template("admin/languages/list.html",
languages=Language.query.all(), total_package_count=total_package_count,
at_least_one_count=at_least_one_count)
class LanguageForm(FlaskForm):
id = StringField("Id", [InputRequired(), Length(2, 10)])
title = TextAreaField("Title", [Optional(), Length(2, 100)], filters=[normalize_line_endings])
submit = SubmitField("Save")
@bp.route("/admin/languages/new/", methods=["GET", "POST"])
@bp.route("/admin/languages/<id_>/edit/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN)
def create_edit_language(id_=None):
language = None
if id_ is not None:
language = Language.query.filter_by(id=id_).first()
if language is None:
abort(404)
form = LanguageForm(obj=language)
if form.validate_on_submit():
if language is None:
language = Language()
db.session.add(language)
form.populate_obj(language)
add_audit_log(AuditSeverity.EDITOR, current_user, f"Created language {language.id}",
url_for("admin.create_edit_language", id_=language.id))
else:
form.populate_obj(language)
add_audit_log(AuditSeverity.EDITOR, current_user, f"Edited language {language.id}",
url_for("admin.create_edit_language", id_=language.id))
db.session.commit()
return redirect(url_for("admin.create_edit_language", id_=language.id))
return render_template("admin/languages/edit.html", language=language, form=form)

View File

@@ -23,7 +23,7 @@ from wtforms.validators import InputRequired, Length, Optional, Regexp
from . import bp from . import bp
from app.models import Permission, Tag, db, AuditSeverity from app.models import Permission, Tag, db, AuditSeverity
from app.utils import add_audit_log, normalize_line_endings from app.utils import add_audit_log
@bp.route("/tags/") @bp.route("/tags/")
@@ -44,7 +44,7 @@ def tag_list():
class TagForm(FlaskForm): class TagForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3, 100)]) title = StringField("Title", [InputRequired(), Length(3, 100)])
description = TextAreaField("Description", [Optional(), Length(0, 500)], filters=[normalize_line_endings]) description = TextAreaField("Description", [Optional(), Length(0, 500)])
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0,
"Lower case letters (a-z), digits (0-9), and underscores (_) only")]) "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
submit = SubmitField("Save") submit = SubmitField("Save")

View File

@@ -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")

View File

@@ -20,7 +20,7 @@ from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import InputRequired, Length, Optional, Regexp from wtforms.validators import InputRequired, Length, Optional, Regexp
from app.utils import rank_required, normalize_line_endings from app.utils import rank_required
from . import bp from . import bp
from app.models import UserRank, ContentWarning, db from app.models import UserRank, ContentWarning, db
@@ -33,7 +33,7 @@ def warning_list():
class WarningForm(FlaskForm): class WarningForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3, 100)]) title = StringField("Title", [InputRequired(), Length(3, 100)])
description = TextAreaField("Description", [Optional(), Length(0, 500)], filters=[normalize_line_endings]) description = TextAreaField("Description", [Optional(), Length(0, 500)])
name = StringField("Name", [Optional(), Length(1, 20), name = StringField("Name", [Optional(), Length(1, 20),
Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")]) Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
submit = SubmitField("Save") submit = SubmitField("Save")

View File

@@ -14,36 +14,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/>.
import json
from flask import Blueprint from flask import Blueprint
from .support import error
bp = Blueprint("api", __name__) bp = Blueprint("api", __name__)
from . import tokens, endpoints from . import tokens, endpoints
@bp.errorhandler(400)
@bp.errorhandler(401)
@bp.errorhandler(403)
@bp.errorhandler(404)
def handle_exception(e):
"""Return JSON instead of HTML for HTTP errors."""
# start with the correct headers and status code from the error
response = e.get_response()
# replace the body with JSON
response.data = json.dumps({
"success": False,
"code": e.code,
"name": e.name,
"description": e.description,
})
response.content_type = "application/json"
return response
@bp.route("/api/<path:path>")
def page_not_found(path):
error(404, "Endpoint or method not found")

View File

@@ -39,7 +39,7 @@ def is_api_authd(f):
if token is None: if token is None:
error(403, "Unknown API token") error(403, "Unknown API token")
else: else:
error(403, "Unsupported authentication method") abort(403, "Unsupported authentication method")
return f(token=token, *args, **kwargs) return f(token=token, *args, **kwargs)

View File

@@ -15,12 +15,12 @@
# 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 math import math
import os from functools import wraps
from typing import List from typing import List
import flask_sqlalchemy import flask_sqlalchemy
from flask import request, jsonify, current_app from flask import request, jsonify, current_app, Response
from flask_babel import gettext from flask_login import current_user, login_required
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
@@ -28,35 +28,52 @@ from sqlalchemy.sql.expression import func
from app import csrf 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, ForumTopic, \
LuantiRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread, Collection, \ MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread, Collection, \
PackageAlias, Language PackageAlias
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
cors_allowed
from app.utils.luanti_hypertext import html_to_luanti, 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 app.utils.minetest_hypertext import html_to_minetest
def cors_allowed(f):
@wraps(f)
def inner(*args, **kwargs):
res: Response = f(*args, **kwargs)
res.headers["Access-Control-Allow-Origin"] = "*"
res.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
res.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
return res
return inner
def cached(max_age: int):
def decorator(f):
@wraps(f)
def inner(*args, **kwargs):
res: Response = f(*args, **kwargs)
res.cache_control.max_age = max_age
return res
return inner
return decorator
@bp.route("/api/packages/") @bp.route("/api/packages/")
@cors_allowed @cors_allowed
@cached(300) @cached(300)
def packages(): def packages():
allowed_languages = set([x[0] for x in db.session.query(Language.id).all()]) qb = QueryBuilder(request.args)
lang = request.accept_languages.best_match(allowed_languages)
qb = QueryBuilder(request.args, lang=lang)
query = qb.build_package_query() query = qb.build_package_query()
fmt = request.args.get("fmt") if request.args.get("fmt") == "keys":
if fmt == "keys":
return jsonify([pkg.as_key_dict() for pkg in query.all()]) return jsonify([pkg.as_key_dict() for pkg in query.all()])
include_vcs = fmt == "vcs" pkgs = qb.convert_to_dictionary(query.all())
pkgs = qb.convert_to_dictionary(query.all(), include_vcs)
if "engine_version" in request.args or "protocol_version" in request.args: if "engine_version" in request.args or "protocol_version" in request.args:
pkgs = [pkg for pkg in pkgs if pkg.get("release")] pkgs = [pkg for pkg in pkgs if pkg.get("release")]
@@ -67,93 +84,32 @@ def packages():
"limit" not in request.args: "limit" not in request.args:
featured_lut = set() featured_lut = set()
featured = qb.convert_to_dictionary(query.filter( featured = qb.convert_to_dictionary(query.filter(
Package.collections.any(and_(Collection.name == "featured", Collection.author.has(username="ContentDB")))).all(), Package.collections.any(and_(Collection.name == "featured", Collection.author.has(username="ContentDB")))).all())
include_vcs)
for pkg in featured: for pkg in featured:
featured_lut.add(f"{pkg['author']}/{pkg['name']}") featured_lut.add(f"{pkg['author']}/{pkg['name']}")
pkg["short_description"] = gettext("Featured") + ". " + pkg["short_description"] pkg["short_description"] = "Featured. " + pkg["short_description"]
pkg["featured"] = True
not_featured = [pkg for pkg in pkgs if f"{pkg['author']}/{pkg['name']}" not in featured_lut] not_featured = [pkg for pkg in pkgs if f"{pkg['author']}/{pkg['name']}" not in featured_lut]
pkgs = featured + not_featured pkgs = featured + not_featured
resp = jsonify(pkgs) return jsonify(pkgs)
resp.vary = "Accept-Language"
return resp
@bp.route("/api/packages/<author>/<name>/") @bp.route("/api/packages/<author>/<name>/")
@is_package_page @is_package_page
@cors_allowed @cors_allowed
def package_view(package): def package_view(package):
allowed_languages = set([x[0] for x in db.session.query(Language.id).all()]) return jsonify(package.as_dict(current_app.config["BASE_URL"]))
lang = request.accept_languages.best_match(allowed_languages)
data = package.as_dict(current_app.config["BASE_URL"], lang=lang)
resp = jsonify(data)
resp.vary = "Accept-Language"
return resp
@bp.route("/api/packages/<author>/<name>/for-client/")
@is_package_page
@cors_allowed
def package_view_client(package: Package):
protocol_version = request.args.get("protocol_version")
engine_version = request.args.get("engine_version")
if protocol_version or engine_version:
version = LuantiRelease.get(engine_version, get_int_or_abort(protocol_version))
else:
version = None
allowed_languages = set([x[0] for x in db.session.query(Language.id).all()])
lang = request.accept_languages.best_match(allowed_languages)
data = package.as_dict(current_app.config["BASE_URL"], version, lang=lang, screenshots_dict=True)
formspec_version = get_int_or_abort(request.args["formspec_version"])
include_images = is_yes(request.args.get("include_images", "true"))
page_url = package.get_url("packages.view", absolute=True)
if data["long_description"] is not None:
html = render_markdown(data["long_description"])
data["long_description"] = html_to_luanti(html, page_url, formspec_version, include_images)
data["info_hypertext"] = package_info_as_hypertext(package, formspec_version)
data["download_size"] = package.get_download_release(version).file_size
data["reviews"] = {
"positive": package.reviews.filter(PackageReview.rating > 3).count(),
"neutral": package.reviews.filter(PackageReview.rating == 3).count(),
"negative": package.reviews.filter(PackageReview.rating < 3).count(),
}
resp = jsonify(data)
resp.vary = "Accept-Language"
return resp
@bp.route("/api/packages/<author>/<name>/for-client/reviews/")
@is_package_page
@cors_allowed
def package_view_client_reviews(package: Package):
formspec_version = get_int_or_abort(request.args["formspec_version"])
data = package_reviews_as_hypertext(package, formspec_version)
resp = jsonify(data)
resp.vary = "Accept-Language"
return resp
@bp.route("/api/packages/<author>/<name>/hypertext/") @bp.route("/api/packages/<author>/<name>/hypertext/")
@is_package_page @is_package_page
@cors_allowed @cors_allowed
def package_hypertext(package): def package_hypertext(package):
formspec_version = get_int_or_abort(request.args["formspec_version"]) formspec_version = request.args["formspec_version"]
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)
page_url = package.get_url("packages.view", absolute=True) return jsonify(html_to_minetest(html, formspec_version, include_images))
return jsonify(html_to_luanti(html, page_url, formspec_version, include_images))
@bp.route("/api/packages/<author>/<name>/", methods=["PUT"]) @bp.route("/api/packages/<author>/<name>/", methods=["PUT"])
@@ -211,7 +167,6 @@ def resolve_package_deps(out, package, only_hard, depth=1):
@bp.route("/api/packages/<author>/<name>/dependencies/") @bp.route("/api/packages/<author>/<name>/dependencies/")
@is_package_page @is_package_page
@cors_allowed @cors_allowed
@cached(300)
def package_dependencies(package): def package_dependencies(package):
only_hard = request.args.get("only_hard") only_hard = request.args.get("only_hard")
@@ -229,6 +184,24 @@ def topics():
return jsonify([t.as_dict() for t in query.all()]) return jsonify([t.as_dict() for t in query.all()])
@bp.route("/api/topic_discard/", methods=["POST"])
@login_required
def topic_set_discard():
tid = request.args.get("tid")
discard = request.args.get("discard")
if tid is None or discard is None:
error(400, "Missing topic ID or discard bool")
topic = ForumTopic.query.get(tid)
if not topic.check_perm(current_user, Permission.TOPIC_DISCARD):
error(403, "Permission denied, need: TOPIC_DISCARD")
topic.discarded = discard == "true"
db.session.commit()
return jsonify(topic.as_dict())
@bp.route("/api/whoami/") @bp.route("/api/whoami/")
@is_api_authd @is_api_authd
@cors_allowed @cors_allowed
@@ -264,7 +237,7 @@ def markdown():
def list_all_releases(): def list_all_releases():
query = PackageRelease.query.filter_by(approved=True) \ query = PackageRelease.query.filter_by(approved=True) \
.filter(PackageRelease.package.has(state=PackageState.APPROVED)) \ .filter(PackageRelease.package.has(state=PackageState.APPROVED)) \
.order_by(db.desc(PackageRelease.created_at)) .order_by(db.desc(PackageRelease.releaseDate))
if "author" in request.args: if "author" in request.args:
author = User.query.filter_by(username=request.args["author"]).first() author = User.query.filter_by(username=request.args["author"]).first()
@@ -306,19 +279,15 @@ def create_release(token, package):
else: else:
data = request.form data = request.form
if not ("title" in data or "name" in data): if "title" not in data:
error(400, "name is required in the POST data") error(400, "Title is required in the POST data")
name = data.get("name")
title = data.get("title") or name
name = name or title
if data.get("method") == "git": if data.get("method") == "git":
for option in ["method", "ref"]: for option in ["method", "ref"]:
if option not in data: if option not in data:
error(400, option + " is required in the POST data") error(400, option + " is required in the POST data")
return api_create_vcs_release(token, package, name, title, data.get("release_notes"), data["ref"]) return api_create_vcs_release(token, package, data["title"], data["ref"])
elif request.files: elif request.files:
file = request.files.get("file") file = request.files.get("file")
@@ -327,7 +296,7 @@ def create_release(token, package):
commit_hash = data.get("commit") commit_hash = data.get("commit")
return api_create_zip_release(token, package, name, title, data.get("release_notes"), file, None, None, "API", commit_hash) return api_create_zip_release(token, package, data["title"], file, None, None, "API", commit_hash)
else: else:
error(400, "Unknown release-creation method. Specify the method or provide a file.") error(400, "Unknown release-creation method. Specify the method or provide a file.")
@@ -366,9 +335,6 @@ def delete_release(token: APIToken, package: Package, id: int):
db.session.delete(release) db.session.delete(release)
db.session.commit() db.session.commit()
if release.file_path and os.path.isfile(release.file_path):
os.remove(release.file_path)
return jsonify({"success": True}) return jsonify({"success": True})
@@ -440,8 +406,6 @@ def delete_screenshot(token: APIToken, package: Package, id: int):
db.session.delete(ss) db.session.delete(ss)
db.session.commit() db.session.commit()
os.remove(ss.file_path)
return jsonify({ "success": True }) return jsonify({ "success": True })
@@ -557,7 +521,7 @@ def all_package_stats():
@bp.route("/api/scores/") @bp.route("/api/scores/")
@cors_allowed @cors_allowed
@cached(900) @cached(300)
def package_scores(): def package_scores():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.build_package_query() query = qb.build_package_query()
@@ -568,21 +532,18 @@ def package_scores():
@bp.route("/api/tags/") @bp.route("/api/tags/")
@cors_allowed @cors_allowed
@cached(60*60)
def tags(): def tags():
return jsonify([tag.as_dict() for tag in Tag.query.order_by(db.asc(Tag.name)).all()]) return jsonify([tag.as_dict() for tag in Tag.query.all() ])
@bp.route("/api/content_warnings/") @bp.route("/api/content_warnings/")
@cors_allowed @cors_allowed
@cached(60*60)
def content_warnings(): def content_warnings():
return jsonify([warning.as_dict() for warning in ContentWarning.query.order_by(db.asc(ContentWarning.name)).all() ]) return jsonify([warning.as_dict() for warning in ContentWarning.query.all() ])
@bp.route("/api/licenses/") @bp.route("/api/licenses/")
@cors_allowed @cors_allowed
@cached(60*60)
def licenses(): def licenses():
all_licenses = License.query.order_by(db.asc(License.name)).all() all_licenses = License.query.order_by(db.asc(License.name)).all()
return jsonify([{"name": license.name, "is_foss": license.is_foss} for license in all_licenses]) return jsonify([{"name": license.name, "is_foss": license.is_foss} for license in all_licenses])
@@ -590,7 +551,6 @@ def licenses():
@bp.route("/api/homepage/") @bp.route("/api/homepage/")
@cors_allowed @cors_allowed
@cached(300)
def homepage(): def homepage():
query = Package.query.filter_by(state=PackageState.APPROVED) query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count() count = query.count()
@@ -607,7 +567,7 @@ def homepage():
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \ updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
.filter_by(state=PackageState.APPROVED) \ .filter_by(state=PackageState.APPROVED) \
.order_by(db.desc(PackageRelease.created_at)) \ .order_by(db.desc(PackageRelease.releaseDate)) \
.limit(20).all() .limit(20).all()
updated = updated[:4] updated = updated[:4]
@@ -630,26 +590,38 @@ def homepage():
}) })
@bp.route("/api/welcome/v1/")
@cors_allowed
def welcome_v1():
featured = Package.query \
.filter(Package.type == PackageType.GAME, Package.state == PackageState.APPROVED,
Package.collections.any(
and_(Collection.name == "featured", Collection.author.has(username="ContentDB")))) \
.order_by(func.random()) \
.limit(5).all()
def map_packages(packages: List[Package]):
return [pkg.as_short_dict(current_app.config["BASE_URL"]) for pkg in packages]
return jsonify({
"featured": map_packages(featured),
})
@bp.route("/api/minetest_versions/") @bp.route("/api/minetest_versions/")
@cors_allowed @cors_allowed
def versions(): 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/")
@cors_allowed
def languages():
return jsonify([x.as_dict() for x in Language.query.all()])
@bp.route("/api/dependencies/") @bp.route("/api/dependencies/")
@@ -696,7 +668,6 @@ def user_view(username: str):
@bp.route("/api/users/<username>/stats/") @bp.route("/api/users/<username>/stats/")
@cors_allowed @cors_allowed
@cached(300)
def user_stats(username: str): def user_stats(username: str):
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
if user is None: if user is None:
@@ -709,7 +680,6 @@ def user_stats(username: str):
@bp.route("/api/cdb_schema/") @bp.route("/api/cdb_schema/")
@cors_allowed @cors_allowed
@cached(60*60)
def json_schema(): def json_schema():
tags = Tag.query.all() tags = Tag.query.all()
warnings = ContentWarning.query.all() warnings = ContentWarning.query.all()
@@ -815,11 +785,6 @@ def json_schema():
"type": ["string", "null"], "type": ["string", "null"],
"format": "uri" "format": "uri"
}, },
"translation_url": {
"description": "URL to send users interested in translating your package",
"type": ["string", "null"],
"format": "uri"
}
}, },
}) })
@@ -828,14 +793,14 @@ def json_schema():
@csrf.exempt @csrf.exempt
@cors_allowed @cors_allowed
def hypertext(): def hypertext():
formspec_version = get_int_or_abort(request.args["formspec_version"]) formspec_version = request.args["formspec_version"]
include_images = is_yes(request.args.get("include_images", "true")) include_images = is_yes(request.args.get("include_images", "true"))
html = request.data.decode("utf-8") html = request.data.decode("utf-8")
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/")
@@ -860,21 +825,18 @@ def collection_list():
@bp.route("/api/collections/<author>/<name>/") @bp.route("/api/collections/<author>/<name>/")
@is_api_authd
@cors_allowed @cors_allowed
def collection_view(token, author, name): def collection_view(author, name):
user = token.owner if token else None
collection = Collection.query \ collection = Collection.query \
.filter(Collection.name == name, Collection.author.has(username=author)) \ .filter(Collection.name == name, Collection.author.has(username=author)) \
.one_or_404() .one_or_404()
if not collection.check_perm(user, Permission.VIEW_COLLECTION): if not collection.check_perm(current_user, Permission.VIEW_COLLECTION):
error(404, "Collection not found") error(404, "Collection not found")
items = collection.items items = collection.items
if not collection.check_perm(user, Permission.EDIT_COLLECTION): if collection.check_perm(current_user, Permission.EDIT_COLLECTION):
items = [x for x in items if x.package.check_perm(user, Permission.VIEW_PACKAGE)] items = [x for x in items if x.package.check_perm(current_user, Permission.VIEW_PACKAGE)]
ret = collection.as_dict() ret = collection.as_dict()
ret["items"] = [x.as_dict() for x in items] ret["items"] = [x.as_dict() for x in items]
@@ -882,13 +844,11 @@ def collection_view(token, author, name):
@bp.route("/api/updates/") @bp.route("/api/updates/")
@cors_allowed
@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

View File

@@ -14,13 +14,13 @@
# 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 typing import Optional
from flask import jsonify, abort, make_response, url_for, current_app 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):
@@ -38,14 +38,14 @@ def guard(f):
return ret return ret
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, title: 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")
reason += ", token=" + token.name reason += ", token=" + token.name
rel = guard(do_create_vcs_release)(token.owner, package, name, title, release_notes, ref, min_v, max_v, reason) rel = guard(do_create_vcs_release)(token.owner, package, title, ref, min_v, max_v, reason)
return jsonify({ return jsonify({
"success": True, "success": True,
@@ -54,14 +54,14 @@ 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, title: 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")
reason += ", token=" + token.name reason += ", token=" + token.name
rel = guard(do_create_zip_release)(token.owner, package, name, title, release_notes, file, min_v, max_v, reason, commit_hash) rel = guard(do_create_zip_release)(token.owner, package, title, file, min_v, max_v, reason, commit_hash)
return jsonify({ return jsonify({
"success": True, "success": True,
@@ -112,9 +112,9 @@ def api_edit_package(token: APIToken, package: Package, data: dict, reason: str
reason += ", token=" + token.name reason += ", token=" + token.name
was_modified = guard(do_edit_package)(token.owner, package, False, False, data, reason) package = guard(do_edit_package)(token.owner, package, False, False, data, reason)
return jsonify({ return jsonify({
"success": True, "success": True,
"package": package.as_dict(current_app.config["BASE_URL"]), "package": package.as_dict(current_app.config["BASE_URL"])
"was_modified": was_modified,
}) })

View File

@@ -59,7 +59,10 @@ def list_tokens(username):
@bp.route("/users/<username>/tokens/<int:id>/edit/", methods=["GET", "POST"]) @bp.route("/users/<username>/tokens/<int:id>/edit/", methods=["GET", "POST"])
@login_required @login_required
def create_edit_token(username, id=None): def create_edit_token(username, id=None):
user = User.query.filter_by(username=username).one_or_404() user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
if not user.check_perm(current_user, Permission.CREATE_TOKEN): if not user.check_perm(current_user, Permission.CREATE_TOKEN):
abort(403) abort(403)

View File

@@ -17,7 +17,7 @@
import re import re
import typing import typing
from flask import Blueprint, request, redirect, render_template, flash, abort, url_for, jsonify from flask import Blueprint, request, redirect, render_template, flash, abort, url_for
from flask_babel import lazy_gettext, gettext from flask_babel import lazy_gettext, gettext
from flask_login import current_user, login_required from flask_login import current_user, login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
@@ -25,7 +25,7 @@ from wtforms import StringField, BooleanField, SubmitField, FieldList, HiddenFie
from wtforms.validators import InputRequired, Length, Optional, Regexp from wtforms.validators import InputRequired, Length, Optional, Regexp
from app.models import Collection, db, Package, Permission, CollectionPackage, User, UserRank, AuditSeverity from app.models import Collection, db, Package, Permission, CollectionPackage, User, UserRank, AuditSeverity
from app.utils import nonempty_or_none, normalize_line_endings, should_return_json from app.utils import nonempty_or_none
from app.utils.models import is_package_page, add_audit_log, create_session from app.utils.models import is_package_page, add_audit_log, create_session
bp = Blueprint("collections", __name__) bp = Blueprint("collections", __name__)
@@ -67,13 +67,10 @@ def view(author, name):
abort(404) abort(404)
items = collection.items items = collection.items
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION): if collection.check_perm(current_user, Permission.EDIT_COLLECTION):
items = [x for x in items if x.package.check_perm(current_user, Permission.VIEW_PACKAGE)] items = [x for x in items if x.package.check_perm(current_user, Permission.VIEW_PACKAGE)]
if should_return_json(): return render_template("collections/view.html", collection=collection, items=items)
return jsonify([ item.package.as_key_dict() for item in items ])
else:
return render_template("collections/view.html", collection=collection, items=items)
class CollectionForm(FlaskForm): class CollectionForm(FlaskForm):
@@ -81,9 +78,8 @@ class CollectionForm(FlaskForm):
name = StringField("URL", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, name = StringField("URL", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0,
"Lower case letters (a-z), digits (0-9), and underscores (_) only")]) "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
short_description = StringField(lazy_gettext("Short Description"), [Optional(), Length(0, 200)]) short_description = StringField(lazy_gettext("Short Description"), [Optional(), Length(0, 200)])
long_description = TextAreaField(lazy_gettext("Page Content"), [Optional()], filters=[nonempty_or_none, normalize_line_endings]) long_description = TextAreaField(lazy_gettext("Page Content"), [Optional()], filters=[nonempty_or_none])
private = BooleanField(lazy_gettext("Private")) private = BooleanField(lazy_gettext("Private"))
pinned = BooleanField(lazy_gettext("Pinned to my profile"))
descriptions = FieldList( descriptions = FieldList(
StringField(lazy_gettext("Short Description"), [Optional(), Length(0, 500)], filters=[nonempty_or_none]), StringField(lazy_gettext("Short Description"), [Optional(), Length(0, 500)], filters=[nonempty_or_none]),
min_entries=0) min_entries=0)
@@ -126,7 +122,6 @@ def create_edit(author=None, name=None):
if request.method == "GET": if request.method == "GET":
# HACK: fix bug in wtforms # HACK: fix bug in wtforms
form.private.data = collection.private if collection else False form.private.data = collection.private if collection else False
form.pinned.data = collection.pinned if collection else False
if collection: if collection:
for item in collection.items: for item in collection.items:
form.descriptions.append_entry(item.description) form.descriptions.append_entry(item.description)
@@ -134,7 +129,6 @@ def create_edit(author=None, name=None):
form.package_removed.append_entry("0") form.package_removed.append_entry("0")
else: else:
form.name = None form.name = None
form.pinned = None
if form.validate_on_submit(): if form.validate_on_submit():
ret = handle_create_edit(collection, form, initial_packages, author) ret = handle_create_edit(collection, form, initial_packages, author)
@@ -325,7 +319,6 @@ def package_add(package):
@login_required @login_required
def package_toggle_favorite(package): def package_toggle_favorite(package):
collection, _is_new = get_or_create_favorites(db.session) collection, _is_new = get_or_create_favorites(db.session)
collection.author = current_user
if toggle_package(collection, package): if toggle_package(collection, package):
msg = gettext("Added package to favorites collection") msg = gettext("Added package to favorites collection")

View File

@@ -1,179 +0,0 @@
# ContentDB
# Copyright (C) 2024 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, jsonify, render_template, make_response
from flask_babel import gettext
from app.markdown import render_markdown
from app.models import Package, PackageState, db, PackageRelease
from app.utils import is_package_page, abs_url_for, cached, cors_allowed
bp = Blueprint("feeds", __name__)
def _make_feed(title: str, feed_url: str, items: list):
return {
"version": "https://jsonfeed.org/version/1",
"title": title,
"description": gettext("Welcome to the best place to find Luanti mods, games, and texture packs"),
"home_page_url": "https://content.luanti.org/",
"feed_url": feed_url,
"icon": "https://content.luanti.org/favicon-128.png",
"expired": False,
"items": items,
}
def _render_link(url: str):
return f"<p><a href='{url}'>Read more</a></p>"
def _get_new_packages_feed(feed_url: str) -> dict:
packages = (Package.query
.filter(Package.state == PackageState.APPROVED)
.order_by(db.desc(Package.approved_at))
.limit(100)
.all())
items = [{
"id": package.get_url("packages.view", absolute=True),
"language": "en",
"title": f"New: {package.title}",
"content_html": render_markdown(package.desc) \
if package.desc else _render_link(package.get_url("packages.view", absolute=True)),
"author": {
"name": package.author.display_name,
"avatar": package.author.get_profile_pic_url(absolute=True),
"url": abs_url_for("users.profile", username=package.author.username),
},
"image": package.get_thumb_url(level=4, abs=True, format="png"),
"url": package.get_url("packages.view", absolute=True),
"summary": package.short_desc,
"date_published": package.approved_at.isoformat(timespec="seconds") + "Z",
"tags": ["new_package"],
} for package in packages]
return _make_feed(gettext("ContentDB new packages"), feed_url, items)
def _get_releases_feed(query, feed_url: str):
releases = (query
.filter(PackageRelease.package.has(state=PackageState.APPROVED), PackageRelease.approved==True)
.order_by(db.desc(PackageRelease.created_at))
.limit(250)
.all())
items = [{
"id": release.package.get_url("packages.view_release", id=release.id, absolute=True),
"language": "en",
"title": f"\"{release.package.title}\" updated: {release.title}",
"content_html": render_markdown(release.release_notes) \
if release.release_notes else _render_link(release.package.get_url("packages.view_release", id=release.id, absolute=True)),
"author": {
"name": release.package.author.display_name,
"avatar": release.package.author.get_profile_pic_url(absolute=True),
"url": abs_url_for("users.profile", username=release.package.author.username),
},
"url": release.package.get_url("packages.view_release", id=release.id, absolute=True),
"image": release.package.get_thumb_url(level=4, abs=True, format="png"),
"summary": release.summary,
"date_published": release.created_at.isoformat(timespec="seconds") + "Z",
"tags": ["release"],
} for release in releases]
return _make_feed(gettext("ContentDB package updates"), feed_url, items)
def _get_all_feed(feed_url: str):
releases = _get_releases_feed(PackageRelease.query, "")["items"]
packages = _get_new_packages_feed("")["items"]
items = releases + packages
items.sort(reverse=True, key=lambda x: x["date_published"])
return _make_feed(gettext("ContentDB all"), feed_url, items)
def _atomify(feed):
resp = make_response(render_template("feeds/json_to_atom.xml", feed=feed))
resp.headers["Content-type"] = "application/atom+xml; charset=utf-8"
return resp
@bp.route("/feeds/all.json")
@cors_allowed
@cached(1800)
def all_json():
feed = _get_all_feed(abs_url_for("feeds.all_json"))
return jsonify(feed)
@bp.route("/feeds/all.atom")
@cors_allowed
@cached(1800)
def all_atom():
feed = _get_all_feed(abs_url_for("feeds.all_atom"))
return _atomify(feed)
@bp.route("/feeds/packages.json")
@cors_allowed
@cached(1800)
def packages_all_json():
feed = _get_new_packages_feed(abs_url_for("feeds.packages_all_json"))
return jsonify(feed)
@bp.route("/feeds/packages.atom")
@cors_allowed
@cached(1800)
def packages_all_atom():
feed = _get_new_packages_feed(abs_url_for("feeds.packages_all_atom"))
return _atomify(feed)
@bp.route("/feeds/releases.json")
@cors_allowed
@cached(1800)
def releases_all_json():
feed = _get_releases_feed(PackageRelease.query, abs_url_for("feeds.releases_all_json"))
return jsonify(feed)
@bp.route("/feeds/releases.atom")
@cors_allowed
@cached(1800)
def releases_all_atom():
feed = _get_releases_feed(PackageRelease.query, abs_url_for("feeds.releases_all_atom"))
return _atomify(feed)
@bp.route("/packages/<author>/<name>/releases_feed.json")
@cors_allowed
@is_package_page
@cached(1800)
def releases_package_json(package: Package):
feed = _get_releases_feed(package.releases, package.get_url("feeds.releases_package_json", absolute=True))
return jsonify(feed)
@bp.route("/packages/<author>/<name>/releases_feed.atom")
@cors_allowed
@is_package_page
@cached(1800)
def releases_package_atom(package: Package):
feed = _get_releases_feed(package.releases, package.get_url("feeds.releases_package_atom", absolute=True))
return _atomify(feed)

View File

@@ -0,0 +1,180 @@
# ContentDB
# Copyright (C) 2018-21 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, abort
from flask_babel import gettext
bp = Blueprint("github", __name__)
from flask import redirect, url_for, request, flash, jsonify, current_app
from flask_login import current_user
from sqlalchemy import func, or_, and_
from app import github, csrf
from app.models import db, User, APIToken, Package, Permission, AuditSeverity, PackageState
from app.utils import abs_url_for, add_audit_log, login_user_set_active, is_safe_url
from app.blueprints.api.support import error, api_create_vcs_release
import hmac, requests
@bp.route("/github/start/")
def start():
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
return github.authorize("", redirect_uri=abs_url_for("github.callback", next=next))
@bp.route("/github/view/")
def view_permissions():
url = "https://github.com/settings/connections/applications/" + \
current_app.config["GITHUB_CLIENT_ID"]
return redirect(url)
@bp.route("/github/callback/")
@github.authorized_handler
def callback(oauth_token):
if oauth_token is None:
flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger")
return redirect(url_for("users.login"))
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
redirect_to = next
if redirect_to is None:
redirect_to = url_for("homepage.home")
# Get GitGub username
url = "https://api.github.com/user"
r = requests.get(url, headers={"Authorization": "token " + oauth_token})
username = r.json()["login"]
# Get user by GitHub username
userByGithub = User.query.filter(func.lower(User.github_username) == func.lower(username)).first()
# If logged in, connect
if current_user and current_user.is_authenticated:
if userByGithub is None:
current_user.github_username = username
db.session.commit()
flash(gettext("Linked GitHub to account"), "success")
return redirect(redirect_to)
else:
flash(gettext("GitHub account is already associated with another user"), "danger")
return redirect(redirect_to)
# If not logged in, log in
else:
if userByGithub is None:
flash(gettext("Unable to find an account for that GitHub user"), "danger")
return redirect(url_for("users.claim_forums"))
ret = login_user_set_active(userByGithub, next, remember=True)
if ret is None:
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger")
return redirect(url_for("users.login"))
add_audit_log(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
url_for("users.profile", username=userByGithub.username))
db.session.commit()
return ret
@bp.route("/github/webhook/", methods=["POST"])
@csrf.exempt
def webhook():
json = request.json
# Get package
github_url = "github.com/" + json["repository"]["full_name"]
package = Package.query.filter(
Package.repo.ilike("%{}%".format(github_url)), Package.state != PackageState.DELETED).first()
if package is None:
return error(400, "Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(github_url))
# Get all tokens for package
tokens_query = APIToken.query.filter(or_(APIToken.package==package,
and_(APIToken.package==None, APIToken.owner==package.author)))
possible_tokens = tokens_query.all()
actual_token = None
#
# Check signature
#
header_signature = request.headers.get('X-Hub-Signature')
if header_signature is None:
return error(403, "Expected payload signature")
sha_name, signature = header_signature.split('=')
if sha_name != 'sha1':
return error(403, "Expected SHA1 payload signature")
for token in possible_tokens:
mac = hmac.new(token.access_token.encode("utf-8"), msg=request.data, digestmod='sha1')
if hmac.compare_digest(str(mac.hexdigest()), signature):
actual_token = token
break
if actual_token is None:
return error(403, "Invalid authentication, couldn't validate API token")
if not package.check_perm(actual_token.owner, Permission.APPROVE_RELEASE):
return error(403, "You do not have the permission to approve releases")
#
# Check event
#
event = request.headers.get("X-GitHub-Event")
if event == "push":
ref = json["after"]
title = json["head_commit"]["message"].partition("\n")[0]
branch = json["ref"].replace("refs/heads/", "")
if branch not in [ "master", "main" ]:
return jsonify({ "success": False, "message": "Webhook ignored, as it's not on the master/main branch" })
elif event == "create":
ref_type = json.get("ref_type")
if ref_type != "tag":
return jsonify({
"success": False,
"message": "Webhook ignored, as it's a non-tag create event. ref_type='{}'.".format(ref_type)
})
ref = json["ref"]
title = ref
elif event == "ping":
return jsonify({ "success": True, "message": "Ping successful" })
else:
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
.format(event or "null"))
#
# Perform release
#
if package.releases.filter_by(commit_hash=ref).count() > 0:
return
return api_create_vcs_release(actual_token, package, title, ref, reason="Webhook")

View File

@@ -0,0 +1,86 @@
# ContentDB
# Copyright (C) 2020 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, request, jsonify
bp = Blueprint("gitlab", __name__)
from app import csrf
from app.models import Package, APIToken, Permission, PackageState
from app.blueprints.api.support import error, api_create_vcs_release
def webhook_impl():
json = request.json
# Get package
gitlab_url = json["project"]["web_url"].replace("https://", "").replace("http://", "")
package = Package.query.filter(
Package.repo.ilike("%{}%".format(gitlab_url)), Package.state != PackageState.DELETED).first()
if package is None:
return error(400,
"Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(gitlab_url))
# Get all tokens for package
secret = request.headers.get("X-Gitlab-Token")
if secret is None:
return error(403, "Token required")
token = APIToken.query.filter_by(access_token=secret).first()
if token is None:
return error(403, "Invalid authentication")
if not package.check_perm(token.owner, Permission.APPROVE_RELEASE):
return error(403, "You do not have the permission to approve releases")
#
# Check event
#
event = json["event_name"]
if event == "push":
ref = json["after"]
title = ref[:5]
branch = json["ref"].replace("refs/heads/", "")
if branch not in ["master", "main"]:
return jsonify({"success": False,
"message": "Webhook ignored, as it's not on the master/main branch"})
elif event == "tag_push":
ref = json["ref"]
title = ref.replace("refs/tags/", "")
else:
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
.format(event or "null"))
#
# Perform release
#
if package.releases.filter_by(commit_hash=ref).count() > 0:
return
return api_create_vcs_release(token, package, title, ref, reason="Webhook")
@bp.route("/gitlab/webhook/", methods=["POST"])
@csrf.exempt
def webhook():
try:
return webhook_impl()
except KeyError as err:
return error(400, "Missing field: {}".format(err.args[0]))

View File

@@ -18,108 +18,58 @@ from flask import Blueprint, render_template, redirect
from sqlalchemy import and_ from sqlalchemy import and_
from app.models import Package, PackageReview, Thread, User, PackageState, db, PackageType, PackageRelease, Tags, Tag, \ from app.models import Package, PackageReview, Thread, User, PackageState, db, PackageType, PackageRelease, Tags, Tag, \
Collection, License, Language Collection
bp = Blueprint("homepage", __name__) bp = Blueprint("homepage", __name__)
from sqlalchemy.orm import joinedload, subqueryload, load_only, noload from sqlalchemy.orm import joinedload, subqueryload
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
PKGS_PER_ROW = 4 PKGS_PER_ROW = 4
# GAMEJAM_BANNER = "https://jam.luanti.org/img/banner.png"
#
# class GameJam:
# cover_image = type("", (), dict(url=GAMEJAM_BANNER))()
# tags = []
#
# def get_cover_image_url(self):
# return GAMEJAM_BANNER
#
# def get_url(self, _name):
# return "/gamejam/"
#
# title = "Luanti Game Jam 2023: \"Unexpected\""
# author = None
#
# short_desc = "The game jam has finished! It's now up to the community to play and rate the games."
# type = type("", (), dict(value="Competition"))()
# content_warnings = []
# reviews = []
@bp.route("/gamejam/") @bp.route("/gamejam/")
def gamejam(): def gamejam():
return redirect("https://jam.luanti.org/") return redirect("https://forum.minetest.net/viewtopic.php?t=28802")
@bp.route("/") @bp.route("/")
def home(): def home():
def package_load(query): def package_load(query):
return query.options( return query.options(
load_only(Package.name, Package.title, Package.short_desc, Package.state, raiseload=True), joinedload(Package.author),
subqueryload(Package.main_screenshot), subqueryload(Package.main_screenshot),
joinedload(Package.author).load_only(User.username, User.display_name, raiseload=True),
joinedload(Package.license).load_only(License.name, License.is_foss, raiseload=True),
joinedload(Package.media_license).load_only(License.name, License.is_foss, raiseload=True))
def package_spotlight_load(query):
return query.options(
load_only(Package.name, Package.title, Package.type, Package.short_desc, Package.state, Package.cover_image_id, raiseload=True),
subqueryload(Package.main_screenshot),
joinedload(Package.tags),
joinedload(Package.content_warnings),
joinedload(Package.author).load_only(User.username, User.display_name, raiseload=True),
subqueryload(Package.cover_image), subqueryload(Package.cover_image),
joinedload(Package.license).load_only(License.name, License.is_foss, raiseload=True), joinedload(Package.license),
joinedload(Package.media_license).load_only(License.name, License.is_foss, raiseload=True)) joinedload(Package.media_license))
def review_load(query): def review_load(query):
return query.options( return query.options(
load_only(PackageReview.id, PackageReview.rating, PackageReview.created_at, PackageReview.language_id, raiseload=True), joinedload(PackageReview.author),
joinedload(PackageReview.author).load_only(User.username, User.rank, User.email, User.display_name, User.profile_pic, User.is_active, raiseload=True), joinedload(PackageReview.thread).subqueryload(Thread.first_reply),
joinedload(PackageReview.votes), joinedload(PackageReview.package).joinedload(Package.author).load_only(User.username, User.display_name),
joinedload(PackageReview.language).load_only(Language.title, raiseload=True), joinedload(PackageReview.package).load_only(Package.title, Package.name).subqueryload(Package.main_screenshot))
joinedload(PackageReview.thread).load_only(Thread.title, Thread.replies_count, raiseload=True).subqueryload(Thread.first_reply),
joinedload(PackageReview.package)
.load_only(Package.title, Package.name, raiseload=True)
.joinedload(Package.author).load_only(User.username, User.display_name, raiseload=True))
query = Package.query.filter_by(state=PackageState.APPROVED) query = Package.query.filter_by(state=PackageState.APPROVED)
count = db.session.query(Package.id).filter(Package.state == PackageState.APPROVED).count() count = query.count()
spotlight_pkgs = package_spotlight_load(query.filter( spotlight_pkgs = query.filter(
Package.collections.any(and_(Collection.name == "spotlight", Collection.author.has(username="ContentDB")))) Package.collections.any(and_(Collection.name == "spotlight", Collection.author.has(username="ContentDB")))) \
.order_by(func.random())).limit(6).all() .order_by(func.random()).limit(6).all()
# spotlight_pkgs.insert(0, GameJam())
new = package_load(query).order_by(db.desc(Package.approved_at)).limit(PKGS_PER_ROW).all() # 0.06 new = package_load(query.order_by(db.desc(Package.approved_at))).limit(PKGS_PER_ROW).all()
pop_mod = package_load(query).filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(2*PKGS_PER_ROW).all() pop_mod = package_load(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(2*PKGS_PER_ROW).all()
pop_gam = package_load(query).filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(2*PKGS_PER_ROW).all() pop_gam = package_load(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(2*PKGS_PER_ROW).all()
pop_txp = package_load(query).filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(2*PKGS_PER_ROW).all() pop_txp = package_load(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(2*PKGS_PER_ROW).all()
high_reviewed = package_load(query.order_by(db.desc(Package.score - Package.score_downloads))) \
.filter(Package.reviews.any()).limit(PKGS_PER_ROW).all()
high_reviewed = package_load(query.order_by(db.desc(Package.score - Package.score_downloads)) updated = package_load(db.session.query(Package).select_from(PackageRelease).join(Package)
.filter(Package.reviews.any()).limit(PKGS_PER_ROW)).all() .filter_by(state=PackageState.APPROVED)
.order_by(db.desc(PackageRelease.releaseDate))
recent_releases_query = ( .limit(20)).all()
db.session.query( updated = updated[:PKGS_PER_ROW]
Package.id,
func.max(PackageRelease.created_at).label("max_created_at")
)
.join(PackageRelease, Package.releases)
.group_by(Package.id)
.order_by(db.desc("max_created_at"))
.limit(3*PKGS_PER_ROW)
.subquery())
updated = (
package_load(db.session.query(Package)
.select_from(recent_releases_query)
.join(Package, Package.id == recent_releases_query.c.id)
.filter(Package.state == PackageState.APPROVED)
.limit(PKGS_PER_ROW))
.all())
reviews = review_load(PackageReview.query.filter(PackageReview.rating > 3) reviews = review_load(PackageReview.query.filter(PackageReview.rating > 3)
.order_by(db.desc(PackageReview.created_at))).limit(5).all() .order_by(db.desc(PackageReview.created_at))).limit(5).all()

View File

@@ -14,107 +14,64 @@
# 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 datetime
from flask import Blueprint, make_response from flask import Blueprint, make_response
from sqlalchemy import or_, and_
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
from app.models import Package, db, User, UserRank, PackageState, PackageReview, ThreadReply, Collection, AuditLogEntry, \ from app.models import Package, db, User, UserRank, PackageState, PackageReview, ThreadReply, Collection
PackageTranslation, Language
from app.rediscache import get_key from app.rediscache import get_key
bp = Blueprint("metrics", __name__) bp = Blueprint("metrics", __name__)
def generate_metrics(): def generate_metrics(full=False):
def write_single_stat(name, help, type, value): def write_single_stat(name, help, type, value):
fmt = "# HELP {name} {help}\n# TYPE {name} {type}\n{name} {value}\n\n" fmt = "# HELP {name} {help}\n# TYPE {name} {type}\n{name} {value}\n\n"
return fmt.format(name=name, help=help, type=type, value=value) return fmt.format(name=name, help=help, type=type, value=value)
def gen_labels(labels): def gen_labels(labels):
pieces = [f"{key}=\"{val}\"" for key, val in labels.items()] pieces = [key + "=" + str(val) for key, val in labels.items()]
return ",".join(pieces) return ",".join(pieces)
def write_array_stat(name, help, type, data): def write_array_stat(name, help, type, data):
result = "# HELP {name} {help}\n# TYPE {name} {type}\n" \ ret = "# HELP {name} {help}\n# TYPE {name} {type}\n" \
.format(name=name, help=help, type=type) .format(name=name, help=help, type=type)
for entry in data: for entry in data:
assert(len(entry) == 2) assert(len(entry) == 2)
result += "{name}{{{labels}}} {value}\n" \ ret += "{name}{{{labels}}} {value}\n" \
.format(name=name, labels=gen_labels(entry[0]), value=entry[1]) .format(name=name, labels=gen_labels(entry[0]), value=entry[1])
return result + "\n" return ret + "\n"
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none() downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0] downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
packages = Package.query.filter_by(state=PackageState.APPROVED).count() packages = Package.query.filter_by(state=PackageState.APPROVED).count()
users = User.query.filter(User.rank > UserRank.NOT_JOINED, User.rank != UserRank.BOT, User.is_active).count() users = User.query.filter(User.rank != UserRank.NOT_JOINED).count()
authors = User.query.filter(User.packages.any(state=PackageState.APPROVED)).count()
one_day_ago = datetime.datetime.now() - datetime.timedelta(days=1)
one_week_ago = datetime.datetime.now() - datetime.timedelta(weeks=1)
one_month_ago = datetime.datetime.now() - datetime.timedelta(weeks=4)
active_users_day = User.query.filter(and_(User.rank != UserRank.BOT, or_(
User.audit_log_entries.any(AuditLogEntry.created_at > one_day_ago),
User.replies.any(ThreadReply.created_at > one_day_ago)))).count()
active_users_week = User.query.filter(and_(User.rank != UserRank.BOT, or_(
User.audit_log_entries.any(AuditLogEntry.created_at > one_week_ago),
User.replies.any(ThreadReply.created_at > one_week_ago)))).count()
active_users_month = User.query.filter(and_(User.rank != UserRank.BOT, or_(
User.audit_log_entries.any(AuditLogEntry.created_at > one_month_ago),
User.replies.any(ThreadReply.created_at > one_month_ago)))).count()
reviews = PackageReview.query.count() reviews = PackageReview.query.count()
comments = ThreadReply.query.count() comments = ThreadReply.query.count()
collections = Collection.query.count() collections = Collection.query.count()
score_result = db.session.query(func.sum(Package.score)).one_or_none()
score = 0 if not score_result or not score_result[0] else score_result[0]
packages_with_translations = (db.session.query(PackageTranslation.package_id)
.filter(PackageTranslation.language_id != "en")
.group_by(PackageTranslation.package_id).count())
packages_with_translations_meta = (db.session.query(PackageTranslation.package_id)
.filter(PackageTranslation.short_desc.is_not(None), PackageTranslation.language_id != "en")
.group_by(PackageTranslation.package_id).count())
languages_packages = (db.session.query(PackageTranslation.language_id, func.count(Package.id))
.select_from(PackageTranslation).outerjoin(Package)
.order_by(db.asc(PackageTranslation.language_id))
.group_by(PackageTranslation.language_id).all())
languages_packages_meta = (db.session.query(PackageTranslation.language_id, func.count(Package.id))
.select_from(PackageTranslation).outerjoin(Package)
.filter(PackageTranslation.short_desc.is_not(None))
.order_by(db.asc(PackageTranslation.language_id))
.group_by(PackageTranslation.language_id).all())
ret = "" ret = ""
ret += write_single_stat("contentdb_packages", "Total packages", "gauge", packages) ret += write_single_stat("contentdb_packages", "Total packages", "gauge", packages)
ret += write_single_stat("contentdb_users", "Number of registered users", "gauge", users) ret += write_single_stat("contentdb_users", "Number of registered users", "gauge", users)
ret += write_single_stat("contentdb_authors", "Number of users with packages", "gauge", authors)
ret += write_single_stat("contentdb_users_active_1d", "Number of daily active registered users", "gauge", active_users_day)
ret += write_single_stat("contentdb_users_active_1w", "Number of weekly active registered users", "gauge", active_users_week)
ret += write_single_stat("contentdb_users_active_1m", "Number of monthly active registered users", "gauge", active_users_month)
ret += write_single_stat("contentdb_downloads", "Total downloads", "gauge", downloads) ret += write_single_stat("contentdb_downloads", "Total downloads", "gauge", downloads)
ret += write_single_stat("contentdb_emails", "Number of emails sent", "counter", int(get_key("emails_sent", "0"))) ret += write_single_stat("contentdb_emails", "Number of emails sent", "counter", int(get_key("emails_sent", "0")))
ret += write_single_stat("contentdb_reviews", "Number of reviews", "gauge", reviews) ret += write_single_stat("contentdb_reviews", "Number of reviews", "gauge", reviews)
ret += write_single_stat("contentdb_comments", "Number of comments", "gauge", comments) ret += write_single_stat("contentdb_comments", "Number of comments", "gauge", comments)
ret += write_single_stat("contentdb_collections", "Number of collections", "gauge", collections) ret += write_single_stat("contentdb_collections", "Number of collections", "gauge", collections)
ret += write_single_stat("contentdb_score", "Total package score", "gauge", score)
ret += write_single_stat("contentdb_packages_with_translations", "Number of packages with translations", "gauge", if full:
packages_with_translations) scores = Package.query.join(User).with_entities(User.username, Package.name, Package.score) \
ret += write_single_stat("contentdb_packages_with_translations_meta", "Number of packages with translated meta", .filter(Package.state==PackageState.APPROVED).all()
"gauge", packages_with_translations_meta)
ret += write_array_stat("contentdb_languages_translated", ret += write_array_stat("contentdb_package_score", "Package score", "gauge",
"Number of packages per language", "gauge", [({ "author": score[0], "name": score[1] }, score[2]) for score in scores])
[({"language": x[0]}, x[1]) for x in languages_packages]) else:
ret += write_array_stat("contentdb_languages_translated_meta", score_result = db.session.query(func.sum(Package.score)).one_or_none()
"Number of packages with translated short desc per language", "gauge", score = 0 if not score_result or not score_result[0] else score_result[0]
[({"language": x[0]}, x[1]) for x in languages_packages_meta]) ret += write_single_stat("contentdb_score", "Total package score", "gauge", score)
return ret return ret

View File

@@ -15,15 +15,15 @@
# 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 urllib.parse as urlparse import urllib.parse as urlparse
from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
import typing
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, abort, make_response, flash from flask import Blueprint, render_template, redirect, url_for, request, jsonify, abort, make_response, flash
from flask_babel import lazy_gettext, gettext from flask_babel import lazy_gettext, gettext
from flask_login import current_user, login_required from flask_login import current_user, login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, URLField, SelectField from wtforms import StringField, SubmitField, URLField
from wtforms.validators import InputRequired, Length, Optional from wtforms.validators import InputRequired, Length
from app import csrf from app import csrf
from app.blueprints.users.settings import get_setting_tabs from app.blueprints.users.settings import get_setting_tabs
@@ -33,7 +33,7 @@ from app.utils import random_string, add_audit_log
bp = Blueprint("oauth", __name__) bp = Blueprint("oauth", __name__)
def build_redirect_url(url: str, code: str, state: typing.Optional[str]): def build_redirect_url(url: str, code: str, state: Optional[str]):
params = {"code": code} params = {"code": code}
if state is not None: if state is not None:
params["state"] = state params["state"] = state
@@ -51,12 +51,12 @@ def oauth_start():
if response_type != "code": if response_type != "code":
return "Unsupported response_type, only code is supported", 400 return "Unsupported response_type, only code is supported", 400
client_id = request.args.get("client_id", "") client_id = request.args.get("client_id")
if client_id == "": if client_id is None:
return "Missing client_id", 400 return "Missing client_id", 400
redirect_uri = request.args.get("redirect_uri", "") redirect_uri = request.args.get("redirect_uri")
if redirect_uri == "": if redirect_uri is None:
return "Missing redirect_uri", 400 return "Missing redirect_uri", 400
client = OAuthClient.query.get_or_404(client_id) client = OAuthClient.query.get_or_404(client_id)
@@ -66,14 +66,18 @@ def oauth_start():
if not client.approved and client.owner != current_user: if not client.approved and client.owner != current_user:
abort(404) abort(404)
scope = request.args.get("scope", "public") valid_scopes = {"user:email", "package", "package:release", "package:screenshot"}
if scope != "public": scope = request.args.get("scope", "")
return "Unsupported scope, only public is supported", 400 scopes = [x.strip() for x in scope.split(",")]
scopes = set([x for x in scopes if x != ""])
unknown_scopes = scopes - valid_scopes
if unknown_scopes:
return f"Unknown scopes: {', '.join(unknown_scopes)}", 400
state = request.args.get("state") state = request.args.get("state")
token = APIToken.query.filter(APIToken.client == client, APIToken.owner == current_user).first() token = APIToken.query.filter(APIToken.client == client, APIToken.owner == current_user).first()
if token: if token and not (scopes - token.get_scopes()):
token.access_token = random_string(32) token.access_token = random_string(32)
token.auth_code = random_string(32) token.auth_code = random_string(32)
db.session.commit() db.session.commit()
@@ -85,15 +89,19 @@ def oauth_start():
return redirect(client.redirect_url) return redirect(client.redirect_url)
elif action == "authorize": elif action == "authorize":
token = APIToken() if token is None:
token = APIToken()
token.name = f"Token for {client.title} by {client.owner.username}"
token.owner = current_user
token.client = client
token.access_token = random_string(32) token.access_token = random_string(32)
token.name = f"Token for {client.title} by {client.owner.username}"
token.owner = current_user
token.client = client
assert client is not None assert client is not None
token.auth_code = random_string(32) token.auth_code = random_string(32)
db.session.add(token) db.session.add(token)
token.set_scopes(scopes)
add_audit_log(AuditSeverity.USER, current_user, add_audit_log(AuditSeverity.USER, current_user,
f"Granted \"{scope}\" to OAuth2 application \"{client.title}\" by {client.owner.username} [{client_id}] ", f"Granted \"{scope}\" to OAuth2 application \"{client.title}\" by {client.owner.username} [{client_id}] ",
url_for("users.profile", username=current_user.username)) url_for("users.profile", username=current_user.username))
@@ -102,7 +110,42 @@ def oauth_start():
return redirect(build_redirect_url(client.redirect_url, token.auth_code, state)) return redirect(build_redirect_url(client.redirect_url, token.auth_code, state))
return render_template("oauth/authorize.html", client=client) scopes_info = []
if not scopes:
scopes_info.append({
"icon": "globe-europe",
"title": "Public data only",
"description": "Read-only access to your public data",
})
if "user:email" in scopes:
scopes_info.append({
"icon": "user",
"title": gettext("Personal data"),
"description": gettext("Email address (read-only)"),
})
if ("package" in scopes or
"package:release" in scopes or
"package:screenshot" in scopes):
if "package" in scopes:
msg = gettext("Ability to edit packages and their releases, screenshots, and related data")
elif "package:release" in scopes and "package:screenshot" in scopes:
msg = gettext("Ability to create and edit releases and screenshots")
elif "package:release" in scopes:
msg = gettext("Ability to create and edit releases")
elif "package:screenshot" in scopes:
msg = gettext("Ability to create and edit screenshots")
else:
assert False, "This should never happen"
scopes_info.append({
"icon": "pen",
"title": gettext("Packages"),
"description": msg,
})
return render_template("oauth/authorize.html", client=client, scopes=scopes_info)
def error(code: int, msg: str): def error(code: int, msg: str):
@@ -118,16 +161,16 @@ def oauth_grant():
if grant_type != "authorization_code": if grant_type != "authorization_code":
error(400, "Unsupported grant_type, only authorization_code is supported") error(400, "Unsupported grant_type, only authorization_code is supported")
client_id = form.get("client_id", "") client_id = form.get("client_id")
if client_id == "": if client_id is None:
error(400, "Missing client_id") error(400, "Missing client_id")
client_secret = form.get("client_secret", "") client_secret = form.get("client_secret")
if client_secret == "": if client_secret is None:
error(400, "Missing client_secret") error(400, "Missing client_secret")
code = form.get("code", "") code = form.get("code")
if code == "": if code is None:
error(400, "Missing code") error(400, "Missing code")
client = OAuthClient.query.filter_by(id=client_id, secret=client_secret).first() client = OAuthClient.query.filter_by(id=client_id, secret=client_secret).first()
@@ -142,7 +185,6 @@ def oauth_grant():
db.session.commit() db.session.commit()
return jsonify({ return jsonify({
"success": True,
"access_token": token.access_token, "access_token": token.access_token,
"token_type": "Bearer", "token_type": "Bearer",
}) })
@@ -166,12 +208,7 @@ def list_clients(username):
class OAuthClientForm(FlaskForm): class OAuthClientForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(5, 30)]) title = StringField(lazy_gettext("Title"), [InputRequired(), Length(5, 30)])
description = StringField(lazy_gettext("Description"), [Optional()])
redirect_url = URLField(lazy_gettext("Redirect URL"), [InputRequired(), Length(5, 123)]) redirect_url = URLField(lazy_gettext("Redirect URL"), [InputRequired(), Length(5, 123)])
app_type = SelectField(lazy_gettext("App Type"), [InputRequired()], choices=[
("server", "Server-side (client_secret is kept safe)"),
("client", "Client-side (client_secret is visible to all users)"),
], coerce=lambda x: x)
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
@@ -191,13 +228,8 @@ def create_edit_client(username, id_=None):
abort(404) abort(404)
form = OAuthClientForm(formdata=request.form, obj=client) form = OAuthClientForm(formdata=request.form, obj=client)
if form.validate_on_submit(): if form.validate_on_submit():
if is_new: if is_new:
if OAuthClient.query.filter(OAuthClient.title.ilike(form.title.data.strip())).count() > 0:
flash(gettext("An OAuth client with that title already exists. Please choose a new title."), "danger")
return render_template("oauth/create_edit.html", user=user, form=form, client=client)
client = OAuthClient() client = OAuthClient()
db.session.add(client) db.session.add(client)
client.owner = user client.owner = user
@@ -205,7 +237,6 @@ def create_edit_client(username, id_=None):
client.secret = random_string(32) client.secret = random_string(32)
client.approved = current_user.rank.at_least(UserRank.EDITOR) client.approved = current_user.rank.at_least(UserRank.EDITOR)
form.populate_obj(client) form.populate_obj(client)
verb = "Created" if is_new else "Edited" verb = "Created" if is_new else "Edited"

View File

@@ -32,11 +32,6 @@ def get_package_tabs(user: User, package: Package):
"title": gettext("Edit Details"), "title": gettext("Edit Details"),
"url": package.get_url("packages.create_edit") "url": package.get_url("packages.create_edit")
}, },
{
"id": "translation",
"title": gettext("Translation"),
"url": package.get_url("packages.translation")
},
{ {
"id": "releases", "id": "releases",
"title": gettext("Releases"), "title": gettext("Releases"),
@@ -75,7 +70,7 @@ def get_package_tabs(user: User, package: Package):
] ]
if package.type == PackageType.MOD or package.type == PackageType.TXP: if package.type == PackageType.MOD or package.type == PackageType.TXP:
retval.insert(2, { retval.insert(1, {
"id": "game_support", "id": "game_support",
"title": gettext("Supported Games"), "title": gettext("Supported Games"),
"url": package.get_url("packages.game_support") "url": package.get_url("packages.game_support")
@@ -84,4 +79,4 @@ def get_package_tabs(user: User, package: Package):
return retval return retval
from . import packages, advanced_search, screenshots, releases, reviews, game_hub from . import packages, screenshots, releases, reviews, game_hub

View File

@@ -1,103 +0,0 @@
# ContentDB
# Copyright (C) 2024 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import render_template
from flask_babel import lazy_gettext, gettext
from flask_wtf import FlaskForm
from wtforms.fields.choices import SelectField, SelectMultipleField
from wtforms.fields.simple import StringField, BooleanField
from wtforms.validators import Optional
from wtforms_sqlalchemy.fields import QuerySelectMultipleField, QuerySelectField
from . import bp
from ...models import PackageType, Tag, db, ContentWarning, License, Language, LuantiRelease, Package, PackageState
def make_label(obj: Tag | ContentWarning):
translated = obj.get_translated()
if translated["description"]:
return "{}: {}".format(translated["title"], translated["description"])
else:
return translated["title"]
def get_hide_choices():
ret = [
("android_default", gettext("Android Default")),
("desktop_default", gettext("Desktop Default")),
("nonfree", gettext("Non-free")),
("wip", gettext("Work in Progress")),
("deprecated", gettext("Deprecated")),
("*", gettext("All content warnings")),
]
content_warnings = ContentWarning.query.order_by(db.asc(ContentWarning.name)).all()
tags = Tag.query.order_by(db.asc(Tag.name)).all()
ret += [(x.name, make_label(x)) for x in content_warnings + tags]
return ret
class AdvancedSearchForm(FlaskForm):
q = StringField(lazy_gettext("Query"), [Optional()])
type = SelectMultipleField(lazy_gettext("Type"), [Optional()],
choices=PackageType.choices(), coerce=PackageType.coerce)
author = StringField(lazy_gettext("Author"), [Optional()])
tag = QuerySelectMultipleField(lazy_gettext('Tags'),
query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)),
get_pk=lambda a: a.name, get_label=make_label)
flag = QuerySelectMultipleField(lazy_gettext('Content Warnings'),
query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)),
get_pk=lambda a: a.name, get_label=make_label)
license = QuerySelectMultipleField(lazy_gettext("License"), [Optional()],
query_factory=lambda: License.query.order_by(db.asc(License.name)),
allow_blank=True, blank_value="",
get_pk=lambda a: a.name, get_label=lambda a: a.name)
game = QuerySelectField(lazy_gettext("Supports Game"), [Optional()],
query_factory=lambda: Package.query.filter(Package.type == PackageType.GAME, Package.state == PackageState.APPROVED).order_by(db.asc(Package.name)),
allow_blank=True, blank_value="",
get_pk=lambda a: f"{a.author.username}/{a.name}",
get_label=lambda a: lazy_gettext("%(title)s by %(author)s", title=a.title, author=a.author.display_name))
lang = QuerySelectField(lazy_gettext("Language"),
query_factory=lambda: Language.query.order_by(db.asc(Language.title)),
allow_blank=True, blank_value="",
get_pk=lambda a: a.id, get_label=lambda a: a.title)
hide = SelectMultipleField(lazy_gettext("Hide Tags and Content Warnings"), [Optional()])
engine_version = QuerySelectField(lazy_gettext("Luanti Version"),
query_factory=lambda: LuantiRelease.query.order_by(db.asc(LuantiRelease.id)),
allow_blank=True, blank_value="",
get_pk=lambda a: a.value, get_label=lambda a: a.name)
sort = SelectField(lazy_gettext("Sort by"), [Optional()], choices=[
("", ""),
("name", lazy_gettext("Name")),
("title", lazy_gettext("Title")),
("score", lazy_gettext("Package score")),
("reviews", lazy_gettext("Reviews")),
("downloads", lazy_gettext("Downloads")),
("created_at", lazy_gettext("Created At")),
("approved_at", lazy_gettext("Approved At")),
("last_release", lazy_gettext("Last Release")),
])
order = SelectField(lazy_gettext("Order"), [Optional()], choices=[
("desc", lazy_gettext("Descending")),
("asc", lazy_gettext("Ascending")),
])
random = BooleanField(lazy_gettext("Random order"))
@bp.route("/packages/advanced-search/")
def advanced_search():
form = AdvancedSearchForm()
form.hide.choices = get_hide_choices()
return render_template("packages/advanced_search.html", form=form)

View File

@@ -44,7 +44,7 @@ def game_hub(package: Package):
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \ updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
.filter(Package.supported_games.any(game=package, supports=True), Package.state==PackageState.APPROVED) \ .filter(Package.supported_games.any(game=package, supports=True), Package.state==PackageState.APPROVED) \
.order_by(db.desc(PackageRelease.created_at)) \ .order_by(db.desc(PackageRelease.releaseDate)) \
.limit(20).all() .limit(20).all()
updated = updated[:4] updated = updated[:4]

View File

@@ -33,11 +33,10 @@ from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from app.logic.LogicError import LogicError from app.logic.LogicError import LogicError
from app.logic.packages import do_edit_package from app.logic.packages import do_edit_package
from app.querybuilder import QueryBuilder from app.querybuilder import QueryBuilder
from app.rediscache import has_key, set_temp_key from app.rediscache import has_key, set_key
from app.tasks.importtasks import import_repo_screenshot, check_zip_release, remove_package_game_support, \ from app.tasks.importtasks import import_repo_screenshot, check_zip_release
update_package_game_support
from app.tasks.pkgtasks import check_package_on_submit
from app.tasks.webhooktasks import post_discord_webhook from app.tasks.webhooktasks import post_discord_webhook
from app.logic.game_support import GameSupportResolver
from . import bp, get_package_tabs from . import bp, get_package_tabs
from app.models import Package, Tag, db, User, Tags, PackageState, Permission, PackageType, MetaPackage, ForumTopic, \ from app.models import Package, Tag, db, User, Tags, PackageState, Permission, PackageType, MetaPackage, ForumTopic, \
@@ -45,15 +44,12 @@ from app.models import Package, Tag, db, User, Tags, PackageState, Permission, P
PackageScreenshot, NotificationType, AuditLogEntry, PackageAlias, PackageProvides, PackageGameSupport, \ PackageScreenshot, NotificationType, AuditLogEntry, PackageAlias, PackageProvides, PackageGameSupport, \
PackageDailyStats, Collection PackageDailyStats, Collection
from app.utils import is_user_bot, get_int_or_abort, is_package_page, abs_url_for, add_audit_log, get_package_by_info, \ from app.utils import is_user_bot, get_int_or_abort, is_package_page, abs_url_for, add_audit_log, get_package_by_info, \
add_notification, get_system_user, rank_required, get_games_from_csv, get_daterange_options, \ add_notification, get_system_user, rank_required, get_games_from_csv, get_daterange_options
post_to_approval_thread, normalize_line_endings
from app.logic.package_approval import validate_package_for_approval, can_move_to_state
from app.logic.game_support import game_support_set
@bp.route("/packages/") @bp.route("/packages/")
def list_all(): def list_all():
qb = QueryBuilder(request.args, cookies=True) qb = QueryBuilder(request.args)
query = qb.build_package_query() query = qb.build_package_query()
title = qb.title title = qb.title
@@ -69,7 +65,7 @@ def list_all():
edited = True edited = True
key = "tag/{}/{}".format(ip, tag.name) key = "tag/{}/{}".format(ip, tag.name)
if not has_key(key): if not has_key(key):
set_temp_key(key, "true") set_key(key, "true")
Tag.query.filter_by(id=tag.id).update({ Tag.query.filter_by(id=tag.id).update({
"views": Tag.views + 1 "views": Tag.views + 1
}) })
@@ -84,7 +80,7 @@ def list_all():
topic = qb.build_topic_query().first() topic = qb.build_topic_query().first()
if qb.search and topic: if qb.search and topic:
return redirect(topic.url) return redirect("https://forum.minetest.net/viewtopic.php?t=" + str(topic.topic_id))
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))
@@ -103,6 +99,7 @@ def list_all():
topics = None topics = None
if qb.search and not query.has_next: if qb.search and not query.has_next:
qb.show_discarded = True
topics = qb.build_topic_query().all() topics = qb.build_topic_query().all()
tags_query = db.session.query(func.count(Tags.c.tag_id), Tag) \ tags_query = db.session.query(func.count(Tags.c.tag_id), Tag) \
@@ -113,7 +110,7 @@ def list_all():
selected_tags = set(qb.tags) selected_tags = set(qb.tags)
return render_template("packages/list.html", return render_template("packages/list.html",
query_hint=qb.query_hint, packages=query.items, pagination=query, query_hint=title, packages=query.items, pagination=query,
query=search, tags=tags, selected_tags=selected_tags, type=type_name, query=search, tags=tags, selected_tags=selected_tags, type=type_name,
authors=authors, packages_count=query.total, topics=topics, noindex=qb.noindex) authors=authors, packages_count=query.total, topics=topics, noindex=qb.noindex)
@@ -136,6 +133,26 @@ def view(package):
if not package.check_perm(current_user, Permission.VIEW_PACKAGE): if not package.check_perm(current_user, Permission.VIEW_PACKAGE):
return render_template("packages/gone.html", package=package), 403 return render_template("packages/gone.html", package=package), 403
show_similar = not package.approved and (
current_user in package.maintainers or
package.check_perm(current_user, Permission.APPROVE_NEW))
conflicting_modnames = None
if show_similar and package.type != PackageType.TXP:
conflicting_modnames = db.session.query(MetaPackage.name) \
.filter(MetaPackage.id.in_([ mp.id for mp in package.provides ])) \
.filter(MetaPackage.packages.any(and_(Package.id != package.id, Package.state == PackageState.APPROVED))) \
.all()
conflicting_modnames += db.session.query(ForumTopic.name) \
.filter(ForumTopic.name.in_([ mp.name for mp in package.provides ])) \
.filter(ForumTopic.topic_id != package.forums) \
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
conflicting_modnames = set([x[0] for x in conflicting_modnames])
packages_uses = None packages_uses = None
if package.type == PackageType.MOD: if package.type == PackageType.MOD:
packages_uses = Package.query.filter( packages_uses = Package.query.filter(
@@ -152,6 +169,24 @@ def view(package):
if review_thread is not None and not review_thread.check_perm(current_user, Permission.SEE_THREAD): if review_thread is not None and not review_thread.check_perm(current_user, Permission.SEE_THREAD):
review_thread = None review_thread = None
topic_error = None
topic_error_lvl = "warning"
if package.state != PackageState.APPROVED and package.forums is not None:
errors = []
if Package.query.filter(Package.forums==package.forums, Package.state!=PackageState.DELETED).count() > 1:
errors.append("<b>" + gettext("Error: Another package already uses this forum topic!") + "</b>")
topic_error_lvl = "danger"
topic = ForumTopic.query.get(package.forums)
if topic is not None:
if topic.author != package.author:
errors.append("<b>" + gettext("Error: Forum topic author doesn't match package author.") + "</b>")
topic_error_lvl = "danger"
elif package.type != PackageType.TXP:
errors.append(gettext("Warning: Forum topic not found. This may happen if the topic has only just been created."))
topic_error = "<br />".join(errors)
threads = Thread.query.filter_by(package_id=package.id, review_id=None) threads = Thread.query.filter_by(package_id=package.id, review_id=None)
if not current_user.is_authenticated: if not current_user.is_authenticated:
threads = threads.filter_by(private=False) threads = threads.filter_by(private=False)
@@ -161,18 +196,6 @@ def view(package):
has_review = current_user.is_authenticated and \ has_review = current_user.is_authenticated and \
PackageReview.query.filter_by(package=package, author=current_user).count() > 0 PackageReview.query.filter_by(package=package, author=current_user).count() > 0
validation = None
if package.state != PackageState.APPROVED:
validation = validate_package_for_approval(package)
favorites_count = Collection.query.filter(
Collection.packages.contains(package),
Collection.name == "favorites").count()
public_collection_count = Collection.query.filter(
Collection.packages.contains(package),
Collection.private == False).count()
is_favorited = current_user.is_authenticated and \ is_favorited = current_user.is_authenticated and \
Collection.query.filter( Collection.query.filter(
Collection.author == current_user, Collection.author == current_user,
@@ -181,9 +204,9 @@ def view(package):
return render_template("packages/view.html", return render_template("packages/view.html",
package=package, releases=releases, packages_uses=packages_uses, package=package, releases=releases, packages_uses=packages_uses,
review_thread=review_thread, threads=threads.all(), validation=validation, conflicting_modnames=conflicting_modnames,
has_review=has_review, favorites_count=favorites_count, is_favorited=is_favorited, review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl,
public_collection_count=public_collection_count) threads=threads.all(), has_review=has_review, is_favorited=is_favorited)
@bp.route("/packages/<author>/<name>/shields/<type>/") @bp.route("/packages/<author>/<name>/shields/<type>/")
@@ -219,12 +242,11 @@ def download(package):
return redirect(release.get_download_url()) return redirect(release.get_download_url())
def make_label(obj: Tag | ContentWarning): def makeLabel(obj):
translated = obj.get_translated() if obj.description:
if translated["description"]: return "{}: {}".format(obj.title, obj.description)
return "{}: {}".format(translated["title"], translated["description"])
else: else:
return translated["title"] return obj.title
class PackageForm(FlaskForm): class PackageForm(FlaskForm):
@@ -233,14 +255,14 @@ 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=makeLabel)
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=makeLabel)
license = QuerySelectField(lazy_gettext("License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name) license = QuerySelectField(lazy_gettext("License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
media_license = QuerySelectField(lazy_gettext("Media License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name) media_license = QuerySelectField(lazy_gettext("Media License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
desc = TextAreaField(lazy_gettext("Long Description (Markdown)"), [Optional(), Length(0,10000)], filters=[normalize_line_endings]) desc = TextAreaField(lazy_gettext("Long Description (Markdown)"), [Optional(), Length(0,10000)])
repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None]) repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None])
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None]) website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
@@ -248,7 +270,6 @@ class PackageForm(FlaskForm):
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0, 999999)]) forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0, 999999)])
video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters=[lambda x: x or None]) video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters=[lambda x: x or None])
donate_url = StringField(lazy_gettext("Donate URL"), [Optional(), URL()], filters=[lambda x: x or None]) donate_url = StringField(lazy_gettext("Donate URL"), [Optional(), URL()], filters=[lambda x: x or None])
translation_url = StringField(lazy_gettext("Translation URL"), [Optional(), URL()], filters=[lambda x: x or None])
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
@@ -266,7 +287,6 @@ def handle_create_edit(package: typing.Optional[Package], form: PackageForm, aut
flash( flash(
gettext("Package already exists, but is removed. Please contact ContentDB staff to restore the package"), gettext("Package already exists, but is removed. Please contact ContentDB staff to restore the package"),
"danger") "danger")
return redirect(url_for("report.report", url=package.get_url("packages.view")))
else: else:
flash(markupsafe.Markup( flash(markupsafe.Markup(
f"<a class='btn btn-sm btn-danger float-end' href='{package.get_url('packages.view')}'>View</a>" + f"<a class='btn btn-sm btn-danger float-end' href='{package.get_url('packages.view')}'>View</a>" +
@@ -303,9 +323,12 @@ def handle_create_edit(package: typing.Optional[Package], form: PackageForm, aut
"forums": form.forums.data, "forums": form.forums.data,
"video_url": form.video_url.data, "video_url": form.video_url.data,
"donate_url": form.donate_url.data, "donate_url": form.donate_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,22 +341,15 @@ 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):
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"))
package = None package = None
if author is None: if author is None:
form = PackageForm(formdata=request.form) form = PackageForm(formdata=request.form)
form.submit.label.text = lazy_gettext("Save draft")
author = request.args.get("author") author = request.args.get("author")
if author is None or author == current_user.username: if author is None or author == current_user.username:
author = current_user author = current_user
@@ -352,7 +368,7 @@ def create_edit(author=None, name=None):
if package is None: if package is None:
abort(404) abort(404)
if not package.check_perm(current_user, Permission.EDIT_PACKAGE): if not package.check_perm(current_user, Permission.EDIT_PACKAGE):
abort(403) return redirect(package.get_url("packages.view"))
author = package.author author = package.author
@@ -399,14 +415,10 @@ def move_to_state(package):
if state is None: if state is None:
abort(400) abort(400)
if package.state == state: if not package.can_move_to_state(current_user, state):
return redirect(package.get_url("packages.view"))
if not can_move_to_state(package, current_user, state):
flash(gettext("You don't have permission to do that"), "danger") flash(gettext("You don't have permission to do that"), "danger")
return redirect(package.get_url("packages.view")) return redirect(package.get_url("packages.view"))
old_state = package.state
package.state = state package.state = state
msg = "Marked {} as {}".format(package.title, state.value) msg = "Marked {} as {}".format(package.title, state.value)
@@ -414,7 +426,7 @@ def move_to_state(package):
if not package.approved_at: if not package.approved_at:
post_discord_webhook.delay(package.author.display_name, post_discord_webhook.delay(package.author.display_name,
"New package {}".format(package.get_url("packages.view", absolute=True)), False, "New package {}".format(package.get_url("packages.view", absolute=True)), False,
package.title, package.short_desc, package.get_thumb_url(2, True, "png")) package.title, package.short_desc, package.get_thumb_url(2, True))
package.approved_at = datetime.datetime.now() package.approved_at = datetime.datetime.now()
screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all() screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all()
@@ -422,78 +434,36 @@ def move_to_state(package):
s.approved = True s.approved = True
msg = "Approved {}".format(package.title) msg = "Approved {}".format(package.title)
update_package_game_support.delay(package.id)
elif state == PackageState.READY_FOR_REVIEW: elif state == PackageState.READY_FOR_REVIEW:
post_discord_webhook.delay(package.author.display_name, post_discord_webhook.delay(package.author.display_name,
"Ready for Review: {}".format(package.get_url("packages.view", absolute=True)), True, "Ready for Review: {}".format(package.get_url("packages.view", absolute=True)), True,
package.title, package.short_desc, package.get_thumb_url(2, True, "png")) package.title, package.short_desc, package.get_thumb_url(2, True))
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.get_url("packages.view"), package) add_notification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.get_url("packages.view"), package)
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
add_audit_log(severity, current_user, msg, package.get_url("packages.view"), package) add_audit_log(severity, current_user, msg, package.get_url("packages.view"), package)
post_to_approval_thread(package, current_user, msg, True)
db.session.commit() db.session.commit()
check_package_on_submit.delay(package.id)
if package.state == PackageState.CHANGES_NEEDED: if package.state == PackageState.CHANGES_NEEDED:
flash(gettext("Please comment what changes are needed in the approval thread"), "warning") flash(gettext("Please comment what changes are needed in the approval thread"), "warning")
if package.review_thread: if package.review_thread:
return redirect(package.review_thread.get_view_url()) return redirect(package.review_thread.get_view_url())
else: else:
return redirect(url_for('threads.new', pid=package.id, title='Package approval comments')) return redirect(url_for('threads.new', pid=package.id, title='Package approval comments'))
elif (package.review_thread and
old_state == PackageState.CHANGES_NEEDED and package.state == PackageState.READY_FOR_REVIEW):
flash(gettext("Please comment in the approval thread so editors know what you have changed"), "warning")
return redirect(package.review_thread.get_view_url())
return redirect(package.get_url("packages.view")) return redirect(package.get_url("packages.view"))
@bp.route("/packages/<author>/<name>/translation/")
@login_required
@is_package_page
def translation(package):
return render_template("packages/translation.html", package=package,
has_content_translations=any([x.title or x.short_desc for x in package.translations.all()]),
tabs=get_package_tabs(current_user, package), current_tab="translation")
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
@login_required @login_required
@is_package_page @is_package_page
def remove(package): def remove(package):
if not package.check_perm(current_user, Permission.EDIT_PACKAGE):
abort(403)
states = [PackageDevState.AS_IS, PackageDevState.DEPRECATED, PackageDevState.LOOKING_FOR_MAINTAINER]
if request.method == "GET": if request.method == "GET":
# Find packages that will having missing hard deps after this action return render_template("packages/remove.html", package=package,
broken_meta = MetaPackage.query.filter(MetaPackage.packages.contains(package),
~MetaPackage.packages.any(and_(Package.id != package.id, Package.state == PackageState.APPROVED)))
hard_deps = Package.query.filter(
Package.state == PackageState.APPROVED,
Package.dependencies.any(
and_(Dependency.meta_package_id.in_([x.id for x in broken_meta]), Dependency.optional == False))).all()
return render_template("packages/remove.html",
package=package, hard_deps=hard_deps, states=states,
tabs=get_package_tabs(current_user, package), current_tab="remove") tabs=get_package_tabs(current_user, package), current_tab="remove")
for state in states:
if state.name in request.form:
flash(gettext("Set state to %(state)s", state=state.title), "success")
package.dev_state = state
msg = "Set dev state of {} to {}".format(package.title, state.title)
add_audit_log(AuditSeverity.NORMAL, current_user, msg, package.get_url("packages.view"), package)
db.session.commit()
return redirect(package.get_url("packages.view"))
reason = request.form.get("reason") or "?" reason = request.form.get("reason") or "?"
if len(reason) > 500:
abort(400)
if "delete" in request.form: if "delete" in request.form:
if not package.check_perm(current_user, Permission.DELETE_PACKAGE): if not package.check_perm(current_user, Permission.DELETE_PACKAGE):
@@ -510,9 +480,7 @@ def remove(package):
post_discord_webhook.delay(current_user.username, post_discord_webhook.delay(current_user.username,
f"Deleted package {package.author.username}/{package.name} with reason '{reason}'", f"Deleted package {package.author.username}/{package.name} with reason '{reason}'",
True, package.title, package.short_desc, package.get_thumb_url(2, True, "png")) True, package.title, package.short_desc, package.get_thumb_url(2, True))
remove_package_game_support.delay(package.id)
flash(gettext("Deleted package"), "success") flash(gettext("Deleted package"), "success")
@@ -532,9 +500,7 @@ def remove(package):
post_discord_webhook.delay(current_user.username, post_discord_webhook.delay(current_user.username,
"Unapproved package with reason {}\n\n{}".format(reason, package.get_url("packages.view", absolute=True)), True, "Unapproved package with reason {}\n\n{}".format(reason, package.get_url("packages.view", absolute=True)), True,
package.title, package.short_desc, package.get_thumb_url(2, True, "png")) package.title, package.short_desc, package.get_thumb_url(2, True))
remove_package_game_support.delay(package.id)
flash(gettext("Unapproved package"), "success") flash(gettext("Unapproved package"), "success")
@@ -569,7 +535,7 @@ def edit_maintainers(package):
for user in users: for user in users:
if not user in package.maintainers: if not user in package.maintainers:
if thread and user not in thread.watchers: if thread:
thread.watchers.append(user) thread.watchers.append(user)
add_notification(user, current_user, NotificationType.MAINTAINER, add_notification(user, current_user, NotificationType.MAINTAINER,
"Added you as a maintainer of {}".format(package.title), package.get_url("packages.view"), package) "Added you as a maintainer of {}".format(package.title), package.get_url("packages.view"), package)
@@ -708,27 +674,10 @@ def similar(package):
packages_modnames=packages_modnames, similar_topics=similar_topics) packages_modnames=packages_modnames, similar_topics=similar_topics)
def csv_games_check(_form, field):
game_names = [name.strip() for name in field.data.split(",")]
if len(game_names) == 0 or (len(game_names) == 1 and game_names[0] == ""):
return
missing = set()
for game_name in game_names:
if game_name.endswith("_game"):
game_name = game_name[:-5]
if Package.query.filter(and_(Package.state==PackageState.APPROVED, Package.type==PackageType.GAME,
or_(Package.name==game_name, Package.name==game_name + "_game"))).count() == 0:
missing.add(game_name)
if len(missing) > 0:
raise ValidationError(f"Unable to find game {','.join(missing)}")
class GameSupportForm(FlaskForm): class GameSupportForm(FlaskForm):
enable_support_detection = BooleanField(lazy_gettext("Enable support detection based on dependencies (recommended)"), [Optional()]) enable_support_detection = BooleanField(lazy_gettext("Enable support detection based on dependencies (recommended)"), [Optional()])
supported = StringField(lazy_gettext("Supported games"), [Optional(), csv_games_check]) supported = StringField(lazy_gettext("Supported games"), [Optional()])
unsupported = StringField(lazy_gettext("Unsupported games"), [Optional(), csv_games_check]) unsupported = StringField(lazy_gettext("Unsupported games"), [Optional()])
supports_all_games = BooleanField(lazy_gettext("Supports all games (unless stated) / is game independent"), [Optional()]) supports_all_games = BooleanField(lazy_gettext("Supports all games (unless stated) / is game independent"), [Optional()])
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
@@ -779,12 +728,14 @@ def game_support(package):
if can_override: if can_override:
try: try:
resolver = GameSupportResolver(db.session)
game_is_supported = {} game_is_supported = {}
for game in get_games_from_csv(db.session, form.supported.data or ""): for game in get_games_from_csv(db.session, form.supported.data or ""):
game_is_supported[game.id] = True game_is_supported[game.id] = True
for game in get_games_from_csv(db.session, form.unsupported.data or ""): for game in get_games_from_csv(db.session, form.unsupported.data or ""):
game_is_supported[game.id] = False game_is_supported[game.id] = False
game_support_set(db.session, package, game_is_supported, 11) resolver.set_supported(package, game_is_supported, 11)
detect_update_needed = True detect_update_needed = True
except LogicError as e: except LogicError as e:
flash(e.message, "danger") flash(e.message, "danger")

View File

@@ -13,23 +13,21 @@
# #
# 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
from flask import render_template, request, redirect, flash, url_for, abort 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 wtforms import StringField, SubmitField, BooleanField, RadioField, FileField from wtforms import StringField, SubmitField, BooleanField, RadioField, FileField
from wtforms.fields.simple import TextAreaField
from wtforms.validators import InputRequired, Length, Optional 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_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
from . import bp, get_package_tabs from . import bp, get_package_tabs
@@ -42,41 +40,35 @@ 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
class CreatePackageReleaseForm(FlaskForm): class CreatePackageReleaseForm(FlaskForm):
name = StringField(lazy_gettext("Name"), [InputRequired(), Length(1, 30)]) title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
title = StringField(lazy_gettext("Title"), [Optional(), Length(1, 100)], filters=[nonempty_or_none]) uploadOpt = RadioField(lazy_gettext("Method"), choices=[("upload", lazy_gettext("File Upload"))], default="upload")
release_notes = TextAreaField(lazy_gettext("Release Notes"), [Optional(), Length(1, 5000)], vcsLabel = StringField(lazy_gettext("Git reference (ie: commit hash, branch, or tag)"), default=None)
filters=[nonempty_or_none, normalize_line_endings])
upload_mode = RadioField(lazy_gettext("Method"), choices=[("upload", lazy_gettext("File Upload"))], default="upload")
vcs_label = StringField(lazy_gettext("Git reference (ie: commit hash, branch, or tag)"), default=None)
file_upload = FileField(lazy_gettext("File Upload")) file_upload = FileField(lazy_gettext("File Upload"))
min_rel = QuerySelectField(lazy_gettext("Minimum Luanti Version"), [InputRequired()], min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name) query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
max_rel = QuerySelectField(lazy_gettext("Maximum Luanti Version"), [InputRequired()], max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name) query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
class EditPackageReleaseForm(FlaskForm): class EditPackageReleaseForm(FlaskForm):
name = StringField(lazy_gettext("Name"), [InputRequired(), Length(1, 30)]) title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)], filters=[nonempty_or_none])
release_notes = TextAreaField(lazy_gettext("Release Notes"), [Optional(), Length(1, 5000)],
filters=[nonempty_or_none, normalize_line_endings])
url = StringField(lazy_gettext("URL"), [Optional()]) url = StringField(lazy_gettext("URL"), [Optional()])
task_id = StringField(lazy_gettext("Task ID"), filters = [lambda x: x or None]) task_id = StringField(lazy_gettext("Task ID"), filters = [lambda x: x or None])
approved = BooleanField(lazy_gettext("Is Approved")) approved = BooleanField(lazy_gettext("Is Approved"))
min_rel = QuerySelectField(lazy_gettext("Minimum Luanti Version"), [InputRequired()], min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name) query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
max_rel = QuerySelectField(lazy_gettext("Maximum Luanti Version"), [InputRequired()], max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name) query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
@@ -85,31 +77,27 @@ 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):
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"))
if not package.check_perm(current_user, Permission.MAKE_RELEASE): if not package.check_perm(current_user, Permission.MAKE_RELEASE):
return redirect(package.get_url("packages.view")) return redirect(package.get_url("packages.view"))
# Initial form class from post data and default data # Initial form class from post data and default data
form = CreatePackageReleaseForm() form = CreatePackageReleaseForm()
if package.repo is not None: if package.repo is not None:
form.upload_mode.choices = [("vcs", gettext("Import from Git")), ("upload", gettext("Upload .zip file"))] form["uploadOpt"].choices = [("vcs", gettext("Import from Git")), ("upload", gettext("Upload .zip file"))]
if request.method == "GET": if request.method == "GET":
form.upload_mode.data = "vcs" form["uploadOpt"].data = "vcs"
form.vcs_label.data = request.args.get("ref") form.vcsLabel.data = request.args.get("ref")
if request.method == "GET": if request.method == "GET":
form.title.data = request.args.get("title") form.title.data = request.args.get("title")
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
if form.upload_mode.data == "vcs": if form["uploadOpt"].data == "vcs":
rel = do_create_vcs_release(current_user, package, form.name.data, form.title.data, form.release_notes.data, rel = do_create_vcs_release(current_user, package, form.title.data,
form.vcs_label.data, form.min_rel.data.get_actual(), form.max_rel.data.get_actual()) form.vcsLabel.data, form.min_rel.data.get_actual(), form.max_rel.data.get_actual())
else: else:
rel = do_create_zip_release(current_user, package, form.name.data, form.title.data, form.release_notes.data, rel = do_create_zip_release(current_user, package, form.title.data,
form.file_upload.data, form.min_rel.data.get_actual(), form.max_rel.data.get_actual()) form.file_upload.data, form.min_rel.data.get_actual(), form.max_rel.data.get_actual())
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.get_edit_url())) return redirect(url_for("tasks.check", id=rel.task_id, r=rel.get_edit_url()))
except LogicError as e: except LogicError as e:
@@ -127,14 +115,13 @@ 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 "" is_minetest = (request.headers.get("User-Agent") or "").startswith("Minetest")
is_luanti = user_agent.startswith("Luanti") or user_agent.startswith("Minetest")
reason = request.args.get("reason") reason = request.args.get("reason")
PackageDailyStats.update(package, is_luanti, reason) PackageDailyStats.update(package, is_minetest, 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):
set_temp_key(key, "true") set_key(key, "true")
bonus = 0 bonus = 0
if reason == "new": if reason == "new":
@@ -157,21 +144,11 @@ def download_release(package, id):
return redirect(release.url) return redirect(release.url)
@bp.route("/packages/<author>/<name>/releases/<int:id>/") @bp.route("/packages/<author>/<name>/releases/<int:id>/", methods=["GET", "POST"])
@is_package_page
def view_release(package, id):
release: PackageRelease = PackageRelease.query.get(id)
if release is None or release.package != package:
abort(404)
return render_template("packages/release_view.html", package=package, release=release)
@bp.route("/packages/<author>/<name>/releases/<int:id>/edit/", methods=["GET", "POST"])
@login_required @login_required
@is_package_page @is_package_page
def edit_release(package, id): def edit_release(package, id):
release: PackageRelease = PackageRelease.query.get(id) release : PackageRelease = PackageRelease.query.get(id)
if release is None or release.package != package: if release is None or release.package != package:
abort(404) abort(404)
@@ -189,15 +166,13 @@ def edit_release(package, id):
if form.validate_on_submit(): if form.validate_on_submit():
if canEdit: if canEdit:
release.name = form.name.data release.title = form["title"].data
release.title = form.title.data release.min_rel = form["min_rel"].data.get_actual()
release.release_notes = form.release_notes.data release.max_rel = form["max_rel"].data.get_actual()
release.min_rel = form.min_rel.data.get_actual()
release.max_rel = form.max_rel.data.get_actual()
if package.check_perm(current_user, Permission.CHANGE_RELEASE_URL): if package.check_perm(current_user, Permission.CHANGE_RELEASE_URL):
release.url = form.url.data release.url = form["url"].data
release.task_id = form.task_id.data release.task_id = form["task_id"].data
if release.task_id is not None: if release.task_id is not None:
release.task_id = None release.task_id = None
@@ -215,10 +190,10 @@ def edit_release(package, id):
class BulkReleaseForm(FlaskForm): class BulkReleaseForm(FlaskForm):
set_min = BooleanField(lazy_gettext("Set Min")) set_min = BooleanField(lazy_gettext("Set Min"))
min_rel = QuerySelectField(lazy_gettext("Minimum Luanti Version"), [InputRequired()], min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name) query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
set_max = BooleanField(lazy_gettext("Set Max")) set_max = BooleanField(lazy_gettext("Set Max"))
max_rel = QuerySelectField(lazy_gettext("Maximum Luanti Version"), [InputRequired()], max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name) query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
only_change_none = BooleanField(lazy_gettext("Only change values previously set as none")) only_change_none = BooleanField(lazy_gettext("Only change values previously set as none"))
submit = SubmitField(lazy_gettext("Update")) submit = SubmitField(lazy_gettext("Update"))
@@ -240,10 +215,10 @@ def bulk_change_release(package):
only_change_none = form.only_change_none.data only_change_none = form.only_change_none.data
for release in package.releases.all(): for release in package.releases.all():
if form.set_min.data and (not only_change_none or release.min_rel is None): if form["set_min"].data and (not only_change_none or release.min_rel is None):
release.min_rel = form.min_rel.data.get_actual() release.min_rel = form["min_rel"].data.get_actual()
if form.set_max.data and (not only_change_none or release.max_rel is None): if form["set_max"].data and (not only_change_none or release.max_rel is None):
release.max_rel = form.max_rel.data.get_actual() release.max_rel = form["max_rel"].data.get_actual()
db.session.commit() db.session.commit()
@@ -266,9 +241,6 @@ def delete_release(package, id):
db.session.delete(release) db.session.delete(release)
db.session.commit() db.session.commit()
if release.file_path and os.path.isfile(release.file_path):
os.remove(release.file_path)
return redirect(package.get_url("packages.view")) return redirect(package.get_url("packages.view"))

View File

@@ -18,18 +18,17 @@ from collections import namedtuple
import typing import typing
from flask import render_template, request, redirect, flash, url_for, abort, jsonify from flask import render_template, request, redirect, flash, url_for, abort, jsonify
from flask_babel import gettext, lazy_gettext, get_locale from flask_babel import gettext, lazy_gettext
from flask_login import current_user, login_required from flask_login import current_user, login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField, RadioField from wtforms import StringField, TextAreaField, SubmitField, RadioField
from wtforms.validators import InputRequired, Length, DataRequired from wtforms.validators import InputRequired, Length
from wtforms_sqlalchemy.fields import QuerySelectField
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \ from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
Permission, AuditSeverity, PackageState, Language Permission, AuditSeverity, PackageState
from app.tasks.webhooktasks import post_discord_webhook from app.tasks.webhooktasks import post_discord_webhook
from app.utils import is_package_page, add_notification, get_int_or_abort, is_yes, is_safe_url, rank_required, \ from app.utils import is_package_page, add_notification, get_int_or_abort, is_yes, is_safe_url, rank_required, \
add_audit_log, has_blocked_domains, should_return_json, normalize_line_endings add_audit_log, has_blocked_domains, should_return_json
from . import bp from . import bp
@@ -42,22 +41,9 @@ def list_reviews():
return render_template("packages/reviews_list.html", pagination=pagination, reviews=pagination.items) return render_template("packages/reviews_list.html", pagination=pagination, reviews=pagination.items)
def get_default_language():
locale = get_locale()
if locale:
return Language.query.filter_by(id=locale.language).first()
return None
class ReviewForm(FlaskForm): class ReviewForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3, 100)]) title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3, 100)])
language = QuerySelectField(lazy_gettext("Language"), [DataRequired()], comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
allow_blank=True,
query_factory=lambda: Language.query.order_by(db.asc(Language.title)),
get_pk=lambda a: a.id,
get_label=lambda a: a.title,
default=get_default_language)
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)], filters=[normalize_line_endings])
rating = RadioField(lazy_gettext("Rating"), [InputRequired()], rating = RadioField(lazy_gettext("Rating"), [InputRequired()],
choices=[("5", lazy_gettext("Yes")), ("3", lazy_gettext("Neutral")), ("1", lazy_gettext("No"))]) choices=[("5", lazy_gettext("Yes")), ("3", lazy_gettext("Neutral")), ("1", lazy_gettext("No"))])
btn_submit = SubmitField(lazy_gettext("Save")) btn_submit = SubmitField(lazy_gettext("Save"))
@@ -102,7 +88,6 @@ def review(package):
db.session.add(review) db.session.add(review)
review.rating = int(form.rating.data) review.rating = int(form.rating.data)
review.language = form.language.data
thread = review.thread thread = review.thread
if not thread: if not thread:
@@ -143,8 +128,8 @@ def review(package):
url_for("threads.view", id=thread.id), package) url_for("threads.view", id=thread.id), package)
if was_new: if was_new:
msg = f"Reviewed {package.title} ({review.language.title}): {thread.get_view_url(absolute=True)}" post_discord_webhook.delay(thread.author.display_name,
post_discord_webhook.delay(thread.author.display_name, msg, False) "Reviewed {}: {}".format(package.title, thread.get_view_url(absolute=True)), False)
db.session.commit() db.session.commit()
@@ -260,17 +245,15 @@ def review_votes(package):
else: else:
user_biases[vote.user.username][1] += 1 user_biases[vote.user.username][1] += 1
reviews = package.reviews.all()
BiasInfo = namedtuple("BiasInfo", "username balance with_ against no_vote perc_with") BiasInfo = namedtuple("BiasInfo", "username balance with_ against no_vote perc_with")
user_biases_info = [] user_biases_info = []
for username, bias in user_biases.items(): for username, bias in user_biases.items():
total_votes = bias[0] + bias[1] total_votes = bias[0] + bias[1]
balance = bias[0] - bias[1] balance = bias[0] - bias[1]
perc_with = round((100 * bias[0]) / total_votes) perc_with = round((100 * bias[0]) / total_votes)
user_biases_info.append(BiasInfo(username, balance, bias[0], bias[1], len(reviews) - total_votes, perc_with)) user_biases_info.append(BiasInfo(username, balance, bias[0], bias[1], len(package.reviews) - total_votes, perc_with))
user_biases_info.sort(key=lambda x: -abs(x.balance)) user_biases_info.sort(key=lambda x: -abs(x.balance))
return render_template("packages/review_votes.html", package=package, reviews=reviews, return render_template("packages/review_votes.html", package=package, reviews=package.reviews,
user_biases=user_biases_info) user_biases=user_biases_info)

View File

@@ -13,15 +13,13 @@
# #
# 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
from flask import render_template, request, redirect, flash, url_for, abort 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 +31,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"))
@@ -113,10 +111,10 @@ def edit_screenshot(package, id):
was_approved = screenshot.approved was_approved = screenshot.approved
if can_edit: if can_edit:
screenshot.title = form.title.data or "Untitled" screenshot.title = form["title"].data or "Untitled"
if can_approve: if can_approve:
screenshot.approved = form.approved.data screenshot.approved = form["approved"].data
else: else:
screenshot.approved = was_approved screenshot.approved = was_approved
@@ -145,6 +143,4 @@ def delete_screenshot(package, id):
db.session.delete(screenshot) db.session.delete(screenshot)
db.session.commit() db.session.commit()
os.remove(screenshot.file_path)
return redirect(package.get_url("packages.screenshots")) return redirect(package.get_url("packages.screenshots"))

View File

@@ -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
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)])
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,22 @@ 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":
try:
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)

View File

@@ -55,10 +55,7 @@ def check(id):
if current_user.is_authenticated and current_user.rank.at_least(UserRank.ADMIN): if current_user.is_authenticated and current_user.rank.at_least(UserRank.ADMIN):
info["error"] = str(traceback) info["error"] = str(traceback)
elif str(result)[1:12] == "TaskError: ": elif str(result)[1:12] == "TaskError: ":
if hasattr(result, "value"): info["error"] = str(result)[12:-1]
info["error"] = result.value
else:
info["error"] = str(result)
else: else:
info["error"] = "Unknown server error" info["error"] = "Unknown server error"
else: else:

View File

@@ -16,7 +16,8 @@
from flask import Blueprint, request, render_template, abort, flash, redirect, url_for from flask import Blueprint, request, render_template, abort, flash, redirect, url_for
from flask_babel import gettext, lazy_gettext from flask_babel import gettext, lazy_gettext
from sqlalchemy.orm import selectinload from sqlalchemy import or_
from sqlalchemy.orm import selectinload, joinedload
from app.markdown import get_user_mentions, render_markdown from app.markdown import get_user_mentions, render_markdown
from app.tasks.webhooktasks import post_discord_webhook from app.tasks.webhooktasks import post_discord_webhook
@@ -26,10 +27,9 @@ bp = Blueprint("threads", __name__)
from flask_login import current_user, login_required from flask_login import current_user, login_required
from app.models import Package, db, User, Permission, Thread, UserRank, AuditSeverity, \ from app.models import Package, db, User, Permission, Thread, UserRank, AuditSeverity, \
NotificationType, ThreadReply NotificationType, ThreadReply
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
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
@@ -178,7 +178,7 @@ def delete_reply(id):
class CommentForm(FlaskForm): class CommentForm(FlaskForm):
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(2, 2000)], filters=[normalize_line_endings]) comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(2, 2000)])
btn_submit = SubmitField(lazy_gettext("Comment")) btn_submit = SubmitField(lazy_gettext("Comment"))
@@ -254,15 +254,11 @@ 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)
if mentioned not in thread.watchers: thread.watchers.append(mentioned)
thread.watchers.append(mentioned)
msg = "New comment on '{}'".format(thread.title) msg = "New comment on '{}'".format(thread.title)
add_notification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.get_view_url(), thread.package) add_notification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.get_view_url(), thread.package)
@@ -283,7 +279,8 @@ def view(id):
class ThreadForm(FlaskForm): class ThreadForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)]) title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)], filters=[normalize_line_endings]) comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
private = BooleanField(lazy_gettext("Private"))
btn_submit = SubmitField(lazy_gettext("Open Thread")) btn_submit = SubmitField(lazy_gettext("Open Thread"))
@@ -298,11 +295,14 @@ def new():
if package is None: if package is None:
abort(404) abort(404)
def_is_private = request.args.get("private") or False
if package is None and not current_user.rank.at_least(UserRank.APPROVER): if package is None and not current_user.rank.at_least(UserRank.APPROVER):
abort(404) abort(404)
is_review_thread = package and not package.approved is_review_thread = package and not package.approved
is_private_thread = is_review_thread allow_private_change = not is_review_thread
if is_review_thread:
def_is_private = True
# Check that user can make the thread # Check that user can make the thread
if package and not package.check_perm(current_user, Permission.CREATE_THREAD): if package and not package.check_perm(current_user, Permission.CREATE_THREAD):
@@ -325,6 +325,7 @@ def new():
# Set default values # Set default values
elif request.method == "GET": elif request.method == "GET":
form.private.data = def_is_private
form.title.data = request.args.get("title") or "" form.title.data = request.args.get("title") or ""
# Validate and submit # Validate and submit
@@ -335,7 +336,7 @@ def new():
thread = Thread() thread = Thread()
thread.author = current_user thread.author = current_user
thread.title = form.title.data thread.title = form.title.data
thread.private = is_private_thread thread.private = form.private.data if allow_private_change else def_is_private
thread.package = package thread.package = package
db.session.add(thread) db.session.add(thread)
@@ -365,8 +366,7 @@ def new():
add_notification(mentioned, current_user, NotificationType.NEW_THREAD, add_notification(mentioned, current_user, NotificationType.NEW_THREAD,
msg, thread.get_view_url(), thread.package) msg, thread.get_view_url(), thread.package)
if mentioned not in thread.watchers: thread.watchers.append(mentioned)
thread.watchers.append(mentioned)
notif_msg = "New thread '{}'".format(thread.title) notif_msg = "New thread '{}'".format(thread.title)
if package is not None: if package is not None:
@@ -383,7 +383,7 @@ def new():
return redirect(thread.get_view_url()) return redirect(thread.get_view_url())
return render_template("threads/new.html", form=form, package=package) return render_template("threads/new.html", form=form, allow_private_change=allow_private_change, package=package)
@bp.route("/users/<username>/comments/") @bp.route("/users/<username>/comments/")

View File

@@ -14,23 +14,15 @@
# 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 re
import requests
from flask import abort, send_file, Blueprint, current_app, request
import os
from PIL import Image
from flask import abort, send_file, Blueprint, current_app
bp = Blueprint("thumbnails", __name__) bp = Blueprint("thumbnails", __name__)
import os
from PIL import Image
ALLOWED_RESOLUTIONS = [(100, 67), (270, 180), (350, 233), (1100, 520)] ALLOWED_RESOLUTIONS=[(100,67), (270,180), (350,233), (1100,520)]
ALLOWED_MIMETYPES = {
"png": "image/png",
"webp": "image/webp",
"jpg": "image/jpeg",
}
def mkdir(path): def mkdir(path):
assert path != "" and path is not None assert path != "" and path is not None
@@ -42,62 +34,34 @@ def mkdir(path):
def resize_and_crop(img_path, modified_path, size): def resize_and_crop(img_path, modified_path, size):
with Image.open(img_path) as img: try:
# Get current and desired ratio for the images img = Image.open(img_path)
img_ratio = img.size[0] / float(img.size[1]) except FileNotFoundError:
desired_ratio = size[0] / float(size[1])
# Is more portrait than target, scale and crop
if desired_ratio > img_ratio:
img = img.resize((int(size[0]), int(size[0] * img.size[1] / img.size[0])),
Image.BICUBIC)
box = (0, (img.size[1] - size[1]) / 2, img.size[0], (img.size[1] + size[1]) / 2)
img = img.crop(box)
# Is more landscape than target, scale and crop
elif desired_ratio < img_ratio:
img = img.resize((int(size[1] * img.size[0] / img.size[1]), int(size[1])),
Image.BICUBIC)
box = ((img.size[0] - size[0]) / 2, 0, (img.size[0] + size[0]) / 2, img.size[1])
img = img.crop(box)
# Is exactly the same ratio as target
else:
img = img.resize(size, Image.BICUBIC)
if modified_path.endswith(".jpg") and img.mode != "RGB":
img = img.convert("RGB")
img.save(modified_path, lossless=True)
def find_source_file(img):
upload_dir = current_app.config["UPLOAD_DIR"]
source_filepath = os.path.join(upload_dir, img)
if os.path.isfile(source_filepath):
return source_filepath
period = source_filepath.rfind(".")
start = source_filepath[:period]
ext = source_filepath[period + 1:]
if ext not in ALLOWED_MIMETYPES:
abort(404) abort(404)
for other_ext in ALLOWED_MIMETYPES.keys(): # Get current and desired ratio for the images
other_path = f"{start}.{other_ext}" img_ratio = img.size[0] / float(img.size[1])
if ext != other_ext and os.path.isfile(other_path): ratio = size[0] / float(size[1])
return other_path
abort(404) # Is more portrait than target, scale and crop
if ratio > img_ratio:
img = img.resize((int(size[0]), int(size[0] * img.size[1] / img.size[0])),
Image.BICUBIC)
box = (0, (img.size[1] - size[1]) / 2, img.size[0], (img.size[1] + size[1]) / 2)
img = img.crop(box)
# Is more landscape than target, scale and crop
elif ratio < img_ratio:
img = img.resize((int(size[1] * img.size[0] / img.size[1]), int(size[1])),
Image.BICUBIC)
box = ((img.size[0] - size[0]) / 2, 0, (img.size[0] + size[0]) / 2, img.size[1])
img = img.crop(box)
def get_mimetype(cache_filepath: str) -> str: # Is exactly the same ratio as target
period = cache_filepath.rfind(".") else:
ext = cache_filepath[period + 1:] img = img.resize(size, Image.BICUBIC)
mimetype = ALLOWED_MIMETYPES.get(ext)
if mimetype is None: img.save(modified_path)
abort(404)
return mimetype
@bp.route("/thumbnails/<int:level>/<img>") @bp.route("/thumbnails/<int:level>/<img>")
@@ -106,40 +70,15 @@ def make_thumbnail(img, level):
abort(403) abort(403)
w, h = ALLOWED_RESOLUTIONS[level - 1] w, h = ALLOWED_RESOLUTIONS[level - 1]
upload_dir = current_app.config["UPLOAD_DIR"]
thumbnail_dir = current_app.config["THUMBNAIL_DIR"] thumbnail_dir = current_app.config["THUMBNAIL_DIR"]
mkdir(thumbnail_dir) mkdir(thumbnail_dir)
output_dir = os.path.join(thumbnail_dir, str(level)) output_dir = os.path.join(thumbnail_dir, str(level))
mkdir(output_dir) mkdir(output_dir)
cache_filepath = os.path.join(output_dir, img) cache_filepath = os.path.join(output_dir, img)
if not os.path.isfile(cache_filepath): source_filepath = os.path.join(upload_dir, img)
source_filepath = find_source_file(img)
resize_and_crop(source_filepath, cache_filepath, (w, h))
res = send_file(cache_filepath, mimetype=get_mimetype(cache_filepath)) resize_and_crop(source_filepath, cache_filepath, (w, h))
res.headers["Cache-Control"] = "max-age=604800" # 1 week return send_file(cache_filepath)
return res
@bp.route("/thumbnails/youtube/<id_>.jpg")
def youtube(id_: str):
if not re.match(r"^[A-Za-z0-9\-_]+$", id_):
abort(400)
cache_dir = os.path.join(current_app.config["THUMBNAIL_DIR"], "youtube")
os.makedirs(cache_dir, exist_ok=True)
cache_filepath = os.path.join(cache_dir, id_ + ".jpg")
url = f"https://img.youtube.com/vi/{id_}/default.jpg"
response = requests.get(url, stream=True)
if response.status_code != 200:
abort(response.status_code)
with open(cache_filepath, "wb") as file:
file.write(response.content)
res = send_file(cache_filepath)
res.headers["Cache-Control"] = "max-age=604800" # 1 week
return res

View File

@@ -17,12 +17,12 @@
from flask import redirect, url_for, abort, render_template, request from flask import redirect, url_for, abort, render_template, request
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import or_, and_ from sqlalchemy import or_
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
from . import bp from . import bp
@@ -44,7 +44,7 @@ def view_editor():
releases = None releases = None
if can_approve_rel: if can_approve_rel:
releases = PackageRelease.query.filter_by(approved=False, task_id=None).all() releases = PackageRelease.query.filter_by(approved=False).all()
screenshots = None screenshots = None
if can_approve_scn: if can_approve_scn:
@@ -83,19 +83,49 @@ 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/topics/")
@login_required
def topics():
qb = QueryBuilder(request.args)
qb.set_sort_if_none("date")
query = qb.build_topic_query()
tmp_q = ForumTopic.query
if not qb.show_discarded:
tmp_q = tmp_q.filter_by(discarded=False)
total = tmp_q.count()
topic_count = query.count()
page = get_int_or_abort(request.args.get("page"), 1)
num = get_int_or_abort(request.args.get("n"), 100)
if num > 100 and not current_user.rank.at_least(UserRank.APPROVER):
num = 100
query = query.paginate(page=page, per_page=num)
next_url = url_for("todo.topics", page=query.next_num, query=qb.search,
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
if query.has_next else None
prev_url = url_for("todo.topics", page=query.prev_num, query=qb.search,
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
if query.has_prev else None
return render_template("todo/topics.html", current_tab="topics", topics=query.items, total=total,
topic_count=topic_count, query=qb.search, show_discarded=qb.show_discarded,
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages,
n=num, sort_by=qb.order_by)
@bp.route("/todo/tags/") @bp.route("/todo/tags/")
@login_required @login_required
def tags(): def tags():
qb = QueryBuilder(request.args, cookies=True) qb = QueryBuilder(request.args)
qb.set_sort_if_none("score", "desc") qb.set_sort_if_none("score", "desc")
query = qb.build_package_query() query = qb.build_package_query()
@@ -172,7 +202,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))) \
@@ -190,28 +220,3 @@ def mtver_support():
return render_template("todo/mtver_support.html", current_tab="screenshots", return render_template("todo/mtver_support.html", current_tab="screenshots",
packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only, current_stable=current_stable) packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only, current_stable=current_stable)
@bp.route("/todo/topics/mismatch/")
@rank_required(UserRank.EDITOR)
def topics_mismatch():
missing_topics = Package.query.filter(Package.forums.is_not(None)) .filter(~ForumTopic.query.filter(ForumTopic.topic_id == Package.forums).exists()).all()
packages_bad_author = (
db.session.query(Package, ForumTopic)
.select_from(Package)
.join(ForumTopic, Package.forums == ForumTopic.topic_id)
.filter(Package.author_id != ForumTopic.author_id)
.all())
packages_bad_title = (
db.session.query(Package, ForumTopic)
.select_from(Package)
.join(ForumTopic, Package.forums == ForumTopic.topic_id)
.filter(and_(ForumTopic.name != Package.name, ~ForumTopic.title.ilike("%" + Package.title + "%"), ~ForumTopic.title.ilike("%" + Package.name + "%")))
.all())
return render_template("todo/topics_mismatch.html",
missing_topics=missing_topics,
packages_bad_author=packages_bad_author,
packages_bad_title=packages_bad_title)

View File

@@ -113,12 +113,11 @@ def apply_all_updates(username):
PackageRelease.commit_hash == package.update_config.last_commit)).count() > 0: PackageRelease.commit_hash == package.update_config.last_commit)).count() > 0:
continue continue
title = package.update_config.title title = package.update_config.get_title()
ref = package.update_config.get_ref() ref = package.update_config.get_ref()
rel = PackageRelease() rel = PackageRelease()
rel.package = package rel.package = package
rel.name = title
rel.title = title rel.title = title
rel.url = "" rel.url = ""
rel.task_id = uuid() rel.task_id = uuid()

View File

@@ -1,48 +0,0 @@
# ContentDB
# Copyright (C) 2024 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, render_template, request
from sqlalchemy import or_
from app.models import Package, PackageState, db, PackageTranslation
bp = Blueprint("translate", __name__)
@bp.route("/translate/")
def translate():
query = Package.query.filter(
Package.state == PackageState.APPROVED,
or_(
Package.translation_url.is_not(None),
Package.translations.any(PackageTranslation.language_id != "en")
))
has_langs = request.args.getlist("has_lang")
for lang in has_langs:
query = query.filter(Package.translations.any(PackageTranslation.language_id == lang))
not_langs = request.args.getlist("not_lang")
for lang in not_langs:
query = query.filter(~Package.translations.any(PackageTranslation.language_id == lang))
supports_translation = (query
.order_by(Package.translation_url.is_(None), db.desc(Package.score))
.all())
return render_template("translate/index.html",
supports_translation=supports_translation, has_langs=has_langs, not_langs=not_langs)

View File

@@ -16,7 +16,7 @@
import datetime import datetime
from flask import redirect, abort, render_template, flash, request, url_for, Response from flask import redirect, abort, render_template, flash, request, url_for
from flask_babel import gettext, get_locale, lazy_gettext from flask_babel import gettext, get_locale, lazy_gettext
from flask_login import current_user, login_required, logout_user, login_user from flask_login import current_user, login_required, logout_user, login_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
@@ -26,10 +26,10 @@ from wtforms.validators import InputRequired, Length, Regexp, DataRequired, Opti
from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email
from app.utils import random_string, make_flask_login_password, is_safe_url, check_password_hash, add_audit_log, \ from app.utils import random_string, make_flask_login_password, is_safe_url, check_password_hash, add_audit_log, \
nonempty_or_none, post_login nonempty_or_none, post_login, is_username_valid
from . import bp from . import bp
from app.models import User, AuditSeverity, db, EmailSubscription, UserEmailVerification from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \
from app.logic.users import create_user UserEmailVerification
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
@@ -104,7 +104,7 @@ 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", []) agree = BooleanField(lazy_gettext("I agree"), [DataRequired()])
submit = SubmitField(lazy_gettext("Register")) submit = SubmitField(lazy_gettext("Register"))
@@ -113,15 +113,46 @@ def handle_register(form):
flash(gettext("Incorrect captcha answer"), "danger") flash(gettext("Incorrect captcha answer"), "danger")
return return
user = create_user(form.username.data, form.display_name.data, form.email.data) if not is_username_valid(form.username.data):
if isinstance(user, Response): flash(gettext("Username is invalid"))
return user
elif user is None:
return return
elif form.first_name.data != "":
abort(500)
user.password = make_flask_login_password(form.password.data) user_by_name = User.query.filter(or_(
User.username == form.username.data,
User.username == form.display_name.data,
User.display_name == form.display_name.data,
User.forums_username == form.username.data,
User.github_username == form.username.data)).first()
if user_by_name:
if user_by_name.rank == UserRank.NOT_JOINED and user_by_name.forums_username:
flash(gettext("An account already exists for that username but hasn't been claimed yet."), "danger")
return redirect(url_for("users.claim_forums", username=user_by_name.forums_username))
else:
flash(gettext("That username/display name is already in use, please choose another."), "danger")
return
alias_by_name = PackageAlias.query.filter(or_(
PackageAlias.author==form.username.data,
PackageAlias.author==form.display_name.data)).first()
if alias_by_name:
flash(gettext("That username/display name is already in use, please choose another."), "danger")
return
user_by_email = User.query.filter_by(email=form.email.data).first()
if user_by_email:
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
gettext("We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
display_name=user_by_email.display_name))
return redirect(url_for("users.email_sent"))
elif EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
return
user = User(form.username.data, False, form.email.data, make_flask_login_password(form.password.data))
user.notification_preferences = UserNotificationPreferences(user)
if form.display_name.data:
user.display_name = form.display_name.data
db.session.add(user)
add_audit_log(AuditSeverity.USER, user, "Registered with email, display name=" + user.display_name, add_audit_log(AuditSeverity.USER, user, "Registered with email, display name=" + user.display_name,
url_for("users.profile", username=user.username)) url_for("users.profile", username=user.username))
@@ -288,7 +319,9 @@ def verify_email():
flash(gettext("Unknown verification token!"), "danger") flash(gettext("Unknown verification token!"), "danger")
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
if ver.is_expired: delta = (datetime.datetime.now() - ver.created_at)
delta: datetime.timedelta
if delta.total_seconds() > 12*60*60:
flash(gettext("Token has expired"), "danger") flash(gettext("Token has expired"), "danger")
db.session.delete(ver) db.session.delete(ver)
db.session.commit() db.session.commit()

View File

@@ -15,12 +15,11 @@
# 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 gettext from flask_babel import gettext
from flask_login import current_user
from . import bp from . import bp
from flask import redirect, render_template, session, request, flash, url_for from flask import redirect, render_template, session, request, flash, url_for
from app.models import db, User, UserRank from app.models import db, User, UserRank
from app.utils import random_string, login_user_set_active from app.utils import random_string, login_user_set_active, is_username_valid
from app.tasks.forumtasks import check_forum_account from app.tasks.forumtasks import check_forum_account
from app.utils.phpbbparser import get_profile from app.utils.phpbbparser import get_profile
@@ -32,25 +31,26 @@ def claim():
@bp.route("/user/claim-forums/", methods=["GET", "POST"]) @bp.route("/user/claim-forums/", methods=["GET", "POST"])
def claim_forums(): def claim_forums():
if current_user.is_authenticated:
return redirect(url_for("homepage.home"))
username = request.args.get("username") username = request.args.get("username")
if username is None: if username is None:
username = "" username = ""
else: else:
method = request.args.get("method") method = request.args.get("method")
if not is_username_valid(username):
flash(gettext("Invalid username, Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed. Consider contacting an admin"), "danger")
return redirect(url_for("users.claim_forums"))
user = User.query.filter_by(forums_username=username).first() user = User.query.filter_by(forums_username=username).first()
if user and user.rank.at_least(UserRank.NEW_MEMBER): if user and user.rank.at_least(UserRank.NEW_MEMBER):
flash(gettext("User has already been claimed"), "danger") flash(gettext("User has already been claimed"), "danger")
return redirect(url_for("users.claim_forums")) return redirect(url_for("users.claim_forums"))
elif method == "github": elif method == "github":
if user is None or user.github_username is None: if user is None or user.github_username is None:
flash(gettext("Unable to get GitHub username for user. Make sure the forum account exists."), "danger") flash(gettext("Unable to get GitHub username for user"), "danger")
return redirect(url_for("users.claim_forums", username=username)) return redirect(url_for("users.claim_forums", username=username))
else: else:
return redirect(url_for("vcs.github_start")) return redirect(url_for("github.start"))
if "forum_token" in session: if "forum_token" in session:
token = session["forum_token"] token = session["forum_token"]
@@ -62,11 +62,9 @@ def claim_forums():
ctype = request.form.get("claim_type") ctype = request.form.get("claim_type")
username = request.form.get("username") username = request.form.get("username")
if User.query.filter(User.username == username, User.forums_username.is_(None)).first(): if not is_username_valid(username):
flash(gettext("A ContentDB user with that name already exists. Please contact an admin to link to your forum account"), "danger") flash(gettext("Invalid username, Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed. Consider contacting an admin"), "danger")
return redirect(url_for("users.claim_forums")) elif ctype == "github":
if ctype == "github":
task = check_forum_account.delay(username) task = check_forum_account.delay(username)
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim_forums", username=username, method="github"))) return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim_forums", username=username, method="github")))
elif ctype == "forum": elif ctype == "forum":
@@ -77,7 +75,7 @@ def claim_forums():
# Get signature # Get signature
try: try:
profile = get_profile("https://forum.luanti.org", username) profile = get_profile("https://forum.minetest.net", username)
sig = profile.signature if profile else None sig = profile.signature if profile else None
except IOError as e: except IOError as e:
if hasattr(e, 'message'): if hasattr(e, 'message'):

View File

@@ -22,7 +22,7 @@ from flask_babel import gettext
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import func, text from sqlalchemy import func, text
from app.models import User, db, Package, PackageReview, PackageState, PackageType, UserRank, Collection from app.models import User, db, Package, PackageReview, PackageState, PackageType, UserRank
from app.utils import get_daterange_options from app.utils import get_daterange_options
from app.tasks.forumtasks import check_forum_account from app.tasks.forumtasks import check_forum_account
@@ -162,7 +162,10 @@ def get_user_medals(user: User) -> Tuple[List[Medal], List[Medal]]:
if user_package_ranks: if user_package_ranks:
top_rank = user_package_ranks[2] top_rank = user_package_ranks[2]
top_type = PackageType.coerce(user_package_ranks[0]) top_type = PackageType.coerce(user_package_ranks[0])
title = top_type.get_top_ordinal(top_rank) if top_rank == 1:
title = gettext(u"Top %(type)s", type=top_type.text.lower())
else:
title = gettext(u"Top %(group)d %(type)s", group=top_rank, type=top_type.text.lower())
if top_type == PackageType.MOD: if top_type == PackageType.MOD:
icon = "fa-box" icon = "fa-box"
elif top_type == PackageType.GAME: elif top_type == PackageType.GAME:
@@ -170,7 +173,8 @@ def get_user_medals(user: User) -> Tuple[List[Medal], List[Medal]]:
else: else:
icon = "fa-paint-brush" icon = "fa-paint-brush"
description = top_type.get_top_ordinal_description(user.display_name, top_rank) description = gettext(u"%(display_name)s has a %(type)s placed at #%(place)d.",
display_name=user.display_name, type=top_type.text.lower(), place=top_rank)
unlocked.append( unlocked.append(
Medal.make_unlocked(place_to_color(top_rank), icon, title, description)) Medal.make_unlocked(place_to_color(top_rank), icon, title, description))
@@ -226,14 +230,11 @@ def profile(username):
.filter(Package.author != user) \ .filter(Package.author != user) \
.order_by(db.asc(Package.title)).all() .order_by(db.asc(Package.title)).all()
pinned_collections = user.collections.filter(Collection.private == False,
Collection.pinned == True, Collection.packages.any()).all()
unlocked, locked = get_user_medals(user) unlocked, locked = get_user_medals(user)
# Process GET or invalid POST # Process GET or invalid POST
return render_template("users/profile.html", user=user, return render_template("users/profile.html", user=user,
packages=packages, maintained_packages=maintained_packages, packages=packages, maintained_packages=maintained_packages,
medals_unlocked=unlocked, medals_locked=locked, pinned_collections=pinned_collections) medals_unlocked=unlocked, medals_locked=locked)
@bp.route("/users/<username>/check-forums/", methods=["POST"]) @bp.route("/users/<username>/check-forums/", methods=["POST"])
@@ -255,18 +256,6 @@ def user_check_forums(username):
return redirect(url_for("tasks.check", id=task.id, r=next_url)) return redirect(url_for("tasks.check", id=task.id, r=next_url))
@bp.route("/users/<username>/remove-profile-pic/", methods=["POST"])
@login_required
def user_remove_profile_pic(username):
user = User.query.filter_by(username=username).one_or_404()
if current_user != user and not current_user.rank.at_least(UserRank.MODERATOR):
abort(403)
user.profile_pic = None
db.session.commit()
return redirect(url_for("users.profile_edit", username=username))
@bp.route("/user/stats/") @bp.route("/user/stats/")
@login_required @login_required
def statistics_redirect(): def statistics_redirect():

View File

@@ -18,7 +18,6 @@ from flask import redirect, abort, render_template, request, flash, url_for
from flask_babel import gettext, get_locale, lazy_gettext from flask_babel import gettext, get_locale, lazy_gettext
from flask_login import current_user, login_required, logout_user from flask_login import current_user, login_required, logout_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from kombu import uuid
from sqlalchemy import or_ from sqlalchemy import or_
from wtforms import StringField, SubmitField, BooleanField, SelectField from wtforms import StringField, SubmitField, BooleanField, SelectField
from wtforms.validators import Length, Optional, Email, URL from wtforms.validators import Length, Optional, Email, URL
@@ -26,7 +25,6 @@ from wtforms.validators import Length, Optional, Email, URL
from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \ from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \
UserEmailVerification, Permission, NotificationType, UserBan UserEmailVerification, Permission, NotificationType, UserBan
from app.tasks.emails import send_verify_email from app.tasks.emails import send_verify_email
from app.tasks.usertasks import update_github_user_id
from app.utils import nonempty_or_none, add_audit_log, random_string, rank_required, has_blocked_domains from app.utils import nonempty_or_none, add_audit_log, random_string, rank_required, has_blocked_domains
from . import bp from . import bp
@@ -130,7 +128,8 @@ def profile_edit(username):
abort(404) abort(404)
if not user.can_see_edit_profile(current_user): if not user.can_see_edit_profile(current_user):
abort(403) flash(gettext("Permission denied"), "danger")
return redirect(url_for("users.profile", username=username))
form = UserProfileForm(obj=user) form = UserProfileForm(obj=user)
if form.validate_on_submit(): if form.validate_on_submit():
@@ -243,31 +242,9 @@ def account(username):
if not user: if not user:
abort(404) abort(404)
if not user.can_see_edit_profile(current_user):
abort(403)
return render_template("users/account.html", user=user, tabs=get_setting_tabs(user), current_tab="account") return render_template("users/account.html", user=user, tabs=get_setting_tabs(user), current_tab="account")
@bp.route("/users/<username>/settings/account/disconnect-github/", methods=["POST"])
def disconnect_github(username: str):
user: User = User.query.filter_by(username=username).one_or_404()
if not user.can_see_edit_profile(current_user):
abort(403)
if user.password and user.email:
user.github_user_id = None
user.github_username = None
db.session.commit()
flash(gettext("Removed GitHub account"), "success")
else:
flash(gettext("You need to add an email address and password before you can remove your GitHub account"), "danger")
return redirect(url_for("users.account", username=username))
@bp.route("/users/<username>/delete/", methods=["GET", "POST"]) @bp.route("/users/<username>/delete/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN) @rank_required(UserRank.ADMIN)
def delete(username): def delete(username):
@@ -298,9 +275,6 @@ def delete(username):
db.session.delete(reply) db.session.delete(reply)
for thread in user.threads.all(): for thread in user.threads.all():
db.session.delete(thread) db.session.delete(thread)
for token in user.tokens.all():
db.session.delete(token)
user.profile_pic = None
user.email = None user.email = None
if user.rank != UserRank.BANNED: if user.rank != UserRank.BANNED:
@@ -346,8 +320,6 @@ def modtools(username):
add_audit_log(severity, current_user, "Edited {}'s account".format(user.display_name), add_audit_log(severity, current_user, "Edited {}'s account".format(user.display_name),
url_for("users.profile", username=username)) url_for("users.profile", username=username))
redirect_target = url_for("users.modtools", username=username)
# Copy form fields to user_profile fields # Copy form fields to user_profile fields
if user.check_perm(current_user, Permission.CHANGE_USERNAMES): if user.check_perm(current_user, Permission.CHANGE_USERNAMES):
if user.username != form.username.data: if user.username != form.username.data:
@@ -360,21 +332,14 @@ def modtools(username):
user.display_name = form.display_name.data user.display_name = form.display_name.data
user.forums_username = nonempty_or_none(form.forums_username.data) user.forums_username = nonempty_or_none(form.forums_username.data)
github_username = nonempty_or_none(form.github_username.data) user.github_username = nonempty_or_none(form.github_username.data)
if github_username is None:
user.github_username = None
user.github_user_id = None
else:
task_id = uuid()
update_github_user_id.apply_async((user.id, github_username), task_id=task_id)
redirect_target = url_for("tasks.check", id=task_id, r=redirect_target)
if user.check_perm(current_user, Permission.CHANGE_RANK): if user.check_perm(current_user, Permission.CHANGE_RANK):
new_rank = form.rank.data new_rank = form["rank"].data
if current_user.rank.at_least(new_rank): if current_user.rank.at_least(new_rank):
if new_rank != user.rank: if new_rank != user.rank:
user.rank = form.rank.data user.rank = form["rank"].data
msg = "Set rank of {} to {}".format(user.display_name, user.rank.title) msg = "Set rank of {} to {}".format(user.display_name, user.rank.get_title())
add_audit_log(AuditSeverity.MODERATION, current_user, msg, add_audit_log(AuditSeverity.MODERATION, current_user, msg,
url_for("users.profile", username=username)) url_for("users.profile", username=username))
else: else:
@@ -382,7 +347,7 @@ def modtools(username):
db.session.commit() db.session.commit()
return redirect(redirect_target) return redirect(url_for("users.modtools", username=username))
return render_template("users/modtools.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="modtools") return render_template("users/modtools.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="modtools")

View File

@@ -1,43 +0,0 @@
# ContentDB
# Copyright (C) 2024 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.blueprints.api.support import error
from app.models import Package, APIToken, Permission, PackageState
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:
packages = [token.package]
if not token.package.check_perm(token.owner, Permission.APPROVE_RELEASE):
return error(403, "You do not have the permission to approve releases")
actual_repo_url: str = token.package.repo or ""
if repo_url not in actual_repo_url.lower():
return error(400, "Repo URL does not match the API token's package")
else:
# Get package
packages = Package.query.filter(
Package.repo.ilike("%{}%".format(repo_url)), Package.state != PackageState.DELETED).all()
if len(packages) == 0:
return error(400,
"Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(repo_url))
packages = [x for x in packages if x.check_perm(token.owner, Permission.APPROVE_RELEASE)]
if len(packages) == 0:
return error(403, "You do not have the permission to approve releases")
return packages

View File

@@ -1,200 +0,0 @@
# ContentDB
# Copyright (C) 2018-24 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
import hmac
import requests
from flask import abort, Response
from flask import redirect, url_for, request, flash, jsonify, current_app
from flask_babel import gettext
from flask_login import current_user
from app import github, csrf
from app.blueprints.api.support import error, api_create_vcs_release
from app.logic.users import create_user
from app.models import db, User, APIToken, AuditSeverity
from app.utils import abs_url_for, add_audit_log, login_user_set_active, is_safe_url
from . import bp
from .common import get_packages_for_vcs_and_token
@bp.route("/github/start/")
def github_start():
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
return github.authorize("", redirect_uri=abs_url_for("vcs.github_callback", next=next))
@bp.route("/github/view/")
def github_view_permissions():
url = "https://github.com/settings/connections/applications/" + \
current_app.config["GITHUB_CLIENT_ID"]
return redirect(url)
@bp.route("/github/callback/")
@github.authorized_handler
def github_callback(oauth_token):
if oauth_token is None:
flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger")
return redirect(url_for("users.login"))
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
redirect_to = next
if redirect_to is None:
redirect_to = url_for("homepage.home")
# Get GitGub username
url = "https://api.github.com/user"
r = requests.get(url, headers={"Authorization": "token " + oauth_token})
json = r.json()
user_id = json["id"]
github_username = json["login"]
if type(user_id) is not int:
abort(400)
# Get user by GitHub user ID
user_by_github = User.query.filter(User.github_user_id == user_id).one_or_none()
# If logged in, connect
if current_user and current_user.is_authenticated:
if user_by_github is None:
current_user.github_username = github_username
current_user.github_user_id = user_id
db.session.commit()
flash(gettext("Linked GitHub to account"), "success")
return redirect(redirect_to)
elif user_by_github == current_user:
return redirect(redirect_to)
else:
flash(gettext("GitHub account is already associated with another user: %(username)s",
username=user_by_github.username), "danger")
return redirect(redirect_to)
# Log in to existing account
elif user_by_github:
ret = login_user_set_active(user_by_github, next, remember=True)
if ret is None:
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger")
return redirect(url_for("users.login"))
user_by_github.github_username = github_username
add_audit_log(AuditSeverity.USER, user_by_github, "Logged in using GitHub OAuth",
url_for("users.profile", username=user_by_github.username))
db.session.commit()
return ret
# Sign up
else:
user = create_user(github_username, github_username, None, "GitHub")
if isinstance(user, Response):
return user
elif user is None:
return redirect(url_for("users.login"))
user.github_username = github_username
user.github_user_id = user_id
add_audit_log(AuditSeverity.USER, user, "Registered with GitHub, display name=" + user.display_name,
url_for("users.profile", username=user.username))
db.session.commit()
ret = login_user_set_active(user, next, remember=True)
if ret is None:
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger")
return redirect(url_for("users.login"))
return ret
def _find_api_token(header_signature: str) -> APIToken:
sha_name, signature = header_signature.split('=')
if sha_name != 'sha1':
error(403, "Expected SHA1 payload signature")
for token in APIToken.query.all():
mac = hmac.new(token.access_token.encode("utf-8"), msg=request.data, digestmod='sha1')
if hmac.compare_digest(str(mac.hexdigest()), signature):
return token
error(401, "Invalid authentication, couldn't validate API token")
@bp.route("/github/webhook/", methods=["POST"])
@csrf.exempt
def github_webhook():
json = request.json
header_signature = request.headers.get('X-Hub-Signature')
if header_signature is None:
return error(403, "Expected payload signature")
token = _find_api_token(header_signature)
packages = get_packages_for_vcs_and_token(token, "github.com/" + json["repository"]["full_name"])
for package in packages:
#
# Check event
#
event = request.headers.get("X-GitHub-Event")
if event == "push":
ref = json["after"]
title = datetime.datetime.utcnow().strftime("%Y-%m-%d") + " " + ref[:5]
branch = json["ref"].replace("refs/heads/", "")
if package.update_config and package.update_config.ref:
if branch != package.update_config.ref:
continue
elif branch not in ["master", "main"]:
continue
elif event == "create":
ref_type = json.get("ref_type")
if ref_type != "tag":
return jsonify({
"success": False,
"message": "Webhook ignored, as it's a non-tag create event. ref_type='{}'.".format(ref_type)
})
ref = json["ref"]
title = ref
elif event == "ping":
return jsonify({"success": True, "message": "Ping successful"})
else:
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
.format(event or "null"))
#
# Perform release
#
if package.releases.filter_by(commit_hash=ref).count() > 0:
return
return api_create_vcs_release(token, package, title, title, None, ref, reason="Webhook")
return jsonify({
"success": False,
"message": "No release made. Either the release already exists or the event was filtered based on the branch"
})

View File

@@ -1,86 +0,0 @@
# ContentDB
# Copyright (C) 2020-24 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from flask import request, jsonify
from app import csrf
from app.blueprints.api.support import error, api_create_vcs_release
from app.models import APIToken
from . import bp
from .common import get_packages_for_vcs_and_token
def webhook_impl():
json = request.json
# Get all tokens for package
secret = request.headers.get("X-Gitlab-Token")
if secret is None:
return error(403, "Token required")
token: APIToken = APIToken.query.filter_by(access_token=secret).first()
if token is None:
return error(403, "Invalid authentication")
packages = get_packages_for_vcs_and_token(token, json["project"]["web_url"])
for package in packages:
#
# Check event
#
event = json["event_name"]
if event == "push":
ref = json["after"]
title = datetime.datetime.utcnow().strftime("%Y-%m-%d") + " " + ref[:5]
branch = json["ref"].replace("refs/heads/", "")
if package.update_config and package.update_config.ref:
if branch != package.update_config.ref:
continue
elif branch not in ["master", "main"]:
continue
elif event == "tag_push":
ref = json["ref"]
title = ref.replace("refs/tags/", "")
else:
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
.format(event or "null"))
#
# Perform release
#
if package.releases.filter_by(commit_hash=ref).count() > 0:
continue
return api_create_vcs_release(token, package, title, title, None, ref, reason="Webhook")
return jsonify({
"success": False,
"message": "No release made. Either the release already exists or the event was filtered based on the branch"
})
@bp.route("/gitlab/webhook/", methods=["POST"])
@csrf.exempt
def gitlab_webhook():
try:
return webhook_impl()
except KeyError as err:
return error(400, "Missing field: {}".format(err.args[0]))

View File

@@ -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))

View File

@@ -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"
@@ -105,7 +105,6 @@ def populate_test_data(session):
rel = PackageRelease() rel = PackageRelease()
rel.package = mod rel.package = mod
rel.name = "v1.0.0"
rel.title = "v1.0.0" rel.title = "v1.0.0"
rel.url = "https://github.com/ezhh/handholds/archive/master.zip" rel.url = "https://github.com/ezhh/handholds/archive/master.zip"
rel.approved = True rel.approved = True
@@ -143,7 +142,6 @@ awards.register_achievement("award_mesefind",{
rel = PackageRelease() rel = PackageRelease()
rel.package = mod1 rel.package = mod1
rel.min_rel = v51 rel.min_rel = v51
rel.name = "v1.0.0"
rel.title = "v1.0.0" rel.title = "v1.0.0"
rel.url = "https://github.com/rubenwardy/awards/archive/master.zip" rel.url = "https://github.com/rubenwardy/awards/archive/master.zip"
rel.approved = True rel.approved = True
@@ -256,7 +254,6 @@ No warranty is provided, express or implied, for any part of the project.
rel = PackageRelease() rel = PackageRelease()
rel.package = mod rel.package = mod
rel.name = "v1.0.0"
rel.title = "v1.0.0" rel.title = "v1.0.0"
rel.max_rel = v4 rel.max_rel = v4
rel.url = "https://github.com/ezhh/handholds/archive/master.zip" rel.url = "https://github.com/ezhh/handholds/archive/master.zip"
@@ -370,7 +367,6 @@ Uses the CTF PvP Engine.
rel = PackageRelease() rel = PackageRelease()
rel.package = game1 rel.package = game1
rel.name = "v1.0.0"
rel.title = "v1.0.0" rel.title = "v1.0.0"
rel.url = "https://github.com/rubenwardy/capturetheflag/archive/master.zip" rel.url = "https://github.com/rubenwardy/capturetheflag/archive/master.zip"
rel.approved = True rel.approved = True
@@ -392,7 +388,6 @@ Uses the CTF PvP Engine.
rel = PackageRelease() rel = PackageRelease()
rel.package = mod rel.package = mod
rel.name = "v1.0.0"
rel.title = "v1.0.0" rel.title = "v1.0.0"
rel.url = "http://mamadou3.free.fr/Minetest/PixelBOX.zip" rel.url = "http://mamadou3.free.fr/Minetest/PixelBOX.zip"
rel.approved = True rel.approved = True

View File

@@ -10,12 +10,10 @@ 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="https://rubenwardy.com/contact/" class="btn btn-secondary me-1">Contact admin</a>
{% if monitoring_url -%} <a href="https://monitor.rubenwardy.com/d/3ELzFy3Wz/contentdb" class="btn btn-secondary">Stats / monitoring</a>
<a href="{{ monitoring_url }}" class="btn btn-secondary">Stats / monitoring</a>
{%- endif %}
## Why was ContentDB created? ## Why was ContentDB created?
@@ -25,25 +23,17 @@ poor user experience, especially for first-time users.
ContentDB isn't just about supporting the in-game content downloader; it's common for technical users to find ContentDB isn't just about supporting the in-game content downloader; it's common for technical users to find
and review packages using the ContentDB website, but install using Git rather than the in-game installer. and review packages using the ContentDB website, but install using Git rather than the in-game installer.
**ContentDB's purpose is to be a well-formatted source of information about mods, games, **ContentDB's purpose is to be a well-formatted source of information about mods, games,
and texture packs for Luanti**. and texture packs for Minetest**.
## How do I learn how to make mods and games for Luanti? ## How do I learn how to make mods and games for Minetest?
You should read You should read
[the official Luanti Modding Book](https://rubenwardy.com/minetest_modding_book/) [the official Minetest Modding Book](https://rubenwardy.com/minetest_modding_book/)
for a guide to making mods and games using Luanti. for a guide to making mods and games using Minetest.
## How can I support / donate to ContentDB?
<h2 id="donate">How can I support / donate to ContentDB?</h2> You can donate to rubenwardy to cover ContentDB's costs and support future
development.
You can donate to rubenwardy to cover ContentDB's costs and support future development.
For more information about the cost of ContentDB and what rubenwardy does, see his donation page:
<a href="https://rubenwardy.com/donate/" class="btn btn-primary me-1">Donate</a> <a href="https://rubenwardy.com/donate/" class="btn btn-primary me-1">Donate</a>
<a href="/donate/" class="btn btn-secondary">Support Creators</a>
## Sponsorships
Luanti and ContentDB are sponsored by <a href="https://sentry.io/" rel="nofollow">sentry.io</a>.
This provides us with improved error logging and performance insights.

View File

@@ -4,7 +4,7 @@ toc: False
## Rules ## Rules
* [Terms of Service](/terms/) * [Rules](/rules/)
* [Package Inclusion Policy and Guidance](/policy_and_guidance/) * [Package Inclusion Policy and Guidance](/policy_and_guidance/)
## General Help ## General Help
@@ -18,7 +18,6 @@ toc: False
* [Contact Us](contact_us/) * [Contact Us](contact_us/)
* [Top Packages Algorithm](top_packages/) * [Top Packages Algorithm](top_packages/)
* [Featured Packages](featured/) * [Featured Packages](featured/)
* [Feeds](feeds/)
## Help for Package Authors ## Help for Package Authors
@@ -28,8 +27,6 @@ toc: False
* [Creating Releases using Webhooks](release_webhooks/) * [Creating Releases using Webhooks](release_webhooks/)
* [Package Configuration and Releases Guide](package_config/) * [Package Configuration and Releases Guide](package_config/)
* [Supported Games](game_support/) * [Supported Games](game_support/)
* [Creating an appealing ContentDB page](appealing_page/)
## Help for Specific User Ranks ## Help for Specific User Ranks

View File

@@ -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 Minetest client uses the API](https://github.com/minetest/contentdb/blob/master/docs/minetest_client.md)
## Responses and Error Handling ## Responses and Error Handling
@@ -54,7 +54,7 @@ The response will be a dictionary with the following keys:
Not all endpoints require authentication, but it is done using Bearer tokens: Not all endpoints require authentication, but it is done using Bearer tokens:
```bash ```bash
curl https://content.luanti.org/api/whoami/ \ curl https://content.minetest.net/api/whoami/ \
-H "Authorization: Bearer YOURTOKEN" -H "Authorization: Bearer YOURTOKEN"
``` ```
@@ -67,8 +67,8 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* DELETE `/api/delete-token/`: Deletes the currently used token. * DELETE `/api/delete-token/`: Deletes the currently used token.
```bash ```bash
# Logout # Logout
curl -X DELETE https://content.luanti.org/api/delete-token/ \ curl -X DELETE https://content.minetest.net/api/delete-token/ \
-H "Authorization: Bearer YOURTOKEN" -H "Authorization: Bearer YOURTOKEN"
``` ```
@@ -78,12 +78,9 @@ curl -X DELETE https://content.luanti.org/api/delete-token/ \
* GET `/api/packages/` (List) * GET `/api/packages/` (List)
* See [Package Queries](#package-queries) * See [Package Queries](#package-queries)
* GET `/api/packages/<username>/<name>/` (Read) * GET `/api/packages/<username>/<name>/` (Read)
* Redirects a JSON object with the keys documented by the PUT endpoint, below.
* Plus:
* `forum_url`: String or null.
* PUT `/api/packages/<author>/<name>/` (Update) * PUT `/api/packages/<author>/<name>/` (Update)
* Requires authentication. * Requires authentication.
* JSON object with any of these keys (all are optional, null to delete Nullables): * JSON dictionary with any of these keys (all are optional, null to delete Nullables):
* `type`: One of `GAME`, `MOD`, `TXP`. * `type`: One of `GAME`, `MOD`, `TXP`.
* `title`: Human-readable title. * `title`: Human-readable title.
* `name`: Technical name (needs permission if already approved). * `name`: Technical name (needs permission if already approved).
@@ -95,52 +92,24 @@ curl -X DELETE https://content.luanti.org/api/delete-token/ \
* `license`: A [license](#licenses) name. * `license`: A [license](#licenses) name.
* `media_license`: A [license](#licenses) name. * `media_license`: A [license](#licenses) name.
* `long_description`: Long markdown description. * `long_description`: Long markdown description.
* `repo`: Source repository (eg: Git) * `repo`: Git repo URL.
* `website`: Website URL. * `website`: Website URL.
* `issue_tracker`: Issue tracker URL. * `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID. * `forums`: forum topic ID.
* `video_url`: URL to a video. * `video_url`: URL to a video.
* `donate_url`: URL to a donation page. * `donate_url`: URL to a donation page.
* `translation_url`: URL to send users interested in translating your package. * `game_support`: Array of game support information objects. Not currently documented, as subject to change.
* `game_support`: Array of game support information objects. Not currently documented,
* Returns a JSON object with:
* `success`
* `package`: updated package
* `was_modified`: bool, whether anything changed
* GET `/api/packages/<username>/<name>/for-client/`
* Similar to the read endpoint, but optimised for the Luanti client
* `long_description` is given as a hypertext object, see `/hypertext/` below.
* `info_hypertext` is the info sidebar as a hypertext object.
* Query arguments
* `formspec_version`: Required. See /hypertext/ below.
* `include_images`: Optional, defaults to true. If true, images use `<img>`. If false, they're linked.
* `protocol_version`: Optional, used to get the correct release.
* `engine_version`: Optional, used to get the correct release. Ex: `5.3.0`.
* GET `/api/packages/<username>/<name>/for-client/reviews/`
* Returns hypertext representing the package's reviews
* Query arguments
* `formspec_version`: Required. See /hypertext/ below.
* Returns JSON dictionary with following keys:
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
* `body`: markup for long description.
* `links`: dictionary of anchor name to link URL.
* `images`: dictionary of img name to image URL.
* `image_tooltips`: dictionary of img name to tooltip text.
* The hypertext body contains some placeholders that should be replaced client-side:
* `<thumbsup>` with a thumbs up icon.
* `<neutral>` 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 [Minetest 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.
* `include_images`: Optional, defaults to true. If true, images use `<img>`. If false, they're linked. * `include_images`: Optional, defaults to true.
* Returns JSON dictionary with following keys: * Returns JSON dictionary with following key:
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying. * `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
* `body`: markup for long description. * `body`: markup for long description.
* `links`: dictionary of anchor name to link URL. * `links`: dictionary of anchor name to link URL.
* `images`: dictionary of img name to image URL. * `images`: dictionary of img name to image URL
* `image_tooltips`: dictionary of img name to tooltip text. * `image_tooltips`: dictionary of img name to tooltip text.
* GET `/api/packages/<username>/<name>/dependencies/` * GET `/api/packages/<username>/<name>/dependencies/`
* Returns dependencies, with suggested candidates * Returns dependencies, with suggested candidates
@@ -184,20 +153,20 @@ curl -X DELETE https://content.luanti.org/api/delete-token/ \
You can download a package by building one of the two URLs: You can download a package by building one of the two URLs:
``` ```
https://content.luanti.org/packages/${author}/${name}/download/` https://content.minetest.net/packages/${author}/${name}/download/`
https://content.luanti.org/packages/${author}/${name}/releases/${release}/download/` https://content.minetest.net/packages/${author}/${name}/releases/${release}/download/`
``` ```
Examples: Examples:
```bash ```bash
# Edit package # Edit package
curl -X PUT https://content.luanti.org/api/packages/username/name/ \ curl -X PUT https://content.minetest.net/api/packages/username/name/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \ -H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d '{ "title": "Foo bar", "tags": ["pvp", "survival"], "license": "MIT" }' -d '{ "title": "Foo bar", "tags": ["pvp", "survival"], "license": "MIT" }'
# Remove website URL # Remove website URL
curl -X PUT https://content.luanti.org/api/packages/username/name/ \ curl -X PUT https://content.minetest.net/api/packages/username/name/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \ -H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d '{ "website": null }' -d '{ "website": null }'
``` ```
@@ -208,33 +177,23 @@ Example:
/api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore /api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore
Filter query parameters: Supported query parameters:
* `type`: Filter by package type (`mod`, `game`, `txp`). Multiple types are OR-ed together. * `type`: Package types (`mod`, `game`, `txp`).
* `q`: Query string. * `q`: Query string.
* `author`: Filter by author. * `author`: Filter by author.
* `tag`: Filter by tags. Multiple tags are AND-ed together. * `tag`: Filter by tags.
* `flag`: Filter to show packages with [Content Flags](/help/content_flags/). * `game`: Filter by [Game Support](/help/game_support/), ex: `Wuzzy/mineclone2`. (experimental, doesn't show items that support every game currently).
* `hide`: Hide content based on tags or [Content Flags](/help/content_flags/). * `random`: When present, enable random ordering and ignore `sort`.
* `license`: Filter by [license name](#licenses). Multiple licenses are OR-ed together, ie: `&license=MIT&license=LGPL-2.1-only` * `limit`: Return at most `limit` packages.
* `game`: Filter by [Game Support](/help/game_support/), ex: `Warr1024/nodecore`. (experimental, doesn't show items that support every game currently). * `hide`: Hide content based on [Content Flags](/help/content_flags/).
* `lang`: Filter by translation support, eg: `en`/`de`/`ja`/`zh_TW`.
* `protocol_version`: Only show packages supported by this Luanti protocol version.
* `engine_version`: Only show packages supported by this Luanti engine version, eg: `5.3.0`.
Sorting query parameters:
* `sort`: Sort by (`name`, `title`, `score`, `reviews`, `downloads`, `created_at`, `approved_at`, `last_release`). * `sort`: Sort by (`name`, `title`, `score`, `reviews`, `downloads`, `created_at`, `approved_at`, `last_release`).
* `order`: Sort ascending (`asc`) or descending (`desc`). * `order`: Sort ascending (`asc`) or descending (`desc`).
* `random`: When present, enable random ordering and ignore `sort`. * `protocol_version`: Only show packages supported by this Minetest protocol version.
* `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`.
Format query parameters:
* `limit`: Return at most `limit` packages.
* `fmt`: How the response is formatted. * `fmt`: How the response is formatted.
* `keys`: author/name only. * `keys`: author/name only.
* `short`: stuff needed for the Luanti client. * `short`: stuff needed for the Minetest client.
* `vcs`: `short` but with `repo`.
### Releases ### Releases
@@ -246,16 +205,13 @@ Format query parameters:
* `maintainer`: Filter by maintainer * `maintainer`: Filter by maintainer
* Returns array of release dictionaries with keys: * Returns array of release dictionaries with keys:
* `id`: release ID * `id`: release ID
* `name`: short release name
* `title`: human-readable title * `title`: human-readable title
* `release_notes`: string or null, what's new in this release. Markdown.
* `release_date`: Date released * `release_date`: Date released
* `url`: download URL * `url`: download URL
* `commit`: commit hash or null * `commit`: commit hash or null
* `downloads`: number of downloads * `downloads`: number of downloads
* `min_minetest_version`: dict or null, minimum supported Luanti version (inclusive). * `min_minetest_version`: dict or null, minimum supported minetest version (inclusive).
* `max_minetest_version`: dict or null, minimum supported Luanti version (inclusive). * `max_minetest_version`: dict or null, minimum supported minetest version (inclusive).
* `size`: size of zip file, in bytes.
* `package` * `package`
* `author`: author username * `author`: author username
* `name`: technical name * `name`: technical name
@@ -263,8 +219,8 @@ Format query parameters:
* GET `/api/updates/` (Look-up table) * GET `/api/updates/` (Look-up table)
* Returns a look-up table from package key (`author/name`) to latest release id * Returns a look-up table from package key (`author/name`) to latest release id
* Query arguments * Query arguments
* `protocol_version`: Only show packages supported by this Luanti protocol version. * `protocol_version`: Only show packages supported by this Minetest protocol version.
* `engine_version`: Only show packages supported by this Luanti engine version, eg: `5.3.0`. * `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`.
* GET `/api/packages/<username>/<name>/releases/` (List) * GET `/api/packages/<username>/<name>/releases/` (List)
* Returns array of release dictionaries, see above, but without package info. * Returns array of release dictionaries, see above, but without package info.
* GET `/api/packages/<username>/<name>/releases/<id>/` (Read) * GET `/api/packages/<username>/<name>/releases/<id>/` (Read)
@@ -272,14 +228,13 @@ Format query parameters:
* Requires authentication. * Requires authentication.
* Body can be JSON or multipart form data. Zip uploads must be multipart form data. * Body can be JSON or multipart form data. Zip uploads must be multipart form data.
* `title`: human-readable name of the release. * `title`: human-readable name of the release.
* `release_notes`: string or null, what's new in this release.
* For Git release creation: * For Git release creation:
* `method`: must be `git`. * `method`: must be `git`.
* `ref`: (Optional) git reference, eg: `master`. * `ref`: (Optional) git reference, eg: `master`.
* For zip upload release creation: * For zip upload release creation:
* `file`: multipart file to upload, like `<input type="file" name="file">`. * `file`: multipart file to upload, like `<input type="file" name="file">`.
* `commit`: (Optional) Source Git commit hash, for informational purposes. * `commit`: (Optional) Source Git commit hash, for informational purposes.
* You can set min and max Luanti Versions [using the content's .conf file](/help/package_config/). * You can set min and max Minetest Versions [using the content's .conf file](/help/package_config/).
* DELETE `/api/packages/<username>/<name>/releases/<id>/` (Delete) * DELETE `/api/packages/<username>/<name>/releases/<id>/` (Delete)
* Requires authentication. * Requires authentication.
* Deletes release. * Deletes release.
@@ -288,28 +243,22 @@ Examples:
```bash ```bash
# Create release from Git # Create release from Git
curl -X POST https://content.luanti.org/api/packages/username/name/releases/new/ \ curl -X POST https://content.minetest.net/api/packages/username/name/releases/new/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \ -H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d '{ -d '{ "method": "git", "title": "My Release", "ref": "master" }'
"method": "git",
"name": "1.2.3",
"title": "My Release",
"ref": "master",
"release_notes": "some\nrelease\nnotes\n"
}'
# Create release from zip upload # Create release from zip upload
curl -X POST https://content.luanti.org/api/packages/username/name/releases/new/ \ curl -X POST https://content.minetest.net/api/packages/username/name/releases/new/ \
-H "Authorization: Bearer YOURTOKEN" \ -H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/file.zip -F title="My Release" -F file=@path/to/file.zip
# Create release from zip upload with commit hash # Create release from zip upload with commit hash
curl -X POST https://content.luanti.org/api/packages/username/name/releases/new/ \ curl -X POST https://content.minetest.net/api/packages/username/name/releases/new/ \
-H "Authorization: Bearer YOURTOKEN" \ -H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F commit="8ef74deec170a8ce789f6055a59d43876d16a7ea" -F file=@path/to/file.zip -F title="My Release" -F commit="8ef74deec170a8ce789f6055a59d43876d16a7ea" -F file=@path/to/file.zip
# Delete release # Delete release
curl -X DELETE https://content.luanti.org/api/packages/username/name/releases/3/ \ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/3/ \
-H "Authorization: Bearer YOURTOKEN" -H "Authorization: Bearer YOURTOKEN"
``` ```
@@ -350,26 +299,26 @@ Examples:
```bash ```bash
# Create screenshot # Create screenshot
curl -X POST https://content.luanti.org/api/packages/username/name/screenshots/new/ \ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
-H "Authorization: Bearer YOURTOKEN" \ -H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/screnshot.png -F title="My Release" -F file=@path/to/screnshot.png
# Create screenshot and set it as the cover image # Create screenshot and set it as the cover image
curl -X POST https://content.luanti.org/api/packages/username/name/screenshots/new/ \ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
-H "Authorization: Bearer YOURTOKEN" \ -H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/screnshot.png -F is_cover_image="true" -F title="My Release" -F file=@path/to/screnshot.png -F is_cover_image="true"
# Delete screenshot # Delete screenshot
curl -X DELETE https://content.luanti.org/api/packages/username/name/screenshots/3/ \ curl -X DELETE https://content.minetest.net/api/packages/username/name/screenshots/3/ \
-H "Authorization: Bearer YOURTOKEN" -H "Authorization: Bearer YOURTOKEN"
# Reorder screenshots # Reorder screenshots
curl -X POST https://content.luanti.org/api/packages/username/name/screenshots/order/ \ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/order/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \ -H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d "[13, 2, 5, 7]" -d "[13, 2, 5, 7]"
# Set cover image # Set cover image
curl -X POST https://content.luanti.org/api/packages/username/name/screenshots/cover-image/ \ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/cover-image/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \ -H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d "{ 'cover_image': 123 }" -d "{ 'cover_image': 123 }"
``` ```
@@ -473,13 +422,14 @@ Supported query parameters:
* `type`: Package types (`mod`, `game`, `txp`). * `type`: Package types (`mod`, `game`, `txp`).
* `sort`: Sort by (`name`, `views`, `created_at`). * `sort`: Sort by (`name`, `views`, `created_at`).
* `show_added`: Show topics that have an existing package. * `show_added`: Show topics that have an existing package.
* `show_discarded`: Show topics marked as discarded.
* `limit`: Return at most `limit` topics. * `limit`: Return at most `limit` topics.
## Collections ## Collections
* GET `/api/collections/` * GET `/api/collections/`
* Query args: * Query args:
* `author`: collection author username. * `author`: collection author username.
* `package`: collections that contain the package. * `package`: collections that contain the package.
* Returns JSON array of collection entries: * Returns JSON array of collection entries:
@@ -489,7 +439,7 @@ Supported query parameters:
* `short_description` * `short_description`
* `created_at`: creation time in iso format. * `created_at`: creation time in iso format.
* `private`: whether collection is private, boolean. * `private`: whether collection is private, boolean.
* `package_count`: number of packages, integer. * `package_count`: number of packages, integer.
* GET `/api/collections/<username>/<name>/` * GET `/api/collections/<username>/<name>/`
* Returns JSON object for collection: * Returns JSON object for collection:
* `author`: author username. * `author`: author username.
@@ -509,43 +459,31 @@ Supported query parameters:
### Tags ### Tags
* GET `/api/tags/` ([View](/api/tags/)) * GET `/api/tags/` ([View](/api/tags/)): List of:
* List of objects with: * `name`: technical name.
* `name`: technical name. * `title`: human-readable title.
* `title`: human-readable title. * `description`: tag description or null.
* `description`: tag description or null. * `views`: number of views of this tag.
* `views`: number of views of this tag.
### Content Warnings ### Content Warnings
* GET `/api/content_warnings/` ([View](/api/content_warnings/)) * GET `/api/content_warnings/` ([View](/api/content_warnings/)): List of:
* List of objects with * `name`: technical name
* `name`: technical name * `title`: human-readable title
* `title`: human-readable title * `description`: tag description or null
* `description`: tag description or null
### Licenses ### Licenses
* GET `/api/licenses/` ([View](/api/licenses/)) * GET `/api/licenses/` ([View](/api/licenses/)): List of:
* List of objects with: * `name`
* `name` * `is_foss`: whether the license is foss
* `is_foss`: whether the license is foss
### Luanti Versions ### Minetest Versions
* GET `/api/minetest_versions/` ([View](/api/minetest_versions/)) * GET `/api/minetest_versions/` ([View](/api/minetest_versions/))
* List of objects with: * `name`: Version name.
* `name`: Version name. * `is_dev`: boolean, is dev version.
* `is_dev`: boolean, is dev version. * `protocol_version`: protocol version umber.
* `protocol_version`: protocol version number.
### Languages
* GET `/api/languages/` ([View](/api/languages/))
* List of objects with:
* `id`: language code.
* `title`: native language name.
* `has_contentdb_translation`: whether ContentDB has been translated into this language.
## Misc ## Misc
@@ -560,10 +498,6 @@ Supported query parameters:
* `score`: total package score. * `score`: total package score.
* `score_reviews`: score from reviews. * `score_reviews`: score from reviews.
* `score_downloads`: score from downloads. * `score_downloads`: score from downloads.
* `reviews`: a dictionary of
* `positive`: int, number of positive reviews.
* `neutral`: int, number of neutral reviews.
* `negative`: int, number of negative reviews.
* GET `/api/homepage/` ([View](/api/homepage/)) - get contents of homepage. * GET `/api/homepage/` ([View](/api/homepage/)) - get contents of homepage.
* `count`: number of packages * `count`: number of packages
* `downloads`: get number of downloads * `downloads`: get number of downloads
@@ -573,17 +507,19 @@ Supported query parameters:
* `pop_txp`: popular textures * `pop_txp`: popular textures
* `pop_game`: popular games * `pop_game`: popular games
* `high_reviewed`: highest reviewed * `high_reviewed`: highest reviewed
* GET `/api/welcome/v1/` ([View](/api/welcome/v1/)) - in-menu welcome dialog. Experimental (may change without warning)
* `featured`: featured games
* GET `/api/cdb_schema/` ([View](/api/cdb_schema/)) * GET `/api/cdb_schema/` ([View](/api/cdb_schema/))
* 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 [Minetest 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`.
* Query arguments: * Query arguments:
* `formspec_version`: Required, maximum supported formspec version. Ie: 6 * `formspec_version`: Required, maximum supported formspec version. Ie: 6
* `include_images`: Optional, defaults to true. If true, images use `<img>`. If false, they're linked. * `include_images`: Optional, defaults to true.
* Returns JSON dictionary with following key: * Returns JSON dictionary with following key:
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying. * `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
* `body`: markup for long description. * `body`: markup for long description.

View File

@@ -1,74 +0,0 @@
title: Creating an appealing ContentDB page
## Title and short description
Make sure that your package's title is unique, short, and descriptive.
Expand on the title with the short description. You have a limited number
of characters, use them wisely!
```ini
# Bad, we know this is a mod for Luanti. Doesn't give much information other than "food"
description = The food mod for Luanti
# Much better, says what is actually in this mod!
description = Adds soup, cakes, bakes and juices
```
## Thumbnail
A good thumbnail goes a long way to making a package more appealing. It's one of the few things
a user sees before clicking on your package. Make sure it's possible to tell what a
thumbnail is when it's small.
For a preview of what your package will look like inside Luanti, see
Edit Package > Screenshots.
## Screenshots
Upload a good selection of screenshots that show what is possible with your packages.
You may wish to focus on a different key feature in each of your screenshots.
A lot of users won't bother reading text, and will just look at screenshots.
## Long description
The target audience of your package page is end users.
The long description should explain what your package is about,
why the user should choose it, and how to use it if they download it.
[NodeCore](https://content.luanti.org/packages/Warr1024/nodecore/) is a good
example of what to do. For inspiration, you might want to look at how games on
Steam write their descriptions.
Your long description might contain:
* What does the package contain/have? ie: list of high-level features.
* What makes it special? Why should users choose this over another package?
* How can you use it?
The following are redundant and should probably not be included:
* A heading with the title of the package
* The short description
* Links to a Git repository, the forum topic, the package's ContentDB page (ContentDB has fields for this)
* License (unless you need to give more information than ContentDB's license fields)
* API reference (unless your mod is a library only)
* Development instructions for your package (this should be in the repo's README)
* Screenshots that are already uploaded (unless you want to embed a recipe image in a specific place)
* Note: you should avoid images in the long description as they won't be visible inside Luanti,
when support for showing the long description is added.
## Localize / Translate your package
According to Google Play, 64% of Luanti Android users don't have English as their main language.
Adding translation support to your package increases accessibility. Using content translation, you
can also translate your ContentDB page. See Edit Package > Translation for more information.
<p>
<a class="btn btn-primary me-2" href="https://rubenwardy.com/minetest_modding_book/en/quality/translations.html">
{{ _("Translation - Luanti Modding Book") }}
</a>
<a class="btn btn-primary" href="https://api.luanti.org/translations/#translating-content-meta">
{{ _("Translating content meta - lua_api.md") }}
</a>
</p>

View File

@@ -11,4 +11,4 @@ We take copyright violation and other offenses very seriously.
## Other ## Other
<a href="{{ admin_contact_url }}" class="btn btn-primary">Contact the admin</a> <a href="https://rubenwardy.com/contact/" class="btn btn-primary">Contact the admin</a>

View File

@@ -6,7 +6,7 @@ your client to use new flags.
## Flags ## Flags
Luanti allows you to specify a comma-separated list of flags to hide in the Minetest allows you to specify a comma-separated list of flags to hide in the
client: client:
``` ```
@@ -17,7 +17,7 @@ A flag can be:
* `nonfree`: can be used to hide packages which do not qualify as * `nonfree`: can be used to hide packages which do not qualify as
'free software', as defined by the Free Software Foundation. 'free software', as defined by the Free Software Foundation.
* `wip`: packages marked as Work in Progress * `wip`: packages marked as Work in Progress
* `deprecated`: packages marked as Deprecated * `deprecated`: packages marked as Deprecated
* A content warning, given below. * A content warning, given below.
* `*`: hides all content warnings. * `*`: hides all content warnings.
@@ -33,8 +33,8 @@ without making a release.
Packages with mature content will be tagged with a content warning based Packages with mature content will be tagged with a content warning based
on the content type. on the content type.
* `alcohol_tobacco`: alcohol or tobacco.
* `bad_language`: swearing. * `bad_language`: swearing.
* `drugs`: drugs or alcohol.
* `gambling` * `gambling`
* `gore`: blood, etc. * `gore`: blood, etc.
* `horror`: shocking and scary content. * `horror`: shocking and scary content.

View File

@@ -48,7 +48,7 @@ It's common to do this in README.md or LICENSE.md like so:
* conquer_arrow_*.png from [Simple Shooter](https://github.com/stujones11/shooter) by Stuart Jones, CC0 1.0. * conquer_arrow_*.png from [Simple Shooter](https://github.com/stujones11/shooter) by Stuart Jones, CC0 1.0.
* conquer_arrow.b3d from [Simple Shooter](https://github.com/stujones11/shooter) by Stuart Jones, CC-BY-SA 3.0. * conquer_arrow.b3d from [Simple Shooter](https://github.com/stujones11/shooter) by Stuart Jones, CC-BY-SA 3.0.
* conquer_arrow_head.png from MTG, CC-BY-SA 3.0. * conquer_arrow_head.png from MTG, CC-BY-SA 3.0.
* health_*.png from [Gauges](https://content.luanti.org/packages/Calinou/gauges/) by Calinou, CC0. * health_*.png from [Gauges](https://content.minetest.net/packages/Calinou/gauges/) by Calinou, CC0.
``` ```
if you have a lot of media, then you can split it up by author like so: if you have a lot of media, then you can split it up by author like so:
@@ -75,7 +75,7 @@ Your Name, CC BY-SA 4.0:
* [Kenney game assets](https://www.kenney.nl/assets) - everything * [Kenney game assets](https://www.kenney.nl/assets) - everything
* [Free Sound](https://freesound.org/) - sounds * [Free Sound](https://freesound.org/) - sounds
* [PolyHaven](https://polyhaven.com/) - 3d models and textures. * [PolyHaven](https://polyhaven.com/) - 3d models and textures.
* Other Luanti mods/games * Other Minetest mods/games
Don't assume the author has correctly licensed their work. Don't assume the author has correctly licensed their work.
Make sure they have clearly indicated the source in a list [like above](#list-the-sources-of-your-media). Make sure they have clearly indicated the source in a list [like above](#list-the-sources-of-your-media).
@@ -135,13 +135,12 @@ ContentDB editors will check packages to make sure the package page's license ma
inside the package download, but do not investigate each piece of media or line of code. inside the package download, but do not investigate each piece of media or line of code.
If a copyright violation is reported to us, we will unlist the package and contact the author/maintainers. If a copyright violation is reported to us, we will unlist the package and contact the author/maintainers.
Once the problem has been fixed, the package can be restored. Repeated copyright infringement may lead to Once the problem has been fixed, the package can be restored.
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.

View File

@@ -26,6 +26,7 @@ The [Editor Work Queue](/todo/) and related pages contain useful information for
* The package, release, and screenshot approval queues. * The package, release, and screenshot approval queues.
* Packages which are outdated or are missing tags. * Packages which are outdated or are missing tags.
* A list of forum topics without packages. * A list of forum topics without packages.
Editors can create the packages or "discard" them if they don't think it's worth adding them.
## Editor Notifications ## Editor Notifications
@@ -53,4 +54,5 @@ A simplified process for reviewing a package is as follows:
usually incorrect. usually incorrect.
4. check source, etc links to make sure they work and are correct. 4. check source, etc links to make sure they work and are correct.
5. verify that the package has license file that matches what is on the contentdb fields 5. verify that the package has license file that matches what is on the contentdb fields
6. if the above steps pass, approve the package, else request changes needed from the author 6. verify that all assets and code are licensed correctly
7. if the above steps pass, approve the package, else request changes needed from the author

View File

@@ -34,7 +34,7 @@ then you can just set a new email in
[Settings > Email and Notifications](/user/settings/email/). [Settings > Email and Notifications](/user/settings/email/).
If you have previously unsubscribed this email, then ContentDB is completely prevented from sending emails to that If you have previously unsubscribed this email, then ContentDB is completely prevented from sending emails to that
address. You'll need to use a different email address, or [contact the admin]({{ admin_contact_url }}) to address. You'll need to use a different email address, or [contact rubenwardy](https://rubenwardy.com/contact/) to
remove your email from the blacklist. remove your email from the blacklist.
@@ -48,20 +48,16 @@ There are a number of methods:
* [Webhooks](/help/release_webhooks/): you can configure your Git host to send a webhook to ContentDB, and create an update immediately. * [Webhooks](/help/release_webhooks/): you can configure your Git host to send a webhook to ContentDB, and create an update immediately.
* the [API](/help/api/): This is especially powerful when combined with CI/CD and other API endpoints. * the [API](/help/api/): This is especially powerful when combined with CI/CD and other API endpoints.
### How do I learn how to make mods and games for Luanti? ### How do I learn how to make mods and games for Minetest?
You should read You should read
[the official Luanti Modding Book](https://rubenwardy.com/minetest_modding_book/) [the official Minetest Modding Book](https://rubenwardy.com/minetest_modding_book/)
for a guide to making mods and games using Luanti. for a guide to making mods and games using Minetest.
### How do I install something from here? ### How do I install something from here?
See [Installing content](/help/installing/). See [Installing content](/help/installing/).
### How can my package get more downloads?
See [Creating an appealing ContentDB page](/help/appealing_page/).
## How do I get help? ## How do I get help?

View File

@@ -7,10 +7,10 @@ title: Featured Packages
## What are Featured Packages? ## What are Featured Packages?
Featured Packages are shown at the top of the ContentDB homepage. In the future, Featured Packages are shown at the top of the ContentDB homepage. In the future,
featured packages may be shown inside the Luanti client. featured packages may be shown inside the Minetest client.
The purpose is to promote content that demonstrates a high quality of what is The purpose is to promote content that demonstrates a high quality of what is
possible in Luanti. The selection should be varied, and should vary over time. possible in Minetest. The selection should be varied, and should vary over time.
The featured content should be content that we are comfortable recommending to The featured content should be content that we are comfortable recommending to
a first time player. a first time player.
@@ -47,7 +47,7 @@ other packages to be featured, or for another reason.
* MUST: Be 100% free and open source (as marked as Free on ContentDB). * MUST: Be 100% free and open source (as marked as Free on ContentDB).
* MUST: Work out-of-the-box (no weird setup or settings required). * MUST: Work out-of-the-box (no weird setup or settings required).
* MUST: Be compatible with the latest stable Luanti release. * MUST: Be compatible with the latest stable Minetest release.
* SHOULD: Use public source control (such as Git). * SHOULD: Use public source control (such as Git).
* SHOULD: Have at least 3 reviews, and be largely positive. * SHOULD: Have at least 3 reviews, and be largely positive.
@@ -94,7 +94,7 @@ is available.
### Usability ### Usability
* MUST: Unsupported mapgens are disabled in game.conf. * MUST: Unsupported mapgens are disabled in game.conf.
* SHOULD: Passes the Beginner Test: A newbie to the game (but not Luanti) wouldn't get completely * SHOULD: Passes the Beginner Test: A newbie to the game (but not Minetest) wouldn't get completely
stuck within the first 5 minutes of playing. stuck within the first 5 minutes of playing.
* SHOULD: Have good documentation. This may include one or more of: * SHOULD: Have good documentation. This may include one or more of:
* A craftguide, or other in-game learning system * A craftguide, or other in-game learning system

View File

@@ -1,16 +0,0 @@
title: Feeds
You can follow updates from ContentDB in your RSS feed reader. If in doubt, copy the Atom URL.
* All events: [Atom]({{ url_for('feeds.all_atom') }}) | [JSONFeed]({{ url_for('feeds.all_json') }})
* New packages: [Atom]({{ url_for('feeds.packages_all_atom') }}) | [JSONFeed]({{ url_for('feeds.packages_all_json') }})
* New releases: [Atom]({{ url_for('feeds.releases_all_atom') }}) | [JSONFeed]({{ url_for('feeds.releases_all_json') }})
## Package feeds
Follow new releases for a package:
```
https://content.luanti.org/packages/AUTHOR/NAME/releases_feed.atom
https://content.luanti.org/packages/AUTHOR/NAME/releases_feed.json
```

View File

@@ -1,5 +1,9 @@
title: Supported Games title: Supported Games
<p class="alert alert-warning">
This feature is experimental
</p>
## Why? ## Why?
The supported/compatible games feature allows mods to specify the games that The supported/compatible games feature allows mods to specify the games that
@@ -17,7 +21,7 @@ You can use `unsupported_games` to specify games that your package doesn't work
with, which is useful for overriding ContentDB's automatic detection. with, which is useful for overriding ContentDB's automatic detection.
Both of these are comma-separated lists of game technical ids. Any `_game` Both of these are comma-separated lists of game technical ids. Any `_game`
suffixes are ignored, just like in Luanti. suffixes are ignored, just like in Minetest.
supported_games = minetest_game, repixture supported_games = minetest_game, repixture
unsupported_games = lordofthetest, nodecore, whynot unsupported_games = lordofthetest, nodecore, whynot

View File

@@ -1,5 +1,5 @@
title: How to install mods, games, and texture packs title: How to install mods, games, and texture packs
description: A guide to installing mods, games, and texture packs in Luanti. description: A guide to installing mods, games, and texture packs in Minetest.
## Installing from the main menu (recommended) ## Installing from the main menu (recommended)
@@ -7,8 +7,8 @@ description: A guide to installing mods, games, and texture packs in Luanti.
1. Open the mainmenu 1. Open the mainmenu
2. Go to the Content tab and click "Browse online content". 2. Go to the Content tab and click "Browse online content".
If you don't see this, then you need to update Luanti to v5. If you don't see this, then you need to update Minetest to v5.
3. Search for the package you want to install, and click "Install". 3. Search for the package you want to install, and click "Install".
4. When installing a mod, you may be shown a dialog about dependencies here. 4. When installing a mod, you may be shown a dialog about dependencies here.
Make sure the base game dropdown box is correct, and then click "Install". Make sure the base game dropdown box is correct, and then click "Install".
@@ -16,7 +16,7 @@ description: A guide to installing mods, games, and texture packs in Luanti.
<div class="col-md-6"> <div class="col-md-6">
<figure> <figure>
<a href="/static/installing_content_tab.png"> <a href="/static/installing_content_tab.png">
<img class="w-100" src="/static/installing_content_tab.png" alt="Screenshot of the content tab in Luanti"> <img class="w-100" src="/static/installing_content_tab.png" alt="Screenshot of the content tab in minetest">
</a> </a>
<figcaption class="text-muted ps-1"> <figcaption class="text-muted ps-1">
1. Click Browser Online Content in the content tab. 1. Click Browser Online Content in the content tab.
@@ -26,7 +26,7 @@ description: A guide to installing mods, games, and texture packs in Luanti.
<div class="col-md-6"> <div class="col-md-6">
<figure> <figure>
<a href="/static/installing_cdb_dialog.png"> <a href="/static/installing_cdb_dialog.png">
<img class="w-100" src="/static/installing_cdb_dialog.png" alt="Screenshot of the content tab in Luanti"> <img class="w-100" src="/static/installing_cdb_dialog.png" alt="Screenshot of the content tab in minetest">
</a> </a>
<figcaption class="text-muted ps-1"> <figcaption class="text-muted ps-1">
2. Search for the package and click "Install". 2. Search for the package and click "Install".
@@ -38,7 +38,7 @@ description: A guide to installing mods, games, and texture packs in Luanti.
Troubleshooting: Troubleshooting:
* I can't find it in the ContentDB dialog (Browse online content) * I can't find it in the ContentDB dialog (Browse online content)
* Make sure that you're on the latest version of Luanti. * Make sure that you're on the latest version of Minetest.
* Are you using Android? Packages with content warnings are hidden by default on android, * Are you using Android? Packages with content warnings are hidden by default on android,
you can show them by removing `android_default` from the `contentdb_flag_blacklist` setting. you can show them by removing `android_default` from the `contentdb_flag_blacklist` setting.
* Does the webpage show "Non-free" warnings? Non-free content is hidden by default from all clients, * Does the webpage show "Non-free" warnings? Non-free content is hidden by default from all clients,
@@ -51,14 +51,14 @@ Troubleshooting:
1. Mods: Enable the content using "Select Mods" when selecting a world. 1. Mods: Enable the content using "Select Mods" when selecting a world.
2. Games: choose a game when making a world. 2. Games: choose a game when making a world.
3. Texture packs: Content > Select pack > Click enable. 3. Texture packs: Content > Select pack > Click enable.
<div class="row mt-5"> <div class="row mt-5">
<div class="col-md-6"> <div class="col-md-6">
<figure> <figure>
<a href="/static/installing_select_mods.png"> <a href="/static/installing_select_mods.png">
<img class="w-100" src="/static/installing_select_mods.png" alt="Screenshot of Select Mods in Luanti"> <img class="w-100" src="/static/installing_select_mods.png" alt="Screenshot of Select Mods in Minetest">
</a> </a>
<figcaption class="text-muted ps-1"> <figcaption class="text-muted ps-1">
Enable mods using the Select Mods dialog. Enable mods using the Select Mods dialog.
@@ -76,7 +76,7 @@ Troubleshooting:
3. Find the user data directory. 3. Find the user data directory.
In 5.4.0 and above, you can click "Open user data directory" in the Credits tab. In 5.4.0 and above, you can click "Open user data directory" in the Credits tab.
Otherwise: Otherwise:
* Windows: wherever you extracted or installed Luanti to. * Windows: whereever you extracted or installed Minetest to.
* Linux: usually `~/.minetest/` * Linux: usually `~/.minetest/`
4. Open or create the folder for the type of content (`mods`, `games`, or `textures`) 4. Open or create the folder for the type of content (`mods`, `games`, or `textures`)
5. Git clone there 5. Git clone there

View File

@@ -9,13 +9,11 @@ and modern alerting approach".
Prometheus Metrics can be accessed at [/metrics](/metrics), or you can view them Prometheus Metrics can be accessed at [/metrics](/metrics), or you can view them
on the Grafana instance below. on the Grafana instance below.
{% if monitoring_url %}
<p> <p>
<a class="btn btn-primary" href="{{ monitoring_url }}"> <a class="btn btn-primary" href="https://monitor.rubenwardy.com/d/3ELzFy3Wz/contentdb">
View ContentDB on Grafana View ContentDB on Grafana
</a> </a>
</p> </p>
{% endif %}
## Metrics ## Metrics

View File

@@ -13,7 +13,7 @@ and they will be subject to limited promotion.
**ContentDB does not allow certain non-free licenses, and will limit the promotion **ContentDB does not allow certain non-free licenses, and will limit the promotion
of packages with non-free licenses.** of packages with non-free licenses.**
Luanti is free and open source software, and is only as big as it is now Minetest is free and open source software, and is only as big as it is now
because of this. It's pretty amazing you can take nearly any published mod and modify it because of this. It's pretty amazing you can take nearly any published mod and modify it
to how you like - add some features, maybe fix some bugs - and then share those to how you like - add some features, maybe fix some bugs - and then share those
modifications without the worry of legal issues. The project, itself, relies on open modifications without the worry of legal issues. The project, itself, relies on open
@@ -24,9 +24,9 @@ If you have played nearly any game with a large modding scene, you will find
that most mods are legally ambiguous. A lot of them don't even provide the that most mods are legally ambiguous. A lot of them don't even provide the
source code to allow you to bug fix or extend as you need. source code to allow you to bug fix or extend as you need.
Limiting the promotion of problematic licenses helps Luanti avoid ending up in Limiting the promotion of problematic licenses helps Minetest avoid ending up in
such a state. Licenses that prohibit redistribution or modification are such a state. Licenses that prohibit redistribution or modification are
completely banned from ContentDB and the Luanti forums. Other non-free licenses completely banned from ContentDB and the Minetest forums. Other non-free licenses
will be subject to limited promotion - they won't be shown by default in will be subject to limited promotion - they won't be shown by default in
the client. the client.
@@ -37,7 +37,7 @@ you spread it.
## What's so bad about licenses that forbid commercial use? ## What's so bad about licenses that forbid commercial use?
Please read [reasons not to use a Creative Commons -NC license](https://freedomdefined.org/Licenses/NC). Please read [reasons not to use a Creative Commons -NC license](https://freedomdefined.org/Licenses/NC).
Here's a quick summary related to Luanti content: Here's a quick summary related to Minetest content:
1. They make your work incompatible with a growing body of free content, even if 1. They make your work incompatible with a growing body of free content, even if
you do want to allow derivative works or combinations. you do want to allow derivative works or combinations.
@@ -68,16 +68,6 @@ Users can opt in to showing non-free software, if they wish:
The [`platform_default` flag](/help/content_flags/) is used to control what content The [`platform_default` flag](/help/content_flags/) is used to control what content
each platforms shows. It doesn't hide anything on Desktop, but hides all mature each platforms shows. It doesn't hide anything on Desktop, but hides all mature
content on Android. You may wish to remove all text from that setting completely, content on Android. You may wish to remove all text from that setting completely,
leaving it blank. See [Content Warnings](/help/content_flags/#content-warnings) leaving it blank. See [Content Warnings](/help/content_flags/#content-warnings)
for information on mature content. for information on mature content.
## How can I hide non-free packages on the website?
Clicking "Hide non-free packages" in the footer of ContentDB will hide non-free packages from search results.
It will not hide non-free packages from user profiles.
## See also
* [List of non-free packages](/packages/?flag=nonfree)
* [Copyright Guide](/help/copyright)

View File

@@ -8,11 +8,6 @@ ContentDB allows you to create an OAuth2 Application and obtain access tokens
for users. for users.
## Scopes
OAuth2 applications can currently only access public user data, using the whoami API.
## Create an OAuth2 Client ## Create an OAuth2 Client
Go to Settings > [OAuth2 Applications](/user/apps/) > Create Go to Settings > [OAuth2 Applications](/user/apps/) > Create
@@ -27,7 +22,7 @@ ContentDB supports the Authorization Code OAuth2 method.
Get the user to open the following URL in a web browser: Get the user to open the following URL in a web browser:
``` ```
https://content.luanti.org/oauth/authorize/ https://content.minetest.net/oauth/authorize/
?response_type=code ?response_type=code
&client_id={CLIENT_ID} &client_id={CLIENT_ID}
&redirect_uri={REDIRECT_URL} &redirect_uri={REDIRECT_URL}
@@ -52,7 +47,7 @@ Next, you'll need to exchange the auth for an access token.
Do this by making a POST request to the `/oauth/token/` API: Do this by making a POST request to the `/oauth/token/` API:
```bash ```bash
curl -X POST https://content.luanti.org/oauth/token/ \ curl -X POST https://content.minetest.net/oauth/token/ \
-F grant_type=authorization_code \ -F grant_type=authorization_code \
-F client_id="CLIENT_ID" \ -F client_id="CLIENT_ID" \
-F client_secret="CLIENT_SECRET" \ -F client_secret="CLIENT_SECRET" \
@@ -69,7 +64,6 @@ If successful, you'll receive:
```json ```json
{ {
"success": true,
"access_token": "access_token", "access_token": "access_token",
"token_type": "Bearer" "token_type": "Bearer"
} }
@@ -98,6 +92,15 @@ Possible errors:
Next, you should check the access token works by getting the user information: Next, you should check the access token works by getting the user information:
```bash ```bash
curl https://content.luanti.org/api/whoami/ \ curl https://content.minetest.net/api/whoami/ \
-H "Authorization: Bearer YOURTOKEN" -H "Authorization: Bearer YOURTOKEN"
``` ```
## Scopes
* (no scope) - public data only
* `user:email`: read user email
* `package`: write access to packages
* `package:release`: create and delete releases
* `package:screenshot`: create, edit, delete screenshots

View File

@@ -42,8 +42,8 @@ ContentDB understands the following information:
* `description` - A short description to show in the client. * `description` - A short description to show in the client.
* `depends` - Comma-separated hard dependencies. * `depends` - Comma-separated hard dependencies.
* `optional_depends` - Comma-separated soft dependencies. * `optional_depends` - Comma-separated soft dependencies.
* `min_minetest_version` - The minimum Luanti version this runs on, see [Min and Max Luanti Versions](#min_max_versions). * `min_minetest_version` - The minimum Minetest version this runs on, see [Min and Max Minetest Versions](#min_max_versions).
* `max_minetest_version` - The maximum Luanti version this runs on, see [Min and Max Luanti Versions](#min_max_versions). * `max_minetest_version` - The maximum Minetest version this runs on, see [Min and Max Minetest Versions](#min_max_versions).
and for mods only: and for mods only:
@@ -68,15 +68,14 @@ It should be a JSON dictionary with one or more of the following optional keys:
* `tags`: List of tag names, see [/api/tags/](/api/tags/). * `tags`: List of tag names, see [/api/tags/](/api/tags/).
* `content_warnings`: List of content warning names, see [/api/content_warnings/](/api/content_warnings/). * `content_warnings`: List of content warning names, see [/api/content_warnings/](/api/content_warnings/).
* `license`: A license name, see [/api/licenses/](/api/licenses/). * `license`: A license name, see [/api/licenses/](/api/licenses/).
* `media_license`: A license name. * `media_license`: A license name.
* `long_description`: Long markdown description. * `long_description`: Long markdown description.
* `repo`: Source repository (eg: Git). * `repo`: Git repo URL.
* `website`: Website URL. * `website`: Website URL.
* `issue_tracker`: Issue tracker URL. * `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID. * `forums`: forum topic ID.
* `video_url`: URL to a video. * `video_url`: URL to a video.
* `donate_url`: URL to a donation page. * `donate_url`: URL to a donation page.
* `translation_url`: URL to send users interested in translating your package.
Use `null` or `[]` to unset fields where relevant. Use `null` or `[]` to unset fields where relevant.
@@ -106,11 +105,11 @@ See [Git Update Detection](/help/update_config/).
You can also use [GitLab/GitHub webhooks](/help/release_webhooks/) or the [API](/help/api/) You can also use [GitLab/GitHub webhooks](/help/release_webhooks/) or the [API](/help/api/)
to create releases. to create releases.
### Min and Max Luanti Versions ### Min and Max Minetest Versions
<a name="min_max_versions" /> <a name="min_max_versions" />
When creating a release, the `.conf` file will be read to determine what Luanti When creating a release, the `.conf` file will be read to determine what Minetest
versions the release supports. If the `.conf` doesn't specify, then it is assumed versions the release supports. If the `.conf` doesn't specify, then it is assumed
that it supports all versions. that it supports all versions.

View File

@@ -16,21 +16,14 @@ See [Git Update Detection](/help/update_config/).
The process is as follows: The process is as follows:
1. The user creates an API Token and a webhook to use it. 1. The user creates an API Token and a webhook to use it.
2. The user pushes a commit to the git host (GitLab or GitHub). 2. The user pushes a commit to the git host (Gitlab or Github).
3. The git host posts a webhook notification to ContentDB, using the API token assigned to it. 3. The git host posts a webhook notification to ContentDB, using the API token assigned to it.
4. ContentDB checks the API token and issues a new release. 4. ContentDB checks the API token and issues a new release.
* If multiple packages match, then only the first will have a release created.
### Branch filtering
By default, "New commit" or "push" based webhooks will only work on "master"/"main" branches.
You can configure the branch used by changing "Branch name" in [Git update detection](update_config).
For example, to support production and beta packages you can have multiple packages with the same VCS repo URL
but different [Git update detection](update_config) branch names.
Tag-based webhooks are accepted on any branch.
<p class="alert alert-warning">
"New commit" or "push" based webhooks will currently only work on branches named `master` or
`main`.
</p>
## Setting up ## Setting up
@@ -39,33 +32,32 @@ Tag-based webhooks are accepted on any branch.
1. Create a ContentDB API Token at [Profile > API Tokens: Manage](/user/tokens/). 1. Create a ContentDB API Token at [Profile > API Tokens: Manage](/user/tokens/).
2. Copy the access token that was generated. 2. Copy the access token that was generated.
3. Go to the GitLab repository's settings > Webhooks > Add Webhook. 3. Go to the GitLab repository's settings > Webhooks > Add Webhook.
4. Set the payload URL to `https://content.luanti.org/github/webhook/` 4. Set the payload URL to `https://content.minetest.net/github/webhook/`
5. Set the content type to JSON. 5. Set the content type to JSON.
6. Set the secret to the access token that you copied. 6. Set the secret to the access token that you copied.
7. Set the events 7. Set the events
* If you want a rolling release, choose "just the push event". * If you want a rolling release, choose "just the push event".
* Or if you want a stable release cycle based on tags, choose "Let me select" > Branch or tag creation. * Or if you want a stable release cycle based on tags,
choose "Let me select" > Branch or tag creation.
8. Create. 8. Create.
9. If desired, change [Git update detection](update_config) > Branch name to configure the [branch filtering](#branch-filtering).
### GitLab ### GitLab
1. Create a ContentDB API Token at [Profile > API Tokens: Manage](/user/tokens/). 1. Create a ContentDB API Token at [Profile > API Tokens: Manage](/user/tokens/).
2. Copy the access token that was generated. 2. Copy the access token that was generated.
3. Go to the GitLab repository's settings > Webhooks. 3. Go to the GitLab repository's settings > Webhooks.
4. Set the URL to `https://content.luanti.org/gitlab/webhook/` 4. Set the URL to `https://content.minetest.net/gitlab/webhook/`
6. Set the secret token to the ContentDB access token that you copied. 6. Set the secret token to the ContentDB access token that you copied.
7. Set the events 7. Set the events
* If you want a rolling release, choose "Push events". * If you want a rolling release, choose "Push events".
* Or if you want a stable release cycle based on tags, * Or if you want a stable release cycle based on tags,
choose "Tag push events". choose "Tag push events".
8. Add webhook. 8. Add webhook.
9. If desired, change [Git update detection](update_config) > Branch name to configure the [branch filtering](#branch-filtering).
## Configuring Release Creation ## Configuring Release Creation
See the [Package Configuration and Releases Guide](/help/package_config/) for See the [Package Configuration and Releases Guide](/help/package_config/) for
documentation on configuring the release creation. documentation on configuring the release creation.
From the Git repository, you can set the min/max Luanti versions, which files are included, From the Git repository, you can set the min/max Minetest versions, which files are included,
and update the package meta. and update the package meta.

View File

@@ -19,7 +19,7 @@ score = avg_downloads + reviews_sum;
## Pseudo rolling average of downloads ## Pseudo rolling average of downloads
Each package adds 1 to `avg_downloads` for each unique download, Each package adds 1 to `avg_downloads` for each unique download,
and then loses 6.66% (=1/15) of the value every day. and then loses 5% (=1/20) of the value every day.
This is called a [Frecency](https://en.wikipedia.org/wiki/Frecency) heuristic, This is called a [Frecency](https://en.wikipedia.org/wiki/Frecency) heuristic,
a measure which combines both frequency and recency. a measure which combines both frequency and recency.
@@ -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=).

View File

@@ -39,5 +39,5 @@ Clicking "Save" on "Update Settings" will mark a package as up-to-date.
See the [Package Configuration and Releases Guide](/help/package_config/) for See the [Package Configuration and Releases Guide](/help/package_config/) for
documentation on configuring the release creation. documentation on configuring the release creation.
From the Git repository, you can set the min/max Luanti versions, which files are included, From the Git repository, you can set the min/max Minetest versions, which files are included,
and update the package meta. and update the package meta.

View File

@@ -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)

View File

@@ -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 Mature content is permitted providing that it is labelled correctly.
[content warning](/help/content_flags/). See [Content Flags](/help/content_flags/).
### 2.2. Useful Content / State of Completion ### 2.2. State of Completion
ContentDB is for playable and useful content - content which is sufficiently ContentDB should only currently contain playable content - content which is
complete to be useful to end-users. sufficiently 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 adds sufficient value;
MineClone 2 is a good example of a WIP package which may break between releases
but still has value. Note that this doesn't mean that you should add a thing
you started working on yesterday, it's worth adding all the basic stuff to
make your package useful.
It's fine to add stuff which is still a Work in Progress (WIP) as long as it You should make sure to mark Work in Progress stuff as such in the "maintenance status" column,
adds sufficient value. You must make sure to mark Work in Progress stuff as as this will help advise players.
such in the "maintenance status" dropdown, 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
fine and encouraged. ContentDB isn't just for player-facing things and adding 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,19 +98,20 @@ 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
number of potential users your package has. Using a closed source license will number of potential users your package has. Using a closed source license will
result in your package not being shown in Luanti by default. See the help page result in your package being massively penalised in the search results and
on [non-free licenses](/help/non_free/) for more information. package lists. See the help page on [non-free licenses](/help/non_free/) for more
information.
It is recommended that you use a proper license for code with a warranty It is recommended that you use a proper license for code with a warranty
disclaimer, such as the (L)GPL or MIT. You should also use a proper media license disclaimer, such as the (L)GPL or MIT. You should also use a proper media license
@@ -176,14 +153,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 +172,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
@@ -212,8 +196,6 @@ clearly that it does in the package meta.
Packages must not ask that users disable mod security (`secure.enable_security`). Packages must not ask that users disable mod security (`secure.enable_security`).
Instead, they should use the insecure environment API. Instead, they should use the insecure environment API.
Packages must not contain obfuscated code.
## 9. Reporting Violations ## 9. Reporting Violations

View File

@@ -1,8 +1,7 @@
title: Privacy Policy title: Privacy Policy
---
Last Updated: 2024-04-30 Last Updated: 2022-01-23
([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
@@ -12,9 +11,8 @@ Last Updated: 2024-04-30
* Time * Time
* IP address * IP address
* Page URL * Page URL
* Platform and Operating System * Response status code
* Preferred language/locale. This defaults to the browser's locale, but can be changed by the user * Preferred language/locale. This defaults to the browser's locale, but can be changed by the user
* Whether an IP address has downloaded a particular package in the last 14 days
**With an account:** **With an account:**
@@ -34,7 +32,7 @@ Please avoid giving other personal information as we do not want it.
## How this information is used ## How this information is used
* Logged HTTP requests may be used for debugging ContentDB and combating abuse. * Logged HTTP requests may be used for debugging ContentDB.
* Email addresses are used to: * Email addresses are used to:
* Provide essential system messages, such as password resets and privacy policy updates. * Provide essential system messages, such as password resets and privacy policy updates.
* Send notifications - the user may configure this to their needs, including opting out. * Send notifications - the user may configure this to their needs, including opting out.
@@ -42,21 +40,13 @@ Please avoid giving other personal information as we do not want it.
* Passwords are used to authenticate the user. * Passwords are used to authenticate the user.
* The audit log is used to record actions that may be harmful. * The audit log is used to record actions that may be harmful.
* Preferred language/locale is used to translate emails and the ContentDB interface. * Preferred language/locale is used to translate emails and the ContentDB interface.
* Requests (such as downloads) are used for aggregated statistics and for
calculating the popularity of packages. For example, download counts are shown
for each package and release and there are also download graphs available for
each package.
* Whether an IP address has downloaded a package or release is cached to prevent
downloads from being counted multiple times per IP address, but this
information is deleted after 14 days.
* IP addresses are used to monitor and combat abuse.
* Other information is displayed as part of ContentDB's service. * Other information is displayed as part of ContentDB's service.
## Who has access ## Who has access
* Only the admin has access to the HTTP requests. * Only the admin has access to the HTTP requests.
The logs may be shared with others to aid in debugging, but care will be taken to remove any personal information. The logs may be shared with others to aid in debugging, but care will be taken to remove any personal information.
* Encrypted backups may be shared with selected Luanti staff members (moderators + core devs). * Encrypted backups may be shared with selected Minetest staff members (moderators + core devs).
The keys and the backups themselves are given to different people, The keys and the backups themselves are given to different people,
requiring at least two staff members to read a backup. requiring at least two staff members to read a backup.
* Email addresses are visible to moderators and the admin. * Email addresses are visible to moderators and the admin.
@@ -67,52 +57,44 @@ Please avoid giving other personal information as we do not want it.
They are either public, or visible only to the package author and editors. They are either public, or visible only to the package author and editors.
* The complete audit log is visible to moderators. * The complete audit log is visible to moderators.
Users may see their own audit log actions on their account settings page. Users may see their own audit log actions on their account settings page.
Owners, maintainers, and editors can see the actions on a package. Owners, maintainers, and editors may be able to see the actions on a package in the future.
* Preferred language can only be viewed by those with access to the database or a backup. * Preferred language can only be viewed by this with access to the database or a backup.
* We may be required to share information with law enforcement. * We may be required to share information with law enforcement.
## Third-parties
We do not share any personal information with third parties.
We use <a href="https://sentry.io/">Sentry.io</a> for error logging and performance monitoring.
## Location ## Location
The ContentDB production server is currently located in Germany. The ContentDB production server is currently located in Germany.
Backups are stored in the UK. Backups are stored in the UK.
Encrypted backups may be stored in other countries, such as the US or EU. Encrypted backups may be stored in other countries, such as the US or EU.
By using this service, you give permission for the data to be moved within the By using this service, you give permission for the data to be moved as needed.
United Kingdom and/or EU.
## Period of Retention ## Period of Retention
Logged HTTP requests are automatically deleted within 2 weeks. The server uses log rotation, meaning that any logged HTTP requests will be
forgotten within a few weeks.
Usernames may be kept indefinitely, but other user information will be deleted Usernames may be kept indefinitely, but other user information will be deleted if
if requested. See below. requested. See below.
Whether an IP address has downloaded a package or release is deleted after 14 days.
## Removal Requests ## Removal Requests
Please [raise a report](/report/?anon=0) if you wish to remove your personal Please [raise a report](/report/?anon=0) if you
information. wish to remove your personal information.
ContentDB keeps a record of each username and forum topic on the forums, for use ContentDB keeps a record of each username and forum topic on the forums,
in indexing mod/game topics. ContentDB also requires the use of a username to for use in indexing mod/game topics. ContentDB also requires the use of a username
uniquely identify a package. Therefore, an author cannot be removed completely to uniquely identify a package. Therefore, an author cannot be removed completely
from ContentDB if they have any packages or mod/game topics on the forum. from ContentDB if they have any packages or mod/game topics on the forum.
If we are unable to remove your account for one of the above reasons, your user If we are unable to remove your account for one of the above reasons, your user
account will instead be wiped and deactivated, ending up exactly like an author account will instead be wiped and deactivated, ending up exactly like an author
who has not yet joined ContentDB. All personal information will be removed from who has not yet joined ContentDB. All personal information will be removed from the profile,
the profile, and any comments or threads will be deleted. and any comments or threads will be deleted.
## Future Changes to Privacy Policy ## Future Changes to Privacy Policy
We will alert any future changes to the privacy policy via notices on the We will alert any future changes to the privacy policy via email and
ContentDB website. via notices on the ContentDB website.
By continuing to use this service, you agree to the privacy policy. By continuing to use this service, you agree to the privacy policy.

15
app/flatpages/rules.md Normal file
View File

@@ -0,0 +1,15 @@
title: Rules
The following are the rules for user behaviour on ContentDB, including reviews,
threads, comments, and profiles. For packages, see the
[Package Inclusion Policy](/policy_and_guidance/).
1. **Be respectful:** attacks towards any person or group, slurs,
trolling/baiting, and other toxic behavior are not welcome.
2. **Assume good faith:** communication over the Internet is hard, try to assume
good faith when eg: responding to reviews.
3. **No sexual content** and ensure you keep discussion appropriate given the
package's [content warnings](/help/content_flags/).
You can report things by clicking [report](/report/) in the footer of pages you
want to report.

View File

@@ -1,133 +0,0 @@
title: Terms of Service
Also see the [Package Inclusion Policy](/policy_and_guidance/).
## Content
### Prohibited content
You must not post/transmit anything which is illegal under the laws in any part of the United Kingdom.
You must not (or use the service to) facilitate or commit any offence under the laws in any part of the United Kingdom.
This includes, in particular, terrorism content (as set out in Schedule 5, Online Safety Act 2023),
child sexual exploitation and abuse content (as set out in Schedule 6, Online Safety Act 2023), and
content that amounts to an offence specified in Schedule 7, Online Safety Act 2023.
Prohibited content includes:
* Pornographic content. This includes content of such a nature that it is reasonable to assume that it was produced
solely or principally for the purpose of sexual arousal.
* Content which encourages, promotes or provides instructions for suicide
* Content which encourages, promotes or provides instructions for an act of deliberate self-injury
* Content which encourages, promotes or provides instructions for an eating disorder or behaviours associated with an eating disorder
* Content which is abusive and which targets any of the following characteristics: race, religion, sex,
sexual orientation, disability, gender reassignment.
* Content which incites hatred against people:
* of a particular race, religion, sex or sexual orientation
* who have a disability
* who have the characteristic of gender reassignment
* Content which encourages, promotes or provides instructions for an act of serious violence against a person
* Bullying content
* Content which:
* depicts real or realistic serious violence against a person
* depicts the real or realistic serious injury of a person in graphic detail
* Content which:
* depicts real or realistic serious violence against an animal
* depicts the real or realistic serious injury of an animal in graphic detail
* realistically depicts serious violence against a fictional creature or the serious injury of a fictional
creature in graphic detail
* Content which encourages, promotes or provides instructions for a challenge or stunt highly likely to result in
serious injury to the person who does it or to someone else
* Content which encourages a person to ingest, inject, inhale or in any other way self-administer:
* a physically harmful substance
* a substance in such a quantity as to be physically harmful
### Protecting users from illegal content
We provide this service free of charge, and on the basis that we may:
* take down, or restrict access to, anything that you generate, upload or share; and
* suspend or ban you from using all or part of the service
if we think that this is reasonable to protect you, other users, the service, or us. This applies, in particular,
to prohibited content.
If we are alerted by a person to the presence of any illegal content, or we become aware of it in any other way,
we will swiftly take down that content.
To minimise the length of time for which any illegal content within the scope of the Online Safety Act 2023 is present:
* in respect of terrorism content, we offer an easy-to-access and use reporting function and will swiftly remove such content when we become aware of it.
* in respect of child sexual exploitation or abuse content, we offer an easy-to-access and use reporting function and will swiftly remove such content when we become aware of it.
* in respect of other content that amounts to an offence specified in Schedule 7, Online Safety Act 2023, we offer an easy-to-access and use reporting function and will swiftly remove such content when we become aware of it.
### Protecting children
We protect all children from the kinds of content listed in "Prohibited Content" by:
* prohibiting that type of content from our service; and
* swiftly taking down that content, if we are alerted by a person to its presence, or we become aware of it in any other way.
### Proactive technology
We do not use proactive technology to detect illegal content.
## Limitation of Liability
THE SERVICE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL WE BE LIABLE
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
OR IN CONNECTION WITH THE SERVICE OR THE USE OR OTHER DEALINGS IN THE SERVICE.
We reserve the right to ban or suspend your account, or take down your content, for any reason.
## Jurisdiction and Governing Law
This service is subject to the jurisdiction of the United Kingdom.
## Complaints
### Reporting content
You may report content by clicking the report flag next to a comment or "Report" on the page containing the content.
You can also make reports by [contacting the admin]({{ admin_contact_url }}).
### Complaints and Appeals
You may send a complaint / request an appeal by [contacting the admin]({{ admin_contact_url }}).
### Your right to bring a claim
This clause applies only to users within the United Kingdom.
The Online Safety Act 2023 says that you have a right to bring a claim for breach of contract if:
* anything that you generate, upload or share is taken down, or access to it is restricted, in breach of the terms of service, or
* you are suspended or banned from using the service in breach of the terms of service.
This does not apply to emails, SMS messages, MMS messages, one-to-one live aural communications,
comments and reviews (together with any further comments on such comments or reviews), or content which identifies
you as a user (e.g. a user name or profile picture).
Whether or not a contract exists between you and us is a question of fact. If we do not have a contractual
relationship with you in respect of the service, there can be no breach of contract and, as such, this cannot apply.
It is for a court to determine:
- if there is a contract between you and us and, if so, its terms
- if there has been a breach by us of that contract
- if that breach has caused you any recoverable loss
- the size (e.g. value) of your loss
This clause is subject to "Limitation of liability" and "Jurisdiction".
## Acknowledgements
This terms of service was written based on [a template](https://onlinesafetyact.co.uk/online_safety_act_terms/)
created by Neil Brown, CC BY-SA 4.0.

View File

@@ -1,148 +0,0 @@
# ContentDB
# Copyright (C) 2024 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from collections import namedtuple, defaultdict
from typing import Dict, Optional
from sqlalchemy import or_
from app.models import AuditLogEntry, db, PackageState
class PackageInfo:
state: Optional[PackageState]
first_submitted: Optional[datetime.datetime]
last_change: Optional[datetime.datetime]
approved_at: Optional[datetime.datetime]
wait_time: int
total_approval_time: int
is_in_range: bool
events: list[tuple[str, str, str]]
def __init__(self):
self.state = None
self.first_submitted = None
self.last_change = None
self.approved_at = None
self.wait_time = 0
self.total_approval_time = -1
self.is_in_range = False
self.events = []
def __lt__(self, other):
return self.wait_time < other.wait_time
def __dict__(self):
return {
"first_submitted": self.first_submitted.isoformat(),
"last_change": self.last_change.isoformat(),
"approved_at": self.approved_at.isoformat() if self.approved_at else None,
"wait_time": self.wait_time,
"total_approval_time": self.total_approval_time if self.total_approval_time >= 0 else None,
"events": [ { "date": x[0], "by": x[1], "title": x[2] } for x in self.events ],
}
def add_event(self, created_at: datetime.datetime, causer: str, title: str):
self.events.append((created_at.isoformat(), causer, title))
def get_state(title: str):
if title.startswith("Approved "):
return PackageState.APPROVED
assert title.startswith("Marked ")
for state in PackageState:
if state.value in title:
return state
if "Work in Progress" in title:
return PackageState.WIP
raise Exception(f"Unable to get state for title {title}")
Result = namedtuple("Result", "editor_approvals packages_info avg_turnaround_time max_turnaround_time")
def _get_approval_statistics(entries: list[AuditLogEntry], start_date: Optional[datetime.datetime] = None, end_date: Optional[datetime.datetime] = None) -> Result:
editor_approvals = defaultdict(int)
package_info: Dict[str, PackageInfo] = {}
ignored_packages = set()
turnaround_times: list[int] = []
for entry in entries:
package_id = str(entry.package.get_id())
if package_id in ignored_packages:
continue
info = package_info.get(package_id, PackageInfo())
package_info[package_id] = info
is_in_range = (((start_date is None or entry.created_at >= start_date) and
(end_date is None or entry.created_at <= end_date)))
info.is_in_range = info.is_in_range or is_in_range
new_state = get_state(entry.title.replace("", "") + (entry.description or ""))
if new_state == info.state:
continue
info.add_event(entry.created_at, entry.causer.username if entry.causer else None, new_state.value)
if info.state == PackageState.READY_FOR_REVIEW:
seconds = int((entry.created_at - info.last_change).total_seconds())
info.wait_time += seconds
if is_in_range:
turnaround_times.append(seconds)
if new_state == PackageState.APPROVED:
ignored_packages.add(package_id)
info.approved_at = entry.created_at
if is_in_range:
editor_approvals[entry.causer.username] += 1
if info.first_submitted is not None:
info.total_approval_time = int((entry.created_at - info.first_submitted).total_seconds())
elif new_state == PackageState.READY_FOR_REVIEW:
if info.first_submitted is None:
info.first_submitted = entry.created_at
info.state = new_state
info.last_change = entry.created_at
packages_info_2 = {}
package_count = 0
for package_id, info in package_info.items():
if info.first_submitted and info.is_in_range:
package_count += 1
packages_info_2[package_id] = info
if len(turnaround_times) > 0:
avg_turnaround_time = sum(turnaround_times) / len(turnaround_times)
max_turnaround_time = max(turnaround_times)
else:
avg_turnaround_time = 0
max_turnaround_time = 0
return Result(editor_approvals, packages_info_2, avg_turnaround_time, max_turnaround_time)
def get_approval_statistics(start_date: Optional[datetime.datetime] = None, end_date: Optional[datetime.datetime] = None) -> Result:
entries = AuditLogEntry.query.filter(AuditLogEntry.package).filter(or_(
AuditLogEntry.title.like("Approved %"),
AuditLogEntry.title.like("Marked %"))
).order_by(db.asc(AuditLogEntry.created_at)).all()
return _get_approval_statistics(entries, start_date, end_date)

View File

@@ -1,5 +1,5 @@
# ContentDB # ContentDB
# Copyright (C) rubenwardy # Copyright (C) 2022 rubenwardy
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@@ -14,12 +14,31 @@
# 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 typing import List, Dict, Optional, Tuple
import sqlalchemy import sys
from typing import List, Dict
from app.models import PackageType, Package, PackageState, PackageGameSupport import sqlalchemy.orm
from app.utils import post_bot_message
from app.logic.LogicError import LogicError
from app.models import Package, MetaPackage, PackageType, PackageState, PackageGameSupport
"""
get_game_support(package):
if package is a game:
return [ package ]
for all hard dependencies:
support = support AND get_meta_package_support(dep)
return support
get_meta_package_support(meta):
for package implementing mod name:
support = support OR get_game_support(package)
return support
"""
minetest_game_mods = { minetest_game_mods = {
@@ -36,326 +55,123 @@ mtg_mod_blacklist = {
} }
class GSPackage: class GameSupportResolver:
author: str session: sqlalchemy.orm.Session
name: str checked_packages = set()
type: PackageType checked_modnames = set()
resolved_packages: Dict[int, set[int]] = {}
resolved_modnames: Dict[int, set[int]] = {}
provides: set[str] def __init__(self, session):
depends: set[str] self.session = session
user_supported_games: set[str] def resolve_for_meta_package(self, meta: MetaPackage, history: List[str]) -> set[int]:
user_unsupported_games: set[str] print(f"Resolving for {meta.name}", file=sys.stderr)
detected_supported_games: set[str]
supports_all_games: bool
detection_disabled: bool key = meta.name
if key in self.resolved_modnames:
return self.resolved_modnames.get(key)
is_confirmed: bool if key in self.checked_modnames:
errors: set[str] print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
return set()
def __init__(self, author: str, name: str, type: PackageType, provides: set[str]): self.checked_modnames.add(key)
self.author = author
self.name = name
self.type = type
self.provides = provides
self.depends = set()
self.user_supported_games = set()
self.user_unsupported_games = set()
self.detected_supported_games = set()
self.supports_all_games = False
self.detection_disabled = False
self.is_confirmed = type == PackageType.GAME
self.errors = set()
# For dodgy games, discard MTG mods retval = set()
if self.type == PackageType.GAME and self.name in mtg_mod_blacklist:
self.provides.difference_update(minetest_game_mods)
@property for package in meta.packages:
def id_(self) -> str: if package.state != PackageState.APPROVED:
return f"{self.author}/{self.name}" continue
@property if meta.name in minetest_game_mods and package.name in mtg_mod_blacklist:
def supported_games(self) -> set[str]: continue
ret = set()
ret.update(self.user_supported_games)
if not self.detection_disabled:
ret.update(self.detected_supported_games)
ret.difference_update(self.user_unsupported_games)
return ret
@property ret = self.resolve(package, history)
def unsupported_games(self) -> set[str]: if len(ret) == 0:
return self.user_unsupported_games retval = set()
def add_error(self, error: str):
return self.errors.add(error)
class GameSupport:
packages: Dict[str, GSPackage]
modified_packages: set[GSPackage]
def __init__(self):
self.packages = {}
self.modified_packages = set()
@property
def all_confirmed(self):
return all([x.is_confirmed for x in self.packages.values()])
@property
def has_errors(self):
return any([len(x.errors) > 0 for x in self.packages.values()])
@property
def error_count(self):
return sum([len(x.errors) for x in self.packages.values()])
@property
def all_errors(self) -> set[str]:
errors = set()
for package in self.packages.values():
for err in package.errors:
errors.add(package.id_ + ": " + err)
return errors
def add(self, package: GSPackage) -> GSPackage:
self.packages[package.id_] = package
return package
def get(self, id_: str) -> Optional[GSPackage]:
return self.packages.get(id_)
def get_all_that_provide(self, modname: str) -> List[GSPackage]:
return [package for package in self.packages.values() if modname in package.provides]
def get_all_that_depend_on(self, modname: str) -> List[GSPackage]:
return [package for package in self.packages.values() if modname in package.depends]
def _get_supported_games_for_modname(self, depend: str, visited: list[str]):
dep_supports_all = False
for_dep = set()
for provider in self.get_all_that_provide(depend):
found_in = self._get_supported_games(provider, visited)
if found_in is None:
# Unsupported, keep going
pass
elif len(found_in) == 0:
dep_supports_all = True
break break
else:
for_dep.update(found_in)
return dep_supports_all, for_dep retval.update(ret)
def _get_supported_games_for_deps(self, package: GSPackage, visited: list[str]) -> Optional[set[str]]: self.resolved_modnames[key] = retval
ret = set() return retval
for depend in package.depends: def resolve(self, package: Package, history: List[str]) -> set[int]:
dep_supports_all, for_dep = self._get_supported_games_for_modname(depend, visited) key: int = package.id
print(f"Resolving for {key}", file=sys.stderr)
if dep_supports_all: history = history.copy()
# Dep is game independent history.append(package.get_id())
pass
elif len(for_dep) == 0:
package.add_error(f"Unable to fulfill dependency {depend}")
return None
elif len(ret) == 0:
ret = for_dep
else:
ret.intersection_update(for_dep)
if len(ret) == 0:
package.add_error("Game support conflict, unable to install package on any games")
return None
return ret
def _get_supported_games(self, package: GSPackage, visited: list[str]) -> Optional[set[str]]:
if package.id_ in visited:
return None
if package.type == PackageType.GAME: if package.type == PackageType.GAME:
return {package.name} return {package.id}
elif package.is_confirmed:
return package.supported_games
visited = visited.copy() if key in self.resolved_packages:
visited.append(package.id_) return self.resolved_packages.get(key)
ret = self._get_supported_games_for_deps(package, visited) if key in self.checked_packages:
if ret is None: print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
assert len(package.errors) > 0 return set()
return None
ret = ret.copy() self.checked_packages.add(key)
ret.difference_update(package.user_unsupported_games)
package.detected_supported_games = ret
self.modified_packages.add(package)
if len(ret) > 0: if package.type != PackageType.MOD:
for supported in package.user_supported_games: raise LogicError(500, "Got non-mod")
if supported not in ret:
package.add_error(f"`{supported}` is specified in supported_games but it is impossible to run {package.name} in that game. " +
f"Its dependencies can only be fulfilled in {', '.join([f'`{x}`' for x in ret])}. " +
"Check your hard dependencies.")
if package.supports_all_games: retval = set()
package.add_error(
"This package cannot support all games as some dependencies require specific game(s): " +
", ".join([f'`{x}`' for x in ret]))
package.is_confirmed = True for dep in package.dependencies.filter_by(optional=False).all():
return package.supported_games ret = self.resolve_for_meta_package(dep.meta_package, history)
if len(ret) == 0:
continue
elif len(retval) == 0:
retval.update(ret)
else:
retval.intersection_update(ret)
if len(retval) == 0:
raise LogicError(500, f"Detected game support contradiction, {key} may not be compatible with any games")
def on_update(self, package: GSPackage, old_provides: Optional[set[str]] = None): self.resolved_packages[key] = retval
to_update = {package} return retval
checked = set()
while len(to_update) > 0: def init_all(self) -> None:
current_package = to_update.pop() for package in self.session.query(Package).filter(Package.type == PackageType.MOD, Package.state != PackageState.DELETED).all():
if current_package.id_ in self.packages and current_package.type != PackageType.GAME: retval = self.resolve(package, [])
self._get_supported_games(current_package, []) for game_id in retval:
game = self.session.query(Package).get(game_id)
support = PackageGameSupport(package, game, 1, True)
self.session.add(support)
provides = current_package.provides """
if current_package == package and old_provides is not None: Update game supported package on a package, given the confidence.
provides = provides.union(old_provides)
Higher confidences outweigh lower ones.
"""
def set_supported(self, package: Package, game_is_supported: Dict[int, bool], confidence: int):
previous_supported: Dict[int, PackageGameSupport] = {}
for support in package.supported_games.all():
previous_supported[support.game.id] = support
for modname in provides: for game_id, supports in game_is_supported.items():
for depending_package in self.get_all_that_depend_on(modname): game = self.session.query(Package).get(game_id)
if depending_package not in checked: lookup = previous_supported.pop(game_id, None)
if depending_package.id_ in self.packages and depending_package.type != PackageType.GAME: if lookup is None:
depending_package.is_confirmed = False support = PackageGameSupport(package, game, confidence, supports)
depending_package.detected_supported_games = [] self.session.add(support)
elif lookup.confidence <= confidence:
lookup.supports = supports
lookup.confidence = confidence
to_update.add(depending_package) for game, support in previous_supported.items():
checked.add(depending_package) if support.confidence == confidence:
self.session.delete(support)
def on_remove(self, package: GSPackage): def update(self, package: Package) -> None:
del self.packages[package.id_] game_is_supported = {}
self.on_update(package) if package.enable_game_support_detection:
retval = self.resolve(package, [])
for game_id in retval:
game_is_supported[game_id] = True
def on_first_run(self): self.set_supported(package, game_is_supported, 1)
for package in self.packages.values():
if not package.is_confirmed:
self.on_update(package)
def _convert_package(support: GameSupport, package: Package) -> GSPackage:
# Unapproved packages shouldn't be considered to fulfill anything
provides = set()
if package.state == PackageState.APPROVED:
provides = set([x.name for x in package.provides])
gs_package = GSPackage(package.author.username, package.name, package.type, provides)
gs_package.depends = set([x.meta_package.name for x in package.dependencies if not x.optional])
gs_package.detection_disabled = not package.enable_game_support_detection
gs_package.supports_all_games = package.supports_all_games
existing_game_support = (package.supported_games
.filter(PackageGameSupport.game.has(state=PackageState.APPROVED),
PackageGameSupport.confidence > 5)
.all())
if not package.supports_all_games:
gs_package.user_supported_games = [x.game.name for x in existing_game_support if x.supports]
gs_package.user_unsupported_games = [x.game.name for x in existing_game_support if not x.supports]
return support.add(gs_package)
def _create_instance(session: sqlalchemy.orm.Session) -> GameSupport:
support = GameSupport()
packages: List[Package] = (session.query(Package)
.filter(Package.state == PackageState.APPROVED, Package.type.in_([PackageType.GAME, PackageType.MOD]))
.all())
for package in packages:
_convert_package(support, package)
return support
def _persist(session: sqlalchemy.orm.Session, support: GameSupport):
for gs_package in support.packages.values():
if len(gs_package.errors) != 0:
msg = "\n".join([f"- {x}" for x in gs_package.errors])
package = session.query(Package).filter(
Package.author.has(username=gs_package.author),
Package.name == gs_package.name).one()
post_bot_message(package, "Error when checking game support", msg, session)
for gs_package in support.modified_packages:
if not gs_package.detection_disabled:
package = session.query(Package).filter(
Package.author.has(username=gs_package.author),
Package.name == gs_package.name).one()
# Clear existing
session.query(PackageGameSupport) \
.filter_by(package=package, confidence=1) \
.delete()
# Add new
supported_games = gs_package.supported_games \
.difference(gs_package.user_supported_games)
for game_name in supported_games:
game_id = session.query(Package.id) \
.filter(Package.type == PackageType.GAME, Package.name == game_name, Package.state == PackageState.APPROVED) \
.one()[0]
new_support = PackageGameSupport()
new_support.package = package
new_support.game_id = game_id
new_support.confidence = 1
new_support.supports = True
session.add(new_support)
def game_support_update(session: sqlalchemy.orm.Session, package: Package, old_provides: Optional[set[str]]) -> set[str]:
support = _create_instance(session)
gs_package = support.get(package.get_id())
if gs_package is None:
gs_package = _convert_package(support, package)
support.on_update(gs_package, old_provides)
_persist(session, support)
return gs_package.errors
def game_support_update_all(session: sqlalchemy.orm.Session):
support = _create_instance(session)
support.on_first_run()
_persist(session, support)
def game_support_remove(session: sqlalchemy.orm.Session, package: Package):
support = _create_instance(session)
gs_package = support.get(package.get_id())
if gs_package is None:
gs_package = _convert_package(support, package)
support.on_remove(gs_package)
_persist(session, support)
def game_support_set(session, package: Package, game_is_supported: Dict[int, bool], confidence: int):
previous_supported: Dict[int, PackageGameSupport] = {}
for support in package.supported_games.all():
previous_supported[support.game.id] = support
for game_id, supports in game_is_supported.items():
game = session.query(Package).get(game_id)
lookup = previous_supported.pop(game_id, None)
if lookup is None:
support = PackageGameSupport()
support.package = package
support.game = game
support.confidence = confidence
support.supports = supports
session.add(support)
elif lookup.confidence <= confidence:
lookup.supports = supports
lookup.confidence = confidence
for game, support in previous_supported.items():
if support.confidence == confidence:
session.delete(support)

View File

@@ -1,204 +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 List, Tuple, Union, Optional
from flask_babel import lazy_gettext, LazyString
from sqlalchemy import and_, or_
from app.models import Package, PackageType, PackageState, PackageRelease, db, MetaPackage, ForumTopic, User, \
Permission, UserRank
class PackageValidationNote:
# level is danger, warning, or info
level: str
message: LazyString
buttons: List[Tuple[str, LazyString]]
# False to prevent "Approve"
allow_approval: bool
# False to prevent "Submit for Approval"
allow_submit: bool
def __init__(self, level: str, message: LazyString, allow_approval: bool, allow_submit: bool):
self.level = level
self.message = message
self.buttons = []
self.allow_approval = allow_approval
self.allow_submit = allow_submit
def add_button(self, url: str, label: LazyString) -> "PackageValidationNote":
self.buttons.append((url, label))
return self
def __repr__(self):
return str(self.message)
def is_package_name_taken(normalised_name: str) -> bool:
return Package.query.filter(
and_(Package.state == PackageState.APPROVED,
or_(Package.name == normalised_name,
Package.name == normalised_name + "_game"))).count() > 0
def get_conflicting_mod_names(package: Package) -> set[str]:
conflicting_modnames = (db.session.query(MetaPackage.name)
.filter(MetaPackage.id.in_([mp.id for mp in package.provides]))
.filter(MetaPackage.packages.any(and_(Package.id != package.id, Package.state == PackageState.APPROVED)))
.all())
conflicting_modnames += (db.session.query(ForumTopic.name)
.filter(ForumTopic.name.in_([mp.name for mp in package.provides]))
.filter(ForumTopic.topic_id != package.forums)
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id))
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title))
.all())
return set([x[0] for x in conflicting_modnames])
def count_packages_with_forum_topic(topic_id: int) -> int:
return Package.query.filter(Package.forums == topic_id, Package.state != PackageState.DELETED).count() > 1
def get_forum_topic(topic_id: int) -> Optional[ForumTopic]:
return ForumTopic.query.get(topic_id)
def validate_package_for_approval(package: Package) -> List[PackageValidationNote]:
retval: List[PackageValidationNote] = []
def template(level: str, allow_approval: bool, allow_submit: bool):
def inner(msg: LazyString):
note = PackageValidationNote(level, msg, allow_approval, allow_submit)
retval.append(note)
return note
return inner
danger = template("danger", allow_approval=False, allow_submit=False)
warning = template("warning", allow_approval=True, allow_submit=True)
info = template("info", allow_approval=False, allow_submit=True)
if package.type != PackageType.MOD and is_package_name_taken(package.normalised_name):
danger(lazy_gettext("A package already exists with this name. Please see Policy and Guidance 3"))
if package.releases.filter(PackageRelease.task_id.is_(None)).count() == 0:
if package.releases.count() == 0:
message = lazy_gettext("You need to create a release before this package can be approved.")
else:
message = lazy_gettext("Release is still importing, or has an error.")
danger(message) \
.add_button(package.get_url("packages.create_release"), lazy_gettext("Create release")) \
.add_button(package.get_url("packages.setup_releases"), lazy_gettext("Set up releases"))
# Don't bother validating any more until we have a release
return retval
if package.screenshots.count() == 0:
danger(lazy_gettext("You need to add at least one screenshot."))
missing_deps = package.get_missing_hard_dependencies_query().all()
if len(missing_deps) > 0:
missing_deps = ", ".join([ x.name for x in missing_deps])
danger(lazy_gettext(
"The following hard dependencies need to be added to ContentDB first: %(deps)s", deps=missing_deps))
if package.type != PackageType.GAME and not package.supports_all_games and package.supported_games.count() == 0:
danger(lazy_gettext(
"What games does your package support? Please specify on the supported games page", deps=missing_deps)) \
.add_button(package.get_url("packages.game_support"), lazy_gettext("Supported Games"))
if "Other" in package.license.name or "Other" in package.media_license.name:
info(lazy_gettext("Please wait for the license to be added to CDB."))
# Check similar mod name
conflicting_modnames = set()
if package.type != PackageType.TXP:
conflicting_modnames = get_conflicting_mod_names(package)
if len(conflicting_modnames) > 4:
warning(lazy_gettext("Please make sure that this package has the right to the names it uses."))
elif len(conflicting_modnames) > 0:
names_list = list(conflicting_modnames)
names_list.sort()
warning(lazy_gettext("Please make sure that this package has the right to the names %(names)s",
names=", ".join(names_list))) \
.add_button(package.get_url('packages.similar'), lazy_gettext("See more"))
# Check forum topic
if package.state != PackageState.APPROVED and package.forums is not None:
if count_packages_with_forum_topic(package.forums) > 1:
danger("<b>" + lazy_gettext("Error: Another package already uses this forum topic!") + "</b>")
topic = get_forum_topic(package.forums)
if topic is not None:
if topic.author != package.author:
danger("<b>" + lazy_gettext("Error: Forum topic author doesn't match package author.") + "</b>")
elif package.type != PackageType.TXP:
warning(lazy_gettext("Warning: Forum topic not found. The topic may have been created since the last forum crawl."))
return retval
PACKAGE_STATE_FLOW = {
PackageState.WIP: {PackageState.READY_FOR_REVIEW},
PackageState.CHANGES_NEEDED: {PackageState.READY_FOR_REVIEW},
PackageState.READY_FOR_REVIEW: {PackageState.WIP, PackageState.CHANGES_NEEDED, PackageState.APPROVED},
PackageState.APPROVED: {PackageState.CHANGES_NEEDED},
PackageState.DELETED: {PackageState.READY_FOR_REVIEW},
}
def can_move_to_state(package: Package, user: User, new_state: Union[str, PackageState]) -> bool:
if not user.is_authenticated:
return False
if type(new_state) == str:
new_state = PackageState[new_state]
elif type(new_state) != PackageState:
raise Exception("Unknown state given to can_move_to_state()")
if new_state not in PACKAGE_STATE_FLOW[package.state]:
return False
if new_state == PackageState.READY_FOR_REVIEW or new_state == PackageState.APPROVED:
# Can the user approve?
if new_state == PackageState.APPROVED and not package.check_perm(user, Permission.APPROVE_NEW):
return False
# Must be able to edit or approve package to change its state
if not (package.check_perm(user, Permission.APPROVE_NEW) or package.check_perm(user, Permission.EDIT_PACKAGE)):
return False
# Are there any validation warnings?
validation_notes = validate_package_for_approval(package)
for note in validation_notes:
if not note.allow_submit or (new_state == PackageState.APPROVED and not note.allow_approval):
return False
return True
elif new_state == PackageState.CHANGES_NEEDED:
return package.check_perm(user, Permission.APPROVE_NEW)
elif new_state == PackageState.WIP:
return package.check_perm(user, Permission.EDIT_PACKAGE) and \
(user in package.maintainers or user.rank.at_least(UserRank.ADMIN))
return True

View File

@@ -0,0 +1,56 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from collections import namedtuple
from typing import List
from flask_babel import lazy_gettext
from sqlalchemy import and_, or_
from app.models import Package, PackageType, PackageState, PackageRelease
ValidationError = namedtuple("ValidationError", "status message")
def validate_package_for_approval(package: Package) -> List[ValidationError]:
retval: List[ValidationError] = []
normalised_name = package.getNormalisedName()
if package.type != PackageType.MOD and Package.query.filter(
and_(Package.state == PackageState.APPROVED,
or_(Package.name == normalised_name,
Package.name == normalised_name + "_game"))).count() > 0:
retval.append(("danger", lazy_gettext("A package already exists with this name. Please see Policy and Guidance 3")))
if package.releases.filter(PackageRelease.task_id == None).count() == 0:
retval.append(("danger", lazy_gettext("A release is required before this package can be approved.")))
# Don't bother validating any more until we have a release
return retval
missing_deps = package.get_missing_hard_dependencies_query().all()
if len(missing_deps) > 0:
retval.append(("danger", lazy_gettext(
"The following hard dependencies need to be added to ContentDB first: %(deps)s", deps=missing_deps)))
if (package.type == package.type.GAME or package.type == package.type.TXP) and \
package.screenshots.count() == 0:
retval.append(("danger", lazy_gettext("You need to add at least one screenshot.")))
if "Other" in package.license.name or "Other" in package.media_license.name:
retval.append(("info", lazy_gettext("Please wait for the license to be added to CDB.")))
return retval

View File

@@ -23,8 +23,8 @@ from flask_babel import lazy_gettext, LazyString
from app.logic.LogicError import LogicError from app.logic.LogicError import LogicError
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \ from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \
License, PackageDevState, PackageState License, UserRank, PackageDevState
from app.utils import add_audit_log, has_blocked_domains, diff_dictionaries, describe_difference, normalize_line_endings from app.utils import add_audit_log, has_blocked_domains, diff_dictionaries, describe_difference
from app.utils.url import clean_youtube_url from app.utils.url import clean_youtube_url
@@ -66,20 +66,6 @@ ALLOWED_FIELDS = {
"forums": int, "forums": int,
"video_url": str, "video_url": str,
"donate_url": str, "donate_url": str,
"translation_url": str,
}
NULLABLE = {
"tags",
"content_warnings",
"repo",
"website",
"issue_tracker",
"issueTracker",
"forums",
"video_url",
"donate_url",
"translation_url",
} }
ALIASES = { ALIASES = {
@@ -99,13 +85,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,12 +101,13 @@ 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, public=True), 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,
reason: str = None) -> bool: reason: str = None):
if not package.check_perm(user, Permission.EDIT_PACKAGE): if not package.check_perm(user, Permission.EDIT_PACKAGE):
raise LogicError(403, lazy_gettext("You don't have permission to edit this package")) raise LogicError(403, lazy_gettext("You don't have permission to edit this package"))
@@ -136,26 +121,17 @@ 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)
for field in ["short_desc", "desc", "website", "issueTracker", "repo", "video_url", "donate_url", "translation_url"]: for field in ["short_desc", "desc", "website", "issueTracker", "repo", "video_url", "donate_url"]:
if field in data and has_blocked_domains(data[field], user.username, if field in data and has_blocked_domains(data[field], user.username,
f"{field} of {package.get_id()}"): f"{field} of {package.get_id()}"):
raise LogicError(403, lazy_gettext("Linking to blocked sites is not allowed")) raise LogicError(403, lazy_gettext("Linking to blocked sites is not allowed"))
if "type" in data: if "type" in data:
new_type = PackageType.coerce(data["type"]) data["type"] = PackageType.coerce(data["type"])
if new_type == package.type:
pass
elif package.state != PackageState.APPROVED:
package.type = new_type
else:
raise LogicError(403, lazy_gettext("You cannot change package type once approved"))
if "dev_state" in data: if "dev_state" in data:
data["dev_state"] = PackageDevState.coerce(data["dev_state"]) data["dev_state"] = PackageDevState.coerce(data["dev_state"])
@@ -166,16 +142,13 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
if "media_license" in data: if "media_license" in data:
data["media_license"] = get_license(data["media_license"]) data["media_license"] = get_license(data["media_license"])
if "desc" in data:
data["desc"] = normalize_line_endings(data["desc"])
if "video_url" in data and data["video_url"] is not None: if "video_url" in data and data["video_url"] is not None:
data["video_url"] = clean_youtube_url(data["video_url"]) or data["video_url"] data["video_url"] = clean_youtube_url(data["video_url"]) or data["video_url"]
if "dQw4w9WgXcQ" in data["video_url"]: if "dQw4w9WgXcQ" in data["video_url"]:
raise LogicError(403, "Never gonna give you up / Never gonna let you down / Never gonna run around and desert you") raise LogicError(403, "Never gonna give you up / Never gonna let you down / Never gonna run around and desert you")
for key in ["name", "title", "short_desc", "desc", "dev_state", "license", "media_license", for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license",
"repo", "website", "issueTracker", "forums", "video_url", "donate_url", "translation_url"]: "repo", "website", "issueTracker", "forums", "video_url", "donate_url"]:
if key in data: if key in data:
setattr(package, key, data[key]) setattr(package, key, data[key])
@@ -187,6 +160,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):
@@ -209,14 +183,9 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
raise LogicError(400, "Unknown warning: " + warning_id) raise LogicError(400, "Unknown warning: " + warning_id)
package.content_warnings.append(warning) package.content_warnings.append(warning)
was_modified = was_new if not was_new:
if 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
if reason is None: if reason is None:
msg = "Edited {}".format(package.title) msg = "Edited {}".format(package.title)
@@ -230,7 +199,6 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR
add_audit_log(severity, user, msg, package.get_url("packages.view"), package, json.dumps(diff, indent=4)) add_audit_log(severity, user, msg, package.get_url("packages.view"), package, json.dumps(diff, indent=4))
if was_modified: db.session.commit()
db.session.commit()
return was_modified return package

View File

@@ -16,40 +16,34 @@
import datetime import datetime
import re import re
from typing import Optional
from celery import uuid from celery import uuid
from flask_babel import lazy_gettext 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
def check_can_create_release(user: User, package: Package, name: str): def check_can_create_release(user: User, package: Package):
if not package.check_perm(user, Permission.MAKE_RELEASE): if not package.check_perm(user, Permission.MAKE_RELEASE):
raise LogicError(403, lazy_gettext("You don't have permission to make releases")) raise LogicError(403, lazy_gettext("You don't have permission to make releases"))
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5) five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
count = package.releases.filter(PackageRelease.created_at > five_minutes_ago).count() count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count()
if count >= 5: if count >= 5:
raise LogicError(429, lazy_gettext("You've created too many releases for this package in the last 5 minutes, please wait before trying again")) raise LogicError(429, lazy_gettext("You've created too many releases for this package in the last 5 minutes, please wait before trying again"))
if PackageRelease.query.filter_by(package_id=package.id, name=name).count() > 0:
raise LogicError(403, lazy_gettext("A release with this name already exists"))
def do_create_vcs_release(user: User, package: Package, title: 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: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None):
min_v: LuantiRelease = None, max_v: LuantiRelease = None, reason: str = None): check_can_create_release(user, package)
check_can_create_release(user, package, name)
rel = PackageRelease() rel = PackageRelease()
rel.package = package rel.package = package
rel.name = name rel.title = title
rel.title = title or name
rel.release_notes = normalize_line_endings(release_notes)
rel.url = "" rel.url = ""
rel.task_id = uuid() rel.task_id = uuid()
rel.min_rel = min_v rel.min_rel = min_v
@@ -69,10 +63,10 @@ def do_create_vcs_release(user: User, package: Package, name: str, title: Option
return rel return rel
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, title: 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)
if commit_hash: if commit_hash:
commit_hash = commit_hash.lower() commit_hash = commit_hash.lower()
@@ -83,9 +77,7 @@ def do_create_zip_release(user: User, package: Package, name: str, title: Option
rel = PackageRelease() rel = PackageRelease()
rel.package = package rel.package = package
rel.name = name rel.title = title
rel.title = title or name
rel.release_notes = normalize_line_endings(release_notes)
rel.url = uploaded_url rel.url = uploaded_url
rel.task_id = uuid() rel.task_id = uuid()
rel.commit_hash = commit_hash rel.commit_hash = commit_hash

6
app/logic/scope.py Normal file
View File

@@ -0,0 +1,6 @@
from app.models import APIToken
class Scope:
def copy_to_token(self, token: APIToken):
pass

View File

@@ -31,7 +31,7 @@ def do_create_screenshot(user: User, package: Package, title: str, file, is_cove
if count >= 20: if count >= 20:
raise LogicError(429, lazy_gettext("Too many requests, please wait before trying again")) raise LogicError(429, lazy_gettext("Too many requests, please wait before trying again"))
uploaded_url, uploaded_path = upload_file(file, "image", lazy_gettext("a PNG, JPEG, or WebP image file")) uploaded_url, uploaded_path = upload_file(file, "image", lazy_gettext("a PNG or JPG image file"))
counter = 1 counter = 1
for screenshot in package.screenshots.all(): for screenshot in package.screenshots.all():

View File

@@ -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
@@ -28,14 +28,14 @@ def get_extension(filename):
return filename.rsplit(".", 1)[1].lower() if "." in filename else None return filename.rsplit(".", 1)[1].lower() if "." in filename else None
ALLOWED_IMAGES = {"jpeg", "png", "webp"} ALLOWED_IMAGES = {"jpeg", "png"}
def is_allowed_image(data): 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")
@@ -43,7 +43,7 @@ def upload_file(file, file_type: str, file_type_desc: LazyString | str, length:
is_image = False is_image = False
if file_type == "image": if file_type == "image":
allowed_extensions = ["jpg", "png", "webp"] allowed_extensions = ["jpg", "jpeg", "png"]
is_image = True is_image = True
elif file_type == "zip": elif file_type == "zip":
allowed_extensions = ["zip"] allowed_extensions = ["zip"]
@@ -51,9 +51,6 @@ def upload_file(file, file_type: str, file_type_desc: LazyString | str, length:
raise Exception("Invalid fileType") raise Exception("Invalid fileType")
ext = get_extension(file.filename) ext = get_extension(file.filename)
if ext == "jpeg":
ext = "jpg"
if ext is None or ext not in allowed_extensions: if ext is None or ext not in allowed_extensions:
raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=file_type_desc)) raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=file_type_desc))
@@ -62,7 +59,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)

View File

@@ -1,60 +0,0 @@
from typing import Optional
from flask import flash, redirect, url_for
from flask_babel import gettext, get_locale
from sqlalchemy import or_
from werkzeug import Response
from app.models import User, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, db
from app.utils import is_username_valid
from app.tasks.emails import send_anon_email
def create_user(username: str, display_name: str, email: Optional[str], oauth_provider: Optional[str] = None) -> None | Response | User:
if not is_username_valid(username):
flash(gettext("Username is invalid"))
return
user_by_name = User.query.filter(or_(
User.username == username,
User.username == display_name,
User.display_name == display_name,
User.forums_username == username,
User.github_username == username)).first()
if user_by_name:
if user_by_name.rank == UserRank.NOT_JOINED and user_by_name.forums_username:
flash(gettext("An account already exists for that username but hasn't been claimed yet."), "danger")
return redirect(url_for("users.claim_forums", username=user_by_name.forums_username))
elif oauth_provider:
flash(gettext("Unable to create an account as the username is already taken. "
"If you meant to log in, you need to connect %(provider)s to your account first", provider=oauth_provider), "danger")
return
else:
flash(gettext("That username/display name is already in use, please choose another."), "danger")
return
alias_by_name = (PackageAlias.query
.filter(or_(PackageAlias.author == username, PackageAlias.author == display_name))
.first())
if alias_by_name:
flash(gettext("Unable to create an account as the username was used in the past."), "danger")
return
if email:
user_by_email = User.query.filter_by(email=email).first()
if user_by_email:
send_anon_email.delay(email, get_locale().language, gettext("Email already in use"),
gettext("We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
display_name=user_by_email.display_name))
return redirect(url_for("users.email_sent"))
elif EmailSubscription.query.filter_by(email=email, blacklisted=True).count() > 0:
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
return
user = User(username, False, email)
user.notification_preferences = UserNotificationPreferences(user)
if display_name:
user.display_name = display_name
db.session.add(user)
return user

115
app/maillogger.py Normal file
View File

@@ -0,0 +1,115 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import logging
from app.tasks.emails import send_user_email
def _has_newline(line):
"""Used by has_bad_header to check for \\r or \\n"""
if line and ("\r" in line or "\n" in line):
return True
return False
def _is_bad_subject(subject):
"""Copied from: flask_mail.py class Message def has_bad_headers"""
if _has_newline(subject):
for linenum, line in enumerate(subject.split("\r\n")):
if not line:
return True
if linenum > 0 and line[0] not in "\t ":
return True
if _has_newline(line):
return True
if len(line.strip()) == 0:
return True
return False
class FlaskMailSubjectFormatter(logging.Formatter):
def format(self, record):
record.message = record.getMessage()
if self.usesTime():
record.asctime = self.formatTime(record, self.datefmt)
s = self.formatMessage(record)
return s
class FlaskMailTextFormatter(logging.Formatter):
pass
class FlaskMailHTMLFormatter(logging.Formatter):
def formatException(self, exc_info):
formatted_exception = logging.Handler.formatException(self, exc_info)
return "<pre>%s</pre>" % formatted_exception
def formatStack(self, stack_info):
return "<pre>%s</pre>" % stack_info
# see: https://github.com/python/cpython/blob/3.6/Lib/logging/__init__.py (class Handler)
class FlaskMailHandler(logging.Handler):
def __init__(self, send_to, subject_template, level=logging.NOTSET):
logging.Handler.__init__(self, level)
self.send_to = send_to
self.subject_template = subject_template
def setFormatter(self, text_fmt):
"""
Set the formatters for this handler. Provide at least one formatter.
When no text_fmt is provided, no text-part is created for the email body.
"""
assert text_fmt != None, "At least one formatter should be provided"
if type(text_fmt)==str:
text_fmt = FlaskMailTextFormatter(text_fmt)
self.formatter = text_fmt
def getSubject(self, record):
fmt = FlaskMailSubjectFormatter(self.subject_template)
subject = fmt.format(record)
# Since templates can cause header problems, and we rather have an incomplete email then an error, we fix this
if _is_bad_subject(subject):
subject="FlaskMailHandler log-entry from ContentDB [original subject is replaced, because it would result in a bad header]"
return subject
def emit(self, record):
subject = self.getSubject(record)
text = self.format(record) if self.formatter else None
html = "<pre>{}</pre>".format(text)
if "The recipient has exceeded message rate limit. Try again later" in subject:
return
for email in self.send_to:
send_user_email.delay(email, "en", subject, text, html)
def build_handler(app):
subject_template = "ContentDB %(message)s (%(module)s > %(funcName)s)"
text_template = ("Message type: %(levelname)s\n"
"Location: %(pathname)s:%(lineno)d\n"
"Module: %(module)s\n"
"Function: %(funcName)s\n"
"Time: %(asctime)s\n"
"Message: %(message)s\n\n")
mail_handler = FlaskMailHandler(app.config["MAIL_UTILS_ERROR_SEND_TO"], subject_template)
mail_handler.setLevel(logging.ERROR)
mail_handler.setFormatter(text_template)
return mail_handler

204
app/markdown.py Normal file
View File

@@ -0,0 +1,204 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from functools import partial
import bleach
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",
}
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,
}
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])

View File

@@ -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])

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
@@ -51,8 +52,38 @@ class APIToken(db.Model):
client = db.relationship("OAuthClient", foreign_keys=[client_id], back_populates="tokens") client = db.relationship("OAuthClient", foreign_keys=[client_id], back_populates="tokens")
auth_code = db.Column(db.String(34), unique=True, nullable=True) auth_code = db.Column(db.String(34), unique=True, nullable=True)
scope_user_email = db.Column(db.Boolean, nullable=False, default=False)
scope_package = db.Column(db.Boolean, nullable=False, default=False)
scope_package_release = db.Column(db.Boolean, nullable=False, default=False)
scope_package_screenshot = db.Column(db.Boolean, nullable=False, default=False)
def get_scopes(self) -> set[str]:
ret = set()
if self.scope_user_email:
ret.add("user:email")
if self.scope_package:
ret.add("package")
if self.scope_package_release:
ret.add("package:release")
if self.scope_package_screenshot:
ret.add("package:screenshot")
return ret
def set_scopes(self, v: set[str]):
def pop(key: str):
if key in v:
v.remove(key)
return True
self.scope_user_email = pop("user:email")
self.scope_package = pop("package")
self.scope_package_release = pop("package:release") or self.scope_package
self.scope_package_screenshot = pop("package:screenshot") or self.scope_package
return v
def can_operate_on_package(self, package): def can_operate_on_package(self, package):
if self.client is not None: if (self.client is not None and
not (self.scope_package or self.scope_package_release or self.scope_package_screenshot)):
return False return False
if self.package and self.package != package: if self.package and self.package != package:
@@ -70,13 +101,12 @@ class AuditSeverity(enum.Enum):
def __str__(self): def __str__(self):
return self.name return self.name
@property def get_title(self):
def title(self):
return self.name.replace("_", " ").title() return self.name.replace("_", " ").title()
@classmethod @classmethod
def choices(cls): def choices(cls):
return [(choice, choice.title) for choice in cls] return [(choice, choice.get_title()) for choice in cls]
@classmethod @classmethod
def coerce(cls, item): def coerce(cls, item):
@@ -124,115 +154,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"]
@@ -244,7 +172,6 @@ class ForumTopic(db.Model):
author = db.relationship("User", back_populates="forum_topics") author = db.relationship("User", back_populates="forum_topics")
wip = db.Column(db.Boolean, default=False, nullable=False) wip = db.Column(db.Boolean, default=False, nullable=False)
# TODO: remove
discarded = db.Column(db.Boolean, default=False, nullable=False) discarded = db.Column(db.Boolean, default=False, nullable=False)
type = db.Column(db.Enum(PackageType), nullable=False) type = db.Column(db.Enum(PackageType), nullable=False)
@@ -257,10 +184,6 @@ class ForumTopic(db.Model):
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
@property
def url(self):
return "https://forum.luanti.org/viewtopic.php?t=" + str(self.topic_id)
def get_repo_url(self): def get_repo_url(self):
if self.link is None: if self.link is None:
return None return None
@@ -282,6 +205,7 @@ class ForumTopic(db.Model):
"posts": self.posts, "posts": self.posts,
"views": self.views, "views": self.views,
"is_wip": self.wip, "is_wip": self.wip,
"discarded": self.discarded,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
} }

View File

@@ -55,7 +55,6 @@ class Collection(db.Model):
long_description = db.Column(db.UnicodeText, nullable=True) long_description = db.Column(db.UnicodeText, nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
private = db.Column(db.Boolean, nullable=False, default=False) private = db.Column(db.Boolean, nullable=False, default=False)
pinned = db.Column(db.Boolean, nullable=False, default=False)
packages = db.relationship("Package", secondary=CollectionPackage.__table__, backref="collections") packages = db.relationship("Package", secondary=CollectionPackage.__table__, backref="collections")
items = db.relationship("CollectionPackage", back_populates="collection", order_by=db.asc("order"), items = db.relationship("CollectionPackage", back_populates="collection", order_by=db.asc("order"),
@@ -95,7 +94,7 @@ class Collection(db.Model):
elif type(perm) != Permission: elif type(perm) != Permission:
raise Exception("Unknown permission given to Collection.check_perm()") raise Exception("Unknown permission given to Collection.check_perm()")
if user is None or not user.is_authenticated: if not user.is_authenticated:
return perm == Permission.VIEW_COLLECTION and not self.private return perm == Permission.VIEW_COLLECTION and not self.private
can_view = not self.private or self.author == user or user.rank.at_least(UserRank.MODERATOR) can_view = not self.private or self.author == user or user.rank.at_least(UserRank.MODERATOR)

View File

@@ -17,23 +17,21 @@
import datetime import datetime
import enum import enum
import os
import typing
from flask import url_for from flask import url_for
from flask_babel import lazy_gettext, get_locale, gettext, pgettext from flask_babel import lazy_gettext
from flask_sqlalchemy.query import Query from flask_sqlalchemy import BaseQuery
from sqlalchemy import or_, func from sqlalchemy import or_
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy_searchable import SearchQueryMixin from sqlalchemy_searchable import SearchQueryMixin
from sqlalchemy_utils.types import TSVectorType from sqlalchemy_utils.types import TSVectorType
from sqlalchemy.dialects.postgresql import insert
from app import app
from . import db from . import db
from .users import Permission, UserRank, User from .users import Permission, UserRank, User
from app import app
class PackageQuery(Query, SearchQueryMixin): class PackageQuery(BaseQuery, SearchQueryMixin):
pass pass
@@ -81,42 +79,6 @@ class PackageType(enum.Enum):
elif self == PackageType.TXP: elif self == PackageType.TXP:
return lazy_gettext("Texture Packs") return lazy_gettext("Texture Packs")
def get_top_ordinal(self, place: int):
if place == 1:
if self == PackageType.MOD:
return lazy_gettext("Top mod")
elif self == PackageType.GAME:
return lazy_gettext("Top game")
elif self == PackageType.TXP:
return lazy_gettext("Top texture pack")
else:
if self == PackageType.MOD:
return lazy_gettext("Top %(place)d mod", place=place)
elif self == PackageType.GAME:
return lazy_gettext("Top %(place)d game", place=place)
elif self == PackageType.TXP:
return lazy_gettext("Top %(place)d texture pack", place=place)
def get_top_ordinal_description(self, display_name: str, place: int):
if self == PackageType.MOD:
return lazy_gettext(u"%(display_name)s has a mod placed at #%(place)d.",
display_name=display_name, place=place)
elif self == PackageType.GAME:
return lazy_gettext(u"%(display_name)s has a game placed at #%(place)d.",
display_name=display_name, place=place)
elif self == PackageType.TXP:
return lazy_gettext(u"%(display_name)s has a texture pack placed at #%(place)d.",
display_name=display_name, place=place)
@property
def do_you_recommend(self):
if self == PackageType.MOD:
return lazy_gettext(u"Do you recommend this mod?")
elif self == PackageType.GAME:
return lazy_gettext(u"Do you recommend this game?")
elif self == PackageType.TXP:
return lazy_gettext(u"Do you recommend this texture pack?")
@classmethod @classmethod
def get(cls, name): def get(cls, name):
try: try:
@@ -126,7 +88,7 @@ class PackageType(enum.Enum):
@classmethod @classmethod
def choices(cls): def choices(cls):
return [(choice.name.lower(), choice.text) for choice in cls] return [(choice, choice.text) for choice in cls]
@classmethod @classmethod
def coerce(cls, item): def coerce(cls, item):
@@ -135,7 +97,7 @@ class PackageType(enum.Enum):
class PackageDevState(enum.Enum): class PackageDevState(enum.Enum):
WIP = "Work in Progress" WIP = "Work in Progress"
BETA = "Beta" BETA = "Beta"
ACTIVELY_DEVELOPED = "Actively Developed" ACTIVELY_DEVELOPED = "Actively Developed"
MAINTENANCE_ONLY = "Maintenance Only" MAINTENANCE_ONLY = "Maintenance Only"
AS_IS = "As-Is" AS_IS = "As-Is"
@@ -148,41 +110,17 @@ class PackageDevState(enum.Enum):
def __str__(self): def __str__(self):
return self.name return self.name
@property
def title(self):
if self == PackageDevState.WIP:
# NOTE: Package maintenance state
return lazy_gettext("Looking for Maintainer")
elif self == PackageDevState.BETA:
# NOTE: Package maintenance state
return lazy_gettext("Beta")
elif self == PackageDevState.ACTIVELY_DEVELOPED:
# NOTE: Package maintenance state
return lazy_gettext("Actively Developed")
elif self == PackageDevState.MAINTENANCE_ONLY:
# NOTE: Package maintenance state
return lazy_gettext("Maintenance Only")
elif self == PackageDevState.AS_IS:
# NOTE: Package maintenance state
return lazy_gettext("As-is")
elif self == PackageDevState.DEPRECATED:
# NOTE: Package maintenance state
return lazy_gettext("Deprecated")
elif self == PackageDevState.LOOKING_FOR_MAINTAINER:
# NOTE: Package maintenance state
return lazy_gettext("Looking for Maintainer")
def get_desc(self): def get_desc(self):
if self == PackageDevState.WIP: if self == PackageDevState.WIP:
return lazy_gettext("Under active development, and may break worlds/things without warning") return "Under active development, and may break worlds/things without warning"
elif self == PackageDevState.BETA: elif self == PackageDevState.BETA:
return lazy_gettext("Fully playable, but with some breakages/changes expected") return "Fully playable, but with some breakages/changes expected"
elif self == PackageDevState.MAINTENANCE_ONLY: elif self == PackageDevState.MAINTENANCE_ONLY:
return lazy_gettext("Finished, with bug fixes being made as needed") return "Finished, with bug fixes being made as needed"
elif self == PackageDevState.AS_IS: elif self == PackageDevState.AS_IS:
return lazy_gettext("Finished, the maintainer doesn't intend to continue working on it or provide support") return "Finished, the maintainer doesn't intend to continue working on it or provide support"
elif self == PackageDevState.DEPRECATED: elif self == PackageDevState.DEPRECATED:
return lazy_gettext("The maintainer doesn't recommend this package. See the description for more info") return "The maintainer doesn't recommend this package. See the description for more info"
else: else:
return None return None
@@ -268,6 +206,15 @@ class PackageState(enum.Enum):
return item if type(item) == PackageState else PackageState[item.upper()] return item if type(item) == PackageState else PackageState[item.upper()]
PACKAGE_STATE_FLOW = {
PackageState.WIP: {PackageState.READY_FOR_REVIEW},
PackageState.CHANGES_NEEDED: {PackageState.READY_FOR_REVIEW},
PackageState.READY_FOR_REVIEW: {PackageState.WIP, PackageState.CHANGES_NEEDED, PackageState.APPROVED},
PackageState.APPROVED: {PackageState.CHANGES_NEEDED},
PackageState.DELETED: {PackageState.READY_FOR_REVIEW},
}
PackageProvides = db.Table("provides", PackageProvides = db.Table("provides",
db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True), db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True),
db.Column("metapackage_id", db.Integer, db.ForeignKey("meta_package.id"), primary_key=True) db.Column("metapackage_id", db.Integer, db.ForeignKey("meta_package.id"), primary_key=True)
@@ -389,6 +336,12 @@ class PackageGameSupport(db.Model):
__table_args__ = (db.UniqueConstraint("game_id", "package_id", name="_package_game_support_uc"),) __table_args__ = (db.UniqueConstraint("game_id", "package_id", name="_package_game_support_uc"),)
def __init__(self, package, game, confidence, supports):
self.package = package
self.game = game
self.confidence = confidence
self.supports = supports
class Package(db.Model): class Package(db.Model):
query_class = PackageQuery query_class = PackageQuery
@@ -446,41 +399,29 @@ class Package(db.Model):
forums = db.Column(db.Integer, nullable=True) forums = db.Column(db.Integer, nullable=True)
video_url = db.Column(db.String(200), nullable=True, default=None) video_url = db.Column(db.String(200), nullable=True, default=None)
donate_url = db.Column(db.String(200), nullable=True, default=None) donate_url = db.Column(db.String(200), nullable=True, default=None)
translation_url = db.Column(db.String(200), nullable=True)
@property @property
def donate_url_actual(self): def donate_url_actual(self):
return self.donate_url or self.author.donate_url return self.donate_url or self.author.donate_url
@property
def forums_url(self) -> typing.Optional[str]:
if self.forums is None:
return None
return "https://forum.luanti.org/viewtopic.php?t=" + str(self.forums)
enable_game_support_detection = db.Column(db.Boolean, nullable=False, default=True) enable_game_support_detection = db.Column(db.Boolean, nullable=False, default=True)
translations = db.relationship("PackageTranslation", back_populates="package",
lazy="dynamic", order_by=db.asc("package_translation_language_id"),
cascade="all, delete, delete-orphan")
provides = db.relationship("MetaPackage", secondary=PackageProvides, order_by=db.asc("name"), back_populates="packages") provides = db.relationship("MetaPackage", secondary=PackageProvides, order_by=db.asc("name"), back_populates="packages")
dependencies = db.relationship("Dependency", back_populates="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id]) dependencies = db.relationship("Dependency", back_populates="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id])
supported_games = db.relationship("PackageGameSupport", back_populates="package", lazy="dynamic", supported_games = db.relationship("PackageGameSupport", back_populates="package", lazy="dynamic",
foreign_keys=[PackageGameSupport.package_id], cascade="all, delete, delete-orphan") foreign_keys=[PackageGameSupport.package_id])
game_supported_mods = db.relationship("PackageGameSupport", back_populates="game", lazy="dynamic", game_supported_mods = db.relationship("PackageGameSupport", back_populates="game", lazy="dynamic",
foreign_keys=[PackageGameSupport.game_id], cascade="all, delete, delete-orphan") foreign_keys=[PackageGameSupport.game_id])
tags = db.relationship("Tag", secondary=Tags, back_populates="packages") tags = db.relationship("Tag", secondary=Tags, back_populates="packages")
content_warnings = db.relationship("ContentWarning", secondary=ContentWarnings, back_populates="packages") content_warnings = db.relationship("ContentWarning", secondary=ContentWarnings, back_populates="packages")
releases = db.relationship("PackageRelease", back_populates="package", releases = db.relationship("PackageRelease", back_populates="package",
lazy="dynamic", order_by=db.desc("package_release_created_at"), cascade="all, delete, delete-orphan") lazy="dynamic", order_by=db.desc("package_release_releaseDate"), cascade="all, delete, delete-orphan")
screenshots = db.relationship("PackageScreenshot", back_populates="package", foreign_keys="PackageScreenshot.package_id", screenshots = db.relationship("PackageScreenshot", back_populates="package", foreign_keys="PackageScreenshot.package_id",
lazy="dynamic", order_by=db.asc("package_screenshot_order"), cascade="all, delete, delete-orphan") lazy="dynamic", order_by=db.asc("package_screenshot_order"), cascade="all, delete, delete-orphan")
@@ -490,15 +431,15 @@ class Package(db.Model):
primaryjoin="and_(Package.id==PackageScreenshot.package_id, PackageScreenshot.approved)") primaryjoin="and_(Package.id==PackageScreenshot.package_id, PackageScreenshot.approved)")
cover_image_id = db.Column(db.Integer, db.ForeignKey("package_screenshot.id"), nullable=True, default=None) cover_image_id = db.Column(db.Integer, db.ForeignKey("package_screenshot.id"), nullable=True, default=None)
cover_image = db.relationship("PackageScreenshot", uselist=False, foreign_keys=[cover_image_id], post_update=True) cover_image = db.relationship("PackageScreenshot", uselist=False, foreign_keys=[cover_image_id])
maintainers = db.relationship("User", secondary=maintainers) maintainers = db.relationship("User", secondary=maintainers)
threads = db.relationship("Thread", back_populates="package", order_by=db.desc("thread_created_at"), threads = db.relationship("Thread", back_populates="package", order_by=db.desc("thread_created_at"),
foreign_keys="Thread.package_id", cascade="all, delete, delete-orphan", lazy="dynamic") foreign_keys="Thread.package_id", cascade="all, delete, delete-orphan", lazy="dynamic")
reviews = db.relationship("PackageReview", back_populates="package", lazy="dynamic", reviews = db.relationship("PackageReview", back_populates="package",
order_by=[db.desc("package_review_score"), db.desc("package_review_created_at")], order_by=[db.desc("package_review_score"),db.desc("package_review_created_at")],
cascade="all, delete, delete-orphan") cascade="all, delete, delete-orphan")
audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.package_id", audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.package_id",
@@ -508,7 +449,7 @@ class Package(db.Model):
back_populates="package", cascade="all, delete, delete-orphan") back_populates="package", cascade="all, delete, delete-orphan")
tokens = db.relationship("APIToken", foreign_keys="APIToken.package_id", back_populates="package", tokens = db.relationship("APIToken", foreign_keys="APIToken.package_id", back_populates="package",
cascade="all, delete") cascade="all, delete, delete-orphan")
update_config = db.relationship("PackageUpdateConfig", uselist=False, back_populates="package", update_config = db.relationship("PackageUpdateConfig", uselist=False, back_populates="package",
cascade="all, delete, delete-orphan") cascade="all, delete, delete-orphan")
@@ -539,44 +480,13 @@ class Package(db.Model):
if name.endswith("_game"): if name.endswith("_game"):
name = name[:-5] name = name[:-5]
return Package.query.filter(or_(Package.name == name, Package.name == name + "_game"), return Package.query.filter(
or_(Package.name == name, Package.name == name + "_game"),
Package.author.has(username=parts[0])).first() Package.author.has(username=parts[0])).first()
def get_id(self): def get_id(self):
return "{}/{}".format(self.author.username, self.name) return "{}/{}".format(self.author.username, self.name)
@property
def normalised_name(self):
name = self.name
if name.endswith("_game"):
name = name[:-5]
return name
def get_translated(self, lang=None, load_desc=True):
if lang is None:
locale = get_locale()
if locale:
lang = locale.language
else:
lang = "en"
translation: typing.Optional[PackageTranslation] = None
if lang != "en":
translation = self.translations.filter_by(language_id=lang).first()
if translation is None:
return {
"title": self.title,
"short_desc": self.short_desc,
"desc": self.desc if load_desc else None,
}
return {
"title": translation.title or self.title,
"short_desc": translation.short_desc or self.short_desc,
"desc": (translation.desc or self.desc) if load_desc else None,
}
def get_sorted_dependencies(self, is_hard=None): def get_sorted_dependencies(self, is_hard=None):
query = self.dependencies query = self.dependencies
if is_hard is not None: if is_hard is not None:
@@ -592,14 +502,14 @@ class Package(db.Model):
def get_sorted_optional_dependencies(self): def get_sorted_optional_dependencies(self):
return self.get_sorted_dependencies(False) return self.get_sorted_dependencies(False)
def get_sorted_game_support(self) -> list[PackageGameSupport]: def get_sorted_game_support(self):
query = self.supported_games.filter(PackageGameSupport.game.has(state=PackageState.APPROVED)) query = self.supported_games.filter(PackageGameSupport.game.has(state=PackageState.APPROVED))
supported = query.all() supported = query.all()
supported.sort(key=lambda x: -(x.game.score + 100000*x.confidence)) supported.sort(key=lambda x: -(x.game.score + 100000*x.confidence))
return supported return supported
def get_sorted_game_support_pair(self) -> list[list[PackageGameSupport]]: def get_sorted_game_support_pair(self):
supported = self.get_sorted_game_support() supported = self.get_sorted_game_support()
return [ return [
[x for x in supported if x.supports], [x for x in supported if x.supports],
@@ -617,21 +527,20 @@ class Package(db.Model):
"type": self.type.to_name(), "type": self.type.to_name(),
} }
def as_short_dict(self, base_url, version=None, release_id=None, no_load=False, lang="en", include_vcs=False): def as_short_dict(self, base_url, version=None, release_id=None, no_load=False):
tnurl = self.get_thumb_url(1, format="png") tnurl = self.get_thumb_url(1)
if release_id is None and no_load == False: if release_id is None and no_load == False:
release = self.get_download_release(version=version) release = self.get_download_release(version=version)
release_id = release and release.id release_id = release and release.id
meta = self.get_translated(lang, load_desc=False) short_desc = self.short_desc
short_desc = meta["short_desc"]
if self.dev_state == PackageDevState.WIP: if self.dev_state == PackageDevState.WIP:
short_desc = gettext("Work in Progress") + ". " + self.short_desc short_desc = "Work in Progress. " + self.short_desc
ret = { ret = {
"name": self.name, "name": self.name,
"title": meta["title"], "title": self.title,
"author": self.author.username, "author": self.author.username,
"short_description": short_desc, "short_description": short_desc,
"type": self.type.to_name(), "type": self.type.to_name(),
@@ -643,21 +552,11 @@ class Package(db.Model):
if not ret["aliases"]: if not ret["aliases"]:
del ret["aliases"] del ret["aliases"]
if include_vcs:
ret["repo"] = self.repo
return ret return ret
def as_dict(self, base_url, version=None, lang="en", screenshots_dict=False): def as_dict(self, base_url, version=None):
tnurl = self.get_thumb_url(1, format="png") tnurl = self.get_thumb_url(1)
release = self.get_download_release(version=version) release = self.get_download_release(version=version)
meta = self.get_translated(lang)
if screenshots_dict:
screenshots = [ss.as_short_dict(base_url) for ss in self.screenshots]
else:
screenshots = [base_url + ss.url for ss in self.screenshots]
return { return {
"author": self.author.username, "author": self.author.username,
"maintainers": [x.username for x in self.maintainers], "maintainers": [x.username for x in self.maintainers],
@@ -666,9 +565,9 @@ class Package(db.Model):
"dev_state": self.dev_state.name if self.dev_state else None, "dev_state": self.dev_state.name if self.dev_state else None,
"name": self.name, "name": self.name,
"title": meta["title"], "title": self.title,
"short_description": meta["short_desc"], "short_description": self.short_desc,
"long_description": meta["desc"], "long_description": self.desc,
"type": self.type.to_name(), "type": self.type.to_name(),
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
@@ -679,18 +578,15 @@ class Package(db.Model):
"website": self.website, "website": self.website,
"issue_tracker": self.issueTracker, "issue_tracker": self.issueTracker,
"forums": self.forums, "forums": self.forums,
"forum_url": self.forums_url,
"video_url": self.video_url, "video_url": self.video_url,
"video_thumbnail_url": self.get_video_thumbnail_url(True),
"donate_url": self.donate_url_actual, "donate_url": self.donate_url_actual,
"translation_url": self.translation_url,
"tags": sorted([x.name for x in self.tags]), "tags": sorted([x.name for x in self.tags]),
"content_warnings": sorted([x.name for x in self.content_warnings]), "content_warnings": sorted([x.name for x in self.content_warnings]),
"provides": sorted([x.name for x in self.provides]), "provides": sorted([x.name for x in self.provides]),
"thumbnail": (base_url + tnurl) if tnurl is not None else None, "thumbnail": (base_url + tnurl) if tnurl is not None else None,
"screenshots": screenshots, "screenshots": [base_url + ss.url for ss in self.screenshots],
"url": base_url + self.get_url("packages.download"), "url": base_url + self.get_url("packages.download"),
"release": release and release.id, "release": release and release.id,
@@ -707,21 +603,21 @@ class Package(db.Model):
] ]
} }
def get_thumb_or_placeholder(self, level=2, format="webp"): def get_thumb_or_placeholder(self, level=2):
return self.get_thumb_url(level, False, format) or "/static/placeholder.png" return self.get_thumb_url(level) or "/static/placeholder.png"
def get_thumb_url(self, level=2, abs=False, format="webp"): def get_thumb_url(self, level=2, abs=False):
screenshot = self.main_screenshot screenshot = self.main_screenshot
url = screenshot.get_thumb_url(level, format) if screenshot is not None else None url = screenshot.get_thumb_url(level) if screenshot is not None else None
if abs: if abs:
from app.utils import abs_url from app.utils import abs_url
return abs_url(url) return abs_url(url)
else: else:
return url return url
def get_cover_image_url(self, format="webp"): def get_cover_image_url(self):
screenshot = self.cover_image or self.main_screenshot screenshot = self.cover_image or self.main_screenshot
return screenshot and screenshot.get_thumb_url(4, format) return screenshot and screenshot.get_thumb_url(4)
def get_url(self, endpoint, absolute=False, **kwargs): def get_url(self, endpoint, absolute=False, **kwargs):
if absolute: if absolute:
@@ -739,32 +635,16 @@ class Package(db.Model):
return "[![ContentDB]({})]({})" \ return "[![ContentDB]({})]({})" \
.format(self.get_shield_url(type), self.get_url("packages.view", True)) .format(self.get_shield_url(type), self.get_url("packages.view", True))
def get_video_thumbnail_url(self, absolute: bool = False):
from app.utils.url import get_youtube_id
if self.video_url is None:
return None
id_ = get_youtube_id(self.video_url)
if id_ is None:
return None
if absolute:
from app.utils import abs_url_for
return abs_url_for("thumbnails.youtube", id_=id_)
else:
return url_for("thumbnails.youtube", id_=id_)
def get_set_state_url(self, state): def get_set_state_url(self, state):
if type(state) == str: if type(state) == str:
state = PackageState[state] state = PackageState[state]
elif type(state) != PackageState: elif type(state) != PackageState:
raise Exception("Unknown state given to Package.get_set_state_url()") raise Exception("Unknown state given to Package.can_move_to_state()")
return url_for("packages.move_to_state", return url_for("packages.move_to_state",
author=self.author.username, name=self.name, state=state.name.lower()) author=self.author.username, name=self.name, state=state.name.lower())
def get_download_release(self, version=None) -> typing.Optional["PackageRelease"]: def get_download_release(self, version=None):
for rel in self.releases: for rel in self.releases:
if rel.approved and (version is None or if rel.approved and (version is None or
((rel.min_rel is None or rel.min_rel_id <= version.id) and ((rel.min_rel is None or rel.min_rel_id <= version.id) and
@@ -812,7 +692,7 @@ class Package(db.Model):
elif perm == Permission.APPROVE_SCREENSHOT: elif perm == Permission.APPROVE_SCREENSHOT:
return (is_maintainer or is_approver) and \ return (is_maintainer or is_approver) and \
user.rank.at_least(UserRank.TRUSTED_MEMBER if self.approved else UserRank.NEW_MEMBER) user.rank.at_least(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
elif perm == Permission.EDIT_MAINTAINERS or perm == Permission.DELETE_PACKAGE: elif perm == Permission.EDIT_MAINTAINERS or perm == Permission.DELETE_PACKAGE:
return is_owner or user.rank.at_least(UserRank.EDITOR) return is_owner or user.rank.at_least(UserRank.EDITOR)
@@ -835,35 +715,66 @@ class Package(db.Model):
def get_missing_hard_dependencies(self): def get_missing_hard_dependencies(self):
return [mp.name for mp in self.get_missing_hard_dependencies_query().all()] return [mp.name for mp in self.get_missing_hard_dependencies_query().all()]
def get_next_states(self, user): def can_move_to_state(self, user, state):
from app.logic.package_approval import can_move_to_state if not user.is_authenticated:
return False
if type(state) == str:
state = PackageState[state]
elif type(state) != PackageState:
raise Exception("Unknown state given to Package.can_move_to_state()")
if state not in PACKAGE_STATE_FLOW[self.state]:
return False
if state == PackageState.READY_FOR_REVIEW or state == PackageState.APPROVED:
if state == PackageState.APPROVED and not self.check_perm(user, Permission.APPROVE_NEW):
return False
if not (self.check_perm(user, Permission.APPROVE_NEW) or self.check_perm(user, Permission.EDIT_PACKAGE)):
return False
if state == PackageState.APPROVED and ("Other" in self.license.name or "Other" in self.media_license.name):
return False
if self.get_missing_hard_dependencies_query().count() > 0:
return False
needs_screenshot = \
(self.type == self.type.GAME or self.type == self.type.TXP) and self.screenshots.count() == 0
return self.releases.filter(PackageRelease.task_id==None).count() > 0 and not needs_screenshot
elif state == PackageState.CHANGES_NEEDED:
return self.check_perm(user, Permission.APPROVE_NEW)
elif state == PackageState.WIP:
return self.check_perm(user, Permission.EDIT_PACKAGE) and \
(user in self.maintainers or user.rank.at_least(UserRank.ADMIN))
return True
def get_next_states(self, user):
states = [] states = []
for state in PackageState: for state in PackageState:
if can_move_to_state(self, user, state): if self.can_move_to_state(user, state):
states.append(state) states.append(state)
return states return states
def as_score_dict(self): def as_score_dict(self):
reviews = self.get_review_summary()
return { return {
"author": self.author.username, "author": self.author.username,
"name": self.name, "name": self.name,
"score": self.score, "score": self.score,
"score_downloads": self.score_downloads, "score_downloads": self.score_downloads,
"score_reviews": self.score - self.score_downloads, "score_reviews": self.score - self.score_downloads,
"downloads": self.downloads, "downloads": self.downloads
"reviews": {
"positive": reviews[0],
"neutral": reviews[1],
"negative": reviews[2],
},
} }
def recalculate_score(self): def recalculate_score(self):
review_scores = [ 150 * r.as_weight() for r in self.reviews ] review_scores = [ 100 * r.as_weight() for r in self.reviews ]
self.score = self.score_downloads + sum(review_scores) self.score = self.score_downloads + sum(review_scores)
def get_conf_file_name(self): def get_conf_file_name(self):
@@ -874,57 +785,6 @@ class Package(db.Model):
elif self.type == PackageType.GAME: elif self.type == PackageType.GAME:
return "game.conf" return "game.conf"
def get_review_summary(self):
from app.models import PackageReview
rows = (db.session.query(PackageReview.rating, func.count(PackageReview.id))
.select_from(PackageReview)
.where(PackageReview.package_id == self.id)
.group_by(PackageReview.rating)
.all())
negative = 0
neutral = 0
positive = 0
for rating, count in rows:
if rating > 3:
positive += count
elif rating == 3:
neutral += count
else:
negative += count
return [positive, neutral, negative]
class Language(db.Model):
id = db.Column(db.String(10), primary_key=True)
title = db.Column(db.String(100), unique=True, nullable=False)
packages = db.relationship("Package", secondary="package_translation", lazy="dynamic")
@property
def has_contentdb_translation(self):
return self.id in app.config["LANGUAGES"].keys()
def as_dict(self):
return {
"id": self.id,
"title": self.title,
"has_contentdb_translation": self.has_contentdb_translation,
}
class PackageTranslation(db.Model):
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), primary_key=True)
package = db.relationship("Package", back_populates="translations", foreign_keys=[package_id])
language_id = db.Column(db.String(10), db.ForeignKey("language.id"), primary_key=True)
language = db.relationship("Language", foreign_keys=[language_id])
title = db.Column(db.Unicode(100), nullable=True)
short_desc = db.Column(db.Unicode(200), nullable=True)
desc = db.Column(db.UnicodeText, nullable=True)
class MetaPackage(db.Model): class MetaPackage(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@@ -994,13 +854,6 @@ class ContentWarning(db.Model):
regex = re.compile("[^a-z_]") regex = re.compile("[^a-z_]")
self.name = regex.sub("", self.title.lower().replace(" ", "_")) self.name = regex.sub("", self.title.lower().replace(" ", "_"))
def get_translated(self):
# Translations are automated on dynamic data using `extract_translations.py`
return {
"title": pgettext("tags", self.title),
"description": pgettext("content_warnings", self.description),
}
def as_dict(self): def as_dict(self):
description = self.description if self.description != "" else None description = self.description if self.description != "" else None
return { "name": self.name, "title": self.title, "description": description } return { "name": self.name, "title": self.title, "description": description }
@@ -1026,13 +879,6 @@ class Tag(db.Model):
regex = re.compile("[^a-z_]") regex = re.compile("[^a-z_]")
self.name = regex.sub("", self.title.lower().replace(" ", "_")) self.name = regex.sub("", self.title.lower().replace(" ", "_"))
def get_translated(self):
# Translations are automated on dynamic data using `extract_translations.py`
return {
"title": pgettext("tags", self.title),
"description": pgettext("tags", self.description) if self.description else "",
}
def as_dict(self): def as_dict(self):
description = self.description if self.description != "" else None description = self.description if self.description != "" else None
return { return {
@@ -1043,7 +889,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)
@@ -1052,10 +898,6 @@ class LuantiRelease(db.Model):
self.name = name self.name = name
self.protocol = protocol self.protocol = protocol
@property
def value(self):
return self.name
def get_actual(self): def get_actual(self):
return None if self.name == "None" else self return None if self.name == "None" else self
@@ -1067,11 +909,12 @@ 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, protocol_num):
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])) major_minor = parts[0] + "." + parts[1]
query = MinetestRelease.query.filter(MinetestRelease.name.like("{}%".format(major_minor)))
if protocol_num: if protocol_num:
query = query.filter_by(protocol=protocol_num) query = query.filter_by(protocol=protocol_num)
@@ -1081,9 +924,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
@@ -1094,31 +937,19 @@ class PackageRelease(db.Model):
package_id = db.Column(db.Integer, db.ForeignKey("package.id")) package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
package = db.relationship("Package", back_populates="releases", foreign_keys=[package_id]) package = db.relationship("Package", back_populates="releases", foreign_keys=[package_id])
name = db.Column(db.String(30), nullable=False)
title = db.Column(db.String(100), nullable=False) title = db.Column(db.String(100), nullable=False)
created_at = db.Column(db.DateTime, nullable=False) releaseDate = db.Column(db.DateTime, nullable=False)
url = db.Column(db.String(200), nullable=False, default="") url = db.Column(db.String(200), nullable=False, default="")
approved = db.Column(db.Boolean, nullable=False, default=False) approved = db.Column(db.Boolean, nullable=False, default=False)
task_id = db.Column(db.String(37), nullable=True) task_id = db.Column(db.String(37), nullable=True)
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)
file_size_bytes = db.Column(db.Integer, nullable=False, default=0)
@property min_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None)
def summary(self) -> str: min_rel = db.relationship("MinetestRelease", foreign_keys=[min_rel_id])
if self.release_notes is None or \
self.release_notes.startswith("-") or \
self.release_notes.startswith("*"):
return self.title
return self.release_notes.split("\n")[0] max_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None)
max_rel = db.relationship("MinetestRelease", foreign_keys=[max_rel_id])
min_rel_id = db.Column(db.Integer, db.ForeignKey("luanti_release.id"), nullable=True, server_default=None)
min_rel = db.relationship("LuantiRelease", foreign_keys=[min_rel_id])
max_rel_id = db.Column(db.Integer, db.ForeignKey("luanti_release.id"), nullable=True, server_default=None)
max_rel = db.relationship("LuantiRelease", 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,36 +958,16 @@ 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):
path = self.file_path
if not os.path.isfile(path):
self.file_size_bytes = 0
return
file_stats = os.stat(path)
self.file_size_bytes = file_stats.st_size
@property
def file_size(self):
size = self.file_size_bytes / 1024
if size > 1024:
return f"{round(size / 1024, 1)} MB"
else:
return f"{round(size)} KB"
def as_dict(self): def as_dict(self):
return { return {
"id": self.id, "id": self.id,
"name": self.name,
"title": self.title, "title": self.title,
"release_notes": self.release_notes,
"url": self.url if self.url != "" else None, "url": self.url if self.url != "" else None,
"release_date": self.created_at.isoformat(), "release_date": self.releaseDate.isoformat(),
"commit": self.commit_hash, "commit": self.commit_hash,
"downloads": self.downloads, "downloads": self.downloads,
"min_minetest_version": self.min_rel and self.min_rel.as_dict(), "min_minetest_version": self.min_rel and self.min_rel.as_dict(),
"max_minetest_version": self.max_rel and self.max_rel.as_dict(), "max_minetest_version": self.max_rel and self.max_rel.as_dict(),
"size": self.file_size_bytes,
} }
def as_long_dict(self): def as_long_dict(self):
@@ -1164,13 +975,12 @@ class PackageRelease(db.Model):
"id": self.id, "id": self.id,
"title": self.title, "title": self.title,
"url": self.url if self.url != "" else None, "url": self.url if self.url != "" else None,
"release_date": self.created_at.isoformat(), "release_date": self.releaseDate.isoformat(),
"commit": self.commit_hash, "commit": self.commit_hash,
"downloads": self.downloads, "downloads": self.downloads,
"min_minetest_version": self.min_rel and self.min_rel.as_dict(), "min_minetest_version": self.min_rel and self.min_rel.as_dict(),
"max_minetest_version": self.max_rel and self.max_rel.as_dict(), "max_minetest_version": self.max_rel and self.max_rel.as_dict(),
"package": self.package.as_key_dict(), "package": self.package.as_key_dict()
"size": self.file_size_bytes,
} }
def get_edit_url(self): def get_edit_url(self):
@@ -1192,7 +1002,7 @@ class PackageRelease(db.Model):
id=self.id) id=self.id)
def __init__(self): def __init__(self):
self.created_at = datetime.datetime.now() self.releaseDate = datetime.datetime.now()
def get_download_filename(self): def get_download_filename(self):
return f"{self.package.name}_{self.id}.zip" return f"{self.package.name}_{self.id}.zip"
@@ -1215,7 +1025,7 @@ class PackageRelease(db.Model):
return True return True
def check_perm(self, user, perm): def check_perm(self, user, perm):
if not hasattr(user, "rank") or user.is_banned: if not user.is_authenticated:
return False return False
if type(perm) == str: if type(perm) == str:
@@ -1241,7 +1051,9 @@ class PackageRelease(db.Model):
return count > 0 return count > 0
elif perm == Permission.APPROVE_RELEASE: elif perm == Permission.APPROVE_RELEASE:
return is_maintainer or user.rank.at_least(UserRank.APPROVER) return user.rank.at_least(UserRank.APPROVER) or \
(is_maintainer and user.rank.at_least(
UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER))
else: else:
raise Exception("Permission {} is not related to releases".format(perm.name)) raise Exception("Permission {} is not related to releases".format(perm.name))
@@ -1264,8 +1076,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,23 +1089,6 @@ 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):
path = self.file_path
if not os.path.isfile(path):
self.file_size_bytes = 0
return
file_stats = os.stat(path)
self.file_size_bytes = file_stats.st_size
@property
def file_size(self):
size = self.file_size_bytes / 1024
if size > 1024:
return f"{round(size / 1024, 1)} MB"
else:
return f"{round(size)} KB"
def get_edit_url(self): def get_edit_url(self):
return url_for("packages.edit_screenshot", return url_for("packages.edit_screenshot",
author=self.package.author.username, author=self.package.author.username,
@@ -1308,12 +1101,8 @@ class PackageScreenshot(db.Model):
name=self.package.name, name=self.package.name,
id=self.id) id=self.id)
def get_thumb_url(self, level=2, format="webp"): def get_thumb_url(self, level=2):
url = self.url.replace("/uploads/", "/thumbnails/{:d}/".format(level)) return self.url.replace("/uploads/", "/thumbnails/{:d}/".format(level))
if format is not None:
start = url[:url.rfind(".")]
url = f"{start}.{format}"
return url
def as_dict(self, base_url=""): def as_dict(self, base_url=""):
return { return {
@@ -1328,12 +1117,6 @@ class PackageScreenshot(db.Model):
"is_cover_image": self.package.cover_image == self, "is_cover_image": self.package.cover_image == self,
} }
def as_short_dict(self, base_url=""):
return {
"title": self.title,
"url": base_url + self.url,
}
class PackageUpdateTrigger(enum.Enum): class PackageUpdateTrigger(enum.Enum):
COMMIT = "New Commit" COMMIT = "New Commit"
@@ -1371,8 +1154,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)
@@ -1386,27 +1167,25 @@ class PackageUpdateConfig(db.Model):
def get_message(self): def get_message(self):
if self.trigger == PackageUpdateTrigger.COMMIT: if self.trigger == PackageUpdateTrigger.COMMIT:
msg = lazy_gettext("New commit %(hash)s found on the Git repo.", hash=self.last_commit[0:5]) msg = "New commit {} found on the Git repo.".format(self.last_commit[0:5])
last_release = self.package.releases.first() last_release = self.package.releases.first()
if last_release and last_release.commit_hash: if last_release and last_release.commit_hash:
msg += " " + lazy_gettext("The last release was commit %(hash)s", msg += " The last release was commit {}".format(last_release.commit_hash[0:5])
hash=last_release.commit_hash[0:5])
return msg return msg
else: else:
return lazy_gettext("New tag %(tag_name)s found on the Git repo.", tag_name=self.last_tag) return "New tag {} found on the Git repo.".format(self.last_tag)
@property def get_title(self):
def title(self):
return self.last_tag or self.outdated_at.strftime("%Y-%m-%d") return self.last_tag or self.outdated_at.strftime("%Y-%m-%d")
def get_ref(self): def get_ref(self):
return self.last_tag or self.last_commit return self.last_tag or self.last_commit
def get_create_release_url(self): def get_create_release_url(self):
return self.package.get_url("packages.create_release", title=self.title, ref=self.get_ref()) return self.package.get_url("packages.create_release", title=self.get_title(), ref=self.get_ref())
class PackageAlias(db.Model): class PackageAlias(db.Model):
@@ -1443,7 +1222,7 @@ class PackageDailyStats(db.Model):
reason_update = db.Column(db.Integer, nullable=False, default=0) reason_update = db.Column(db.Integer, nullable=False, default=0)
@staticmethod @staticmethod
def update(package: Package, is_luanti: bool, reason: str): def update(package: Package, is_minetest: bool, reason: str):
date = datetime.datetime.utcnow().date() date = datetime.datetime.utcnow().date()
to_update = dict() to_update = dict()
@@ -1451,7 +1230,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

View File

@@ -18,8 +18,6 @@ import datetime
from typing import Tuple, List from typing import Tuple, List
from flask import url_for from flask import url_for
from sqlalchemy import select, func, text
from sqlalchemy.orm import column_property
from . import db from . import db
from .users import Permission, UserRank, User from .users import Permission, UserRank, User
@@ -57,17 +55,10 @@ 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")
replies_count = column_property(select(func.count(text("thread_reply.id")))
.select_from(text("thread_reply"))
.where(text("thread_reply.thread_id") == id)
.as_scalar())
def get_description(self): def get_description(self):
comment = self.first_reply.comment.replace("\r\n", " ").replace("\n", " ").replace(" ", " ") comment = self.first_reply.comment.replace("\r\n", " ").replace("\n", " ").replace(" ", " ")
if len(comment) > 100: if len(comment) > 100:
@@ -178,7 +169,7 @@ class ThreadReply(db.Model):
class PackageReview(db.Model): class PackageReview(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False) package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
package = db.relationship("Package", foreign_keys=[package_id], back_populates="reviews") package = db.relationship("Package", foreign_keys=[package_id], back_populates="reviews")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
@@ -186,9 +177,6 @@ class PackageReview(db.Model):
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User", foreign_keys=[author_id], back_populates="reviews") author = db.relationship("User", foreign_keys=[author_id], back_populates="reviews")
language_id = db.Column(db.String, db.ForeignKey("language.id"), nullable=True, default=None)
language = db.relationship("Language", foreign_keys=[language_id])
rating = db.Column(db.Integer, nullable=False) rating = db.Column(db.Integer, nullable=False)
thread = db.relationship("Thread", uselist=False, back_populates="review") thread = db.relationship("Thread", uselist=False, back_populates="review")

View File

@@ -17,11 +17,11 @@
import datetime import datetime
import enum import enum
from flask import current_app from flask import url_for
from flask_babel import lazy_gettext
from flask_login import UserMixin from flask_login import UserMixin
from sqlalchemy import desc, text from sqlalchemy import desc, text
from app import gravatar
from . import db from . import db
@@ -40,28 +40,8 @@ class UserRank(enum.Enum):
def at_least(self, min): def at_least(self, min):
return self.value >= min.value return self.value >= min.value
@property def get_title(self):
def title(self): return self.name.replace("_", " ").title()
if self == UserRank.BANNED:
return lazy_gettext("Banned")
elif self == UserRank.NOT_JOINED:
return lazy_gettext("Not Joined")
elif self == UserRank.NEW_MEMBER:
return lazy_gettext("New Member")
elif self == UserRank.MEMBER:
return lazy_gettext("Member")
elif self == UserRank.TRUSTED_MEMBER:
return lazy_gettext("Trusted Member")
elif self == UserRank.APPROVER:
return lazy_gettext("Approver")
elif self == UserRank.EDITOR:
return lazy_gettext("Editor")
elif self == UserRank.BOT:
return lazy_gettext("Bot")
elif self == UserRank.MODERATOR:
return lazy_gettext("Moderator")
elif self == UserRank.ADMIN:
return lazy_gettext("Admin")
def to_name(self): def to_name(self):
return self.name.lower() return self.name.lower()
@@ -71,7 +51,7 @@ class UserRank(enum.Enum):
@classmethod @classmethod
def choices(cls): def choices(cls):
return [(choice, choice.title) for choice in cls] return [(choice, choice.get_title()) for choice in cls]
@classmethod @classmethod
def coerce(cls, item): def coerce(cls, item):
@@ -96,7 +76,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 +94,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
@@ -166,7 +144,6 @@ class User(db.Model, UserMixin):
# Account linking # Account linking
github_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True) github_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True)
github_user_id = db.Column(db.Integer, nullable=True, unique=True)
forums_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True) forums_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True)
# Access token for webhook setup # Access token for webhook setup
@@ -212,14 +189,9 @@ 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)
@property
def is_banned(self):
return (self.ban and not self.ban.has_expired) or self.rank == UserRank.BANNED
def get_dict(self): def get_dict(self):
from app.utils.flask import abs_url_for from app.utils.flask import abs_url_for
return { return {
@@ -250,20 +222,13 @@ class User(db.Model, UserMixin):
def can_access_todo_list(self): def can_access_todo_list(self):
return Permission.APPROVE_NEW.check(self) or Permission.APPROVE_RELEASE.check(self) return Permission.APPROVE_NEW.check(self) or Permission.APPROVE_RELEASE.check(self)
def get_profile_pic_url(self, absolute: bool = False): def get_profile_pic_url(self):
if self.profile_pic: if self.profile_pic:
if absolute: return self.profile_pic
return current_app.config["BASE_URL"] + self.profile_pic
else:
return self.profile_pic
elif self.rank == UserRank.BOT: elif self.rank == UserRank.BOT:
if absolute: return "/static/bot_avatar.png"
return current_app.config["BASE_URL"] + "/static/bot_avatar.png"
else:
return "/static/bot_avatar.png"
else: else:
from app.utils.gravatar import get_gravatar return gravatar(self.email or f"{self.username}@content.minetest.net")
return get_gravatar(self.email or f"{self.username}@content.luanti.org")
def check_perm(self, user, perm): def check_perm(self, user, perm):
if not user.is_authenticated: if not user.is_authenticated:
@@ -290,8 +255,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))
@@ -308,12 +271,12 @@ class User(db.Model, UserMixin):
one_min_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=1) one_min_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
if ThreadReply.query.filter_by(author=self) \ if ThreadReply.query.filter_by(author=self) \
.filter(ThreadReply.created_at > one_min_ago, ThreadReply.is_status_update == False).count() >= 2 * factor: .filter(ThreadReply.created_at > one_min_ago).count() >= 2 * factor:
return False return False
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1) hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
if ThreadReply.query.filter_by(author=self) \ if ThreadReply.query.filter_by(author=self) \
.filter(ThreadReply.created_at > hour_ago, ThreadReply.is_status_update == False).count() >= 10 * factor: .filter(ThreadReply.created_at > hour_ago).count() >= 10 * factor:
return False return False
return True return True
@@ -355,8 +318,7 @@ class User(db.Model, UserMixin):
if other is None: if other is None:
return False return False
# Anonymous users if not self.is_authenticated or not other.is_authenticated:
if not hasattr(self, "id") or not hasattr(other, "id"):
return False return False
assert self.id > 0 assert self.id > 0
@@ -383,12 +345,6 @@ class UserEmailVerification(db.Model):
is_password_reset = db.Column(db.Boolean, nullable=False, default=False) is_password_reset = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
@property
def is_expired(self):
delta = (datetime.datetime.now() - self.created_at)
delta: datetime.timedelta
return delta.total_seconds() > 12 * 60 * 60
class EmailSubscription(db.Model): class EmailSubscription(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@@ -438,93 +394,36 @@ class NotificationType(enum.Enum):
# Any other # Any other
OTHER = 0 OTHER = 0
@property
def title(self): def get_title(self):
if self == NotificationType.PACKAGE_EDIT: return self.name.replace("_", " ").title()
# NOTE: PACKAGE_EDIT notification type
return lazy_gettext("Package Edit")
elif self == NotificationType.PACKAGE_APPROVAL:
# NOTE: PACKAGE_APPROVAL notification type
return lazy_gettext("Package Approval")
elif self == NotificationType.NEW_THREAD:
# NOTE: NEW_THREAD notification type
return lazy_gettext("New Thread")
elif self == NotificationType.NEW_REVIEW:
# NOTE: NEW_REVIEW notification type
return lazy_gettext("New Review")
elif self == NotificationType.THREAD_REPLY:
# NOTE: THREAD_REPLY notification type
return lazy_gettext("Thread Reply")
elif self == NotificationType.BOT:
# NOTE: BOT notification type
return lazy_gettext("Bot")
elif self == NotificationType.MAINTAINER:
# NOTE: MAINTAINER notification type
return lazy_gettext("Maintainer")
elif self == NotificationType.EDITOR_ALERT:
# NOTE: EDITOR_ALERT notification type
return lazy_gettext("Editor Alert")
elif self == NotificationType.EDITOR_MISC:
# NOTE: EDITOR_MISC notification type
return lazy_gettext("Editor Misc")
elif self == NotificationType.OTHER:
# NOTE: OTHER notification type
return lazy_gettext("Other")
else:
raise "Unknown notification type"
def to_name(self): def to_name(self):
return self.name.lower() return self.name.lower()
@property def get_description(self):
def this_is(self):
if self == NotificationType.PACKAGE_EDIT: if self == NotificationType.PACKAGE_EDIT:
return lazy_gettext("This is a Package Edit notification.") return "When another user edits your packages, releases, etc."
elif self == NotificationType.PACKAGE_APPROVAL: elif self == NotificationType.PACKAGE_APPROVAL:
return lazy_gettext("This is a Package Approval notification.") return "Notifications from editors related to the package approval process."
elif self == NotificationType.NEW_THREAD: elif self == NotificationType.NEW_THREAD:
return lazy_gettext("This is a New Thread notification.") return "When a thread is created on your package."
elif self == NotificationType.NEW_REVIEW: elif self == NotificationType.NEW_REVIEW:
return lazy_gettext("This is a New Review notification.") return "When a user posts a review on your package."
elif self == NotificationType.THREAD_REPLY: elif self == NotificationType.THREAD_REPLY:
return lazy_gettext("This is a Thread Reply notification.") return "When someone replies to a thread you're watching."
elif self == NotificationType.BOT: elif self == NotificationType.BOT:
return lazy_gettext("This is a Bot notification.") return "From a bot - for example, update notifications."
elif self == NotificationType.MAINTAINER: elif self == NotificationType.MAINTAINER:
return lazy_gettext("This is a Maintainer change notification.") return "When your package's maintainers change."
elif self == NotificationType.EDITOR_ALERT: elif self == NotificationType.EDITOR_ALERT:
return lazy_gettext("This is an Editor Alert notification.") return "For editors: Important alerts."
elif self == NotificationType.EDITOR_MISC: elif self == NotificationType.EDITOR_MISC:
return lazy_gettext("This is an Editor Misc notification.") return "For editors: Minor notifications, including new threads."
elif self == NotificationType.OTHER: elif self == NotificationType.OTHER:
return lazy_gettext("This is an Other notification.") return "Minor notifications not important enough for a dedicated category."
else: else:
raise "Unknown notification type" return ""
@property
def description(self):
if self == NotificationType.PACKAGE_EDIT:
return lazy_gettext("When another user edits your packages, releases, etc.")
elif self == NotificationType.PACKAGE_APPROVAL:
return lazy_gettext("Notifications from editors related to the package approval process.")
elif self == NotificationType.NEW_THREAD:
return lazy_gettext("When a thread is created on your package.")
elif self == NotificationType.NEW_REVIEW:
return lazy_gettext("When a user posts a review on your package.")
elif self == NotificationType.THREAD_REPLY:
return lazy_gettext("When someone replies to a thread you're watching.")
elif self == NotificationType.BOT:
return lazy_gettext("From a bot - for example, update notifications.")
elif self == NotificationType.MAINTAINER:
return lazy_gettext("When your package's maintainers change.")
elif self == NotificationType.EDITOR_ALERT:
return lazy_gettext("For editors: Important alerts.")
elif self == NotificationType.EDITOR_MISC:
return lazy_gettext("For editors: Minor notifications, including new threads.")
elif self == NotificationType.OTHER:
return lazy_gettext("Minor notifications not important enough for a dedicated category.")
else:
raise "Unknown notification type"
def __str__(self): def __str__(self):
return self.name return self.name
@@ -534,7 +433,7 @@ class NotificationType(enum.Enum):
@classmethod @classmethod
def choices(cls): def choices(cls):
return [(choice, choice.title) for choice in cls] return [(choice, choice.get_title()) for choice in cls]
@classmethod @classmethod
def coerce(cls, item): def coerce(cls, item):
@@ -661,7 +560,6 @@ class OAuthClient(db.Model):
redirect_url = db.Column(db.String(128), nullable=False) redirect_url = db.Column(db.String(128), nullable=False)
approved = db.Column(db.Boolean, nullable=False, default=False) approved = db.Column(db.Boolean, nullable=False, default=False)
verified = db.Column(db.Boolean, nullable=False, default=False) verified = db.Column(db.Boolean, nullable=False, default=False)
is_clientside = db.Column(db.Boolean, nullable=False, default=False)
owner_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) owner_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
owner = db.relationship("User", foreign_keys=[owner_id], back_populates="clients") owner = db.relationship("User", foreign_keys=[owner_id], back_populates="clients")
@@ -669,11 +567,3 @@ class OAuthClient(db.Model):
tokens = db.relationship("APIToken", back_populates="client", lazy="dynamic", cascade="all, delete, delete-orphan") tokens = db.relationship("APIToken", back_populates="client", lazy="dynamic", cascade="all, delete, delete-orphan")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
def get_app_type(self):
return "client" if self.is_clientside else "server"
def set_app_type(self, value):
self.is_clientside = value == "client"
app_type = property(get_app_type, set_app_type)

View File

@@ -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": []
}
}

Some files were not shown because too many files have changed in this diff Show More