Compare commits

...

22 Commits

Author SHA1 Message Date
rubenwardy
ba08becd3a Fix migration error 2018-06-11 23:42:20 +01:00
rubenwardy
68b7a5e922 Add thread watchers 2018-06-11 23:39:41 +01:00
rubenwardy
e8cc685f89 Prevent new threads being created on approved packages 2018-06-11 23:22:48 +01:00
rubenwardy
86dd137f75 Add dates to comments 2018-06-11 23:20:18 +01:00
rubenwardy
b48f684c0a Add note about review thread being private 2018-06-11 23:14:14 +01:00
rubenwardy
e0e6f3392d Improve comment CSS 2018-06-11 23:11:15 +01:00
rubenwardy
b1c349cc35 Add comment system 2018-06-11 22:52:37 +01:00
rubenwardy
40aac38d43 Fix worker stopping due to gitpython asking for credentials 2018-06-07 23:25:00 +01:00
rubenwardy
051df7ab87 Increase timeout in polltask.js 2018-06-07 23:25:00 +01:00
rubenwardy
bb1f6702f6 Add name to create link 2018-06-05 23:51:40 +01:00
rubenwardy
c9542427b4 Add create links to topic table 2018-06-05 23:45:15 +01:00
rubenwardy
8601c5e075 Add support for importing generic git releases 2018-06-05 23:13:39 +01:00
rubenwardy
3d97eca387 Add git screenshot importing 2018-06-05 22:39:08 +01:00
rubenwardy
99b21f996c Fix screenshot import being broken 2018-06-05 19:59:07 +01:00
rubenwardy
700cd7ce1f Add game detection 2018-06-05 19:51:01 +01:00
rubenwardy
8d9da5a750 Make git error public, delete dir after clone 2018-06-05 19:47:02 +01:00
rubenwardy
9a36bb7d72 Add git support for importing meta 2018-06-05 00:10:47 +01:00
rubenwardy
e424dc57e7 Set remember me to true in loginUser 2018-06-04 19:34:29 +01:00
rubenwardy
7d60e2f671 Fix crash on any type search 2018-06-04 19:02:02 +01:00
rubenwardy
8b2018852e Add redirection to set password after login if not set 2018-06-04 18:49:42 +01:00
rubenwardy
0aeefa2387 Add email usage note 2018-06-04 18:36:26 +01:00
rubenwardy
4420f489ac Require email in set password 2018-06-04 18:34:04 +01:00
30 changed files with 849 additions and 248 deletions

View File

