Compare commits

..

66 Commits

Author SHA1 Message Date
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
rubenwardy
1da86f27a7 Fix topic ID parse error in import topics task 2019-07-29 23:31:42 +01:00
rubenwardy
85340a2fe9 Add note about media license
Fixes #150
2019-07-29 22:48:05 +01:00
rubenwardy
c4a4d9c116 Fix broken link on create thread
Fixes #147
2019-07-29 22:39:56 +01:00
rubenwardy
87a184595c Add file extension filters to file upload dialogs
Thanks to @b3u
2019-07-29 22:34:39 +01:00
rubenwardy
b3b1e421f2 Check that uploaded images are valid images 2019-07-29 22:21:56 +01:00
rubenwardy
60483ef542 Add translation support 2019-07-29 21:44:39 +01:00
rubenwardy
3c8a8b8988 Fix name field always being readonly 2019-07-29 21:03:04 +01:00
rubenwardy
2f8bdd8f0f Increase CSS version 2019-07-29 20:41:48 +01:00
rubenwardy
e87db8b87f Prevent users from changing the name of approved packages 2019-07-29 20:29:55 +01:00
rubenwardy
b36273a848 Add website and donation support 2019-07-02 00:45:16 +01:00
Hugo Locurcio
7b087158d7 Optimize images losslessly using oxipng -o6 --zopfli --strip 2019-06-12 00:10:56 +01:00
rubenwardy
2fbc44bd54 Make user list public 2019-06-10 00:11:57 +01:00
rubenwardy
950512c2a7 Add favicon 2019-06-07 16:54:33 +01:00
rubenwardy
f4010d498f Update policy and guidance 2019-04-23 01:30:17 +01:00
rubenwardy
f04d4ff3cd Allow release auto-approval on unapproved packages 2019-03-30 15:42:31 +00:00
rubenwardy
f8b290fc45 Add badges next to packages awaiting approval list 2019-03-30 15:41:38 +00:00
rubenwardy
7e4eb29db7 Limit releases on package view 2019-03-29 21:01:19 +00:00
rubenwardy
93a74b7681 Fix release auto-approval 2019-03-29 20:52:08 +00:00
rubenwardy
2677e088a8 Fix small style issue on todo page 2019-03-29 20:33:15 +00:00
rubenwardy
0fd4984e5a Redesign todo page, add ability to Approve All screenshots 2019-03-29 20:32:13 +00:00
rubenwardy
896a65fd99 Fix progress bar total 2019-03-29 20:02:10 +00:00
rubenwardy
885209a614 Add unified topic search in QueryBuilder 2019-03-29 19:48:21 +00:00
rubenwardy
4c109d6bd3 Fix release being null in API when release is unapproved
Fixes #129
2019-03-13 14:37:27 +00:00
rubenwardy
9c2c8c21f1 Add content flag support in the API 2019-02-03 13:03:30 +00:00
rubenwardy
e40b247a97 Add OpenSearch and Google site search support 2019-02-02 17:05:18 +00:00
rubenwardy
a79cc758ed Add placeholder content ratings page 2019-01-30 17:57:56 +00:00
rubenwardy
bafd426eaf Add automatic approval of releases and screenshots 2019-01-29 18:30:30 +00:00
rubenwardy
36f9572cbb Fix replace problem in migration 2019-01-29 03:02:46 +00:00
rubenwardy
2586a11bcf Add fulltext search support 2019-01-29 03:00:01 +00:00
rubenwardy
d36138d5e1 Add version information to package page 2019-01-29 02:03:10 +00:00
rubenwardy
7810bb54e0 Add download counter to home page 2019-01-29 01:43:21 +00:00
rubenwardy
2844773e4d Fix wrong release ID returned by API on explicit protocol version 2019-01-29 01:29:49 +00:00
rubenwardy
23c406bff9 Add download counting 2019-01-29 00:49:44 +00:00
rubenwardy
0f3adda592 Improve spacing on bulk change releases 2019-01-29 00:27:26 +00:00
rubenwardy
441ed3beeb Add option to only change None entries with bulk change releases 2019-01-29 00:24:59 +00:00
rubenwardy
d1f5585fda Fix typos in text 2019-01-29 00:18:49 +00:00
rubenwardy
0fd3ed8f6b Fix bulk change form 2019-01-28 23:54:00 +00:00
rubenwardy
0e5c1f83ff Add MinetestRelease editor 2019-01-28 23:49:27 +00:00
rubenwardy
f112756b04 Disable fields in bulk change on checkbox 2019-01-28 23:40:31 +00:00
rubenwardy
f822027ec5 Hide create release fields depending on radio buttons 2019-01-28 23:17:00 +00:00
rubenwardy
034315d421 Add notes about min/max, and hide invalid options 2019-01-28 22:28:47 +00:00
rubenwardy
5cd8b35d1f Add ability to bulk change releases 2019-01-28 21:49:29 +00:00
rubenwardy
84b996c489 Add Minetest version checking to packages API 2019-01-28 21:33:50 +00:00
rubenwardy
d77403c0be Add min and max Minetest version support 2019-01-28 20:48:07 +00:00
rubenwardy
e9fe936aa9 Increase visibility of thread creation 2019-01-28 19:41:24 +00:00
rubenwardy
8afe17b984 Add comment ratelimiting, allow any member to open threads 2019-01-28 19:01:37 +00:00
rubenwardy
2691105513 Remove limit on provides field 2019-01-09 22:44:23 +00:00
rubenwardy
5f7efd4f31 Reduce README size 2019-01-09 22:35:11 +00:00
rubenwardy
7d52931a20 Add celery support to docker config 2019-01-09 22:29:32 +00:00
rubenwardy
a45df0e173 Add Docker support 2019-01-09 21:58:11 +00:00
rubenwardy
0db49efe4a Fix weird ordering of screenshots 2019-01-08 21:35:46 +00:00
rubenwardy
9639cf04f1 Improve views subfoldering 2019-01-08 17:37:33 +00:00
rubenwardy
9866e43b4b Split up packages/__init__.py 2019-01-08 17:17:36 +00:00
rubenwardy
014370ea06 Add email template 2019-01-04 19:17:04 +00:00
rubenwardy
fbf374ff5d Add manual email support 2019-01-04 17:57:00 +00:00
rubenwardy
a68ac9cb4d Add number of packages to bottom of homepage 2018-12-31 14:01:19 +00:00
88 changed files with 2197 additions and 760 deletions

4
.gitignore vendored
View File

@@ -1,6 +1,7 @@
config.cfg
config.prod.cfg
*.env
*.sqlite
.vscode
custom.css
tmp
log.txt
@@ -8,6 +9,7 @@ log.txt
uploads
thumbnails
celerybeat-schedule
/data
# Created by https://www.gitignore.io/api/linux,macos,python,windows

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.6
WORKDIR /home/cdb
COPY requirements.txt 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 migrations migrations
COPY config.cfg ./config.cfg

View File

