Compare commits

..

37 Commits

Author SHA1 Message Date
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
56 changed files with 941 additions and 242 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
config.cfg config.cfg
*.env *.env
*.sqlite *.sqlite
.vscode
custom.css custom.css
tmp tmp
log.txt log.txt

View File

@@ -24,6 +24,7 @@ from flaskext.markdown import Markdown
from flask_github import GitHub from flask_github import GitHub
from flask_wtf.csrf import CsrfProtect from flask_wtf.csrf import CsrfProtect
from flask_flatpages import FlatPages from flask_flatpages import FlatPages
from flask_babel import Babel
import os import os
app = Flask(__name__, static_folder="public/static") app = Flask(__name__, static_folder="public/static")
@@ -37,6 +38,7 @@ github = GitHub(app)
csrf = CsrfProtect(app) csrf = CsrfProtect(app)
mail = Mail(app) mail = Mail(app)
pages = FlatPages(app) pages = FlatPages(app)
babel = Babel(app)
gravatar = Gravatar(app, gravatar = Gravatar(app,
size=58, size=58,
rating='g', rating='g',
@@ -50,5 +52,11 @@ if not app.debug:
from .maillogger import register_mail_error_handler from .maillogger import register_mail_error_handler
register_mail_error_handler(app, mail) 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 . import models, tasks
from .views import * from .views import *

View File

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

@@ -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 community. To help with this, there are a few rules to improve the quality of
the listings and to combat abuse. the listings and to combat abuse.
* No inappropriate content. * No inappropriate content. <sup>2.1</sup>
* Content must be playable/useful, but not necessarily finished. * 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. * 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. * Licenses must allow derivatives, redistribution, and must not discriminate. <sup>4</sup>
* Don't put promotions are advertisements in package listings, except for * Don't put promotions or advertisements in package listings, except for
donation and personal website links which are permitted in the long description. 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 ## 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, 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. 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/). 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 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 does not do as it advertises, for example if it posts telemetry without stating
clearly that it does in the package meta. clearly that it does in the package meta.
### 2.2. State of Completion
ContentDB should only currently contain playable content - content which is 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 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 - 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 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 you started working on yesterday, it's worth adding all the basic stuff to
make your package useful. 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) ## 5. Promotions and Advertisements (inc. asking for donations)
Any information other than the long description - including screenshots - must You may note place any promotions or advertisements in any meta data including
not contain any promotions or advertisements. This includes asking for donations, screensthos. This includes asking for donations, promoting online shops,
promoting online shops, or linking to personal websites and social media. 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 ContentDB is for the community. We may remove any promotions if we feel that
they're inappropriate. they're inappropriate.
Paid promotions are not allowed at all, anywhere.
## 6. Reporting Violations ## 6. Reporting Violations

View File

@@ -15,18 +15,29 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Flask, url_for
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from urllib.parse import urlparse
from app import app, gravatar
from sqlalchemy.orm import validates
from flask_user import login_required, UserManager, UserMixin, SQLAlchemyAdapter
import enum, datetime import enum, datetime
from app import app, gravatar
from urllib.parse import urlparse
from flask import Flask, url_for
from flask_sqlalchemy import SQLAlchemy, BaseQuery
from flask_migrate import Migrate
from flask_user import login_required, UserManager, UserMixin, SQLAlchemyAdapter
from sqlalchemy.orm import validates
from sqlalchemy_searchable import SearchQueryMixin
from sqlalchemy_utils.types import TSVectorType
from sqlalchemy_searchable import make_searchable
# Initialise database # Initialise database
db = SQLAlchemy(app) db = SQLAlchemy(app)
migrate = Migrate(app, db) migrate = Migrate(app, db)
make_searchable(db.metadata)
class ArticleQuery(BaseQuery, SearchQueryMixin):
pass
class UserRank(enum.Enum): class UserRank(enum.Enum):
@@ -65,6 +76,7 @@ class Permission(enum.Enum):
APPROVE_CHANGES = "APPROVE_CHANGES" APPROVE_CHANGES = "APPROVE_CHANGES"
DELETE_PACKAGE = "DELETE_PACKAGE" DELETE_PACKAGE = "DELETE_PACKAGE"
CHANGE_AUTHOR = "CHANGE_AUTHOR" CHANGE_AUTHOR = "CHANGE_AUTHOR"
CHANGE_NAME = "CHANGE_NAME"
MAKE_RELEASE = "MAKE_RELEASE" MAKE_RELEASE = "MAKE_RELEASE"
ADD_SCREENSHOTS = "ADD_SCREENSHOTS" ADD_SCREENSHOTS = "ADD_SCREENSHOTS"
APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT" APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
@@ -118,6 +130,10 @@ class User(db.Model, UserMixin):
active = db.Column("is_active", db.Boolean, nullable=False, server_default="0") active = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
display_name = db.Column(db.String(100), nullable=False, server_default="") 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 # Content
notifications = db.relationship("Notification", primaryjoin="User.id==Notification.user_id") notifications = db.relationship("Notification", primaryjoin="User.id==Notification.user_id")
@@ -246,7 +262,7 @@ class PackageType(enum.Enum):
class PackagePropertyKey(enum.Enum): class PackagePropertyKey(enum.Enum):
name = "Name" name = "Name"
title = "Title" title = "Title"
shortDesc = "Short Description" short_desc = "Short Description"
desc = "Description" desc = "Description"
type = "Type" type = "Type"
license = "License" license = "License"
@@ -343,19 +359,22 @@ class Dependency(db.Model):
return retval return retval
class Package(db.Model): class Package(db.Model):
query_class = ArticleQuery
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
# Basic details # Basic details
author_id = db.Column(db.Integer, db.ForeignKey("user.id")) author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
name = db.Column(db.String(100), nullable=False) name = db.Column(db.String(100), nullable=False)
title = db.Column(db.String(100), nullable=False) title = db.Column(db.Unicode(100), nullable=False)
shortDesc = db.Column(db.String(200), nullable=False) short_desc = db.Column(db.Unicode(200), nullable=False)
desc = db.Column(db.Text, nullable=True) desc = db.Column(db.UnicodeText, nullable=True)
type = db.Column(db.Enum(PackageType)) type = db.Column(db.Enum(PackageType))
created_at = db.Column(db.DateTime, nullable=False, default=datetime.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_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
license = db.relationship("License", foreign_keys=[license_id]) license = db.relationship("License", foreign_keys=[license_id])
media_license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1) media_license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
@@ -403,26 +422,44 @@ class Package(db.Model):
for e in PackagePropertyKey: for e in PackagePropertyKey:
setattr(self, e.name, getattr(package, e.name)) 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 getAsDictionaryShort(self, base_url, version=None, protonum=None):
tnurl = self.getThumbnailURL(1) tnurl = self.getThumbnailURL(1)
release = self.getDownloadRelease(version=version, protonum=protonum)
return { return {
"name": self.name, "name": self.name,
"title": self.title, "title": self.title,
"author": self.author.display_name, "author": self.author.display_name,
"short_description": self.shortDesc, "short_description": self.short_desc,
"type": self.type.toName(), "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, "thumbnail": (base_url + tnurl) if tnurl is not None else None,
"score": round(self.score * 10) / 10 "score": round(self.score * 10) / 10
} }
def getAsDictionary(self, base_url): def getAsDictionary(self, base_url, version=None, protonum=None):
tnurl = self.getThumbnailURL(1) tnurl = self.getThumbnailURL(1)
release = self.getDownloadRelease(version=version, protonum=protonum)
return { return {
"author": self.author.display_name, "author": self.author.display_name,
"name": self.name, "name": self.name,
"title": self.title, "title": self.title,
"short_description": self.shortDesc, "short_description": self.short_desc,
"desc": self.desc, "desc": self.desc,
"type": self.type.toName(), "type": self.type.toName(),
"created_at": self.created_at, "created_at": self.created_at,
@@ -440,7 +477,7 @@ class Package(db.Model):
"screenshots": [base_url + ss.url for ss in self.screenshots], "screenshots": [base_url + ss.url for ss in self.screenshots],
"url": base_url + self.getDownloadURL(), "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 "score": round(self.score * 10) / 10
} }
@@ -489,13 +526,29 @@ class Package(db.Model):
return url_for("package_download_page", return url_for("package_download_page",
author=self.author.username, name=self.name) 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: 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 rel
return None return None
def getDownloadCount(self):
counter = 0
for release in self.releases:
counter += release.downloads
return counter
def checkPerm(self, user, perm): def checkPerm(self, user, perm):
if not user.is_authenticated: if not user.is_authenticated:
return False return False
@@ -520,6 +573,10 @@ class Package(db.Model):
else: else:
return user.rank.atLeast(UserRank.EDITOR) 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 # Editors can change authors and approve new packages
elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR: elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR:
return user.rank.atLeast(UserRank.EDITOR) return user.rank.atLeast(UserRank.EDITOR)
@@ -639,6 +696,7 @@ class PackageRelease(db.Model):
approved = db.Column(db.Boolean, nullable=False, default=False) approved = db.Column(db.Boolean, nullable=False, default=False)
task_id = db.Column(db.String(37), nullable=True) task_id = db.Column(db.String(37), nullable=True)
commit_hash = db.Column(db.String(41), nullable=True, default=None) commit_hash = db.Column(db.String(41), nullable=True, default=None)
downloads = db.Column(db.Integer, nullable=False, default=0)
min_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None) 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]) min_rel = db.relationship("MinetestRelease", foreign_keys=[min_rel_id])
@@ -663,6 +721,16 @@ class PackageRelease(db.Model):
def __init__(self): def __init__(self):
self.releaseDate = datetime.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): class PackageReview(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

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("#repo", result.repo || repoURL);
setField("#issueTracker", result.issueTracker); setField("#issueTracker", result.issueTracker);
setField("#desc", result.description); setField("#desc", result.description);
setField("#shortDesc", result.short_description); setField("#short_desc", result.short_description);
setField("#harddep_str", result.depends); setField("#harddep_str", result.depends);
setField("#softdep_str", result.optional_depends); setField("#softdep_str", result.optional_depends);
setField("#shortDesc", result.short_description); setField("#short_desc", result.short_description);
setField("#forums", result.forumId); setField("#forums", result.forumId);
if (result.type && result.type.length > 2) { if (result.type && result.type.length > 2) {
$("#type").val(result.type); $("#type").val(result.type);

View File

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

@@ -1,5 +1,5 @@
from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease
from .utils import isNo from .utils import isNo, isYes
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
from flask import abort from flask import abort
from sqlalchemy import or_ from sqlalchemy import or_
@@ -19,20 +19,42 @@ class QueryBuilder:
if len(types) > 0: if len(types) > 0:
title = ", ".join([type.value + "s" for type in types]) title = ", ".join([type.value + "s" for type in types])
hide_flags = args.getlist("hide")
self.title = title self.title = title
self.types = types self.types = types
self.search = args.get("q") self.search = args.get("q")
self.random = "random" in args self.random = "random" in args
self.lucky = self.random or "lucky" in args self.lucky = self.random or "lucky" in args
self.hide_nonfree = isNo(args.get("nonfree")) self.hide_nonfree = "nonfree" in hide_flags
self.limit = 1 if self.lucky else None self.limit = 1 if self.lucky else None
self.order_by = args.get("sort") or "score" self.order_by = args.get("sort")
self.order_dir = args.get("order") or "desc" self.order_dir = args.get("order") or "desc"
self.protocol_version = args.get("protocol_version") 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() == "": if self.search is not None and self.search.strip() == "":
self.search = None 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): def buildPackageQuery(self):
query = Package.query.filter_by(soft_deleted=False, approved=True) query = Package.query.filter_by(soft_deleted=False, approved=True)
@@ -40,13 +62,13 @@ class QueryBuilder:
query = query.filter(Package.type.in_(self.types)) query = query.filter(Package.type.in_(self.types))
if self.search: if self.search:
query = query.filter(Package.title.ilike('%' + self.search + '%')) query = query.search(self.search)
if self.random: if self.random:
query = query.order_by(func.random()) query = query.order_by(func.random())
else: else:
to_order = None to_order = None
if self.order_by == "score": if self.order_by is None or self.order_by == "score":
to_order = Package.score to_order = Package.score
elif self.order_by == "created_at": elif self.order_by == "created_at":
to_order = Package.created_at to_order = Package.created_at
@@ -67,14 +89,9 @@ class QueryBuilder:
query = query.filter(Package.media_license.has(License.is_foss == True)) query = query.filter(Package.media_license.has(License.is_foss == True))
if self.protocol_version: if self.protocol_version:
self.protocol_version = int(self.protocol_version) version = self.getMinetestVersion()
version = MinetestRelease.query.filter(MinetestRelease.protocol>=self.protocol_version).first()
if version is not None:
version = version.id
else:
version = 10000000
query = query.join(Package.releases) \ query = query.join(Package.releases) \
.filter(PackageRelease.approved==True) \
.filter(or_(PackageRelease.min_rel_id==None, PackageRelease.min_rel_id<=version)) \ .filter(or_(PackageRelease.min_rel_id==None, PackageRelease.min_rel_id<=version)) \
.filter(or_(PackageRelease.max_rel_id==None, PackageRelease.max_rel_id>=version)) .filter(or_(PackageRelease.max_rel_id==None, PackageRelease.max_rel_id>=version))
@@ -83,18 +100,31 @@ class QueryBuilder:
return query return query
def buildTopicQuery(self): def buildTopicQuery(self, show_added=False):
topics = ForumTopic.query \ query = 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)) 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: if self.search:
topics = topics.filter(ForumTopic.title.ilike('%' + self.search + '%')) query = query.filter(ForumTopic.title.ilike('%' + self.search + '%'))
if len(self.types) > 0: if len(self.types) > 0:
topics = topics.filter(ForumTopic.type.in_(self.types)) query = query.filter(ForumTopic.type.in_(self.types))
if self.limit: if self.limit:
topics = topics.limit(self.limit) query = query.limit(self.limit)
return topics return query