@@ -76,6 +76,7 @@ class Permission(enum.Enum):
CHANGE_RANK = "CHANGE_RANK"
CHANGE_EMAIL = "CHANGE_EMAIL"
EDIT_EDITREQUEST = "EDIT_EDITREQUEST"
SEE_THREAD = "SEE_THREAD"
# Only return true if the permission is valid for *all* contexts
# See Package.checkPerm for package-specific contexts
@@ -91,7 +92,6 @@ class Permission(enum.Enum):
else:
raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
@@ -120,6 +120,8 @@ class User(db.Model, UserMixin):
# causednotifs = db.relationship("Notification", backref="causer", lazy="dynamic")
packages = db.relationship("Package", backref="author", lazy="dynamic")
requests = db.relationship("EditRequest", backref="author", lazy="dynamic")
threads = db.relationship("Thread", backref="author", lazy="dynamic")
replies = db.relationship("ThreadReply", backref="author", lazy="dynamic")
def __init__(self, username, active=False, email=None, password=None):
import datetime
@@ -337,6 +339,9 @@ class Package(db.Model):
approved = db.Column(db.Boolean, nullable=False, default=False)
soft_deleted = db.Column(db.Boolean, nullable=False, default=False)
review_thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True, default=None)
review_thread = db.relationship("Thread", foreign_keys=[review_thread_id])
# Downloads
repo = db.Column(db.String(200), nullable=True)
website = db.Column(db.String(200), nullable=True)
@@ -434,26 +439,6 @@ class Package(db.Model):
return None
def canImportScreenshot(self):
if self.repo is None:
return False
url = urlparse(self.repo)
if url.netloc == "github.com":
return True
return False
def canMakeReleaseFromVCS(self):
if self.repo is None:
return False
url = urlparse(self.repo)
if url.netloc == "github.com":
return True
return False
def checkPerm(self, user, perm):
if not user.is_authenticated:
return False
@@ -679,6 +664,61 @@ class EditRequestChange(db.Model):
setattr(package, self.key.name, self.newValue)
watchers = db.Table("watchers",
db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
db.Column("thread_id", db.Integer, db.ForeignKey("thread.id"), primary_key=True)
)
class Thread(db.Model):
id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
package = db.relationship("Package", foreign_keys=[package_id])
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
title = db.Column(db.String(100), nullable=False)
private = db.Column(db.Boolean, server_default="0")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
replies = db.relationship("ThreadReply", backref="thread", lazy="dynamic")
watchers = db.relationship("User", secondary=watchers, lazy="subquery", \
backref=db.backref("watching", lazy=True))
def checkPerm(self, user, perm):
if not user.is_authenticated:
return not self.private
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to Thread.checkPerm()")
isOwner = user == self.author
if perm == Permission.SEE_THREAD:
return not self.private or isOwner or user.rank.atLeast(UserRank.EDITOR)
else:
raise Exception("Permission {} is not related to threads".format(perm.name))
class ThreadReply(db.Model):
id = db.Column(db.Integer, primary_key=True)
thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False)
comment = db.Column(db.String(500), nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com", \
"minetest.net", "dropboxusercontent.com", "4shared.com", \
"digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net", \
"imageshack.com", "imgur.com"]
class KrockForumTopic(db.Model):
topic_id = db.Column(db.Integer, primary_key=True, autoincrement=False)
@@ -696,6 +736,13 @@ class KrockForumTopic(db.Model):
elif self.ttype == 6:
return PackageType.GAME
def getRepoURL(self):
for item in REPO_BLACKLIST:
if item in self.link:
return None
return self.link.replace("repo.or.cz/w/", "repo.or.cz/")
# Setup Flask-User
db_adapter = SQLAlchemyAdapter(db, User) # Register the User model

View File

@@ -9,19 +9,11 @@ $(function() {
$(".pkg_meta").show()
}
function repoIsSupported(url) {
try {
return URI(url).hostname() == "github.com"
} catch(e) {
return false
}
}
$(".pkg_meta").hide()
$(".pkg_wiz_1").show()
$("#pkg_wiz_1_next").click(function() {
const repoURL = $("#repo").val();
if (repoIsSupported(repoURL)) {
if (repoURL.trim() != "") {
$(".pkg_wiz_1").hide()
$(".pkg_wiz_2").show()
$(".pkg_repo").hide()
@@ -35,19 +27,24 @@ $(function() {
}
performTask("/tasks/getmeta/new/?url=" + encodeURI(repoURL)).then(function(result) {
$("#name").val(result.name || "")
setSpecial("#provides_str", result.name || "")
$("#title").val(result.title || "")
$("#name").val(result.name)
setSpecial("#provides_str", result.provides)
$("#title").val(result.title)
$("#repo").val(result.repo || repoURL)
$("#issueTracker").val(result.issueTracker || "")
$("#desc").val(result.description || "")
$("#shortDesc").val(result.short_description || "")
setSpecial("#harddep_str", result.depends || "")
setSpecial("#softdep_str", result.optional_depends || "")
$("#shortDesc").val(result.short_description || "")
$("#issueTracker").val(result.issueTracker)
$("#desc").val(result.description)
$("#shortDesc").val(result.short_description)
setSpecial("#harddep_str", result.depends)
setSpecial("#softdep_str", result.optional_depends)
$("#shortDesc").val(result.short_description)
if (result.forumId) {
$("#forums").val(result.forumId)
}
if (result.type && result.type.length > 2) {
$("#type").val(result.type)
}
finish()
}).catch(function(e) {
alert(e)

View File

@@ -22,7 +22,7 @@ function pollTask(poll_url, disableTimeout) {
var tries = 0;
function retry() {
tries++;
if (!disableTimeout && tries > 10) {
if (!disableTimeout && tries > 30) {
reject("timeout")
} else {
const interval = Math.min(tries*100, 1000)

49
app/scss/comments.scss Normal file
View File

@@ -0,0 +1,49 @@
.comments, .comments li {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid #444;
}
.comments {
border-radius: 5px;
margin: 15px 0;
background: #333;
.info_strip, .msg {
display: block;
margin: 0;
}
.info_strip {
padding: 0.2em 1em;
border-bottom: 1px solid #444;
}
.msg {
padding: 1em;
background: #222;
}
.author {
font-weight: bold;
float: left;
display: inline-block;
}
.info_strip span {
float: right;
display: inline-block;
color: #bbb;
}
}
.comment_form {
margin: 1em 0;
}
.comment_form textarea {
min-width: 60%;
max-width: 100%;
margin: 0 0 1em 0;
}

View File

@@ -315,6 +315,11 @@ select:not([multiple]) {
border: 1px solid #c96;
}
.alert-primary {
background: #339;
border: 1px solid #66a;
}
.alert-success {
background: #161;
border: 1px solid #393;

View File

@@ -3,3 +3,4 @@
@import "nav.scss";
@import "packages.scss";
@import "packagegrid.scss";
@import "comments.scss";

View File

@@ -15,16 +15,18 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import flask, json, os
import flask, json, os, git, tempfile, shutil
from git import GitCommandError
from flask.ext.sqlalchemy import SQLAlchemy
from urllib.error import HTTPError
import urllib.request
from urllib.parse import urlparse, quote_plus
from urllib.parse import urlparse, quote_plus, urlsplit
from app import app
from app.models import *
from app.tasks import celery, TaskError
from app.utils import randomString
class GithubURLMaker:
def __init__(self, url):
# Rewrite path
@@ -46,18 +48,6 @@ class GithubURLMaker:
def getRepoURL(self):
return "https://github.com/{}/{}".format(self.user, self.repo)
def getIssueTrackerURL(self):
return "https://github.com/{}/{}/issues/".format(self.user, self.repo)
def getModConfURL(self):
return self.baseUrl + "/mod.conf"
def getDescURL(self):
return self.baseUrl + "/description.txt"
def getDependsURL(self):
return self.baseUrl + "/depends.txt"
def getScreenshotURL(self):
return self.baseUrl + "/screenshot.png"
@@ -69,7 +59,6 @@ class GithubURLMaker:
return "https://github.com/{}/{}/archive/{}.zip" \
.format(self.user, self.repo, commit)
krock_list_cache = None
krock_list_cache_by_name = None
def getKrockList():
@@ -97,9 +86,9 @@ def getKrockList():
return {
"title": x["title"],
"author": x["author"],
"name": x["name"],
"name": x["name"],
"topicId": x["topicId"],
"link": x["link"],
"link": x["link"],
}
krock_list_cache = [g(x) for x in list if h(x)]
@@ -143,99 +132,208 @@ def parseConf(string):
return retval
@celery.task()
def getMeta(urlstr, author):
url = urlparse(urlstr)
class PackageTreeNode:
def __init__(self, baseDir, author=None, repo=None, name=None):
print("Scanning " + baseDir)
self.baseDir = baseDir
self.author = author
self.name = name
self.repo = repo
self.meta = None
self.children = []
urlmaker = None
if url.netloc == "github.com":
urlmaker = GithubURLMaker(url)
else:
raise TaskError("Unsupported repo")
# Detect type
type = None
is_modpack = False
if os.path.isfile(baseDir + "/game.conf"):
type = PackageType.GAME
elif os.path.isfile(baseDir + "/init.lua"):
type = PackageType.MOD
elif os.path.isfile(baseDir + "/modpack.txt"):
type = PackageType.MOD
is_modpack = True
elif os.path.isdir(baseDir + "/mods"):
type = PackageType.GAME
elif os.listdir(baseDir) == []:
# probably a submodule
return
else:
raise TaskError("Unable to detect package type!")
if not urlmaker.isValid():
raise TaskError("Error! Url maker not valid")
self.type = type
self.readMetaFiles()
result = {}
if self.type == PackageType.GAME:
self.addChildrenFromModDir(baseDir + "/mods")
elif is_modpack:
self.addChildrenFromModDir(baseDir)
result["repo"] = urlmaker.getRepoURL()
result["issueTracker"] = urlmaker.getIssueTrackerURL()
try:
contents = urllib.request.urlopen(urlmaker.getModConfURL()).read().decode("utf-8")
conf = parseConf(contents)
for key in ["name", "description", "title", "depends", "optional_depends"]:
try:
result[key] = conf[key]
except KeyError:
pass
except HTTPError:
print("mod.conf does not exist")
def readMetaFiles(self):
result = {}
if "name" in result:
result["title"] = result["name"].replace("_", " ").title()
if not "description" in result:
# .conf file
try:
contents = urllib.request.urlopen(urlmaker.getDescURL()).read().decode("utf-8")
result["description"] = contents.strip()
except HTTPError:
with open(self.baseDir + "/mod.conf", "r") as myfile:
conf = parseConf(myfile.read())
for key in ["name", "description", "title", "depends", "optional_depends"]:
try:
result[key] = conf[key]
except KeyError:
pass
except IOError:
print("description.txt does not exist!")
import re
pattern = re.compile("^([a-z0-9_]+)\??$")
if not "depends" in result and not "optional_depends" in result:
try:
contents = urllib.request.urlopen(urlmaker.getDependsURL()).read().decode("utf-8")
soft = []
hard = []
for line in contents.split("\n"):
line = line.strip()
if pattern.match(line):
if line[len(line) - 1] == "?":
soft.append( line[:-1])
else:
hard.append(line)
# description.txt
if not "description" in result:
try:
with open(self.baseDir + "/description.txt", "r") as myfile:
result["description"] = myfile.read()
except IOError:
print("description.txt does not exist!")
result["depends"] = ",".join(hard)
result["optional_depends"] = ",".join(soft)
# depends.txt
import re
pattern = re.compile("^([a-z0-9_]+)\??$")
if not "depends" in result and not "optional_depends" in result:
try:
with open(self.baseDir + "/depends.txt", "r") as myfile:
contents = myfile.read()
soft = []
hard = []
for line in contents.split("\n"):
line = line.strip()
if pattern.match(line):
if line[len(line) - 1] == "?":
soft.append( line[:-1])
else:
hard.append(line)
result["depends"] = hard
result["optional_depends"] = soft
except IOError:
print("depends.txt does not exist!")
else:
if "depends" in result:
result["depends"] = [x.strip() for x in result["depends"].split(",")]
if "optional_depends" in result:
result["optional_depends"] = [x.strip() for x in result["optional_depends"].split(",")]
except HTTPError:
print("depends.txt does not exist!")
# Calculate Title
if "name" in result and not "title" in result:
result["title"] = result["name"].replace("_", " ").title()
if "description" in result:
desc = result["description"]
idx = desc.find(".") + 1
cutIdx = min(len(desc), 200 if idx < 5 else idx)
result["short_description"] = desc[:cutIdx]
# Calculate short description
if "description" in result:
desc = result["description"]
idx = desc.find(".") + 1
cutIdx = min(len(desc), 200 if idx < 5 else idx)
result["short_description"] = desc[:cutIdx]
# Get forum ID
info = findModInfo(self.author, result.get("name"), self.repo)
if info is not None:
result["forumId"] = info.get("topicId")
if "name" in result:
self.name = result["name"]
del result["name"]
self.meta = result
def addChildrenFromModDir(self, dir):
for entry in next(os.walk(dir))[1]:
path = dir + "/" + entry
if not entry.startswith('.') and os.path.isdir(path):
self.children.append(PackageTreeNode(path, name=entry))
info = findModInfo(author, result.get("name"), result["repo"])
if info is not None:
result["forumId"] = info.get("topicId")
def fold(self, attr, key=None, acc=None):
if acc is None:
acc = set()
if self.meta is None:
return acc
at = getattr(self, attr)
value = at if key is None else at.get(key)
if isinstance(value, list):
acc |= set(value)
elif value is not None:
acc.add(value)
for child in self.children:
child.fold(attr, key, acc)
return acc
def get(self, key):
return self.meta.get(key)
def generateGitURL(urlstr):
scheme, netloc, path, query, frag = urlsplit(urlstr)
return "http://:@" + netloc + path + query
# Clones a repo from an unvalidated URL.
# Returns a tuple of path and repo on sucess.
# Throws `TaskError` on failure.
# Caller is responsible for deleting returned directory.
def cloneRepo(urlstr, ref=None, recursive=False):
gitDir = tempfile.gettempdir() + "/" + randomString(10)
err = None
try:
gitUrl = generateGitURL(urlstr)
print("Cloning from " + gitUrl)
repo = git.Repo.clone_from(gitUrl, gitDir, \
progress=None, env=None, depth=1, recursive=recursive, kill_after_timeout=15)
if ref is not None:
repo.create_head("myhead", ref).checkout()
return gitDir, repo
except GitCommandError as e:
# This is needed to stop the backtrace being weird
err = e.stderr
raise TaskError(err.replace("stderr: ", "") \
.replace("Cloning into '" + gitDir + "'...", "") \
.strip())
@celery.task()
def getMeta(urlstr, author):
gitDir, _ = cloneRepo(urlstr, recursive=True)
tree = PackageTreeNode(gitDir, author=author, repo=urlstr)
shutil.rmtree(gitDir)
result = {}
result["name"] = tree.name
result["provides"] = tree.fold("name")
result["type"] = tree.type.name
for key in ["depends", "optional_depends"]:
result[key] = tree.fold("meta", key)
for key in ["title", "repo", "issueTracker", "forumId", "description", "short_description"]:
result[key] = tree.get(key)
for mod in result["provides"]:
result["depends"].discard(mod)
result["optional_depends"].discard(mod)
for key, value in result.items():
if isinstance(value, set):
result[key] = list(value)
return result
@celery.task()
def makeVCSRelease(id, branch):
release = PackageRelease.query.get(id)
if release is None:
raise TaskError("No such release!")
if release.package is None:
raise TaskError("No package attached to release")
url = urlparse(release.package.repo)
urlmaker = None
if url.netloc == "github.com":
urlmaker = GithubURLMaker(url)
else:
raise TaskError("Unsupported repo")
def makeVCSReleaseFromGithub(id, branch, release, url):
urlmaker = GithubURLMaker(url)
if not urlmaker.isValid():
raise TaskError("Invalid github repo URL")
@@ -254,6 +352,37 @@ def makeVCSRelease(id, branch):
return release.url
@celery.task()
def makeVCSRelease(id, branch):
release = PackageRelease.query.get(id)
if release is None:
raise TaskError("No such release!")
elif release.package is None:
raise TaskError("No package attached to release")
urlmaker = None
url = urlparse(release.package.repo)
if url.netloc == "github.com":
return makeVCSReleaseFromGithub(id, branch, release, url)
else:
gitDir, repo = cloneRepo(release.package.repo, ref=branch, recursive=True)
try:
filename = randomString(10) + ".zip"
destPath = os.path.join("app/public/uploads", filename)
with open(destPath, "wb") as fp:
repo.archive(fp)
release.url = "/uploads/" + filename
print(release.url)
release.task_id = None
db.session.commit()
return release.url
finally:
shutil.rmtree(gitDir)
@celery.task()
def importRepoScreenshot(id):
package = Package.query.get(id)
@@ -261,34 +390,35 @@ def importRepoScreenshot(id):
raise Exception("Unexpected none package")
# Get URL Maker
url = urlparse(package.repo)
urlmaker = None
if url.netloc == "github.com":
urlmaker = GithubURLMaker(url)
else:
raise TaskError("Unsupported repo")
if not urlmaker.isValid():
raise TaskError("Error! Url maker not valid")
try:
filename = randomString(10) + ".png"
imagePath = os.path.join("app/public/uploads", filename)
print(imagePath)
urllib.request.urlretrieve(urlmaker.getScreenshotURL(), imagePath)
gitDir, _ = cloneRepo(package.repo)
except TaskError as e:
# ignore download errors
print(e)
return None
ss = PackageScreenshot()
ss.approved = True
ss.package = package
ss.title = "screenshot.png"
ss.url = "/uploads/" + filename
db.session.add(ss)
db.session.commit()
# Find and import screenshot
try:
for ext in ["png", "jpg", "jpeg"]:
sourcePath = gitDir + "/screenshot." + ext
if os.path.isfile(sourcePath):
filename = randomString(10) + "." + ext
destPath = os.path.join("app/public/uploads", filename)
shutil.copyfile(sourcePath, destPath)
return "/uploads/" + filename
except HTTPError:
print("screenshot.png does not exist")
ss = PackageScreenshot()
ss.approved = True
ss.package = package
ss.title = "screenshot.png"
ss.url = "/uploads/" + filename
db.session.add(ss)
db.session.commit()
return "/uploads/" + filename
finally:
shutil.rmtree(gitDir)
print("screenshot.png does not exist")
return None

View File

@@ -11,10 +11,6 @@ Sign in
<h2>{%trans%}Sign in{%endtrans%}</h2>
<form action="" method="POST" class="form box-body" role="form">
<h3>Sign in with Github</h3>
<p><a class="button" href="{{ url_for('github_signin_page') }}">GitHub</a></p>
<h3>Sign in with username/password</h3>
{{ form.hidden_tag() }}
@@ -38,17 +34,13 @@ Sign in
{# Password field #}
{% set field = form.password %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
{# Label on left, "Forgot your Password?" on right #}
<div class="row">
<div class="col-xs-6">
<label for="{{ field.id }}" class="control-label">{{ field.label.text }}</label>
</div>
<div class="col-xs-6 text-right">
{% if user_manager.enable_forgot_password %}
<label for="{{ field.id }}" class="control-label">{{ field.label.text }}
{% if user_manager.enable_forgot_password %}
<a href="{{ url_for('user.forgot_password') }}" tabindex='195'>
{%trans%}Forgot your Password?{%endtrans%}</a>
{% endif %}
</div>
[{%trans%}Forgot My Password{%endtrans%}]</a>
{% endif %}
</label>
</div>
{{ field(class_='form-control', tabindex=120) }}
{% if field.errors %}
@@ -64,7 +56,12 @@ Sign in
{% endif %}
{# Submit button #}
{{ render_submit_field(form.submit, tabindex=180) }}
<p>
{{ render_submit_field(form.submit, tabindex=180) }}
</p>
<h3>Sign in with Github</h3>
<p><a class="button" href="{{ url_for('github_signin_page') }}">GitHub</a></p>
</form>
</div>

View File

@@ -0,0 +1,34 @@
{% macro render_thread(thread, current_user) -%}
<ul class="comments">
{% for r in thread.replies %}
<li>
<div class="info_strip">
<a class="author {{ r.author.rank.name }}"
href="{{ url_for('user_profile_page', username=r.author.username) }}">
{{ r.author.display_name }}</a>
<span>{{ r.created_at | datetime }}</span>
<div class="clearboth"></div>
</div>
<div class="msg">
{{ r.comment }}
</div>
</li>
{% endfor %}
</ul>
{% if current_user.is_authenticated %}
<form method="post" action="{{ url_for('thread_page', id=thread.id)}}" class="comment_form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<textarea required maxlength=500 name="comment"></textarea><br />
<input type="submit" value="Comment" />
</form>
{% endif %}
{% endmacro %}
{% macro render_threadlist(threads) -%}
<ul>
{% for t in threads %}
<li><a href="{{ url_for('thread_page', id=t.id) }}">{{ t.title }}</a> by {{ t.author.display_name }}</li>
{% endfor %}
</ul>
{% endmacro %}

View File

@@ -0,0 +1,26 @@
{% macro render_topictable(topics, show_author=True) -%}
<table>
<tr>
<th>Id</th>
<th>Title</th>
{% if show_author %}<th>Author</th>{% endif %}
<th>Name</th>
<th>Link</th>
<th>Actions</th>
</tr>
{% for topic in topics %}
<tr>
<td>{{ topic.topic_id }}</td>
<td>[{{ topic.getType().value }}] <a href="https://forum.minetest.net/viewtopic.php?t={{ topic.topic_id}}">{{ topic.title }}</a></td>
{% if show_author %}
<td><a href="{{ url_for('user_profile_page', username=topic.author.username) }}">{{ topic.author.display_name}}</a></td>
{% endif %}
<td>{{ topic.name or ""}}</td>
<td><a href="{{ topic.link }}">{{ topic.link | domain }}</a></td>
<td>
<a href="{{ url_for('create_edit_package_page', author=topic.author.username, repo=topic.getRepoURL(), forums=topic.topic_id, title=topic.title, bname=topic.name) }}">Create</a>
</td>
</tr>
{% endfor %}
</table>
{% endmacro %}

View File

@@ -49,7 +49,7 @@
<div class="pkg_wiz_1">
<p>Enter the repo URL for the package.
If it's hosted on Github then metadata will automatically be 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.</p>
</div>
@@ -61,7 +61,7 @@
</div>
<div class="pkg_wiz_2">
Importing...
Importing... (This may take a while)
</div>
{{ render_field(form.website, class_="pkg_meta") }}

View File

@@ -11,7 +11,7 @@
{{ render_field(form.title, placeholder="Human readable. Eg: 1.0.0 or 2018-05-28") }}
{{ render_field(form.uploadOpt) }}
{% if package.canMakeReleaseFromVCS() %}
{% if package.repo %}
{{ render_field(form.vcsLabel) }}
{% endif %}
{{ render_field(form.fileUpload) }}

View File

@@ -43,6 +43,25 @@
{% endif %}
<div style="clear: both;"></div>
</div>
{% if package.author == current_user or package.checkPerm(current_user, "APPROVE_NEW") %}
{% if review_thread %}
<h2>&#x1f512; {{ review_thread.title }}</h2>
<p><i>
This thread is only visible to the package owner and users of
Editor rank or above.
</i></p>
{% from "macros/threads.html" import render_thread %}
{{ render_thread(review_thread, current_user) }}
{% else %}
<div class="box box_grey alert alert-info">
Privately ask a question or give feedback
<a class="alert_right button" href="{{ url_for('new_thread_page', pid=package.id, title='Package approval comments') }}">Open Thread</a>
</div>
{% endif %}
{% endif %}
{% endif %}
<h1>{{ package.title }} by {{ package.author.display_name }}</h1>

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}
Threads
{% endblock %}
{% block content %}
<h1>Threads</h1>
{% from "macros/threads.html" import render_threadlist %}
{{ render_threadlist(threads) }}
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}
New Thread
{% endblock %}
{% block content %}
{% from "macros/forms.html" import render_field, render_submit_field %}
<form method="POST" action="" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{{ render_field(form.title) }}
{{ render_field(form.comment) }}
{{ render_field(form.private) }}
{{ render_submit_field(form.submit) }}
<p>Only the you, the package author, and users of Editor rank and above can read private threads.</p>
</form>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}
Threads
{% endblock %}
{% block content %}
<h1>{% if thread.private %}&#x1f512; {% endif %}{{ thread.title }}</h1>
{% if thread.package %}
<p>
Package: <a href="{{ thread.package.getDetailsURL() }}">{{ thread.package.title }}</a>
</p>
{% endif %}
{% if thread.private %}
<i>
This thread is only visible to its creator, the package owner, and users of
Editor rank or above.
</i>
{% endif %}
{% from "macros/threads.html" import render_thread %}
{{ render_thread(thread, current_user) }}
{% endblock %}

View File

@@ -12,23 +12,6 @@ Topics to be Added
{{ topics | count }} remaining.
</p>
<table>
<tr>
<th>Id</th>
<th>Title</th>
<th>Author</th>
<th>Name</th>
<th>Link</th>
</tr>
{% for topic in topics %}
<tr>
<td>{{ topic.topic_id }}</td>
<td>[{{ topic.getType().value }}] <a href="https://forum.minetest.net/viewtopic.php?t={{ topic.topic_id}}">{{ topic.title }}</a></td>
<td><a href="{{ url_for('user_profile_page', username=topic.author.username) }}">{{ topic.author.display_name}}</a></td>
<td>{{ topic.name or ""}}</td>
<td><a href="{{ topic.link }}">{{ topic.link | domain }}</a></td>
</tr>
{% endfor %}
</table>
{% from "macros/topictable.html" import render_topictable %}
{{ render_topictable(topics) }}
{% endblock %}

View File

@@ -5,24 +5,36 @@
{% endblock %}
{% block content %}
{% if optional %}
<div class="box box_grey alert alert-primary">
It is recommended that you set a password for your account.
<a class="alert_right button" href="{{ url_for('home_page') }}">Skip</a>
</div>
{% endif %}
<h1>Set Password</h1>
{% from "macros/forms.html" import render_field, render_submit_field %}
<form action="" method="POST" class="form" role="form">
<div class="row">
<div class="col-sm-6 col-md-5 col-lg-4">
{{ form.hidden_tag() }}
{{ form.hidden_tag() }}
{% if not current_user.email %}
{{ render_field(form.email, tabindex=230) }}
{% endif %}
{% if not current_user.email %}
{{ render_field(form.email, tabindex=230) }}
{{ render_field(form.password, tabindex=230) }}
{{ render_field(form.password2, tabindex=240) }}
<p>
Your email is needed to recover your account if you forget your
password, and to optionally send notifications.
Your email will never be shared to a third-party.
</p>
{% endif %}
{{ render_submit_field(form.submit, tabindex=280) }}
</div>
</div>
{{ render_field(form.password, tabindex=230) }}
{{ render_field(form.password2, tabindex=240) }}
{{ render_submit_field(form.submit, tabindex=280) }}
</form>
{% endblock %}

View File

@@ -108,22 +108,10 @@
Powered by Krock's Mod Search.
</p>
<table>
<tr>
<th>Id</th>
<th>Title</th>
<th>Name</th>
<th>Link</th>
</tr>
{% for topic in topics_to_add %}
<tr>
<td>{{ topic.topic_id }}</td>
<td>[{{ topic.getType().value }}] <a href="https://forum.minetest.net/viewtopic.php?t={{ topic.topic_id}}">{{ topic.title }}</a></td>
<td>{{ topic.name or ""}}</td>
<td><a href="{{ topic.link }}">{{ topic.link | domain }}</a></td>
</tr>
{% endfor %}
</table>
{% from "macros/topictable.html" import render_topictable %}
{{ render_topictable(topics_to_add, show_author=False) }}
</div>
</div>
{% endif %}

View File

@@ -99,7 +99,7 @@ def loginUser(user):
if user_manager.enable_username:
user_mixin = user_manager.find_user_by_username(user.username)
return _do_login_user(user_mixin, False)
return _do_login_user(user_mixin, True)
def rank_required(rank):
def decorator(f):

View File

@@ -51,7 +51,7 @@ def home_page():
packages = query.order_by(db.desc(Package.created_at)).limit(15).all()
return render_template("index.html", packages=packages, count=count)
from . import users, githublogin, packages, sass, tasks, admin, notifications, tagseditor, meta, thumbnails
from . import users, githublogin, packages, sass, tasks, admin, notifications, tagseditor, meta, thumbnails, threads
@menu.register_menu(app, ".help", "Help", order=19, endpoint_arguments_constructor=lambda: { 'path': 'help' })
@app.route('/<path:path>/')

View File

@@ -64,7 +64,10 @@ def github_authorized(oauth_token):
flash("Unable to find an account for that Github user", "error")
return redirect(url_for("user_claim_page"))
elif loginUser(userByGithub):
return redirect(next_url or url_for("home_page"))
if current_user.password is None:
return redirect(next_url or url_for("set_password_page", optional=True))
else:
return redirect(next_url or url_for("home_page"))
else:
flash("Authorization failed [err=gh-login-failed]", "danger")
return redirect(url_for("user.login"))

View File

@@ -38,9 +38,10 @@ from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleF
@menu.register_menu(app, ".txp", "Texture Packs", order=13, endpoint_arguments_constructor=lambda: { 'type': 'txp' })
@app.route("/packages/")
def packages_page():
type = request.args.get("type")
if type is not None:
type = PackageType[type.upper()]
type_name = request.args.get("type")
type = None
if type_name is not None:
type = PackageType[type_name.upper()]
title = "Packages"
query = Package.query.filter_by(soft_deleted=False)
@@ -50,7 +51,7 @@ def packages_page():
query = query.filter_by(type=type, approved=True)
search = request.args.get("q")
if search is not None:
if search is not None and search.strip() != "":
query = query.filter(Package.title.ilike('%' + search + '%'))
if shouldReturnJson():
@@ -62,14 +63,14 @@ def packages_page():
num = min(42, int(request.args.get("n") or 100))
query = query.paginate(page, num, True)
next_url = url_for("packages_page", type=type.toName(), q=search, page=query.next_num) \
next_url = url_for("packages_page", type=type_name, q=search, page=query.next_num) \
if query.has_next else None
prev_url = url_for("packages_page", type=type.toName(), q=search, page=query.prev_num) \
prev_url = url_for("packages_page", type=type_name, q=search, page=query.prev_num) \
if query.has_prev else None
tags = Tag.query.all()
return render_template("packages/list.html", title=title, packages=query.items, \
query=search, tags=tags, type=None if type is None else type.toName(), \
query=search, tags=tags, type=type_name, \
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages, packages_count=query.total)
@@ -109,9 +110,15 @@ def package_page(package):
releases = getReleases(package)
requests = [r for r in package.requests if r.status == 0]
review_thread = Thread.query.filter_by(package_id=package.id, private=True).first()
if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD):
review_thread = None
return render_template("packages/view.html", \
package=package, releases=releases, requests=requests, \
alternatives=alternatives, similar_topics=similar_topics)
alternatives=alternatives, similar_topics=similar_topics, \
review_thread=review_thread)
@app.route("/packages/<author>/<name>/download/")
@@ -142,7 +149,7 @@ class PackageForm(FlaskForm):
tags = QuerySelectMultipleField('Tags', query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=lambda a: a.title)
harddep_str = StringField("Hard Dependencies", [Optional(), Length(0,1000)])
softdep_str = StringField("Soft Dependencies", [Optional(), Length(0,1000)])
repo = StringField("Repo URL", [Optional(), URL()])
repo = StringField("VCS Repository URL", [Optional(), URL()])
website = StringField("Website URL", [Optional(), URL()])
issueTracker = StringField("Issue Tracker URL", [Optional(), URL()])
forums = IntegerField("Forum Topic ID", [Optional(), NumberRange(0,999999)])
@@ -179,11 +186,17 @@ def create_edit_package_page(author=None, name=None):
form = PackageForm(formdata=request.form, obj=package)
# Initial form class from post data and default data
if request.method == "GET" and package is not None:
deps = package.dependencies
form.harddep_str.data = ",".join([str(x) for x in deps if not x.optional])
form.softdep_str.data = ",".join([str(x) for x in deps if x.optional])
form.provides_str.data = MetaPackage.ListToSpec(package.provides)
if request.method == "GET":
if package is None:
form.name.data = request.args.get("bname")
form.title.data = request.args.get("title")
form.repo.data = request.args.get("repo")
form.forums.data = request.args.get("forums")
else:
deps = package.dependencies
form.harddep_str.data = ",".join([str(x) for x in deps if not x.optional])
form.softdep_str.data = ",".join([str(x) for x in deps if x.optional])
form.provides_str.data = MetaPackage.ListToSpec(package.provides)
if request.method == "POST" and form.validate():
wasNew = False
@@ -232,7 +245,7 @@ def create_edit_package_page(author=None, name=None):
db.session.commit() # save
if wasNew and package.canImportScreenshot():
if wasNew and package.repo is not None:
task = importRepoScreenshot.delay(package.id)
return redirect(url_for("check_task", id=task.id, r=package.getDetailsURL()))

View File

@@ -52,8 +52,8 @@ def create_release_page(package):
# Initial form class from post data and default data
form = CreatePackageReleaseForm()
if package.canMakeReleaseFromVCS():
form["uploadOpt"].choices = [("vcs", "From VCS Commit or Branch"), ("upload", "File Upload")]
if package.repo is not None:
form["uploadOpt"].choices = [("vcs", "From Git Commit or Branch"), ("upload", "File Upload")]
if request.method != "POST":
form["uploadOpt"].data = "vcs"

154
app/views/threads.py Normal file
View File

@@ -0,0 +1,154 @@
# Content DB
# Copyright (C) 2018 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask_user import *
from app import app
from app.models import *
from app.utils import triggerNotif, clearNotifications
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
@app.route("/threads/")
def threads_page():
threads = Thread.query.filter_by(private=False).all()
return render_template("threads/list.html", threads=threads)
@app.route("/threads/<int:id>/", methods=["GET", "POST"])
def thread_page(id):
clearNotifications(url_for("thread_page", id=id))
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
abort(404)
if current_user.is_authenticated and request.method == "POST":
comment = request.form["comment"]
if len(comment) <= 500 and len(comment) > 3:
reply = ThreadReply()
reply.author = current_user
reply.comment = comment
db.session.add(reply)
thread.replies.append(reply)
if not current_user in thread.watchers:
thread.watchers.append(current_user)
msg = None
if thread.package is None:
msg = "New comment on '{}'".format(thread.title)
else:
msg = "New comment on '{}' on package {}".format(thread.title, thread.package.title)
for user in thread.watchers:
if user != current_user:
triggerNotif(user, current_user, msg, url_for("thread_page", id=thread.id))
db.session.commit()
return redirect(url_for("thread_page", id=id))
else:
flash("Comment needs to be between 3 and 500 characters.")
return render_template("threads/view.html", thread=thread)
class ThreadForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3,100)])
comment = TextAreaField("Comment", [InputRequired(), Length(10, 500)])
private = BooleanField("Private")
submit = SubmitField("Open Thread")
@app.route("/threads/new/", methods=["GET", "POST"])
@login_required
def new_thread_page():
form = ThreadForm(formdata=request.form)
package = None
if "pid" in request.args:
package = Package.query.get(int(request.args.get("pid")))
if package is None:
flash("Unable to find that package!", "error")
# Don't allow making threads on approved packages for now
if package is None or package.approved:
abort(403)
def_is_private = request.args.get("private") or False
if not package.approved:
def_is_private = True
allow_change = package.approved
is_review_thread = package is not None and not package.approved
# Check that user can make the thread
if is_review_thread and not (package.author == current_user or \
package.checkPerm(current_user, Permission.APPROVE_NEW)):
flash("Unable to create thread!", "error")
return redirect(url_for("home_page"))
# Only allow creating one thread when not approved
elif is_review_thread and package.review_thread is not None:
flash("A review thread already exists!", "error")
if request.method == "GET":
return redirect(url_for("thread_page", id=package.review_thread.id))
# Set default values
elif request.method == "GET":
form.private.data = def_is_private
form.title.data = request.args.get("title") or ""
# Validate and submit
elif request.method == "POST" and form.validate():
thread = Thread()
thread.author = current_user
thread.title = form.title.data
thread.private = form.private.data if allow_change else def_is_private
thread.package = package
db.session.add(thread)
thread.watchers.append(current_user)
if package is not None and package.author != current_user:
thread.watchers.append(package.author)
reply = ThreadReply()
reply.thread = thread
reply.author = current_user
reply.comment = form.comment.data
db.session.add(reply)
thread.replies.append(reply)
db.session.commit()
if is_review_thread:
package.review_thread = thread
if package is not None:
triggerNotif(package.author, current_user,
"New thread '{}' on package {}".format(thread.title, package.title), url_for("thread_page", id=thread.id))
db.session.commit()
return redirect(url_for("thread_page", id=thread.id))
return render_template("threads/new.html", form=form, allow_private_change=allow_change)

View File

@@ -109,7 +109,7 @@ def user_profile_page(username):
user=user, form=form, packages=packages, topics_to_add=topics_to_add)
class SetPasswordForm(FlaskForm):
email = StringField("Email (Optional)", [Optional(), Email()])
email = StringField("Email", [Optional(), Email()])
password = PasswordField("New password", [InputRequired(), Length(2, 20)])
password2 = PasswordField("Verify password", [InputRequired(), Length(2, 20)])
submit = SubmitField("Save")
@@ -121,6 +121,9 @@ def set_password_page():
return redirect(url_for("user.change_password"))
form = SetPasswordForm(request.form)
if current_user.email == None:
form.email.validators = [InputRequired(), Email()]
if request.method == "POST" and form.validate():
one = form.password.data
two = form.password2.data
@@ -159,7 +162,7 @@ def set_password_page():
else:
flash("Passwords do not match", "error")
return render_template("users/set_password.html", form=form)
return render_template("users/set_password.html", form=form, optional=request.args.get("optional"))
@app.route("/user/claim/", methods=["GET", "POST"])

View File

@@ -19,17 +19,11 @@ depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('user','username', type_=ty.VARCHAR(50, collation='NOCASE'))
op.alter_column('user','github_username', type_=ty.VARCHAR(50, collation='NOCASE'))
op.alter_column('user','forums_username', type_=ty.VARCHAR(50, collation='NOCASE'))
op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True)
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('user','username', type_=ty.VARCHAR(50))
op.alter_column('user','github_username', type_=ty.VARCHAR(50))
op.alter_column('user','forums_username', type_=ty.VARCHAR(50))
op.drop_index(op.f('ix_user_username'), table_name='user')
pass
# ### end Alembic commands ###

View File

@@ -0,0 +1,55 @@
"""empty message
Revision ID: 605b3d74ada1
Revises: 28a427cbd4cf
Create Date: 2018-06-11 22:50:36.828818
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '605b3d74ada1'
down_revision = '28a427cbd4cf'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('thread',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('package_id', sa.Integer(), nullable=True),
sa.Column('author_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=100), nullable=False),
sa.Column('private', sa.Boolean(), server_default='0', nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['package_id'], ['package.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('thread_reply',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('thread_id', sa.Integer(), nullable=False),
sa.Column('comment', sa.String(length=500), nullable=False),
sa.Column('author_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['thread_id'], ['thread.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.add_column('package', sa.Column('review_thread_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'package', 'thread', ['review_thread_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'package', type_='foreignkey')
op.drop_constraint(None, 'package', type_='foreignkey')
op.drop_column('package', 'review_thread_id')
op.drop_table('thread_reply')
op.drop_table('thread')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""empty message
Revision ID: de004661c5e1
Revises: 605b3d74ada1
Create Date: 2018-06-11 23:38:38.611039
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'de004661c5e1'
down_revision = '605b3d74ada1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('watchers',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('thread_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['thread_id'], ['thread.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id', 'thread_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('watchers')
# ### end Alembic commands ###

View File

@@ -13,3 +13,4 @@ lxml==4.2.1
Flask-FlatPages==0.6
Flask-Migrate==2.1.1
pillow==5.1.0
GitPython==2.1.10