mirror of
https://github.com/minetest/contentdb.git
synced 2025-01-08 22:17:34 +01:00
Add API Token creation
This commit is contained in:
parent
cb5451fe5d
commit
4ce388c8aa
@ -14,87 +14,8 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
from flask import *
|
|
||||||
from flask_user import *
|
|
||||||
from app.models import *
|
|
||||||
from app.utils import is_package_page
|
|
||||||
from app.querybuilder import QueryBuilder
|
|
||||||
|
|
||||||
bp = Blueprint("api", __name__)
|
bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
@bp.route("/api/packages/")
|
from . import tokens, endpoints
|
||||||
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])
|
|
||||||
|
42
app/blueprints/api/auth.py
Normal file
42
app/blueprints/api/auth.py
Normal file
@ -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
|
109
app/blueprints/api/endpoints.py
Normal file
109
app/blueprints/api/endpoints.py
Normal file
@ -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 })
|
141
app/blueprints/api/tokens.py
Normal file
141
app/blueprints/api/tokens.py
Normal file
@ -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))
|
@ -4,3 +4,4 @@ title: Help
|
|||||||
* [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)
|
||||||
|
* [API](api)
|
||||||
|
51
app/flatpages/help/api.md
Normal file
51
app/flatpages/help/api.md
Normal file
@ -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.
|
@ -219,6 +219,21 @@ title: Ranks and Permissions
|
|||||||
<th>✓</th> <!-- admin -->
|
<th>✓</th> <!-- admin -->
|
||||||
<th>✓</th>
|
<th>✓</th>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>Set Rank</td>
|
<td>Set Rank</td>
|
||||||
<th></th> <!-- new -->
|
<th></th> <!-- new -->
|
||||||
|
@ -92,6 +92,7 @@ class Permission(enum.Enum):
|
|||||||
CREATE_THREAD = "CREATE_THREAD"
|
CREATE_THREAD = "CREATE_THREAD"
|
||||||
UNAPPROVE_PACKAGE = "UNAPPROVE_PACKAGE"
|
UNAPPROVE_PACKAGE = "UNAPPROVE_PACKAGE"
|
||||||
TOPIC_DISCARD = "TOPIC_DISCARD"
|
TOPIC_DISCARD = "TOPIC_DISCARD"
|
||||||
|
CREATE_TOKEN = "CREATE_TOKEN"
|
||||||
|
|
||||||
# Only return true if the permission is valid for *all* contexts
|
# Only return true if the permission is valid for *all* contexts
|
||||||
# See Package.checkPerm for package-specific 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")
|
packages = db.relationship("Package", backref="author", 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")
|
||||||
replies = db.relationship("ThreadReply", backref="author", lazy="dynamic")
|
replies = db.relationship("ThreadReply", backref="author", lazy="dynamic")
|
||||||
|
|
||||||
def __init__(self, username, active=False, email=None, password=None):
|
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)
|
return user.rank.atLeast(UserRank.MODERATOR)
|
||||||
elif perm == Permission.CHANGE_EMAIL:
|
elif perm == Permission.CHANGE_EMAIL:
|
||||||
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank))
|
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:
|
else:
|
||||||
raise Exception("Permission {} is not related to users".format(perm.name))
|
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))
|
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):
|
class EditRequest(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
53
app/templates/api/create_edit_token.html
Normal file
53
app/templates/api/create_edit_token.html
Normal file
@ -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 %}
|
23
app/templates/api/list_tokens.html
Normal file
23
app/templates/api/list_tokens.html
Normal file
@ -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 %}
|
@ -127,6 +127,15 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% 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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
37
migrations/versions/fd25bf3e57c3_.py
Normal file
37
migrations/versions/fd25bf3e57c3_.py
Normal file
@ -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 ###
|
Loading…
Reference in New Issue
Block a user