Compare commits

...

67 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
rubenwardy
015abe5a25 Indicate stuck releases in todo list and allow admins to delete them 2019-11-14 23:39:41 +00:00
rubenwardy
719a652235 Enable hot code reloading 2019-11-14 23:38:29 +00:00
rubenwardy
50892ce9fc Add debug warning to template 2019-11-14 23:38:11 +00:00
rubenwardy
2e14836ed6 Fix permission issues by not mounting source code 2019-11-14 23:02:36 +00:00
rubenwardy
35e1aba4ad Fix CDB running as root in docker container 2019-11-14 22:53:46 +00:00
rubenwardy
913537f96f Sort packages in approval queue by creation date 2019-11-14 22:43:05 +00:00
rubenwardy
b36a60d3a2 Fix worker start command in docker-compose.yml 2019-11-14 22:42:49 +00:00
rubenwardy
df247b021e Improve docker image and deployment scripts 2019-11-14 22:24:37 +00:00
rubenwardy
9f678d8fde Add issue templates 2019-11-12 22:49:47 +00:00
rubenwardy
d89442438f Add security policy 2019-11-12 22:46:42 +00:00
rubenwardy
08a9ae7b94 Make review threads public by default 2019-11-12 22:39:17 +00:00
rubenwardy
904e09f0dd Create utils folder 2019-11-12 22:36:30 +00:00
Alex
038ef5b739 Specify excessive horror 2019-10-22 21:18:56 +01:00
TumeniNodes
f8958ae1bc Fix error in thread privacy message 2019-10-22 21:17:08 +01:00
rubenwardy
03eccbd56a Fix text in emails 2019-09-15 18:43:22 +01:00
rubenwardy
fb31ea3c22 Fix git clone breaking when branch is None 2019-09-15 18:30:42 +01:00
rubenwardy
4082863b5a Add basic dependency resolution 2019-09-03 00:42:51 +01:00
rubenwardy
cc564af44e Fix broken reference based git import
Fixes #130
2019-08-31 22:09:19 +01:00
rubenwardy
655ed2255a Fix crash by truncating notification titles 2019-08-31 18:38:15 +01:00
rubenwardy
96b22744ec Fix crash on null release task_id 2019-08-31 18:32:59 +01:00
rubenwardy
130d0bc7a0 Fix wtfforms setting fields to empty string instead of None 2019-08-30 21:11:38 +01:00
rubenwardy
1469e37c38 Fix accidental limit on password length 2019-08-12 14:10:28 +01:00
rubenwardy
6ce495fcd3 Fix crash on reading mod.conf from Github 2019-08-09 11:27:54 +01:00
rubenwardy
776a3eff2a Fail gracefully when given a bad git reference 2019-08-09 11:25:19 +01:00
rubenwardy
04e8ae5bdd Fix unexpected crash on bad Github URL 2019-08-09 11:17:39 +01:00
rubenwardy
18b9fb3876 Fix typo in zip uploading 2019-08-09 11:10:45 +01:00
115 changed files with 2276 additions and 893 deletions

5
.dockerignore Normal file
View File

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

13
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,13 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: Unconfirmed Bug
assignees: ''
---
## Summary
Describe your problem here
##### Steps to reproduce
For bug reports or build issues, explain how the problem happened

View File

@@ -0,0 +1,25 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: Feature
assignees: ''
---
## Problem
A clear and concise description of what the problem is.
ie: Why is this needed?
Ex. I'm always frustrated when [...]
## Solutions
A clear and concise description of what you want to happen.
## Alternatives
A clear and concise description of any alternative solutions or features you've considered.
## Additional context
Add any other context or screenshots about the feature request here.

7
.github/ISSUE_TEMPLATE/policy.md vendored Normal file
View File

@@ -0,0 +1,7 @@
---
name: Policy suggestion
about: Suggest a change to the guidelines
title: ''
labels: Policy
assignees: ''
---

19
.github/SECURITY.md vendored Normal file
View File

