Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • acides/hiboo
  • frju365/hiboo
  • pascoual/hiboo
  • thedarky/hiboo
  • jeremy/hiboo
  • cyrinux/hiboo
  • a.f/hiboo
  • mickge/hiboo
  • llaq/hiboo
  • vaguelysalaried/hiboo
  • felinn-glotte/hiboo
  • AntoninDelFabbro/hiboo
  • docemmetbrown/hiboo
13 results
Show changes
Showing
with 781 additions and 169 deletions
{% import "macros.html" as macros %} {{ render_form(form) }}
{{ macros.form(form) }}
{% if user %} {% if user %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<p> <p>
<a href="{{ url_for("service.action", service_uuid=service.uuid, action="list_rooms", mxid=user["name"]) }}">Rooms</a> <a href="{{ url_for("service.action", service_uuid=service.uuid, action="list_rooms", mxid=user["name"]) }}">Rooms</a>
<a href="{{ url_for("service.action", service_uuid=service.uuid, action="list_media", mxid=user["name"]) }}">Media</a> <a href="{{ url_for("service.action", service_uuid=service.uuid, action="list_media", mxid=user["name"]) }}">Media</a>
</p> </p>
<dl> <dl>
{% for name, value in user.items() %} {% for name, value in user.items() %}
<dt>{{ name }}</dt> <dt>{{ name }}</dt>
<dd>{{ value }}</dd> <dd>
{% endfor %} {{ value }}
</dl> </dd>
</div> {% endfor %}
<div class="col-md-6"> </dl>
<table class="table table-striped table-head-fixed text-nowrap"> </div>
<thead> <div class="col-md-6">
<tr> <div class="table-responsive">
<th>{% trans %}Devices{% endtrans %}</th> <table class="table table-striped table-head-fixed table-hover">
</tr> <thead>
</thead> <tr>
<tbody> <th>{% trans %}Devices{% endtrans %}</th>
{% for device in devices["devices"] %} </tr>
<tr> </thead>
<td>{{ device["device_id"] }}</td> <tbody>
<td>{{ device["display_name"] }}</td> {% for device in devices["devices"] %}
<td>{{ device["last_seen_ip"] }}</td> <tr>
</tr> <td>{{ device["device_id"] }}</td>
{% endfor %} <td>{{ device["display_name"] }}</td>
</tbody> <td>{{ device["last_seen_ip"] }}</td>
</table> </tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</div>
{% endif %} {% endif %}
<h3>Setting up WriteFreely</h3> <div class="card-header">
<p>WriteFreely supports OIDC authentication through Oauth2.</p> <h3>Setting up WriteFreely</h3>
<p>In order to configure Oauth2 for WriteFreely, you may copy then paste the following lines directly into your WriteFreely config.ini and restart.</p> </div>
<pre> <div class="card-body">
[oauth.generic] <p>WriteFreely supports OIDC authentication through Oauth2.</p>
<p>In order to configure Oauth2 for WriteFreely, you may copy then paste the
following lines directly into your WriteFreely config.ini and restart.</p>
<pre class="bg-dark-subtle rounded p-2"><code>[oauth.generic]
client_id = {{ service.config["client_id"] }} client_id = {{ service.config["client_id"] }}
client_secret = {{ service.config["client_secret"] }} client_secret = {{ service.config["client_secret"] }}
host = {{ url_for('account.home', _external=True).split('/account')[0] }} host = {{ url_for('account.home', _external=True).split('/account')[0] }}
...@@ -11,36 +14,26 @@ callback_proxy = ...@@ -11,36 +14,26 @@ callback_proxy =
callback_proxy_api = callback_proxy_api =
token_endpoint = {{ url_for("sso.oidc_token", service_uuid=service.uuid, _external=False) }} token_endpoint = {{ url_for("sso.oidc_token", service_uuid=service.uuid, _external=False) }}
inspect_endpoint = {{ url_for("sso.oidc_userinfo", service_uuid=service.uuid, _external=False) }} inspect_endpoint = {{ url_for("sso.oidc_userinfo", service_uuid=service.uuid, _external=False) }}
auth_endpoint = {{ url_for("sso.oidc_authorize", service_uuid=service.uuid, _external=False) }} auth_endpoint = {{ url_for("sso.oidc_authorize", service_uuid=service.uuid, _external=False) }}</code></pre>
</pre> <h4>Migrating accounts</h4>
<p>If you are running an existing WriteFreely server, you may import your
<h3>Migrating accounts</h3> existing accounts as claimable profiles under Hiboo.</p>
<p>If you are running an existing WriteFreely server, you may import your existing accounts as claimable <p>Accounts are stored in the <code>users</code> table of the database.
profiles under Hiboo. The following SQL query exports username and password hash to a CSV file.
</p> Hiboo will recognize the hash as it use a proper crypt context hash identifier</p>
<p>Accounts are stored in the <i>users</i> table of the database. The following SQL query exports <pre class="bg-dark-subtle rounded p-2"><code>use writefreely;
username and password hash to a CSV file. Hiboo will recognize the hash as it use a proper crypt context hash identifier
</p>
<pre>
use writefreely;
select select
username, password from users username, password from users
into into
outfile 'usersWF.csv' outfile 'usersWF.csv'
fields terminated by ','; fields terminated by ',';</code></pre>
</pre> <p>Run the following command to import these profiles as unclaimed:</p>
<p>Run the following command to import these profiles as unclaimed:</p> <p><code>flask profile csv-unclaimed {{ service.uuid }} /var/lib/mysql/writefreely/usersWF.csv</code></p>
<pre> <p>Now we need to attach created unclaimed profiles to Writefreely users (as writefreely don't use Oauth2
flask profile csv-unclaimed {{ service.uuid }} /var/lib/mysql/writefreely/usersWF.csv profile name but only profile uuid).<br>Click on "View profiles" on top of this page and click on
</pre> "Export unclaimed profiles", save the csv file in <code>/var/lib/mysql/writefreely/unclaimedWF.csv</code>.
<p> Use this script on Writefreely database:</p>
Now we need to attach created unclaimed profiles to Writefreely users (as writefreely don't use Oauth2 profile name but only profile uuid).<br> <pre class="bg-dark-subtle rounded p-2"><code>use writefreely;
Click on "View profiles" on top of this page and click on "Export unclaimed profiles", save the csv file in /var/lib/mysql/writefreely/unclaimedWF.csv.
Use this script on Writefreely database:
</p>
<pre>
use writefreely;
CREATE TABLE tmp_hiboo ( CREATE TABLE tmp_hiboo (
service_client_id VARCHAR(36) NOT NULL, service_client_id VARCHAR(36) NOT NULL,
profile_name VARCHAR(36) NOT NULL, profile_name VARCHAR(36) NOT NULL,
...@@ -68,8 +61,6 @@ auth_endpoint = {{ url_for("sso.oidc_authorize", service_uuid=service.uuid, ...@@ -68,8 +61,6 @@ auth_endpoint = {{ url_for("sso.oidc_authorize", service_uuid=service.uuid,
WHERE wf.id = oau.user_id WHERE wf.id = oau.user_id
); );
drop table tmp_hiboo; drop table tmp_hiboo;</code></pre>
{% include "application_oidc.html" %}
</pre> </div>
{% include "application_oidc.html" %}
\ No newline at end of file
...@@ -67,29 +67,33 @@ class ImageCaptchaField(fields.CaptchaField): ...@@ -67,29 +67,33 @@ class ImageCaptchaField(fields.CaptchaField):
'<p><img src="data:image/png;base64,{}"></p>'.format(output) '<p><img src="data:image/png;base64,{}"></p>'.format(output)
) + super(ImageCaptchaField, self).__call__(**kwargs) ) + super(ImageCaptchaField, self).__call__(**kwargs)
def get_size(self, text, scale=(1, 1)):
left, top, right, bottom = self.font.getbbox(text)
sx, sy = scale
return (int(sx * (right-left)), int(sy * (bottom-top)))
def make_char(self, char, rotation_range=15): def make_char(self, char, rotation_range=15):
""" Renders a character """ Renders a character
""" """
drawn = " {} ".format(char) drawn = " {} ".format(char)
rotation = random.randrange(-rotation_range, rotation_range) rotation = random.randrange(-rotation_range, rotation_range)
image = Image.new("L", self.font.getsize(drawn), self.bgcolor) bbox = self.font.getbbox(drawn)
image = Image.new("L", self.get_size(drawn, (2, 2)), self.bgcolor)
draw = ImageDraw.Draw(image) draw = ImageDraw.Draw(image)
draw.text((0, 0), drawn, font=self.font, fill=self.fgcolor) draw.text((0, 0), drawn, font=self.font, fill=self.fgcolor)
return image.rotate(rotation, expand=0, resample=Image.BICUBIC) \ return image.rotate(rotation, expand=True, fillcolor=self.bgcolor, resample=Image.BICUBIC)
.crop(image.getbbox())
def make_text(self, text, **kwargs): def make_text(self, text, **kwargs):
""" Renders a text """ Renders a text
""" """
size = self.font.getsize(text) size = self.get_size(text, (4, 3))
size = (size[0] * 2, int(size[1] * 1.433))
image = Image.new("RGB", size, self.bgcolor) image = Image.new("RGB", size, self.bgcolor)
xpos = 2 xpos = 2
for char in text: for char in text:
charimage = self.make_char(char) charimage = self.make_char(char)
image.paste(charimage, (xpos, 4, xpos + charimage.size[0], 4 + charimage.size[1])) image.paste(charimage, (xpos, 4, xpos + charimage.size[0], 4 + charimage.size[1]))
xpos = xpos + 2 + charimage.size[0] xpos = xpos + 2 + charimage.size[0]
return image.crop((0, 0, xpos + 1, size[1])) return image
def make_image(self, challenge): def make_image(self, challenge):
pass pass
......
from wtforms import validators, fields, widgets, utils from wtforms import validators, fields, widgets, utils
from jwcrypto import jwe, common from joserfc import jwk, jwt
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
import flask import flask
import random import random
import datetime
class ContextTokenField(fields.Field): class ContextTokenField(fields.Field):
""" Field that has a token hidden field that holds a stateless context. """ Field that has a token hidden field that holds a stateless context.
""" """
# TODO: implement context expiration
# TODO: implement context validity based on request attributes
@classmethod @classmethod
def encode(cls, context): def encode(cls, context):
""" JOSE authenticating encryption """JOSE authenticating encryption"""
""" kdf = HKDF(hashes.SHA512(), length=32, salt=None, info=None)
jose = jwe.JWE( dkey = kdf.derive(flask.current_app.config["SECRET_KEY"].encode())
common.json_encode(context), key = jwk.OctKey.import_key(dkey)
common.json_encode({"alg": "PBES2-HS512+A256KW", "enc": "A256GCM"}) header = {"alg": "A256KW", "enc": "A256GCM"}
) expired = datetime.datetime.now() + datetime.timedelta(minutes=3)
jose.add_recipient(flask.current_app.config["SECRET_KEY"]) claims = {
return jose.serialize(True) "exp": int(expired.timestamp()),
"aud": flask.url_for('account.signup'),
"context": context
}
return jwt.encode(header, claims, key)
@classmethod @classmethod
def decode(cls, serialized): def decode(cls, token):
""" JOSE decryption """JOSE decryption"""
""" kdf = HKDF(hashes.SHA512(), length=32, salt=None, info=None)
jose = jwe.JWE() dkey = kdf.derive(flask.current_app.config["SECRET_KEY"].encode())
jose.deserialize(serialized) key = jwk.OctKey.import_key(dkey)
jose.decrypt(flask.current_app.config["SECRET_KEY"]) decoded_token = jwt.decode(token, key)
return common.json_decode(jose.payload) claims_requests = jwt.JWTClaimsRegistry(
now=int(datetime.datetime.now().timestamp()),
aud={'essential': True, 'value': flask.url_for('account.signup')},
exp={'essential': True},
context={'essential': True}
)
claims_requests.validate(decoded_token.claims)
return decoded_token.claims["context"]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.received_context = {} self.received_context = {}
......
import os import os
import ast
import logging import logging
from hiboo.security import username_blocklist
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
# Common configuration variables # Common configuration variables
...@@ -9,14 +12,17 @@ DEFAULT_CONFIG = { ...@@ -9,14 +12,17 @@ DEFAULT_CONFIG = {
'BABEL_DEFAULT_TIMEZONE': 'UTC', 'BABEL_DEFAULT_TIMEZONE': 'UTC',
'SQLALCHEMY_DATABASE_URI': 'sqlite:////tmp/hiboo.db', 'SQLALCHEMY_DATABASE_URI': 'sqlite:////tmp/hiboo.db',
'SQLALCHEMY_TRACK_MODIFICATIONS': False, 'SQLALCHEMY_TRACK_MODIFICATIONS': False,
'REDIS_URL': 'redis://redis:6379/0', 'CACHE_TYPE': 'RedisCache',
'CACHE_REDIS_URL': 'redis://redis:6379/0',
'SECRET_KEY': 'changeMe', 'SECRET_KEY': 'changeMe',
'BOOTSTRAP_SERVE_LOCAL': True,
'TEMPLATES_AUTO_RELOAD': False, 'TEMPLATES_AUTO_RELOAD': False,
'MAIL_DOMAIN': 'tedomum.net', 'MAIL_DOMAIN': 'tedomum.net',
'WEBSITE_NAME': 'Hiboo', 'WEBSITE_NAME': 'Hiboo',
'OPEN_SIGNUP': True, 'OPEN_SIGNUP': True,
'USER_TIMEOUT': 86400, 'USER_TIMEOUT': 86400,
'API_TOKEN': 'changeMe' 'API_TOKEN': 'changeMe',
'USERNAME_BLOCKLIST': username_blocklist(),
} }
class ConfigManager(dict): class ConfigManager(dict):
...@@ -31,19 +37,26 @@ class ConfigManager(dict): ...@@ -31,19 +37,26 @@ class ConfigManager(dict):
return True return True
elif isinstance(value, str) and value.lower() in ('false', 'no'): elif isinstance(value, str) and value.lower() in ('false', 'no'):
return False return False
elif isinstance(value, str) and value[0] in "[{(" and value[-1] in ")}]":
return ast.literal_eval(value)
return value return value
def init_app(self, app): def init_app(self, app):
self.config.update(app.config) self.config.update(app.config)
# get environment variables # Get environment variables for all keys
self.config.update({ self.config.update({
key: self.__coerce_value(os.environ.get(key, value)) key: self.__coerce_value(os.environ.get(key, value))
for key, value in DEFAULT_CONFIG.items() for key, value in DEFAULT_CONFIG.items()
}) })
# update the logging config if necessary # Ensure environment variables for flask-caching CACHE_ keys are handled
self.config.update({
key: self.__coerce_value(os.environ.get(key, DEFAULT_CONFIG.get(key)))
for key in os.environ.keys() if key.startswith('CACHE_')
})
# Update the logging config if necessary
if self.config.get("DEBUG", False): if self.config.get("DEBUG", False):
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
# update the app config itself # Update the app config itself
app.config = self app.config = self
def setdefault(self, key, value): def setdefault(self, key, value):
...@@ -65,3 +78,8 @@ class ConfigManager(dict): ...@@ -65,3 +78,8 @@ class ConfigManager(dict):
def __contains__(self, key): def __contains__(self, key):
return key in self.config return key in self.config
def get_config_by_prefix(self, prefix):
return {
key: value for key, value in self.config.items() if key.startswith(prefix)
}
import flask
blueprint = flask.Blueprint("docs", __name__, template_folder="../static/docs/")
from hiboo.docs import views
import os
import sys
import flask
from werkzeug.utils import safe_join
from hiboo import security
from hiboo.utils import get_locale
from hiboo.docs import blueprint
@blueprint.route("/")
@blueprint.route("/<lang>/")
@blueprint.route("/<path:path>")
@blueprint.route("/<lang>/<path:path>/")
@security.authentication_required()
def index(lang: str = "en", path: str = "") -> flask.Response:
"""
Serve documentation templates from the static/docs folder.
This route handles requests for the builtin documentation. It use
`safe_join` to get the absolute path of requested URL and raise a 404 if
it's not found.
Args:
lang (str): The mkdocs localized template managed by mkdocs-i18n
path (str): The relative path of the requested template obtained from
the URL. Defaults to the documentation root page `""`.
Returns:
flask.Response: Jinja2 rendered for the template.
- 200 OK: If the template exists.
- 404 Not Found: If the template does not exist.
Notes:
If the requested URL is a path, "index.html" is added to the request to
match the Mkdocs files strucutre.
If the requested lang is not english (default), a "/" is added to the
request to serve all the localized template folder
Exemple:
Request: GET /reference/docs/views
Response: Template `../static/docs/reference/docs/views/index.html`.
"""
docs_base_path = os.path.join(flask.current_app.static_folder, "docs")
l10n = str(get_locale())[0:2]
full_path = safe_join(docs_base_path, path)
if l10n != "en" and lang == "en":
return flask.redirect(flask.url_for(".index", lang=l10n))
if lang != "en":
full_path = safe_join(docs_base_path, lang, path)
if os.path.isdir(full_path):
full_path = safe_join(full_path, "index.html")
if not os.path.exists(full_path):
flask.abort(404)
relative_path = os.path.relpath(full_path, docs_base_path)
return flask.render_template(relative_path)
@blueprint.route("/assets/<path:path>")
@security.authentication_required()
def assets(path: str) -> flask.Response:
"""
Serve documentation assets from the `static/docs/assets/` folder.
This route handles requests for assets-related resources, such as CSS and
JS files required by mkdocs theme. The `send_from_directory` ensure the
file exists or raise a 404.
Args:
path (str): The relative path of the requested file within the `search`
directory, obtained from the request URL.
Returns:
flask.Response: The requested file, or a 404 error if the file is not
found.
"""
return flask.send_from_directory("static/docs/assets/", path)
@blueprint.route("/search/<path:path>")
@security.authentication_required()
def search(path: str) -> flask.Response:
"""
Serve documentation search files from the `static/docs/search/` folder.
This route handles requests for search-related resources, such as JSON
index file required by the mkdocs's search functionality. The
`send_from_directory` ensure the file exists or raise a 404.
Args:
path (str): The relative path of the requested file within the `search`
directory, obtained from the request URL.
Returns:
flask.Response: The requested file, or a 404 error if the file is not
found.
"""
return flask.send_from_directory("static/docs/search/", path)
from wtforms import validators from wtforms import validators
from flask_babel import lazy_gettext as _ from flask_babel import lazy_gettext as _
from hiboo.security import username_blocklist
import string import string
class ProfileFormat(object): class NameFormat(object):
registry = {} registry = {}
regex = ".*" regex = ".*"
...@@ -16,7 +17,7 @@ class ProfileFormat(object): ...@@ -16,7 +17,7 @@ class ProfileFormat(object):
@classmethod @classmethod
def register(cls, *format_ids): def register(cls, *format_ids):
""" Class decorator """ Class decorator
""" """
def register_function(format): def register_function(format):
for format_id in format_ids: for format_id in format_ids:
...@@ -25,18 +26,22 @@ class ProfileFormat(object): ...@@ -25,18 +26,22 @@ class ProfileFormat(object):
return register_function return register_function
@classmethod @classmethod
def validators(cls): def validators(cls, blocklist=True):
""" Return a username validator for wtforms """ Return a username validator for wtforms
""" """
return [ return [
validators.DataRequired(), validators.DataRequired(),
validators.Length( validators.Length(
min=cls.min_length, max=cls.max_length, min=cls.min_length, max=cls.max_length,
message=(_("must be at least {} and at most {} characters long".format(cls.min_length, cls.max_length))) message=(_("The name must be at least {} and at most {} characters long".format(cls.min_length, cls.max_length)))
), ),
validators.Regexp( validators.Regexp(
"^{}$".format(cls.regex), "^{}$".format(cls.regex),
message=(_("must comprise only of ")) + cls.message message=cls.message
),
validators.NoneOf(
username_blocklist() if blocklist else (),
message=(_("Sorry, this username is not available"))
) )
] ]
...@@ -57,25 +62,43 @@ class ProfileFormat(object): ...@@ -57,25 +62,43 @@ class ProfileFormat(object):
index += 1 index += 1
register = ProfileFormat.register register = NameFormat.register
@register("lowercase", "", None) @register("lowercase")
class LowercaseAlphanumPunct(ProfileFormat): class LowercaseAlphanumPunct(NameFormat):
""" Lowercase username, including digits and very basic punctuation """ Lowercase name, including digits and very basic punctuation
""" """
regex = "[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?" regex = r"[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?"
allowed = string.digits + string.ascii_lowercase + ".-_" allowed = string.digits + string.ascii_lowercase + ".-_"
transform = str.lower transform = str.lower
message = (_("lowercase letters, digits, dots, dashes, and underscores")) # Translators: "it" refers to a user/group/profile name
message = (_("It can only include lowercase letters, digits, dots, hyphens\
and underscores, but may not begin or end with dots or\
hyphens."))
@register("alnum") @register("alnum")
class AlphanumPunct(ProfileFormat): class AlphanumPunct(NameFormat):
""" Alphanum username, including some very basic punctuation """ Alphanum name, including some very basic punctuation
""" """
regex = "[a-zA-Z0-9_]+([a-zA-Z0-9_\.-]+[a-zA-Z0-9_]+)?" regex = r"[a-zA-Z0-9_]+([a-zA-Z0-9_\.-]+[a-zA-Z0-9_]+)?"
allowed = string.digits + string.ascii_letters + ".-_" allowed = string.digits + string.ascii_letters + ".-_"
message = (_("letters, digits, dots, dashes and underscores")) # Translators: "it" refers to a user/group/profile name
message = (_("It can only include letters, digits, dots, hyphens and\
underscores, but may not begin or end with dots or hyphens."))
@register("alnumSpace")
class AlphanumPunctSpace(NameFormat):
""" Alphanum name, including some very basic punctuation and space
"""
regex = r"[a-zA-Z0-9_]+([a-zA-Z0-9_\.\s-]+[a-zA-Z0-9_]+)?"
allowed = string.digits + string.ascii_letters + ". -_"
# Translators: "it" refers to a user/group/profile name
message = (_("It can only include letters, digits, dots, hyphens,\
underscores and spaces, but may not begin or end with dots,\
hyphens or spaces."))
import flask
blueprint = flask.Blueprint("group", __name__, template_folder="templates")
from hiboo.group import cli, views
from hiboo.group import blueprint
from hiboo import models
import click
import terminaltables
@blueprint.cli.command()
@click.argument("groupname")
@click.argument("comment", required=False, default="")
def create(groupname, comment):
""" Create a new group.
"""
assert not models.Group.query.filter_by(groupname=groupname).first()
group = models.Group(
groupname=groupname,
comment=comment
)
models.db.session.add(group)
models.db.session.commit()
@blueprint.cli.command()
def list():
""" List all groups.
"""
click.echo(terminaltables.SingleTable(
[('uuid', 'groupname', 'comment', 'users')] +
[
[
group.uuid, group.groupname, group.comment,
','.join([user.name for user in group.users])
]
for group in models.Group.query.all()
], title='Groups').table
)
@blueprint.cli.command()
@click.argument("groupname")
@click.argument("usernames", nargs=-1, required=True)
def add_user(groupname, usernames):
""" Add one or many users to a group
"""
group = models.Group.query.filter_by(groupname=groupname).first()
assert group
actual_users = group.users
selected_users = []
for username in usernames:
user = models.User.query.filter_by(name=username).first()
selected_users.append(user)
group.users = [*actual_users, *selected_users]
models.db.session.add(group)
models.db.session.commit()
from hiboo import utils
from hiboo.format import NameFormat
from wtforms import validators, fields
from flask_babel import lazy_gettext as _
import flask_wtf
class GroupCreateEdit(flask_wtf.FlaskForm):
formatter = NameFormat.registry["alnumSpace"]
groupname = fields.StringField(
_('Group name'),
formatter.validators(),
description = format(formatter.message)
)
comment = fields.StringField(_('Comment'))
submit = fields.SubmitField(_('Edit group'))
class GroupUserForm(flask_wtf.FlaskForm):
users = utils.MultiCheckboxField(_('Available users', coerce=int))
pre_populate = utils.MultiCheckboxField(_('Users already in the group', coerce=int))
submit = fields.SubmitField(_('Update group memberships'))
{% extends "form.html" %}
{% block title %}{% trans %}Create a group{% endtrans %}{% endblock %}
{% extends "base.html" %}
{% block title %}{{ group.groupname }}{% endblock %}
{% block subtitle %}{% trans %}group details{% endtrans %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3>{% trans %}Attributes{% endtrans %}</h3>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-lg-3">{% trans %}groupname{% endtrans %}</dt>
<dd class="col-lg-9">{{ group.groupname }}</dd>
<dt class="col-lg-3">{% trans %}UUID{% endtrans %}</dt>
<dd class="col-lg-9">
<code>{{ group.uuid }}</code>
</dd>
<dt class="col-lg-3">{% trans %}Commentaire{% endtrans %}</dt>
<dd class="col-lg-9">{{ group.comment }}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">
<h4>User list</h4>
</div>
<div class="card-body table-responsive">
<table class="table table-striped table-head-fixed text-nowrap">
<thead>
<tr>
<th>{% trans %}Username{% endtrans %}</th>
<th>{% trans %}Groups{% endtrans %}</th>
<th>{% trans %}Auth. methods{% endtrans %}</th>
<th>{% trans %}Created on{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for user in group.users %}
<tr>
<td><a href="{{ url_for("user.details", user_uuid=user.uuid) }}">{{ user.name }}</a></td>
<td>{{ macros.groups_badges(user.groups) }}</td>
<td>{{ macros.auths_badges(user.auths) }}</td>
<td>{{ user.created_at.date() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block actions %}
<a href="{{ url_for(".membership", group_uuid=group.uuid) }}" class="btn btn-outline-primary">{% trans %}Manage members{% endtrans %}</a>
<a href="{{ url_for(".edit", group_uuid=group.uuid) }}" class="btn btn-outline-warning">{% trans %}Edit group{% endtrans %}</a>
<a href="{{ url_for(".delete", group_uuid=group.uuid) }}" class="btn btn-outline-danger">{% trans %}Delete group{% endtrans %}</a>
{% endblock %}
{% extends "form.html" %}
{% block title %}{% trans %}Edit a group{% endtrans %}{% endblock %}
{% extends "base.html" %}
{% block title %}{% trans %}Group list{% endtrans %}{% endblock %}
{% block subtitle %}{% trans %}all available groups{% endtrans %}{% endblock %}
{% block content %}
<div class="row">
<div class="col">
<div class="table-responsive">
<table class="table table-striped table-head-fixed table-hover">
<thead>
<tr>
<th>{% trans %}Group{% endtrans %}</th>
<th>{% trans %}Comment{% endtrans %}</th>
<th>{% trans %}Members{% endtrans %}</th>
<th>{% trans %}Actions{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for group in groups %}
<tr>
<td><a href="{{ url_for("group.details", group_uuid=group.uuid) }}">{{ group.groupname }}</a></td>
<td>{{ group.comment }}</td>
<td>
{% set user_list = [] %}
{% for user in group.users %}
{{ user_list.append( user ) or "" }}
{% endfor %}
{{ user_list|count }}
</td>
<td>
<a href="{{ url_for(".membership", group_uuid=group.uuid)}}">{% trans %}Members{% endtrans %}</a>&nbsp;
<a href="{{ url_for(".edit", group_uuid=group.uuid)}}">{% trans %}Edit{% endtrans %}</a>&nbsp;
<a href="{{ url_for(".delete", group_uuid=group.uuid)}}">{% trans %}Delete{% endtrans %}</a>&nbsp;
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block actions %}
<a href="{{ url_for(".create") }}" class="btn btn-outline-success">{% trans %}Create a group{% endtrans %}</a>
{% endblock %}
{% extends "form.html" %}
{% from 'bootstrap5/form.html' import render_field %}
{% block title %}{% trans %}Manage members of group{% endtrans %} {{ group.groupname }}{% endblock %}
{% block content %}
<form method="post">
{{ form.csrf_token() }}
<div class="row">
{{ render_field(form.users, class="overflow-y-scroll", style="max-height: 100vh", form_group_classes="mb-3 col-sm-6 col-12") }}
{{ render_field(form.pre_populate, class="overflow-y-scroll", style="max-height: 100vh", form_group_classes="mb-3 col-sm-6 col-12") }}
</div>
{{ render_field(form.submit) }}
</form>
{% endblock %}
from hiboo.group import blueprint, forms
from hiboo import models, utils, security
from sqlalchemy import not_
from flask_babel import lazy_gettext as _
import flask
import flask_login
@blueprint.route("/create", methods=["GET", "POST"])
@security.admin_required()
def create():
form = forms.GroupCreateEdit()
form.submit.label.text = (_("Create"))
if form.validate_on_submit():
conflict = models.Group.query.filter_by(groupname=form.groupname.data).first()
if conflict:
flask.flash(_("A group with the same name exists already"), "danger")
else:
group = models.Group()
group.groupname = form.groupname.data
group.comment = form.comment.data
models.db.session.add(group)
models.db.session.commit()
flask.flash(_("Group created successfully"), "success")
return flask.redirect(utils.url_or_intent(".list"))
return flask.render_template("group_create.html", form=form)
@blueprint.route("/delete/<group_uuid>", methods=["GET", "POST"])
@security.admin_required()
@security.confirmation_required(_("Delete the group"))
def delete(group_uuid):
group = models.Group.query.get(group_uuid) or flask.abort(404)
#TODO log implementation needs a way to keep group infos after deletion
models.db.session.delete(group)
models.db.session.commit()
return flask.redirect(flask.url_for(".list"))
@blueprint.route("/details/<group_uuid>")
@security.admin_required()
def details(group_uuid):
group = models.Group.query.get(group_uuid) or flask.abort(404)
return flask.render_template("group_details.html", group=group)
@blueprint.route("/edit/<group_uuid>", methods=["GET", "POST"])
@security.admin_required()
def edit(group_uuid):
group = models.Group.query.get(group_uuid) or flask.abort(404)
form = forms.GroupCreateEdit()
form.submit.label.text = (_("Edit"))
if form.validate_on_submit():
if form.groupname.data != group.groupname:
models.log(
models.History.GROUP,
comment=str(_("Group {} was renamed to {}").format(group.groupname, form.groupname.data)),
actor=flask_login.current_user, group=group
)
group.groupname = form.groupname.data
group.comment = form.comment.data
models.db.session.add(group)
models.db.session.commit()
flask.flash(_("Group successfully updated"), "success")
return flask.redirect(flask.url_for(".list"))
form.process(obj=group)
return flask.render_template("group_edit.html", group=group, form=form)
@blueprint.route("/membership/<group_uuid>", methods=["GET", "POST"])
@security.admin_required()
def membership(group_uuid):
group = models.Group.query.get(group_uuid) or flask.abort(404)
form = forms.GroupUserForm(pre_populate=[user.name for user in group.users])
form.pre_populate.choices = sorted([user.name for user in group.users])
legacy_users = form.pre_populate.choices[:]
available_users = (models.User.query
.filter(not_(models.User.groups.any(models.Group.uuid.in_([group_uuid]))))
.order_by('name')
)
form.users.choices = [user.name for user in available_users]
if form.validate_on_submit():
if form.users.data:
for username in form.users.data:
user = models.User.query.filter_by(name=username).first() or flask.abort(404)
models.log(
models.History.GROUP,
comment=str(_("User {} was added to the group {}").format(user.name, group.groupname)),
user=user, actor=flask_login.current_user, group=group
)
exit_list = (user for user in legacy_users if user not in form.pre_populate.data)
if exit_list:
for username in exit_list:
user = models.User.query.filter_by(name=username).first() or flask.abort(404)
models.log(
models.History.GROUP,
comment=str(_("User {} was removed from the group {}").format(user.name, group.groupname)),
user=user, actor=flask_login.current_user, group=group
)
selected_users = []
for username in [*form.users.data, *form.pre_populate.data]:
user = models.User.query.filter_by(name=username).first() or flask.abort(404)
selected_users.append(user)
group.users = selected_users
models.db.session.add(group)
models.db.session.commit()
flask.flash(_("Group memberships successfully updated"), "success")
return flask.redirect(flask.url_for(".membership", group_uuid=group.uuid))
return flask.render_template("group_membership.html", group=group, form=form)
@blueprint.route("/list")
@security.admin_required()
def list():
groups = models.Group.query.all()
return flask.render_template("group_list.html", groups=groups)
from passlib import context, hash from passlib import context, hash
from flask import current_app as app from flask import current_app as app
from sqlalchemy.ext import declarative, mutable from hiboo.security import username_blocklist
from sqlalchemy.orm.collections import attribute_mapped_collection from sqlalchemy import orm, types, schema, sql
from sqlalchemy.ext import mutable
from flask_babel import lazy_gettext as _ from flask_babel import lazy_gettext as _
from hiboo import actions from hiboo import actions
import flask_sqlalchemy import flask_sqlalchemy
import flask_babel
import sqlalchemy
import datetime import datetime
import json import json
import uuid import uuid
import pyotp import pyotp
def log(category, value=None, comment=None, user=None, profile=None, def log(category, value=None, comment=None, user=None, group=None,
service=None, actor=None, public=True): profile=None, service=None, actor=None, public=True):
""" Log a history event """ Log a history event
""" """
event = History() event = History()
...@@ -23,6 +22,7 @@ def log(category, value=None, comment=None, user=None, profile=None, ...@@ -23,6 +22,7 @@ def log(category, value=None, comment=None, user=None, profile=None,
event.value = value event.value = value
event.comment = comment event.comment = comment
event.user = user event.user = user
event.group = group
event.profile = profile event.profile = profile
event.service = service event.service = service
event.actor = actor event.actor = actor
...@@ -30,32 +30,32 @@ def log(category, value=None, comment=None, user=None, profile=None, ...@@ -30,32 +30,32 @@ def log(category, value=None, comment=None, user=None, profile=None,
db.session.add(event) db.session.add(event)
class Base(flask_sqlalchemy.Model): class Base(orm.DeclarativeBase):
""" Base class for all models """ Base class for all models
""" """
metadata = sqlalchemy.schema.MetaData( metadata = schema.MetaData(
naming_convention={ naming_convention={
"fk": "%(table_name)s_%(column_0_name)s_fkey", "fk": "%(table_name)s_%(column_0_name)s_fkey",
"pk": "%(table_name)s_pkey" "pk": "%(table_name)s_pkey"
} }
) )
@declarative.declared_attr @orm.declared_attr
def uuid(cls): def uuid(cls):
return sqlalchemy.Column(sqlalchemy.String(36), primary_key=True, return schema.Column(types.String(36), primary_key=True,
default=lambda: str(uuid.uuid4())) default=lambda: str(uuid.uuid4()))
@declarative.declared_attr @orm.declared_attr
def created_at(cls): def created_at(cls):
return sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.now) return schema.Column(types.DateTime, nullable=False, default=datetime.datetime.now)
@declarative.declared_attr @orm.declared_attr
def updated_at(cls): def updated_at(cls):
return sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, onupdate=datetime.datetime.now) return schema.Column(types.DateTime, nullable=True, onupdate=datetime.datetime.now)
@declarative.declared_attr @orm.declared_attr
def comment(cls): def comment(cls):
return sqlalchemy.Column(sqlalchemy.String(255), nullable=True) return schema.Column(types.String(255), nullable=True)
db = flask_sqlalchemy.SQLAlchemy(model_class=Base) db = flask_sqlalchemy.SQLAlchemy(model_class=Base)
...@@ -74,14 +74,21 @@ class JSONEncoded(db.TypeDecorator): ...@@ -74,14 +74,21 @@ class JSONEncoded(db.TypeDecorator):
return json.loads(value) if value else None return json.loads(value) if value else None
membership = db.Table('membership', db.Model.metadata,
db.Column('user_uuid', db.ForeignKey('user.uuid'), primary_key=True),
db.Column('group_uuid', db.ForeignKey('group.uuid'), primary_key=True)
)
class User(db.Model): class User(db.Model):
""" A user is the local representation of an authenticated person. """ A user is the local representation of an authenticated person.
""" """
__tablename__ = "user" __tablename__ = "user"
username = db.Column(db.String(255), nullable=False, unique=True) name = db.Column(db.String(255), nullable=False, unique=True)
is_admin = db.Column(db.Boolean(), nullable=False, default=False) is_admin = db.Column(db.Boolean(), nullable=False, default=False)
contact = db.Column(mutable.MutableDict.as_mutable(JSONEncoded)) contact = db.Column(mutable.MutableDict.as_mutable(JSONEncoded))
groups = db.relationship("Group", secondary=membership, backref="users")
# Flask-login attributes # Flask-login attributes
is_authenticated = True is_authenticated = True
...@@ -94,7 +101,7 @@ class User(db.Model): ...@@ -94,7 +101,7 @@ class User(db.Model):
@classmethod @classmethod
def login(cls, username, password): def login(cls, username, password):
user = cls.query.filter_by(username=username).first() user = cls.query.filter_by(name=username).first()
if not user: if not user:
return False return False
auths = user.auths auths = user.auths
...@@ -111,7 +118,7 @@ class User(db.Model): ...@@ -111,7 +118,7 @@ class User(db.Model):
profile = Profile() profile = Profile()
profile.service = service profile.service = service
profile.user = self profile.user = self
profile.username = self.username profile.name = self.name
profile.uuid = self.uuid profile.uuid = self.uuid
profile.status = Profile.ACTIVE profile.status = Profile.ACTIVE
return profile return profile
...@@ -143,13 +150,35 @@ class User(db.Model): ...@@ -143,13 +150,35 @@ class User(db.Model):
.filter(Profile.uuid == None) .filter(Profile.uuid == None)
.filter( .filter(
cls.get_timeout() > cls.get_timeout() >
sqlalchemy.sql.func.coalesce(cls.updated_at, cls.created_at) sql.func.coalesce(cls.updated_at, cls.created_at)
)) ))
for user in unused.all(): for user in unused.all():
print("Deleting user {}".format(user.username)) print("Deleting user {}".format(user.name))
db.session.delete(user) db.session.delete(user)
db.session.commit() db.session.commit()
@classmethod
def exist_username(cls, username):
return cls.query.filter(cls.name.ilike(username)).first()
def reserved_username(self, username):
return self.name == username.lower()
def available_username(self, username, service, spoof_allowed=False):
return not Profile.exist_profilename(username, service=service) and (
not User.exist_username(username)
or self.reserved_username(username)
or spoof_allowed
)
class Group(db.Model):
""" A group is an instance that a user can be attached to.
"""
__tablename__ = "group"
groupname = db.Column(db.String(255), nullable=False, unique=True)
class Auth(db.Model): class Auth(db.Model):
""" An authenticator is a method to authenticate a user. """ An authenticator is a method to authenticate a user.
...@@ -160,8 +189,8 @@ class Auth(db.Model): ...@@ -160,8 +189,8 @@ class Auth(db.Model):
TOTP = "totp" TOTP = "totp"
BADGES = { BADGES = {
PASSWORD: "gray", PASSWORD: "primary",
TOTP: "blue" TOTP: "info"
} }
def __init__(self, realm, enabled=False): def __init__(self, realm, enabled=False):
...@@ -173,7 +202,7 @@ class Auth(db.Model): ...@@ -173,7 +202,7 @@ class Auth(db.Model):
user_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid)) user_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid))
user = db.relationship(User, user = db.relationship(User,
backref=db.backref('auths', backref=db.backref('auths',
collection_class=attribute_mapped_collection('realm'), collection_class=orm.attribute_mapped_collection('realm'),
cascade='all, delete-orphan')) cascade='all, delete-orphan'))
value = db.Column(db.String) value = db.Column(db.String)
extra = db.Column(mutable.MutableDict.as_mutable(JSONEncoded)) extra = db.Column(mutable.MutableDict.as_mutable(JSONEncoded))
...@@ -218,13 +247,10 @@ class Service(db.Model): ...@@ -218,13 +247,10 @@ class Service(db.Model):
description = db.Column(db.String()) description = db.Column(db.String())
policy = db.Column(db.String(255)) policy = db.Column(db.String(255))
max_profiles = db.Column(db.Integer(), nullable=False, default=1) max_profiles = db.Column(db.Integer(), nullable=False, default=1)
profile_format = db.Column(db.String(255)) profile_format = db.Column(db.String(255), nullable=False, default='lowercase')
single_profile = db.Column(db.Boolean(), nullable=False, default=False) single_profile = db.Column(db.Boolean(), nullable=False, default=False)
config = db.Column(mutable.MutableDict.as_mutable(JSONEncoded)) config = db.Column(mutable.MutableDict.as_mutable(JSONEncoded))
def check_username(self, username):
return Profile.query.filter_by(service_uuid=self.uuid, username=username).first()
class Profile(db.Model): class Profile(db.Model):
""" A profile is a per-service custom identity. """ A profile is a per-service custom identity.
...@@ -246,42 +272,51 @@ class Profile(db.Model): ...@@ -246,42 +272,51 @@ class Profile(db.Model):
DONE = "done" DONE = "done"
STATUSES = { STATUSES = {
UNCLAIMED: ("gray", _("unclaimed")), # Translators: this qualifier refers to a profile.
REQUEST: ("blue", _("requested")), UNCLAIMED: ("secondary", _("unclaimed")),
ACTIVE: ("green", _("active")), # Translators: this qualifier refers to a profile.
BLOCKED: ("orange", _("blocked")), REQUEST: ("info", _("requested")),
DELETED: ("red", _("deleted")), # Translators: this qualifier refers to a profile.
PURGED: ("red", _("purged")) ACTIVE: ("success", _("active")),
# Translators: this qualifier refers to a profile.
BLOCKED: ("warning", _("blocked")),
# Translators: this qualifier refers to a profile.
DELETED: ("danger", _("deleted")),
# Translators: this qualifier refers to a profile.
PURGED: ("dark", _("purged"))
} }
TRANSITIONS = { TRANSITIONS = {
"assign": actions.Transition("assign",
label=(_("assign")), label_alt=(_("assigned")), description=(_("assign this profile to a user")),
icon="person-fill-up", from_=(UNCLAIMED,), to=ACTIVE
),
"activate": actions.Transition("activate", "activate": actions.Transition("activate",
label=(_("activate")), description=(_("activate this profile")), label=(_("activate")), label_alt=(_("activated")), description=(_("activate this profile")),
icon="fas fa-user-check", from_=(REQUEST,), to=ACTIVE icon="check", from_=(REQUEST,), to=ACTIVE
), ),
"reject": actions.Transition("reject", "reject": actions.Transition("reject",
label=(_("reject")), description=(_("reject this request")), label=(_("reject")), label_alt=(_("rejected")), description=(_("reject this request")),
icon="fas fa-user-times", from_=(REQUEST,), to=PURGED icon="user-times", from_=(REQUEST,), to=PURGED
), ),
"block": actions.Transition("block", "block": actions.Transition("block",
label=(_("block")), description=(_("block this profile")), label=(_("block")), label_alt=(_("blocked")), description=(_("block this profile")),
icon="fas fa-ban", from_=(ACTIVE,), to=BLOCKED icon="slash-circle-fill", from_=(ACTIVE,), to=BLOCKED
), ),
"unblock": actions.Transition("unblock", "unblock": actions.Transition("unblock",
label=(_("unblock")), description=(_("unblock this blocked profile")), label=(_("unblock")), label_alt=(_("unblocked")), description=(_("unblock this blocked profile")),
icon="fas fa-unlock-alt", from_=(BLOCKED,), to=ACTIVE icon="check-circle-fill", from_=(BLOCKED,), to=ACTIVE
), ),
"delete": actions.Transition("delete", "delete": actions.Transition("delete",
label=(_("delete")), description=(_("delete this profile")), label=(_("delete")), label_alt=(_("deleted")), description=(_("delete this profile")),
icon="fas fa-user-slash", from_=(ACTIVE, BLOCKED), to=DELETED, icon="x-circle-fill", from_=(ACTIVE, BLOCKED), to=DELETED,
admin_only=False, delay=120 admin_only=False, delay=120
), ),
"purge": actions.Transition("purge", "purge": actions.Transition("purge",
label=(_("purge")), description=(_("delete and purge this profile")), label=(_("purge")), label_alt=(_("purged")), description=(_("delete and purge this profile")),
icon="fas fa-trash", from_=(ACTIVE, BLOCKED, DELETED), to=PURGED, icon="trash-fill", from_=(UNCLAIMED, ACTIVE, BLOCKED, DELETED), to=PURGED,
delay=120 delay=120
), )
"assign": actions.Assign()
} }
user_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid)) user_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid))
...@@ -291,7 +326,7 @@ class Profile(db.Model): ...@@ -291,7 +326,7 @@ class Profile(db.Model):
service = db.relationship(Service, service = db.relationship(Service,
backref=db.backref('profiles', cascade='all, delete-orphan', lazy='dynamic')) backref=db.backref('profiles', cascade='all, delete-orphan', lazy='dynamic'))
username = db.Column(db.String(255), nullable=False) name = db.Column(db.String(255), nullable=False)
status = db.Column(db.String(25), nullable=False) status = db.Column(db.String(25), nullable=False)
server_status = db.Column(db.String(25)) server_status = db.Column(db.String(25))
transition = db.Column(db.String(25)) transition = db.Column(db.String(25))
...@@ -320,14 +355,18 @@ class Profile(db.Model): ...@@ -320,14 +355,18 @@ class Profile(db.Model):
@classmethod @classmethod
def transition_ready(cls): def transition_ready(cls):
return cls.query.filter(sqlalchemy.or_( return cls.query.filter(sql.expression.or_(
cls.transition_step.in_ ([cls.START, cls.DONE]), cls.transition_step.in_ ([cls.START, cls.DONE]),
sqlalchemy.and_( sql.expression.and_(
cls.transition_step == cls.INIT, cls.transition_step == cls.INIT,
datetime.datetime.now() > cls.transition_time datetime.datetime.now() > cls.transition_time
) )
)) ))
@classmethod
def exist_profilename(cls, profilename, service):
return cls.query.filter_by(service_uuid=service.uuid).filter(cls.name.ilike(profilename)).first()
class ClaimName(db.Model): class ClaimName(db.Model):
""" A profile might have multiple claimable names. """ A profile might have multiple claimable names.
...@@ -339,7 +378,7 @@ class ClaimName(db.Model): ...@@ -339,7 +378,7 @@ class ClaimName(db.Model):
profile = db.relationship(Profile, profile = db.relationship(Profile,
backref=db.backref('claimnames', cascade='all, delete-orphan', lazy='dynamic')) backref=db.backref('claimnames', cascade='all, delete-orphan', lazy='dynamic'))
username = db.Column(db.String(255), nullable=False) name = db.Column(db.String(255), nullable=False)
class History(db.Model): class History(db.Model):
...@@ -349,26 +388,31 @@ class History(db.Model): ...@@ -349,26 +388,31 @@ class History(db.Model):
SIGNUP = "signup" SIGNUP = "signup"
CREATE = "create" CREATE = "create"
EDIT = "edit"
DELETE = "delete" DELETE = "delete"
NOTE = "note" NOTE = "note"
STATUS = "status" STATUS = "status"
TRANSITION = "transition" TRANSITION = "transition"
PASSWORD = "password" PASSWORD = "password"
MFA = "mfa" MFA = "mfa"
GROUP = "group"
DESCRIPTION = { DESCRIPTION = {
SIGNUP: _("signed up for this account"), SIGNUP: _("signed up for this account"),
CREATE: _("created the profile {this.profile.username} on {this.service.name}"), CREATE: _("created the profile {this.profile.name} on {this.service.name}"),
EDIT: _("edited the profile {this.profile.name} on {this.service.name}"),
PASSWORD: _("changed this account password"), PASSWORD: _("changed this account password"),
MFA: _("modified this account multi-factor authentication (MFA) setting"), MFA: _("modified this account multi-factor authentication (MFA) setting"),
STATUS: _("set the {this.service.name} profile {this.profile.username} as {this.value}"), STATUS: _("set the {this.service.name} profile {this.profile.name} as {this.value}"),
TRANSITION: _("did {this.transition.label} the profile {this.profile.username} on {this.service.name}") TRANSITION: _("{this.transition.label_alt} the profile {this.profile.name} on {this.service.name}"),
GROUP: _("modified the group {this.group.groupname}")
} }
user_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid)) user_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid))
profile_uuid = db.Column(db.String(36), db.ForeignKey(Profile.uuid)) profile_uuid = db.Column(db.String(36), db.ForeignKey(Profile.uuid))
service_uuid = db.Column(db.String(36), db.ForeignKey(Service.uuid)) service_uuid = db.Column(db.String(36), db.ForeignKey(Service.uuid))
actor_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid)) actor_uuid = db.Column(db.String(36), db.ForeignKey(User.uuid))
group_uuid = db.Column(db.String(36), db.ForeignKey(Group.uuid))
user = db.relationship(User, foreign_keys=[user_uuid], user = db.relationship(User, foreign_keys=[user_uuid],
backref=db.backref('history', lazy='dynamic')) backref=db.backref('history', lazy='dynamic'))
profile = db.relationship(Profile, profile = db.relationship(Profile,
...@@ -377,6 +421,8 @@ class History(db.Model): ...@@ -377,6 +421,8 @@ class History(db.Model):
backref=db.backref('history', lazy='dynamic')) backref=db.backref('history', lazy='dynamic'))
actor = db.relationship(User, foreign_keys=[actor_uuid], actor = db.relationship(User, foreign_keys=[actor_uuid],
backref=db.backref('actions', lazy='dynamic')) backref=db.backref('actions', lazy='dynamic'))
group = db.relationship(Group, foreign_keys=[group_uuid],
backref=db.backref('history', lazy='dynamic'))
public = db.Column(db.Boolean(), default=True) public = db.Column(db.Boolean(), default=True)
category = db.Column(db.String(25)) category = db.Column(db.String(25))
......
import flask
blueprint = flask.Blueprint("moderation", __name__, template_folder="templates")
from hiboo.moderation import views
{% extends "base.html" %}
{% from "bootstrap5/pagination.html" import render_pagination %}
{% block title %}{% trans %}Moderation{% endtrans %}{% endblock %}
{% block subtitle %}{% trans %}Activity logs{% endtrans %}{% endblock %}
{% block content %}
<div class="row">
<div class="col">
<div class="table-responsive">
<table class="table table-striped table-head-fixed table-hover">
<thead>
<tr>
<th>{% trans %}Category{% endtrans %}</th>
<th>{% trans %}Timestamp{% endtrans %}</th>
<th>{% trans %}Scope{% endtrans %}</th>
<th>{% trans %}Message{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td>{{ macros.history_category_icons(event) }} {{ event.category|upper }}</td>
<td>{{ event.created_at.isoformat(sep=" ", timespec="seconds") }}</td>
<td>
{% if event.user.name %}
<span class="badge border rounded-pill
bg-secondary-subtle border-secondary-subtle
text-secondary-emphasis">
{{ render_icon("person-fill") }}{{ event.user.name }}
</span>
{% endif %}
{% if event.group.groupname %}
<span class="badge border rounded-pill
bg-secondary-subtle border-secondary-subtle
text-secondary-emphasis">{{ render_icon("diagram-3-fill") }}
{{ event.group.groupname }}
</span>
{% endif %}
</td>
<td>
<b>{% if event.actor.is_admin %}{{ render_icon("shield-shaded") }}{% endif %}
{{ event.actor.name or event.user.name }}
</b>
{{ event.description }}
{% if event.comment %}
<br><span class="text-secondary-emphasis">{{ event.comment }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if (events.page) and (events.has_next or events.has_prev) %}
{{ render_pagination(events, align="center") }}
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block actions %}
<a href="{{ url_for(".pending_profiles") }}" class="btn btn-outline-primary">
{% trans %}Pending profiles{% endtrans %}</a>
{% endblock %}