This commit is contained in:
Alexandre Aubin 2024-08-31 18:14:01 +00:00 committed by GitHub
commit 6c1389257a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 116 additions and 151 deletions

View file

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: [3.11]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
@ -26,13 +26,13 @@ jobs:
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install tox tox-gh-actions pip install tox tox-gh-actions
- name: Test with tox - name: Test with tox
run: tox -e py39-pytest run: tox -e py311-pytest
invalidcode: invalidcode:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: [3.11]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
@ -44,6 +44,6 @@ jobs:
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install tox tox-gh-actions pip install tox tox-gh-actions
- name: Linter - name: Linter
run: tox -e py39-invalidcode run: tox -e py311-invalidcode
- name: Mypy - name: Mypy
run: tox -e py39-mypy run: tox -e py311-mypy

17
debian/changelog vendored
View file

@ -1,3 +1,20 @@
moulinette (12.0.2) testing; urgency=low
- portal-api: Bypass CSRF protection for login route ([#340](http://github.com/YunoHost/moulinette/pull/340))
- portal-api: login/logout redirect to referer when param referer_redirect is set ([#339](http://github.com/YunoHost/moulinette/pull/339))
Thanks to all contributors <3 ! (selfhoster1312)
-- Alexandre Aubin <alex.aubin@mailoo.org> Sat, 31 Aug 2024 20:12:43 +0200
moulinette (12.0.1) testing; urgency=low
- Tweaks and fixes for new portal API / ssowat refactoring ([#341](https://github.com/YunoHost/moulinette/pull/341))
- Simplify logging : unecessary messages + obscure concept of "action id" ([#337](https://github.com/YunoHost/moulinette/pull/337))
- Misc tweaks to adapt code and tests to Python 3.11
-- Alexandre Aubin <alex.aubin@mailoo.org> Thu, 04 May 2023 20:30:19 +0200
moulinette (11.2.1) stable; urgency=low moulinette (11.2.1) stable; urgency=low
- repo chores: various black enhancements - repo chores: various black enhancements

View file

@ -53,7 +53,7 @@ class Moulinette:
# Easy access to interfaces # Easy access to interfaces
def api(host="localhost", port=80, routes={}, actionsmap=None, locales_dir=None): def api(host="localhost", port=80, routes={}, actionsmap=None, locales_dir=None, allowed_cors_origins=[]):
"""Web server (API) interface """Web server (API) interface
Run a HTTP server with the moulinette for an API usage. Run a HTTP server with the moulinette for an API usage.
@ -73,6 +73,7 @@ def api(host="localhost", port=80, routes={}, actionsmap=None, locales_dir=None)
Api( Api(
routes=routes, routes=routes,
actionsmap=actionsmap, actionsmap=actionsmap,
allowed_cors_origins=allowed_cors_origins,
).run(host, port) ).run(host, port)
except MoulinetteError as e: except MoulinetteError as e:
import logging import logging

View file

@ -19,7 +19,6 @@ from moulinette.core import (
MoulinetteValidationError, MoulinetteValidationError,
) )
from moulinette.interfaces import BaseActionsMapParser from moulinette.interfaces import BaseActionsMapParser
from moulinette.utils.log import start_action_logging
from moulinette.utils.filesystem import read_yaml from moulinette.utils.filesystem import read_yaml
logger = logging.getLogger("moulinette.actionsmap") logger = logging.getLogger("moulinette.actionsmap")
@ -392,8 +391,6 @@ class ActionsMap:
self.from_cache = False self.from_cache = False
logger.debug("loading actions map")
actionsmap_yml_dir = os.path.dirname(actionsmap_yml) actionsmap_yml_dir = os.path.dirname(actionsmap_yml)
actionsmap_yml_file = os.path.basename(actionsmap_yml) actionsmap_yml_file = os.path.basename(actionsmap_yml)
actionsmap_yml_stat = os.stat(actionsmap_yml) actionsmap_yml_stat = os.stat(actionsmap_yml)
@ -461,7 +458,7 @@ class ActionsMap:
# Load and initialize the authenticator module # Load and initialize the authenticator module
auth_module = f"{self.namespace}.authenticators.{auth_method}" auth_module = f"{self.namespace}.authenticators.{auth_method}"
logger.debug(f"Loading auth module {auth_module}") #logger.debug(f"Loading auth module {auth_module}")
try: try:
mod = import_module(auth_module) mod = import_module(auth_module)
except ImportError as e: except ImportError as e:
@ -556,17 +553,7 @@ class ActionsMap:
logger.exception(error_message) logger.exception(error_message)
raise MoulinetteError(error_message, raw_msg=True) raise MoulinetteError(error_message, raw_msg=True)
else: else:
log_id = start_action_logging() logger.debug("processing action '%s'", full_action_name)
if logger.isEnabledFor(logging.DEBUG):
# Log arguments in debug mode only for safety reasons
logger.debug(
"processing action [%s]: %s with args=%s",
log_id,
full_action_name,
arguments,
)
else:
logger.debug("processing action [%s]: %s", log_id, full_action_name)
# Load translation and process the action # Load translation and process the action
start = time() start = time()
@ -574,7 +561,7 @@ class ActionsMap:
return func(**arguments) return func(**arguments)
finally: finally:
stop = time() stop = time()
logger.debug("action [%s] executed in %.3fs", log_id, stop - start) logger.debug("action executed in %.3fs", stop - start)
# Private methods # Private methods
@ -592,9 +579,6 @@ class ActionsMap:
""" """
logger.debug("building parser...")
start = time()
interface_type = top_parser.interface interface_type = top_parser.interface
# If loading from cache, extra were already checked when cache was # If loading from cache, extra were already checked when cache was
@ -711,5 +695,4 @@ class ActionsMap:
else: else:
action_parser.want_to_take_lock = True action_parser.want_to_take_lock = True
logger.debug("building parser took %.3fs", time() - start)
return top_parser return top_parser

View file

@ -32,8 +32,7 @@ class BaseActionsMapParser:
""" """
def __init__(self, parent=None, **kwargs): def __init__(self, parent=None, **kwargs):
if not parent: pass
logger.debug("initializing base actions map parser for %s", self.interface)
# Virtual properties # Virtual properties
# Each parser classes must implement these properties. # Each parser classes must implement these properties.

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import sys
import re import re
import errno import errno
import logging import logging
@ -13,7 +13,7 @@ from gevent import sleep
from gevent.queue import Queue from gevent.queue import Queue
from geventwebsocket import WebSocketError from geventwebsocket import WebSocketError
from bottle import request, response, Bottle, HTTPResponse, FileUpload from bottle import redirect, request, response, Bottle, HTTPResponse, FileUpload
from bottle import abort from bottle import abort
from moulinette import m18n, Moulinette from moulinette import m18n, Moulinette
@ -30,7 +30,7 @@ from moulinette.interfaces import (
) )
from moulinette.utils import log from moulinette.utils import log
logger = log.getLogger("moulinette.interface.api") logger = logging.getLogger("moulinette.interface.api")
# API helpers ---------------------------------------------------------- # API helpers ----------------------------------------------------------
@ -269,13 +269,14 @@ class _ActionsMapPlugin:
name="login", name="login",
method="POST", method="POST",
callback=self.login, callback=self.login,
skip=["actionsmap"], skip=[filter_csrf, "actionsmap"],
) )
app.route( app.route(
"/logout", "/logout",
name="logout", name="logout",
method="GET", method="GET",
callback=self.logout, callback=self.logout,
# No need to bypass CSRF here because filter allows GET requests
skip=["actionsmap"], skip=["actionsmap"],
) )
@ -309,6 +310,9 @@ class _ActionsMapPlugin:
return value return value
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if request.get_header("Content-Type") == "application/json":
return callback((request.method, context.rule), request.json)
params = kwargs params = kwargs
# Format boolean params # Format boolean params
for a in args: for a in args:
@ -350,11 +354,21 @@ class _ActionsMapPlugin:
""" """
if "credentials" not in request.params: if request.get_header("Content-Type") == "application/json":
raise HTTPResponse("Missing credentials parameter", 400) if "credentials" not in request.json:
credentials = request.params["credentials"] raise HTTPResponse("Missing credentials parameter", 400)
credentials = request.json["credentials"]
profile = request.json.get("profile", self.actionsmap.default_authentication)
else:
if "credentials" in request.params:
credentials = request.params["credentials"]
elif "username" in request.params and "password" in request.params:
credentials = request.params["username"] + ":" + request.params["password"]
else:
raise HTTPResponse("Missing credentials parameter", 400)
profile = request.params.get("profile", self.actionsmap.default_authentication)
profile = request.params.get("profile", self.actionsmap.default_authentication)
authenticator = self.actionsmap.get_authenticator(profile) authenticator = self.actionsmap.get_authenticator(profile)
try: try:
@ -367,13 +381,18 @@ class _ActionsMapPlugin:
raise HTTPResponse(e.strerror, 401) raise HTTPResponse(e.strerror, 401)
else: else:
authenticator.set_session_cookie(auth_infos) authenticator.set_session_cookie(auth_infos)
return m18n.g("logged_in") referer = request.get_header("Referer")
if "referer_redirect" in request.params and referer:
redirect(referer)
else:
return m18n.g("logged_in")
# This is called before each time a route is going to be processed # This is called before each time a route is going to be processed
def authenticate(self, authenticator): def authenticate(self, authenticator):
try: try:
session_infos = authenticator.get_session_cookie() session_infos = authenticator.get_session_cookie()
except Exception: except Exception:
authenticator.delete_session_cookie()
msg = m18n.g("authentication_required") msg = m18n.g("authentication_required")
raise HTTPResponse(msg, 401) raise HTTPResponse(msg, 401)
@ -390,7 +409,11 @@ class _ActionsMapPlugin:
else: else:
# Delete cookie and clean the session # Delete cookie and clean the session
authenticator.delete_session_cookie() authenticator.delete_session_cookie()
return m18n.g("logged_out") referer = request.get_header("Referer")
if "referer_redirect" in request.params and referer:
redirect(referer)
else:
return m18n.g("logged_in")
def messages(self): def messages(self):
"""Listen to the messages WebSocket stream """Listen to the messages WebSocket stream
@ -457,6 +480,7 @@ class _ActionsMapPlugin:
tb = traceback.format_exc() tb = traceback.format_exc()
logs = {"route": _route, "arguments": arguments, "traceback": tb} logs = {"route": _route, "arguments": arguments, "traceback": tb}
print(tb, file=sys.stderr)
return HTTPResponse(json_encode(logs), 500) return HTTPResponse(json_encode(logs), 500)
else: else:
return format_for_response(ret) return format_for_response(ret)
@ -702,9 +726,11 @@ class Interface:
type = "api" type = "api"
def __init__(self, routes={}, actionsmap=None): def __init__(self, routes={}, actionsmap=None, allowed_cors_origins=[]):
actionsmap = ActionsMap(actionsmap, ActionsMapParser()) actionsmap = ActionsMap(actionsmap, ActionsMapParser())
self.allowed_cors_origins = allowed_cors_origins
# Attempt to retrieve log queues from an APIQueueHandler # Attempt to retrieve log queues from an APIQueueHandler
handler = log.getHandlersByClass(APIQueueHandler, limit=1) handler = log.getHandlersByClass(APIQueueHandler, limit=1)
if handler: if handler:
@ -714,11 +740,22 @@ class Interface:
# TODO: Return OK to 'OPTIONS' xhr requests (l173) # TODO: Return OK to 'OPTIONS' xhr requests (l173)
app = Bottle(autojson=True) app = Bottle(autojson=True)
# Wrapper which sets proper header def cors(callback):
def apiheader(callback):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
response.set_header("Access-Control-Allow-Origin", "*") try:
return callback(*args, **kwargs) r = callback(*args, **kwargs)
except HTTPResponse as e:
r = e
origin = request.headers.environ.get("HTTP_ORIGIN", "")
if origin and origin in self.allowed_cors_origins:
resp = r if isinstance(r, HTTPResponse) else response
resp.headers['Access-Control-Allow-Origin'] = origin
resp.headers['Access-Control-Allow-Methods'] = 'GET, HEAD, POST, PUT, OPTIONS, DELETE'
resp.headers['Access-Control-Allow-Headers'] = 'Origin, Accept, Content-Type, X-Requested-With, X-CSRF-Token'
resp.headers['Access-Control-Allow-Credentials'] = 'true'
return r
return wrapper return wrapper
@ -727,7 +764,7 @@ class Interface:
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
try: try:
locale = request.params.pop("locale") locale = request.params.pop("locale")
except KeyError: except (KeyError, ValueError):
locale = m18n.default_locale locale = m18n.default_locale
m18n.set_locale(locale) m18n.set_locale(locale)
return callback(*args, **kwargs) return callback(*args, **kwargs)
@ -736,7 +773,7 @@ class Interface:
# Install plugins # Install plugins
app.install(filter_csrf) app.install(filter_csrf)
app.install(apiheader) app.install(cors)
app.install(api18n) app.install(api18n)
actionsmapplugin = _ActionsMapPlugin(actionsmap, log_queues) actionsmapplugin = _ActionsMapPlugin(actionsmap, log_queues)
app.install(actionsmapplugin) app.install(actionsmapplugin)
@ -745,6 +782,12 @@ class Interface:
self.display = actionsmapplugin.display self.display = actionsmapplugin.display
self.prompt = actionsmapplugin.prompt self.prompt = actionsmapplugin.prompt
def handle_options():
return HTTPResponse("", 204)
app.route('/<:re:.*>', method="OPTIONS",
callback=handle_options, skip=["actionsmap"])
# Append additional routes # Append additional routes
# TODO: Add optional authentication to those routes? # TODO: Add optional authentication to those routes?
for (m, p), c in routes.items(): for (m, p), c in routes.items():

View file

@ -50,7 +50,7 @@ def monkey_get_action_name(argument):
argparse._get_action_name = monkey_get_action_name argparse._get_action_name = monkey_get_action_name
logger = log.getLogger("moulinette.cli") logger = logging.getLogger("moulinette.cli")
# CLI helpers ---------------------------------------------------------- # CLI helpers ----------------------------------------------------------
@ -234,28 +234,26 @@ class TTYHandler(logging.StreamHandler):
log.CRITICAL: "red", log.CRITICAL: "red",
} }
def __init__(self, message_key="fmessage"): def __init__(self, message_key="message_with_color"):
logging.StreamHandler.__init__(self) logging.StreamHandler.__init__(self)
self.message_key = message_key self.message_key = message_key
def format(self, record): def format(self, record):
"""Enhance message with level and colors if supported.""" """Enhance message with level and colors if supported."""
msg = record.getMessage() msg = record.getMessage()
level = record.levelname
level_with_color = level
if self.supports_color(): if self.supports_color():
level = "" if self.level > log.DEBUG and record.levelname in ["SUCCESS", "WARNING", "ERROR", "INFO"]:
if self.level <= log.DEBUG: level = m18n.g(record.levelname.lower())
# add level name before message
level = "%s " % record.levelname
elif record.levelname in ["SUCCESS", "WARNING", "ERROR", "INFO"]:
# add translated level name before message
level = "%s " % m18n.g(record.levelname.lower())
color = self.LEVELS_COLOR.get(record.levelno, "white") color = self.LEVELS_COLOR.get(record.levelno, "white")
msg = "{}{}{}{}".format(colors_codes[color], level, END_CLI_COLOR, msg) level_with_color = f"{colors_codes[color]}{level}{END_CLI_COLOR}"
if self.level == log.DEBUG:
level_with_color = level_with_color + " " * max(0, 7 - len(level))
if self.formatter: if self.formatter:
# use user-defined formatter record.__dict__["level_with_color"] = level_with_color
record.__dict__[self.message_key] = msg
return self.formatter.format(record) return self.formatter.format(record)
return msg return level_with_color + " " + msg
def emit(self, record): def emit(self, record):
# set proper stream first # set proper stream first

View file

@ -6,7 +6,6 @@ from logging import (
addLevelName, addLevelName,
setLoggerClass, setLoggerClass,
Logger, Logger,
getLogger,
NOTSET, # noqa NOTSET, # noqa
DEBUG, DEBUG,
INFO, INFO,
@ -109,88 +108,14 @@ class MoulinetteLogger(Logger):
f = currentframe() f = currentframe()
if f is not None: if f is not None:
f = f.f_back f = f.f_back
rv = "(unknown file)", 0, "(unknown function)" rv = "(unknown file)", 0, "(unknown function)", None
while hasattr(f, "f_code"): while hasattr(f, "f_code"):
co = f.f_code co = f.f_code
filename = os.path.normcase(co.co_filename) filename = os.path.normcase(co.co_filename)
if filename == _srcfile or filename == __file__: if filename == _srcfile or filename == __file__:
f = f.f_back f = f.f_back
continue continue
rv = (co.co_filename, f.f_lineno, co.co_name) rv = (co.co_filename, f.f_lineno, co.co_name, None)
break break
return rv return rv
def _log(self, *args, **kwargs):
"""Append action_id if available to the extra."""
if self.action_id is not None:
extra = kwargs.get("extra", {})
if "action_id" not in extra:
# FIXME: Get real action_id instead of logger/current one
extra["action_id"] = _get_action_id()
kwargs["extra"] = extra
return super()._log(*args, **kwargs)
# Action logging -------------------------------------------------------
pid = os.getpid()
action_id = 0
def _get_action_id():
return "%d.%d" % (pid, action_id)
def start_action_logging():
"""Configure logging for a new action
Returns:
The new action id
"""
global action_id
action_id += 1
return _get_action_id()
def getActionLogger(name=None, logger=None, action_id=None):
"""Get the logger adapter for an action
Return a logger for the specified name - or use given logger - and
optionally for a given action id, retrieving it if necessary.
Either a name or a logger must be specified.
"""
if not name and not logger:
raise ValueError("Either a name or a logger must be specified")
logger = logger or getLogger(name)
logger.action_id = action_id if action_id else _get_action_id()
return logger
class ActionFilter:
"""Extend log record for an optionnal action
Filter a given record and look for an `action_id` key. If it is not found
and `strict` is True, the record will not be logged. Otherwise, the key
specified by `message_key` will be added to the record, containing the
message formatted for the action or just the original one.
"""
def __init__(self, message_key="fmessage", strict=False):
self.message_key = message_key
self.strict = strict
def filter(self, record):
msg = record.getMessage()
action_id = record.__dict__.get("action_id", None)
if action_id is not None:
msg = "[{:s}] {:s}".format(action_id, msg)
elif self.strict:
return False
record.__dict__[self.message_key] = msg
return True

View file

@ -82,7 +82,7 @@ def call_async_output(args, callback, **kwargs):
while p.poll() is None: while p.poll() is None:
while True: while True:
try: try:
callback, message = log_queue.get(True, 1) callback, message = log_queue.get(True, 0.1)
except queue.Empty: except queue.Empty:
break break

View file

@ -60,7 +60,7 @@ setup(
license="AGPL", license="AGPL",
packages=find_packages(exclude=["test"]), packages=find_packages(exclude=["test"]),
data_files=[("/usr/share/moulinette/locales", locale_files)], data_files=[("/usr/share/moulinette/locales", locale_files)],
python_requires=">=3.7.0,<3.10", python_requires=">=3.11.0,<3.12",
install_requires=install_deps, install_requires=install_deps,
tests_require=test_deps, tests_require=test_deps,
extras_require=extras, extras_require=extras,

View file

@ -46,7 +46,7 @@ def logging_configuration(moulinette):
"format": "%(asctime)-15s %(levelname)-8s %(name)s %(funcName)s - %(fmessage)s" # noqa "format": "%(asctime)-15s %(levelname)-8s %(name)s %(funcName)s - %(fmessage)s" # noqa
}, },
}, },
"filters": {"action": {"()": "moulinette.utils.log.ActionFilter"}}, "filters": {},
"handlers": { "handlers": {
"api": { "api": {
"level": level, "level": level,

View file

@ -180,7 +180,7 @@ class TestAuthAPI:
def test_request_arg_without_action(self, moulinette_webapi, caplog, mocker): def test_request_arg_without_action(self, moulinette_webapi, caplog, mocker):
self.login(moulinette_webapi) self.login(moulinette_webapi)
moulinette_webapi.get("/test-auth", status=404) moulinette_webapi.get("/test-auth", status=405)
class TestAuthCLI: class TestAuthCLI:

21
tox.ini
View file

@ -1,6 +1,6 @@
[tox] [tox]
envlist = envlist =
py{37,39}-{pytest,lint,invalidcode,mypy} py311-{pytest,lint,invalidcode,mypy}
format format
format-check format-check
docs docs
@ -11,20 +11,19 @@ usedevelop = True
passenv = * passenv = *
extras = tests extras = tests
deps = deps =
py{37,39}-pytest: .[tests] py311-pytest: .[tests]
py{37,39}-lint: flake8 py311-lint: flake8
py{37,39}-invalidcode: flake8 py311-invalidcode: flake8
py{37,39}-mypy: mypy >= 0.761 py311-mypy: mypy >= 0.761
commands = commands =
py{37,39}-pytest: pytest {posargs} -c pytest.ini py311-pytest: pytest {posargs} -c pytest.ini
py{37,39}-lint: flake8 moulinette test py311-lint: flake8 moulinette test
py{37,39}-invalidcode: flake8 moulinette test --select F py311-invalidcode: flake8 moulinette test --select F
py{37,39}-mypy: mypy --ignore-missing-imports --install-types --non-interactive moulinette/ py311-mypy: mypy --ignore-missing-imports --install-types --non-interactive moulinette/
[gh-actions] [gh-actions]
python = python =
3.7: py37 3.11: py311
3.9: py39
[testenv:format] [testenv:format]
basepython = python3 basepython = python3