diff --git a/app/blueprints/api/__init__.py b/app/blueprints/api/__init__.py
index 5092f21..03adaf8 100644
--- a/app/blueprints/api/__init__.py
+++ b/app/blueprints/api/__init__.py
@@ -14,87 +14,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-
-from flask import *
-from flask_user import *
-from app.models import *
-from app.utils import is_package_page
-from app.querybuilder import QueryBuilder
+from flask import Blueprint
bp = Blueprint("api", __name__)
-@bp.route("/api/packages/")
-def packages():
- qb = QueryBuilder(request.args)
- query = qb.buildPackageQuery()
- ver = qb.getMinetestVersion()
-
- pkgs = [package.getAsDictionaryShort(current_app.config["BASE_URL"], version=ver) \
- for package in query.all()]
- return jsonify(pkgs)
-
-
-@bp.route("/api/packages///")
-@is_package_page
-def package(package):
- return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
-
-
-@bp.route("/api/packages///dependencies/")
-@is_package_page
-def package_dependencies(package):
- ret = []
-
- for dep in package.dependencies:
- name = None
- fulfilled_by = None
-
- if dep.package:
- name = dep.package.name
- fulfilled_by = [ dep.package.getAsDictionaryKey() ]
-
- elif dep.meta_package:
- name = dep.meta_package.name
- fulfilled_by = [ pkg.getAsDictionaryKey() for pkg in dep.meta_package.packages]
-
- else:
- raise "Malformed dependency"
-
- ret.append({
- "name": name,
- "is_optional": dep.optional,
- "packages": fulfilled_by
- })
-
- return jsonify(ret)
-
-
-@bp.route("/api/topics/")
-def topics():
- qb = QueryBuilder(request.args)
- query = qb.buildTopicQuery(show_added=True)
- return jsonify([t.getAsDictionary() for t in query.all()])
-
-
-@bp.route("/api/topic_discard/", methods=["POST"])
-@login_required
-def topic_set_discard():
- tid = request.args.get("tid")
- discard = request.args.get("discard")
- if tid is None or discard is None:
- abort(400)
-
- topic = ForumTopic.query.get(tid)
- if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
- abort(403)
-
- topic.discarded = discard == "true"
- db.session.commit()
-
- return jsonify(topic.getAsDictionary())
-
-
-@bp.route("/api/minetest_versions/")
-def versions():
- return jsonify([{ "name": rel.name, "protocol_version": rel.protocol }\
- for rel in MinetestRelease.query.all() if rel.getActual() is not None])
+from . import tokens, endpoints
diff --git a/app/blueprints/api/auth.py b/app/blueprints/api/auth.py
new file mode 100644
index 0000000..6eeadde
--- /dev/null
+++ b/app/blueprints/api/auth.py
@@ -0,0 +1,42 @@
+# Content DB
+# Copyright (C) 2019 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 .
+
+from flask import request, make_response, jsonify, abort
+from app.models import APIToken
+from functools import wraps
+
+def is_api_authd(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ token = None
+
+ value = request.headers.get("authorization")
+ if value is None:
+ pass
+ elif value[0:7].lower() == "bearer ":
+ access_token = value[7:]
+ if len(access_token) < 10:
+ abort(400)
+
+ token = APIToken.query.filter_by(access_token=access_token).first()
+ if token is None:
+ abort(403)
+ else:
+ abort(403)
+
+ return f(token=token, *args, **kwargs)
+
+ return decorated_function
diff --git a/app/blueprints/api/endpoints.py b/app/blueprints/api/endpoints.py
new file mode 100644
index 0000000..e37454f
--- /dev/null
+++ b/app/blueprints/api/endpoints.py
@@ -0,0 +1,109 @@
+# 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 .
+
+
+from flask import *
+from flask_user import *
+from . import bp
+from .auth import is_api_authd
+from app.models import *
+from app.utils import is_package_page
+from app.querybuilder import QueryBuilder
+
+@bp.route("/api/packages/")
+def packages():
+ qb = QueryBuilder(request.args)
+ query = qb.buildPackageQuery()
+ ver = qb.getMinetestVersion()
+
+ pkgs = [package.getAsDictionaryShort(current_app.config["BASE_URL"], version=ver) \
+ for package in query.all()]
+ return jsonify(pkgs)
+
+
+@bp.route("/api/packages///")
+@is_package_page
+def package(package):
+ return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
+
+
+@bp.route("/api/packages///dependencies/")
+@is_package_page
+def package_dependencies(package):
+ ret = []
+
+ for dep in package.dependencies:
+ name = None
+ fulfilled_by = None
+
+ if dep.package:
+ name = dep.package.name
+ fulfilled_by = [ dep.package.getAsDictionaryKey() ]
+
+ elif dep.meta_package:
+ name = dep.meta_package.name
+ fulfilled_by = [ pkg.getAsDictionaryKey() for pkg in dep.meta_package.packages]
+
+ else:
+ raise "Malformed dependency"
+
+ ret.append({
+ "name": name,
+ "is_optional": dep.optional,
+ "packages": fulfilled_by
+ })
+
+ return jsonify(ret)
+
+
+@bp.route("/api/topics/")
+def topics():
+ qb = QueryBuilder(request.args)
+ query = qb.buildTopicQuery(show_added=True)
+ return jsonify([t.getAsDictionary() for t in query.all()])
+
+
+@bp.route("/api/topic_discard/", methods=["POST"])
+@login_required
+def topic_set_discard():
+ tid = request.args.get("tid")
+ discard = request.args.get("discard")
+ if tid is None or discard is None:
+ abort(400)
+
+ topic = ForumTopic.query.get(tid)
+ if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
+ abort(403)
+
+ topic.discarded = discard == "true"
+ db.session.commit()
+
+ return jsonify(topic.getAsDictionary())
+
+
+@bp.route("/api/minetest_versions/")
+def versions():
+ return jsonify([{ "name": rel.name, "protocol_version": rel.protocol }\
+ for rel in MinetestRelease.query.all() if rel.getActual() is not None])
+
+
+@bp.route("/api/whoami/")
+@is_api_authd
+def whoami(token):
+ if token is None:
+ return jsonify({ "is_authenticated": False, "username": None })
+ else:
+ return jsonify({ "is_authenticated": True, "username": token.owner.username })
diff --git a/app/blueprints/api/tokens.py b/app/blueprints/api/tokens.py
new file mode 100644
index 0000000..3f6b151
--- /dev/null
+++ b/app/blueprints/api/tokens.py
@@ -0,0 +1,141 @@
+# 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 .
+
+
+from flask import render_template, redirect, request, session, url_for
+from flask_user import login_required, current_user
+from . import bp
+from app.models import db, User, APIToken, Package, Permission
+from app.utils import randomString
+from app.querybuilder import QueryBuilder
+
+from flask_wtf import FlaskForm
+from wtforms import *
+from wtforms.validators import *
+from wtforms.ext.sqlalchemy.fields import QuerySelectField
+
+class CreateAPIToken(FlaskForm):
+ name = StringField("Name", [InputRequired(), Length(1, 30)])
+ submit = SubmitField("Save")
+
+
+@bp.route("/users//tokens/")
+@login_required
+def list_tokens(username):
+ user = User.query.filter_by(username=username).first()
+ if user is None:
+ abort(404)
+
+ if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
+ abort(403)
+
+ return render_template("api/list_tokens.html", user=user)
+
+
+@bp.route("/users//tokens/new/", methods=["GET", "POST"])
+@bp.route("/users//tokens//edit/", methods=["GET", "POST"])
+@login_required
+def create_edit_token(username, id=None):
+ user = User.query.filter_by(username=username).first()
+ if user is None:
+ abort(404)
+
+ if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
+ abort(403)
+
+ is_new = id is None
+
+ token = None
+ access_token = None
+ if not is_new:
+ token = APIToken.query.get(id)
+ if token is None:
+ abort(404)
+ elif token.owner != user:
+ abort(403)
+
+ access_token = session.pop("token_" + str(id), None)
+
+ form = CreateAPIToken(formdata=request.form, obj=token)
+ if request.method == "POST" and form.validate():
+ if is_new:
+ token = APIToken()
+ token.owner = user
+ token.access_token = randomString(32)
+
+ form.populate_obj(token)
+ db.session.add(token)
+
+ db.session.commit() # save
+
+ # Store token so it can be shown in the edit page
+ session["token_" + str(token.id)] = token.access_token
+
+ return redirect(url_for("api.create_edit_token", username=username, id=token.id))
+
+ return render_template("api/create_edit_token.html", user=user, form=form, token=token, access_token=access_token)
+
+
+@bp.route("/users//tokens//reset/", methods=["POST"])
+@login_required
+def reset_token(username, id):
+ user = User.query.filter_by(username=username).first()
+ if user is None:
+ abort(404)
+
+ if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
+ abort(403)
+
+ is_new = id is None
+
+ token = APIToken.query.get(id)
+ if token is None:
+ abort(404)
+ elif token.owner != user:
+ abort(403)
+
+ token.access_token = randomString(32)
+
+ db.session.commit() # save
+
+ # Store token so it can be shown in the edit page
+ session["token_" + str(token.id)] = token.access_token
+
+ return redirect(url_for("api.create_edit_token", username=username, id=token.id))
+
+
+@bp.route("/users//tokens//delete/", methods=["POST"])
+@login_required
+def delete_token(username, id):
+ user = User.query.filter_by(username=username).first()
+ if user is None:
+ abort(404)
+
+ if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
+ abort(403)
+
+ is_new = id is None
+
+ token = APIToken.query.get(id)
+ if token is None:
+ abort(404)
+ elif token.owner != user:
+ abort(403)
+
+ db.session.delete(token)
+ db.session.commit()
+
+ return redirect(url_for("api.list_tokens", username=username))
diff --git a/app/flatpages/help.md b/app/flatpages/help.md
index 553111d..0087f26 100644
--- a/app/flatpages/help.md
+++ b/app/flatpages/help.md
@@ -4,3 +4,4 @@ title: Help
* [Ranks and Permissions](ranks_permissions)
* [Content Ratings and Flags](content_flags)
* [Reporting Content](reporting)
+* [API](api)
diff --git a/app/flatpages/help/api.md b/app/flatpages/help/api.md
new file mode 100644
index 0000000..95e23d2
--- /dev/null
+++ b/app/flatpages/help/api.md
@@ -0,0 +1,51 @@
+title: API
+
+## Authentication
+
+Not all endpoints require authentication.
+Authentication is done using Bearer tokens:
+
+ Authorization: Bearer YOURTOKEN
+
+You can use the `/api/whoami` to check authentication.
+
+## Endpoints
+
+### Misc
+
+* GET `/api/whoami/` - Json dictionary with the following keys:
+ * `is_authenticated` - True on successful API authentication
+ * `username` - Username of the user authenticated as, null otherwise.
+ * 403 will be thrown on unsupported authentication type, invalid access token, or other errors.
+
+### Packages
+
+* GET `/api/packages/` - See [Package Queries](#package-queries)
+* GET `/api/packages///`
+
+### Topics
+
+* GET `/api/topics/` - Supports [Package Queries](#package-queries), and the following two options:
+ * `show_added` - Show topics which exist as packages, default true.
+ * `show_discarded` - Show topics which have been marked as outdated, default false.
+
+### Minetest
+
+* GET `/api/minetest_versions/`
+
+
+## Package Queries
+
+Example:
+
+ /api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore
+
+Supported query parameters:
+
+* `type` - Package types (`mod`, `game`, `txp`).
+* `q` - Query string
+* `random` - When present, enable random ordering and ignore `sort`.
+* `hide` - Hide content based on [Content Flags](content_flags).
+* `sort` - Sort by (`name`, `views`, `date`, `score`).
+* `order` - Sort ascending (`Asc`) or descending (`desc`).
+* `protocol_version` - Only show packages supported by this Minetest protocol version.
diff --git a/app/flatpages/help/ranks_permissions.md b/app/flatpages/help/ranks_permissions.md
index 9252930..1740c55 100644
--- a/app/flatpages/help/ranks_permissions.md
+++ b/app/flatpages/help/ranks_permissions.md
@@ -219,6 +219,21 @@ title: Ranks and Permissions
✓
✓
+
+
Create Token
+
+
+
✓
+
+
✓
+
+
✓
+
+
✓
+
✓2
+
✓
+
✓
+
Set Rank
diff --git a/app/models.py b/app/models.py
index 9a80873..736a0dc 100644
--- a/app/models.py
+++ b/app/models.py
@@ -92,6 +92,7 @@ class Permission(enum.Enum):
CREATE_THREAD = "CREATE_THREAD"
UNAPPROVE_PACKAGE = "UNAPPROVE_PACKAGE"
TOPIC_DISCARD = "TOPIC_DISCARD"
+ CREATE_TOKEN = "CREATE_TOKEN"
# Only return true if the permission is valid for *all* contexts
# See Package.checkPerm for package-specific contexts
@@ -142,6 +143,7 @@ class User(db.Model, UserMixin):
packages = db.relationship("Package", backref="author", lazy="dynamic")
requests = db.relationship("EditRequest", backref="author", lazy="dynamic")
threads = db.relationship("Thread", backref="author", lazy="dynamic")
+ tokens = db.relationship("APIToken", backref="owner", lazy="dynamic")
replies = db.relationship("ThreadReply", backref="author", lazy="dynamic")
def __init__(self, username, active=False, email=None, password=None):
@@ -183,6 +185,11 @@ class User(db.Model, UserMixin):
return user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.CHANGE_EMAIL:
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank))
+ elif perm == Permission.CREATE_TOKEN:
+ if user == self:
+ return user.rank.atLeast(UserRank.MEMBER)
+ else:
+ return user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank)
else:
raise Exception("Permission {} is not related to users".format(perm.name))
@@ -776,6 +783,16 @@ class PackageScreenshot(db.Model):
return self.url.replace("/uploads/", ("/thumbnails/{:d}/").format(level))
+class APIToken(db.Model):
+ id = db.Column(db.Integer, primary_key=True)
+ access_token = db.Column(db.String(34), unique=True)
+ name = db.Column(db.String(100), nullable=False)
+ owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
+ created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
+
+ def canOperateOnPackage(self, package):
+ return packages.count() == 0 or package in packages
+
class EditRequest(db.Model):
id = db.Column(db.Integer, primary_key=True)
diff --git a/app/templates/api/create_edit_token.html b/app/templates/api/create_edit_token.html
new file mode 100644
index 0000000..582cb94
--- /dev/null
+++ b/app/templates/api/create_edit_token.html
@@ -0,0 +1,53 @@
+{% extends "base.html" %}
+
+{% block title %}
+ {% if token %}
+ {{ _("Edit - %(name)s", name=token.name) }}
+ {% else %}
+ {{ _("Create API Token") }}
+ {% endif %}
+{% endblock %}
+
+{% from "macros/forms.html" import render_field, render_submit_field, render_radio_field %}
+
+{% block content %}
+ {% if token %}
+
+ {% endif %}
+
+
{{ self.title() }}
+
+
+ {{ _("Use carefully, as you may be held responsible for any damage caused by rogue scripts") }}
+
+
+ {% if token %}
+
+
{{ _("Access Token") }}
+
+
+ For security reasons, access tokens will only be shown once.
+ Reset the token if it is lost.
+