@@ -0,0 +1,19 @@
# Security Policy
## Supported Versions
We only support the latest production version, deployed to <https://content.minetest.net>.
See the [releases page](https://github.com/minetest/contentdb/releases).
## Reporting a Vulnerability
We ask that you report vulnerabilities privately, by contacting rubenwardy,
to give us time to fix them. You can do that by using one of the methods outlined in the following link:
* https://rubenwardy.com/contact/
Depending on severity, we will either create a private issue for the vulnerability
and release a security update, or give you permission to file the issue publicly.
For more information on the justification of this policy, see
[Responsible Disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure).

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

@@ -1,17 +1,22 @@
FROM python:3.6
RUN groupadd -g 5123 cdb && \
useradd -r -u 5123 -g cdb 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
RUN pip install psycopg2
COPY runprodguni.sh ./
COPY rundebug.sh ./
RUN chmod +x runprodguni.sh
COPY setup.py ./setup.py
COPY app app
COPY utils utils
COPY config.cfg config.cfg
COPY migrations migrations
COPY config.cfg ./config.cfg
COPY app app
RUN chown -R cdb:cdb /home/cdb
USER cdb

View File

@@ -13,15 +13,51 @@ Note: you should first read one of the guides on the [Github repo wiki](https://
FLASK_CONFIG=../config.cfg celery -A app.tasks.celery worker
# if sqlite
python setup.py -t
python utils/setup.py -t
rm db.sqlite && python setup.py -t && FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db stamp head
# 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,15 +50,44 @@ gravatar = Gravatar(app,
use_ssl=True,
base_url=None)
if not app.debug:
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)
@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,27 +18,46 @@
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":
action = request.form["action"]
if action == "importmodlist":
if action == "delstuckreleases":
PackageRelease.query.filter(PackageRelease.task_id != None).delete()
db.session.commit()
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) \
@@ -48,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:
@@ -56,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 = {}
@@ -68,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:
@@ -106,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,35 +17,66 @@
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/topics/")
def api_topics_page():
@bp.route("/api/packages/<author>/<name>/dependencies/")
@is_package_page
def package_dependencies(package):
ret = []
for dep in package.dependencies:
name = None
fulfilled_by = None
if dep.package:
name = dep.package.name
fulfilled_by = [ dep.package.getAsDictionaryKey() ]
elif dep.meta_package:
name = dep.meta_package.name
fulfilled_by = [ pkg.getAsDictionaryKey() for pkg in dep.meta_package.packages]
else:
raise "Malformed dependency"
ret.append({
"name": name,
"is_optional": dep.optional,
"packages": fulfilled_by
})
return jsonify(ret)
@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")
@@ -63,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:
@@ -180,16 +183,16 @@ class PackageForm(FlaskForm):
tags = QuerySelectMultipleField('Tags', query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=lambda a: a.title)
harddep_str = StringField("Hard Dependencies", [Optional()])
softdep_str = StringField("Soft Dependencies", [Optional()])
repo = StringField("VCS Repository URL", [Optional(), URL()])
website = StringField("Website URL", [Optional(), URL()])
issueTracker = StringField("Issue Tracker URL", [Optional(), URL()])
repo = StringField("VCS Repository URL", [Optional(), URL()], filters = [lambda x: x or None])
website = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
issueTracker = StringField("Issue Tracker URL", [Optional(), URL()], filters = [lambda x: x or None])
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
@@ -54,7 +56,7 @@ class CreatePackageReleaseForm(FlaskForm):
class EditPackageReleaseForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(1, 30)])
url = StringField("URL", [URL])
task_id = StringField("Task ID")
task_id = StringField("Task ID", filters = [lambda x: x or None])
approved = BooleanField("Is Approved")
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
@@ -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:
@@ -164,7 +181,7 @@ def edit_release_page(package, id):
if package.checkPerm(current_user, Permission.CHANGE_RELEASE_URL):
release.url = form["url"].data
release.task_id = form["task_id"].data
if release.task_id.strip() == "":
if release.task_id is not None:
release.task_id = None
if canApprove:
@@ -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
@@ -140,7 +142,7 @@ def new_thread_page():
abort(403)
def_is_private = request.args.get("private") or False
if package is None or not package.approved:
if package is None:
def_is_private = True
allow_change = package and package.approved
is_review_thread = package and not package.approved
@@ -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,24 +14,25 @@
# 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)
packages = None
if canApproveNew:
packages = Package.query.filter_by(approved=False, soft_deleted=False).all()
packages = Package.query.filter_by(approved=False, soft_deleted=False).order_by(db.desc(Package.created_at)).all()
releases = None
if canApproveRel:
@@ -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 *
@@ -31,21 +32,21 @@ from app.tasks.phpbbparser import getProfile
# Define the User profile form
class UserProfileForm(FlaskForm):
display_name = StringField("Display name", [Optional(), Length(2, 20)])
email = StringField("Email", [Optional(), Email()])
website_url = StringField("Website URL", [Optional(), URL()])
donate_url = StringField("Donation URL", [Optional(), URL()])
email = StringField("Email", [Optional(), Email()], filters = [lambda x: x or None])
website_url = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
donate_url = StringField("Donation URL", [Optional(), URL()], filters = [lambda x: x or None])
rank = SelectField("Rank", [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce, default=UserRank.NEW_MEMBER)
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)
@@ -162,14 +163,14 @@ def send_email_page(username):
class SetPasswordForm(FlaskForm):
email = StringField("Email", [Optional(), Email()])
password = PasswordField("New password", [InputRequired(), Length(2, 20)])
password2 = PasswordField("Verify password", [InputRequired(), Length(2, 20)])
password = PasswordField("New password", [InputRequired(), Length(2, 100)])
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"))