@@ -1,57 +1,17 @@
# Content Database
## Setup
Content database for Minetest mods, games, and more.
First create a Python virtual env:
virtualenv env -ppython3
source env/bin/activate
then use pip:
pip3 install -r requirements.txt
### Development
* Copy config.example.cfg to config.cfg
* Fill SECRET_KEY and WTF_CSRF_SECRET_KEY in with a random string
* Make a Github OAuth Client at <https://github.com/settings/developers>:
* Homepage URL - `http://localhost:5000/`
* Authorization callback URL - `http://localhost:5000/user/github/callback/`
* Put client id and client secret in GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET
* Setup the database: python3 setup.py
## Running
### Development
You need to enter the virtual environment if you haven't yet in
the current session:
source env/bin/activate
If you need to, reset the db like so:
python3 setup.py -t
Then run the server:
./rundebug.py
Then view in your web browser: http://localhost:5000/
Developed by rubenwardy, license GPLv3.0+.
## How-tos
### Start celery worker
Note: you should first read one of the guides on the [Github repo wiki](https://github.com/minetest/contentdb/wiki)
```sh
# Run celery worker
FLASK_CONFIG=../config.cfg celery -A app.tasks.celery worker
```
### Create migration
```sh
# if sqlite
python setup.py -t
rm db.sqlite && python setup.py -t && FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db stamp head
@@ -61,4 +21,7 @@ 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
```

View File

@@ -24,6 +24,7 @@ from flaskext.markdown import Markdown
from flask_github import GitHub
from flask_wtf.csrf import CsrfProtect
from flask_flatpages import FlatPages
from flask_babel import Babel
import os
app = Flask(__name__, static_folder="public/static")
@@ -32,11 +33,12 @@ app.config["FLATPAGES_EXTENSION"] = ".md"
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
menu.Menu(app=app)
Markdown(app, extensions=["fenced_code"], safe_mode=True, output_format="html5")
markdown = Markdown(app, extensions=["fenced_code"], safe_mode=True, output_format="html5")
github = GitHub(app)
csrf = CsrfProtect(app)
mail = Mail(app)
pages = FlatPages(app)
babel = Babel(app)
gravatar = Gravatar(app,
size=58,
rating='g',
@@ -50,5 +52,11 @@ if not app.debug:
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())
from . import models, tasks
from .views import *

View File

@@ -2,4 +2,5 @@ title: Help
* [Package Tags](package_tags)
* [Ranks and Permissions](ranks_permissions)
* [Content Ratings and Flags](content_flags)
* [Reporting Content](reporting)

View File

@@ -0,0 +1,26 @@
title: Content Flags
Content flags allow you to hide content based on your preferences.
The filtering is done server-side, which means that you don't need to update
your client to use new flags.
## Flags
* `nonfree` - can be used to hide packages which do not qualify as
'free software', as defined by the Free Software Foundation.
* A content rating, given below.
## Ratings
Content ratings aren't currently supported by ContentDB.
Instead, mature content isn't allowed at all for now.
In the future, more mature content will be allowed but labelled with
content ratings which may contain the following:
* android_default - meta-rating which includes gore and drugs.
* desktop_default - meta-rating which won't include anything for now.
* gore - more than just blood
* drugs
* swearing

View File

@@ -5,4 +5,4 @@ laws.
We take copyright violation and other offenses very seriously.
<a href="https://rubenwardy.com/contact/" class="button btn_green">Contact</a>
<a href="https://rubenwardy.com/contact/" class="btn btn-success">Contact</a>

View File

@@ -10,36 +10,45 @@ ContentDB is for the community, and as such listings should be useful to the
community. To help with this, there are a few rules to improve the quality of
the listings and to combat abuse.
* No inappropriate content.
* Content must be playable/useful, but not necessarily finished.
* Don't use the name of another mod unless your mod is a fork or reimplementation.
* Licenses must allow derivatives, redistribution, and must not discriminate.
* Don't put promotions are advertisements in package listings, except for
donation and personal website links which are permitted in the long description.
* No inappropriate content. <sup>2.1</sup>
* Content must be playable/useful, but not necessarily finished. <sup>2.2</sup>
* Don't use the name of another mod unless your mod is a fork or reimplementation. <sup>3</sup>
* Licenses must allow derivatives, redistribution, and must not discriminate. <sup>4</sup>
* Don't put promotions or advertisements in package listings, except for
donation and personal website links which are permitted in the
long description. <sup>5</sup>
* The ContentDB admin reserves the right to remove packages for any reason,
including ones not covered by this document, and to ban users who abuse
this service. <sup>1</sup>
## 1. General
It is not permitted to submit abusive, obscene, vulgar, slanderous, hateful,
threatening, sexually-orientated or any material that may violate any laws be
it of your country, the country where "Content DB” is hosted or International Law.
The ContentDB admin reserves the right to remove packages for any reason,
including ones not covered by this document, and to ban users who abuse this service.
Also see the [help page on tags](/help/package_tags/).
## 2. Accepted Content and State of Completion
## 2. Accepted Content
### 2.1. Acceptable Content
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.
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
clearly that it does in the package meta.
### 2.2. State of Completion
ContentDB should only currently contain playable content - content which is
sufficiently complete to be useful to end users. It's fine to add stuff which
is still a work in progress (WIP) as long as it adds sufficient value -
Mineclone 2 is a good example of a WIP package which may break between releases
MineClone 2 is a good example of a WIP package which may break between releases
but still has value. Note that this doesn't mean that you should add a thing
you started working on yesterday, it's worth adding all the basic stuff to
make your package useful.
@@ -116,15 +125,15 @@ Public domain is not a valid license in many countries, please use CC0 or MIT in
## 5. Promotions and Advertisements (inc. asking for donations)
Any information other than the long description - including screenshots - must
not contain any promotions or advertisements. This includes asking for donations,
promoting online shops, or linking to personal websites and social media.
You may note place any promotions or advertisements in any meta data including
screensthos. This includes asking for donations, promoting online shops,
or linking to personal websites and social media. Please instead use the
fields provided on your user profile page to place links to websites and
donation pages.
ContentDB is for the community. We may remove any promotions if we feel that
they're inappropriate.
Paid promotions are not allowed at all, anywhere.
## 6. Reporting Violations

View File

@@ -15,19 +15,29 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Flask, url_for
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from urllib.parse import urlparse
import enum, datetime
from app import app, gravatar
from datetime import datetime
from sqlalchemy.orm import validates
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
import enum
from sqlalchemy.orm import validates
from sqlalchemy_searchable import SearchQueryMixin
from sqlalchemy_utils.types import TSVectorType
from sqlalchemy_searchable import make_searchable
# Initialise database
db = SQLAlchemy(app)
migrate = Migrate(app, db)
make_searchable(db.metadata)
class ArticleQuery(BaseQuery, SearchQueryMixin):
pass
class UserRank(enum.Enum):
@@ -66,6 +76,7 @@ class Permission(enum.Enum):
APPROVE_CHANGES = "APPROVE_CHANGES"
DELETE_PACKAGE = "DELETE_PACKAGE"
CHANGE_AUTHOR = "CHANGE_AUTHOR"
CHANGE_NAME = "CHANGE_NAME"
MAKE_RELEASE = "MAKE_RELEASE"
ADD_SCREENSHOTS = "ADD_SCREENSHOTS"
APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
@@ -119,6 +130,10 @@ class User(db.Model, UserMixin):
active = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
display_name = db.Column(db.String(100), nullable=False, server_default="")
# Links
website_url = db.Column(db.String(255), nullable=True, default=None)
donate_url = db.Column(db.String(255), nullable=True, default=None)
# Content
notifications = db.relationship("Notification", primaryjoin="User.id==Notification.user_id")
@@ -129,8 +144,6 @@ class User(db.Model, UserMixin):
replies = db.relationship("ThreadReply", backref="author", lazy="dynamic")
def __init__(self, username, active=False, email=None, password=None):
import datetime
self.username = username
self.confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
self.display_name = username
@@ -172,6 +185,16 @@ class User(db.Model, UserMixin):
else:
raise Exception("Permission {} is not related to users".format(perm.name))
def canCommentRL(self):
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
return ThreadReply.query.filter_by(author=self) \
.filter(ThreadReply.created_at > hour_ago).count() < 4
def canOpenThreadRL(self):
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
return Thread.query.filter_by(author=self) \
.filter(Thread.created_at > hour_ago).count() < 2
class UserEmailVerification(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"))
@@ -190,6 +213,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
@@ -239,7 +265,7 @@ class PackageType(enum.Enum):
class PackagePropertyKey(enum.Enum):
name = "Name"
title = "Title"
shortDesc = "Short Description"
short_desc = "Short Description"
desc = "Description"
type = "Type"
license = "License"
@@ -336,18 +362,21 @@ class Dependency(db.Model):
return retval
class Package(db.Model):
query_class = ArticleQuery
id = db.Column(db.Integer, primary_key=True)
# Basic details
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
name = db.Column(db.String(100), nullable=False)
title = db.Column(db.String(100), nullable=False)
shortDesc = db.Column(db.String(200), nullable=False)
desc = db.Column(db.Text, nullable=True)
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.utcnow)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
search_vector = db.Column(TSVectorType("title", "short_desc", "desc"))
license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
license = db.relationship("License", foreign_keys=[license_id])
@@ -369,7 +398,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])
@@ -380,7 +409,7 @@ class Package(db.Model):
lazy="dynamic", order_by=db.desc("package_release_releaseDate"))
screenshots = db.relationship("PackageScreenshot", backref="package",
lazy="dynamic")
lazy="dynamic", order_by=db.asc("package_screenshot_id"))
requests = db.relationship("EditRequest", backref="package",
lazy="dynamic")
@@ -396,26 +425,51 @@ class Package(db.Model):
for e in PackagePropertyKey:
setattr(self, e.name, getattr(package, e.name))
def getAsDictionaryShort(self, base_url):
def getState(self):
if self.approved:
return "approved"
elif self.review_thread_id:
return "thread"
elif (self.type == PackageType.GAME or \
self.type == PackageType.TXP) and \
self.screenshots.count() == 0:
return "wip"
elif not self.getDownloadRelease():
return "wip"
elif "Other" in self.license.name or "Other" in self.media_license.name:
return "license"
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)
return {
"name": self.name,
"title": self.title,
"author": self.author.display_name,
"short_description": self.shortDesc,
"short_description": self.short_desc,
"type": self.type.toName(),
"release": self.getDownloadRelease().id if self.getDownloadRelease() is not None else None,
"release": release and release.id,
"thumbnail": (base_url + tnurl) if tnurl is not None else None,
"score": round(self.score * 10) / 10
}
def getAsDictionary(self, base_url):
def getAsDictionary(self, base_url, version=None, protonum=None):
tnurl = self.getThumbnailURL(1)
release = self.getDownloadRelease(version=version, protonum=protonum)
return {
"author": self.author.display_name,
"name": self.name,
"title": self.title,
"short_description": self.shortDesc,
"short_description": self.short_desc,
"desc": self.desc,
"type": self.type.toName(),
"created_at": self.created_at,
@@ -433,17 +487,17 @@ class Package(db.Model):
"screenshots": [base_url + ss.url for ss in self.screenshots],
"url": base_url + self.getDownloadURL(),
"release": self.getDownloadRelease().id if self.getDownloadRelease() is not None else None,
"release": release and release.id,
"score": round(self.score * 10) / 10
}
def getThumbnailURL(self, level=2):
screenshot = self.screenshots.filter_by(approved=True).first()
screenshot = self.screenshots.filter_by(approved=True).order_by(db.asc(PackageScreenshot.id)).first()
return screenshot.getThumbnailURL(level) if screenshot is not None else None
def getMainScreenshotURL(self):
screenshot = self.screenshots.filter_by(approved=True).first()
screenshot = self.screenshots.filter_by(approved=True).order_by(db.asc(PackageScreenshot.id)).first()
return screenshot.url if screenshot is not None else None
def getDetailsURL(self):
@@ -474,17 +528,37 @@ class Package(db.Model):
return url_for("create_edit_editrequest_page",
author=self.author.username, name=self.name)
def getBulkReleaseURL(self):
return url_for("bulk_change_release_page",
author=self.author.username, name=self.name)
def getDownloadURL(self):
return url_for("package_download_page",
author=self.author.username, name=self.name)
def getDownloadRelease(self):
def getDownloadRelease(self, version=None, protonum=None):
if version is None and protonum is not None:
version = MinetestRelease.query.filter(MinetestRelease.protocol >= int(protonum)).first()
if version is not None:
version = version.id
else:
version = 10000000
for rel in self.releases:
if rel.approved:
if rel.approved and (version is None or
((rel.min_rel is None or rel.min_rel_id <= version) and \
(rel.max_rel is None or rel.max_rel_id >= version))):
return rel
return None
def getDownloadCount(self):
counter = 0
for release in self.releases:
counter += release.downloads
return counter
def checkPerm(self, user, perm):
if not user.is_authenticated:
return False
@@ -496,8 +570,11 @@ class Package(db.Model):
isOwner = user == self.author
if perm == Permission.CREATE_THREAD:
return user.rank.atLeast(UserRank.MEMBER)
# Members can edit their own packages, and editors can edit any packages
if perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS or perm == Permission.CREATE_THREAD:
if perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS:
return isOwner or user.rank.atLeast(UserRank.EDITOR)
if perm == Permission.EDIT_PACKAGE or perm == Permission.APPROVE_CHANGES:
@@ -506,6 +583,10 @@ class Package(db.Model):
else:
return user.rank.atLeast(UserRank.EDITOR)
# Anyone can change the package name when not approved, but only editors when approved
elif perm == Permission.CHANGE_NAME:
return not self.approved or user.rank.atLeast(UserRank.EDITOR)
# Editors can change authors and approve new packages
elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR:
return user.rank.atLeast(UserRank.EDITOR)
@@ -522,8 +603,6 @@ class Package(db.Model):
raise Exception("Permission {} is not related to packages".format(perm.name))
def recalcScore(self):
import datetime
self.score = 10
if self.forums is not None:
@@ -604,16 +683,36 @@ class Tag(db.Model):
regex = re.compile("[^a-z_]")
self.name = regex.sub("", self.title.lower().replace(" ", "_"))
class MinetestRelease(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
protocol = db.Column(db.Integer, nullable=False, default=0)
def __init__(self, name=None):
self.name = name
def getActual(self):
return None if self.name == "None" else self
class PackageRelease(db.Model):
id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
title = db.Column(db.String(100), nullable=False)
releaseDate = db.Column(db.DateTime, nullable=False)
releaseDate = db.Column(db.DateTime, nullable=False)
url = db.Column(db.String(200), nullable=False)
approved = db.Column(db.Boolean, nullable=False, default=False)
task_id = db.Column(db.String(37), nullable=True)
commit_hash = db.Column(db.String(41), nullable=True, default=None)
downloads = db.Column(db.Integer, nullable=False, default=0)
min_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None)
min_rel = db.relationship("MinetestRelease", foreign_keys=[min_rel_id])
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])
def getEditURL(self):
@@ -630,7 +729,17 @@ class PackageRelease(db.Model):
def __init__(self):
self.releaseDate = datetime.now()
self.releaseDate = datetime.datetime.now()
def approve(self, user):
if self.package.approved and \
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 != "")
self.approved = True
return True
class PackageReview(db.Model):
@@ -762,7 +871,7 @@ class Thread(db.Model):
title = db.Column(db.String(100), nullable=False)
private = db.Column(db.Boolean, server_default="0")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
replies = db.relationship("ThreadReply", backref="thread", lazy="dynamic")
@@ -800,7 +909,7 @@ class ThreadReply(db.Model):
thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False)
comment = db.Column(db.String(500), nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com", \
@@ -824,7 +933,7 @@ class ForumTopic(db.Model):
posts = db.Column(db.Integer, nullable=False)
views = db.Column(db.Integer, nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
def getRepoURL(self):
if self.link is None:

BIN
app/public/favicon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
app/public/favicon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 B

BIN
app/public/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 B

After

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 B

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 B

After

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 B

After

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>ContentDB</ShortName>
<LongName>ContentDB</LongName>
<InputEncoding>UTF-8</InputEncoding>
<Description>Search mods, games, and textures for Minetest.</Description>
<Tags>Minetest Mod Game Subgame Search</Tags>
<Url type="text/html" method="get" template="https://content.minetest.net/packages?q={searchTerms}"/>
</OpenSearchDescription>

View File

@@ -35,10 +35,10 @@ $(function() {
setField("#repo", result.repo || repoURL);
setField("#issueTracker", result.issueTracker);
setField("#desc", result.description);
setField("#shortDesc", result.short_description);
setField("#short_desc", result.short_description);
setField("#harddep_str", result.depends);
setField("#softdep_str", result.optional_depends);
setField("#shortDesc", result.short_description);
setField("#short_desc", result.short_description);
setField("#forums", result.forumId);
if (result.type && result.type.length > 2) {
$("#type").val(result.type);

View File

@@ -41,7 +41,7 @@ $(function() {
It's obvious that this adds something to Minetest,
there's no need to use phrases such as \"adds X to the game\".`
$("#shortDesc").on("change paste keyup", function() {
$("#short_desc").on("change paste keyup", function() {
var val = $(this).val().toLowerCase();
if (val.indexOf("minetest") >= 0 || val.indexOf("mod") >= 0 ||
val.indexOf("modpack") >= 0 || val.indexOf("mod pack") >= 0) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,18 @@
var min = $("#min_rel");
var max = $("#max_rel");
var none = $("#min_rel option:first-child").attr("value");
var warning = $("#minmax_warning");
function ver_check() {
var minv = min.val();
var maxv = max.val();
if (minv != none && maxv != none && minv > maxv) {
warning.show();
} else {
warning.hide();
}
}
min.change(ver_check);
max.change(ver_check);

130
app/querybuilder.py Normal file
View File

@@ -0,0 +1,130 @@
from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease
from .utils import isNo, isYes
from sqlalchemy.sql.expression import func
from flask import abort
from sqlalchemy import or_
class QueryBuilder:
title = None
types = None
search = None
def __init__(self, args):
title = "Packages"
# Get request types
types = args.getlist("type")
types = [PackageType.get(tname) for tname in types]
types = [type for type in types if type is not None]
if len(types) > 0:
title = ", ".join([type.value + "s" for type in types])
hide_flags = args.getlist("hide")
self.title = title
self.types = types
self.search = args.get("q")
self.random = "random" in args
self.lucky = self.random or "lucky" in args
self.hide_nonfree = "nonfree" in hide_flags
self.limit = 1 if self.lucky else None
self.order_by = args.get("sort")
self.order_dir = args.get("order") or "desc"
self.protocol_version = args.get("protocol_version")
self.show_discarded = isYes(args.get("show_discarded"))
self.show_added = args.get("show_added")
if self.show_added is not None:
self.show_added = isYes(self.show_added)
if self.search is not None and self.search.strip() == "":
self.search = None
def setSortIfNone(self, name):
if self.order_by is None:
self.order_by = name
def getMinetestVersion(self):
if not self.protocol_version:
return None
self.protocol_version = int(self.protocol_version)
version = MinetestRelease.query.filter(MinetestRelease.protocol>=self.protocol_version).first()
if version is not None:
return version.id
else:
return 10000000
def buildPackageQuery(self):
query = Package.query.filter_by(soft_deleted=False, approved=True)
if len(self.types) > 0:
query = query.filter(Package.type.in_(self.types))
if self.search:
query = query.search(self.search)
if self.random:
query = query.order_by(func.random())
else:
to_order = None
if self.order_by is None or self.order_by == "score":
to_order = Package.score
elif self.order_by == "created_at":
to_order = Package.created_at
else:
abort(400)
if self.order_dir == "asc":
to_order = db.asc(to_order)
elif self.order_dir == "desc":
to_order = db.desc(to_order)
else:
abort(400)
query = query.order_by(to_order)
if self.hide_nonfree:
query = query.filter(Package.license.has(License.is_foss == True))
query = query.filter(Package.media_license.has(License.is_foss == True))
if self.protocol_version:
version = self.getMinetestVersion()
query = query.join(Package.releases) \
.filter(PackageRelease.approved==True) \
.filter(or_(PackageRelease.min_rel_id==None, PackageRelease.min_rel_id<=version)) \
.filter(or_(PackageRelease.max_rel_id==None, PackageRelease.max_rel_id>=version))
if self.limit:
query = query.limit(self.limit)
return query
def buildTopicQuery(self, show_added=False):
query = ForumTopic.query
if not self.show_discarded:
query = query.filter_by(discarded=False)
show_added = self.show_added == True or (self.show_added is None and show_added)
if not show_added:
query = query.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id))
if self.order_by is None or self.order_by == "name":
query = query.order_by(db.asc(ForumTopic.wip), db.asc(ForumTopic.name), db.asc(ForumTopic.title))
elif self.order_by == "views":
query = query.order_by(db.desc(ForumTopic.views))
elif self.order_by == "date":
query = query.order_by(db.asc(ForumTopic.created_at))
sort_by = "date"
if self.search:
query = query.filter(ForumTopic.title.ilike('%' + self.search + '%'))
if len(self.types) > 0:
query = query.filter(ForumTopic.type.in_(self.types))
if self.limit:
query = query.limit(self.limit)
return query

View File

@@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask import render_template
from flask_mail import Message
from app import mail
from app.tasks import celery
@@ -32,8 +32,10 @@ def sendVerifyEmail(newEmail, token):
def sendEmailRaw(to, subject, text, html):
from flask_mail import Message
msg = Message(subject, recipients=to)
if text:
msg.body = text
if html:
msg.html = html
html = html or text
msg.html = render_template("emails/base.html", subject=subject, content=html)
mail.send(msg)

View File

@@ -99,11 +99,19 @@ def parseTitle(title):
def getLinksFromModSearch():
links = {}
contents = urllib.request.urlopen("https://krock-works.uk.to/minetest/modList.php").read().decode("utf-8")
for x in json.loads(contents):
link = x.get("link")
if link is not None:
links[int(x["topicId"])] = link
try:
contents = urllib.request.urlopen("https://krock-works.uk.to/minetest/modList.php").read().decode("utf-8")
for x in json.loads(contents):
try:
link = x.get("link")
if link is not None:
links[int(x["topicId"])] = link
except ValueError:
pass
except urllib.error.URLError:
print("Unable to open krocks mod search!")
return links
return links

View File

@@ -29,6 +29,10 @@ from app.utils import randomString
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 +55,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))
@@ -292,15 +299,17 @@ def cloneRepo(urlstr, ref=None, recursive=False):
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)
progress=None, env=None, depth=1, recursive=recursive, kill_after_timeout=15, b=ref)
if ref is not None:
repo.create_head("myhead", ref).checkout()
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())
@@ -339,8 +348,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")
@@ -348,7 +360,7 @@ def makeVCSReleaseFromGithub(id, branch, release, url):
release.url = urlmaker.getCommitDownload(commits[0]["sha"])
release.task_id = None
release.commit_hash = commits[0]["sha"]
print(release.url)
release.approve(release.package.author)
db.session.commit()
return release.url
@@ -379,6 +391,7 @@ def makeVCSRelease(id, branch):
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()

View File

@@ -6,7 +6,7 @@ Licenses
{% block content %}
<p>
<a href="{{ url_for('createedit_license_page') }}">New Tag</a>
<a href="{{ url_for('createedit_license_page') }}">New License</a>
</p>
<ul>
{% for l in licenses %}

View File

@@ -9,6 +9,7 @@
<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>
</ul>

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}
{% if version %}
Edit {{ version.name }}
{% else %}
New Minetest Version
{% endif %}
{% endblock %}
{% block content %}
<p>
<a href="{{ url_for('version_list_page') }}">Back to list</a> |
<a href="{{ url_for('createedit_version_page') }}">New Version</a>
</p>
{% from "macros/forms.html" import render_field, render_submit_field %}
<form method="POST" action="" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{{ render_field(form.name) }}
{{ render_field(form.protocol) }}
{{ render_submit_field(form.submit) }}
</form>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}
Minetest Versions
{% endblock %}
{% block content %}
<p>
<a href="{{ url_for('createedit_version_page') }}">New Version</a>
</p>
<ul>
{% for v in versions %}
<li><a href="{{ url_for('createedit_version_page', name=v.name) }}">{{ v.name }}</a></li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -7,7 +7,11 @@
<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=6">
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=7">
<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">
<link rel="icon" href="/favicon-32.png" sizes="32x32">
{% block headextra %}{% endblock %}
</head>
@@ -75,24 +79,24 @@
<a class="nav-link" href="{{ url_for('user_profile_page', 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_page') }}">{{ _("Work Queue") }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('user_list_page') }}">{{ _("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_page') }}">{{ _("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_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('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>
{% endif %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('user.logout') }}">Sign out</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('user.logout') }}">{{ _("Sign out") }}</a></li>
</ul>
</li>
{% else %}
<li><a class="nav-link" href="{{ url_for('user.login') }}">Sign in</a></li>
<li><a class="nav-link" href="{{ url_for('user.login') }}">{{ _("Sign in") }}</a></li>
{% endif %}
</ul>
</div>
@@ -125,10 +129,12 @@
{% endblock %}
<footer class="container footer-copyright my-5 page-footer font-small text-center">
ContentDB &copy; 2018 to <a href="https://rubenwardy.com/">rubenwardy</a> |
ContentDB &copy; 2018-9 to <a href="https://rubenwardy.com/">rubenwardy</a> |
<a href="https://github.com/minetest/contentdb">GitHub</a> |
<a href="{{ url_for('flatpage', path='help') }}">Help</a> |
<a href="{{ url_for('flatpage', path='help/reporting') }}">Report / DMCA</a>
<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>
</footer>
<script src="/static/jquery.min.js"></script>
@@ -138,7 +144,7 @@
<link rel="stylesheet" type="text/css" href="/static/easymde.min.css">
<script>
$("textarea.markdown").each(function() {
new EasyMDE({ element: this, hideIcons: ["image"] });
new EasyMDE({ element: this, hideIcons: ["image"], forceSync: true });
})
</script>
{% block scriptextra %}{% endblock %}