View File

@@ -99,11 +99,19 @@ def parseTitle(title):
def getLinksFromModSearch(): def getLinksFromModSearch():
links = {} links = {}
contents = urllib.request.urlopen("https://krock-works.uk.to/minetest/modList.php").read().decode("utf-8") try:
for x in json.loads(contents): contents = urllib.request.urlopen("https://krock-works.uk.to/minetest/modList.php").read().decode("utf-8")
link = x.get("link") for x in json.loads(contents):
if link is not None: try:
links[int(x["topicId"])] = link 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 return links

View File

@@ -29,6 +29,10 @@ from app.utils import randomString
class GithubURLMaker: class GithubURLMaker:
def __init__(self, url): def __init__(self, url):
self.baseUrl = None
self.user = None
self.repo = None
# Rewrite path # Rewrite path
import re import re
m = re.search("^\/([^\/]+)\/([^\/]+)\/?$", url.path) m = re.search("^\/([^\/]+)\/([^\/]+)\/?$", url.path)
@@ -51,6 +55,9 @@ class GithubURLMaker:
def getScreenshotURL(self): def getScreenshotURL(self):
return self.baseUrl + "/screenshot.png" return self.baseUrl + "/screenshot.png"
def getModConfURL(self):
return self.baseUrl + "/mod.conf"
def getCommitsURL(self, branch): def getCommitsURL(self, branch):
return "https://api.github.com/repos/{}/{}/commits?sha={}" \ return "https://api.github.com/repos/{}/{}/commits?sha={}" \
.format(self.user, self.repo, urllib.parse.quote_plus(branch)) .format(self.user, self.repo, urllib.parse.quote_plus(branch))
@@ -339,8 +346,11 @@ def makeVCSReleaseFromGithub(id, branch, release, url):
raise TaskError("Invalid github repo URL") raise TaskError("Invalid github repo URL")
commitsURL = urlmaker.getCommitsURL(branch) commitsURL = urlmaker.getCommitsURL(branch)
contents = urllib.request.urlopen(commitsURL).read().decode("utf-8") try:
commits = json.loads(contents) 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]: if len(commits) == 0 or not "sha" in commits[0]:
raise TaskError("No commits found") raise TaskError("No commits found")
@@ -348,7 +358,7 @@ def makeVCSReleaseFromGithub(id, branch, release, url):
release.url = urlmaker.getCommitDownload(commits[0]["sha"]) release.url = urlmaker.getCommitDownload(commits[0]["sha"])
release.task_id = None release.task_id = None
release.commit_hash = commits[0]["sha"] release.commit_hash = commits[0]["sha"]
print(release.url) release.approve(release.package.author)
db.session.commit() db.session.commit()
return release.url return release.url
@@ -379,6 +389,7 @@ def makeVCSRelease(id, branch):
release.url = "/uploads/" + filename release.url = "/uploads/" + filename
release.task_id = None release.task_id = None
release.commit_hash = repo.head.object.hexsha release.commit_hash = repo.head.object.hexsha
release.approve(release.package.author)
print(release.url) print(release.url)
db.session.commit() db.session.commit()