View File

@@ -1,46 +1,65 @@
# 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 .models import *
from .utils import make_flask_user_password
import os, sys, datetime
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)
if not "FLASK_CONFIG" in os.environ:
os.environ["FLASK_CONFIG"] = "../config.cfg"
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))
delete_db = len(sys.argv) >= 2 and sys.argv[1].strip() == "-d"
create_db = not (len(sys.argv) >= 2 and sys.argv[1].strip() == "-o")
test_data = len(sys.argv) >= 2 and sys.argv[1].strip() == "-t" or not create_db
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)
from app.models import *
from app.utils import make_flask_user_password
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()
def defineDummyData(licenses, tags, ruben):
ez = User("Shara")
ez.github_username = "Ezhh"
ez.forums_username = "Shara"
ez.rank = UserRank.EDITOR
db.session.add(ez)
session.add(ez)
not1 = Notification(ruben, ez, "Awards approved", "/packages/rubenwardy/awards/")
db.session.add(not1)
not1 = Notification(admin_user, ez, "Awards approved", "/packages/rubenwardy/awards/")
session.add(not1)
jeija = User("Jeija")
jeija.github_username = "Jeija"
jeija.forums_username = "Jeija"
db.session.add(jeija)
session.add(jeija)
mod = Package()
@@ -48,8 +67,9 @@ def defineDummyData(licenses, tags, ruben):
mod.name = "alpha"
mod.title = "Alpha Test"
mod.license = licenses["MIT"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.MOD
mod.author = ruben
mod.author = admin_user
mod.tags.append(tags["mapgen"])
mod.tags.append(tags["environment"])
mod.repo = "https://github.com/ezhh/other_worlds"
@@ -57,22 +77,23 @@ def defineDummyData(licenses, tags, ruben):
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"
db.session.add(mod)
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
db.session.add(rel)
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 = ruben
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"
@@ -96,10 +117,11 @@ awards.register_achievement("award_mesefind",{
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
db.session.add(rel)
session.add(rel)
mod2 = Package()
mod2.approved = True
@@ -108,22 +130,13 @@ awards.register_achievement("award_mesefind",{
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?
@@ -196,14 +209,15 @@ No warranty is provided, express or implied, for any part of the project.
"""
db.session.add(mod1)
db.session.add(mod2)
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"])
@@ -212,20 +226,22 @@ No warranty is provided, express or implied, for any part of the project.
mod.forums = 17069
mod.short_desc = "Adds hand holds and climbing thingies"
mod.desc = "This is the long desc"
db.session.add(mod)
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
db.session.add(rel)
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"])
@@ -235,31 +251,32 @@ No warranty is provided, express or implied, for any part of the project.
mod.forums = 16015
mod.short_desc = "Adds space with asteroids and comets"
mod.desc = "This is the long desc"
db.session.add(mod)
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 = ruben
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"
food = mod
db.session.add(mod)
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 = ruben
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/"
@@ -267,7 +284,7 @@ No warranty is provided, express or implied, for any part of the project.
mod.short_desc = "Adds sweet food"
mod.desc = "This is the long desc"
food_sweet = mod
db.session.add(mod)
session.add(mod)
game1 = Package()
game1.approved = True
@@ -275,7 +292,8 @@ No warranty is provided, express or implied, for any part of the project.
game1.title = "Capture The Flag"
game1.type = PackageType.GAME
game1.license = licenses["LGPLv2.1"]
game1.author = ruben
game1.media_license = licenses["MIT"]
game1.author = admin_user
game1.tags.append(tags["pvp"])
game1.tags.append(tags["survival"])
game1.tags.append(tags["multiplayer"])
@@ -289,14 +307,14 @@ As seen on the Capture the Flag server (minetest.rubenwardy.com:30000)
Uses the CTF PvP Engine.
"""
db.session.add(game1)
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
db.session.add(rel)
session.add(rel)
mod = Package()
@@ -304,21 +322,22 @@ Uses the CTF PvP Engine.
mod.name = "pixelbox"
mod.title = "PixelBOX Reloaded"
mod.license = licenses["CC0"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.TXP
mod.author = ruben
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"
db.session.add(mod)
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
db.session.add(rel)
session.add(rel)
db.session.commit()
session.commit()
metas = {}
for package in Package.query.filter_by(type=PackageType.MOD).all():
@@ -327,55 +346,9 @@ Uses the CTF PvP Engine.
meta = metas[package.name]
except KeyError:
meta = MetaPackage(package.name)
db.session.add(meta)
session.add(meta)
metas[package.name] = meta
package.provides.append(meta)
dep = Dependency(food_sweet, meta=metas["food"])
db.session.add(dep)
if delete_db and os.path.isfile("db.sqlite"):
os.remove("db.sqlite")
if create_db:
print("Creating database tables...")
db.create_all()
print("Filling database...")
ruben = User("rubenwardy")
ruben.active = True
ruben.password = make_flask_user_password("tuckfrump")
ruben.github_username = "rubenwardy"
ruben.forums_username = "rubenwardy"
ruben.rank = UserRank.ADMIN
db.session.add(ruben)
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
db.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
db.session.add(row)
for license in ["CC-BY-NC-SA", "Other (Non-free)"]:
row = License(license, False)
licenses[row.name] = row
db.session.add(row)
if test_data:
defineDummyData(licenses, tags, ruben)
db.session.commit()
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

@@ -37,7 +37,7 @@ Also see the [help page on tags](/help/package_tags/).
Sexually-orientated content is not permitted.
Mature content, including that relating to drugs, excessive gore, violence, or
horror, is not currently permitted - but will be in the future.
excessive horror, is not currently permitted - but will be in the future.
The submission of malware is strictly prohibited. This includes software which
does not do as it advertises, for example if it posts telemetry without stating

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"))
@@ -213,6 +231,9 @@ class Notification(db.Model):
url = db.Column(db.String(200), nullable=True)
def __init__(self, us, cau, titl, ur):
if len(titl) > 100:
title = title[:99] + ""
self.user = us
self.causer = cau
self.title = titl
@@ -299,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:
@@ -366,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])
@@ -395,7 +419,7 @@ class Package(db.Model):
forums = db.Column(db.Integer, nullable=True)
provides = db.relationship("MetaPackage", secondary=provides, lazy="subquery",
backref=db.backref("packages", lazy="dynamic"))
backref=db.backref("packages", lazy="dynamic", order_by=db.desc("score")))
dependencies = db.relationship("Dependency", backref="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id])
@@ -422,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"
@@ -438,6 +465,13 @@ class Package(db.Model):
else:
return "ready"
def getAsDictionaryKey(self):
return {
"name": self.name,
"author": self.author.display_name,
"type": self.type.toName(),
}
def getAsDictionaryShort(self, base_url, version=None, protonum=None):
tnurl = self.getThumbnailURL(1)
release = self.getDownloadRelease(version=version, protonum=protonum)
@@ -448,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):
@@ -491,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):
@@ -519,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):
@@ -592,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
@@ -609,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)
@@ -679,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
@@ -704,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)
@@ -726,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):
@@ -748,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)
@@ -757,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)
@@ -870,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):
@@ -967,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