View File

@@ -0,0 +1,64 @@
<!doctype html>
<html>
<head>
<style>
.btn {
display: inline-block !important;
color: #fff !important;
font-weight: 400;
text-align: center;
vertical-align: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background-color: transparent;
border: 1px solid transparent;
border-top-color: transparent;
border-bottom-color: transparent;
border-left-color: transparent;
padding: 0.375rem 0.75rem;
font-size: 0.9375rem;
line-height: 1.5;
border-radius: 0.25rem;
-webkit-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;
background-color: #2C3E50;
border-color: #2C3E50;
text-decoration: none;
}
.btn:hover {
color: #fff;
background-color: #1e2b37;
border-color: #1a252f;
}
.btn:focus {
-webkit-box-shadow: 0 0 0 0.2rem rgba(76, 91, 106, 0.5);
box-shadow: 0 0 0 0.2rem rgba(76, 91, 106, 0.5);
outline: 0;
}
</style>
</head>
<body>
<div style="font-family: 'Arial', 'sans-serif'; max-width: 700px; margin: auto; padding: 0;">
<div style="background: #2C3E50; padding: 1.2rem 1.2rem 1.2rem 2em; color: white;">
<h1 style="margin: 0; font-size: 120%; font-weight: normal;">ContentDB</h1>
</div>
<div style="padding: 2em; background: white;">
{% block content %}
<h2 style="margin-top: 0;">{{ subject }}</h2>
{{ content | safe }}
{% endblock %}
<div style="margin-top: 3em;font-size: 80%;color: #666;">
ContentDB &copy; rubenwardy
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,4 +1,7 @@
<h1>Hello!</h1>
{% extends "emails/base.html" %}
{% block content %}
<h2 style="margin-top: 0;">Hello!</h2>
<p>
This email has been sent to you because someone (hopefully you)
@@ -6,12 +9,19 @@
</p>
<p>
If this was you, then please click this link to verify the address:
<a href="{{ url_for('verify_email_page', token=token, _external=True) }}">
{{ url_for('verify_email_page', token=token, _external=True) }}
</a>
If it wasn't you, then just delete this email.
</p>
<p>
If it wasn't you, then just delete this email.
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) }}">
Confirm Email Address
</a>
<p style="font-size: 80%;">
Or paste this into your browser: {{ url_for('verify_email_page', token=token, _external=True) }}
<p>
{% endblock %}

View File

