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. +

+ {% if access_token %} + + {% endif %} +
+ + +
+
+
+ {% endif %} + +
+ {{ form.hidden_tag() }} + + {{ render_field(form.name, placeholder="Human readable") }} + + {{ render_submit_field(form.submit) }} +
+{% endblock %} diff --git a/app/templates/api/list_tokens.html b/app/templates/api/list_tokens.html new file mode 100644 index 0000000..b2be8ce --- /dev/null +++ b/app/templates/api/list_tokens.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %} + {{ _("List tokens for %(username)s", username=user.username) }} +{% endblock %} + + +{% block content %} + Create +

{{ self.title() }}

+ +
    + {% for token in user.tokens %} +
  • + {{ token.name }} +
  • + {% else %} +
  • + No tokens created +
  • + {% endfor %} +
+{% endblock %} diff --git a/app/templates/users/profile.html b/app/templates/users/profile.html index d1edf54..bd4875d 100644 --- a/app/templates/users/profile.html +++ b/app/templates/users/profile.html @@ -127,6 +127,15 @@ {% endif %} + {% if user.checkPerm(current_user, "CREATE_TOKEN") %} + + API Tokens: + + Manage + {{ user.tokens.count() }} + + + {% endif %} diff --git a/migrations/versions/fd25bf3e57c3_.py b/migrations/versions/fd25bf3e57c3_.py new file mode 100644 index 0000000..ec6e56f --- /dev/null +++ b/migrations/versions/fd25bf3e57c3_.py @@ -0,0 +1,37 @@ +"""empty message + +Revision ID: fd25bf3e57c3 +Revises: d6ae9682c45f +Create Date: 2019-11-26 23:43:47.476346 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'fd25bf3e57c3' +down_revision = 'd6ae9682c45f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('api_token', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('access_token', sa.String(length=34), nullable=True), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('owner_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('access_token') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('api_token') + # ### end Alembic commands ###