@@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import render_template
from flask import render_template, url_for
from flask_mail import Message
from app import mail
from app.tasks import celery
@@ -24,7 +24,18 @@ from app.tasks import celery
def sendVerifyEmail(newEmail, token):
print("Sending verify email!")
msg = Message("Verify email address", recipients=[newEmail])
msg.body = "This is a verification email!"
msg.body = """
This email has been sent to you because someone (hopefully you)
has entered your email address as a user's email.
If it wasn't you, then just delete this email.
If this was you, then please click this link to verify the address:
{}
""".format(url_for('users.verify_email', token=token, _external=True))
msg.html = render_template("emails/verify.html", token=token)
mail.send(msg)
@@ -33,9 +44,7 @@ def sendEmailRaw(to, subject, text, html):
from flask_mail import Message
msg = Message(subject, recipients=to)
if text:
msg.body = text
msg.body = text or html
html = html or text
msg.html = render_template("emails/base.html", subject=subject, content=html)
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,20 +15,28 @@
# 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):
self.baseUrl = None
self.user = None
self.repo = None
# Rewrite path
import re
m = re.search("^\/([^\/]+)\/([^\/]+)\/?$", url.path)
@@ -51,6 +59,9 @@ class GithubURLMaker:
def getScreenshotURL(self):
return self.baseUrl + "/screenshot.png"
def getModConfURL(self):
return self.baseUrl + "/mod.conf"
def getCommitsURL(self, branch):
return "https://api.github.com/repos/{}/{}/commits?sha={}" \
.format(self.user, self.repo, urllib.parse.quote_plus(branch))
@@ -119,188 +130,44 @@ 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:
gitUrl = generateGitURL(urlstr)
print("Cloning from " + gitUrl)
repo = git.Repo.clone_from(gitUrl, gitDir, \
progress=None, env=None, depth=1, recursive=recursive, kill_after_timeout=15)
if ref is not None:
repo.create_head("myhead", ref).checkout()
if ref is None:
repo = git.Repo.clone_from(gitUrl, gitDir, \
progress=None, env=None, depth=1, recursive=recursive, kill_after_timeout=15)
else:
repo = git.Repo.clone_from(gitUrl, gitDir, \
progress=None, env=None, depth=1, recursive=recursive, kill_after_timeout=15, b=ref)
return gitDir, repo
except GitCommandError as e:
# This is needed to stop the backtrace being weird
err = e.stderr
except gitdb.exc.BadName as e:
err = "Unable to find the reference " + (ref or "?") + "\n" + e.stderr
raise TaskError(err.replace("stderr: ", "") \
.replace("Cloning into '" + gitDir + "'...", "") \
.strip())
@@ -308,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 = {}
@@ -339,8 +211,11 @@ def makeVCSReleaseFromGithub(id, branch, release, url):
raise TaskError("Invalid github repo URL")
commitsURL = urlmaker.getCommitsURL(branch)
contents = urllib.request.urlopen(commitsURL).read().decode("utf-8")
commits = json.loads(contents)
try:
contents = urllib.request.urlopen(commitsURL).read().decode("utf-8")
commits = json.loads(contents)
except HTTPError:
raise TaskError("Unable to get commits for Github repository. Either the repository or reference doesn't exist.")
if len(commits) == 0 or not "sha" in commits[0]:
raise TaskError("No commits found")
@@ -349,12 +224,44 @@ def makeVCSReleaseFromGithub(id, branch, release, url):
release.task_id = None
release.commit_hash = commits[0]["sha"]
release.approve(release.package.author)
print(release.url)
db.session.commit()
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):
@@ -364,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):
@@ -408,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()
@@ -445,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">
@@ -19,7 +19,9 @@
<form method="post" action="" class="card-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<select name="action">
<option value="importmodlist" selected>Import forum topics</option>
<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>
<option value="importscreenshots">Import screenshots from VCS</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,13 @@
<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">
DEBUG MODE ENABLED
</p>
{% endif %}
</footer>
<script src="/static/jquery.min.js"></script>

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