@@ -1,7 +1,22 @@
{% extends "base.html" %}
{% block title %}
Welcome
{{ _("Welcome") }}
{% endblock %}
{% block scriptextra %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"url": "https://content.minetest.net/",
"potentialAction": {
"@type": "SearchAction",
"target": "https://content.minetest.net/packages?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
{% endblock %}
{% block content %}
@@ -23,31 +38,36 @@ Welcome
<a href="{{ url_for('packages_page', sort='created_at', order='desc') }}" class="btn btn-secondary float-right">
See more
{{ _("See more") }}
</a>
<h2 class="my-3">Recently Added</h2>
<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">
See more
{{ _("See more") }}
</a>
<h2 class="my-3">Top Mods</h2>
<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">
See more
{{ _("See more") }}
</a>
<h2 class="my-3">Top Games</h2>
<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">
See more
{{ _("See more") }}
</a>
<h2 class="my-3">Top Texture Packs</h2>
<h2 class="my-3">{{ _("Top Texture Packs") }}</h2>
{{ render_pkggrid(pop_txp) }}
<div class="text-center">
<small>
{{ _("CDB has %(count)d packages, with a total of %(downloads)d downloads.", count=count, downloads=downloads) }}
</small>
</div>
<!-- </main> -->
{% endblock %}

View File

@@ -115,7 +115,7 @@
{% macro render_checkbox_field(field, label=None) -%}
{% if not label %}{% set label=field.label.text %}{% endif %}
<div class="checkbox">
<div class="checkbox {{ kwargs.pop('class_', '') }}">
<label>
{{ field(type='checkbox', **kwargs) }} {{ label }}
</label>

View File

@@ -12,7 +12,7 @@
</h3>
<p>
{{ package.shortDesc }}
{{ package.short_desc }}
</p>

View File

@@ -42,11 +42,18 @@
<a name="reply"></a>
</div>
<form method="post" action="{{ url_for('thread_page', 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" />
</form>
{% if current_user.canCommentRL() %}
<form method="post" action="{{ url_for('thread_page', 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" />
</form>
{% else %}
<div class="card-body">
<textarea class="form-control" readonly disabled>Please wait before commenting again.</textarea><br />
<input class="btn btn-primary" type="submit" disabled value="Comment" />
</div>
{% endif %}
</div>
</div>
</div>

View File

@@ -20,19 +20,19 @@
{% endblock %}
{% block content %}
<h1>Create Package</h1>
<h1>{{ _("Create Package") }}</h1>
<div class="alert alert-info">
<a class="float-right btn btn-sm btn-default" href="{{ url_for('flatpage', path='policy_and_guidance') }}">View</a>
<a class="float-right btn btn-sm btn-default" href="{{ url_for('flatpage', path='policy_and_guidance') }}">{{ _("View") }}</a>
Have you read the Package Inclusion Policy and Guidance yet?
{{ _("Have you read the Package Inclusion Policy and Guidance yet?") }}
</div>
<noscript>
<div class="alert alert-warning">
Javascript is needed to improve the user interface, and is needed for features
such as finding metadata from git, and autocompletion.<br />
Whilst disabled Javascript may work, it is not officially supported.
{{ _("Javascript is needed to improve the user interface, and is needed for features
such as finding metadata from git, and autocompletion.") }}<br />
{{ _("Whilst disabled Javascript may work, it is not officially supported.") }}
</div>
</noscript>
@@ -42,24 +42,32 @@
{{ form.hidden_tag() }}
<fieldset>
<legend>Package</legend>
<legend>{{ _("Package") }}</legend>
<div class="row">
{{ render_field(form.type, class_="pkg_meta col-sm-2") }}
{{ render_field(form.title, class_="pkg_meta col-sm-7") }}
{{ render_field(form.name, class_="pkg_meta col-sm-3") }}
{% if package and package.approved and not package.checkPerm(current_user, "CHANGE_NAME") %}
{{ render_field(form.name, class_="pkg_meta col-sm-3", readonly=True) }}
{% else %}
{{ render_field(form.name, class_="pkg_meta col-sm-3") }}
{% endif %}
</div>
{{ render_field(form.shortDesc, class_="pkg_meta") }}
{{ render_field(form.short_desc, class_="pkg_meta") }}
{{ render_multiselect_field(form.tags, class_="pkg_meta") }}
<div class="pkg_meta row">
{{ render_field(form.license, class_="not_txp col-sm-6") }}
{{ render_field(form.media_license, class_="col-sm-6") }}
</div>
<div class="pkg_meta row">
<div class="not_txp col-sm-6"></div>
<div class="not_txp col-sm-6">{{ _("If there is no media, set the Media License to the same as the License.") }}</div>
</div>
{{ render_field(form.desc, class_="pkg_meta", fieldclass="form-control markdown") }}
</fieldset>
<fieldset class="pkg_meta">
<legend class="not_txp">Dependencies</legend>
<legend class="not_txp">{{ _("Dependencies") }}</legend>
{{ render_mpackage_field(form.provides_str, class_="not_txp", placeholder="Comma separated list") }}
{{ render_deps_field(form.harddep_str, class_="not_txp not_game", placeholder="Comma separated list") }}
@@ -67,30 +75,29 @@
</fieldset>
<fieldset>
<legend class="pkg_meta">Repository and Links</legend>
<legend class="pkg_meta">{{ _("Repository and Links") }}</legend>
<div class="pkg_wiz_1">
<p>Enter the repo URL for the package.
If the repo uses git then the metadata will be automatically imported.</p>
<p>{{ _("Enter the repo URL for the package.
If the repo uses git then the metadata will be automatically imported.") }}</p>
<p>Leave blank if you don't have a repo. Click skip if the import fails.</p>
<p>{{ _("Leave blank if you don't have a repo. Click skip if the import fails.") }}</p>
</div>
{{ render_field(form.repo, class_="pkg_repo") }}
<div class="pkg_wiz_1">
<a id="pkg_wiz_1_next" class="btn btn-primary">Next (Autoimport)</a>
<a id="pkg_wiz_1_skip" class="btn btn-default">Skip Autoimport</a>
<a id="pkg_wiz_1_next" class="btn btn-primary">{{ _("Next (Autoimport)") }}</a>
<a id="pkg_wiz_1_skip" class="btn btn-default">{{ _("Skip Autoimport") }}</a>
</div>
<div class="pkg_wiz_2">
Importing... (This may take a while)
{{ _("Importing... (This may take a while)") }}
</div>
{{ render_field(form.website, class_="pkg_meta") }}
{{ render_field(form.issueTracker, class_="pkg_meta") }}
{{ render_field(form.forums, class_="pkg_meta", placeholder="Tip: paste in a forum topic URL") }}
{{ render_field(form.forums, class_="pkg_meta", placeholder=_("Tip: paste in a forum topic URL")) }}
</fieldset>
<div class="pkg_meta">{{ render_submit_field(form.submit) }}</div>

View File

@@ -17,7 +17,7 @@
{{ render_field(form.type) }}
{{ render_field(form.name) }}
{{ render_field(form.title) }}
{{ render_field(form.shortDesc) }}
{{ render_field(form.short_desc) }}
{{ render_field(form.desc) }}
{{ render_multiselect_field(form.tags) }}

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}
Create a release | {{ package.title }}
{% endblock %}
{% block content %}
<h1>Bulk Change Releases</h1>
<p class="mb-5">
Use this page to set the min and max of all releases for your package.
</p>
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %}
<form method="POST" action="">
{{ form.hidden_tag() }}
<div class="row">
{{ render_checkbox_field(form.set_min, class_="col-sm-2") }}
{{ render_field(form.min_rel, class_="col-sm-10") }}
</div>
<div class="row">
{{ render_checkbox_field(form.set_max, class_="col-sm-2") }}
{{ render_field(form.max_rel, class_="col-sm-10") }}
</div>
{{ render_checkbox_field(form.only_change_none) }}
<p id="minmax_warning" style="color:#f00; display: none;">
Maximum must be greater than or equal to the minimum!
</p>
<p class="mt-3">
Note: Min and max versions will be used to hide the package on
platforms not within the range.
You cannot select the oldest version for min or the newest version
for max as this does not make sense - you can't predict the future.<br />
Leave both as None if in doubt.
</p>
{{ render_submit_field(form.submit) }}
</form>
{% endblock %}
{% block scriptextra %}
<script src="/static/release_minmax.js?v=1"></script>
<script>
function setup_toggle(type) {
var toggle = $("#set_" + type);
function on_change() {
if (toggle.is(":checked")) {
// $("#" + type + "_rel").removeAttr("disabled");
$("#" + type + "_rel").parent().css("opacity", "1");
} else {
// $("#" + type + "_rel").attr("disabled", "disabled");
$("#" + type + "_rel").parent().css("opacity", "0.4");
$("#" + type + "_rel").val($("#" + type + "_rel option:first-child").attr("value"));
$("#" + type + "_rel").change();
}
}
toggle.change(on_change);
on_change();
}
setup_toggle("min");
setup_toggle("max");
</script>
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block title %}
Create a release | {{ package.title }}
Edit release | {{ package.title }}
{% endblock %}
{% block content %}
@@ -40,6 +40,27 @@
{% endif %}
{% endif %}
<div class="row">
{{ render_field(form.min_rel, class_="col-sm-6") }}
{{ render_field(form.max_rel, class_="col-sm-6") }}
</div>
<p id="minmax_warning" style="color:#f00; display: none;">
Maximum must be greater than or equal to the minimum!
</p>
<p>
Note: Min and max versions will be used to hide the package on
platforms not within the range.
You cannot select the oldest version for min or the newest version
for max as this does not make sense - you can't predict the future.<br />
Leave both as None if in doubt.
</p>
{{ render_submit_field(form.submit) }}
</form>
{% endblock %}
{% block scriptextra %}
<script src="/static/release_minmax.js?v=1"></script>
{% endblock %}

View File

@@ -17,7 +17,42 @@
{{ render_field(form.vcsLabel, class_="mt-3") }}
{% endif %}
{{ render_field(form.fileUpload, fieldclass="form-control-file") }}
{{ render_field(form.fileUpload, fieldclass="form-control-file", class_="mt-3", accept=".zip") }}
<div class="row">
{{ render_field(form.min_rel, class_="col-sm-6") }}
{{ render_field(form.max_rel, class_="col-sm-6") }}
</div>
<p id="minmax_warning" style="color:#f00; display: none;">
Maximum must be greater than or equal to the minimum!
</p>
<p>
Note: Min and max versions will be used to hide the package on
platforms not within the range.
You cannot select the oldest version for min or the newest version
for max as this does not make sense - you can't predict the future.<br />
Leave both as None if in doubt.
</p>
{{ render_submit_field(form.submit) }}
</form>
{% endblock %}
{% block scriptextra %}
<script src="/static/release_minmax.js?v=1"></script>
<script>
function check_opt() {
if ($("input[name=uploadOpt]:checked").val() == "vcs") {
$("#fileUpload").parent().hide();
$("#vcsLabel").parent().show();
} else {
$("#fileUpload").parent().show();
$("#vcsLabel").parent().hide();
}
}
$("input[name=uploadOpt]").change(check_opt);
check_opt();
</script>
{% endblock %}

View File

@@ -10,7 +10,7 @@
{{ form.hidden_tag() }}
{{ render_field(form.title) }}
{{ render_field(form.fileUpload, fieldclass="form-control-file") }}
{{ render_field(form.fileUpload, fieldclass="form-control-file", accept="image/png,image/jpeg") }}
{{ render_submit_field(form.submit) }}
</form>
{% endblock %}

View File

@@ -19,11 +19,12 @@
</h1>
<p class="lead">
{{ package.shortDesc }}
{{ package.short_desc }}
</p>
<div class="row" style="margin-top: 2rem;">
<div class="col">
{{ 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 %}
@@ -111,11 +112,22 @@
{% endif %}
<aside class="float-right ml-4" style="width: 18rem;">
{% if package.getDownloadRelease() %}
<a class="btn btn-download btn-lg btn-block"
{% set release = package.getDownloadRelease() %}
{% if release %}
<a class="btn btn-download btn-lg btn-block" rel="nofollow"
href="{{ package.getDownloadURL() }}" class="btn_green">
Download
</a>
<p class="text-center m-2" style="font-size: 80%;">
{% if release.min_rel and release.max_rel %}
Minetest {{ release.min_rel.name }} - {{ release.max_rel.name }}
{% elif release.min_rel %}
Supports Minetest {{ release.min_rel.name }} and above.
{% elif release.max_rel %}
Supports Minetest {{ release.max_rel.name }} and below.
{% endif %}
</p>
{% else %}
No download available.
{% endif %}
@@ -205,6 +217,13 @@
</table>
</div>
{% if package.author.donate_url %}
<div class="alert alert-secondary">
Like {{ package.author.display_name }}'s work?
<a href="{{ package.author.donate_url }}" rel="nofollow">Donate now!</a>
</div>
{% endif %}
{% if package.type == package.type.MOD %}
<div class="card my-4">
<div class="card-header">Dependencies</div>
@@ -237,10 +256,13 @@
<div class="card my-4">
<div class="card-header">
Releases
{% if package.checkPerm(current_user, "MAKE_RELEASE") %}
<a class="float-right"
href="{{ package.getCreateReleaseURL() }}">+</a>
{% endif %}
<div class="float-right">
{% if package.checkPerm(current_user, "MAKE_RELEASE") %}
<a href="{{ package.getBulkReleaseURL() }}">bulk</a>
|
<a href="{{ package.getCreateReleaseURL() }}">+</a>
{% endif %}
</div>
</div>
<ul class="list-group list-group-flush">
{% for rel in releases %}
@@ -257,10 +279,28 @@
{% if not rel.approved %}<i>{% endif %}
<a href="{{ rel.getDownloadURL() }}">{{ rel.title }}</a>{% if rel.commit_hash %}
[{{ rel.commit_hash | truncate(5, end='') }}]{% endif %}<br>
<small>created {{ rel.releaseDate | datetime }}.</small>
{% if rel.task_id %}
<a href="{{ rel.getDownloadURL() }}" rel="nofollow">{{ rel.title }}</a>
<span style="color:#ddd;">
{% if rel.min_rel and rel.max_rel %}
[MT {{ rel.min_rel.name }}-{{ rel.max_rel.name }}]
{% elif rel.min_rel %}
[MT {{ rel.min_rel.name }}+]
{% elif rel.max_rel %}
[MT &le;{{ rel.max_rel.name }}]
{% endif %}
</span>
<br>
<small style="color:#999;">
{% if rel.commit_hash %}
[{{ rel.commit_hash | truncate(5, end='') }}]
{% endif %}
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>
{% elif not rel.approved %}
Waiting for approval.
@@ -289,6 +329,13 @@
{{ render_threadlist(threads, list_group=True) }}
</ul>
</div>
{% 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) }}">
Report a problem with this listing
</a>
{% endif %}
</aside>
{% if not package.approved and (package.author == current_user or package.checkPerm(current_user, "APPROVE_NEW")) %}

View File

@@ -6,6 +6,13 @@
{% block content %}
{% if package and current_user != package.author and not current_user.rank.atLeast(current_user.rank.EDITOR) and package.issueTracker %}
<div class="alert alert-warning">
Found a bug? Post on the <a href="{{ package.issueTracker }}">issue tracker</a> instead.<br />
If the package shouldn't be on CDB - for example, if it doesn't work at all - then please let us know here.
</div>
{% endif %}
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %}
<form method="POST" action="" enctype="multipart/form-data">
{{ form.hidden_tag() }}

View File

@@ -5,53 +5,89 @@
{% endblock %}
{% block content %}
<h2>Awaiting Approval</h2>
<h2 class="mb-4">Approval Queue</h2>
{% if canApproveNew and packages %}
<h3>Packages</h3>
<ul>
{% for p in packages %}
<li><a href="{{ p.getDetailsURL() }}">
{{ p.title }} by {{ p.author.display_name }}
</a></li>
{% else %}
<li><i>No packages need reviewing.</i></ul>
{% endfor %}
</ul>
{% endif %}
<div class="row">
{% if canApproveNew and packages %}
<div class="col-sm-6">
<div class="card">
<h3 class="card-header">Packages</h3>
<div class="list-group list-group-flush">
{% for p in packages %}
<a href="{{ p.getDetailsURL() }}" class="list-group-item list-group-item-action">
{% if p.getState() == "thread" %}
<span class="mr-2 badge badge-danger">Thread</span>
{% elif p.getState() == "ready" %}
<span class="mr-2 badge badge-success">Ready</span>
{% elif p.getState() == "wip" %}
<span class="mr-2 badge badge-warning">WIP</span>
{% elif p.getState() == "license" %}
<span class="mr-2 badge badge-info">WIP</span>
{% endif %}
{{ p.title }} by {{ p.author.display_name }}
</a>
{% else %}
<li class="list-group-item"><i>No packages need reviewing.</i></li>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if canApproveRel and releases %}
<div class="col-sm-6">
<div class="card">
<h3 class="card-header">Releases</h3>
<ul class="list-group list-group-flush">
{% for r in releases %}
<li class="list-group-item">
<a href="{{ r.getEditURL() }}">{{ r.title }}</a>
on
<a href="{{ r.package.getDetailsURL() }}">
{{ r.package.title }} by {{ r.package.author.display_name }}
</a>
</li>
{% else %}
<li class="list-group-item"><i>No releases need reviewing.</i></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</div>
{% if canApproveScn and screenshots %}
<h3>Screenshots</h3>
<ul>
{% for s in screenshots %}
<li>
<a href="{{ s.getEditURL() }}">{{ s.title }}</a>
on
<a href="{{ s.package.getDetailsURL() }}">
{{ s.package.title }} by {{ s.package.author.display_name }}
</a>
</li>
{% else %}
<li><i>No screenshots need reviewing.</i></ul>
{% endfor %}
</ul>
{% endif %}
{% if canApproveRel and releases %}
<h3>Releases</h3>
<ul>
{% for r in releases %}
<li>
<a href="{{ r.getEditURL() }}">{{ r.title }}</a>
on
<a href="{{ r.package.getDetailsURL() }}">
{{ r.package.title }} by {{ r.package.author.display_name }}
</a>
</li>
{% else %}
<li><i>No releases need reviewing.</i></ul>
{% endfor %}
</ul>
<div class="card my-4">
<h3 class="card-header">Screenshots
<form class="float-right" method="post" action="{{ url_for('todo_page') }}">
<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" />
</form>
</h3>
<ul class="card-body d-flex p-0 flex-row flex-wrap justify-content-start align-content-start p-4">
{% for s in screenshots %}
<li class="packagetile flex-fill"><a href="{{ s.getEditURL() }}"
style="background-image: url({{ s.getThumbnailURL(3) or '/static/placeholder.png' }});">
<div class="packagegridscrub"></div>
<div class="packagegridinfo">
<h3>
{{ s.title }}
<br />
<small>{{ s.package.title }} by {{ s.package.author.display_name }}</small>
</h3>
<p></p>
</div>
</a></li>
{% else %}
<li><i>No screenshots need reviewing.</i></li>
{% endfor %}
{% for i in range(4) %}
<li class="packagetile flex-fill"></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if not (packages or screenshots or releases) %}
@@ -60,11 +96,19 @@
</p>
{% endif %}
<h2>Unadded Topic List</h2>
<h2 class="mt-4">Unadded Topic List</h2>
<p>
There are
<a href="{{ url_for('todo_topics_page') }}">{{ topics_to_add }} packages</a>
to be added to cdb, based on cdb's forum parser.
{{ 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>
<a class="btn btn-primary" href="{{ url_for('todo_topics_page') }}">View Unadded Topic List</a>
{% endblock %}

View File

@@ -11,8 +11,10 @@
<a href="{{ url_for('user_profile_page', username=user.username) }}">
{{ user.display_name }}
</a> -
{{ user.rank.getTitle() }} -
{{ user.packages.count() }} packages.
{{ user.rank.getTitle() }}
{% if current_user.is_authenticated %}
- {{ user.packages.count() }} packages.
{% endif %}
{% endfor %}
</ul>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}
Send Email
{% endblock %}
{% block content %}
<h1>Send Email</h1>
{% from "macros/forms.html" import render_field, render_submit_field %}
<form action="" method="POST" class="form" role="form">
{{ form.hidden_tag() }}
{{ render_field(form.subject) }}
{{ render_field(form.text, fieldclass="form-control markdown") }}
{{ render_submit_field(form.submit) }}
</form>
{% endblock %}

View File

@@ -7,7 +7,7 @@
{% block content %}
{% if not current_user.is_authenticated and user.rank == user.rank.NOT_JOINED and user.forums_username %}
<div class="alert alert-info alert alert-info">
<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>
@@ -40,7 +40,7 @@
</td>
</tr>
<tr>
<td>Accounts:</td>
<td>Links:</td>
<td>
{% if user.forums_username %}
<a href="https://forum.minetest.net/memberlist.php?mode=viewprofile&un={{ user.forums_username }}">
@@ -50,7 +50,7 @@
No forum account
{% endif %}
{% if (user.forums_username and user.github_username) or user == current_user %}
{% if user.github_username or user == current_user %}
|
{% endif %}
@@ -60,11 +60,38 @@
<a href="{{ url_for('github_signin_page') }}">Link Github</a>
{% endif %}
{% if user.website_url %}
| <a href="{{ user.website_url }}" rel="nofollow">Website</a>
{% endif %}
{% if user == current_user %}
&#x1f30e;
<br>
<small class="text-muted">
<span style="padding-right: 5px;">&#x1f30e;</span>
Visible to everyone
</small>
{% endif %}
</td>
</tr>
{% if current_user.is_authenticated and current_user.rank.atLeast(current_user.rank.MODERATOR) %}
<tr>
<td>Admin</td>
<td>
{% if user.email %}
<a class="btn btn-primary" href="{{ url_for('send_email_page', username=user.username) }}">
Email
</a>
{% else %}
<a class="btn btn-primary disabled"
data-toggle="tooltip" data-placement="bottom"
title="No email address for user"
style="pointer-events: all;">
Email
</a>
{% endif %}
</td>
</tr>
{% endif %}
{% if user == current_user %}
<tr>
<td>Profile Picture:</td>
@@ -117,6 +144,8 @@
{% if user.checkPerm(current_user, "CHANGE_DNAME") %}
{{ render_field(form.display_name, tabindex=230) }}
{{ render_field(form.website_url, tabindex=232) }}
{{ render_field(form.donate_url, tabindex=233) }}
{% endif %}
{% if user.checkPerm(current_user, "CHANGE_EMAIL") %}
@@ -139,6 +168,13 @@
{% from "macros/packagegridtile.html" import render_pkggrid %}
{{ render_pkggrid(packages, show_author=False) }}
{% if user.donate_url %}
<div class="alert alert-secondary">
Like {{ user.display_name }}'s work?
<a href="{{ user.donate_url }}" rel="nofollow">Donate now!</a>
</div>
{% endif %}
{% if current_user == user or (current_user.is_authenticated and current_user.rank.atLeast(current_user.rank.EDITOR)) %}
<div class="card mt-3">
<a name="unadded-topics"></a>

View File

@@ -20,7 +20,7 @@ from flask_user import *
from flask_login import login_user, logout_user
from app.models import *
from app import app
import random, string, os
import random, string, os, imghdr
def getExtension(filename):
return filename.rsplit(".", 1)[1].lower() if "." in filename else None
@@ -28,6 +28,10 @@ def getExtension(filename):
def isFilenameAllowed(filename, exts):
return getExtension(filename) in exts
ALLOWED_IMAGES = set(["jpeg", "png"])
def isAllowedImage(data):
return imghdr.what(None, data) in ALLOWED_IMAGES
def shouldReturnJson():
return "application/json" in request.accept_mimetypes and \
not "text/html" in request.accept_mimetypes
@@ -36,16 +40,32 @@ def randomString(n):
return ''.join(random.choice(string.ascii_lowercase + \
string.ascii_uppercase + string.digits) for _ in range(n))
def doFileUpload(file, allowedExtensions, fileTypeName):
def doFileUpload(file, fileType, fileTypeDesc):
if not file or file is None or file.filename == "":
flash("No selected file", "error")
return None
allowedExtensions = []
isImage = False
if fileType == "image":
allowedExtensions = ["jpg", "jpeg", "png"]
isImage = True
elif fileType == "zip":
allowedExtensions = ["zip"]
else:
raise Exception("Invalid fileType")
ext = getExtension(file.filename)
if ext is None or not ext in allowedExtensions:
flash("Please upload load " + fileTypeName, "error")
flash("Please upload load " + fileTypeDesc, "danger")
return None
if isImage and not isAllowedImage(file.stream.read()):
flash("Uploaded image isn't actually an image", "danger")
return None
file.stream.seek(0)
filename = randomString(10) + "." + ext
file.save(os.path.join("app/public/uploads", filename))
return "/uploads/" + filename

View File

@@ -22,6 +22,7 @@ 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()
@@ -53,12 +54,12 @@ def home_page():
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()
return render_template("index.html", count=count, \
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, githublogin, packages, meta, threads, api
from . import tasks, admin, notifications, tagseditor, licenseseditor
from . import sass, thumbnails
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>/')

View File

@@ -0,0 +1,18 @@
# 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 . import admin, licenseseditor, tagseditor, versioneditor, todo

View File

@@ -20,8 +20,9 @@ from flask_user import *
import flask_menu as menu
from app import app
from app.models import *
from app.querybuilder import QueryBuilder
@app.route("/todo/")
@app.route("/todo/", methods=["GET", "POST"])
@login_required
def todo_page():
canApproveNew = Permission.APPROVE_NEW.check(current_user)
@@ -40,60 +41,61 @@ def todo_page():
if canApproveScn:
screenshots = PackageScreenshot.query.filter_by(approved=False).all()
if not canApproveNew and not canApproveRel and not canApproveScn:
abort(403)
topics_to_add = ForumTopic.query \
if request.method == "POST":
if request.form["action"] == "screenshots_approve_all":
if not canApproveScn:
abort(403)
PackageScreenshot.query.update({ "approved": True })
db.session.commit()
return redirect(url_for("todo_page"))
else:
abort(400)
topic_query = ForumTopic.query \
.filter_by(discarded=False)
total_topics = topic_query.count()
topics_to_add = topic_query \
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
.filter_by(discarded=False) \
.count()
return render_template("todo/list.html", title="Reports and Work Queue",
packages=packages, releases=releases, screenshots=screenshots,
canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn,
topics_to_add=topics_to_add)
topics_to_add=topics_to_add, total_topics=total_topics)
@app.route("/todo/topics/")
@login_required
def todo_topics_page():
query = ForumTopic.query
show_discarded = request.args.get("show_discarded") == "True"
if not show_discarded:
query = query.filter_by(discarded=False)
total = query.count()
query = query.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
sort_by = request.args.get("sort")
if sort_by == "name":
query = query.order_by(db.asc(ForumTopic.wip), db.asc(ForumTopic.name), db.asc(ForumTopic.title))
elif sort_by == "views":
query = query.order_by(db.desc(ForumTopic.views))
elif sort_by is None or sort_by == "date":
query = query.order_by(db.asc(ForumTopic.created_at))
sort_by = "date"
qb = QueryBuilder(request.args)
qb.setSortIfNone("date")
query = qb.buildTopicQuery()
tmp_q = ForumTopic.query
if not qb.show_discarded:
tmp_q = tmp_q.filter_by(discarded=False)
total = tmp_q.count()
topic_count = query.count()
search = request.args.get("q")
if search is not None and search.strip() != "":
query = query.filter(ForumTopic.title.ilike('%' + search + '%'))
page = int(request.args.get("page") or 1)
num = int(request.args.get("n") or 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=search, \
show_discarded=show_discarded, n=num, sort=sort_by) \
next_url = url_for("todo_topics_page", 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=search, \
show_discarded=show_discarded, n=num, sort=sort_by) \
prev_url = url_for("todo_topics_page", page=query.prev_num, query=qb.search, \
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
if query.has_prev else None
return render_template("todo/topics.html", topics=query.items, total=total, \
topic_count=topic_count, query=search, show_discarded=show_discarded, \
topic_count=topic_count, query=qb.search, show_discarded=qb.show_discarded, \
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages, \
n=num, sort_by=sort_by)
n=num, sort_by=qb.order_by)

View File

@@ -0,0 +1,60 @@
# 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 *
from flask_user import *
from app import app
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/")
@rank_required(UserRank.MODERATOR)
def version_list_page():
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
class VersionForm(FlaskForm):
name = StringField("Name", [InputRequired(), Length(3,100)])
protocol = IntegerField("Protocol")
submit = SubmitField("Save")
@app.route("/versions/new/", methods=["GET", "POST"])
@app.route("/versions/<name>/edit/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def createedit_version_page(name=None):
version = None
if name is not None:
version = MinetestRelease.query.filter_by(name=name).first()
if version is None:
abort(404)
form = VersionForm(formdata=request.form, obj=version)
if request.method == "POST" and form.validate():
if version is None:
version = MinetestRelease(form.name.data)
db.session.add(version)
flash("Created version " + form.name.data, "success")
else:
flash("Updated version " + form.name.data, "success")
form.populate_obj(version)
db.session.commit()
return redirect(url_for("version_list_page"))
return render_template("admin/versions/edit.html", version=version, form=form)

View File

@@ -19,30 +19,60 @@ from flask import *
from flask_user import *
from app import app
from app.models import *
from app.utils import is_package_page, rank_required
from .packages import QueryBuilder
from app.utils import is_package_page
from app.querybuilder import QueryBuilder
@app.route("/api/packages/")
def api_packages_page():
qb = QueryBuilder()
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
ver = qb.getMinetestVersion()
pkgs = [package.getAsDictionaryShort(app.config["BASE_URL"]) \
for package in query.all() if package.getDownloadRelease() is not None]
pkgs = [package.getAsDictionaryShort(app.config["BASE_URL"], version=ver) \
for package in query.all()]
return jsonify(pkgs)
@app.route("/api/packages/<author>/<name>/")
@is_package_page
def api_package_page(package):
return jsonify(package.getAsDictionary(app.config["BASE_URL"]))
@app.route("/api/packages/<author>/<name>/dependencies/")
@is_package_page
def api_package_deps_page(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)
@app.route("/api/topics/")
def api_topics_page():
query = ForumTopic.query \
.order_by(db.asc(ForumTopic.wip), db.asc(ForumTopic.name), db.asc(ForumTopic.title))
pkgs = [t.getAsDictionary() for t in query.all()]
return jsonify(pkgs)
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"])
@@ -61,3 +91,9 @@ def topic_set_discard():
db.session.commit()
return jsonify(topic.getAsDictionary())
@app.route("/api/minetest_versions/")
def api_minetest_versions_page():
return jsonify([{ "name": rel.name, "protocol_version": rel.protocol }\
for rel in MinetestRelease.query.all() if rel.getActual() is not None])

View File

@@ -15,431 +15,4 @@
# 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.tasks.importtasks import importRepoScreenshot, makeVCSRelease
from app.utils import *
from celery import uuid
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from sqlalchemy import or_, any_
from sqlalchemy.sql.expression import func
class QueryBuilder:
title = None
types = None
search = None
def __init__(self):
title = "Packages"
# Get request types
types = request.args.getlist("type")
types = [PackageType.get(tname) for tname in types]
types = [type for type in types if type is not None]
if len(types) > 0:
title = ", ".join([type.value + "s" for type in types])
self.title = title
self.types = types
self.search = request.args.get("q")
self.random = "random" in request.args
self.lucky = self.random or "lucky" in request.args
self.hide_nonfree = isNo(request.args.get("nonfree"))
self.limit = 1 if self.lucky else None
self.order_by = request.args.get("sort") or "score"
self.order_dir = request.args.get("order") or "desc"
def buildPackageQuery(self):
query = Package.query.filter_by(soft_deleted=False, approved=True)
if len(self.types) > 0:
query = query.filter(Package.type.in_(self.types))
if self.search is not None and self.search.strip() != "":
query = query.filter(Package.title.ilike('%' + self.search + '%'))
if self.random:
query = query.order_by(func.random())
else:
to_order = None
if self.order_by == "score":
to_order = Package.score
elif self.order_by == "created_at":
to_order = Package.created_at
else:
abort(400)
if self.order_dir == "asc":
to_order = db.asc(to_order)
elif self.order_dir == "desc":
to_order = db.desc(to_order)
else:
abort(400)
query = query.order_by(to_order)
if self.hide_nonfree:
query = query.filter(Package.license.has(License.is_foss == True))
query = query.filter(Package.media_license.has(License.is_foss == True))
if self.limit:
query = query.limit(self.limit)
return query
def buildTopicQuery(self):
topics = ForumTopic.query \
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.wip), db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.filter(ForumTopic.title.ilike('%' + self.search + '%'))
if len(self.types) > 0:
topics = topics.filter(ForumTopic.type.in_(self.types))
if self.hide_nonfree:
query = query.filter(Package.license.has(License.is_foss == True))
query = query.filter(Package.media_license.has(License.is_foss == True))
if self.limit:
topics = topics.limit(self.limit)
return topics
@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():
if shouldReturnJson():
return redirect(url_for("api_packages_page"))
qb = QueryBuilder()
query = qb.buildPackageQuery()
title = qb.title
if qb.lucky:
package = query.first()
if package:
return redirect(package.getDetailsURL())
topic = qb.buildTopicQuery().first()
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))
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) \
if query.has_next else None
prev_url = url_for("packages_page", type=type_name, q=search, page=query.prev_num) \
if query.has_prev else None
topics = None
if qb.search and not query.has_next:
topics = qb.buildTopicQuery().all()
tags = Tag.query.all()
return render_template("packages/list.html", \
title=title, packages=query.items, topics=topics, \
query=search, tags=tags, type=type_name, \
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages, packages_count=query.total)
def getReleases(package):
if package.checkPerm(current_user, Permission.MAKE_RELEASE):
return package.releases
else:
return [rel for rel in package.releases if rel.approved]
@app.route("/packages/<author>/<name>/")
@is_package_page
def package_page(package):
clearNotifications(package.getDetailsURL())
alternatives = None
if package.type == PackageType.MOD:
alternatives = Package.query \
.filter_by(name=package.name, type=PackageType.MOD, soft_deleted=False) \
.filter(Package.id != package.id) \
.order_by(db.desc(Package.score)) \
.all()
show_similar_topics = current_user == package.author or \
package.checkPerm(current_user, Permission.APPROVE_NEW)
similar_topics = None if not show_similar_topics else \
ForumTopic.query \
.filter_by(name=package.name) \
.filter(ForumTopic.topic_id != package.forums) \
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
releases = getReleases(package)
requests = [r for r in package.requests if r.status == 0]
review_thread = package.review_thread
if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD):
review_thread = None
topic_error = None
topic_error_lvl = "warning"
if not package.approved and package.forums is not None:
errors = []
if Package.query.filter_by(forums=package.forums, soft_deleted=False).count() > 1:
errors.append("<b>Error: Another package already uses this forum topic!</b>")
topic_error_lvl = "danger"
topic = ForumTopic.query.get(package.forums)
if topic is not None:
if topic.author != package.author:
errors.append("<b>Error: Forum topic author doesn't match package author.</b>")
topic_error_lvl = "danger"
if topic.wip:
errors.append("Warning: Forum topic is in WIP section, make sure package meets playability standards.")
elif package.type != PackageType.TXP:
errors.append("Warning: Forum topic not found. This may happen if the topic has only just been created.")
topic_error = "<br />".join(errors)
threads = Thread.query.filter_by(package_id=package.id)
if not current_user.is_authenticated:
threads = threads.filter_by(private=False)
elif not current_user.rank.atLeast(UserRank.EDITOR) and not current_user == package.author:
threads = threads.filter(or_(Thread.private == False, Thread.author == current_user))
return render_template("packages/view.html", \
package=package, releases=releases, requests=requests, \
alternatives=alternatives, similar_topics=similar_topics, \
review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl, \
threads=threads.all())
@app.route("/packages/<author>/<name>/download/")
@is_package_page
def package_download_page(package):
release = package.getDownloadRelease()
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:
return redirect(release.url, code=302)
class PackageForm(FlaskForm):
name = StringField("Name (Technical)", [InputRequired(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
title = StringField("Title (Human-readable)", [InputRequired(), Length(3, 50)])
shortDesc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)])
desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)])
type = SelectField("Type", [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
license = QuerySelectField("License", [InputRequired()], query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
media_license = QuerySelectField("Media License", [InputRequired()], query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
provides_str = StringField("Provides (mods included in package)", [Optional(), Length(0,1000)])
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()])
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"])
@login_required
def create_edit_package_page(author=None, name=None):
package = None
form = None
if author is None:
form = PackageForm(formdata=request.form)
author = request.args.get("author")
if author is None or author == current_user.username:
author = current_user
else:
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"))
if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
flash("Permission denied", "error")
return redirect(url_for("create_edit_package_page"))
else:
package = getPackageByInfo(author, name)
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
return redirect(package.getDetailsURL())
author = package.author
form = PackageForm(formdata=request.form, obj=package)
# Initial form class from post data and default data
if request.method == "GET":
if package is None:
form.name.data = request.args.get("bname")
form.title.data = request.args.get("title")
form.repo.data = request.args.get("repo")
form.forums.data = request.args.get("forums")
else:
deps = package.dependencies
form.harddep_str.data = ",".join([str(x) for x in deps if not x.optional])
form.softdep_str.data = ",".join([str(x) for x in deps if x.optional])
form.provides_str.data = MetaPackage.ListToSpec(package.provides)
if request.method == "POST" and form.validate():
wasNew = False
if not package:
package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
if package is not None:
if package.soft_deleted:
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"))
package = Package()
package.author = author
wasNew = True
else:
triggerNotif(package.author, current_user,
"{} edited".format(package.title), package.getDetailsURL())
form.populate_obj(package) # copy to row
mpackage_cache = {}
package.provides.clear()
mpackages = MetaPackage.SpecToList(form.provides_str.data, mpackage_cache)
for m in mpackages:
package.provides.append(m)
Dependency.query.filter_by(depender=package).delete()
deps = Dependency.SpecToList(package, form.harddep_str.data, mpackage_cache)
for dep in deps:
dep.optional = False
db.session.add(dep)
deps = Dependency.SpecToList(package, form.softdep_str.data, mpackage_cache)
for dep in deps:
dep.optional = True
db.session.add(dep)
if wasNew and package.type == PackageType.MOD and not package.name in mpackage_cache:
m = MetaPackage.GetOrCreate(package.name, mpackage_cache)
package.provides.append(m)
package.tags.clear()
for tag in form.tags.raw_data:
package.tags.append(Tag.query.get(tag))
db.session.commit() # save
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)
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)
return redirect(next_url)
package_query = Package.query.filter_by(approved=True, soft_deleted=False)
if package is not None:
package_query = package_query.filter(Package.id != package.id)
enableWizard = name is None and request.method != "POST"
return render_template("packages/create_edit.html", package=package, \
form=form, author=author, enable_wizard=enableWizard, \
packages=package_query.all(), \
mpackages=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all())
@app.route("/packages/<author>/<name>/approve/", methods=["POST"])
@login_required
@is_package_page
def approve_package_page(package):
if not package.checkPerm(current_user, Permission.APPROVE_NEW):
flash("You don't have permission to do that.", "error")
elif package.approved:
flash("Package has already been approved", "error")
else:
package.approved = True
screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all()
for s in screenshots:
s.approved = True
triggerNotif(package.author, current_user,
"{} approved".format(package.title), package.getDetailsURL())
db.session.commit()
return redirect(package.getDetailsURL())
@app.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
@login_required
@is_package_page
def remove_package_page(package):
if request.method == "GET":
return render_template("packages/remove.html", package=package)
if "delete" in request.form:
if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
flash("You don't have permission to do that.", "error")
return redirect(package.getDetailsURL())
package.soft_deleted = True
url = url_for("user_profile_page", username=package.author.username)
triggerNotif(package.author, current_user,
"{} deleted".format(package.title), url)
db.session.commit()
flash("Deleted package", "success")
return redirect(url)
elif "unapprove" in request.form:
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
flash("You don't have permission to do that.", "error")
return redirect(package.getDetailsURL())
package.approved = False
triggerNotif(package.author, current_user,
"{} deleted".format(package.title), package.getDetailsURL())
db.session.commit()
flash("Unapproved package", "success")
return redirect(package.getDetailsURL())
else:
abort(400)
from . import todo, screenshots, releases
from . import packages, screenshots, releases

