]> git.lizzy.rs Git - cheatdb.git/commitdiff
Add API Token creation
authorrubenwardy <rw@rubenwardy.com>
Fri, 22 Nov 2019 14:33:22 +0000 (14:33 +0000)
committerrubenwardy <rw@rubenwardy.com>
Wed, 27 Nov 2019 01:06:58 +0000 (01:06 +0000)
12 files changed:
app/blueprints/api/__init__.py
app/blueprints/api/auth.py [new file with mode: 0644]
app/blueprints/api/endpoints.py [new file with mode: 0644]
app/blueprints/api/tokens.py [new file with mode: 0644]
app/flatpages/help.md
app/flatpages/help/api.md [new file with mode: 0644]
app/flatpages/help/ranks_permissions.md
app/models.py
app/templates/api/create_edit_token.html [new file with mode: 0644]
app/templates/api/list_tokens.html [new file with mode: 0644]
app/templates/users/profile.html
migrations/versions/fd25bf3e57c3_.py [new file with mode: 0644]

index 5092f21db770cf3956624054b3997b73e22b9b54..03adaf83370310dc6e18144c2e761c7edb03a7a4 100644 (file)
 # 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.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/<author>/<name>/")
-@is_package_page
-def package(package):
-       return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
-
-
-@bp.route("/api/packages/<author>/<name>/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 (file)
index 0000000..6eeadde
--- /dev/null
@@ -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 <https://www.gnu.org/licenses/>.
+
+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 (file)
index 0000000..e37454f
--- /dev/null
@@ -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 <https://www.gnu.org/licenses/>.
+
+
+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/<author>/<name>/")
+@is_package_page
+def package(package):
+       return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
+
+
+@bp.route("/api/packages/<author>/<name>/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 (file)
index 0000000..3f6b151
--- /dev/null
@@ -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 <https://www.gnu.org/licenses/>.
+
+
+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/<username>/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/<username>/tokens/new/", methods=["GET", "POST"])
+@bp.route("/users/<username>/tokens/<int:id>/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/<username>/tokens/<int:id>/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/<username>/tokens/<int:id>/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))
index 553111d53006ad88a515111cfa2f16004b52cc0d..0087f261a878cf2a8568980f5ecf9e4c1d5563ba 100644 (file)
@@ -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 (file)
index 0000000..95e23d2
--- /dev/null
@@ -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/<username>/<name>/`
+
+### 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.
index 92529306f6551fcb9d66923c9602b2576d0b2efb..1740c5515e7713b67f76dbabdaa98f0413fe5487 100644 (file)
@@ -219,6 +219,21 @@ title: Ranks and Permissions
                        <th>✓</th> <!-- admin -->
                        <th>✓</th>
                </tr>
+               <tr>
+                       <td>Create Token</td>
+                       <th></th> <!-- new -->
+                       <th></th>
+                       <th>✓</th> <!-- member -->
+                       <th></th>
+                       <th>✓</th> <!-- trusted member -->
+                       <th></th>
+                       <th>✓</th> <!-- editor -->
+                       <th></th>
+                       <th>✓</th> <!-- moderator -->
+                       <th>✓<sup>2</sup></th>
+                       <th>✓</th> <!-- admin -->
+                       <th>✓</th>
+               </tr>
                <tr>
                        <td>Set Rank</td>
                        <th></th> <!-- new -->
index 9a80873ecc5a025fe60e20b971941dada7106fe3..736a0dce8cbd6315490e523f5d86b0963e6c1854 100644 (file)
@@ -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 (file)
index 0000000..582cb94
--- /dev/null
@@ -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 %}
+               <form class="float-right" method="POST" action="{{ url_for('api.delete_token', username=token.owner.username, id=token.id) }}">
+                       <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
+                       <input class="btn btn-danger" type="submit" value="Delete">
+               </form>
+       {% endif %}
+
+       <h1 class="mt-0">{{ self.title() }}</h1>
+
+       <div class="alert alert-warning">
+               {{ _("Use carefully, as you may be held responsible for any damage caused by rogue scripts") }}
+       </div>
+
+       {% if token %}
+               <div class="card mb-3">
+                       <div class="card-header">{{ _("Access Token") }}</div>
+                       <div class="card-body">
+                               <p>
+                                       For security reasons, access tokens will only be shown once.
+                                       Reset the token if it is lost.
+                               </p>
+                               {% if access_token %}
+                                       <input class="form-control my-3" type="text" readonly value="{{ access_token }}" class="form-control">
+                               {% endif %}
+                               <form method="POST" action="{{ url_for('api.reset_token', username=token.owner.username, id=token.id) }}">
+                                       <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
+                                       <input class="btn btn-primary" type="submit" value="Reset">
+                               </form>
+                       </div>
+               </div>
+       {% endif %}
+
+       <form method="POST" action="" enctype="multipart/form-data">
+               {{ form.hidden_tag() }}
+
+               {{ render_field(form.name, placeholder="Human readable") }}
+
+               {{ render_submit_field(form.submit) }}
+       </form>
+{% endblock %}
diff --git a/app/templates/api/list_tokens.html b/app/templates/api/list_tokens.html
new file mode 100644 (file)
index 0000000..b2be8ce
--- /dev/null
@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+
+{% block title %}
+       {{ _("List tokens for %(username)s", username=user.username) }}
+{% endblock %}
+
+
+{% block content %}
+       <a class="btn btn-primary float-right" href="{{ url_for('api.create_edit_token', username=user.username) }}">Create</a>
+       <h1 class="mt-0">{{ self.title() }}</h1>
+
+       <ul>
+               {% for token in user.tokens %}
+                       <li>
+                               <a href="{{ url_for('api.create_edit_token', username=user.username, id=token.id) }}">{{ token.name }}</a>
+                       </li>
+               {% else %}
+                       <li>
+                               <i>No tokens created</i>
+                       </li>
+               {% endfor %}
+       </ul>
+{% endblock %}
index d1edf541bd0ed5f5e8ffa2b4d63554065c728e14..bd4875d3e9d47aac0bf29cfdd36277aeabe604d3 100644 (file)
                                                                </td>
                                                        </tr>
                                                {% endif %}
+                                               {% if user.checkPerm(current_user, "CREATE_TOKEN") %}
+                                               <tr>
+                                                       <td>API Tokens:</td>
+                                                       <td>
+                                                               <a href="{{ url_for('api.list_tokens', username=user.username) }}">Manage</a>
+                                                               <span class="badge badge-primary">{{ user.tokens.count() }}</span>
+                                                       </td>
+                                               </tr>
+                                               {% endif %}
                                        </table>
                                </div>
                        </div>
diff --git a/migrations/versions/fd25bf3e57c3_.py b/migrations/versions/fd25bf3e57c3_.py
new file mode 100644 (file)
index 0000000..ec6e56f
--- /dev/null
@@ -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 ###