@@ -37,7 +37,7 @@
{{ render_checkbox_field(form.private, class_="my-3") }}
<p>
Only the you, the package author, and users of Editor rank
Only you, the package author, and users of Editor rank
and above can read private threads.
</p>

View File

@@ -42,6 +42,9 @@
<ul class="list-group list-group-flush">
{% for r in releases %}
<li class="list-group-item">
{% if r.task_id %}
<span class="mr-2 badge badge-warning">Importing</span>
{% endif %}
<a href="{{ r.getEditURL() }}">{{ r.title }}</a>
on
<a href="{{ r.package.getDetailsURL() }}">
@@ -60,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" />
@@ -98,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,14 +49,16 @@ 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
if fileType == "image":
allowedExtensions = ["jpg", "jpeg", "png"]
isImage = True
elif filetype == "zip":
elif fileType == "zip":
allowedExtensions = ["zip"]
else:
raise Exception("Invalid fileType")
@@ -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,80 +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.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,13 +15,15 @@ services:
app:
build: .
command: ./rundebug.sh
command: ./utils/entrypoint.sh
env_file:
- config.env
ports:
- 5123:5123
volumes:
- "./data/uploads:/home/cdb/app/public/uploads"
- "./app:/home/cdb/app"
- "./migrations:/home/cdb/migrations"
- "./data/uploads:/var/cdb/uploads"
- "./app:/source/app"
- "./migrations:/source/migrations"
depends_on:
- db
- redis
@@ -31,7 +33,30 @@ services:
command: celery -A app.tasks.celery worker
env_file:
- config.env
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,16 +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~=3.0
redis~=3.3
psycopg2~=2.7
pytest ~= 5.3
pytest-cov ~= 2.8

View File

@@ -1,3 +0,0 @@
#!/bin/bash
FLASK_APP=app/__init__.py FLASK_CONFIG=../config.cfg FLASK_DEBUG=1 python3 -m flask run -h 0.0.0.0 -p 5123

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