View File

@@ -0,0 +1,369 @@
# 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, abort, request, redirect, url_for, flash
from flask_user import current_user
import flask_menu as menu
from app import app
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 *
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
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():
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
title = qb.title
if qb.lucky:
package = query.first()
if package:
return redirect(package.getDetailsURL())
topic = qb.buildTopicQuery().first()
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))
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) \
if query.has_next else None
prev_url = url_for("packages_page", type=type_name, q=search, page=query.prev_num) \
if query.has_prev else None
topics = None
if qb.search and not query.has_next:
topics = qb.buildTopicQuery().all()
tags = Tag.query.all()
return render_template("packages/list.html", \
title=title, packages=query.items, topics=topics, \
query=search, tags=tags, type=type_name, \
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages, packages_count=query.total)
def getReleases(package):
if package.checkPerm(current_user, Permission.MAKE_RELEASE):
return package.releases.limit(5)
else:
return package.releases.filter_by(approved=True).limit(5)
@app.route("/packages/<author>/<name>/")
@is_package_page
def package_page(package):
clearNotifications(package.getDetailsURL())
alternatives = None
if package.type == PackageType.MOD:
alternatives = Package.query \
.filter_by(name=package.name, type=PackageType.MOD, soft_deleted=False) \
.filter(Package.id != package.id) \
.order_by(db.desc(Package.score)) \
.all()
show_similar_topics = current_user == package.author or \
package.checkPerm(current_user, Permission.APPROVE_NEW)
similar_topics = None if not show_similar_topics else \
ForumTopic.query \
.filter_by(name=package.name) \
.filter(ForumTopic.topic_id != package.forums) \
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
releases = getReleases(package)
requests = [r for r in package.requests if r.status == 0]
review_thread = package.review_thread
if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD):
review_thread = None
topic_error = None
topic_error_lvl = "warning"
if not package.approved and package.forums is not None:
errors = []
if Package.query.filter_by(forums=package.forums, soft_deleted=False).count() > 1:
errors.append("<b>Error: Another package already uses this forum topic!</b>")
topic_error_lvl = "danger"
topic = ForumTopic.query.get(package.forums)
if topic is not None:
if topic.author != package.author:
errors.append("<b>Error: Forum topic author doesn't match package author.</b>")
topic_error_lvl = "danger"
if topic.wip:
errors.append("Warning: Forum topic is in WIP section, make sure package meets playability standards.")
elif package.type != PackageType.TXP:
errors.append("Warning: Forum topic not found. This may happen if the topic has only just been created.")
topic_error = "<br />".join(errors)
threads = Thread.query.filter_by(package_id=package.id)
if not current_user.is_authenticated:
threads = threads.filter_by(private=False)
elif not current_user.rank.atLeast(UserRank.EDITOR) and not current_user == package.author:
threads = threads.filter(or_(Thread.private == False, Thread.author == current_user))
return render_template("packages/view.html", \
package=package, releases=releases, requests=requests, \
alternatives=alternatives, similar_topics=similar_topics, \
review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl, \
threads=threads.all())
@app.route("/packages/<author>/<name>/download/")
@is_package_page
def package_download_page(package):
release = package.getDownloadRelease()
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()
return redirect(release.url, code=302)
class PackageForm(FlaskForm):
name = StringField("Name (Technical)", [InputRequired(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
title = StringField("Title (Human-readable)", [InputRequired(), Length(3, 50)])
short_desc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)])
desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)])
type = SelectField("Type", [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
license = QuerySelectField("License", [InputRequired()], query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
media_license = QuerySelectField("Media License", [InputRequired()], query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
provides_str = StringField("Provides (mods included in package)", [Optional()])
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()], 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"])
@login_required
def create_edit_package_page(author=None, name=None):
package = None
form = None
if author is None:
form = PackageForm(formdata=request.form)
author = request.args.get("author")
if author is None or author == current_user.username:
author = current_user
else:
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"))
if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
flash("Permission denied", "error")
return redirect(url_for("create_edit_package_page"))
else:
package = getPackageByInfo(author, name)
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
return redirect(package.getDetailsURL())
author = package.author
form = PackageForm(formdata=request.form, obj=package)
# Initial form class from post data and default data
if request.method == "GET":
if package is None:
form.name.data = request.args.get("bname")
form.title.data = request.args.get("title")
form.repo.data = request.args.get("repo")
form.forums.data = request.args.get("forums")
else:
deps = package.dependencies
form.harddep_str.data = ",".join([str(x) for x in deps if not x.optional])
form.softdep_str.data = ",".join([str(x) for x in deps if x.optional])
form.provides_str.data = MetaPackage.ListToSpec(package.provides)
if request.method == "POST" and form.validate():
wasNew = False
if not package:
package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
if package is not None:
if package.soft_deleted:
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"))
package = Package()
package.author = author
wasNew = True
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))
else:
triggerNotif(package.author, current_user,
"{} edited".format(package.title), package.getDetailsURL())
form.populate_obj(package) # copy to row
if package.type== PackageType.TXP:
package.license = package.media_license
mpackage_cache = {}
package.provides.clear()
mpackages = MetaPackage.SpecToList(form.provides_str.data, mpackage_cache)
for m in mpackages:
package.provides.append(m)
Dependency.query.filter_by(depender=package).delete()
deps = Dependency.SpecToList(package, form.harddep_str.data, mpackage_cache)
for dep in deps:
dep.optional = False
db.session.add(dep)
deps = Dependency.SpecToList(package, form.softdep_str.data, mpackage_cache)
for dep in deps:
dep.optional = True
db.session.add(dep)
if wasNew and package.type == PackageType.MOD and not package.name in mpackage_cache:
m = MetaPackage.GetOrCreate(package.name, mpackage_cache)
package.provides.append(m)
package.tags.clear()
for tag in form.tags.raw_data:
package.tags.append(Tag.query.get(tag))
db.session.commit() # save
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)
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)
return redirect(next_url)
package_query = Package.query.filter_by(approved=True, soft_deleted=False)
if package is not None:
package_query = package_query.filter(Package.id != package.id)
enableWizard = name is None and request.method != "POST"
return render_template("packages/create_edit.html", package=package, \
form=form, author=author, enable_wizard=enableWizard, \
packages=package_query.all(), \
mpackages=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all())
@app.route("/packages/<author>/<name>/approve/", methods=["POST"])
@login_required
@is_package_page
def approve_package_page(package):
if not package.checkPerm(current_user, Permission.APPROVE_NEW):
flash("You don't have permission to do that.", "error")
elif package.approved:
flash("Package has already been approved", "error")
else:
package.approved = True
screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all()
for s in screenshots:
s.approved = True
triggerNotif(package.author, current_user,
"{} approved".format(package.title), package.getDetailsURL())
db.session.commit()
return redirect(package.getDetailsURL())
@app.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
@login_required
@is_package_page
def remove_package_page(package):
if request.method == "GET":
return render_template("packages/remove.html", package=package)
if "delete" in request.form:
if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
flash("You don't have permission to do that.", "error")
return redirect(package.getDetailsURL())
package.soft_deleted = True
url = url_for("user_profile_page", username=package.author.username)
triggerNotif(package.author, current_user,
"{} deleted".format(package.title), url)
db.session.commit()
flash("Deleted package", "success")
return redirect(url)
elif "unapprove" in request.form:
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
flash("You don't have permission to do that.", "error")
return redirect(package.getDetailsURL())
package.approved = False
triggerNotif(package.author, current_user,
"{} deleted".format(package.title), package.getDetailsURL())
db.session.commit()
flash("Unapproved package", "success")
return redirect(package.getDetailsURL())
else:
abort(400)

