Compare commits

..

11 Commits

Author SHA1 Message Date
rubenwardy
a36e233051 Fix API auth crash and add more error messages 2020-05-19 17:24:57 +01:00
rubenwardy
8484c0f0aa Fix minor security vulnerability 2020-05-19 16:46:47 +01:00
rubenwardy
ffb5b49521 Fix crash on invalid protocol_version 2020-05-19 16:39:39 +01:00
rubenwardy
c15dd183a0 Update top packages 2020-04-30 22:41:55 +01:00
rubenwardy
0eca2d49ba Add celery exporter 2020-04-24 00:49:40 +01:00
rubenwardy
57e7cbfd09 Make OpenGraph URLs absolute 2020-04-23 23:51:10 +01:00
Lars Mueller
e94bd9b845 Add meta to package view pages 2020-04-23 23:51:03 +01:00
rubenwardy
05bf8e3b3d Add prometheus support 2020-04-23 23:30:37 +01:00
rubenwardy
3992b19be3 Optimise SQL queries 2020-04-21 20:35:05 +01:00
rubenwardy
a678a61c23 Correct documentation on users allowed to use webhooks 2020-04-21 19:27:34 +01:00
rubenwardy
b5ce0a786a Improve legibility of textual content 2020-04-21 19:18:06 +01:00
24 changed files with 299 additions and 74 deletions

View File

@@ -16,6 +16,7 @@
from flask import request, make_response, jsonify, abort from flask import request, make_response, jsonify, abort
from app.models import APIToken from app.models import APIToken
from .support import error
from functools import wraps from functools import wraps
def is_api_authd(f): def is_api_authd(f):
@@ -29,13 +30,13 @@ def is_api_authd(f):
elif value[0:7].lower() == "bearer ": elif value[0:7].lower() == "bearer ":
access_token = value[7:] access_token = value[7:]
if len(access_token) < 10: if len(access_token) < 10:
abort(400) error(400, "API token is too short")
token = APIToken.query.filter_by(access_token=access_token).first() token = APIToken.query.filter_by(access_token=access_token).first()
if token is None: if token is None:
abort(403) error(403, "Unknown API token")
else: else:
abort(403) abort(403, "Unsupported authentication method")
return f(token=token, *args, **kwargs) return f(token=token, *args, **kwargs)

View File

@@ -28,6 +28,9 @@ from app.querybuilder import QueryBuilder
@bp.route("/api/packages/") @bp.route("/api/packages/")
def packages(): def packages():
import sys
print("\n\n############", file=sys.stderr)
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.buildPackageQuery() query = qb.buildPackageQuery()
ver = qb.getMinetestVersion() ver = qb.getMinetestVersion()
@@ -140,16 +143,21 @@ def markdown():
@is_package_page @is_package_page
@is_api_authd @is_api_authd
def create_release(token, package): def create_release(token, package):
if not token:
error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
error(403, "You do not have the permission to approve releases")
json = request.json json = request.json
if json is None: if json is None:
return error(400, "JSON post data is required") error(400, "JSON post data is required")
for option in ["method", "title", "ref"]: for option in ["method", "title", "ref"]:
if json.get(option) is None: if json.get(option) is None:
return error(400, option + " is required in the POST data") error(400, option + " is required in the POST data")
if json["method"].lower() != "git": if json["method"].lower() != "git":
return error(400, "Release-creation methods other than git are not supported") error(400, "Release-creation methods other than git are not supported")
return handleCreateRelease(token, package, json["title"], json["ref"]) return handleCreateRelease(token, package, json["title"], json["ref"])

View File

@@ -1,12 +1,12 @@
from app.models import PackageRelease, db, Permission from app.models import PackageRelease, db, Permission
from app.tasks.importtasks import makeVCSRelease from app.tasks.importtasks import makeVCSRelease
from celery import uuid from celery import uuid
from flask import jsonify, make_response, url_for from flask import jsonify, abort, url_for
import datetime import datetime
def error(status, message): def error(status, message):
return make_response(jsonify({ "success": False, "error": message }), status) abort(status, jsonify({ "success": False, "error": message }))
def handleCreateRelease(token, package, title, ref): def handleCreateRelease(token, package, title, ref):

View File

@@ -124,7 +124,7 @@ def webhook():
return error(403, "Invalid authentication, couldn't validate API token") return error(403, "Invalid authentication, couldn't validate API token")
if not package.checkPerm(actual_token.owner, Permission.APPROVE_RELEASE): if not package.checkPerm(actual_token.owner, Permission.APPROVE_RELEASE):
return error(403, "Only trusted members can use webhooks") return error(403, "You do not have the permission to approve releases")
# #
# Check event # Check event

