Compare commits

..

41 Commits

Author SHA1 Message Date
rubenwardy
b68a1d7ab9 Reduce chance of accidental release deletion 2020-01-19 20:16:03 +00:00
rubenwardy
2ef90902aa Fix approved checkbox deselection bug 2020-01-19 20:08:58 +00:00
rubenwardy
e115b0678c Fix password issues caused by Flask-User migration 2020-01-19 19:48:41 +00:00
rubenwardy
0bda16de6d Add API tests 2020-01-19 19:09:04 +00:00
rubenwardy
fd6ba459f9 Add Gitlab CI support 2020-01-19 18:15:18 +00:00
rubenwardy
d503908a65 Add populated homepage test 2020-01-19 15:46:29 +00:00
rubenwardy
215839c423 Add end-to-end test framework 2020-01-19 15:03:38 +00:00
rubenwardy
783bc86aaf Update dependencies 2020-01-19 02:46:07 +00:00
rubenwardy
6e626c0f89 Add admin option to check all releases 2020-01-19 02:20:20 +00:00
rubenwardy
facdd35b11 Add validation to zip releases 2020-01-19 01:37:15 +00:00
rubenwardy
ec8a88a7a8 Allow deleting releases with broken tasks 2020-01-19 01:23:56 +00:00
rubenwardy
1b1c94ffa0 Add release contents validation 2020-01-19 01:22:33 +00:00
rubenwardy
bcd003685e Add support for submodules in makeVCSRelease() 2020-01-19 00:28:26 +00:00
rubenwardy
59039a14a5 Add ability to delete releases 2020-01-19 00:02:37 +00:00
rubenwardy
0d6e217405 Fix missing name in search weightings 2020-01-18 23:20:49 +00:00
rubenwardy
64e1805b53 Add more util scripts 2020-01-18 23:20:34 +00:00
rubenwardy
22d02edbd8 Add constraint for release tasks and approval 2020-01-18 23:10:11 +00:00
rubenwardy
5a496f6858 Fix broken search weighting
Fixes #176
2020-01-18 17:54:46 +00:00
rubenwardy
f4209d7a67 Add documentation for reload.sh and update.sh 2020-01-18 01:42:47 +00:00
rubenwardy
077bdeb01c Add reloading support to Docker container 2020-01-18 01:38:00 +00:00
rubenwardy
095494f96f Improve Docker configurations 2020-01-18 01:20:32 +00:00
rubenwardy
6f230ee4b2 Fix uploadPackageScores task 2020-01-18 01:16:33 +00:00
rubenwardy
311e0218af Fiddle with package button styling 2020-01-18 00:15:29 +00:00
rubenwardy
3fee369dc1 Fix crash on clearing all notifications 2019-12-17 20:49:59 +00:00
rubenwardy
e57f2dfe7d Fix crash due to missing import 2019-11-27 01:16:59 +00:00
rubenwardy
dd5de1787f Add database diagram 2019-11-27 01:06:58 +00:00
rubenwardy
62f1aecfaf Fix debug mode in entrypoint.sh 2019-11-27 01:06:58 +00:00
rubenwardy
4ce388c8aa Add API Token creation 2019-11-27 01:06:58 +00:00
rubenwardy
cb5451fe5d Fix pkgtasks crash due to it not being imported 2019-11-22 01:16:17 +00:00
rubenwardy
5466a2d64d Rename run.sh to entrypoint.sh 2019-11-21 23:16:39 +00:00
rubenwardy
77f8a79c51 Add useful scripts 2019-11-21 22:27:38 +00:00
rubenwardy
33b2b38308 Improve package scoring 2019-11-21 22:16:35 +00:00
rubenwardy
94426e97aa Add support for randomly sorting queries 2019-11-21 21:43:58 +00:00
rubenwardy
5b68e494db Fix crash on accessing notifications 2019-11-21 19:50:54 +00:00
rubenwardy
39d4cf362b Fix url_for crash on "home_page" 2019-11-21 19:38:32 +00:00
rubenwardy
b977a42738 Add celery beat and celery flower to docker compose 2019-11-18 22:44:37 +00:00
rubenwardy
ff2a74367f Fix download forgery 2019-11-18 21:42:56 +00:00
rubenwardy
3f666d2302 Fix exception on badly-formed query string 2019-11-17 21:40:55 +00:00
rubenwardy
a7d22973ff Fix user profile after blueprints commit 2019-11-16 00:05:59 +00:00
rubenwardy
20583784f5 Fix hardcoded progress bar in work queue, and related crash 2019-11-16 00:05:35 +00:00
rubenwardy
64f131ae27 Refactor endpoints to use blueprints instead 2019-11-15 23:51:42 +00:00
107 changed files with 2323 additions and 1111 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.git
data
uploads
*.pyc
__pycache__

8
.gitignore vendored
View File