View File

@@ -27,19 +27,39 @@ from celery import uuid
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField
def get_mt_releases(is_max):
query = MinetestRelease.query.order_by(db.asc(MinetestRelease.id))
if is_max:
query = query.limit(query.count() - 1)
else:
query = query.filter(MinetestRelease.name != "0.4.17")
return query
class CreatePackageReleaseForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(1, 30)])
uploadOpt = RadioField ("Method", choices=[("upload", "File Upload")], default="upload")
vcsLabel = StringField("VCS Commit or Branch", default="master")
vcsLabel = StringField("VCS Commit Hash, Branch, or Tag", default="master")
fileUpload = FileField("File Upload")
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)
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
submit = SubmitField("Save")
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)
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
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"])
@@ -63,6 +83,8 @@ def create_release_page(package):
rel.title = form["title"].data
rel.url = ""
rel.task_id = uuid()
rel.min_rel = form["min_rel"].data.getActual()
rel.max_rel = form["max_rel"].data.getActual()
db.session.add(rel)
db.session.commit()
@@ -74,12 +96,15 @@ def create_release_page(package):
return redirect(url_for("check_task", id=rel.task_id, r=rel.getEditURL()))
else:
uploadedPath = doFileUpload(form.fileUpload.data, ["zip"], "a zip file")
uploadedPath = doFileUpload(form.fileUpload.data, "zip", "a zip file")
if uploadedPath is not None:
rel = PackageRelease()
rel.package = package
rel.title = form["title"].data
rel.url = uploadedPath
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()
@@ -105,6 +130,11 @@ def download_release_page(package, id):
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()
return redirect(release.url, code=300)
@app.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"])
@@ -128,11 +158,13 @@ def edit_release_page(package, id):
wasApproved = release.approved
if canEdit:
release.title = form["title"].data
release.min_rel = form["min_rel"].data.getActual()
release.max_rel = form["max_rel"].data.getActual()
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:
@@ -144,3 +176,43 @@ def edit_release_page(package, id):
return redirect(package.getDetailsURL())
return render_template("packages/release_edit.html", package=package, release=release, form=form)
class BulkReleaseForm(FlaskForm):
set_min = BooleanField("Set Min")
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)
set_max = BooleanField("Set Max")
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
only_change_none = BooleanField("Only change values previously set as none")
submit = SubmitField("Update")
@app.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"])
@login_required
@is_package_page
def bulk_change_release_page(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.getDetailsURL())
# Initial form class from post data and default data
form = BulkReleaseForm()
if request.method == "GET":
form.only_change_none.data = True
elif request.method == "POST" and form.validate():
only_change_none = form.only_change_none.data
for release in package.releases.all():
if form["set_min"].data and (not only_change_none or release.min_rel is None):
release.min_rel = form["min_rel"].data.getActual()
if form["set_max"].data and (not only_change_none or release.max_rel is None):
release.max_rel = form["max_rel"].data.getActual()
db.session.commit()
return redirect(package.getDetailsURL())
return render_template("packages/release_bulk_change.html", package=package, form=form)