View File

@@ -44,7 +44,7 @@ def webhook():
return error(403, "Invalid authentication") return error(403, "Invalid authentication")
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE): if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
return error(403, "Only trusted members can use webhooks") return error(403, "You do not have the permission to approve releases")
# #
# Check event # Check event

View File

@@ -4,17 +4,23 @@ bp = Blueprint("homepage", __name__)
from app.models import * from app.models import *
import flask_menu as menu import flask_menu as menu
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
@bp.route("/") @bp.route("/")
@menu.register_menu(bp, ".", "Home") @menu.register_menu(bp, ".", "Home")
def home(): def home():
def join(query):
return query.options( \
joinedload(Package.license), \
joinedload(Package.media_license))
query = Package.query.filter_by(approved=True, soft_deleted=False) query = Package.query.filter_by(approved=True, soft_deleted=False)
count = query.count() count = query.count()
new = query.order_by(db.desc(Package.created_at)).limit(8).all() new = join(query.order_by(db.desc(Package.created_at))).limit(8).all()
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all() pop_mod = join(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 = join(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 = join(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(4).all()
downloads_result = db.session.query(func.sum(PackageRelease.downloads)).one_or_none() downloads_result = db.session.query(func.sum(PackageRelease.downloads)).one_or_none()
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0] downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
return render_template("index.html", count=count, downloads=downloads, \ return render_template("index.html", count=count, downloads=downloads, \

View File

@@ -0,0 +1,74 @@
# Content DB
# Copyright (C) 2020 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 Blueprint, make_response
from app.models import Package, PackageRelease, db, User, UserRank
from sqlalchemy.sql.expression import func
bp = Blueprint("metrics", __name__)
def generate_metrics(full=False):
def write_single_stat(name, help, type, value):
fmt = "# HELP {name} {help}\n# TYPE {name} {type}\n{name} {value}\n\n"
return fmt.format(name=name, help=help, type=type, value=value)
def gen_labels(labels):
pieces = [key + "=" + str(val) for key, val in labels.items()]
return (",").join(pieces)
def write_array_stat(name, help, type, data):
ret = ("# HELP {name} {help}\n# TYPE {name} {type}\n") \
.format(name=name, help=help, type=type)
for entry in data:
assert(len(entry) == 2)
ret += ("{name}{{{labels}}} {value}\n") \
.format(name=name, labels=gen_labels(entry[0]), value=entry[1])
return ret + "\n"
downloads_result = db.session.query(func.sum(PackageRelease.downloads)).one_or_none()
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
packages = Package.query.filter_by(approved=True, soft_deleted=False).count()
users = User.query.filter(User.rank != UserRank.NOT_JOINED).count()
ret = ""
ret += write_single_stat("contentdb_packages", "Total packages", "counter", packages)
ret += write_single_stat("contentdb_users", "Number of registered users", "counter", users)
ret += write_single_stat("contentdb_downloads", "Total downloads", "counter", downloads)
if full:
scores = Package.query.join(User).with_entities(User.username, Package.name, Package.score) \
.filter(Package.approved==True, Package.soft_deleted==False).all()
ret += write_array_stat("contentdb_package_score", "Package score", "gauge", \
[({ "author": score[0], "name": score[1] }, score[2]) for score in scores])
else:
score_result = db.session.query(func.sum(Package.score)).one_or_none()
score = 0 if not score_result or not score_result[0] else score_result[0]
ret += write_single_stat("contentdb_score", "Total package score", "gauge", score)
return ret
@bp.route("/metrics")
def metrics():
response = make_response(generate_metrics(), 200)
response.mimetype = "text/plain"
return response

View File

@@ -31,6 +31,7 @@ from wtforms import *
from wtforms.validators import * from wtforms.validators import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from sqlalchemy import or_, func from sqlalchemy import or_, func
from sqlalchemy.orm import joinedload, subqueryload
@menu.register_menu(bp, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' }) @menu.register_menu(bp, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' })
@@ -43,6 +44,11 @@ def list_all():
query = qb.buildPackageQuery() query = qb.buildPackageQuery()
title = qb.title title = qb.title
query = query.options( \
joinedload(Package.license), \
joinedload(Package.media_license), \
subqueryload(Package.tags))
if qb.lucky: if qb.lucky:
package = query.first() package = query.first()
if package: if package:

View File

@@ -1,9 +1,15 @@
title: Help title: Help
## Content
* [Package Tags](package_tags) * [Package Tags](package_tags)
* [Ranks and Permissions](ranks_permissions) * [Ranks and Permissions](ranks_permissions)
* [Content Ratings and Flags](content_flags) * [Content Ratings and Flags](content_flags)
* [Reporting Content](reporting) * [Reporting Content](reporting)
* [Top Packages Algorithm](top_packages) * [Top Packages Algorithm](top_packages)
## Developers
* [API](api) * [API](api)
* [Metrics](metrics)
* [Creating Releases using Webhooks](release_webhooks) * [Creating Releases using Webhooks](release_webhooks)

View File

@@ -9,6 +9,8 @@ Authentication is done using Bearer tokens:
You can use the `/api/whoami` to check authentication. You can use the `/api/whoami` to check authentication.
Tokens can be attained by visiting "API Tokens" on your profile page.
## Endpoints ## Endpoints
### Misc ### Misc
@@ -16,7 +18,7 @@ You can use the `/api/whoami` to check authentication.
* GET `/api/whoami/` - Json dictionary with the following keys: * GET `/api/whoami/` - Json dictionary with the following keys:
* `is_authenticated` - True on successful API authentication * `is_authenticated` - True on successful API authentication
* `username` - Username of the user authenticated as, null otherwise. * `username` - Username of the user authenticated as, null otherwise.
* 403 will be thrown on unsupported authentication type, invalid access token, or other errors. * 4xx status codes will be thrown on unsupported authentication type, invalid access token, or other errors.
### Packages ### Packages

View File

@@ -0,0 +1,16 @@
title: Prometheus Metrics
## What is Prometheus?
[Prometheus](https://prometheus.io) is an "open-source monitoring system with a
dimensional data model, flexible query language, efficient time series database
and modern alerting approach".
Prometheus Metrics can be accessed at [/metrics](/metrics).
## Metrics
* `contentdb_packages` - Total packages (counter).
* `contentdb_users` - Number of registered users (counter).
* `contentdb_downloads` - Total downloads (counter).
* `contentdb_score` - Total package score (gauge).

View File

@@ -17,10 +17,6 @@ The process is as follows:
3. The git host posts a webhook notification to ContentDB, using the API token assigned to it. 3. The git host posts a webhook notification to ContentDB, using the API token assigned to it.
4. ContentDB checks the API token and issues a new release. 4. ContentDB checks the API token and issues a new release.
<p class="alert alert-info">
This feature is in beta, and is only available for Trusted Members.
</p>
## Setting up ## Setting up
### GitHub (automatic) ### GitHub (automatic)

View File

@@ -1,16 +1,20 @@
title: Top Packages Algorithm title: Top Packages Algorithm
## Pseudo rolling average ## Score
A package's score is currently equal to a pseudo rolling average of downloads.
In the future, a package will also gain score through reviews.
## Pseudo rolling average of downloads
Every package loses 5% of its score every day. Every package loses 5% of its score every day.
An open source package will gain 1 score for each unique download, An open source package will gain 1 score for each unique download,
whereas a non-free package will only gain 0.1 score. whereas a non-free package will only gain 0.1 score.
A package currently only gains score through downloads. This metric aims to be roughly equivalent to the average downloads.
In the future, a package will also gain score through reviews.
## Seed using a legacy heuristic ## Seeded using a legacy heuristic
The scoring system was seeded (ie: the scores were initially set to) 20% of an The scoring system was seeded (ie: the scores were initially set to) 20% of an
arbitrary legacy heuristic that was previously used to rank packages. arbitrary legacy heuristic that was previously used to rank packages.
@@ -32,8 +36,14 @@ As said, this legacy score is no longer used when ranking mods.
It was only used to provide an initial score for the rolling average, It was only used to provide an initial score for the rolling average,
which was 20% of the above value. which was 20% of the above value.
## Manual adjustments
The admin occasionally reduces all packages by a set percentage to speed up
convergence. Convergence is when
## Transparency and Feedback ## Transparency and Feedback
You can see all scores using the [scores REST API](/api/scores/). You can see all scores using the [scores REST API](/api/scores/), or by
using the [Prometheus metrics](/help/metrics/) endpoint.
Consider [suggesting improvements](https://github.com/minetest/contentdb/issues/new?assignees=&labels=Policy&template=policy.md&title=). Consider [suggesting improvements](https://github.com/minetest/contentdb/issues/new?assignees=&labels=Policy&template=policy.md&title=).

View File

@@ -76,7 +76,7 @@ to change the name of the package, or your package won't be accepted.
We reserve the right to issue exceptions for this where we feel necessary. We reserve the right to issue exceptions for this where we feel necessary.
### 3.2 Mod Forks and Reimplementations ### 3.2. Mod Forks and Reimplementations
An exception to the above is that mods are allowed to have the same name as a An exception to the above is that mods are allowed to have the same name as a
mod if its a fork of that mod (or a close reimplementation). In real terms, it mod if its a fork of that mod (or a close reimplementation). In real terms, it
@@ -88,7 +88,7 @@ reimplementation of the mod that owns the name.
## 4. Licenses ## 4. Licenses
### 4.1 Allowed Licenses ### 4.1. Allowed Licenses
Please ensure that you correctly credit any resources (code, assets, or otherwise) Please ensure that you correctly credit any resources (code, assets, or otherwise)
that you have used in your package. that you have used in your package.
@@ -106,7 +106,7 @@ get around to adding it.
Please note that the definitions of "free" and "non-free" is the same as that Please note that the definitions of "free" and "non-free" is the same as that
of the [Free Software Foundation](https://www.gnu.org/philosophy/free-sw.en.html). of the [Free Software Foundation](https://www.gnu.org/philosophy/free-sw.en.html).
### 4.2 Recommended Licenses ### 4.2. Recommended Licenses
It is highly recommended that you use a free and open source software license. It is highly recommended that you use a free and open source software license.
FOSS licenses result in a sharing community and will increase the number of potential users your package has. FOSS licenses result in a sharing community and will increase the number of potential users your package has.

View File

@@ -147,7 +147,7 @@ class User(db.Model, UserMixin):
notifications = db.relationship("Notification", primaryjoin="User.id==Notification.user_id") notifications = db.relationship("Notification", primaryjoin="User.id==Notification.user_id")
# causednotifs = db.relationship("Notification", backref="causer", lazy="dynamic") # causednotifs = db.relationship("Notification", backref="causer", lazy="dynamic")
packages = db.relationship("Package", backref="author", lazy="dynamic") packages = db.relationship("Package", backref=db.backref("author", lazy="joined"), lazy="dynamic")
requests = db.relationship("EditRequest", backref="author", lazy="dynamic") requests = db.relationship("EditRequest", backref="author", lazy="dynamic")
threads = db.relationship("Thread", backref="author", lazy="dynamic") threads = db.relationship("Thread", backref="author", lazy="dynamic")
tokens = db.relationship("APIToken", backref="owner", lazy="dynamic") tokens = db.relationship("APIToken", backref="owner", lazy="dynamic")
@@ -437,12 +437,12 @@ class Package(db.Model):
forums = db.Column(db.Integer, nullable=True) forums = db.Column(db.Integer, nullable=True)
provides = db.relationship("MetaPackage", \ provides = db.relationship("MetaPackage", \
secondary=provides, lazy="subquery", order_by=db.asc("name"), \ secondary=provides, lazy="select", order_by=db.asc("name"), \
backref=db.backref("packages", lazy="dynamic", order_by=db.desc("score"))) backref=db.backref("packages", lazy="dynamic", order_by=db.desc("score")))
dependencies = db.relationship("Dependency", backref="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id]) dependencies = db.relationship("Dependency", backref="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id])
tags = db.relationship("Tag", secondary=tags, lazy="subquery", tags = db.relationship("Tag", secondary=tags, lazy="select",
backref=db.backref("packages", lazy=True)) backref=db.backref("packages", lazy=True))
releases = db.relationship("PackageRelease", backref="package", releases = db.relationship("PackageRelease", backref="package",
@@ -578,13 +578,25 @@ class Package(db.Model):
screenshot = self.screenshots.filter_by(approved=True).order_by(db.asc(PackageScreenshot.id)).first() screenshot = self.screenshots.filter_by(approved=True).order_by(db.asc(PackageScreenshot.id)).first()
return screenshot.getThumbnailURL(level) if screenshot is not None else None return screenshot.getThumbnailURL(level) if screenshot is not None else None
def getMainScreenshotURL(self): def getMainScreenshotURL(self, absolute=False):
screenshot = self.screenshots.filter_by(approved=True).order_by(db.asc(PackageScreenshot.id)).first() screenshot = self.screenshots.filter_by(approved=True).order_by(db.asc(PackageScreenshot.id)).first()
return screenshot.url if screenshot is not None else None if screenshot is None:
return None
def getDetailsURL(self): if absolute:
return url_for("packages.view", from app.utils import abs_url
author=self.author.username, name=self.name) return abs_url(screenshot.url)
else:
return screenshot.url
def getDetailsURL(self, absolute=False):
if absolute:
from app.utils import abs_url_for
return abs_url_for("packages.view",
author=self.author.username, name=self.name)
else:
return url_for("packages.view",
author=self.author.username, name=self.name)
def getEditURL(self): def getEditURL(self):
return url_for("packages.create_edit", return url_for("packages.create_edit",
@@ -835,12 +847,11 @@ class PackageRelease(db.Model):
name=self.package.name, name=self.package.name,
id=self.id) id=self.id)
def __init__(self): def __init__(self):
self.releaseDate = datetime.datetime.now() self.releaseDate = datetime.datetime.now()
def approve(self, user): def approve(self, user):
if self.package.approved and \ if self.package.approved or \
not self.package.checkPerm(user, Permission.APPROVE_RELEASE): not self.package.checkPerm(user, Permission.APPROVE_RELEASE):
return False return False
@@ -1135,3 +1146,8 @@ class ForumTopic(db.Model):
# Setup Flask-User # Setup Flask-User
user_manager = UserManager(app, db, User) user_manager = UserManager(app, db, User)
if app.config.get("LOG_SQL"):
import logging
logging.basicConfig()
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)

View File

@@ -1,6 +1,6 @@
from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease, User, Tag from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease, User, Tag
from .models import tags as Tags from .models import tags as Tags
from .utils import isNo, isYes from .utils import isNo, isYes, get_int_or_abort
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_
@@ -61,7 +61,7 @@ class QueryBuilder:
if not self.protocol_version: if not self.protocol_version:
return None return None
self.protocol_version = int(self.protocol_version) self.protocol_version = get_int_or_abort(self.protocol_version)
version = MinetestRelease.query.filter(MinetestRelease.protocol>=self.protocol_version).first() version = MinetestRelease.query.filter(MinetestRelease.protocol>=self.protocol_version).first()
if version is not None: if version is not None:
return version.id return version.id
@@ -139,7 +139,6 @@ class QueryBuilder:
query = query.order_by(db.desc(ForumTopic.views)) query = query.order_by(db.desc(ForumTopic.views))
elif self.order_by == "date": elif self.order_by == "date":
query = query.order_by(db.asc(ForumTopic.created_at)) query = query.order_by(db.asc(ForumTopic.created_at))
sort_by = "date"
if self.search: if self.search:
query = query.filter(ForumTopic.title.ilike('%' + self.search + '%')) query = query.filter(ForumTopic.title.ilike('%' + self.search + '%'))

View File

@@ -3,12 +3,54 @@
@import "packagegrid.scss"; @import "packagegrid.scss";
@import "comments.scss"; @import "comments.scss";
h1 {
font-size: 2em;
font-weight: bold;
margin: 0 0 0.5em;
letter-spacing: .05em
}
h2 {
font-size: 1.8em;
font-weight: bold;
color: white;
margin: 1.5em 0 1em;
letter-spacing: .05em;
padding: 0 0 0.5em 0;
border-bottom: 1px solid #444;
}
h3 {
font-size: 1.3em;
font-weight: bold;
color: white;
margin: 1.5em 0 1em;
letter-spacing: .05em
}
p, .content li {
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased !important;
-moz-font-smoothing: antialiased !important;
text-rendering: optimizelegibility !important;
letter-spacing: .03em;
line-height: 1.6em;
}
pre code {
display: block;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
padding: 0.75rem 1.25rem;
border-radius: 0.25rem;
}
.dropdown-menu { .dropdown-menu {
margin-top: 0; margin-top: 0;
} }
.dropdown:hover .dropdown-menu { .dropdown:hover .dropdown-menu {
display: block; display: block;
} }
.nav-link > img { .nav-link > img {
@@ -57,8 +99,15 @@
text-decoration: none; text-decoration: none;
} }
.card .table { .card {
margin-bottom: 0; .card-header {
margin: 0;
font-size: 100%;
font-weight: normal;
}
.table {
margin-bottom: 0;
}
} }
.btn-download { .btn-download {

View File

@@ -38,35 +38,43 @@ li.d-flex {
bottom: 0; bottom: 0;
left: 0; left: 0;
padding: 1em; padding: 1em;
h3 {
color: white;
font-size: 120%;
font-weight: bold;
margin: 0;
padding: 0;
}
small {
color: #ddd;
font-size: 75%;
font-weight: bold;
}
p {
display: none;
color: #ddd;
font-weight: normal;
}
} }
.packagegridinfo h3 { .packagetile a:hover {
color: white; .packagegridinfo {
font-size: 120%; top: 0;
font-weight: bold; }
}
.packagegridinfo small { h3 {
color: #ddd; margin-bottom: 0.5em;
font-size: 75%; }
font-weight: bold;
}
.packagegridinfo p { p {
display: none; display: block;
color: #ddd; }
font-weight: normal;
}
.packagetile a:hover .packagegridinfo { .packagegridscrub {
top: 0; top: 0;
} background: rgba(0,0,0,0.8);
}
.packagetile a:hover p {
display: block;
}
.packagetile a:hover .packagegridscrub {
top: 0;
background: rgba(0,0,0,0.8);
} }

View File

@@ -7,7 +7,7 @@
<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=10"> <link rel="stylesheet" type="text/css" href="/static/custom.css?v=11">
<link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" /> <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="shortcut icon" href="/favicon-16.png" sizes="16x16">
<link rel="icon" href="/favicon-128.png" sizes="128x128"> <link rel="icon" href="/favicon-128.png" sizes="128x128">

View File

@@ -4,8 +4,10 @@
{{ page['title'] }} {{ page['title'] }}
{% endblock %} {% endblock %}
{% block content %} {% block container %}
{% if not page["no_h1"] %}<h1>{{ page['title'] }}</h1>{% endif %} <main class="container mt-4 content">
{% if not page["no_h1"] %}<h1>{{ page['title'] }}</h1>{% endif %}
{{ page.html | safe }} {{ page.html | safe }}
</main>
{% endblock %} {% endblock %}

View File

@@ -6,6 +6,16 @@
{{ package.title }} {{ package.title }}
{% endblock %} {% endblock %}
{% block headextra %}
<meta name="og:title" content="{{ package.title }}"/>
<meta name="og:description" content="{{ package.short_desc }}"/>
<meta name="description" content="{{ package.short_desc }}"/>
<meta name="og:url" content="{{ package.getDetailsURL(absolute=True) }}"/>
{% if package.getMainScreenshotURL() %}
<meta name="og:image" content="{{ package.getMainScreenshotURL(absolute=True) }}"/>
{% endif %}
{% endblock %}
{% block container %} {% block container %}
{% if not package.license.is_foss and not package.media_license.is_foss and package.type != package.type.TXP %} {% if not package.license.is_foss and not package.media_license.is_foss and package.type != package.type.TXP %}
{% set package_warning="Non-free code and media" %} {% set package_warning="Non-free code and media" %}

View File

@@ -21,11 +21,15 @@ from flask_login import login_user, logout_user
from .models import * from .models import *
from . import app from . import app
import random, string, os, imghdr import random, string, os, imghdr
from urllib.parse import urljoin
def abs_url_for(path, **kwargs): def abs_url_for(path, **kwargs):
scheme = "https" if app.config["BASE_URL"][:5] == "https" else "http" scheme = "https" if app.config["BASE_URL"][:5] == "https" else "http"
return url_for(path, _external=True, _scheme=scheme, **kwargs) return url_for(path, _external=True, _scheme=scheme, **kwargs)
def abs_url(path):
return urljoin(app.config["BASE_URL"], path)
def get_int_or_abort(v, default=None): def get_int_or_abort(v, default=None):
try: try:
return int(v or default) return int(v or default)

View File

@@ -32,6 +32,9 @@ MAIL_UTILS_ERROR_SEND_TO = [""]
UPLOAD_DIR = "/var/cdb/uploads/" UPLOAD_DIR = "/var/cdb/uploads/"
THUMBNAIL_DIR = "/var/cdb/thumbnails/" THUMBNAIL_DIR = "/var/cdb/thumbnails/"
TEMPLATES_AUTO_RELOAD = False
LOG_SQL = False
LANGUAGES = { LANGUAGES = {
'en': 'English', 'en': 'English',
} }

View File

@@ -60,3 +60,12 @@ services:
- 5124:5124 - 5124:5124
depends_on: depends_on:
- redis - redis
exporter:
image: ovalmoney/celery-exporter
env_file:
- config.env
ports:
- 5125:9540
depends_on:
- redis