Compare commits

..

8 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
13 changed files with 169 additions and 25 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

@@ -143,19 +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): if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
return error(403, "You do not have the permission to approve releases") 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

@@ -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

@@ -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

@@ -1,14 +1,18 @@
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.
## Seeded using a legacy heuristic ## Seeded using a legacy heuristic
@@ -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

@@ -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

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

@@ -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

@@ -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