View File

@@ -49,13 +49,14 @@ def create_screenshot_page(package, id=None):
# Initial form class from post data and default data
form = CreateScreenshotForm()
if request.method == "POST" and form.validate():
uploadedPath = doFileUpload(form.fileUpload.data, ["png", "jpg", "jpeg"],
uploadedPath = doFileUpload(form.fileUpload.data, "image",
"a PNG or JPG image file")
if uploadedPath is not None:
ss = PackageScreenshot()
ss.package = package
ss.title = form["title"].data or "Untitled"
ss.url = uploadedPath
ss.package = package
ss.title = form["title"].data or "Untitled"
ss.url = uploadedPath
ss.approved = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
db.session.add(ss)
msg = "{}: Screenshot added {}" \

View File

@@ -21,6 +21,8 @@ from app import app
from app.models import *
from app.utils import triggerNotif, clearNotifications
import datetime
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
@@ -78,6 +80,13 @@ def thread_page(id):
if current_user.is_authenticated and request.method == "POST":
comment = request.form["comment"]
if not current_user.canCommentRL():
flash("Please wait before commenting again", "danger")
if package:
return redirect(package.getDetailsURL())
else:
return redirect(url_for("home_page"))
if len(comment) <= 500 and len(comment) > 3:
reply = ThreadReply()
reply.author = current_user
@@ -126,15 +135,15 @@ def new_thread_page():
if package is None:
flash("Unable to find that package!", "error")
# Don't allow making threads on approved packages for now
# Don't allow making orphan threads on approved packages for now
if package is None:
abort(403)
def_is_private = request.args.get("private") or False
if not package.approved:
if package is None or not package.approved:
def_is_private = True
allow_change = package.approved
is_review_thread = package is not None and not package.approved
allow_change = package and package.approved
is_review_thread = package and not package.approved
# Check that user can make the thread
if not package.checkPerm(current_user, Permission.CREATE_THREAD):
@@ -144,8 +153,15 @@ def new_thread_page():
# 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")
if request.method == "GET":
return redirect(url_for("thread_page", id=package.review_thread.id))
return redirect(url_for("thread_page", id=package.review_thread.id))
elif not current_user.canOpenThreadRL():
flash("Please wait before opening another thread", "danger")
if package:
return redirect(package.getDetailsURL())
else:
return redirect(url_for("home_page"))
# Set default values
elif request.method == "GET":
@@ -178,13 +194,19 @@ def new_thread_page():
if is_review_thread:
package.review_thread = thread
notif_msg = None
if package is not None:
triggerNotif(package.author, current_user,
"New thread '{}' on package {}".format(thread.title, package.title), url_for("thread_page", id=thread.id))
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))
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))
db.session.commit()
return redirect(url_for("thread_page", id=thread.id))
return render_template("threads/new.html", form=form, allow_private_change=allow_change)
return render_template("threads/new.html", form=form, allow_private_change=allow_change, package=package)

View File

@@ -0,0 +1,18 @@
# 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 . import users, githublogin, notifications

View File