@@ -1,13 +1,13 @@
config.cfg
*.env
/config.cfg
/*.env
*.sqlite
.vscode
custom.css
tmp
log.txt
*.rdb
uploads
thumbnails
app/public/uploads
app/public/thumbnails
celerybeat-schedule
/data

22
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,22 @@
image: docker/compose
services:
- docker:dind
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- /var/lib/docker
# build:
# stage: build
# script:
# - cp utils/gitlabci/* .
# - docker-compose build
UI_Test:
stage: test
script:
- cp utils/gitlabci/* .
- docker-compose up -d
- ./utils/run_migrations.sh
- ./utils/tests_cov.sh
- docker-compose down

View File

@@ -5,15 +5,18 @@ RUN groupadd -g 5123 cdb && \
WORKDIR /home/cdb
RUN mkdir /var/cdb
RUN chown -R cdb:cdb /var/cdb
COPY requirements.txt requirements.txt
RUN pip install -r ./requirements.txt
RUN pip install -r requirements.txt
RUN pip install gunicorn
COPY utils utils
COPY app app
COPY config.cfg config.cfg
COPY migrations migrations
COPY config.cfg ./config.cfg
COPY app app
RUN chown cdb:cdb /home/cdb -R
RUN chown -R cdb:cdb /home/cdb
USER cdb

View File

@@ -18,10 +18,46 @@ rm db.sqlite && python setup.py -t && FLASK_CONFIG=../config.cfg FLASK_APP=app/_
# Create migration
FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db migrate
# Run migration
FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db upgrade
# Enter docker
docker exec -it contentdb_app_1 bash
# Hot/live reload (only works with FLASK_DEBUG=1)
./utils/reload.sh
# Cold update a running version of CDB with minimal downtime
./utils/update.sh
```
## Database
```mermaid
classDiagram
User "1" --> "*" Package
User --> UserEmailVerification
User "1" --> "*" Notification
Package "1" --> "*" Release
Package "1" --> "*" Dependency
Package "1" --> "*" Tag
Package "1" --> "*" MetaPackage : provides
Release --> MinetestVersion
Package --> License
Dependency --> Package
Dependency --> MetaPackage
MetaPackage "1" --> "*" Package
Package "1" --> "*" Screenshot
Package "1" --> "*" Thread
Thread "1" --> "*" Reply
Thread "1" --> "*" User : watchers
User "1" --> "*" Thread
User "1" --> "*" Reply
User "1" --> "*" ForumTopic
User --> "0..1" EmailPreferences
User "1" --> "*" APIToken
APIToken --> Package
```

View File

@@ -25,13 +25,15 @@ from flask_github import GitHub
from flask_wtf.csrf import CsrfProtect
from flask_flatpages import FlatPages
from flask_babel import Babel
import os
import os, redis
app = Flask(__name__, static_folder="public/static")
app.config["FLATPAGES_ROOT"] = "flatpages"
app.config["FLATPAGES_EXTENSION"] = ".md"
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
r = redis.Redis.from_url(app.config["REDIS_URL"])
menu.Menu(app=app)
markdown = Markdown(app, extensions=["fenced_code"], safe_mode=True, output_format="html5")
github = GitHub(app)
@@ -48,6 +50,10 @@ gravatar = Gravatar(app,
use_ssl=True,
base_url=None)
from .sass import sass
sass(app)
if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
from .maillogger import register_mail_error_handler
register_mail_error_handler(app, mail)
@@ -55,8 +61,33 @@ if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
@babel.localeselector
def get_locale():
return request.accept_languages.best_match(app.config['LANGUAGES'].keys())
return request.accept_languages.best_match(app.config['LANGUAGES'].keys())
from . import models, tasks, template_filters
from . import models, tasks
from .views import *
from .blueprints import create_blueprints
create_blueprints(app)
from flask_login import logout_user
@app.route("/uploads/<path:path>")
def send_upload(path):
return send_from_directory(app.config['UPLOAD_DIR'], path)
@menu.register_menu(app, ".help", "Help", order=19, endpoint_arguments_constructor=lambda: { 'path': 'help' })
@app.route('/<path:path>/')
def flatpage(path):
page = pages.get_or_404(path)
template = page.meta.get('template', 'flatpage.html')
return render_template(template, page=page)
@app.before_request
def check_for_ban():
if current_user.is_authenticated:
if current_user.rank == models.UserRank.BANNED:
flash("You have been banned.", "error")
logout_user()
return redirect(url_for('user.login'))
elif current_user.rank == models.UserRank.NOT_JOINED:
current_user.rank = models.UserRank.MEMBER
models.db.session.commit()

View File

@@ -0,0 +1,10 @@
import os, importlib
def create_blueprints(app):
dir = os.path.dirname(os.path.realpath(__file__))
modules = next(os.walk(dir))[1]
for modname in modules:
if all(c.islower() for c in modname):
module = importlib.import_module("." + modname, __name__)
app.register_blueprint(module.bp)

View File

@@ -15,4 +15,8 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from . import admin, licenseseditor, tagseditor, versioneditor, todo
from flask import Blueprint
bp = Blueprint("admin", __name__)
from . import admin, licenseseditor, tagseditor, versioneditor

View File

@@ -18,17 +18,17 @@
from flask import *
from flask_user import *
import flask_menu as menu
from app import app
from . import bp
from app.models import *
from celery import uuid
from app.tasks.importtasks import importRepoScreenshot, importAllDependencies, makeVCSRelease
from celery import uuid, group
from app.tasks.importtasks import importRepoScreenshot, importAllDependencies, makeVCSRelease, checkZipRelease
from app.tasks.forumtasks import importTopicList, checkAllForumAccounts
from flask_wtf import FlaskForm
from wtforms import *
from app.utils import loginUser, rank_required, triggerNotif
import datetime
@app.route("/admin/", methods=["GET", "POST"])
@bp.route("/admin/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN)
def admin_page():
if request.method == "POST":
@@ -36,13 +36,28 @@ def admin_page():
if action == "delstuckreleases":
PackageRelease.query.filter(PackageRelease.task_id != None).delete()
db.session.commit()
return redirect(url_for("admin_page"))
return redirect(url_for("admin.admin_page"))
elif action == "checkreleases":
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
tasks = []
for release in releases:
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
tasks.append(checkZipRelease.s(release.id, zippath))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view"))
elif action == "importmodlist":
task = importTopicList.delay()
return redirect(url_for("check_task", id=task.id, r=url_for("todo_topics_page")))
return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
elif action == "checkusers":
task = checkAllForumAccounts.delay()
return redirect(url_for("check_task", id=task.id, r=url_for("admin_page")))
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
elif action == "importscreenshots":
packages = Package.query \
.filter_by(soft_deleted=False) \
@@ -52,7 +67,7 @@ def admin_page():
for package in packages:
importRepoScreenshot.delay(package.id)
return redirect(url_for("admin_page"))
return redirect(url_for("admin.admin_page"))
elif action == "restore":
package = Package.query.get(request.form["package"])
if package is None:
@@ -60,10 +75,10 @@ def admin_page():
else:
package.soft_deleted = False
db.session.commit()
return redirect(url_for("admin_page"))
return redirect(url_for("admin.admin_page"))
elif action == "importdepends":
task = importAllDependencies.delay()
return redirect(url_for("check_task", id=task.id, r=url_for("admin_page")))
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
elif action == "modprovides":
packages = Package.query.filter_by(type=PackageType.MOD).all()
mpackage_cache = {}
@@ -72,13 +87,13 @@ def admin_page():
p.provides.append(MetaPackage.GetOrCreate(p.name, mpackage_cache))
db.session.commit()
return redirect(url_for("admin_page"))
return redirect(url_for("admin.admin_page"))
elif action == "recalcscores":
for p in Package.query.all():
p.recalcScore()
p.setStartScore()
db.session.commit()
return redirect(url_for("admin_page"))
return redirect(url_for("admin.admin_page"))
elif action == "vcsrelease":
for package in Package.query.filter(Package.repo.isnot(None)).all():
if package.releases.count() != 0:
@@ -110,19 +125,19 @@ class SwitchUserForm(FlaskForm):
submit = SubmitField("Switch")
@app.route("/admin/switchuser/", methods=["GET", "POST"])
@bp.route("/admin/switchuser/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN)
def switch_user_page():
def switch_user():
form = SwitchUserForm(formdata=request.form)
if request.method == "POST" and form.validate():
user = User.query.filter_by(username=form["username"].data).first()
if user is None:
flash("Unable to find user", "error")
elif loginUser(user):
return redirect(url_for("user_profile_page", username=current_user.username))
return redirect(url_for("users.profile", username=current_user.username))
else:
flash("Unable to login as user", "error")
# Process GET or invalid POST
return render_template("admin/switch_user_page.html", form=form)
return render_template("admin/switch_user.html", form=form)

View File

@@ -17,16 +17,16 @@
from flask import *
from flask_user import *
from app import app
from . import bp
from app.models import *
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from app.utils import rank_required
@app.route("/licenses/")
@bp.route("/licenses/")
@rank_required(UserRank.MODERATOR)
def license_list_page():
def license_list():
return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all())
class LicenseForm(FlaskForm):
@@ -34,10 +34,10 @@ class LicenseForm(FlaskForm):
is_foss = BooleanField("Is FOSS")
submit = SubmitField("Save")
@app.route("/licenses/new/", methods=["GET", "POST"])
@app.route("/licenses/<name>/edit/", methods=["GET", "POST"])
@bp.route("/licenses/new/", methods=["GET", "POST"])
@bp.route("/licenses/<name>/edit/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def createedit_license_page(name=None):
def create_edit_license(name=None):
license = None
if name is not None:
license = License.query.filter_by(name=name).first()
@@ -57,6 +57,6 @@ def createedit_license_page(name=None):
form.populate_obj(license)
db.session.commit()
return redirect(url_for("license_list_page"))
return redirect(url_for("admin.license_list"))
return render_template("admin/licenses/edit.html", license=license, form=form)

View File

@@ -17,16 +17,16 @@
from flask import *
from flask_user import *
from app import app
from . import bp
from app.models import *
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from app.utils import rank_required
@app.route("/tags/")
@bp.route("/tags/")
@rank_required(UserRank.MODERATOR)
def tag_list_page():
def tag_list():
return render_template("admin/tags/list.html", tags=Tag.query.order_by(db.asc(Tag.title)).all())
class TagForm(FlaskForm):
@@ -34,10 +34,10 @@ class TagForm(FlaskForm):
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
submit = SubmitField("Save")
@app.route("/tags/new/", methods=["GET", "POST"])
@app.route("/tags/<name>/edit/", methods=["GET", "POST"])
@bp.route("/tags/new/", methods=["GET", "POST"])
@bp.route("/tags/<name>/edit/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def createedit_tag_page(name=None):
def create_edit_tag(name=None):
tag = None
if name is not None:
tag = Tag.query.filter_by(name=name).first()
@@ -52,6 +52,6 @@ def createedit_tag_page(name=None):
else:
form.populate_obj(tag)
db.session.commit()
return redirect(url_for("createedit_tag_page", name=tag.name))
return redirect(url_for("admin.create_edit_tag", name=tag.name))
return render_template("admin/tags/edit.html", tag=tag, form=form)

View File

@@ -17,16 +17,16 @@
from flask import *
from flask_user import *
from app import app
from . import bp
from app.models import *
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from app.utils import rank_required
@app.route("/versions/")
@bp.route("/versions/")
@rank_required(UserRank.MODERATOR)
def version_list_page():
def version_list():
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
class VersionForm(FlaskForm):
@@ -34,10 +34,10 @@ class VersionForm(FlaskForm):
protocol = IntegerField("Protocol")
submit = SubmitField("Save")
@app.route("/versions/new/", methods=["GET", "POST"])
@app.route("/versions/<name>/edit/", methods=["GET", "POST"])
@bp.route("/versions/new/", methods=["GET", "POST"])
@bp.route("/versions/<name>/edit/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def createedit_version_page(name=None):
def create_edit_version(name=None):
version = None
if name is not None:
version = MinetestRelease.query.filter_by(name=name).first()
@@ -55,6 +55,6 @@ def createedit_version_page(name=None):
form.populate_obj(version)
db.session.commit()
return redirect(url_for("version_list_page"))
return redirect(url_for("admin.version_list"))
return render_template("admin/versions/edit.html", version=version, form=form)

View File

@@ -14,5 +14,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint
from . import users, githublogin, notifications
bp = Blueprint("api", __name__)
from . import tokens, endpoints

View File

@@ -0,0 +1,42 @@
# Content DB
# Copyright (C) 2019 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import request, make_response, jsonify, abort
from app.models import APIToken
from functools import wraps
def is_api_authd(f):
@wraps(f)
def decorated_function(*args, **kwargs):
token = None
value = request.headers.get("authorization")
if value is None:
pass
elif value[0:7].lower() == "bearer ":
access_token = value[7:]
if len(access_token) < 10:
abort(400)
token = APIToken.query.filter_by(access_token=access_token).first()
if token is None:
abort(403)
else:
abort(403)
return f(token=token, *args, **kwargs)
return decorated_function

View File

@@ -17,31 +17,32 @@
from flask import *
from flask_user import *
from app import app
from . import bp
from .auth import is_api_authd
from app.models import *
from app.utils import is_package_page
from app.querybuilder import QueryBuilder
@app.route("/api/packages/")
def api_packages_page():
@bp.route("/api/packages/")
def packages():
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
ver = qb.getMinetestVersion()
pkgs = [package.getAsDictionaryShort(app.config["BASE_URL"], version=ver) \
pkgs = [package.getAsDictionaryShort(current_app.config["BASE_URL"], version=ver) \
for package in query.all()]
return jsonify(pkgs)
@app.route("/api/packages/<author>/<name>/")
@bp.route("/api/packages/<author>/<name>/")
@is_package_page
def api_package_page(package):
return jsonify(package.getAsDictionary(app.config["BASE_URL"]))
def package(package):
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
@app.route("/api/packages/<author>/<name>/dependencies/")
@bp.route("/api/packages/<author>/<name>/dependencies/")
@is_package_page
def api_package_deps_page(package):
def package_dependencies(package):
ret = []
for dep in package.dependencies:
@@ -68,14 +69,14 @@ def api_package_deps_page(package):
return jsonify(ret)
@app.route("/api/topics/")
def api_topics_page():
@bp.route("/api/topics/")
def topics():
qb = QueryBuilder(request.args)
query = qb.buildTopicQuery(show_added=True)
return jsonify([t.getAsDictionary() for t in query.all()])
@app.route("/api/topic_discard/", methods=["POST"])
@bp.route("/api/topic_discard/", methods=["POST"])
@login_required
def topic_set_discard():
tid = request.args.get("tid")
@@ -93,7 +94,16 @@ def topic_set_discard():
return jsonify(topic.getAsDictionary())
@app.route("/api/minetest_versions/")
def api_minetest_versions_page():
@bp.route("/api/minetest_versions/")
def versions():
return jsonify([{ "name": rel.name, "protocol_version": rel.protocol }\
for rel in MinetestRelease.query.all() if rel.getActual() is not None])
@bp.route("/api/whoami/")
@is_api_authd
def whoami(token):
if token is None:
return jsonify({ "is_authenticated": False, "username": None })
else:
return jsonify({ "is_authenticated": True, "username": token.owner.username })

View File

@@ -0,0 +1,141 @@
# Content DB
# Copyright (C) 2018 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import render_template, redirect, request, session, url_for, abort
from flask_user import login_required, current_user
from . import bp
from app.models import db, User, APIToken, Package, Permission
from app.utils import randomString
from app.querybuilder import QueryBuilder
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField
class CreateAPIToken(FlaskForm):
name = StringField("Name", [InputRequired(), Length(1, 30)])
submit = SubmitField("Save")
@bp.route("/users/<username>/tokens/")
@login_required
def list_tokens(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403)
return render_template("api/list_tokens.html", user=user)
@bp.route("/users/<username>/tokens/new/", methods=["GET", "POST"])
@bp.route("/users/<username>/tokens/<int:id>/edit/", methods=["GET", "POST"])
@login_required
def create_edit_token(username, id=None):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403)
is_new = id is None
token = None
access_token = None
if not is_new:
token = APIToken.query.get(id)
if token is None:
abort(404)
elif token.owner != user:
abort(403)
access_token = session.pop("token_" + str(id), None)
form = CreateAPIToken(formdata=request.form, obj=token)
if request.method == "POST" and form.validate():
if is_new:
token = APIToken()
token.owner = user
token.access_token = randomString(32)
form.populate_obj(token)
db.session.add(token)
db.session.commit() # save
# Store token so it can be shown in the edit page
session["token_" + str(token.id)] = token.access_token
return redirect(url_for("api.create_edit_token", username=username, id=token.id))
return render_template("api/create_edit_token.html", user=user, form=form, token=token, access_token=access_token)
@bp.route("/users/<username>/tokens/<int:id>/reset/", methods=["POST"])
@login_required
def reset_token(username, id):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403)
is_new = id is None
token = APIToken.query.get(id)
if token is None:
abort(404)
elif token.owner != user:
abort(403)
token.access_token = randomString(32)
db.session.commit() # save
# Store token so it can be shown in the edit page
session["token_" + str(token.id)] = token.access_token
return redirect(url_for("api.create_edit_token", username=username, id=token.id))
@bp.route("/users/<username>/tokens/<int:id>/delete/", methods=["POST"])
@login_required
def delete_token(username, id):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403)
is_new = id is None
token = APIToken.query.get(id)
if token is None:
abort(404)
elif token.owner != user:
abort(403)
db.session.delete(token)
db.session.commit()
return redirect(url_for("api.list_tokens", username=username))

View File

@@ -0,0 +1,21 @@
from flask import Blueprint, render_template
bp = Blueprint("homepage", __name__)
from app.models import *
import flask_menu as menu
from sqlalchemy.sql.expression import func
@bp.route("/")
@menu.register_menu(bp, ".", "Home")
def home():
query = Package.query.filter_by(approved=True, soft_deleted=False)
count = query.count()
new = query.order_by(db.desc(Package.created_at)).limit(8).all()
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(4).all()
pop_txp = query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(4).all()
downloads_result = db.session.query(func.sum(PackageRelease.downloads)).one_or_none()
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
return render_template("index.html", count=count, downloads=downloads, \
new=new, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam)

View File

@@ -16,17 +16,19 @@
from flask import *
bp = Blueprint("metapackages", __name__)
from flask_user import *
from app import app
from app.models import *
@app.route("/metapackages/")
def meta_package_list_page():
@bp.route("/metapackages/")
def list_all():
mpackages = MetaPackage.query.order_by(db.asc(MetaPackage.name)).all()
return render_template("meta/list.html", mpackages=mpackages)
@app.route("/metapackages/<name>/")
def meta_package_page(name):
@bp.route("/metapackages/<name>/")
def view(name):
mpackage = MetaPackage.query.filter_by(name=name).first()
if mpackage is None:
abort(404)

View File

@@ -15,19 +15,20 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask import Blueprint, render_template, redirect, url_for
from flask_user import current_user, login_required
from app import app
from app.models import *
from app.models import db
@app.route("/notifications/")
bp = Blueprint("notifications", __name__)
@bp.route("/notifications/")
@login_required
def notifications_page():
def list_all():
return render_template("notifications/list.html")
@app.route("/notifications/clear/", methods=["POST"])
@bp.route("/notifications/clear/", methods=["POST"])
@login_required
def clear_notifications_page():
def clear():
current_user.notifications.clear()
db.session.commit()
return redirect(url_for("notifications_page"))
return redirect(url_for("notifications.list_all"))

View File

@@ -14,5 +14,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint
bp = Blueprint("packages", __name__)
from . import packages, screenshots, releases

View File

@@ -14,7 +14,6 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask_user import *
from app import app

View File

@@ -18,11 +18,14 @@
from flask import render_template, abort, request, redirect, url_for, flash
from flask_user import current_user
import flask_menu as menu
from app import app
from . import bp
from app.models import *
from app.querybuilder import QueryBuilder
from app.tasks.importtasks import importRepoScreenshot
from app.utils import *
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
@@ -30,12 +33,12 @@ from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleF
from sqlalchemy import or_
@menu.register_menu(app, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' })
@menu.register_menu(app, ".games", "Games", order=12, endpoint_arguments_constructor=lambda: { 'type': 'game' })
@menu.register_menu(app, ".txp", "Texture Packs", order=13, endpoint_arguments_constructor=lambda: { 'type': 'txp' })
@menu.register_menu(app, ".random", "Random", order=14, endpoint_arguments_constructor=lambda: { 'random': '1' })
@app.route("/packages/")
def packages_page():
@menu.register_menu(bp, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' })
@menu.register_menu(bp, ".games", "Games", order=12, endpoint_arguments_constructor=lambda: { 'type': 'game' })
@menu.register_menu(bp, ".txp", "Texture Packs", order=13, endpoint_arguments_constructor=lambda: { 'type': 'txp' })
@menu.register_menu(bp, ".random", "Random", order=14, endpoint_arguments_constructor=lambda: { 'random': '1', 'lucky': '1' })
@bp.route("/packages/")
def list_all():
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
title = qb.title
@@ -49,16 +52,16 @@ def packages_page():
if qb.search and topic:
return redirect("https://forum.minetest.net/viewtopic.php?t=" + str(topic.topic_id))
page = int(request.args.get("page") or 1)
num = min(40, int(request.args.get("n") or 100))
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
query = query.paginate(page, num, True)
search = request.args.get("q")
type_name = request.args.get("type")
next_url = url_for("packages_page", type=type_name, q=search, page=query.next_num) \
next_url = url_for("packages.list_all", type=type_name, q=search, page=query.next_num) \
if query.has_next else None
prev_url = url_for("packages_page", type=type_name, q=search, page=query.prev_num) \
prev_url = url_for("packages.list_all", type=type_name, q=search, page=query.prev_num) \
if query.has_prev else None
topics = None
@@ -79,9 +82,9 @@ def getReleases(package):
return package.releases.filter_by(approved=True).limit(5)
@app.route("/packages/<author>/<name>/")
@bp.route("/packages/<author>/<name>/")
@is_package_page
def package_page(package):
def view(package):
clearNotifications(package.getDetailsURL())
alternatives = None
@@ -147,9 +150,9 @@ def package_page(package):
threads=threads.all())
@app.route("/packages/<author>/<name>/download/")
@bp.route("/packages/<author>/<name>/download/")
@is_package_page
def package_download_page(package):
def download(package):
release = package.getDownloadRelease()
if release is None:
@@ -186,10 +189,10 @@ class PackageForm(FlaskForm):
forums = IntegerField("Forum Topic ID", [Optional(), NumberRange(0,999999)])
submit = SubmitField("Save")
@app.route("/packages/new/", methods=["GET", "POST"])
@app.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
@bp.route("/packages/new/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
@login_required
def create_edit_package_page(author=None, name=None):
def create_edit(author=None, name=None):
package = None
form = None
if author is None:
@@ -201,11 +204,11 @@ def create_edit_package_page(author=None, name=None):
author = User.query.filter_by(username=author).first()
if author is None:
flash("Unable to find that user", "error")
return redirect(url_for("create_edit_package_page"))
return redirect(url_for("packages.create_edit"))
if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
flash("Permission denied", "error")
return redirect(url_for("create_edit_package_page"))
return redirect(url_for("packages.create_edit"))
else:
package = getPackageByInfo(author, name)
@@ -238,7 +241,7 @@ def create_edit_package_page(author=None, name=None):
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
else:
flash("Package already exists!", "error")
return redirect(url_for("create_edit_package_page"))
return redirect(url_for("packages.create_edit"))
package = Package()
package.author = author
@@ -247,7 +250,7 @@ def create_edit_package_page(author=None, name=None):
elif package.approved and package.name != form.name.data and \
not package.checkPerm(current_user, Permission.CHANGE_NAME):
flash("Unable to change package name", "danger")
return redirect(url_for("create_edit_package_page", author=author, name=name))
return redirect(url_for("packages.create_edit", author=author, name=name))
else:
triggerNotif(package.author, current_user,
@@ -288,7 +291,7 @@ def create_edit_package_page(author=None, name=None):
next_url = package.getDetailsURL()
if wasNew and package.repo is not None:
task = importRepoScreenshot.delay(package.id)
next_url = url_for("check_task", id=task.id, r=next_url)
next_url = url_for("tasks.check", id=task.id, r=next_url)
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
@@ -305,10 +308,10 @@ def create_edit_package_page(author=None, name=None):
packages=package_query.all(), \
mpackages=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all())
@app.route("/packages/<author>/<name>/approve/", methods=["POST"])
@bp.route("/packages/<author>/<name>/approve/", methods=["POST"])
@login_required
@is_package_page
def approve_package_page(package):
def approve(package):
if not package.checkPerm(current_user, Permission.APPROVE_NEW):
flash("You don't have permission to do that.", "error")
@@ -329,10 +332,10 @@ def approve_package_page(package):
return redirect(package.getDetailsURL())
@app.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
@login_required
@is_package_page
def remove_package_page(package):
def remove(package):
if request.method == "GET":
return render_template("packages/remove.html", package=package)
@@ -343,7 +346,7 @@ def remove_package_page(package):
package.soft_deleted = True
url = url_for("user_profile_page", username=package.author.username)
url = url_for("users.profile", username=package.author.username)
triggerNotif(package.author, current_user,
"{} deleted".format(package.title), url)
db.session.commit()

View File

@@ -17,10 +17,12 @@
from flask import *
from flask_user import *
from app import app
from app.models import *
from app.tasks.importtasks import makeVCSRelease
from . import bp
from app.rediscache import has_key, set_key, make_download_key
from app.models import *
from app.tasks.importtasks import makeVCSRelease, checkZipRelease
from app.utils import *
from celery import uuid
@@ -62,10 +64,10 @@ class EditPackageReleaseForm(FlaskForm):
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
submit = SubmitField("Save")
@app.route("/packages/<author>/<name>/releases/new/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/releases/new/", methods=["GET", "POST"])
@login_required
@is_package_page
def create_release_page(package):
def create_release(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.getDetailsURL())
@@ -94,53 +96,63 @@ def create_release_page(package):
triggerNotif(package.author, current_user, msg, rel.getEditURL())
db.session.commit()
return redirect(url_for("check_task", id=rel.task_id, r=rel.getEditURL()))
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL()))
else:
uploadedPath = doFileUpload(form.fileUpload.data, "zip", "a zip file")
if uploadedPath is not None:
uploadedUrl, uploadedPath = doFileUpload(form.fileUpload.data, "zip", "a zip file")
if uploadedUrl is not None:
rel = PackageRelease()
rel.package = package
rel.title = form["title"].data
rel.url = uploadedPath
rel.url = uploadedUrl
rel.task_id = uuid()
rel.min_rel = form["min_rel"].data.getActual()
rel.max_rel = form["max_rel"].data.getActual()
rel.approve(current_user)
db.session.add(rel)
db.session.commit()
checkZipRelease.apply_async((rel.id, uploadedPath), task_id=rel.task_id)
msg = "{}: Release {} created".format(package.title, rel.title)
triggerNotif(package.author, current_user, msg, rel.getEditURL())
db.session.commit()
return redirect(package.getDetailsURL())
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL()))
return render_template("packages/release_new.html", package=package, form=form)
@app.route("/packages/<author>/<name>/releases/<id>/download/")
@bp.route("/packages/<author>/<name>/releases/<id>/download/")
@is_package_page
def download_release_page(package, id):
def download_release(package, id):
release = PackageRelease.query.get(id)
if release is None or release.package != package:
abort(404)
if release is None:
if "application/zip" in request.accept_mimetypes and \
not "text/html" in request.accept_mimetypes:
return "", 204
else:
flash("No download available.", "error")
return redirect(package.getDetailsURL())
else:
PackageRelease.query.filter_by(id=release.id).update({
"downloads": PackageRelease.downloads + 1
})
db.session.commit()
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
if ip is not None:
key = make_download_key(ip, release.package)
if not has_key(key):
set_key(key, "true")
return redirect(release.url, code=300)
bonus = 1
if not package.getIsFOSS():
bonus *= 0.1
@app.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"])
PackageRelease.query.filter_by(id=release.id).update({
"downloads": PackageRelease.downloads + 1
})
Package.query.filter_by(id=package.id).update({
"score": Package.score + bonus
})
db.session.commit()
return redirect(release.url, code=300)
@bp.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"])
@login_required
@is_package_page
def edit_release_page(package, id):
def edit_release(package, id):
release = PackageRelease.query.get(id)
if release is None or release.package != package:
abort(404)
@@ -154,6 +166,11 @@ def edit_release_page(package, id):
# Initial form class from post data and default data
form = EditPackageReleaseForm(formdata=request.form, obj=release)
# HACK: fix bug in wtforms
if request.method == "GET":
form.approved.data = release.approved
if request.method == "POST" and form.validate():
wasApproved = release.approved
if canEdit:
@@ -190,10 +207,10 @@ class BulkReleaseForm(FlaskForm):
submit = SubmitField("Update")
@app.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"])
@login_required
@is_package_page
def bulk_change_release_page(package):
def bulk_change_release(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.getDetailsURL())
@@ -216,3 +233,20 @@ def bulk_change_release_page(package):
return redirect(package.getDetailsURL())
return render_template("packages/release_bulk_change.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/releases/<id>/delete/", methods=["POST"])
@login_required
@is_package_page
def delete_release(package, id):
release = PackageRelease.query.get(id)
if release is None or release.package != package:
abort(404)
if not release.checkPerm(current_user, Permission.DELETE_RELEASE):
return redirect(release.getEditURL())
db.session.delete(release)
db.session.commit()
return redirect(package.getDetailsURL())

View File

@@ -17,9 +17,10 @@
from flask import *
from flask_user import *
from app import app
from app.models import *
from . import bp
from app.models import *
from app.utils import *
from flask_wtf import FlaskForm
@@ -39,23 +40,23 @@ class EditScreenshotForm(FlaskForm):
delete = BooleanField("Delete")
submit = SubmitField("Save")
@app.route("/packages/<author>/<name>/screenshots/new/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/screenshots/new/", methods=["GET", "POST"])
@login_required
@is_package_page
def create_screenshot_page(package, id=None):
def create_screenshot(package, id=None):
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.getDetailsURL())
# Initial form class from post data and default data
form = CreateScreenshotForm()
if request.method == "POST" and form.validate():
uploadedPath = doFileUpload(form.fileUpload.data, "image",
uploadedUrl, uploadedPath = doFileUpload(form.fileUpload.data, "image",
"a PNG or JPG image file")
if uploadedPath is not None:
if uploadedUrl is not None:
ss = PackageScreenshot()
ss.package = package
ss.title = form["title"].data or "Untitled"
ss.url = uploadedPath
ss.url = uploadedUrl
ss.approved = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
db.session.add(ss)
@@ -67,10 +68,10 @@ def create_screenshot_page(package, id=None):
return render_template("packages/screenshot_new.html", package=package, form=form)
@app.route("/packages/<author>/<name>/screenshots/<id>/edit/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/screenshots/<id>/edit/", methods=["GET", "POST"])
@login_required
@is_package_page
def edit_screenshot_page(package, id):
def edit_screenshot(package, id):
screenshot = PackageScreenshot.query.get(id)
if screenshot is None or screenshot.package != package:
abort(404)

View File

@@ -18,28 +18,28 @@
from flask import *
from flask_user import *
import flask_menu as menu
from app import app, csrf
from app import csrf
from app.models import *
from app.tasks import celery, TaskError
from app.tasks.importtasks import getMeta
from app.utils import shouldReturnJson
# from celery.result import AsyncResult
from app.utils import *
bp = Blueprint("tasks", __name__)
@csrf.exempt
@app.route("/tasks/getmeta/new/", methods=["POST"])
@bp.route("/tasks/getmeta/new/", methods=["POST"])
@login_required
def new_getmeta_page():
def start_getmeta():
author = request.args.get("author")
author = current_user.forums_username if author is None else author
aresult = getMeta.delay(request.args.get("url"), author)
return jsonify({
"poll_url": url_for("check_task", id=aresult.id),
"poll_url": url_for("tasks.check", id=aresult.id),
})
@app.route("/tasks/<id>/")
def check_task(id):
@bp.route("/tasks/<id>/")
def check(id):
result = celery.AsyncResult(id)
status = result.status
traceback = result.traceback

View File

@@ -16,8 +16,10 @@
from flask import *
bp = Blueprint("threads", __name__)
from flask_user import *
from app import app
from app.models import *
from app.utils import triggerNotif, clearNotifications
@@ -27,17 +29,17 @@ from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
@app.route("/threads/")
def threads_page():
@bp.route("/threads/")
def list_all():
query = Thread.query
if not Permission.SEE_THREAD.check(current_user):
query = query.filter_by(private=False)
return render_template("threads/list.html", threads=query.all())
@app.route("/threads/<int:id>/subscribe/", methods=["POST"])
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
@login_required
def thread_subscribe_page(id):
def subscribe(id):
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
abort(404)
@@ -49,12 +51,12 @@ def thread_subscribe_page(id):
thread.watchers.append(current_user)
db.session.commit()
return redirect(url_for("thread_page", id=id))
return redirect(url_for("threads.view", id=id))
@app.route("/threads/<int:id>/unsubscribe/", methods=["POST"])
@bp.route("/threads/<int:id>/unsubscribe/", methods=["POST"])
@login_required
def thread_unsubscribe_page(id):
def unsubscribe(id):
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
abort(404)
@@ -66,12 +68,12 @@ def thread_unsubscribe_page(id):
else:
flash("Not subscribed to thread", "success")
return redirect(url_for("thread_page", id=id))
return redirect(url_for("threads.view", id=id))
@app.route("/threads/<int:id>/", methods=["GET", "POST"])
def thread_page(id):
clearNotifications(url_for("thread_page", id=id))
@bp.route("/threads/<int:id>/", methods=["GET", "POST"])
def view(id):
clearNotifications(url_for("threads.view", id=id))
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
@@ -85,7 +87,7 @@ def thread_page(id):
if package:
return redirect(package.getDetailsURL())
else:
return redirect(url_for("home_page"))
return redirect(url_for("homepage.home"))
if len(comment) <= 500 and len(comment) > 3:
reply = ThreadReply()
@@ -106,11 +108,11 @@ def thread_page(id):
for user in thread.watchers:
if user != current_user:
triggerNotif(user, current_user, msg, url_for("thread_page", id=thread.id))
triggerNotif(user, current_user, msg, url_for("threads.view", id=thread.id))
db.session.commit()
return redirect(url_for("thread_page", id=id))
return redirect(url_for("threads.view", id=id))
else:
flash("Comment needs to be between 3 and 500 characters.")
@@ -124,9 +126,9 @@ class ThreadForm(FlaskForm):
private = BooleanField("Private")
submit = SubmitField("Open Thread")
@app.route("/threads/new/", methods=["GET", "POST"])
@bp.route("/threads/new/", methods=["GET", "POST"])
@login_required
def new_thread_page():
def new():
form = ThreadForm(formdata=request.form)
package = None
@@ -148,12 +150,12 @@ def new_thread_page():
# Check that user can make the thread
if not package.checkPerm(current_user, Permission.CREATE_THREAD):
flash("Unable to create thread!", "error")
return redirect(url_for("home_page"))
return redirect(url_for("homepage.home"))
# Only allow creating one thread when not approved
elif is_review_thread and package.review_thread is not None:
flash("A review thread already exists!", "error")
return redirect(url_for("thread_page", id=package.review_thread.id))
return redirect(url_for("threads.view", id=package.review_thread.id))
elif not current_user.canOpenThreadRL():
flash("Please wait before opening another thread", "danger")
@@ -161,7 +163,7 @@ def new_thread_page():
if package:
return redirect(package.getDetailsURL())
else:
return redirect(url_for("home_page"))
return redirect(url_for("homepage.home"))
# Set default values
elif request.method == "GET":
@@ -197,16 +199,16 @@ def new_thread_page():
notif_msg = None
if package is not None:
notif_msg = "New thread '{}' on package {}".format(thread.title, package.title)
triggerNotif(package.author, current_user, notif_msg, url_for("thread_page", id=thread.id))
triggerNotif(package.author, current_user, notif_msg, url_for("threads.view", id=thread.id))
else:
notif_msg = "New thread '{}'".format(thread.title)
for user in User.query.filter(User.rank >= UserRank.EDITOR).all():
triggerNotif(user, current_user, notif_msg, url_for("thread_page", id=thread.id))
triggerNotif(user, current_user, notif_msg, url_for("threads.view", id=thread.id))
db.session.commit()
return redirect(url_for("thread_page", id=thread.id))
return redirect(url_for("threads.view", id=thread.id))
return render_template("threads/new.html", form=form, allow_private_change=allow_change, package=package)

View File

@@ -16,7 +16,8 @@
from flask import *
from app import app
bp = Blueprint("thumbnails", __name__)
import os
from PIL import Image
@@ -24,10 +25,10 @@ from PIL import Image
ALLOWED_RESOLUTIONS=[(100,67), (270,180), (350,233)]
def mkdir(path):
assert path != "" and path is not None
if not os.path.isdir(path):
os.mkdir(path)
mkdir("app/public/thumbnails/")
def resize_and_crop(img_path, modified_path, size):
img = Image.open(img_path)
@@ -57,17 +58,22 @@ def resize_and_crop(img_path, modified_path, size):
img.save(modified_path)
@app.route("/thumbnails/<int:level>/<img>")
@bp.route("/thumbnails/<int:level>/<img>")
def make_thumbnail(img, level):
if level > len(ALLOWED_RESOLUTIONS) or level <= 0:
abort(403)
w, h = ALLOWED_RESOLUTIONS[level - 1]
mkdir("app/public/thumbnails/{:d}/".format(level))
upload_dir = current_app.config["UPLOAD_DIR"]
thumbnail_dir = current_app.config["THUMBNAIL_DIR"]
mkdir(thumbnail_dir)
cache_filepath = "public/thumbnails/{:d}/{}".format(level, img)
source_filepath = "public/uploads/" + img
output_dir = os.path.join(thumbnail_dir, str(level))
mkdir(output_dir)
resize_and_crop("app/" + source_filepath, "app/" + cache_filepath, (w, h))
cache_filepath = os.path.join(output_dir, img)
source_filepath = os.path.join(upload_dir, img)
resize_and_crop(source_filepath, cache_filepath, (w, h))
return send_file(cache_filepath)

View File

@@ -14,17 +14,18 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask_user import *
import flask_menu as menu
from app import app
from app.models import *
from app.querybuilder import QueryBuilder
from app.utils import get_int_or_abort
@app.route("/todo/", methods=["GET", "POST"])
bp = Blueprint("todo", __name__)
@bp.route("/todo/", methods=["GET", "POST"])
@login_required
def todo_page():
def view():
canApproveNew = Permission.APPROVE_NEW.check(current_user)
canApproveRel = Permission.APPROVE_RELEASE.check(current_user)
canApproveScn = Permission.APPROVE_SCREENSHOT.check(current_user)
@@ -51,7 +52,7 @@ def todo_page():
PackageScreenshot.query.update({ "approved": True })
db.session.commit()
return redirect(url_for("todo_page"))
return redirect(url_for("todo.view"))
else:
abort(400)
@@ -69,9 +70,9 @@ def todo_page():
topics_to_add=topics_to_add, total_topics=total_topics)
@app.route("/todo/topics/")
@bp.route("/todo/topics/")
@login_required
def todo_topics_page():
def topics():
qb = QueryBuilder(request.args)
qb.setSortIfNone("date")
query = qb.buildTopicQuery()
@@ -82,16 +83,16 @@ def todo_topics_page():
total = tmp_q.count()
topic_count = query.count()
page = int(request.args.get("page") or 1)
num = int(request.args.get("n") or 100)
page = get_int_or_abort(request.args.get("page"), 1)
num = get_int_or_abort(request.args.get("n"), 100)
if num > 100 and not current_user.rank.atLeast(UserRank.EDITOR):
num = 100
query = query.paginate(page, num, True)
next_url = url_for("todo_topics_page", page=query.next_num, query=qb.search, \
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", page=query.prev_num, query=qb.search, \
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

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint("users", __name__)
from . import githublogin, profile

View File

@@ -21,15 +21,16 @@ from flask_login import login_user, logout_user
from sqlalchemy import func
import flask_menu as menu
from flask_github import GitHub
from app import app, github
from . import bp
from app import github
from app.models import *
from app.utils import loginUser
@app.route("/user/github/start/")
def github_signin_page():
@bp.route("/user/github/start/")
def github_signin():
return github.authorize("")
@app.route("/user/github/callback/")
@bp.route("/user/github/callback/")
@github.authorized_handler
def github_authorized(oauth_token):
next_url = request.args.get("next")
@@ -53,21 +54,21 @@ def github_authorized(oauth_token):
current_user.github_username = username
db.session.commit()
flash("Linked github to account", "success")
return redirect(url_for("home_page"))
return redirect(url_for("homepage.home"))
else:
flash("Github account is already associated with another user", "danger")
return redirect(url_for("home_page"))
return redirect(url_for("homepage.home"))
# If not logged in, log in
else:
if userByGithub is None:
flash("Unable to find an account for that Github user", "error")
return redirect(url_for("user_claim_page"))
return redirect(url_for("users.claim"))
elif loginUser(userByGithub):
if current_user.password is None:
return redirect(next_url or url_for("set_password_page", optional=True))
if not current_user.hasPassword():
return redirect(next_url or url_for("users.set_password", optional=True))
else:
return redirect(next_url or url_for("home_page"))
return redirect(next_url or url_for("homepage.home"))
else:
flash("Authorization failed [err=gh-login-failed]", "danger")
return redirect(url_for("user.login"))

View File

@@ -18,7 +18,8 @@
from flask import *
from flask_user import *
from flask_login import login_user, logout_user
from app import app, markdown
from app import markdown
from . import bp
from app.models import *
from flask_wtf import FlaskForm
from wtforms import *
@@ -38,14 +39,14 @@ class UserProfileForm(FlaskForm):
submit = SubmitField("Save")
@app.route("/users/", methods=["GET"])
def user_list_page():
@bp.route("/users/", methods=["GET"])
def list_all():
users = User.query.order_by(db.desc(User.rank), db.asc(User.display_name)).all()
return render_template("users/list.html", users=users)
@app.route("/users/<username>/", methods=["GET", "POST"])
def user_profile_page(username):
@bp.route("/users/<username>/", methods=["GET", "POST"])
def profile(username):
user = User.query.filter_by(username=username).first()
if not user:
abort(404)
@@ -85,13 +86,13 @@ def user_profile_page(username):
db.session.commit()
task = sendVerifyEmail.delay(newEmail, token)
return redirect(url_for("check_task", id=task.id, r=url_for("user_profile_page", username=username)))
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.profile", username=username)))
# Save user_profile
db.session.commit()
# Redirect to home page
return redirect(url_for("user_profile_page", username=username))
return redirect(url_for("users.profile", username=username))
packages = user.packages.filter_by(soft_deleted=False)
if not current_user.is_authenticated or (user != current_user and not current_user.canAccessTodoList()):
@@ -107,11 +108,11 @@ def user_profile_page(username):
.all()
# Process GET or invalid POST
return render_template("users/user_profile_page.html",
return render_template("users/profile.html",
user=user, form=form, packages=packages, topics_to_add=topics_to_add)
@app.route("/users/<username>/check/", methods=["POST"])
@bp.route("/users/<username>/check/", methods=["POST"])
@login_required
def user_check(username):
user = User.query.filter_by(username=username).first()
@@ -125,9 +126,9 @@ def user_check(username):
abort(404)
task = checkForumAccount.delay(user.forums_username)
next_url = url_for("user_profile_page", username=username)
next_url = url_for("users.profile", username=username)
return redirect(url_for("check_task", id=task.id, r=next_url))
return redirect(url_for("tasks.check", id=task.id, r=next_url))
class SendEmailForm(FlaskForm):
@@ -136,14 +137,14 @@ class SendEmailForm(FlaskForm):
submit = SubmitField("Send")
@app.route("/users/<username>/email/", methods=["GET", "POST"])
@bp.route("/users/<username>/email/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def send_email_page(username):
def send_email(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
next_url = url_for("user_profile_page", username=user.username)
next_url = url_for("users.profile", username=user.username)
if user.email is None:
flash("User has no email address!", "error")
@@ -154,7 +155,7 @@ def send_email_page(username):
text = form.text.data
html = markdown(text)
task = sendEmailRaw.delay([user.email], form.subject.data, text, html)
return redirect(url_for("check_task", id=task.id, r=next_url))
return redirect(url_for("tasks.check", id=task.id, r=next_url))
return render_template("users/send_email.html", form=form)
@@ -166,10 +167,10 @@ class SetPasswordForm(FlaskForm):
password2 = PasswordField("Verify password", [InputRequired(), Length(2, 100)])
submit = SubmitField("Save")
@app.route("/user/set-password/", methods=["GET", "POST"])
@bp.route("/user/set-password/", methods=["GET", "POST"])
@login_required
def set_password_page():
if current_user.password is not None:
def set_password():
if current_user.hasPassword():
return redirect(url_for("user.change_password"))
form = SetPasswordForm(request.form)
@@ -184,10 +185,11 @@ def set_password_page():
hashed_password = user_manager.hash_password(form.password.data)
# Change password
user_manager.update_password(current_user, hashed_password)
current_user.password = hashed_password
db.session.commit()
# Send 'password_changed' email
if user_manager.enable_email and user_manager.send_password_changed_email and current_user.email:
if user_manager.USER_ENABLE_EMAIL and current_user.email:
emails.send_password_changed_email(current_user)
# Send password_changed signal
@@ -208,17 +210,17 @@ def set_password_page():
db.session.commit()
task = sendVerifyEmail.delay(newEmail, token)
return redirect(url_for("check_task", id=task.id, r=url_for("user_profile_page", username=current_user.username)))
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.profile", username=current_user.username)))
else:
return redirect(url_for("user_profile_page", username=current_user.username))
return redirect(url_for("user.login"))
else:
flash("Passwords do not match", "error")
return render_template("users/set_password.html", form=form, optional=request.args.get("optional"))
@app.route("/user/claim/", methods=["GET", "POST"])
def user_claim_page():
@bp.route("/user/claim/", methods=["GET", "POST"])
def claim():
username = request.args.get("username")
if username is None:
username = ""
@@ -227,16 +229,16 @@ def user_claim_page():
user = User.query.filter_by(forums_username=username).first()
if user and user.rank.atLeast(UserRank.NEW_MEMBER):
flash("User has already been claimed", "error")
return redirect(url_for("user_claim_page"))
return redirect(url_for("users.claim"))
elif user is None and method == "github":
flash("Unable to get Github username for user", "error")
return redirect(url_for("user_claim_page"))
return redirect(url_for("users.claim"))
elif user is None:
flash("Unable to find that user", "error")
return redirect(url_for("user_claim_page"))
return redirect(url_for("users.claim"))
if user is not None and method == "github":
return redirect(url_for("github_signin_page"))
return redirect(url_for("users.github_signin"))
token = None
if "forum_token" in session:
@@ -253,12 +255,12 @@ def user_claim_page():
flash("Invalid username", "error")
elif ctype == "github":
task = checkForumAccount.delay(username)
return redirect(url_for("check_task", id=task.id, r=url_for("user_claim_page", username=username, method="github")))
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim", username=username, method="github")))
elif ctype == "forum":
user = User.query.filter_by(forums_username=username).first()
if user is not None and user.rank.atLeast(UserRank.NEW_MEMBER):
flash("That user has already been claimed!", "error")
return redirect(url_for("user_claim_page"))
return redirect(url_for("users.claim"))
# Get signature
sig = None
@@ -267,7 +269,7 @@ def user_claim_page():
sig = profile.signature
except IOError:
flash("Unable to get forum signature - does the user exist?", "error")
return redirect(url_for("user_claim_page", username=username))
return redirect(url_for("users.claim", username=username))
# Look for key
if token in sig:
@@ -278,21 +280,21 @@ def user_claim_page():
db.session.commit()
if loginUser(user):
return redirect(url_for("set_password_page"))
return redirect(url_for("users.set_password"))
else:
flash("Unable to login as user", "error")
return redirect(url_for("user_claim_page", username=username))
return redirect(url_for("users.claim", username=username))
else:
flash("Could not find the key in your signature!", "error")
return redirect(url_for("user_claim_page", username=username))
return redirect(url_for("users.claim", username=username))
else:
flash("Unknown claim type", "error")
return render_template("users/claim.html", username=username, key=token)
@app.route("/users/verify/")
def verify_email_page():
@bp.route("/users/verify/")
def verify_email():
token = request.args.get("token")
ver = UserEmailVerification.query.filter_by(token=token).first()
if ver is None:
@@ -303,6 +305,6 @@ def verify_email_page():
db.session.commit()
if current_user.is_authenticated:
return redirect(url_for("user_profile_page", username=current_user.username))
return redirect(url_for("users.profile", username=current_user.username))
else:
return redirect(url_for("home_page"))
return redirect(url_for("homepage.home"))

354
app/default_data.py Normal file
View File

@@ -0,0 +1,354 @@
from .models import *
from .utils import make_flask_user_password
def populate(session):
admin_user = User("rubenwardy")
admin_user.active = True
admin_user.password = make_flask_user_password("tuckfrump")
admin_user.github_username = "rubenwardy"
admin_user.forums_username = "rubenwardy"
admin_user.rank = UserRank.ADMIN
session.add(admin_user)
session.add(MinetestRelease("None", 0))
session.add(MinetestRelease("0.4.16/17", 32))
session.add(MinetestRelease("5.0", 37))
session.add(MinetestRelease("5.1", 38))
tags = {}
for tag in ["Inventory", "Mapgen", "Building", \
"Mobs and NPCs", "Tools", "Player effects", \
"Environment", "Transport", "Maintenance", "Plants and farming", \
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer"]:
row = Tag(tag)
tags[row.name] = row
session.add(row)
licenses = {}
for license in ["GPLv2.1", "GPLv3", "LGPLv2.1", "LGPLv3", "AGPLv2.1", "AGPLv3",
"Apache", "BSD 3-Clause", "BSD 2-Clause", "CC0", "CC-BY-SA",
"CC-BY", "MIT", "ZLib", "Other (Free)"]:
row = License(license)
licenses[row.name] = row
session.add(row)
for license in ["CC-BY-NC-SA", "Other (Non-free)"]:
row = License(license, False)
licenses[row.name] = row
session.add(row)
def populate_test_data(session):
licenses = { x.name : x for x in License.query.all() }
tags = { x.name : x for x in Tag.query.all() }
admin_user = User.query.filter_by(rank=UserRank.ADMIN).first()
v4 = MinetestRelease.query.filter_by(protocol=32).first()
v50 = MinetestRelease.query.filter_by(protocol=37).first()
v51 = MinetestRelease.query.filter_by(protocol=38).first()
ez = User("Shara")
ez.github_username = "Ezhh"
ez.forums_username = "Shara"
ez.rank = UserRank.EDITOR
session.add(ez)
not1 = Notification(admin_user, ez, "Awards approved", "/packages/rubenwardy/awards/")
session.add(not1)
jeija = User("Jeija")
jeija.github_username = "Jeija"
jeija.forums_username = "Jeija"
session.add(jeija)
mod = Package()
mod.approved = True
mod.name = "alpha"
mod.title = "Alpha Test"
mod.license = licenses["MIT"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.MOD
mod.author = admin_user
mod.tags.append(tags["mapgen"])
mod.tags.append(tags["environment"])
mod.repo = "https://github.com/ezhh/other_worlds"
mod.issueTracker = "https://github.com/ezhh/other_worlds/issues"
mod.forums = 16015
mod.short_desc = "The content library should not be used yet as it is still in alpha"
mod.desc = "This is the long desc"
session.add(mod)
rel = PackageRelease()
rel.package = mod
rel.title = "v1.0.0"
rel.url = "https://github.com/ezhh/handholds/archive/master.zip"
rel.approved = True
session.add(rel)
mod1 = Package()
mod1.approved = True
mod1.name = "awards"
mod1.title = "Awards"
mod1.license = licenses["LGPLv2.1"]
mod1.media_license = licenses["MIT"]
mod1.type = PackageType.MOD
mod1.author = admin_user
mod1.tags.append(tags["player_effects"])
mod1.repo = "https://github.com/rubenwardy/awards"
mod1.issueTracker = "https://github.com/rubenwardy/awards/issues"
mod1.forums = 4870
mod1.short_desc = "Adds achievements and an API to register new ones."
mod1.desc = """
Majority of awards are back ported from Calinou's old fork in Carbone, under same license.
```
awards.register_achievement("award_mesefind",{
title = "First Mese Find",
description = "Found some Mese!",
trigger = {
type = "dig", -- award is given when
node = "default:mese", -- this type of node has been dug
target = 1, -- this number of times
},
})
```
"""
rel = PackageRelease()
rel.package = mod1
rel.min_rel = v51
rel.title = "v1.0.0"
rel.url = "https://github.com/rubenwardy/awards/archive/master.zip"
rel.approved = True
session.add(rel)
mod2 = Package()
mod2.approved = True
mod2.name = "mesecons"
mod2.title = "Mesecons"
mod2.tags.append(tags["tools"])
mod2.type = PackageType.MOD
mod2.license = licenses["LGPLv3"]
mod2.media_license = licenses["MIT"]
mod2.author = jeija
mod2.repo = "https://github.com/minetest-mods/mesecons/"
mod2.issueTracker = "https://github.com/minetest-mods/mesecons/issues"
mod2.forums = 628
mod2.short_desc = "Mesecons adds everything digital, from all kinds of sensors, switches, solar panels, detectors, pistons, lamps, sound blocks to advanced digital circuitry like logic gates and programmable blocks."
mod2.desc = """
MESECONS by Jeija and contributors
Mezzee-what?
------------
[Mesecons](http://mesecons.net/)! They're yellow, they're conductive, and they'll add a whole new dimension to Minetest's gameplay.
Mesecons is a mod for [Minetest](http://minetest.net/) that implements a ton of items related to digital circuitry, such as wires, buttons, lights, and even programmable controllers. Among other things, there are also pistons, solar panels, pressure plates, and note blocks.
Mesecons has a similar goal to Redstone in Minecraft, but works in its own way, with different rules and mechanics.
OK, I want in.
--------------
Go get it!
[DOWNLOAD IT NOW](https://github.com/minetest-mods/mesecons/archive/master.zip)
Now go ahead and install it like any other Minetest mod. Don't know how? Check out [the wonderful page about it](http://wiki.minetest.com/wiki/Mods) over at the Minetest Wiki. For your convenience, here's a quick summary:
1. If Mesecons is still in a ZIP file, extract the folder inside to somewhere on the computer.
2. Make sure that when you open the folder, you can directly find `README.md` in the listing. If you just see another folder, move that folder up one level and delete the old one.
3. Open up the Minetest mods folder - usually `/mods/`. If you see the `minetest` or folder inside of that, that is your mod folder instead.
4. Copy the Mesecons folder into the mods folder.
Don't like some parts of Mesecons? Open up the Mesecons folder and delete the subfolder containing the mod you don't want. If you didn't want movestones, for example, all you have to do is delete the `mesecons_movestones` folder and they will no longer be available.
There are no dependencies - it will work right after installing!
How do I use this thing?
------------------------
How about a [quick overview video](https://www.youtube.com/watch?v=6kmeQj6iW5k)?
Or maybe a [comprehensive reference](http://mesecons.net/items.html) is your style?
An overview for the very newest of new beginners? How does [this one](http://uberi.mesecons.net/projects/MeseconsBasics/index.html) look?
Want to get more into building? Why not check out the [Mesecons Laboratory](http://uberi.mesecons.net/), a website dedicated to advanced Mesecons builders?
Want to contribute to Mesecons itself? Check out the [source code](https://github.com/minetest-mods/mesecons)!
Who wrote it anyways?
---------------------
These awesome people made Mesecons possible!
| Contributor | Contribution |
| --------------- | -------------------------------- |
| Hawk777 | Code for VoxelManip caching |
| Jat15 | Various tweaks. |
| Jeija | **Main developer! Everything.** |
| Jordach | Noteblock sounds. |
| khonkhortistan | Code, recipes, textures. |
| Kotolegokot | Nodeboxes for items. |
| minerd247 | Textures. |
| Nore/Novatux | Code. |
| RealBadAngel | Fixes, improvements. |
| sfan5 | Code, recipes, textures. |
| suzenako | Piston sounds. |
| Uberi/Temperest | Code, textures, documentation. |
| VanessaE | Code, recipes, textures, design. |
| Whiskers75 | Logic gates implementation. |
There are also a whole bunch of other people helping with everything from code to testing and feedback. Mesecons would also not be possible without their help!
Alright, how can I use it?
--------------------------
All textures in this project are licensed under the CC-BY-SA 3.0 (Creative Commons Attribution-ShareAlike 3.0 Generic). That means you can distribute and remix them as much as you want to, under the condition that you give credit to the authors and the project, and that if you remix and release them, they must be under the same or similar license to this one.
All code in this project is licensed under the LGPL version 3 or later. That means you have unlimited freedom to distribute and modify the work however you see fit, provided that if you decide to distribute it or any modified versions of it, you must also use the same license. The LGPL also grants the additional freedom to write extensions for the software and distribute them without the extensions being subject to the terms of the LGPL, although the software itself retains its license.
No warranty is provided, express or implied, for any part of the project.
"""
session.add(mod1)
session.add(mod2)
mod = Package()
mod.approved = True
mod.name = "handholds"
mod.title = "Handholds"
mod.license = licenses["MIT"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.MOD
mod.author = ez
mod.tags.append(tags["player_effects"])
mod.repo = "https://github.com/ezhh/handholds"
mod.issueTracker = "https://github.com/ezhh/handholds/issues"
mod.forums = 17069
mod.short_desc = "Adds hand holds and climbing thingies"
mod.desc = "This is the long desc"
session.add(mod)
rel = PackageRelease()
rel.package = mod
rel.title = "v1.0.0"
rel.max_rel = v4
rel.url = "https://github.com/ezhh/handholds/archive/master.zip"
rel.approved = True
session.add(rel)
mod = Package()
mod.approved = True
mod.name = "other_worlds"
mod.title = "Other Worlds"
mod.license = licenses["MIT"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.MOD
mod.author = ez
mod.tags.append(tags["mapgen"])
mod.tags.append(tags["environment"])
mod.repo = "https://github.com/ezhh/other_worlds"
mod.issueTracker = "https://github.com/ezhh/other_worlds/issues"
mod.forums = 16015
mod.short_desc = "Adds space with asteroids and comets"
mod.desc = "This is the long desc"
session.add(mod)
mod = Package()
mod.approved = True
mod.name = "food"
mod.title = "Food"
mod.license = licenses["LGPLv2.1"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.MOD
mod.author = admin_user
mod.tags.append(tags["player_effects"])
mod.repo = "https://github.com/rubenwardy/food/"
mod.issueTracker = "https://github.com/rubenwardy/food/issues/"
mod.forums = 2960
mod.short_desc = "Adds lots of food and an API to manage ingredients"
mod.desc = "This is the long desc"
session.add(mod)
mod = Package()
mod.approved = True
mod.name = "food_sweet"
mod.title = "Sweet Foods"
mod.license = licenses["CC0"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.MOD
mod.author = admin_user
mod.tags.append(tags["player_effects"])
mod.repo = "https://github.com/rubenwardy/food_sweet/"
mod.issueTracker = "https://github.com/rubenwardy/food_sweet/issues/"
mod.forums = 9039
mod.short_desc = "Adds sweet food"
mod.desc = "This is the long desc"
food_sweet = mod
session.add(mod)
game1 = Package()
game1.approved = True
game1.name = "capturetheflag"
game1.title = "Capture The Flag"
game1.type = PackageType.GAME
game1.license = licenses["LGPLv2.1"]
game1.media_license = licenses["MIT"]
game1.author = admin_user
game1.tags.append(tags["pvp"])
game1.tags.append(tags["survival"])
game1.tags.append(tags["multiplayer"])
game1.repo = "https://github.com/rubenwardy/capturetheflag"
game1.issueTracker = "https://github.com/rubenwardy/capturetheflag/issues"
game1.forums = 12835
game1.short_desc = "Two teams battle to snatch and return the enemy's flag, before the enemy takes their own!"
game1.desc = """
As seen on the Capture the Flag server (minetest.rubenwardy.com:30000)
Uses the CTF PvP Engine.
"""
session.add(game1)
rel = PackageRelease()
rel.package = game1
rel.title = "v1.0.0"
rel.url = "https://github.com/rubenwardy/capturetheflag/archive/master.zip"
rel.approved = True
session.add(rel)
mod = Package()
mod.approved = True
mod.name = "pixelbox"
mod.title = "PixelBOX Reloaded"
mod.license = licenses["CC0"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.TXP
mod.author = admin_user
mod.forums = 14132
mod.short_desc = "This is an update of the original PixelBOX texture pack by the brillant artist Gambit"
mod.desc = "This is the long desc"
session.add(mod)
rel = PackageRelease()
rel.package = mod
rel.title = "v1.0.0"
rel.url = "http://mamadou3.free.fr/Minetest/PixelBOX.zip"
rel.approved = True
session.add(rel)
session.commit()
metas = {}
for package in Package.query.filter_by(type=PackageType.MOD).all():
meta = None
try:
meta = metas[package.name]
except KeyError:
meta = MetaPackage(package.name)
session.add(meta)
metas[package.name] = meta
package.provides.append(meta)
dep = Dependency(food_sweet, meta=metas["food"])
session.add(dep)

View File

@@ -4,3 +4,4 @@ title: Help
* [Ranks and Permissions](ranks_permissions)
* [Content Ratings and Flags](content_flags)
* [Reporting Content](reporting)
* [API](api)

51
app/flatpages/help/api.md Normal file
View File

@@ -0,0 +1,51 @@
title: API
## Authentication
Not all endpoints require authentication.
Authentication is done using Bearer tokens:
Authorization: Bearer YOURTOKEN
You can use the `/api/whoami` to check authentication.
## Endpoints
### Misc
* GET `/api/whoami/` - Json dictionary with the following keys:
* `is_authenticated` - True on successful API authentication
* `username` - Username of the user authenticated as, null otherwise.
* 403 will be thrown on unsupported authentication type, invalid access token, or other errors.
### Packages
* GET `/api/packages/` - See [Package Queries](#package-queries)
* GET `/api/packages/<username>/<name>/`
### Topics
* GET `/api/topics/` - Supports [Package Queries](#package-queries), and the following two options:
* `show_added` - Show topics which exist as packages, default true.
* `show_discarded` - Show topics which have been marked as outdated, default false.
### Minetest
* GET `/api/minetest_versions/`
## Package Queries
Example:
/api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore
Supported query parameters:
* `type` - Package types (`mod`, `game`, `txp`).
* `q` - Query string
* `random` - When present, enable random ordering and ignore `sort`.
* `hide` - Hide content based on [Content Flags](content_flags).
* `sort` - Sort by (`name`, `views`, `date`, `score`).
* `order` - Sort ascending (`Asc`) or descending (`desc`).
* `protocol_version` - Only show packages supported by this Minetest protocol version.

View File

@@ -219,6 +219,21 @@ title: Ranks and Permissions
<th></th> <!-- admin -->
<th></th>
</tr>
<tr>
<td>Create Token</td>
<th></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- trusted member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th></th> <!-- moderator -->
<th><sup>2</sup></th>
<th></th> <!-- admin -->
<th></th>
</tr>
<tr>
<td>Set Rank</td>
<th></th> <!-- new -->

View File

@@ -23,8 +23,8 @@ from urllib.parse import urlparse
from flask import Flask, url_for
from flask_sqlalchemy import SQLAlchemy, BaseQuery
from flask_migrate import Migrate
from flask_user import login_required, UserManager, UserMixin, SQLAlchemyAdapter
from sqlalchemy.orm import validates
from flask_user import login_required, UserManager, UserMixin
from sqlalchemy import func, CheckConstraint
from sqlalchemy_searchable import SearchQueryMixin
from sqlalchemy_utils.types import TSVectorType
from sqlalchemy_searchable import make_searchable
@@ -78,6 +78,7 @@ class Permission(enum.Enum):
CHANGE_AUTHOR = "CHANGE_AUTHOR"
CHANGE_NAME = "CHANGE_NAME"
MAKE_RELEASE = "MAKE_RELEASE"
DELETE_RELEASE = "DELETE_RELEASE"
ADD_SCREENSHOTS = "ADD_SCREENSHOTS"
APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
APPROVE_RELEASE = "APPROVE_RELEASE"
@@ -91,6 +92,7 @@ class Permission(enum.Enum):
CREATE_THREAD = "CREATE_THREAD"
UNAPPROVE_PACKAGE = "UNAPPROVE_PACKAGE"
TOPIC_DISCARD = "TOPIC_DISCARD"
CREATE_TOKEN = "CREATE_TOKEN"
# Only return true if the permission is valid for *all* contexts
# See Package.checkPerm for package-specific contexts
@@ -112,7 +114,7 @@ class User(db.Model, UserMixin):
# User authentication information
username = db.Column(db.String(50, collation="NOCASE"), nullable=False, unique=True, index=True)
password = db.Column(db.String(255), nullable=True)
password = db.Column(db.String(255), nullable=False, server_default="")
reset_password_token = db.Column(db.String(100), nullable=False, server_default="")
rank = db.Column(db.Enum(UserRank))
@@ -123,7 +125,7 @@ class User(db.Model, UserMixin):
# User email information
email = db.Column(db.String(255), nullable=True, unique=True)
confirmed_at = db.Column(db.DateTime())
email_confirmed_at = db.Column(db.DateTime())
# User information
profile_pic = db.Column(db.String(255), nullable=True, server_default=None)
@@ -141,17 +143,21 @@ class User(db.Model, UserMixin):
packages = db.relationship("Package", backref="author", lazy="dynamic")
requests = db.relationship("EditRequest", backref="author", lazy="dynamic")
threads = db.relationship("Thread", backref="author", lazy="dynamic")
tokens = db.relationship("APIToken", backref="owner", lazy="dynamic")
replies = db.relationship("ThreadReply", backref="author", lazy="dynamic")
def __init__(self, username, active=False, email=None, password=None):
def __init__(self, username, active=False, email=None, password=""):
self.username = username
self.confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
self.email_confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
self.display_name = username
self.active = active
self.email = email
self.password = password
self.rank = UserRank.NOT_JOINED
def hasPassword(self):
return self.password != ""
def canAccessTodoList(self):
return Permission.APPROVE_NEW.check(self) or \
Permission.APPROVE_RELEASE.check(self) or \
@@ -182,6 +188,11 @@ class User(db.Model, UserMixin):
return user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.CHANGE_EMAIL:
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank))
elif perm == Permission.CREATE_TOKEN:
if user == self:
return user.rank.atLeast(UserRank.MEMBER)
else:
return user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank)
else:
raise Exception("Permission {} is not related to users".format(perm.name))
@@ -195,6 +206,13 @@ class User(db.Model, UserMixin):
return Thread.query.filter_by(author=self) \
.filter(Thread.created_at > hour_ago).count() < 2
def __eq__(self, other):
if not self.is_authenticated or not other.is_authenticated:
return False
assert self.id > 0
return self.id == other.id
class UserEmailVerification(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"))
@@ -302,7 +320,7 @@ class Dependency(db.Model):
package = db.relationship("Package", foreign_keys=[package_id])
meta_package_id = db.Column(db.Integer, db.ForeignKey("meta_package.id"), nullable=True)
optional = db.Column(db.Boolean, nullable=False, default=False)
__table_args__ = (db.UniqueConstraint('depender_id', 'package_id', 'meta_package_id', name='_dependency_uc'), )
__table_args__ = (db.UniqueConstraint("depender_id", "package_id", "meta_package_id", name="_dependency_uc"), )
def __init__(self, depender=None, package=None, meta=None):
if depender is None:
@@ -369,14 +387,17 @@ class Package(db.Model):
# Basic details
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
name = db.Column(db.String(100), nullable=False)
name = db.Column(db.Unicode(100), nullable=False)
title = db.Column(db.Unicode(100), nullable=False)
short_desc = db.Column(db.Unicode(200), nullable=False)
desc = db.Column(db.UnicodeText, nullable=True)
type = db.Column(db.Enum(PackageType))
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
search_vector = db.Column(TSVectorType("title", "short_desc", "desc"))
name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'")
search_vector = db.Column(TSVectorType("name", "title", "short_desc", "desc", \
weights={ "name": "A", "title": "B", "short_desc": "C", "desc": "D" }))
license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
license = db.relationship("License", foreign_keys=[license_id])
@@ -425,6 +446,9 @@ class Package(db.Model):
for e in PackagePropertyKey:
setattr(self, e.name, getattr(package, e.name))
def getIsFOSS(self):
return self.license.is_foss and self.media_license.is_foss
def getState(self):
if self.approved:
return "approved"
@@ -458,8 +482,7 @@ class Package(db.Model):
"short_description": self.short_desc,
"type": self.type.toName(),
"release": release and release.id,
"thumbnail": (base_url + tnurl) if tnurl is not None else None,
"score": round(self.score * 10) / 10
"thumbnail": (base_url + tnurl) if tnurl is not None else None
}
def getAsDictionary(self, base_url, version=None, protonum=None):
@@ -501,27 +524,27 @@ class Package(db.Model):
return screenshot.url if screenshot is not None else None
def getDetailsURL(self):
return url_for("package_page",
return url_for("packages.view",
author=self.author.username, name=self.name)
def getEditURL(self):
return url_for("create_edit_package_page",
return url_for("packages.create_edit",
author=self.author.username, name=self.name)
def getApproveURL(self):
return url_for("approve_package_page",
return url_for("packages.approve",
author=self.author.username, name=self.name)
def getRemoveURL(self):
return url_for("remove_package_page",
return url_for("packages.remove",
author=self.author.username, name=self.name)
def getNewScreenshotURL(self):
return url_for("create_screenshot_page",
return url_for("packages.create_screenshot",
author=self.author.username, name=self.name)
def getCreateReleaseURL(self):
return url_for("create_release_page",
return url_for("packages.create_release",
author=self.author.username, name=self.name)
def getCreateEditRequestURL(self):
@@ -529,11 +552,11 @@ class Package(db.Model):
author=self.author.username, name=self.name)
def getBulkReleaseURL(self):
return url_for("bulk_change_release_page",
return url_for("packages.bulk_change_release",
author=self.author.username, name=self.name)
def getDownloadURL(self):
return url_for("package_download_page",
return url_for("packages.download",
author=self.author.username, name=self.name)
def getDownloadRelease(self, version=None, protonum=None):
@@ -602,16 +625,20 @@ class Package(db.Model):
else:
raise Exception("Permission {} is not related to packages".format(perm.name))
def recalcScore(self):
self.score = 10
def setStartScore(self):
downloads = db.session.query(func.sum(PackageRelease.downloads)). \
filter(PackageRelease.package_id == self.id).scalar() or 0
if self.forums is not None:
topic = ForumTopic.query.get(self.forums)
if topic:
days = (datetime.datetime.now() - topic.created_at).days
months = days / 30
years = days / 365
self.score = topic.views / max(years, 0.0416) + 80*min(max(months, 0.5), 6)
forum_score = 0
forum_bonus = 0
topic = self.forums and ForumTopic.query.get(self.forums)
if topic:
months = (datetime.datetime.now() - topic.created_at).days / 30
years = months / 12
forum_score = topic.views / max(years, 0.0416) + 80*min(max(months, 0.5), 6)
forum_bonus = topic.views + topic.posts
self.score = max(downloads, forum_score * 0.6) + forum_bonus
if self.getMainScreenshotURL() is None:
self.score *= 0.8
@@ -619,6 +646,7 @@ class Package(db.Model):
if not self.license.is_foss or not self.media_license.is_foss:
self.score *= 0.1
class MetaPackage(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
@@ -689,8 +717,9 @@ class MinetestRelease(db.Model):
name = db.Column(db.String(100), unique=True, nullable=False)
protocol = db.Column(db.Integer, nullable=False, default=0)
def __init__(self, name=None):
def __init__(self, name=None, protocol=0):
self.name = name
self.protocol = protocol
def getActual(self):
return None if self.name == "None" else self
@@ -714,15 +743,23 @@ class PackageRelease(db.Model):
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])
# 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)")
def getEditURL(self):
return url_for("edit_release_page",
return url_for("packages.edit_release",
author=self.package.author.username,
name=self.package.name,
id=self.id)
def getDeleteURL(self):
return url_for("packages.delete_release",
author=self.package.author.username,
name=self.package.name,
id=self.id)
def getDownloadURL(self):
return url_for("download_release_page",
return url_for("packages.download_release",
author=self.package.author.username,
name=self.package.name,
id=self.id)
@@ -736,17 +773,47 @@ class PackageRelease(db.Model):
not self.package.checkPerm(user, Permission.APPROVE_RELEASE):
return False
assert(self.task_id is None and self.url is not None and self.url != "")
assert self.task_id is None and self.url is not None and self.url != ""
self.approved = True
return True
def checkPerm(self, user, perm):
if not user.is_authenticated:
return False
class PackageReview(db.Model):
id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False)
recommend = db.Column(db.Boolean, nullable=False, default=True)
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to PackageRelease.checkPerm()")
isOwner = user == self.package.author
if perm == Permission.DELETE_RELEASE:
if user.rank.atLeast(UserRank.ADMIN):
return True
if not (isOwner or user.rank.atLeast(UserRank.EDITOR)):
return False
if not self.package.approved or self.task_id is not None:
return True
count = PackageRelease.query \
.filter_by(package_id=self.package_id) \
.filter(PackageRelease.id > self.id) \
.count()
return count > 0
else:
raise Exception("Permission {} is not related to releases".format(perm.name))
# class PackageReview(db.Model):
# id = db.Column(db.Integer, primary_key=True)
# package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
# thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False)
# recommend = db.Column(db.Boolean, nullable=False, default=True)
class PackageScreenshot(db.Model):
@@ -758,7 +825,7 @@ class PackageScreenshot(db.Model):
def getEditURL(self):
return url_for("edit_screenshot_page",
return url_for("packages.edit_screenshot",
author=self.package.author.username,
name=self.package.name,
id=self.id)
@@ -767,6 +834,16 @@ class PackageScreenshot(db.Model):
return self.url.replace("/uploads/", ("/thumbnails/{:d}/").format(level))
class APIToken(db.Model):
id = db.Column(db.Integer, primary_key=True)
access_token = db.Column(db.String(34), unique=True)
name = db.Column(db.String(100), nullable=False)
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
def canOperateOnPackage(self, package):
return packages.count() == 0 or package in packages
class EditRequest(db.Model):
id = db.Column(db.Integer, primary_key=True)
@@ -880,11 +957,11 @@ class Thread(db.Model):
def getSubscribeURL(self):
return url_for("thread_subscribe_page",
return url_for("threads.subscribe",
id=self.id)
def getUnsubscribeURL(self):
return url_for("thread_unsubscribe_page",
return url_for("threads.unsubscribe",
id=self.id)
def checkPerm(self, user, perm):
@@ -977,5 +1054,4 @@ class ForumTopic(db.Model):
# Setup Flask-User
db_adapter = SQLAlchemyAdapter(db, User) # Register the User model
user_manager = UserManager(db_adapter, app) # Initialize Flask-User
user_manager = UserManager(app, db, User)

View File

@@ -25,7 +25,7 @@ class QueryBuilder:
self.types = types
self.search = args.get("q")
self.random = "random" in args
self.lucky = self.random or "lucky" in args
self.lucky = "lucky" in args
self.hide_nonfree = "nonfree" in hide_flags
self.limit = 1 if self.lucky else None
self.order_by = args.get("sort")
@@ -62,7 +62,7 @@ class QueryBuilder:
query = query.filter(Package.type.in_(self.types))
if self.search:
query = query.search(self.search)
query = query.search(self.search, sort=True)
if self.random:
query = query.order_by(func.random())

13
app/rediscache.py Normal file
View File

@@ -0,0 +1,13 @@
from . import r
# This file acts as a facade between the releases code and redis,
# and also means that the releases code avoids knowing about `app`
def make_download_key(ip, package):
return ("{}/{}/{}").format(ip, package.author.username, package.name)
def set_key(key, v):
r.set(key, v)
def has_key(key):
return r.exists(key)

View File

@@ -15,8 +15,6 @@ import codecs
from flask import *
from scss import Scss
from app import app
def _convert(dir, src, dst):
original_wd = os.getcwd()
os.chdir(dir)
@@ -31,7 +29,7 @@ def _convert(dir, src, dst):
outfile.write(output)
outfile.close()
def _getDirPath(originalPath, create=False):
def _getDirPath(app, originalPath, create=False):
path = originalPath
if not os.path.isdir(path):
@@ -47,8 +45,8 @@ def _getDirPath(originalPath, create=False):
def sass(app, inputDir='scss', outputPath='static', force=False, cacheDir="public/static"):
static_url_path = app.static_url_path
inputDir = _getDirPath(inputDir)
cacheDir = _getDirPath(cacheDir or outputPath, True)
inputDir = _getDirPath(app, inputDir)
cacheDir = _getDirPath(app, cacheDir or outputPath, True)
def _sass(filepath):
sassfile = "%s/%s.scss" % (inputDir, filepath)
@@ -63,5 +61,3 @@ def sass(app, inputDir='scss', outputPath='static', force=False, cacheDir="publi
return send_from_directory(cacheDir, filepath + ".css")
app.add_url_rule("/%s/<path:filepath>.css" % (outputPath), 'sass', _sass)
sass(app)

View File

@@ -4,7 +4,7 @@
@import "comments.scss";
.dropdown-menu {
margin-top: 0;
margin-top: 0;
}
.dropdown:hover .dropdown-menu {
@@ -16,42 +16,48 @@
}
#alerts {
display: block;
list-style: none;
position: fixed;
bottom: 0;
left:0;
right:0;
margin: 0;
padding:0;
z-index: 1000;
display: block;
list-style: none;
position: fixed;
bottom: 0;
left:0;
right:0;
margin: 0;
padding:0;
z-index: 1000;
}
#alerts li {
list-style: none;
list-style: none;
}
.jumbotron {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
.btn-outline-secondary {
color: #eee;
border-color: #666;
background: rgba(102, 102, 102, 0.3);
}
}
.alert .btn {
text-decoration: none;
text-decoration: none;
}
.card .table {
margin-bottom: 0;
margin-bottom: 0;
}
.btn-download {
color: #fff;
background-color: #00b05c;
border-color: #00b05c;
color: #fff;
background-color: #00b05c;
border-color: #00b05c;
}
.btn-download:focus, .btn-download.focus {
-webkit-box-shadow: 0 0 0 0.2rem rgba(231, 76, 60, 0.5);
box-shadow: 0 0 0 0.2rem rgba(231, 76, 60, 0.5);
-webkit-box-shadow: 0 0 0 0.2rem rgba(231, 76, 60, 0.5);
box-shadow: 0 0 0 0.2rem rgba(231, 76, 60, 0.5);
}

View File

@@ -69,8 +69,12 @@ CELERYBEAT_SCHEDULE = {
'topic_list_import': {
'task': 'app.tasks.forumtasks.importTopicList',
'schedule': crontab(minute=1, hour=1),
},
'package_score_update': {
'task': 'app.tasks.pkgtasks.updatePackageScores',
'schedule': crontab(minute=10, hour=1),
}
}
celery.conf.beat_schedule = CELERYBEAT_SCHEDULE
from . import importtasks, forumtasks, emails
from . import importtasks, forumtasks, emails, pkgtasks

View File

@@ -34,7 +34,7 @@ def sendVerifyEmail(newEmail, token):
If this was you, then please click this link to verify the address:
{}
""".format(url_for('verify_email_page', token=token, _external=True))
""".format(url_for('users.verify_email', token=token, _external=True))
msg.html = render_template("emails/verify.html", token=token)
mail.send(msg)

View File

@@ -171,7 +171,4 @@ def importTopicList():
topic.views = int(info["views"])
topic.created_at = info["date"]
for p in Package.query.all():
p.recalcScore()
db.session.commit()

View File

@@ -15,17 +15,21 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import flask, json, os, git, tempfile, shutil
import flask, json, os, git, tempfile, shutil, gitdb
from git import GitCommandError
from git_archive_all import GitArchiver
from flask_sqlalchemy import SQLAlchemy
from urllib.error import HTTPError
import urllib.request
from urllib.parse import urlparse, quote_plus, urlsplit
from zipfile import ZipFile
from app import app
from app.models import *
from app.tasks import celery, TaskError
from app.utils import randomString
from .minetestcheck import build_tree, MinetestCheckError, ContentType
from .minetestcheck.config import parse_conf
class GithubURLMaker:
def __init__(self, url):
@@ -126,173 +130,22 @@ def findModInfo(author, name, link):
return None
def parseConf(string):
retval = {}
for line in string.split("\n"):
idx = line.find("=")
if idx > 0:
key = line[:idx].strip()
value = line[idx+1:].strip()
retval[key] = value
return retval
class PackageTreeNode:
def __init__(self, baseDir, author=None, repo=None, name=None):
print("Scanning " + baseDir)
self.baseDir = baseDir
self.author = author
self.name = name
self.repo = repo
self.meta = None
self.children = []
# Detect type
type = None
is_modpack = False
if os.path.isfile(baseDir + "/game.conf"):
type = PackageType.GAME
elif os.path.isfile(baseDir + "/init.lua"):
type = PackageType.MOD
elif os.path.isfile(baseDir + "/modpack.txt") or \
os.path.isfile(baseDir + "/modpack.conf"):
type = PackageType.MOD
is_modpack = True
elif os.path.isdir(baseDir + "/mods"):
type = PackageType.GAME
elif os.listdir(baseDir) == []:
# probably a submodule
return
else:
raise TaskError("Unable to detect package type!")
self.type = type
self.readMetaFiles()
if self.type == PackageType.GAME:
self.addChildrenFromModDir(baseDir + "/mods")
elif is_modpack:
self.addChildrenFromModDir(baseDir)
def readMetaFiles(self):
result = {}
# .conf file
try:
with open(self.baseDir + "/mod.conf", "r") as myfile:
conf = parseConf(myfile.read())
for key in ["name", "description", "title", "depends", "optional_depends"]:
try:
result[key] = conf[key]
except KeyError:
pass
except IOError:
print("description.txt does not exist!")
# description.txt
if not "description" in result:
try:
with open(self.baseDir + "/description.txt", "r") as myfile:
result["description"] = myfile.read()
except IOError:
print("description.txt does not exist!")
# depends.txt
import re
pattern = re.compile("^([a-z0-9_]+)\??$")
if not "depends" in result and not "optional_depends" in result:
try:
with open(self.baseDir + "/depends.txt", "r") as myfile:
contents = myfile.read()
soft = []
hard = []
for line in contents.split("\n"):
line = line.strip()
if pattern.match(line):
if line[len(line) - 1] == "?":
soft.append( line[:-1])
else:
hard.append(line)
result["depends"] = hard
result["optional_depends"] = soft
except IOError:
print("depends.txt does not exist!")
else:
if "depends" in result:
result["depends"] = [x.strip() for x in result["depends"].split(",")]
if "optional_depends" in result:
result["optional_depends"] = [x.strip() for x in result["optional_depends"].split(",")]
# Calculate Title
if "name" in result and not "title" in result:
result["title"] = result["name"].replace("_", " ").title()
# Calculate short description
if "description" in result:
desc = result["description"]
idx = desc.find(".") + 1
cutIdx = min(len(desc), 200 if idx < 5 else idx)
result["short_description"] = desc[:cutIdx]
# Get forum ID
info = findModInfo(self.author, result.get("name"), self.repo)
if info is not None:
result["forumId"] = info.get("topicId")
if "name" in result:
self.name = result["name"]
del result["name"]
self.meta = result
def addChildrenFromModDir(self, dir):
for entry in next(os.walk(dir))[1]:
path = dir + "/" + entry
if not entry.startswith('.') and os.path.isdir(path):
self.children.append(PackageTreeNode(path, name=entry))
def fold(self, attr, key=None, acc=None):
if acc is None:
acc = set()
if self.meta is None:
return acc
at = getattr(self, attr)
value = at if key is None else at.get(key)
if isinstance(value, list):
acc |= set(value)
elif value is not None:
acc.add(value)
for child in self.children:
child.fold(attr, key, acc)
return acc
def get(self, key):
return self.meta.get(key)
def generateGitURL(urlstr):
scheme, netloc, path, query, frag = urlsplit(urlstr)
return "http://:@" + netloc + path + query
def getTempDir():
return os.path.join(tempfile.gettempdir(), randomString(10))
# Clones a repo from an unvalidated URL.
# Returns a tuple of path and repo on sucess.
# Throws `TaskError` on failure.
# Caller is responsible for deleting returned directory.
def cloneRepo(urlstr, ref=None, recursive=False):
gitDir = tempfile.gettempdir() + "/" + randomString(10)
gitDir = getTempDir()
err = None
try:
@@ -322,7 +175,12 @@ def cloneRepo(urlstr, ref=None, recursive=False):
@celery.task()
def getMeta(urlstr, author):
gitDir, _ = cloneRepo(urlstr, recursive=True)
tree = PackageTreeNode(gitDir, author=author, repo=urlstr)
try:
tree = build_tree(gitDir, author=author, repo=urlstr)
except MinetestCheckError as err:
raise TaskError(str(err))
shutil.rmtree(gitDir)
result = {}
@@ -371,6 +229,39 @@ def makeVCSReleaseFromGithub(id, branch, release, url):
return release.url
@celery.task(bind=True)
def checkZipRelease(self, id, path):
release = PackageRelease.query.get(id)
if release is None:
raise TaskError("No such release!")
elif release.package is None:
raise TaskError("No package attached to release")
temp = getTempDir()
try:
with ZipFile(path, 'r') as zip_ref:
zip_ref.extractall(temp)
try:
tree = build_tree(temp, expected_type=ContentType[release.package.type.name], \
author=release.package.author.username, name=release.package.name)
except MinetestCheckError as err:
if "Fails validation" not in release.title:
release.title += " (Fails validation)"
release.task_id = self.request.id
release.approved = False
db.session.commit()
raise TaskError(str(err))
release.task_id = None
release.approve(release.package.author)
db.session.commit()
finally:
shutil.rmtree(temp)
@celery.task()
def makeVCSRelease(id, branch):
@@ -380,29 +271,37 @@ def makeVCSRelease(id, branch):
elif release.package is None:
raise TaskError("No package attached to release")
urlmaker = None
url = urlparse(release.package.repo)
if url.netloc == "github.com":
return makeVCSReleaseFromGithub(id, branch, release, url)
else:
gitDir, repo = cloneRepo(release.package.repo, ref=branch, recursive=True)
# url = urlparse(release.package.repo)
# if url.netloc == "github.com":
# return makeVCSReleaseFromGithub(id, branch, release, url)
try:
filename = randomString(10) + ".zip"
destPath = os.path.join("app/public/uploads", filename)
with open(destPath, "wb") as fp:
repo.archive(fp, format="zip")
gitDir, repo = cloneRepo(release.package.repo, ref=branch, recursive=True)
release.url = "/uploads/" + filename
release.task_id = None
release.commit_hash = repo.head.object.hexsha
release.approve(release.package.author)
print(release.url)
db.session.commit()
try:
tree = build_tree(gitDir, expected_type=ContentType[release.package.type.name], \
author=release.package.author.username, name=release.package.name)
except MinetestCheckError as err:
raise TaskError(str(err))
return release.url
finally:
shutil.rmtree(gitDir)
try:
filename = randomString(10) + ".zip"
destPath = os.path.join(app.config["UPLOAD_DIR"], filename)
assert(not os.path.isfile(destPath))
archiver = GitArchiver(force_sub=True, main_repo_abspath=gitDir)
archiver.create(destPath)
assert(os.path.isfile(destPath))
release.url = "/uploads/" + filename
release.task_id = None
release.commit_hash = repo.head.object.hexsha
release.approve(release.package.author)
print(release.url)
db.session.commit()
return release.url
finally:
shutil.rmtree(gitDir)
@celery.task()
def importRepoScreenshot(id):
@@ -424,7 +323,7 @@ def importRepoScreenshot(id):
sourcePath = gitDir + "/screenshot." + ext
if os.path.isfile(sourcePath):
filename = randomString(10) + "." + ext
destPath = os.path.join("app/public/uploads", filename)
destPath = os.path.join(app.config["UPLOAD_DIR"], filename)
shutil.copyfile(sourcePath, destPath)
ss = PackageScreenshot()
@@ -461,7 +360,7 @@ def getDepends(package):
#
try:
contents = urllib.request.urlopen(urlmaker.getModConfURL()).read().decode("utf-8")
conf = parseConf(contents)
conf = parse_conf(contents)
for key in ["depends", "optional_depends"]:
try:
result[key] = conf[key]

View File

@@ -0,0 +1,48 @@
from enum import Enum
class MinetestCheckError(Exception):
def __init__(self, value):
self.value = value
def __str__(self):
return repr("Error validating package: " + self.value)
class ContentType(Enum):
UNKNOWN = "unknown"
MOD = "mod"
MODPACK = "modpack"
GAME = "game"
TXP = "texture pack"
def isModLike(self):
return self == ContentType.MOD or self == ContentType.MODPACK
def validate_same(self, other):
"""
Whether or not `other` is an acceptable type for this
"""
assert(other)
if self == ContentType.MOD:
if not other.isModLike():
raise MinetestCheckError("expected a mod or modpack, found " + other.value)
elif self == ContentType.TXP:
if other != ContentType.UNKNOWN and other != ContentType.TXP:
raise MinetestCheckError("expected a " + self.value + ", found a " + other.value)
elif other != self:
raise MinetestCheckError("expected a " + self.value + ", found a " + other.value)
from .tree import PackageTreeNode, get_base_dir
def build_tree(path, expected_type=None, author=None, repo=None, name=None):
path = get_base_dir(path)
root = PackageTreeNode(path, "/", author=author, repo=repo, name=name)
assert(root)
if expected_type:
expected_type.validate_same(root.type)
return root

View File

@@ -0,0 +1,10 @@
def parse_conf(string):
retval = {}
for line in string.split("\n"):
idx = line.find("=")
if idx > 0:
key = line[:idx].strip()
value = line[idx+1:].strip()
retval[key] = value
return retval

View File

@@ -0,0 +1,162 @@
import os
from . import MinetestCheckError, ContentType
from .config import parse_conf
def get_base_dir(path):
if not os.path.isdir(path):
raise IOError("Expected dir")
root, subdirs, files = next(os.walk(path))
if len(subdirs) == 1 and len(files) == 0:
return get_base_dir(path + "/" + subdirs[0])
else:
return path
def detect_type(path):
if os.path.isfile(path + "/game.conf"):
return ContentType.GAME
elif os.path.isfile(path + "/init.lua"):
return ContentType.MOD
elif os.path.isfile(path + "/modpack.txt") or \
os.path.isfile(path + "/modpack.conf"):
return ContentType.MODPACK
elif os.path.isdir(path + "/mods"):
return ContentType.GAME
elif os.path.isfile(path + "/texture_pack.conf"):
return ContentType.TXP
else:
return ContentType.UNKNOWN
class PackageTreeNode:
def __init__(self, baseDir, relative, author=None, repo=None, name=None):
print(baseDir)
self.baseDir = baseDir
self.relative = relative
self.author = author
self.name = name
self.repo = repo
self.meta = None
self.children = []
# Detect type
self.type = detect_type(baseDir)
self.read_meta()
if self.type == ContentType.GAME:
if not os.path.isdir(baseDir + "/mods"):
raise MinetestCheckError(("game at {} does not have a mods/ folder").format(self.relative))
self.add_children_from_mod_dir(baseDir + "/mods")
elif self.type == ContentType.MODPACK:
self.add_children_from_mod_dir(baseDir)
def read_meta(self):
result = {}
# .conf file
try:
with open(self.baseDir + "/mod.conf", "r") as myfile:
conf = parse_conf(myfile.read())
for key in ["name", "description", "title", "depends", "optional_depends"]:
try:
result[key] = conf[key]
except KeyError:
pass
except IOError:
pass
# description.txt
if not "description" in result:
try:
with open(self.baseDir + "/description.txt", "r") as myfile:
result["description"] = myfile.read()
except IOError:
pass
# depends.txt
import re
pattern = re.compile("^([a-z0-9_]+)\??$")
if not "depends" in result and not "optional_depends" in result:
try:
with open(self.baseDir + "/depends.txt", "r") as myfile:
contents = myfile.read()
soft = []
hard = []
for line in contents.split("\n"):
line = line.strip()
if pattern.match(line):
if line[len(line) - 1] == "?":
soft.append( line[:-1])
else:
hard.append(line)
result["depends"] = hard
result["optional_depends"] = soft
except IOError:
pass
else:
if "depends" in result:
result["depends"] = [x.strip() for x in result["depends"].split(",")]
if "optional_depends" in result:
result["optional_depends"] = [x.strip() for x in result["optional_depends"].split(",")]
# Calculate Title
if "name" in result and not "title" in result:
result["title"] = result["name"].replace("_", " ").title()
# Calculate short description
if "description" in result:
desc = result["description"]
idx = desc.find(".") + 1
cutIdx = min(len(desc), 200 if idx < 5 else idx)
result["short_description"] = desc[:cutIdx]
if "name" in result:
self.name = result["name"]
del result["name"]
self.meta = result
def add_children_from_mod_dir(self, dir):
for entry in next(os.walk(dir))[1]:
path = os.path.join(dir, entry)
if not entry.startswith('.') and os.path.isdir(path):
child = PackageTreeNode(path, self.relative + entry + "/", name=entry)
if not child.type.isModLike():
raise MinetestCheckError(("Expecting mod or modpack, found {} at {} inside {}") \
.format(child.type.value, child.relative, self.type.value))
self.children.append(child)
def fold(self, attr, key=None, acc=None):
if acc is None:
acc = set()
if self.meta is None:
return acc
at = getattr(self, attr)
value = at if key is None else at.get(key)
if isinstance(value, list):
acc |= set(value)
elif value is not None:
acc.add(value)
for child in self.children:
child.fold(attr, key, acc)
return acc
def get(self, key):
return self.meta.get(key)
def validate(self):
for child in self.children:
child.validate()

View File

@@ -121,7 +121,7 @@ def parseForumListPage(id, page, out, extra=None):
if id in out:
print(" - got {} again, title: {}".format(id, title))
assert(title == out[id]['title'])
assert title == out[id]['title']
return False
row = {

23
app/tasks/pkgtasks.py Normal file
View File

@@ -0,0 +1,23 @@
# Content DB
# Copyright (C) 2018 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from app.models import Package
from app.tasks import celery
@celery.task()
def updatePackageScores():
Package.query.update({ "score": Package.score * 0.8 })

22
app/template_filters.py Normal file
View File

@@ -0,0 +1,22 @@
from . import app
from urllib.parse import urlparse
@app.context_processor
def inject_debug():
return dict(debug=app.debug)
@app.template_filter()
def throw(err):
raise Exception(err)
@app.template_filter()
def domain(url):
return urlparse(url).netloc
@app.template_filter()
def date(value):
return value.strftime("%Y-%m-%d")
@app.template_filter()
def datetime(value):
return value.strftime("%Y-%m-%d %H:%M") + " UTC"

View File

@@ -10,8 +10,8 @@
{% block content %}
<p>
<a href="{{ url_for('license_list_page') }}">Back to list</a> |
<a href="{{ url_for('createedit_license_page') }}">New License</a>
<a href="{{ url_for('admin.license_list') }}">Back to list</a> |
<a href="{{ url_for('admin.create_edit_license') }}">New License</a>
</p>
{% from "macros/forms.html" import render_field, render_submit_field %}

View File

@@ -6,11 +6,11 @@ Licenses
{% block content %}
<p>
<a href="{{ url_for('createedit_license_page') }}">New License</a>
<a href="{{ url_for('admin.create_edit_license') }}">New License</a>
</p>
<ul>
{% for l in licenses %}
<li><a href="{{ url_for('createedit_license_page', name=l.name) }}">{{ l.name }}</a> [{{ l.is_foss and "Free" or "Non-free"}}]</li>
<li><a href="{{ url_for('admin.create_edit_license', name=l.name) }}">{{ l.name }}</a> [{{ l.is_foss and "Free" or "Non-free"}}]</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -6,11 +6,11 @@
{% block content %}
<ul>
<li><a href="{{ url_for('user_list_page') }}">User list</a></li>
<li><a href="{{ url_for('tag_list_page') }}">Tag Editor</a></li>
<li><a href="{{ url_for('license_list_page') }}">License Editor</a></li>
<li><a href="{{ url_for('version_list_page') }}">Version Editor</a></li>
<li><a href="{{ url_for('switch_user_page') }}">Sign in as another user</a></li>
<li><a href="{{ url_for('users.list_all') }}">User list</a></li>
<li><a href="{{ url_for('admin.tag_list') }}">Tag Editor</a></li>
<li><a href="{{ url_for('admin.license_list') }}">License Editor</a></li>
<li><a href="{{ url_for('admin.version_list') }}">Version Editor</a></li>
<li><a href="{{ url_for('admin.switch_user') }}">Sign in as another user</a></li>
</ul>
<div class="card my-4">
@@ -20,6 +20,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<select name="action">
<option value="delstuckreleases" selected>Delete stuck releases</option>
<option value="checkreleases">Validate all Zip releases</option>
<option value="importmodlist">Import forum topics</option>
<option value="recalcscores">Recalculate package scores</option>
<option value="checkusers">Check forum users</option>

View File

@@ -10,8 +10,8 @@
{% block content %}
<p>
<a href="{{ url_for('tag_list_page') }}">Back to list</a> |
<a href="{{ url_for('createedit_tag_page') }}">New Tag</a>
<a href="{{ url_for('admin.tag_list') }}">Back to list</a> |
<a href="{{ url_for('admin.create_edit_tag') }}">New Tag</a>
</p>
{% from "macros/forms.html" import render_field, render_submit_field %}

View File

@@ -6,11 +6,11 @@ Tags
{% block content %}
<p>
<a href="{{ url_for('createedit_tag_page') }}">New Tag</a>
<a href="{{ url_for('admin.create_edit_tag') }}">New Tag</a>
</p>
<ul>
{% for t in tags %}
<li><a href="{{ url_for('createedit_tag_page', name=t.name) }}">{{ t.title }}</a> [{{ t.packages | count }} packages]</li>
<li><a href="{{ url_for('admin.create_edit_tag', name=t.name) }}">{{ t.title }}</a> [{{ t.packages | count }} packages]</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -10,8 +10,8 @@
{% block content %}
<p>
<a href="{{ url_for('version_list_page') }}">Back to list</a> |
<a href="{{ url_for('createedit_version_page') }}">New Version</a>
<a href="{{ url_for('admin.version_list') }}">Back to list</a> |
<a href="{{ url_for('admin.create_edit_version') }}">New Version</a>
</p>
{% from "macros/forms.html" import render_field, render_submit_field %}

View File

@@ -6,11 +6,11 @@ Minetest Versions
{% block content %}
<p>
<a href="{{ url_for('createedit_version_page') }}">New Version</a>
<a href="{{ url_for('admin.create_edit_version') }}">New Version</a>
</p>
<ul>
{% for v in versions %}
<li><a href="{{ url_for('createedit_version_page', name=v.name) }}">{{ v.name }}</a></li>
<li><a href="{{ url_for('admin.create_edit_version', name=v.name) }}">{{ v.name }}</a></li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}
{% if token %}
{{ _("Edit - %(name)s", name=token.name) }}
{% else %}
{{ _("Create API Token") }}
{% endif %}
{% endblock %}
{% from "macros/forms.html" import render_field, render_submit_field, render_radio_field %}
{% block content %}
{% if token %}
<form class="float-right" method="POST" action="{{ url_for('api.delete_token', username=token.owner.username, id=token.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input class="btn btn-danger" type="submit" value="Delete">
</form>
{% endif %}
<h1 class="mt-0">{{ self.title() }}</h1>
<div class="alert alert-warning">
{{ _("Use carefully, as you may be held responsible for any damage caused by rogue scripts") }}
</div>
{% if token %}
<div class="card mb-3">
<div class="card-header">{{ _("Access Token") }}</div>
<div class="card-body">
<p>
For security reasons, access tokens will only be shown once.
Reset the token if it is lost.
</p>
{% if access_token %}
<input class="form-control my-3" type="text" readonly value="{{ access_token }}" class="form-control">
{% endif %}
<form method="POST" action="{{ url_for('api.reset_token', username=token.owner.username, id=token.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input class="btn btn-primary" type="submit" value="Reset">
</form>
</div>
</div>
{% endif %}
<form method="POST" action="" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{{ render_field(form.name, placeholder="Human readable") }}
{{ render_submit_field(form.submit) }}
</form>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}
{{ _("List tokens for %(username)s", username=user.username) }}
{% endblock %}
{% block content %}
<a class="btn btn-primary float-right" href="{{ url_for('api.create_edit_token', username=user.username) }}">Create</a>
<h1 class="mt-0">{{ self.title() }}</h1>
<ul>
{% for token in user.tokens %}
<li>
<a href="{{ url_for('api.create_edit_token', username=user.username, id=token.id) }}">{{ token.name }}</a>
</li>
{% else %}
<li>
<i>No tokens created</i>
</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -7,7 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}title{% endblock %} - {{ config.USER_APP_NAME }}</title>
<link rel="stylesheet" type="text/css" href="/static/bootstrap.css">
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=7">
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=8">
<link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" />
<link rel="shortcut icon" href="/favicon-16.png" sizes="16x16">
<link rel="icon" href="/favicon-128.png" sizes="128x128">
@@ -60,10 +60,10 @@
</form>
<ul class="navbar-nav ml-auto">
{% if current_user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('notifications_page') }}">
<li class="nav-item"><a class="nav-link" href="{{ url_for('notifications.list_all') }}">
<img src="/static/notification{% if current_user.notifications %}_alert{% endif %}.svg" />
</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('create_edit_package_page') }}">+</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('packages.create_edit') }}">+</a></li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle"
data-toggle="dropdown"
@@ -73,24 +73,24 @@
<ul class="dropdown-menu" role="menu">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('user_profile_page', username=current_user.username) }}">Profile</a>
<a class="nav-link" href="{{ url_for('users.profile', username=current_user.username) }}">Profile</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('user_profile_page', username=current_user.username) }}#unadded-topics">Your unadded topics</a>
<a class="nav-link" href="{{ url_for('users.profile', username=current_user.username) }}#unadded-topics">Your unadded topics</a>
</li>
{% if current_user.canAccessTodoList() %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('todo_page') }}">{{ _("Work Queue") }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('user_list_page') }}">{{ _("User list") }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('todo.view') }}">{{ _("Work Queue") }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('users.list_all') }}">{{ _("User list") }}</a></li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('todo_topics_page') }}">{{ _("All unadded topics") }}</a>
<a class="nav-link" href="{{ url_for('todo.topics') }}">{{ _("All unadded topics") }}</a>
</li>
{% if current_user.rank == current_user.rank.ADMIN %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin_page') }}">{{ _("Admin") }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.admin_page') }}">{{ _("Admin") }}</a></li>
{% endif %}
{% if current_user.rank == current_user.rank.MODERATOR %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('tag_list_page') }}">{{ _("Tag Editor") }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('license_list_page') }}">{{ _("License Editor") }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.tag_list') }}">{{ _("Tag Editor") }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.license_list') }}">{{ _("License Editor") }}</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('user.logout') }}">{{ _("Sign out") }}</a></li>
</ul>
@@ -134,7 +134,7 @@
<a href="{{ url_for('flatpage', path='help') }}">{{ _("Help") }}</a> |
<a href="{{ url_for('flatpage', path='policy_and_guidance') }}">{{ _("Policy and Guidance") }}</a> |
<a href="{{ url_for('flatpage', path='help/reporting') }}">{{ _("Report / DMCA") }}</a> |
<a href="{{ url_for('user_list_page') }}">{{ _("User List") }}</a>
<a href="{{ url_for('users.list_all') }}">{{ _("User List") }}</a>
{% if debug %}
<p style="color: red">

View File

@@ -16,12 +16,12 @@
If this was you, then please click this link to verify the address:
</p>
<a class="btn" href="{{ url_for('verify_email_page', token=token, _external=True) }}">
<a class="btn" href="{{ url_for('users.verify_email', token=token, _external=True) }}">
Confirm Email Address
</a>
<p style="font-size: 80%;">
Or paste this into your browser: {{ url_for('verify_email_page', token=token, _external=True) }}
Or paste this into your browser: {{ url_for('users.verify_email', token=token, _external=True) }}
<p>
{% endblock %}

View File

@@ -15,7 +15,7 @@ Sign in
{{ form.hidden_tag() }}
{# Username or Email field #}
{% set field = form.username if user_manager.enable_username else form.email %}
{% set field = form.username if user_manager.USER_ENABLE_USERNAME else form.email %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
{# Label on left, "New here? Register." on right #}
<label for="{{ field.id }}" class="control-label">{{ field.label.text }}</label>
@@ -31,7 +31,7 @@ Sign in
{% set field = form.password %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
<label for="{{ field.id }}" class="control-label">{{ field.label.text }}
{% if user_manager.enable_forgot_password %}
{% if user_manager.USER_ENABLE_FORGOT_PASSWORD %}
<a href="{{ url_for('user.forgot_password') }}" tabindex='195'>
[{%trans%}Forgot My Password{%endtrans%}]</a>
{% endif %}
@@ -45,7 +45,7 @@ Sign in
</div>
{# Remember me #}
{% if user_manager.enable_remember_me %}
{% if user_manager.USER_ENABLE_REMEMBER_ME %}
{{ render_checkbox_field(login_form.remember_me, tabindex=130) }}
{% endif %}
@@ -60,7 +60,7 @@ Sign in
{% from "flask_user/_macros.html" import render_field, render_checkbox_field, render_submit_field %}
<h2 class="card-header">{%trans%}Sign in with Github{%endtrans%}</h2>
<div class="card-body">
<a class="btn btn-primary" href="{{ url_for('github_signin_page') }}">GitHub</a>
<a class="btn btn-primary" href="{{ url_for('users.github_signin') }}">GitHub</a>
</div>
</div>
</div>
@@ -72,7 +72,7 @@ Sign in
<div class="card-body">
<p>Create an account using your forum account or email.</p>
<a href="{{ url_for('user_claim_page') }}" class="btn btn-primary">{%trans%}Claim your account{%endtrans%}</a>
<a href="{{ url_for('users.claim') }}" class="btn btn-primary">{%trans%}Claim your account{%endtrans%}</a>
</div>
</div>
</aside>

View File

@@ -37,28 +37,28 @@
{% from "macros/packagegridtile.html" import render_pkggrid %}
<a href="{{ url_for('packages_page', sort='created_at', order='desc') }}" class="btn btn-secondary float-right">
<a href="{{ url_for('packages.list_all', sort='created_at', order='desc') }}" class="btn btn-secondary float-right">
{{ _("See more") }}
</a>
<h2 class="my-3">{{ _("Recently Added") }}</h2>
{{ render_pkggrid(new) }}
<a href="{{ url_for('packages_page', type='mod', sort='score', order='desc') }}" class="btn btn-secondary float-right">
<a href="{{ url_for('packages.list_all', type='mod', sort='score', order='desc') }}" class="btn btn-secondary float-right">
{{ _("See more") }}
</a>
<h2 class="my-3">{{ _("Top Mods") }}</h2>
{{ render_pkggrid(pop_mod) }}
<a href="{{ url_for('packages_page', type='game', sort='score', order='desc') }}" class="btn btn-secondary float-right">
<a href="{{ url_for('packages.list_all', type='game', sort='score', order='desc') }}" class="btn btn-secondary float-right">
{{ _("See more") }}
</a>
<h2 class="my-3">{{ _("Top Games") }}</h2>
{{ render_pkggrid(pop_gam) }}
<a href="{{ url_for('packages_page', type='txp', sort='score', order='desc') }}" class="btn btn-secondary float-right">
<a href="{{ url_for('packages.list_all', type='txp', sort='score', order='desc') }}" class="btn btn-secondary float-right">
{{ _("See more") }}
</a>
<h2 class="my-3">{{ _("Top Texture Packs") }}</h2>

View File

@@ -40,8 +40,10 @@
{% else %}
<li><i>No packages available</i></ul>
{% endfor %}
{% for i in range(4) %}
<li class="packagetile flex-fill"></li>
{% endfor %}
{% if packages %}
{% for i in range(4) %}
<li class="packagetile flex-fill"></li>
{% endfor %}
{% endif %}
</ul>
{% endmacro %}

View File

@@ -4,7 +4,7 @@
{% for r in thread.replies %}
<li class="row my-2 mx-0">
<div class="col-md-1 p-1">
<a href="{{ url_for('user_profile_page', username=r.author.username) }}">
<a href="{{ url_for('users.profile', username=r.author.username) }}">
<img class="img-responsive user-photo img-thumbnail img-thumbnail-1" src="{{ r.author.getProfilePicURL() }}">
</a>
</div>
@@ -12,11 +12,11 @@
<div class="card">
<div class="card-header">
<a class="author {{ r.author.rank.name }}"
href="{{ url_for('user_profile_page', username=r.author.username) }}">
href="{{ url_for('users.profile', username=r.author.username) }}">
{{ r.author.display_name }}
</a>
<a name="reply-{{ r.id }}" class="text-muted float-right"
href="{{ url_for('thread_page', id=thread.id) }}#reply-{{ r.id }}">
href="{{ url_for('threads.view', id=thread.id) }}#reply-{{ r.id }}">
{{ r.created_at | datetime }}
</a>
</div>
@@ -43,7 +43,7 @@
</div>
{% if current_user.canCommentRL() %}
<form method="post" action="{{ url_for('thread_page', id=thread.id)}}" class="card-body">
<form method="post" action="{{ url_for('threads.view', id=thread.id)}}" class="card-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<textarea class="form-control markdown" required maxlength=500 name="comment"></textarea><br />
<input class="btn btn-primary" type="submit" value="Comment" />
@@ -65,14 +65,14 @@
{% for t in threads %}
<li {% if list_group %}class="list-group-item"{% endif %}>
{% if list_group %}
<a href="{{ url_for('thread_page', id=t.id) }}">
<a href="{{ url_for('threads.view', id=t.id) }}">
{% if t.private %}&#x1f512; {% endif %}
{{ t.title }}
by {{ t.author.display_name }}
</a>
{% else %}
{% if t.private %}&#x1f512; {% endif %}
<a href="{{ url_for('thread_page', id=t.id) }}">{{ t.title }}</a>
<a href="{{ url_for('threads.view', id=t.id) }}">{{ t.title }}</a>
by {{ t.author.display_name }}
{% endif %}
</li>

View File

@@ -18,14 +18,14 @@
{% if topic.wip %}[WIP]{% endif %}
</td>
{% if show_author %}
<td><a href="{{ url_for('user_profile_page', username=topic.author.username) }}">{{ topic.author.display_name}}</a></td>
<td><a href="{{ url_for('users.profile', username=topic.author.username) }}">{{ topic.author.display_name}}</a></td>
{% endif %}
<td>{{ topic.name or ""}}</td>
<td>{{ topic.created_at | date }}</td>
<td class="btn-group">
{% if current_user == topic.author or topic.author.checkPerm(current_user, "CHANGE_AUTHOR") %}
<a class="btn btn-primary"
href="{{ url_for('create_edit_package_page', author=topic.author.username, repo=topic.getRepoURL(), forums=topic.topic_id, title=topic.title, bname=topic.name) }}">
href="{{ url_for('packages.create_edit', author=topic.author.username, repo=topic.getRepoURL(), forums=topic.topic_id, title=topic.title, bname=topic.name) }}">
Create
</a>
{% endif %}
@@ -56,10 +56,10 @@
{% if topic.wip %}[WIP]{% endif %}
{% if topic.name %}[{{ topic.name }}]{% endif %}
{% if show_author %}
by <a href="{{ url_for('user_profile_page', username=topic.author.username) }}">{{ topic.author.display_name }}</a>
by <a href="{{ url_for('users.profile', username=topic.author.username) }}">{{ topic.author.display_name }}</a>
{% endif %}
{% if topic.author == current_user or topic.author.checkPerm(current_user, "CHANGE_AUTHOR") %}
| <a href="{{ url_for('create_edit_package_page', author=topic.author.username, repo=topic.getRepoURL(), forums=topic.topic_id, title=topic.title, bname=topic.name) }}">Create</a>
| <a href="{{ url_for('packages.create_edit', author=topic.author.username, repo=topic.getRepoURL(), forums=topic.topic_id, title=topic.title, bname=topic.name) }}">Create</a>
{% endif %}
</li>
{% endfor %}

View File

@@ -7,7 +7,7 @@ Meta Packages
{% block content %}
<ul>
{% for meta in mpackages %}
<li><a href="{{ url_for('meta_package_page', name=meta.name) }}">{{ meta.name }}</a> ({{ meta.packages.filter_by(soft_deleted=False, approved=True).all() | count }} packages)</li>
<li><a href="{{ url_for('metapackages.view', name=meta.name) }}">{{ meta.name }}</a> ({{ meta.packages.filter_by(soft_deleted=False, approved=True).all() | count }} packages)</li>
{% else %}
<li><i>No meta packages found.</i></li>
{% endfor %}

View File

@@ -6,7 +6,7 @@ Notifications
{% block content %}
{% if current_user.notifications %}
<form method="post" action="{{ url_for('clear_notifications_page') }}">
<form method="post" action="{{ url_for('notifications.clear') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="submit" value="Clear All" />
</form>

View File

@@ -15,7 +15,7 @@
{% for n in range(1, page_max+1) %}
<li class="page-item {% if n == page %}active{% endif %}">
<a class="page-link"
href="{{ url_for('packages_page', type=type, q=query, page=n) }}">
href="{{ url_for('packages.list_all', type=type, q=query, page=n) }}">
{{ n }}
</a>
</li>

View File

@@ -5,6 +5,7 @@
{% endblock %}
{% block content %}
<h2>{{ _("Edit Release") }}</h2>
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %}
<form method="POST" action="">
{{ form.hidden_tag() }}
@@ -26,7 +27,7 @@
{% endif %}
{% if release.task_id %}
Importing... <a href="{{ url_for('check_task', id=release.task_id, r=release.getEditURL()) }}">view task</a><br />
Importing... <a href="{{ url_for('tasks.check', id=release.task_id, r=release.getEditURL()) }}">view task</a><br />
{% if package.checkPerm(current_user, "CHANGE_RELEASE_URL") %}
{{ render_field(form.task_id) }}
{% endif %}
@@ -59,6 +60,22 @@
{{ render_submit_field(form.submit) }}
</form>
<h2 class="mt-5">{{ _("Delete Release") }}</h2>
{% if release.checkPerm(current_user, "DELETE_RELEASE") %}
<form method="POST" action="{{ release.getDeleteURL() }}" class="alert alert-secondary mb-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input class="btn btn-sm btn-danger float-right" type="submit" value="{{ _('Delete') }}">
<b>{{ _("This is permanent.") }}</b>
{{ _("Any associated uploads will not be deleted immediately, but the release will no longer be listed.") }}
<div style="clear:both;"></div>
</form>
{% else %}
<div class="alert alert-secondary mb-5">
{{ _("You cannot delete the latest release; please create a newer one first.") }}
</div>
{% endif %}
{% endblock %}
{% block scriptextra %}

View File

@@ -23,14 +23,14 @@
</p>
<div class="row" style="margin-top: 2rem;">
<div class="col">
<div class="col text-secondary">
{{ package.getDownloadCount() }} downloads
</div>
<div class="btn-group-horizontal col-md-auto">
{% if package.repo %}<a class="btn btn-secondary" href="{{ package.repo }}">View Source</a>{% endif %}
{% if package.forums %}<a class="btn btn-secondary" href="https://forum.minetest.net/viewtopic.php?t={{ package.forums }}">Forums</a>{% endif %}
{% if package.issueTracker %}<a class="btn btn-secondary" href="{{ package.issueTracker }}">Issue Tracker</a>{% endif %}
{% if package.website %}<a class="btn btn-secondary" href="{{ package.website }}">Website</a>{% endif %}
{% if package.repo %}<a class="btn btn-outline-secondary" href="{{ package.repo }}">View Source</a>{% endif %}
{% if package.forums %}<a class="btn btn-outline-secondary" href="https://forum.minetest.net/viewtopic.php?t={{ package.forums }}">Forums</a>{% endif %}
{% if package.issueTracker %}<a class="btn btn-outline-secondary" href="{{ package.issueTracker }}">Issue Tracker</a>{% endif %}
{% if package.website %}<a class="btn btn-outline-secondary" href="{{ package.website }}">Website</a>{% endif %}
</div>
</div>
</div>
@@ -103,7 +103,7 @@
{% if not review_thread and (package.author == current_user or package.checkPerm(current_user, "APPROVE_NEW")) %}
<div class="alert alert-info">
<a class="float-right btn btn-sm btn-info" href="{{ url_for('new_thread_page', pid=package.id, title='Package approval comments') }}">Open Thread</a>
<a class="float-right btn btn-sm btn-info" href="{{ url_for('threads.new', pid=package.id, title='Package approval comments') }}">Open Thread</a>
Privately ask a question or give feedback
<div style="clear:both;"></div>
@@ -172,14 +172,14 @@
<td>Provides</td>
<td>{% for meta in package.provides %}
<a class="badge badge-primary"
href="{{ url_for('meta_package_page', name=meta.name) }}">{{ meta.name }}</a>
href="{{ url_for('metapackages.view', name=meta.name) }}">{{ meta.name }}</a>
{% endfor %}</td>
</tr>
{% endif %}
<tr>
<td>Author</td>
<td class="{{ package.author.rank }}">
<a href="{{ url_for('user_profile_page', username=package.author.username) }}">
<a href="{{ url_for('users.profile', username=package.author.username) }}">
{{ package.author.display_name }}
</a>
</td>
@@ -241,7 +241,7 @@
{{ dep.package.title }} by {{ dep.package.author.display_name }}
{% elif dep.meta_package %}
<a class="badge badge-{{ color }}"
href="{{ url_for('meta_package_page', name=dep.meta_package.name) }}">
href="{{ url_for('metapackages.view', name=dep.meta_package.name) }}">
{{ dep.meta_package.name }}
{% else %}
{{ "Excepted package or meta_package in dep!" | throw }}
@@ -301,7 +301,7 @@
created {{ rel.releaseDate | date }}.
</small>
{% if (package.checkPerm(current_user, "MAKE_RELEASE") or package.checkPerm(current_user, "APPROVE_RELEASE")) and rel.task_id %}
<a href="{{ url_for('check_task', id=rel.task_id, r=package.getDetailsURL()) }}">Importing...</a>
<a href="{{ url_for('tasks.check', id=rel.task_id, r=package.getDetailsURL()) }}">Importing...</a>
{% elif not rel.approved %}
Waiting for approval.
{% endif %}
@@ -320,7 +320,7 @@
<div class="card-header">
{% if package.approved and package.checkPerm(current_user, "CREATE_THREAD") %}
<a class="float-right"
href="{{ url_for('new_thread_page', pid=package.id) }}">+</a>
href="{{ url_for('threads.new', pid=package.id) }}">+</a>
{% endif %}
Threads
</div>
@@ -332,7 +332,7 @@
{% if package.approved and package.checkPerm(current_user, "CREATE_THREAD") and current_user != package.author and not current_user.rank.atLeast(current_user.rank.EDITOR) %}
<a class="float-right"
href="{{ url_for('new_thread_page', pid=package.id) }}">
href="{{ url_for('threads.new', pid=package.id) }}">
Report a problem with this listing
</a>
{% endif %}
@@ -381,7 +381,7 @@
<li>
<a href="{{ r.getURL() }}">{{ r.title }}</a>
by
<a href="{{ url_for('user_profile_page', username=r.author.username) }}">{{ r.author.display_name }}</a>
<a href="{{ url_for('users.profile', username=r.author.username) }}">{{ r.author.display_name }}</a>
</li>
{% else %}
<li>No edit requests have been made.</li>

View File

@@ -16,7 +16,7 @@ Working
<script>
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
pollTask("{{ url_for('check_task', id=info.id) }}", true)
pollTask("{{ url_for('tasks.check', id=info.id) }}", true)
.then(function() { location.reload() })
.catch(function() { location.reload() })
</script>

View File

@@ -63,7 +63,7 @@
{% if canApproveScn and screenshots %}
<div class="card my-4">
<h3 class="card-header">Screenshots
<form class="float-right" method="post" action="{{ url_for('todo_page') }}">
<form class="float-right" method="post" action="{{ url_for('todo.view') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="action" value="screenshots_approve_all" />
<input class="btn btn-sm btn-primary" type="submit" value="Approve All" />
@@ -101,17 +101,23 @@
<h2 class="mt-4">Unadded Topic List</h2>
<p>
{{ total_topics - topics_to_add }} / {{ total_topics }} packages have been been added to cdb,
based on cdb's forum parser. {{ topics_to_add }} remaining.
</p>
{% if total_topics > 0 %}
<p>
{{ total_topics - topics_to_add }} / {{ total_topics }} packages have been been added to cdb,
based on cdb's forum parser. {{ topics_to_add }} remaining.
</p>
<div class="progress my-4">
{% set perc = 32 %}
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ perc }}%" aria-valuenow="{{ perc }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<div class="progress my-4">
{% set perc = 100 * (total_topics - topics_to_add) / total_topics %}
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ perc }}%" aria-valuenow="{{ perc }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<a class="btn btn-primary" href="{{ url_for('todo_topics_page') }}">View Unadded Topic List</a>
<a class="btn btn-primary" href="{{ url_for('todo.topics') }}">View Unadded Topic List</a>
{% else %}
<p>
The forum topic crawler needs to run at least once for this section to work.
</p>
{% endif %}
{% endblock %}

View File

@@ -8,15 +8,15 @@ Topics to be Added
<div class="float-right">
<div class="btn-group">
<a class="btn btn-primary {% if sort_by=='date' %}active{% endif %}"
href="{{ url_for('todo_topics_page', q=query, show_discarded=show_discarded, n=n, sort='date') }}">
href="{{ url_for('todo.topics', q=query, show_discarded=show_discarded, n=n, sort='date') }}">
Sort by date
</a>
<a class="btn btn-primary {% if sort_by=='name' %}active{% endif %}"
href="{{ url_for('todo_topics_page', q=query, show_discarded=show_discarded, n=n, sort='name') }}">
href="{{ url_for('todo.topics', q=query, show_discarded=show_discarded, n=n, sort='name') }}">
Sort by name
</a>
<a class="btn btn-primary {% if sort_by=='views' %}active{% endif %}"
href="{{ url_for('todo_topics_page', q=query, show_discarded=show_discarded, n=n, sort='views') }}">
href="{{ url_for('todo.topics', q=query, show_discarded=show_discarded, n=n, sort='views') }}">
Sort by views
</a>
</div>
@@ -26,18 +26,18 @@ Topics to be Added
{% if current_user.rank.atLeast(current_user.rank.EDITOR) %}
{% if n >= 10000 %}
<a class="btn btn-primary"
href="{{ url_for('todo_topics_page', q=query, show_discarded=show_discarded, n=100, sort=sort_by) }}">
href="{{ url_for('todo.topics', q=query, show_discarded=show_discarded, n=100, sort=sort_by) }}">
Paginated list
</a>
{% else %}
<a class="btn btn-primary"
href="{{ url_for('todo_topics_page', q=query, show_discarded=show_discarded, n=10000, sort=sort_by) }}">
href="{{ url_for('todo.topics', q=query, show_discarded=show_discarded, n=10000, sort=sort_by) }}">
Unlimited list
</a>
{% endif %}
{% endif %}
<a class="btn btn-primary" href="{{ url_for('todo_topics_page', q=query, show_discarded=not show_discarded, n=n, sort=sort_by) }}">
<a class="btn btn-primary" href="{{ url_for('todo.topics', q=query, show_discarded=not show_discarded, n=n, sort=sort_by) }}">
{% if not show_discarded %}
Show
{% else %}
@@ -51,17 +51,23 @@ Topics to be Added
<h1>Topics to be Added</h1>
<p>
{{ total - topic_count }} / {{ total }} topics have been added as packages to CDB.
{{ topic_count }} remaining.
</p>
<div class="progress">
{% set perc = 100 * (total - topic_count) / total %}
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ perc }}%" aria-valuenow="{{ perc }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
{% if topic_count > 0 %}
<p>
{{ total - topic_count }} / {{ total }} topics have been added as packages to CDB.
{{ topic_count }} remaining.
</p>
<div class="progress">
{% set perc = 100 * (total - topic_count) / total %}
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ perc }}%" aria-valuenow="{{ perc }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
{% else %}
<p>
The forum topic crawler needs to run at least once for this section to work.
</p>
{% endif %}
<form method="GET" action="{{ url_for('todo_topics_page') }}" class="my-4">
<form method="GET" action="{{ url_for('todo.topics') }}" class="my-4">
<input type="hidden" name="show_discarded" value={{ show_discarded and "True" or "False" }} />
<input type="hidden" name="n" value={{ n }} />
<input type="hidden" name="sort" value={{ sort_by or "date" }} />
@@ -79,7 +85,7 @@ Topics to be Added
{% for i in range(1, page_max+1) %}
<li class="page-item {% if i == page %}active{% endif %}">
<a class="page-link"
href="{{ url_for('todo_topics_page', page=i, query=query, show_discarded=show_discarded, n=n, sort=sort_by) }}">
href="{{ url_for('todo.topics', page=i, query=query, show_discarded=show_discarded, n=n, sort=sort_by) }}">
{{ i }}
</a>
</li>

View File

@@ -19,7 +19,7 @@ Creating an Account
Please log out to continue.
</p>
<p>
<a href="{{ url_for('user.logout', next=url_for('user_claim_page')) }}" class="btn">Logout</a>
<a href="{{ url_for('user.logout', next=url_for('users.claim')) }}" class="btn">Logout</a>
</p>
{% else %}
<p>
@@ -44,7 +44,7 @@ Creating an Account
Use GitHub field in forum profile
</div>
<form method="post" class="card-body" action="{{ url_for('user_claim_page') }}">
<form method="post" class="card-body" action="{{ url_for('users.claim') }}">
<input class="form-control" type="hidden" name="claim_type" value="github">
<input class="form-control" type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
@@ -73,7 +73,7 @@ Creating an Account
Verification token
</div>
<form method="post" class="card-body" action="{{ url_for('user_claim_page') }}">
<form method="post" class="card-body" action="{{ url_for('users.claim') }}">
<input type="hidden" name="claim_type" value="forum">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />

View File

@@ -8,7 +8,7 @@
<ul class="userlist">
{% for user in users %}
<li class="{{ user.rank }}">
<a href="{{ url_for('user_profile_page', username=user.username) }}">
<a href="{{ url_for('users.profile', username=user.username) }}">
{{ user.display_name }}
</a> -
{{ user.rank.getTitle() }}

View File

@@ -9,7 +9,7 @@
{% if not current_user.is_authenticated and user.rank == user.rank.NOT_JOINED and user.forums_username %}
<div class="alert alert-info">
<a class="float-right btn btn-default btn-sm"
href="{{ url_for('user_claim_page', username=user.forums_username) }}">Claim</a>
href="{{ url_for('users.claim', username=user.forums_username) }}">Claim</a>
Is this you? Claim your account now!
</div>
@@ -57,7 +57,7 @@
{% if user.github_username %}
<a href="https://github.com/{{ user.github_username }}">GitHub</a>
{% elif user == current_user %}
<a href="{{ url_for('github_signin_page') }}">Link Github</a>
<a href="{{ url_for('users.github_signin') }}">Link Github</a>
{% endif %}
{% if user.website_url %}
@@ -78,7 +78,7 @@
<td>Admin</td>
<td>
{% if user.email %}
<a class="btn btn-primary" href="{{ url_for('send_email_page', username=user.username) }}">
<a class="btn btn-primary" href="{{ url_for('users.send_email', username=user.username) }}">
Email
</a>
{% else %}
@@ -97,7 +97,7 @@
<td>Profile Picture:</td>
<td>
{% if user.forums_username %}
<form method="post" action="{{ url_for('user_check', username=user.username) }}" class="" style="display:inline-block;">
<form method="post" action="{{ url_for('users.user_check', username=user.username) }}" class="" style="display:inline-block;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="submit" class="btn btn-primary" value="Sync with Forums" />
</form>
@@ -122,11 +122,20 @@
{% if user.password %}
Set | <a href="{{ url_for('user.change_password') }}">Change</a>
{% else %}
Not set | <a href="{{ url_for('set_password_page') }}">Set</a>
Not set | <a href="{{ url_for('users.set_password') }}">Set</a>
{% endif %}
</td>
</tr>
{% endif %}
{% if user.checkPerm(current_user, "CREATE_TOKEN") %}
<tr>
<td>API Tokens:</td>
<td>
<a href="{{ url_for('api.list_tokens', username=user.username) }}">Manage</a>
<span class="badge badge-primary">{{ user.tokens.count() }}</span>
</td>
</tr>
{% endif %}
</table>
</div>
</div>

View File

@@ -10,7 +10,7 @@
<div class="alert alert-primary">
It is recommended that you set a password for your account.
<a class="alert_right button" href="{{ url_for('home_page') }}">Skip</a>
<a class="alert_right button" href="{{ url_for('homepage.home') }}">Skip</a>
</div>
{% endif %}

105
app/tests/test_api.py Normal file
View File

@@ -0,0 +1,105 @@
import pytest
from app import app
from app.default_data import populate_test_data
from app.models import db, License, Tag, User, UserRank, Package
from utils import client, recreate_db, parse_json
from utils import is_str, is_int, is_optional
def validate_package_list(packages, strict=False):
valid_keys = {
"author", "name", "release",
"short_description", "thumbnail",
"title", "type"
}
for package in packages:
assert set(package.keys()).issubset(valid_keys)
assert is_str(package.get("author"))
assert is_str(package.get("name"))
if strict:
assert is_int(package.get("release"))
else:
assert is_optional(int, package.get("release"))
assert is_str(package.get("short_description"))
assert is_optional(str, package.get("thumbnail"))
assert is_str(package.get("title"))
assert is_str(package.get("type"))
def test_packages_empty(client):
"""Start with a blank database."""
rv = client.get("/api/packages/")
assert parse_json(rv.data) == []
def test_packages_with_contents(client):
"""Start with a test database."""
populate_test_data(db.session)
db.session.commit()
rv = client.get("/api/packages/")
packages = parse_json(rv.data)
assert len(packages) > 0
assert len(packages) == Package.query.filter_by(approved=True).count()
validate_package_list(packages)
def test_packages_with_query(client):
"""Start with a test database."""
populate_test_data(db.session)
db.session.commit()
rv = client.get("/api/packages/?q=food")
packages = parse_json(rv.data)
assert len(packages) == 2
validate_package_list(packages)
assert (packages[0]["name"] == "food" and packages[1]["name"] == "food_sweet") or \
(packages[1]["name"] == "food" and packages[0]["name"] == "food_sweet")
def test_packages_with_protocol_high(client):
"""Start with a test database."""
populate_test_data(db.session)
db.session.commit()
rv = client.get("/api/packages/?protocol_version=40")
packages = parse_json(rv.data)
assert len(packages) == 4
for package in packages:
assert package["name"] != "mesecons"
validate_package_list(packages, True)
def test_packages_with_protocol_low(client):
"""Start with a test database."""
populate_test_data(db.session)
db.session.commit()
rv = client.get("/api/packages/?protocol_version=20")
packages = parse_json(rv.data)
assert len(packages) == 4
for package in packages:
assert package["name"] != "awards"
validate_package_list(packages, True)

View File

@@ -0,0 +1,22 @@
import pytest
from app import app
from app.default_data import populate_test_data
from app.models import db, License, Tag, User, UserRank
from utils import client, recreate_db
def test_homepage_empty(client):
"""Start with a blank database."""
rv = client.get("/")
assert b"No packages available" in rv.data and b"packagetile" not in rv.data
def test_homepage_with_contents(client):
"""Start with a test database."""
populate_test_data(db.session)
db.session.commit()
rv = client.get("/")
assert b"No packages available" not in rv.data and b"packagetile" in rv.data

45
app/tests/utils.py Normal file
View File

@@ -0,0 +1,45 @@
import pytest, json
from app import app
from app.models import db, User
from app.default_data import populate
def clear_data(session):
meta = db.metadata
for table in reversed(meta.sorted_tables):
session.execute(f'ALTER TABLE "{table.name}" DISABLE TRIGGER ALL;')
session.execute(table.delete())
session.execute(f'ALTER TABLE "{table.name}" ENABLE TRIGGER ALL;')
#session.execute(table.delete())
def recreate_db():
clear_data(db.session)
populate(db.session)
db.session.commit()
def parse_json(b):
return json.loads(b.decode("utf8"))
def is_type(t, v):
return v and isinstance(v, t)
def is_optional(t, v):
return not v or isinstance(v, t)
def is_str(v):
return is_type(str, v)
def is_int(v):
return is_type(int, v)
@pytest.fixture
def client():
app.config["TESTING"] = True
recreate_db()
assert User.query.count() == 1
with app.test_client() as client:
yield client
app.config["TESTING"] = False

View File

@@ -22,6 +22,12 @@ from app.models import *
from app import app
import random, string, os, imghdr
def get_int_or_abort(v, default):
try:
return int(v or default)
except ValueError:
abort(400)
def getExtension(filename):
return filename.rsplit(".", 1)[1].lower() if "." in filename else None
@@ -43,7 +49,9 @@ def randomString(n):
def doFileUpload(file, fileType, fileTypeDesc):
if not file or file is None or file.filename == "":
flash("No selected file", "error")
return None
return None, None
assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist"
allowedExtensions = []
isImage = False
@@ -58,17 +66,18 @@ def doFileUpload(file, fileType, fileTypeDesc):
ext = getExtension(file.filename)
if ext is None or not ext in allowedExtensions:
flash("Please upload load " + fileTypeDesc, "danger")
return None
return None, None
if isImage and not isAllowedImage(file.stream.read()):
flash("Uploaded image isn't actually an image", "danger")
return None
return None, None
file.stream.seek(0)
filename = randomString(10) + "." + ext
file.save(os.path.join("app/public/uploads", filename))
return "/uploads/" + filename
filepath = os.path.join(app.config["UPLOAD_DIR"], filename)
file.save(filepath)
return "/uploads/" + filename, filepath
def make_flask_user_password(plaintext_str):
# http://passlib.readthedocs.io/en/stable/modular_crypt_format.html
@@ -93,7 +102,7 @@ def make_flask_user_password(plaintext_str):
else:
return password.decode("UTF-8")
def _do_login_user(user, remember_me=False):
def loginUser(user):
def _call_or_get(v):
if callable(v):
return v()
@@ -119,29 +128,14 @@ def _do_login_user(user, remember_me=False):
flash("Your account has not been enabled.", "error")
return False
# Check if user has a confirmed email address
user_manager = current_app.user_manager
if user_manager.enable_email and user_manager.enable_confirm_email \
and not current_app.user_manager.enable_login_without_confirm_email \
and not user.has_confirmed_email():
url = url_for("user.resend_confirm_email")
flash("Your email address has not yet been confirmed", "error")
return False
# Use Flask-Login to sign in user
login_user(user, remember=remember_me)
login_user(user, remember=True)
signals.user_logged_in.send(current_app._get_current_object(), user=user)
flash("You have signed in successfully.", "success")
return True
def loginUser(user):
user_mixin = None
if user_manager.enable_username:
user_mixin = user_manager.find_user_by_username(user.username)
return _do_login_user(user_mixin, True)
def rank_required(rank):
def decorator(f):

View File

@@ -1,84 +0,0 @@
# Content DB
# Copyright (C) 2018 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from app import app, pages
from flask import *
from flask_user import *
from app.models import *
import flask_menu as menu
from werkzeug.contrib.cache import SimpleCache
from urllib.parse import urlparse
from sqlalchemy.sql.expression import func
cache = SimpleCache()
@app.context_processor
def inject_debug():
return dict(debug=app.debug)
@app.template_filter()
def throw(err):
raise Exception(err)
@app.template_filter()
def domain(url):
return urlparse(url).netloc
@app.template_filter()
def date(value):
return value.strftime("%Y-%m-%d")
@app.template_filter()
def datetime(value):
return value.strftime("%Y-%m-%d %H:%M") + " UTC"
@app.route("/uploads/<path:path>")
def send_upload(path):
return send_from_directory("public/uploads", path)
@app.route("/")
@menu.register_menu(app, ".", "Home")
def home_page():
query = Package.query.filter_by(approved=True, soft_deleted=False)
count = query.count()
new = query.order_by(db.desc(Package.created_at)).limit(8).all()
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(4).all()
pop_txp = query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(4).all()
downloads = db.session.query(func.sum(PackageRelease.downloads)).first()[0]
return render_template("index.html", count=count, downloads=downloads, \
new=new, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam)
from . import users, packages, meta, threads, api
from . import sass, thumbnails, tasks, admin
@menu.register_menu(app, ".help", "Help", order=19, endpoint_arguments_constructor=lambda: { 'path': 'help' })
@app.route('/<path:path>/')
def flatpage(path):
page = pages.get_or_404(path)
template = page.meta.get('template', 'flatpage.html')
return render_template(template, page=page)
@app.before_request
def do_something_whenever_a_request_comes_in():
if current_user.is_authenticated:
if current_user.rank == UserRank.BANNED:
flash("You have been banned.", "error")
logout_user()
return redirect(url_for('user.login'))
elif current_user.rank == UserRank.NOT_JOINED:
current_user.rank = UserRank.MEMBER
db.session.commit()

View File

@@ -1,28 +1,35 @@
USER_APP_NAME="Content DB"
SERVER_NAME="content.minetest.net"
BASE_URL="http://" + SERVER_NAME
USER_APP_NAME = "Content DB"
SERVER_NAME = "content.minetest.net"
BASE_URL = "http://" + SERVER_NAME
SECRET_KEY=""
WTF_CSRF_SECRET_KEY=""
SECRET_KEY = ""
WTF_CSRF_SECRET_KEY = ""
SQLALCHEMY_DATABASE_URI = "sqlite:///../db.sqlite"
GITHUB_CLIENT_ID = ""
GITHUB_CLIENT_SECRET = ""
CELERY_BROKER_URL='redis://localhost:6379'
CELERY_RESULT_BACKEND='redis://localhost:6379'
REDIS_URL = 'redis://redis:6379'
CELERY_BROKER_URL = 'redis://redis:6379'
CELERY_RESULT_BACKEND = 'redis://redis:6379'
USER_ENABLE_USERNAME = True
USER_ENABLE_REGISTER = False
USER_ENABLE_CHANGE_USERNAME = False
MAIL_USERNAME=""
MAIL_PASSWORD=""
MAIL_DEFAULT_SENDER=""
MAIL_SERVER=""
MAIL_PORT=587
MAIL_USE_TLS=True
MAIL_UTILS_ERROR_SEND_TO=[""]
MAIL_USERNAME = ""
MAIL_PASSWORD = ""
USER_EMAIL_SENDER_NAME = ""
USER_EMAIL_SENDER_EMAIL = ""
MAIL_DEFAULT_SENDER = ""
MAIL_SERVER = ""
MAIL_PORT = 587
MAIL_USE_TLS = True
MAIL_UTILS_ERROR_SEND_TO = [""]
UPLOAD_DIR = "/var/cdb/uploads/"
THUMBNAIL_DIR = "/var/cdb/thumbnails/"
LANGUAGES = {
'en': 'English',

View File

@@ -15,15 +15,15 @@ services:
app:
build: .
command: ./utils/run.sh
command: ./utils/entrypoint.sh
env_file:
- config.env
ports:
- 5123:5123
volumes:
- "./data/uploads:/home/cdb/app/public/uploads"
- "./app:/home/cdb/appsrc"
- "./migrations:/home/cdb/migrations"
- "./data/uploads:/var/cdb/uploads"
- "./app:/source/app"
- "./migrations:/source/migrations"
depends_on:
- db
- redis
@@ -36,6 +36,27 @@ services:
environment:
- FLASK_CONFIG=../config.cfg
volumes:
- "./data/uploads:/home/cdb/app/public/uploads"
- "./data/uploads:/var/cdb/uploads"
- "./app:/home/cdb/app"
depends_on:
- redis
beat:
build: .
command: celery -A app.tasks.celery beat
env_file:
- config.env
environment:
- FLASK_CONFIG=../config.cfg
depends_on:
- redis
flower:
image: mher/flower
command: ["flower", "--broker=redis://redis:6379/0", "--port=5124"]
env_file:
- config.env
ports:
- 5124:5124
depends_on:
- redis

View File

@@ -0,0 +1,26 @@
"""empty message
Revision ID: 306ce331a2a7
Revises: 6dca6eceb04d
Create Date: 2020-01-18 23:00:40.487425
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '306ce331a2a7'
down_revision = '6dca6eceb04d'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
op.create_check_constraint("CK_approval_valid", "package_release", "not approved OR (task_id IS NULL AND NOT url = '')")
def downgrade():
conn = op.get_bind()
op.drop_constraint("CK_approval_valid", "package_release", type_="check")

View File

@@ -0,0 +1,24 @@
"""empty message
Revision ID: 64fee8e5ab34
Revises: 306ce331a2a7
Create Date: 2020-01-19 02:28:05.432244
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '64fee8e5ab34'
down_revision = '306ce331a2a7'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column('user', 'confirmed_at', nullable=False, new_column_name='email_confirmed_at')
def downgrade():
op.alter_column('user', 'email_confirmed_at', nullable=False, new_column_name='confirmed_at')

View File

@@ -0,0 +1,30 @@
"""empty message
Revision ID: 6dca6eceb04d
Revises: fd25bf3e57c3
Create Date: 2020-01-18 17:32:21.885068
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy_searchable import sync_trigger
# revision identifiers, used by Alembic.
revision = '6dca6eceb04d'
down_revision = 'fd25bf3e57c3'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
sync_trigger(conn, 'package', 'search_vector', ["name", "title", "short_desc", "desc"])
op.create_check_constraint("name_valid", "package", "name ~* '^[a-z0-9_]+$'")
def downgrade():
conn = op.get_bind()
sync_trigger(conn, 'package', 'search_vector', ["title", "short_desc", "desc"])
op.drop_constraint("name_valid", "package", type_="check")

View File

@@ -0,0 +1,31 @@
"""empty message
Revision ID: a0f6c8743362
Revises: 64fee8e5ab34
Create Date: 2020-01-19 19:12:39.402679
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'a0f6c8743362'
down_revision = '64fee8e5ab34'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column('user', 'password',
existing_type=sa.VARCHAR(length=255),
nullable=False,
existing_server_default=sa.text("''::character varying"),
server_default='')
def downgrade():
op.alter_column('user', 'password',
existing_type=sa.VARCHAR(length=255),
nullable=True,
existing_server_default=sa.text("''::character varying"))

View File

@@ -0,0 +1,37 @@
"""empty message
Revision ID: fd25bf3e57c3
Revises: d6ae9682c45f
Create Date: 2019-11-26 23:43:47.476346
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'fd25bf3e57c3'
down_revision = 'd6ae9682c45f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('api_token',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('access_token', sa.String(length=34), nullable=True),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('access_token')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('api_token')
# ### end Alembic commands ###

View File

@@ -1,4 +1,4 @@
Flask~=1.0
Flask~=1.1
Flask-FlatPages~=0.7
Flask-Gravatar~=0.5
Flask-Login~=0.4.1
@@ -6,17 +6,21 @@ Flask-Markdown~=0.3
Flask-Menu~=0.7
Flask-Migrate~=2.3
Flask-SQLAlchemy~=2.3
Flask-User~=0.6
Flask-User~=1.0
Flask-Babel
GitHub-Flask~=3.2
SQLAlchemy-Searchable==1.0.3
SQLAlchemy-Searchable~=1.1
beautifulsoup4~=4.6
celery==4.1.1
kombu==4.2.0
GitPython~=2.1
celery~=4.4
kombu~=4.6
GitPython~=3.0
git-archive-all~=1.20
lxml~=4.2
pillow~=5.3
pillow~=7.0
pyScss~=1.3
redis==2.10.6
redis~=3.3
psycopg2~=2.7
pytest ~= 5.3
pytest-cov ~= 2.8

5
utils/bash.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
# Open SSH to app instance
docker exec -it contentdb_app_1 bash

9
utils/create_migration.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
# Create a database migration, and copy it back to the host.
docker exec contentdb_app_1 sh -c "FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db migrate"
docker exec -u root contentdb_app_1 sh -c "cp /home/cdb/migrations/versions/* /source/migrations/versions/"
USER=$(whoami)
sudo chown -R $USER:$USER migrations/versions

5
utils/db.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
# Open SQL console for the database
docker exec -it contentdb_db_1 sh -c "psql contentdb contentdb"

20
utils/entrypoint.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
#
# The entrypoint for the docker containers
#
# Debug
# FLASK_APP=app/__init__.py FLASK_CONFIG=../config.cfg FLASK_DEBUG=1 python3 -m flask run -h 0.0.0.0 -p 5123
if [ -z "$FLASK_DEBUG" ]; then
echo "FLASK_DEBUG is required in config.env"
exit 1
fi
if [ "$FLASK_DEBUG" -eq "1" ]; then
FLASK_APP=app/__init__.py FLASK_CONFIG=../config.cfg FLASK_RUN_PORT=5123 flask run --host=0.0.0.0
else
ENV="-e FLASK_APP=app/__init__.py -e FLASK_CONFIG=../config.cfg -e FLASK_DEBUG=$FLASK_DEBUG"
gunicorn -w 4 -b :5123 $ENV app:app
fi

31
utils/gitlabci/config.cfg Normal file
View File

@@ -0,0 +1,31 @@
USER_APP_NAME="Content DB"
SERVER_NAME="localhost:5123"
BASE_URL="http://" + SERVER_NAME
SECRET_KEY="changeme"
WTF_CSRF_SECRET_KEY="changeme"
SQLALCHEMY_DATABASE_URI = "postgres://contentdb:password@db:5432/contentdb"
GITHUB_CLIENT_ID = ""
GITHUB_CLIENT_SECRET = ""
REDIS_URL='redis://redis:6379'
CELERY_BROKER_URL='redis://redis:6379'
CELERY_RESULT_BACKEND='redis://redis:6379'
USER_ENABLE_USERNAME = True
USER_ENABLE_REGISTER = False
USER_ENABLE_CHANGE_USERNAME = False
USER_ENABLE_EMAIL = False
MAIL_UTILS_ERROR_SEND_TO = [""]
UPLOAD_DIR="/var/cdb/uploads/"
THUMBNAIL_DIR="/var/cdb/thumbnails/"
TEMPLATES_AUTO_RELOAD = True
LANGUAGES = {
'en': 'English',
}

View File

@@ -0,0 +1,4 @@
POSTGRES_USER=contentdb
POSTGRES_PASSWORD=password
POSTGRES_DB=contentdb
FLASK_DEBUG=1

5
utils/reload.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
# Hot/live reload - only works in debug mode
docker exec contentdb_app_1 sh -c "cp -r /source/* ."

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