View File

@@ -7,7 +7,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}title{% endblock %} - {{ config.USER_APP_NAME }}</title> <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/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 %} {% block headextra %}{% endblock %}
</head> </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> <a class="nav-link" href="{{ url_for('user_profile_page', username=current_user.username) }}#unadded-topics">Your unadded topics</a>
</li> </li>
{% if current_user.canAccessTodoList() %} {% 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('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('user_list_page') }}">{{ _("User list") }}</a></li>
{% endif %} {% endif %}
<li class="nav-item"> <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> </li>
{% if current_user.rank == current_user.rank.ADMIN %} {% 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 %} {% endif %}
{% if current_user.rank == current_user.rank.MODERATOR %} {% 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('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('license_list_page') }}">{{ _("License Editor") }}</a></li>
{% endif %} {% 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> </ul>
</li> </li>
{% else %} {% 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 %} {% endif %}
</ul> </ul>
</div> </div>
@@ -125,10 +129,12 @@
{% endblock %} {% endblock %}
<footer class="container footer-copyright my-5 page-footer font-small text-center"> <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="https://github.com/minetest/contentdb">GitHub</a> |
<a href="{{ url_for('flatpage', path='help') }}">Help</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='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> </footer>
<script src="/static/jquery.min.js"></script> <script src="/static/jquery.min.js"></script>

View File

@@ -1,7 +1,22 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% 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 %} {% endblock %}
{% block content %} {% block content %}
@@ -23,35 +38,35 @@ Welcome
<a href="{{ url_for('packages_page', sort='created_at', order='desc') }}" class="btn btn-secondary float-right"> <a href="{{ url_for('packages_page', sort='created_at', order='desc') }}" class="btn btn-secondary float-right">
See more {{ _("See more") }}
</a> </a>
<h2 class="my-3">Recently Added</h2> <h2 class="my-3">{{ _("Recently Added") }}</h2>
{{ render_pkggrid(new) }} {{ render_pkggrid(new) }}
<a href="{{ url_for('packages_page', type='mod', sort='score', order='desc') }}" class="btn btn-secondary float-right"> <a href="{{ url_for('packages_page', type='mod', sort='score', order='desc') }}" class="btn btn-secondary float-right">
See more {{ _("See more") }}
</a> </a>
<h2 class="my-3">Top Mods</h2> <h2 class="my-3">{{ _("Top Mods") }}</h2>
{{ render_pkggrid(pop_mod) }} {{ render_pkggrid(pop_mod) }}
<a href="{{ url_for('packages_page', type='game', sort='score', order='desc') }}" class="btn btn-secondary float-right"> <a href="{{ url_for('packages_page', type='game', sort='score', order='desc') }}" class="btn btn-secondary float-right">
See more {{ _("See more") }}
</a> </a>
<h2 class="my-3">Top Games</h2> <h2 class="my-3">{{ _("Top Games") }}</h2>
{{ render_pkggrid(pop_gam) }} {{ render_pkggrid(pop_gam) }}
<a href="{{ url_for('packages_page', type='txp', sort='score', order='desc') }}" class="btn btn-secondary float-right"> <a href="{{ url_for('packages_page', type='txp', sort='score', order='desc') }}" class="btn btn-secondary float-right">
See more {{ _("See more") }}
</a> </a>
<h2 class="my-3">Top Texture Packs</h2> <h2 class="my-3">{{ _("Top Texture Packs") }}</h2>
{{ render_pkggrid(pop_txp) }} {{ render_pkggrid(pop_txp) }}
<div class="text-center"> <div class="text-center">
<small> <small>
CDB has {{ count }} packages available to download. {{ _("CDB has %(count)d packages, with a total of %(downloads)d downloads.", count=count, downloads=downloads) }}
</small> </small>
</div> </div>
<!-- </main> --> <!-- </main> -->

View File

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

View File

@@ -20,19 +20,19 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h1>Create Package</h1> <h1>{{ _("Create Package") }}</h1>
<div class="alert alert-info"> <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> </div>
<noscript> <noscript>
<div class="alert alert-warning"> <div class="alert alert-warning">
Javascript is needed to improve the user interface, and is needed for features {{ _("Javascript is needed to improve the user interface, and is needed for features
such as finding metadata from git, and autocompletion.<br /> such as finding metadata from git, and autocompletion.") }}<br />
Whilst disabled Javascript may work, it is not officially supported. {{ _("Whilst disabled Javascript may work, it is not officially supported.") }}
</div> </div>
</noscript> </noscript>
@@ -42,24 +42,32 @@
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<fieldset> <fieldset>
<legend>Package</legend> <legend>{{ _("Package") }}</legend>
<div class="row"> <div class="row">
{{ render_field(form.type, class_="pkg_meta col-sm-2") }} {{ render_field(form.type, class_="pkg_meta col-sm-2") }}
{{ render_field(form.title, class_="pkg_meta col-sm-7") }} {{ 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> </div>
{{ render_field(form.shortDesc, class_="pkg_meta") }} {{ render_field(form.short_desc, class_="pkg_meta") }}
{{ render_multiselect_field(form.tags, class_="pkg_meta") }} {{ render_multiselect_field(form.tags, class_="pkg_meta") }}
<div class="pkg_meta row"> <div class="pkg_meta row">
{{ render_field(form.license, class_="not_txp col-sm-6") }} {{ render_field(form.license, class_="not_txp col-sm-6") }}
{{ render_field(form.media_license, class_="col-sm-6") }} {{ render_field(form.media_license, class_="col-sm-6") }}
</div> </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") }} {{ render_field(form.desc, class_="pkg_meta", fieldclass="form-control markdown") }}
</fieldset> </fieldset>
<fieldset class="pkg_meta"> <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_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") }} {{ render_deps_field(form.harddep_str, class_="not_txp not_game", placeholder="Comma separated list") }}
@@ -67,30 +75,29 @@
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend class="pkg_meta">Repository and Links</legend> <legend class="pkg_meta">{{ _("Repository and Links") }}</legend>
<div class="pkg_wiz_1"> <div class="pkg_wiz_1">
<p>Enter the repo URL for the package. <p>{{ _("Enter the repo URL for the package.
If the repo uses git then the metadata will be automatically imported.</p> 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> </div>
{{ render_field(form.repo, class_="pkg_repo") }} {{ render_field(form.repo, class_="pkg_repo") }}
<div class="pkg_wiz_1"> <div class="pkg_wiz_1">
<a id="pkg_wiz_1_next" class="btn btn-primary">Next (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> <a id="pkg_wiz_1_skip" class="btn btn-default">{{ _("Skip Autoimport") }}</a>
</div> </div>
<div class="pkg_wiz_2"> <div class="pkg_wiz_2">
Importing... (This may take a while) {{ _("Importing... (This may take a while)") }}
</div> </div>
{{ render_field(form.website, class_="pkg_meta") }} {{ render_field(form.website, class_="pkg_meta") }}
{{ render_field(form.issueTracker, 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> </fieldset>
<div class="pkg_meta">{{ render_submit_field(form.submit) }}</div> <div class="pkg_meta">{{ render_submit_field(form.submit) }}</div>

View File

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

View File

@@ -17,7 +17,7 @@
{{ render_field(form.vcsLabel, class_="mt-3") }} {{ render_field(form.vcsLabel, class_="mt-3") }}
{% endif %} {% endif %}
{{ render_field(form.fileUpload, fieldclass="form-control-file", class_="mt-3") }} {{ render_field(form.fileUpload, fieldclass="form-control-file", class_="mt-3", accept=".zip") }}
<div class="row"> <div class="row">
{{ render_field(form.min_rel, class_="col-sm-6") }} {{ render_field(form.min_rel, class_="col-sm-6") }}

View File

@@ -10,7 +10,7 @@
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ render_field(form.title) }} {{ 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) }} {{ render_submit_field(form.submit) }}
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -19,11 +19,12 @@
</h1> </h1>
<p class="lead"> <p class="lead">
{{ package.shortDesc }} {{ package.short_desc }}
</p> </p>
<div class="row" style="margin-top: 2rem;"> <div class="row" style="margin-top: 2rem;">
<div class="col"> <div class="col">
{{ package.getDownloadCount() }} downloads
</div> </div>
<div class="btn-group-horizontal col-md-auto"> <div class="btn-group-horizontal col-md-auto">
{% if package.repo %}<a class="btn btn-secondary" href="{{ package.repo }}">View Source</a>{% endif %} {% if package.repo %}<a class="btn btn-secondary" href="{{ package.repo }}">View Source</a>{% endif %}
@@ -111,11 +112,22 @@
{% endif %} {% endif %}
<aside class="float-right ml-4" style="width: 18rem;"> <aside class="float-right ml-4" style="width: 18rem;">
{% if package.getDownloadRelease() %} {% set release = package.getDownloadRelease() %}
<a class="btn btn-download btn-lg btn-block" {% if release %}
<a class="btn btn-download btn-lg btn-block" rel="nofollow"
href="{{ package.getDownloadURL() }}" class="btn_green"> href="{{ package.getDownloadURL() }}" class="btn_green">
Download Download
</a> </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 %} {% else %}
No download available. No download available.
{% endif %} {% endif %}
@@ -205,6 +217,13 @@
</table> </table>
</div> </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 %} {% if package.type == package.type.MOD %}
<div class="card my-4"> <div class="card my-4">
<div class="card-header">Dependencies</div> <div class="card-header">Dependencies</div>
@@ -260,10 +279,28 @@
{% if not rel.approved %}<i>{% endif %} {% if not rel.approved %}<i>{% endif %}
<a href="{{ rel.getDownloadURL() }}">{{ rel.title }}</a>{% if rel.commit_hash %} <a href="{{ rel.getDownloadURL() }}" rel="nofollow">{{ rel.title }}</a>
[{{ rel.commit_hash | truncate(5, end='') }}]{% endif %}<br>
<small>created {{ rel.releaseDate | datetime }}.</small> <span style="color:#ddd;">
{% if rel.task_id %} {% 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> <a href="{{ url_for('check_task', id=rel.task_id, r=package.getDetailsURL()) }}">Importing...</a>
{% elif not rel.approved %} {% elif not rel.approved %}
Waiting for approval. Waiting for approval.

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
{% block content %} {% block content %}
{% if not current_user.is_authenticated and user.rank == user.rank.NOT_JOINED and user.forums_username %} {% 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" <a class="float-right btn btn-default btn-sm"
href="{{ url_for('user_claim_page', username=user.forums_username) }}">Claim</a> href="{{ url_for('user_claim_page', username=user.forums_username) }}">Claim</a>
@@ -40,7 +40,7 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Accounts:</td> <td>Links:</td>
<td> <td>
{% if user.forums_username %} {% if user.forums_username %}
<a href="https://forum.minetest.net/memberlist.php?mode=viewprofile&un={{ user.forums_username }}"> <a href="https://forum.minetest.net/memberlist.php?mode=viewprofile&un={{ user.forums_username }}">
@@ -50,7 +50,7 @@
No forum account No forum account
{% endif %} {% endif %}
{% if (user.forums_username and user.github_username) or user == current_user %} {% if user.github_username or user == current_user %}
| |
{% endif %} {% endif %}
@@ -60,8 +60,16 @@
<a href="{{ url_for('github_signin_page') }}">Link Github</a> <a href="{{ url_for('github_signin_page') }}">Link Github</a>
{% endif %} {% endif %}
{% if user.website_url %}
| <a href="{{ user.website_url }}" rel="nofollow">Website</a>
{% endif %}
{% if user == current_user %} {% if user == current_user %}
&#x1f30e; <br>
<small class="text-muted">
<span style="padding-right: 5px;">&#x1f30e;</span>
Visible to everyone
</small>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -136,6 +144,8 @@
{% if user.checkPerm(current_user, "CHANGE_DNAME") %} {% if user.checkPerm(current_user, "CHANGE_DNAME") %}
{{ render_field(form.display_name, tabindex=230) }} {{ render_field(form.display_name, tabindex=230) }}
{{ render_field(form.website_url, tabindex=232) }}
{{ render_field(form.donate_url, tabindex=233) }}
{% endif %} {% endif %}
{% if user.checkPerm(current_user, "CHANGE_EMAIL") %} {% if user.checkPerm(current_user, "CHANGE_EMAIL") %}
@@ -158,6 +168,13 @@
{% from "macros/packagegridtile.html" import render_pkggrid %} {% from "macros/packagegridtile.html" import render_pkggrid %}
{{ render_pkggrid(packages, show_author=False) }} {{ 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)) %} {% if current_user == user or (current_user.is_authenticated and current_user.rank.atLeast(current_user.rank.EDITOR)) %}
<div class="card mt-3"> <div class="card mt-3">
<a name="unadded-topics"></a> <a name="unadded-topics"></a>

View File

@@ -20,7 +20,7 @@ from flask_user import *
from flask_login import login_user, logout_user from flask_login import login_user, logout_user
from app.models import * from app.models import *
from app import app from app import app
import random, string, os import random, string, os, imghdr
def getExtension(filename): def getExtension(filename):
return filename.rsplit(".", 1)[1].lower() if "." in filename else None return filename.rsplit(".", 1)[1].lower() if "." in filename else None
@@ -28,6 +28,10 @@ def getExtension(filename):
def isFilenameAllowed(filename, exts): def isFilenameAllowed(filename, exts):
return getExtension(filename) in exts return getExtension(filename) in exts
ALLOWED_IMAGES = set(["jpeg", "png"])
def isAllowedImage(data):
return imghdr.what(None, data) in ALLOWED_IMAGES
def shouldReturnJson(): def shouldReturnJson():
return "application/json" in request.accept_mimetypes and \ return "application/json" in request.accept_mimetypes and \
not "text/html" in request.accept_mimetypes not "text/html" in request.accept_mimetypes
@@ -36,16 +40,32 @@ def randomString(n):
return ''.join(random.choice(string.ascii_lowercase + \ return ''.join(random.choice(string.ascii_lowercase + \
string.ascii_uppercase + string.digits) for _ in range(n)) 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 == "": if not file or file is None or file.filename == "":
flash("No selected file", "error") flash("No selected file", "error")
return None 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) ext = getExtension(file.filename)
if ext is None or not ext in allowedExtensions: if ext is None or not ext in allowedExtensions:
flash("Please upload load " + fileTypeName, "error") flash("Please upload load " + fileTypeDesc, "danger")
return None 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 filename = randomString(10) + "." + ext
file.save(os.path.join("app/public/uploads", filename)) file.save(os.path.join("app/public/uploads", filename))
return "/uploads/" + filename return "/uploads/" + filename

View File

@@ -22,6 +22,7 @@ from app.models import *
import flask_menu as menu import flask_menu as menu
from werkzeug.contrib.cache import SimpleCache from werkzeug.contrib.cache import SimpleCache
from urllib.parse import urlparse from urllib.parse import urlparse
from sqlalchemy.sql.expression import func
cache = SimpleCache() cache = SimpleCache()
@app.template_filter() @app.template_filter()
@@ -53,7 +54,8 @@ def home_page():
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all() pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(4).all() pop_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() 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) new=new, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam)
from . import users, packages, meta, threads, api from . import users, packages, meta, threads, api

View File

@@ -20,8 +20,9 @@ from flask_user import *
import flask_menu as menu import flask_menu as menu
from app import app from app import app
from app.models import * from app.models import *
from app.querybuilder import QueryBuilder
@app.route("/todo/") @app.route("/todo/", methods=["GET", "POST"])
@login_required @login_required
def todo_page(): def todo_page():
canApproveNew = Permission.APPROVE_NEW.check(current_user) canApproveNew = Permission.APPROVE_NEW.check(current_user)
@@ -40,60 +41,61 @@ def todo_page():
if canApproveScn: if canApproveScn:
screenshots = PackageScreenshot.query.filter_by(approved=False).all() 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(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
.filter_by(discarded=False) \
.count() .count()
return render_template("todo/list.html", title="Reports and Work Queue", return render_template("todo/list.html", title="Reports and Work Queue",
packages=packages, releases=releases, screenshots=screenshots, packages=packages, releases=releases, screenshots=screenshots,
canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn, 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/") @app.route("/todo/topics/")
@login_required @login_required
def todo_topics_page(): def todo_topics_page():
query = ForumTopic.query qb = QueryBuilder(request.args)
qb.setSortIfNone("date")
show_discarded = request.args.get("show_discarded") == "True" query = qb.buildTopicQuery()
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"
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() 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) page = int(request.args.get("page") or 1)
num = int(request.args.get("n") or 100) num = int(request.args.get("n") or 100)
if num > 100 and not current_user.rank.atLeast(UserRank.EDITOR): if num > 100 and not current_user.rank.atLeast(UserRank.EDITOR):
num = 100 num = 100
query = query.paginate(page, num, True) query = query.paginate(page, num, True)
next_url = url_for("todo_topics_page", page=query.next_num, query=search, \ next_url = url_for("todo_topics_page", page=query.next_num, query=qb.search, \
show_discarded=show_discarded, n=num, sort=sort_by) \ show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
if query.has_next else None if query.has_next else None
prev_url = url_for("todo_topics_page", page=query.prev_num, query=search, \ prev_url = url_for("todo_topics_page", page=query.prev_num, query=qb.search, \
show_discarded=show_discarded, n=num, sort=sort_by) \ show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
if query.has_prev else None if query.has_prev else None
return render_template("todo/topics.html", topics=query.items, total=total, \ 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, \ 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

@@ -26,9 +26,10 @@ from app.querybuilder import QueryBuilder
def api_packages_page(): def api_packages_page():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.buildPackageQuery() query = qb.buildPackageQuery()
ver = qb.getMinetestVersion()
pkgs = [package.getAsDictionaryShort(app.config["BASE_URL"]) \ pkgs = [package.getAsDictionaryShort(app.config["BASE_URL"], version=ver) \
for package in query.all() if package.getDownloadRelease() is not None] for package in query.all()]
return jsonify(pkgs) return jsonify(pkgs)
@app.route("/api/packages/<author>/<name>/") @app.route("/api/packages/<author>/<name>/")
@@ -39,10 +40,9 @@ def api_package_page(package):
@app.route("/api/topics/") @app.route("/api/topics/")
def api_topics_page(): def api_topics_page():
query = ForumTopic.query \ qb = QueryBuilder(request.args)
.order_by(db.asc(ForumTopic.wip), db.asc(ForumTopic.name), db.asc(ForumTopic.title)) query = qb.buildTopicQuery(show_added=True)
pkgs = [t.getAsDictionary() for t in query.all()] return jsonify([t.getAsDictionary() for t in query.all()])
return jsonify(pkgs)
@app.route("/api/topic_discard/", methods=["POST"]) @app.route("/api/topic_discard/", methods=["POST"])

View File

@@ -74,9 +74,9 @@ def packages_page():
def getReleases(package): def getReleases(package):
if package.checkPerm(current_user, Permission.MAKE_RELEASE): if package.checkPerm(current_user, Permission.MAKE_RELEASE):
return package.releases return package.releases.limit(5)
else: else:
return [rel for rel in package.releases if rel.approved] return package.releases.filter_by(approved=True).limit(5)
@app.route("/packages/<author>/<name>/") @app.route("/packages/<author>/<name>/")
@@ -160,13 +160,18 @@ def package_download_page(package):
flash("No download available.", "error") flash("No download available.", "error")
return redirect(package.getDetailsURL()) return redirect(package.getDetailsURL())
else: else:
PackageRelease.query.filter_by(id=release.id).update({
"downloads": PackageRelease.downloads + 1
})
db.session.commit()
return redirect(release.url, code=302) return redirect(release.url, code=302)
class PackageForm(FlaskForm): 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")]) 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)]) title = StringField("Title (Human-readable)", [InputRequired(), Length(3, 50)])
shortDesc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)]) short_desc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)])
desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)]) desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)])
type = SelectField("Type", [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD) 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) 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)
@@ -238,12 +243,21 @@ def create_edit_package_page(author=None, name=None):
package = Package() package = Package()
package.author = author package.author = author
wasNew = True 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: else:
triggerNotif(package.author, current_user, triggerNotif(package.author, current_user,
"{} edited".format(package.title), package.getDetailsURL()) "{} edited".format(package.title), package.getDetailsURL())
form.populate_obj(package) # copy to row form.populate_obj(package) # copy to row
if package.type== PackageType.TXP:
package.license = package.media_license
mpackage_cache = {} mpackage_cache = {}
package.provides.clear() package.provides.clear()
mpackages = MetaPackage.SpecToList(form.provides_str.data, mpackage_cache) mpackages = MetaPackage.SpecToList(form.provides_str.data, mpackage_cache)

View File

@@ -96,7 +96,7 @@ def create_release_page(package):
return redirect(url_for("check_task", id=rel.task_id, r=rel.getEditURL())) return redirect(url_for("check_task", id=rel.task_id, r=rel.getEditURL()))
else: else:
uploadedPath = doFileUpload(form.fileUpload.data, ["zip"], "a zip file") uploadedPath = doFileUpload(form.fileUpload.data, "zip", "a zip file")
if uploadedPath is not None: if uploadedPath is not None:
rel = PackageRelease() rel = PackageRelease()
rel.package = package rel.package = package
@@ -104,6 +104,7 @@ def create_release_page(package):
rel.url = uploadedPath rel.url = uploadedPath
rel.min_rel = form["min_rel"].data.getActual() rel.min_rel = form["min_rel"].data.getActual()
rel.max_rel = form["max_rel"].data.getActual() rel.max_rel = form["max_rel"].data.getActual()
rel.approve(current_user)
db.session.add(rel) db.session.add(rel)
db.session.commit() db.session.commit()
@@ -129,6 +130,11 @@ def download_release_page(package, id):
flash("No download available.", "error") flash("No download available.", "error")
return redirect(package.getDetailsURL()) return redirect(package.getDetailsURL())
else: else:
PackageRelease.query.filter_by(id=release.id).update({
"downloads": PackageRelease.downloads + 1
})
db.session.commit()
return redirect(release.url, code=300) return redirect(release.url, code=300)
@app.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"]) @app.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"])

View File

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

View File

@@ -32,11 +32,13 @@ from app.tasks.phpbbparser import getProfile
class UserProfileForm(FlaskForm): class UserProfileForm(FlaskForm):
display_name = StringField("Display name", [Optional(), Length(2, 20)]) display_name = StringField("Display name", [Optional(), Length(2, 20)])
email = StringField("Email", [Optional(), Email()]) email = StringField("Email", [Optional(), Email()])
website_url = StringField("Website URL", [Optional(), URL()])
donate_url = StringField("Donation URL", [Optional(), URL()])
rank = SelectField("Rank", [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce, default=UserRank.NEW_MEMBER) rank = SelectField("Rank", [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce, default=UserRank.NEW_MEMBER)
submit = SubmitField("Save") submit = SubmitField("Save")
@app.route("/users/", methods=["GET"]) @app.route("/users/", methods=["GET"])
@login_required
def user_list_page(): def user_list_page():
users = User.query.order_by(db.desc(User.rank), db.asc(User.display_name)).all() users = User.query.order_by(db.desc(User.rank), db.asc(User.display_name)).all()
return render_template("users/list.html", users=users) return render_template("users/list.html", users=users)
@@ -60,6 +62,8 @@ def user_profile_page(username):
# Copy form fields to user_profile fields # Copy form fields to user_profile fields
if user.checkPerm(current_user, Permission.CHANGE_DNAME): if user.checkPerm(current_user, Permission.CHANGE_DNAME):
user.display_name = form["display_name"].data 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): if user.checkPerm(current_user, Permission.CHANGE_RANK):
newRank = form["rank"].data newRank = form["rank"].data
@@ -74,7 +78,7 @@ def user_profile_page(username):
token = randomString(32) token = randomString(32)
ver = UserEmailVerification() ver = UserEmailVerification()
ver.user = user ver.user = user
ver.token = token ver.token = token
ver.email = newEmail ver.email = newEmail
db.session.add(ver) db.session.add(ver)

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_PORT=587
MAIL_USE_TLS=True MAIL_USE_TLS=True
MAIL_UTILS_ERROR_SEND_TO=[""] MAIL_UTILS_ERROR_SEND_TO=[""]
LANGUAGES = {
'en': 'English',
}

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,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: 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,10 +7,13 @@ Flask-Menu~=0.7
Flask-Migrate~=2.3 Flask-Migrate~=2.3
Flask-SQLAlchemy~=2.3 Flask-SQLAlchemy~=2.3
Flask-User~=0.6 Flask-User~=0.6
Flask-Babel
GitHub-Flask~=3.2 GitHub-Flask~=3.2
SQLAlchemy-Searchable==1.0.3
beautifulsoup4~=4.6 beautifulsoup4~=4.6
celery~=4.2 celery==4.1.1
kombu==4.2.0
GitPython~=2.1 GitPython~=2.1
lxml~=4.2 lxml~=4.2
pillow~=5.3 pillow~=5.3

View File

@@ -55,7 +55,7 @@ def defineDummyData(licenses, tags, ruben):
mod.repo = "https://github.com/ezhh/other_worlds" mod.repo = "https://github.com/ezhh/other_worlds"
mod.issueTracker = "https://github.com/ezhh/other_worlds/issues" mod.issueTracker = "https://github.com/ezhh/other_worlds/issues"
mod.forums = 16015 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" mod.desc = "This is the long desc"
db.session.add(mod) db.session.add(mod)
@@ -77,7 +77,7 @@ def defineDummyData(licenses, tags, ruben):
mod1.repo = "https://github.com/rubenwardy/awards" mod1.repo = "https://github.com/rubenwardy/awards"
mod1.issueTracker = "https://github.com/rubenwardy/awards/issues" mod1.issueTracker = "https://github.com/rubenwardy/awards/issues"
mod1.forums = 4870 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 = """ mod1.desc = """
Majority of awards are back ported from Calinou's old fork in Carbone, under same license. Majority of awards are back ported from Calinou's old fork in Carbone, under same license.
@@ -112,7 +112,7 @@ awards.register_achievement("award_mesefind",{
mod2.repo = "https://github.com/minetest-mods/mesecons/" mod2.repo = "https://github.com/minetest-mods/mesecons/"
mod2.issueTracker = "https://github.com/minetest-mods/mesecons/issues" mod2.issueTracker = "https://github.com/minetest-mods/mesecons/issues"
mod2.forums = 628 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 = """ mod2.desc = """
######################################################################## ########################################################################
## __ __ _____ _____ _____ _____ _____ _ _ _____ ## ## __ __ _____ _____ _____ _____ _____ _ _ _____ ##
@@ -210,7 +210,7 @@ No warranty is provided, express or implied, for any part of the project.
mod.repo = "https://github.com/ezhh/handholds" mod.repo = "https://github.com/ezhh/handholds"
mod.issueTracker = "https://github.com/ezhh/handholds/issues" mod.issueTracker = "https://github.com/ezhh/handholds/issues"
mod.forums = 17069 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" mod.desc = "This is the long desc"
db.session.add(mod) db.session.add(mod)
@@ -233,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.repo = "https://github.com/ezhh/other_worlds"
mod.issueTracker = "https://github.com/ezhh/other_worlds/issues" mod.issueTracker = "https://github.com/ezhh/other_worlds/issues"
mod.forums = 16015 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" mod.desc = "This is the long desc"
db.session.add(mod) db.session.add(mod)
@@ -248,7 +248,7 @@ No warranty is provided, express or implied, for any part of the project.
mod.repo = "https://github.com/rubenwardy/food/" mod.repo = "https://github.com/rubenwardy/food/"
mod.issueTracker = "https://github.com/rubenwardy/food/issues/" mod.issueTracker = "https://github.com/rubenwardy/food/issues/"
mod.forums = 2960 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" mod.desc = "This is the long desc"
food = mod food = mod
db.session.add(mod) db.session.add(mod)
@@ -264,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.repo = "https://github.com/rubenwardy/food_sweet/"
mod.issueTracker = "https://github.com/rubenwardy/food_sweet/issues/" mod.issueTracker = "https://github.com/rubenwardy/food_sweet/issues/"
mod.forums = 9039 mod.forums = 9039
mod.shortDesc = "Adds sweet food" mod.short_desc = "Adds sweet food"
mod.desc = "This is the long desc" mod.desc = "This is the long desc"
food_sweet = mod food_sweet = mod
db.session.add(mod) db.session.add(mod)
@@ -282,7 +282,7 @@ No warranty is provided, express or implied, for any part of the project.
game1.repo = "https://github.com/rubenwardy/capturetheflag" game1.repo = "https://github.com/rubenwardy/capturetheflag"
game1.issueTracker = "https://github.com/rubenwardy/capturetheflag/issues" game1.issueTracker = "https://github.com/rubenwardy/capturetheflag/issues"
game1.forums = 12835 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 = """ game1.desc = """
As seen on the Capture the Flag server (minetest.rubenwardy.com:30000) As seen on the Capture the Flag server (minetest.rubenwardy.com:30000)
@@ -307,7 +307,7 @@ Uses the CTF PvP Engine.
mod.type = PackageType.TXP mod.type = PackageType.TXP
mod.author = ruben mod.author = ruben
mod.forums = 14132 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" mod.desc = "This is the long desc"
db.session.add(mod) db.session.add(mod)