@@ -18,25 +18,27 @@
from flask import *
from flask_user import *
from flask_login import login_user, logout_user
from app import app
from app import app, markdown
from app.models import *
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from app.utils import randomString, loginUser
from app.utils import randomString, loginUser, rank_required
from app.tasks.forumtasks import checkForumAccount
from app.tasks.emails import sendVerifyEmail
from app.tasks.emails import sendVerifyEmail, sendEmailRaw
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()])
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"])
@login_required
def user_list_page():
users = User.query.order_by(db.desc(User.rank), db.asc(User.display_name)).all()
return render_template("users/list.html", users=users)
@@ -60,6 +62,8 @@ def user_profile_page(username):
# Copy form fields to user_profile fields
if user.checkPerm(current_user, Permission.CHANGE_DNAME):
user.display_name = form["display_name"].data
user.website_url = form["website_url"].data
user.donate_url = form["donate_url"].data
if user.checkPerm(current_user, Permission.CHANGE_RANK):
newRank = form["rank"].data
@@ -74,7 +78,7 @@ def user_profile_page(username):
token = randomString(32)
ver = UserEmailVerification()
ver.user = user
ver.user = user
ver.token = token
ver.email = newEmail
db.session.add(ver)
@@ -126,10 +130,40 @@ def user_check(username):
return redirect(url_for("check_task", id=task.id, r=next_url))
class SendEmailForm(FlaskForm):
subject = StringField("Subject", [InputRequired(), Length(1, 300)])
text = TextAreaField("Message", [InputRequired()])
submit = SubmitField("Send")
@app.route("/users/<username>/email/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def send_email_page(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)
if user.email is None:
flash("User has no email address!", "error")
return redirect(next_url)
form = SendEmailForm(request.form)
if form.validate_on_submit():
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 render_template("users/send_email.html", form=form)
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"])

3
babel.cfg Normal file
View File

@@ -0,0 +1,3 @@
[python: app/**.py]
[jinja2: app/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_

View File

@@ -23,3 +23,7 @@ MAIL_SERVER=""
MAIL_PORT=587
MAIL_USE_TLS=True
MAIL_UTILS_ERROR_SEND_TO=[""]
LANGUAGES = {
'en': 'English',
}

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
version: '3'
services:
db:
image: "postgres:9.6.5"
volumes:
- "./data/db:/var/lib/postgresql/data"
env_file:
- config.env
redis:
image: 'redis:3.0-alpine'
command: redis-server
volumes:
- './data/redis:/data'
app:
build: .
command: ./rundebug.sh
ports:
- 5123:5123
volumes:
- "./data/uploads:/home/cdb/app/public/uploads"
- "./app:/home/cdb/app"
- "./migrations:/home/cdb/migrations"
depends_on:
- db
- redis
worker:
build: .
command: celery -A app.tasks.celery worker
env_file:
- config.env
volumes:
- "./data/uploads:/home/cdb/app/public/uploads"
- "./app:/home/cdb/app"
depends_on:
- redis

View File

@@ -0,0 +1,36 @@
"""empty message
Revision ID: 2f3c3597c78d
Revises: 9ec17b558413
Create Date: 2019-01-29 02:43:08.865695
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy_utils.types import TSVectorType
from sqlalchemy_searchable import sync_trigger
# revision identifiers, used by Alembic.
revision = '2f3c3597c78d'
down_revision = '9ec17b558413'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('package', 'shortDesc', nullable=False, new_column_name='short_desc')
op.add_column('package', sa.Column('search_vector', TSVectorType("title", "short_desc", "desc"), nullable=True))
op.create_index('ix_package_search_vector', 'package', ['search_vector'], unique=False, postgresql_using='gin')
conn = op.get_bind()
sync_trigger(conn, 'package', 'search_vector', ["title", "short_desc", "desc"])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_package_search_vector', table_name='package')
op.drop_column('package', 'search_vector')
# ### end Alembic commands ###

View File

@@ -0,0 +1,41 @@
"""empty message
Revision ID: 7def3e843d04
Revises: dce69ad1e4eb
Create Date: 2019-01-28 20:27:33.760232
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '7def3e843d04'
down_revision = 'dce69ad1e4eb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('minetest_release',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.add_column('package_release', sa.Column('max_rel_id', sa.Integer(), nullable=True, server_default=None))
op.add_column('package_release', sa.Column('min_rel_id', sa.Integer(), nullable=True, server_default=None))
op.create_foreign_key(None, 'package_release', 'minetest_release', ['max_rel_id'], ['id'])
op.create_foreign_key(None, 'package_release', 'minetest_release', ['min_rel_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'package_release', type_='foreignkey')
op.drop_constraint(None, 'package_release', type_='foreignkey')
op.drop_column('package_release', 'min_rel_id')
op.drop_column('package_release', 'max_rel_id')
op.drop_table('minetest_release')
# ### end Alembic commands ###

View File

@@ -0,0 +1,249 @@
"""empty message
Revision ID: 7ff57806ffd5
Revises: 2f3c3597c78d
Create Date: 2019-01-29 02:57:50.279918
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '7ff57806ffd5'
down_revision = '2f3c3597c78d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.execute("""
DROP TYPE IF EXISTS tsq_state CASCADE;
CREATE TYPE tsq_state AS (
search_query text,
parentheses_stack int,
skip_for int,
current_token text,
current_index int,
current_char text,
previous_char text,
tokens text[]
);
CREATE OR REPLACE FUNCTION tsq_append_current_token(state tsq_state)
RETURNS tsq_state AS $$
BEGIN
IF state.current_token != '' THEN
state.tokens := array_append(state.tokens, state.current_token);
state.current_token := '';
END IF;
RETURN state;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE OR REPLACE FUNCTION tsq_tokenize_character(state tsq_state)
RETURNS tsq_state AS $$
BEGIN
IF state.current_char = '(' THEN
state.tokens := array_append(state.tokens, '(');
state.parentheses_stack := state.parentheses_stack + 1;
state := tsq_append_current_token(state);
ELSIF state.current_char = ')' THEN
IF (state.parentheses_stack > 0 AND state.current_token != '') THEN
state := tsq_append_current_token(state);
state.tokens := array_append(state.tokens, ')');
state.parentheses_stack := state.parentheses_stack - 1;
END IF;
ELSIF state.current_char = '"' THEN
state.skip_for := position('"' IN substring(
state.search_query FROM state.current_index + 1
));
IF state.skip_for > 1 THEN
state.tokens = array_append(
state.tokens,
substring(
state.search_query
FROM state.current_index FOR state.skip_for + 1
)
);
ELSIF state.skip_for = 0 THEN
state.current_token := state.current_token || state.current_char;
END IF;
ELSIF (
state.current_char = '-' AND
(state.current_index = 1 OR state.previous_char = ' ')
) THEN
state.tokens := array_append(state.tokens, '-');
ELSIF state.current_char = ' ' THEN
state := tsq_append_current_token(state);
IF substring(
state.search_query FROM state.current_index FOR 4
) = ' or ' THEN
state.skip_for := 2;
-- remove duplicate OR tokens
IF state.tokens[array_length(state.tokens, 1)] != ' | ' THEN
state.tokens := array_append(state.tokens, ' | ');
END IF;
END IF;
ELSE
state.current_token = state.current_token || state.current_char;
END IF;
RETURN state;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE OR REPLACE FUNCTION tsq_tokenize(search_query text) RETURNS text[] AS $$
DECLARE
state tsq_state;
BEGIN
SELECT
search_query::text AS search_query,
0::int AS parentheses_stack,
0 AS skip_for,
''::text AS current_token,
0 AS current_index,
''::text AS current_char,
''::text AS previous_char,
'{}'::text[] AS tokens
INTO state;
state.search_query := lower(trim(
regexp_replace(search_query, '""+', '""', 'g')
));
FOR state.current_index IN (
SELECT generate_series(1, length(state.search_query))
) LOOP
state.current_char := substring(
search_query FROM state.current_index FOR 1
);
IF state.skip_for > 0 THEN
state.skip_for := state.skip_for - 1;
CONTINUE;
END IF;
state := tsq_tokenize_character(state);
state.previous_char := state.current_char;
END LOOP;
state := tsq_append_current_token(state);
state.tokens := array_nremove(state.tokens, '(', -state.parentheses_stack);
RETURN state.tokens;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Processes an array of text search tokens and returns a tsquery
CREATE OR REPLACE FUNCTION tsq_process_tokens(config regconfig, tokens text[])
RETURNS tsquery AS $$
DECLARE
result_query text;
previous_value text;
value text;
BEGIN
result_query := '';
FOREACH value IN ARRAY tokens LOOP
IF value = '"' THEN
CONTINUE;
END IF;
IF left(value, 1) = '"' AND right(value, 1) = '"' THEN
value := phraseto_tsquery(config, value);
ELSIF value NOT IN ('(', ' | ', ')', '-') THEN
value := quote_literal(value) || ':*';
END IF;
IF previous_value = '-' THEN
IF value = '(' THEN
value := '!' || value;
ELSE
value := '!(' || value || ')';
END IF;
END IF;
SELECT
CASE
WHEN result_query = '' THEN value
WHEN (
previous_value IN ('!(', '(', ' | ') OR
value IN (')', ' | ')
) THEN result_query || value
ELSE result_query || ' & ' || value
END
INTO result_query;
previous_value := value;
END LOOP;
RETURN to_tsquery(config, result_query);
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE OR REPLACE FUNCTION tsq_process_tokens(tokens text[])
RETURNS tsquery AS $$
SELECT tsq_process_tokens(get_current_ts_config(), tokens);
$$ LANGUAGE SQL IMMUTABLE;
CREATE OR REPLACE FUNCTION tsq_parse(config regconfig, search_query text)
RETURNS tsquery AS $$
SELECT tsq_process_tokens(config, tsq_tokenize(search_query));
$$ LANGUAGE SQL IMMUTABLE;
CREATE OR REPLACE FUNCTION tsq_parse(config text, search_query text)
RETURNS tsquery AS $$
SELECT tsq_parse(config::regconfig, search_query);
$$ LANGUAGE SQL IMMUTABLE;
CREATE OR REPLACE FUNCTION tsq_parse(search_query text) RETURNS tsquery AS $$
SELECT tsq_parse(get_current_ts_config(), search_query);
$$ LANGUAGE SQL IMMUTABLE;
-- remove first N elements equal to the given value from the array (array
-- must be one-dimensional)
--
-- If negative value is given as the third argument the removal of elements
-- starts from the last array element.
CREATE OR REPLACE FUNCTION array_nremove(anyarray, anyelement, int)
RETURNS ANYARRAY AS $$
WITH replaced_positions AS (
SELECT UNNEST(
CASE
WHEN $2 IS NULL THEN
'{}'::int[]
WHEN $3 > 0 THEN
(array_positions($1, $2))[1:$3]
WHEN $3 < 0 THEN
(array_positions($1, $2))[
(cardinality(array_positions($1, $2)) + $3 + 1):
]
ELSE
'{}'::int[]
END
) AS position
)
SELECT COALESCE((
SELECT array_agg(value)
FROM unnest($1) WITH ORDINALITY AS t(value, index)
WHERE index NOT IN (SELECT position FROM replaced_positions)
), $1[1:0]);
$$ LANGUAGE SQL IMMUTABLE;
""")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -0,0 +1,28 @@
"""empty message
Revision ID: 97a9c461bc2d
Revises: 7def3e843d04
Create Date: 2019-01-28 20:49:41.831991
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '97a9c461bc2d'
down_revision = '7def3e843d04'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('minetest_release', sa.Column('protocol', sa.Integer(), nullable=False, server_default="0"))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('minetest_release', 'protocol')
# ### end Alembic commands ###

View File

@@ -0,0 +1,28 @@
"""empty message
Revision ID: 9ec17b558413
Revises: 97a9c461bc2d
Create Date: 2019-01-29 00:37:49.507631
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '9ec17b558413'
down_revision = '97a9c461bc2d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('package_release', sa.Column('downloads', sa.Integer(), nullable=False, server_default="0"))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('package_release', 'downloads')
# ### end Alembic commands ###

View File

@@ -0,0 +1,30 @@
"""empty message
Revision ID: d6ae9682c45f
Revises: 7ff57806ffd5
Create Date: 2019-07-01 23:27:42.666877
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'd6ae9682c45f'
down_revision = '7ff57806ffd5'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('donate_url', sa.String(length=255), nullable=True))
op.add_column('user', sa.Column('website_url', sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'website_url')
op.drop_column('user', 'donate_url')
# ### end Alembic commands ###

View File

@@ -7,12 +7,15 @@ Flask-Menu~=0.7
Flask-Migrate~=2.3
Flask-SQLAlchemy~=2.3
Flask-User~=0.6
Flask-Babel
GitHub-Flask~=3.2
SQLAlchemy-Searchable==1.0.3
beautifulsoup4~=4.6
celery~=4.2
celery==4.1.1
kombu==4.2.0
GitPython~=2.1
lxml~=4.2
pillow~=5.3
pyScss~=1.3
redis~=3.0
redis==2.10.6

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
#!/bin/bash
gunicorn -w 4 -b 127.0.0.1:5123 -e FLASK_APP=app/__init__.py -e FLASK_CONFIG=../config.prod.cfg -e FLASK_DEBUG=0 app:app
gunicorn -w 4 -b :5123 -e FLASK_APP=app/__init__.py -e FLASK_CONFIG=../config.cfg -e FLASK_DEBUG=0 app:app

View File

@@ -20,7 +20,9 @@ import os, sys, datetime
if not "FLASK_CONFIG" in os.environ:
os.environ["FLASK_CONFIG"] = "../config.cfg"
test_data = len(sys.argv) >= 2 and sys.argv[1].strip() == "-t"
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
from app.models import *
from app.utils import make_flask_user_password
@@ -53,7 +55,7 @@ def defineDummyData(licenses, tags, ruben):
mod.repo = "https://github.com/ezhh/other_worlds"
mod.issueTracker = "https://github.com/ezhh/other_worlds/issues"
mod.forums = 16015
mod.shortDesc = "The content library should not be used yet as it is still in alpha"
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)
@@ -75,7 +77,7 @@ def defineDummyData(licenses, tags, ruben):
mod1.repo = "https://github.com/rubenwardy/awards"
mod1.issueTracker = "https://github.com/rubenwardy/awards/issues"
mod1.forums = 4870
mod1.shortDesc = "Adds achievements and an API to register new ones."
mod1.short_desc = "Adds achievements and an API to register new ones."
mod1.desc = """
Majority of awards are back ported from Calinou's old fork in Carbone, under same license.
@@ -110,7 +112,7 @@ awards.register_achievement("award_mesefind",{
mod2.repo = "https://github.com/minetest-mods/mesecons/"
mod2.issueTracker = "https://github.com/minetest-mods/mesecons/issues"
mod2.forums = 628
mod2.shortDesc = "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.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 = """
########################################################################
## __ __ _____ _____ _____ _____ _____ _ _ _____ ##
@@ -208,7 +210,7 @@ No warranty is provided, express or implied, for any part of the project.
mod.repo = "https://github.com/ezhh/handholds"
mod.issueTracker = "https://github.com/ezhh/handholds/issues"
mod.forums = 17069
mod.shortDesc = "Adds hand holds and climbing thingies"
mod.short_desc = "Adds hand holds and climbing thingies"
mod.desc = "This is the long desc"
db.session.add(mod)
@@ -231,7 +233,7 @@ No warranty is provided, express or implied, for any part of the project.
mod.repo = "https://github.com/ezhh/other_worlds"
mod.issueTracker = "https://github.com/ezhh/other_worlds/issues"
mod.forums = 16015
mod.shortDesc = "Adds space with asteroids and comets"
mod.short_desc = "Adds space with asteroids and comets"
mod.desc = "This is the long desc"
db.session.add(mod)
@@ -246,7 +248,7 @@ No warranty is provided, express or implied, for any part of the project.
mod.repo = "https://github.com/rubenwardy/food/"
mod.issueTracker = "https://github.com/rubenwardy/food/issues/"
mod.forums = 2960
mod.shortDesc = "Adds lots of food and an API to manage ingredients"
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)
@@ -262,7 +264,7 @@ No warranty is provided, express or implied, for any part of the project.
mod.repo = "https://github.com/rubenwardy/food_sweet/"
mod.issueTracker = "https://github.com/rubenwardy/food_sweet/issues/"
mod.forums = 9039
mod.shortDesc = "Adds sweet food"
mod.short_desc = "Adds sweet food"
mod.desc = "This is the long desc"
food_sweet = mod
db.session.add(mod)
@@ -280,7 +282,7 @@ No warranty is provided, express or implied, for any part of the project.
game1.repo = "https://github.com/rubenwardy/capturetheflag"
game1.issueTracker = "https://github.com/rubenwardy/capturetheflag/issues"
game1.forums = 12835
game1.shortDesc = "Two teams battle to snatch and return the enemy's flag, before the enemy takes their own!"
game1.short_desc = "Two teams battle to snatch and return the enemy's flag, before the enemy takes their own!"
game1.desc = """
As seen on the Capture the Flag server (minetest.rubenwardy.com:30000)
@@ -305,7 +307,7 @@ Uses the CTF PvP Engine.
mod.type = PackageType.TXP
mod.author = ruben
mod.forums = 14132
mod.shortDesc = "This is an update of the original PixelBOX texture pack by the brillant artist Gambit"
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)
@@ -333,13 +335,14 @@ Uses the CTF PvP Engine.
db.session.add(dep)
delete_db = len(sys.argv) >= 2 and sys.argv[1].strip() == "-d"
if delete_db and os.path.isfile("db.sqlite"):
os.remove("db.sqlite")
print("Creating database tables...")
db.create_all()
if create_db:
print("Creating database tables...")
db.create_all()
print("Filling database...")
ruben = User("rubenwardy")