Compare commits
1 Commits
master
...
oauth_scop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27e2b64e41 |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -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
7
.github/SECURITY.md
vendored
@@ -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).
|
||||||
|
|||||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -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
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ContentDB
|
# ContentDB
|
||||||

|

|
||||||
|
|
||||||
A content database for Luanti mods, games, and more.\
|
A content database for Minetest mods, games, and more.\
|
||||||
Developed by rubenwardy, license AGPLv3.0+.
|
Developed by rubenwardy, license AGPLv3.0+.
|
||||||
|
|
||||||
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
|
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")))
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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_>/")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
180
app/blueprints/github/__init__.py
Normal file
180
app/blueprints/github/__init__.py
Normal 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")
|
||||||
86
app/blueprints/gitlab/__init__.py
Normal file
86
app/blueprints/gitlab/__init__.py
Normal 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]))
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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/")
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'):
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
})
|
|
||||||
@@ -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]))
|
|
||||||
@@ -14,27 +14,27 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from celery import uuid
|
from celery import uuid
|
||||||
from flask import Blueprint, render_template, redirect, request, abort, url_for
|
from flask import Blueprint, render_template, redirect, request, abort, url_for
|
||||||
from flask_babel import lazy_gettext
|
from flask_babel import lazy_gettext
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, BooleanField, SubmitField, SelectMultipleField
|
from wtforms import StringField, BooleanField, SubmitField
|
||||||
from wtforms.validators import InputRequired, Length, Optional
|
from wtforms.validators import InputRequired, Length
|
||||||
|
|
||||||
from app.tasks import celery
|
from app.tasks import celery
|
||||||
from app.utils import rank_required
|
from app.utils import rank_required
|
||||||
|
|
||||||
bp = Blueprint("zipgrep", __name__)
|
bp = Blueprint("zipgrep", __name__)
|
||||||
|
|
||||||
from app.models import UserRank, Package, PackageType
|
from app.models import UserRank, Package
|
||||||
from app.tasks.zipgrep import search_in_releases
|
from app.tasks.zipgrep import search_in_releases
|
||||||
|
|
||||||
|
|
||||||
class SearchForm(FlaskForm):
|
class SearchForm(FlaskForm):
|
||||||
query = StringField(lazy_gettext("Text to find (regex)"), [InputRequired(), Length(1, 100)])
|
query = StringField(lazy_gettext("Text to find (regex)"), [InputRequired(), Length(1, 100)])
|
||||||
file_filter = StringField(lazy_gettext("File filter"), [InputRequired(), Length(1, 100)], default="*.lua")
|
file_filter = StringField(lazy_gettext("File filter"), [InputRequired(), Length(1, 100)], default="*.lua")
|
||||||
type = SelectMultipleField(lazy_gettext("Type"), [Optional()],
|
remember_me = BooleanField(lazy_gettext("Remember me"), default=True)
|
||||||
choices=PackageType.choices(), coerce=PackageType.coerce)
|
|
||||||
submit = SubmitField(lazy_gettext("Search"))
|
submit = SubmitField(lazy_gettext("Search"))
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ def zipgrep_search():
|
|||||||
form = SearchForm(request.form)
|
form = SearchForm(request.form)
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
task_id = uuid()
|
task_id = uuid()
|
||||||
search_in_releases.apply_async((form.query.data, form.file_filter.data, [x.name for x in form.type.data]), task_id=task_id)
|
search_in_releases.apply_async((form.query.data, form.file_filter.data), task_id=task_id)
|
||||||
result_url = url_for("zipgrep.view_results", id=task_id)
|
result_url = url_for("zipgrep.view_results", id=task_id)
|
||||||
return redirect(url_for("tasks.check", id=task_id, r=result_url))
|
return redirect(url_for("tasks.check", id=task_id, r=result_url))
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from .models import User, UserRank, LuantiRelease, Tag, License, Notification, NotificationType, Package, \
|
from .models import User, UserRank, MinetestRelease, Tag, License, Notification, NotificationType, Package, \
|
||||||
PackageState, PackageType, PackageRelease, MetaPackage, Dependency
|
PackageState, PackageType, PackageRelease, MetaPackage, Dependency
|
||||||
from .utils import make_flask_login_password
|
from .utils import make_flask_login_password
|
||||||
|
|
||||||
@@ -35,12 +35,12 @@ def populate(session):
|
|||||||
system_user.rank = UserRank.BOT
|
system_user.rank = UserRank.BOT
|
||||||
session.add(system_user)
|
session.add(system_user)
|
||||||
|
|
||||||
session.add(LuantiRelease("None", 0))
|
session.add(MinetestRelease("None", 0))
|
||||||
session.add(LuantiRelease("0.4.16/17", 32))
|
session.add(MinetestRelease("0.4.16/17", 32))
|
||||||
session.add(LuantiRelease("5.0", 37))
|
session.add(MinetestRelease("5.0", 37))
|
||||||
session.add(LuantiRelease("5.1", 38))
|
session.add(MinetestRelease("5.1", 38))
|
||||||
session.add(LuantiRelease("5.2", 39))
|
session.add(MinetestRelease("5.2", 39))
|
||||||
session.add(LuantiRelease("5.3", 39))
|
session.add(MinetestRelease("5.3", 39))
|
||||||
|
|
||||||
tags = {}
|
tags = {}
|
||||||
for tag in ["Inventory", "Mapgen", "Building",
|
for tag in ["Inventory", "Mapgen", "Building",
|
||||||
@@ -69,8 +69,8 @@ def populate_test_data(session):
|
|||||||
licenses = { x.name : x for x in License.query.all() }
|
licenses = { x.name : x for x in License.query.all() }
|
||||||
tags = { x.name : x for x in Tag.query.all() }
|
tags = { x.name : x for x in Tag.query.all() }
|
||||||
admin_user = User.query.filter_by(rank=UserRank.ADMIN).first()
|
admin_user = User.query.filter_by(rank=UserRank.ADMIN).first()
|
||||||
v4 = LuantiRelease.query.filter_by(protocol=32).first()
|
v4 = MinetestRelease.query.filter_by(protocol=32).first()
|
||||||
v51 = LuantiRelease.query.filter_by(protocol=38).first()
|
v51 = MinetestRelease.query.filter_by(protocol=38).first()
|
||||||
|
|
||||||
ez = User("Shara")
|
ez = User("Shara")
|
||||||
ez.github_username = "Ezhh"
|
ez.github_username = "Ezhh"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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=).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
title: WTFPL is a terrible license
|
title: WTFPL is a terrible license
|
||||||
toc: False
|
toc: False
|
||||||
|
|
||||||
|
<div id="warning" class="alert alert-warning">
|
||||||
|
<span class="icon_message"></span>
|
||||||
|
|
||||||
|
Please reconsider the choice of WTFPL as a license.
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// @author rubenwardy
|
||||||
|
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||||
|
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
var r = params.get("r");
|
||||||
|
if (r) {
|
||||||
|
document.write("<a class='alert_right button' href='" + r + "'>Okay</a>");
|
||||||
|
} else {
|
||||||
|
document.getElementById("warning").style.display = "none";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
|
||||||
The use of WTFPL as a license is discouraged for multiple reasons.
|
The use of WTFPL as a license is discouraged for multiple reasons.
|
||||||
|
|
||||||
* **No Warranty disclaimer:** This could open you up to being sued.<sup>[1]</sup>
|
* **No Warranty disclaimer:** This could open you up to being sued.<sup>[1]</sup>
|
||||||
@@ -18,4 +37,4 @@ license, saying:<sup>[3]</sup>
|
|||||||
|
|
||||||
1. [WTFPL is harmful to software developers](https://cubicspot.blogspot.com/2017/04/wtfpl-is-harmful-to-software-developers.html)
|
1. [WTFPL is harmful to software developers](https://cubicspot.blogspot.com/2017/04/wtfpl-is-harmful-to-software-developers.html)
|
||||||
2. [FSF](https://www.gnu.org/licenses/license-list.en.html)
|
2. [FSF](https://www.gnu.org/licenses/license-list.en.html)
|
||||||
3. [OSI](https://opensource.org/meeting-minutes/minutes20090304)
|
3. [OSI](https://opensource.org/minutes20090304)
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
title: Package Inclusion Policy and Guidance
|
title: Package Inclusion Policy and Guidance
|
||||||
|
|
||||||
|
## 0. Overview
|
||||||
|
|
||||||
|
ContentDB is for the community, and as such listings should be useful to the
|
||||||
|
community. To help with this, there are a few rules to improve the quality of
|
||||||
|
the listings and to combat abuse.
|
||||||
|
|
||||||
|
* **No inappropriate content.** <sup>2.1</sup>
|
||||||
|
* **Content must be playable/useful, but not necessarily finished.** <sup>2.2</sup>
|
||||||
|
* **Don't use the name of another mod unless your mod is a fork or reimplementation.** <sup>3</sup>
|
||||||
|
* **Licenses must allow derivatives, redistribution, and must not discriminate.** <sup>4</sup>
|
||||||
|
* **Don't put promotions or advertisements in any package metadata.** <sup>5</sup>
|
||||||
|
* **Don't manipulate package placement using reviews or downloads.** <sup>6</sup>
|
||||||
|
* **Screenshots must not be misleading.** <sup>7</sup>
|
||||||
|
* **The ContentDB admin reserves the right to remove packages for any reason**,
|
||||||
|
including ones not covered by this document, and to ban users who abuse
|
||||||
|
this service. <sup>1</sup>
|
||||||
|
|
||||||
|
|
||||||
## 1. General
|
## 1. General
|
||||||
|
|
||||||
@@ -9,53 +26,35 @@ including ones not covered by this document, and to ban users who abuse this ser
|
|||||||
|
|
||||||
## 2. Accepted Content
|
## 2. Accepted Content
|
||||||
|
|
||||||
### 2.1. Mature Content
|
### 2.1. Acceptable Content
|
||||||
|
|
||||||
See the [Terms of Service](/terms/) for a full list of prohibited content.
|
Sexually-orientated content is not permitted.
|
||||||
|
If in doubt at what this means, [contact us by raising a report](/report/).
|
||||||
|
|
||||||
Other mature content is permitted providing that it is labelled with the applicable
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
15
app/flatpages/rules.md
Normal 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.
|
||||||
@@ -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.
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
56
app/logic/package_validator.py
Normal file
56
app/logic/package_validator.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
6
app/logic/scope.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from app.models import APIToken
|
||||||
|
|
||||||
|
|
||||||
|
class Scope:
|
||||||
|
def copy_to_token(self, token: APIToken):
|
||||||
|
pass
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
115
app/maillogger.py
Normal 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
204
app/markdown.py
Normal 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])
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# ContentDB
|
|
||||||
# Copyright (C) rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
from typing import Sequence
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from jinja2.utils import markupsafe
|
|
||||||
from markdown_it import MarkdownIt
|
|
||||||
from markdown_it.common.utils import unescapeAll, escapeHtml
|
|
||||||
from markdown_it.token import Token
|
|
||||||
from markdown_it.presets import gfm_like
|
|
||||||
from mdit_py_plugins.anchors import anchors_plugin
|
|
||||||
from pygments import highlight
|
|
||||||
from pygments.lexers import get_lexer_by_name
|
|
||||||
from pygments.util import ClassNotFound
|
|
||||||
from pygments.formatters.html import HtmlFormatter
|
|
||||||
|
|
||||||
from .cleaner import clean_html
|
|
||||||
from .mention import init_mention
|
|
||||||
|
|
||||||
|
|
||||||
def highlight_code(code, name, attrs):
|
|
||||||
try:
|
|
||||||
lexer = get_lexer_by_name(name)
|
|
||||||
except ClassNotFound:
|
|
||||||
return None
|
|
||||||
|
|
||||||
formatter = HtmlFormatter()
|
|
||||||
|
|
||||||
return highlight(code, lexer, formatter)
|
|
||||||
|
|
||||||
|
|
||||||
def render_code(self, tokens: Sequence[Token], idx, options, env):
|
|
||||||
token = tokens[idx]
|
|
||||||
info = unescapeAll(token.info).strip() if token.info else ""
|
|
||||||
langName = info.split(maxsplit=1)[0] if info else ""
|
|
||||||
|
|
||||||
if options.highlight:
|
|
||||||
return options.highlight(
|
|
||||||
token.content, langName, ""
|
|
||||||
) or f"<pre><code>{escapeHtml(token.content)}</code></pre>"
|
|
||||||
|
|
||||||
return f"<pre><code>{escapeHtml(token.content)}</code></pre>"
|
|
||||||
|
|
||||||
|
|
||||||
gfm_like.make()
|
|
||||||
md = MarkdownIt("gfm-like", {"highlight": highlight_code})
|
|
||||||
md.use(anchors_plugin, permalink=True, permalinkSymbol="🔗", max_level=6)
|
|
||||||
md.add_render_rule("fence", render_code)
|
|
||||||
init_mention(md)
|
|
||||||
|
|
||||||
|
|
||||||
def render_markdown(source, clean=True):
|
|
||||||
html = md.render(source)
|
|
||||||
if clean:
|
|
||||||
return clean_html(html)
|
|
||||||
else:
|
|
||||||
return html
|
|
||||||
|
|
||||||
|
|
||||||
def init_markdown(app):
|
|
||||||
@app.template_filter()
|
|
||||||
def markdown(source):
|
|
||||||
return markupsafe.Markup(render_markdown(source))
|
|
||||||
|
|
||||||
|
|
||||||
def get_headings(html: str):
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
headings = soup.find_all(["h1", "h2", "h3"])
|
|
||||||
|
|
||||||
root = []
|
|
||||||
stack = []
|
|
||||||
for heading in headings:
|
|
||||||
text = heading.find(text=True, recursive=False)
|
|
||||||
this = {"link": heading.get("id") or "", "text": text, "children": []}
|
|
||||||
this_level = int(heading.name[1:]) - 1
|
|
||||||
|
|
||||||
while this_level <= len(stack):
|
|
||||||
stack.pop()
|
|
||||||
|
|
||||||
if len(stack) > 0:
|
|
||||||
stack[-1]["children"].append(this)
|
|
||||||
else:
|
|
||||||
root.append(this)
|
|
||||||
|
|
||||||
stack.append(this)
|
|
||||||
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_mentions(html: str) -> set:
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
links = soup.select("a[data-username]")
|
|
||||||
return set([x.get("data-username") for x in links])
|
|
||||||
|
|
||||||
|
|
||||||
def get_links(html: str) -> set:
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
links = soup.select("a[href]")
|
|
||||||
return set([x.get("href") for x in links])
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# ContentDB
|
|
||||||
# Copyright (C) rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
from functools import partial
|
|
||||||
from bleach import Cleaner
|
|
||||||
from bleach.linkifier import LinkifyFilter, DEFAULT_CALLBACKS
|
|
||||||
|
|
||||||
|
|
||||||
# Based on
|
|
||||||
# https://github.com/Wenzil/mdx_bleach/blob/master/mdx_bleach/whitelist.py
|
|
||||||
#
|
|
||||||
# License: MIT
|
|
||||||
|
|
||||||
ALLOWED_TAGS = {
|
|
||||||
"h1", "h2", "h3", "h4", "h5", "h6", "hr",
|
|
||||||
"ul", "ol", "li",
|
|
||||||
"p",
|
|
||||||
"br",
|
|
||||||
"pre",
|
|
||||||
"code",
|
|
||||||
"blockquote",
|
|
||||||
"strong",
|
|
||||||
"em",
|
|
||||||
"a",
|
|
||||||
"img",
|
|
||||||
"table", "thead", "tbody", "tr", "th", "td",
|
|
||||||
"div", "span", "del", "s",
|
|
||||||
"details",
|
|
||||||
"summary",
|
|
||||||
"sup",
|
|
||||||
}
|
|
||||||
|
|
||||||
ALLOWED_CSS = [
|
|
||||||
"highlight", "codehilite",
|
|
||||||
"hll", "c", "err", "g", "k", "l", "n", "o", "x", "p", "ch", "cm", "cp", "cpf", "c1", "cs",
|
|
||||||
"gd", "ge", "gr", "gh", "gi", "go", "gp", "gs", "gu", "gt", "kc", "kd", "kn", "kp", "kr",
|
|
||||||
"kt", "ld", "m", "s", "na", "nb", "nc", "no", "nd", "ni", "ne", "nf", "nl", "nn", "nx",
|
|
||||||
"py", "nt", "nv", "ow", "w", "mb", "mf", "mh", "mi", "mo", "sa", "sb", "sc", "dl", "sd",
|
|
||||||
"s2", "se", "sh", "si", "sx", "sr", "s1", "ss", "bp", "fm", "vc", "vg", "vi", "vm", "il",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def allow_class(_tag, name, value):
|
|
||||||
return name == "class" and value in ALLOWED_CSS
|
|
||||||
|
|
||||||
|
|
||||||
def allow_a(_tag, name, value):
|
|
||||||
return name in ["href", "title", "data-username"] or (name == "class" and value == "header-anchor")
|
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_ATTRIBUTES = {
|
|
||||||
"h1": ["id"],
|
|
||||||
"h2": ["id"],
|
|
||||||
"h3": ["id"],
|
|
||||||
"h4": ["id"],
|
|
||||||
"a": allow_a,
|
|
||||||
"img": ["src", "title", "alt"],
|
|
||||||
"code": allow_class,
|
|
||||||
"div": allow_class,
|
|
||||||
"span": allow_class,
|
|
||||||
"table": ["id"],
|
|
||||||
}
|
|
||||||
|
|
||||||
ALLOWED_PROTOCOLS = {"http", "https", "mailto"}
|
|
||||||
|
|
||||||
|
|
||||||
def linker_callback(attrs, new=False):
|
|
||||||
if new:
|
|
||||||
text = attrs.get("_text")
|
|
||||||
if not (text.startswith("http://") or text.startswith("https://")):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
def clean_html(html: str):
|
|
||||||
cleaner = Cleaner(
|
|
||||||
tags=ALLOWED_TAGS,
|
|
||||||
attributes=ALLOWED_ATTRIBUTES,
|
|
||||||
protocols=ALLOWED_PROTOCOLS,
|
|
||||||
filters=[partial(LinkifyFilter,
|
|
||||||
callbacks=[linker_callback] + DEFAULT_CALLBACKS,
|
|
||||||
skip_tags={"pre", "code"})])
|
|
||||||
return cleaner.clean(html)
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# ContentDB
|
|
||||||
# Copyright (C) rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
from flask import url_for
|
|
||||||
from markdown_it import MarkdownIt
|
|
||||||
from markdown_it.token import Token
|
|
||||||
from markdown_it.rules_core.state_core import StateCore
|
|
||||||
from typing import Sequence, List
|
|
||||||
|
|
||||||
|
|
||||||
def render_user_mention(self, tokens: Sequence[Token], idx, options, env):
|
|
||||||
token = tokens[idx]
|
|
||||||
username = token.content
|
|
||||||
url = url_for("users.profile", username=username)
|
|
||||||
return f"<a href=\"{url}\" data-username=\"{username}\">@{username}</a>"
|
|
||||||
|
|
||||||
|
|
||||||
def render_package_mention(self, tokens: Sequence[Token], idx, options, env):
|
|
||||||
token = tokens[idx]
|
|
||||||
username = token.content
|
|
||||||
name = token.attrs["name"]
|
|
||||||
url = url_for("packages.view", author=username, name=name)
|
|
||||||
return f"<a href=\"{url}\">@{username}/{name}</a>"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_mentions(state: StateCore):
|
|
||||||
for block_token in state.tokens:
|
|
||||||
if block_token.type != "inline" or block_token.children is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
link_depth = 0
|
|
||||||
html_link_depth = 0
|
|
||||||
|
|
||||||
children = []
|
|
||||||
for token in block_token.children:
|
|
||||||
if token.type == "link_open":
|
|
||||||
link_depth += 1
|
|
||||||
elif token.type == "link_close":
|
|
||||||
link_depth -= 1
|
|
||||||
elif token.type == "html_inline":
|
|
||||||
# is link open / close?
|
|
||||||
pass
|
|
||||||
|
|
||||||
if link_depth > 0 or html_link_depth > 0 or token.type != "text":
|
|
||||||
children.append(token)
|
|
||||||
else:
|
|
||||||
children.extend(split_tokens(token, state))
|
|
||||||
|
|
||||||
block_token.children = children
|
|
||||||
|
|
||||||
|
|
||||||
RE_PARTS = dict(
|
|
||||||
USER=r"[A-Za-z0-9._-]*\b",
|
|
||||||
NAME=r"[A-Za-z0-9_]+\b"
|
|
||||||
)
|
|
||||||
MENTION_RE = r"(@({USER})(?:\/({NAME}))?)".format(**RE_PARTS)
|
|
||||||
|
|
||||||
|
|
||||||
def split_tokens(token: Token, state: StateCore) -> List[Token]:
|
|
||||||
tokens = []
|
|
||||||
content = token.content
|
|
||||||
pos = 0
|
|
||||||
for match in re.finditer(MENTION_RE, content):
|
|
||||||
username = match.group(2)
|
|
||||||
package_name = match.group(3)
|
|
||||||
(start, end) = match.span(0)
|
|
||||||
|
|
||||||
if start > pos:
|
|
||||||
token_text = Token("text", "", 0)
|
|
||||||
token_text.content = content[pos:start]
|
|
||||||
token_text.level = token.level
|
|
||||||
tokens.append(token_text)
|
|
||||||
|
|
||||||
mention = Token("package_mention" if package_name else "user_mention", "", 0)
|
|
||||||
mention.content = username
|
|
||||||
mention.attrSet("name", package_name)
|
|
||||||
mention.level = token.level
|
|
||||||
tokens.append(mention)
|
|
||||||
|
|
||||||
pos = end
|
|
||||||
|
|
||||||
if pos < len(content):
|
|
||||||
token_text = Token("text", "", 0)
|
|
||||||
token_text.content = content[pos:]
|
|
||||||
token_text.level = token.level
|
|
||||||
tokens.append(token_text)
|
|
||||||
|
|
||||||
return tokens
|
|
||||||
|
|
||||||
|
|
||||||
def init_mention(md: MarkdownIt):
|
|
||||||
md.add_render_rule("user_mention", render_user_mention, "html")
|
|
||||||
md.add_render_rule("package_mention", render_package_mention, "html")
|
|
||||||
md.core.ruler.after("inline", "mention", parse_mentions)
|
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from flask_babel import LazyString
|
|
||||||
|
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from sqlalchemy_searchable import make_searchable
|
from sqlalchemy_searchable import make_searchable
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 "[]({})" \
|
return "[]({})" \
|
||||||
.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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user