Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bookworm + portal rework #342

Merged
merged 31 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e8f10ce
Update changelog for 12.0.0
alexAubin May 4, 2023
34a2a66
Fix boring login API expecting a weird form/multiparam thing instead …
alexAubin Jul 14, 2023
0f056d6
Moulinette logging is an unecessarily complex mess, episode 57682
alexAubin Jul 17, 2023
3ad7335
Merge branch 'dev' into bookworm
Tagadda Jul 20, 2023
4104704
allow json requests
Axolotle Jul 28, 2023
328107c
api: Add a proper mechanism to allow specific, configurable CORS origins
alexAubin Jul 29, 2023
a6c7e55
api: fix authentication failure not deleting expired cookies
alexAubin Jul 29, 2023
e24d56d
/yunohost/sso/log{in,out} 303 to referer when GET/POST param referer_…
selfhoster1312 Aug 14, 2023
7daa504
Bypass CSRF protection for the /yunohost/portalapi/login route
selfhoster1312 Aug 14, 2023
8016725
Merge pull request #337 from YunoHost/logging-is-a-mess
alexAubin Sep 27, 2023
75f522b
Merge pull request #341 from YunoHost/portal-api
alexAubin Sep 27, 2023
2696e81
quality: make linter gods happy
alexAubin Sep 27, 2023
7210b66
quality: update tox.ini, bookworm has python 3.11
alexAubin Sep 27, 2023
37331cb
quality: fix test/conftest.py, there's no ActionFilter anymore
alexAubin Sep 27, 2023
fc1eef2
quality: we're in python 3.11 bruh
alexAubin Sep 27, 2023
8c28a57
quality: we're in python 3.11 bruh²
alexAubin Sep 27, 2023
bd9736e
quality: we're in python 3.11 bruh³
alexAubin Sep 27, 2023
f562a9b
fix old logger mechanism remants
alexAubin Sep 27, 2023
20d3b82
fix test ... apparently the API now returns 405 when no action is spe…
alexAubin Sep 27, 2023
924fd78
cors: fix some http response error not being catched by cors decorator
Axolotle Nov 8, 2023
d53dfc2
debug: print stacktrace to stderr upon 500 errors, because otherwise …
alexAubin Nov 13, 2023
976aac0
Do not log about loading auth module, it creates tricky issue when ma…
alexAubin Nov 27, 2023
cfb840c
perf: in call_async_output: only wait for 0.1 sec, should speed up th…
alexAubin Nov 27, 2023
bb0a9bd
Merge remote-tracking branch 'origin/dev' into bookworm
alexAubin Jul 17, 2024
9cc786e
Update changelog for 12.0.1 testing
alexAubin Jul 26, 2024
0d7a143
Merge pull request #340 from selfhoster1312/bypass-csrf-login
alexAubin Aug 20, 2024
6f09185
Merge pull request #339 from selfhoster1312/redirect-referer
alexAubin Aug 20, 2024
709585b
Update changelog for 12.0.2
alexAubin Aug 31, 2024
efeaecb
Merge branch 'dev' into bookworm
tituspijean Oct 30, 2024
4ea830e
Update changelog for 12.0.3
tituspijean Oct 30, 2024
9d78118
Merge branch 'dev' into bookworm
alexAubin Oct 31, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/tox.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
python-version: [3.11]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
Expand All @@ -26,13 +26,13 @@ jobs:
python -m pip install --upgrade pip
pip install tox tox-gh-actions
- name: Test with tox
run: tox -e py39-pytest
run: tox -e py311-pytest

invalidcode:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
python-version: [3.11]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
Expand All @@ -44,6 +44,6 @@ jobs:
python -m pip install --upgrade pip
pip install tox tox-gh-actions
- name: Linter
run: tox -e py39-invalidcode
run: tox -e py311-invalidcode
- name: Mypy
run: tox -e py39-mypy
run: tox -e py311-mypy
23 changes: 23 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
moulinette (12.0.3) stable; urgency=low

- [i18n] Merge branch 'dev' (11.2.2) into bookworm (efeaecbb)

-- tituspijean <tituspijean@yunohost.org> Wed, 30 Oct 2024 22:56:09 +0100

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.3.0) stable; urgency=low

- Bump version for 11.3 release
Expand Down
4 changes: 2 additions & 2 deletions maintenance/make_changelog.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
VERSION="11.2.2"
VERSION="12.0.3"
RELEASE="stable"
REPO=$(basename $(git rev-parse --show-toplevel))
REPO_URL=$(git remote get-url origin)
ME=$(git config --get user.name)
EMAIL=$(git config --get user.email)

LAST_RELEASE=$(git tag --list 'debian/11.*' --sort="v:refname" | tail -n 1)
LAST_RELEASE=$(git tag --list 'debian/12.*' --sort="v:refname" | tail -n 1)

echo "$REPO ($VERSION) $RELEASE; urgency=low"
echo ""
Expand Down
3 changes: 2 additions & 1 deletion moulinette/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def interface(cls):


# 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

Run a HTTP server with the moulinette for an API usage.
Expand All @@ -73,6 +73,7 @@ def api(host="localhost", port=80, routes={}, actionsmap=None, locales_dir=None)
Api(
routes=routes,
actionsmap=actionsmap,
allowed_cors_origins=allowed_cors_origins,
).run(host, port)
except MoulinetteError as e:
import logging
Expand Down
23 changes: 3 additions & 20 deletions moulinette/actionsmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
MoulinetteValidationError,
)
from moulinette.interfaces import BaseActionsMapParser
from moulinette.utils.log import start_action_logging
from moulinette.utils.filesystem import read_yaml

logger = logging.getLogger("moulinette.actionsmap")
Expand Down Expand Up @@ -392,8 +391,6 @@ def __init__(self, actionsmap_yml, top_parser, load_only_category=None):

self.from_cache = False

logger.debug("loading actions map")

actionsmap_yml_dir = os.path.dirname(actionsmap_yml)
actionsmap_yml_file = os.path.basename(actionsmap_yml)
actionsmap_yml_stat = os.stat(actionsmap_yml)
Expand Down Expand Up @@ -461,7 +458,7 @@ def get_authenticator(self, auth_method):

# Load and initialize the authenticator module
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:
mod = import_module(auth_module)
except ImportError as e:
Expand Down Expand Up @@ -556,25 +553,15 @@ def process(self, args, timeout=None, **kwargs):
logger.exception(error_message)
raise MoulinetteError(error_message, raw_msg=True)
else:
log_id = start_action_logging()
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)
logger.debug("processing action '%s'", full_action_name)

# Load translation and process the action
start = time()
try:
return func(**arguments)
finally:
stop = time()
logger.debug("action [%s] executed in %.3fs", log_id, stop - start)
logger.debug("action executed in %.3fs", stop - start)

# Private methods

Expand All @@ -592,9 +579,6 @@ def _construct_parser(self, actionsmap, top_parser):

"""

logger.debug("building parser...")
start = time()

interface_type = top_parser.interface

# If loading from cache, extra were already checked when cache was
Expand Down Expand Up @@ -711,5 +695,4 @@ def _construct_parser(self, actionsmap, top_parser):
else:
action_parser.want_to_take_lock = True

logger.debug("building parser took %.3fs", time() - start)
return top_parser
3 changes: 1 addition & 2 deletions moulinette/interfaces/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ class BaseActionsMapParser:
"""

def __init__(self, parent=None, **kwargs):
if not parent:
logger.debug("initializing base actions map parser for %s", self.interface)
pass

# Virtual properties
# Each parser classes must implement these properties.
Expand Down
77 changes: 60 additions & 17 deletions moulinette/interfaces/api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-

import sys
import re
import errno
import logging
Expand All @@ -13,7 +13,7 @@
from gevent.queue import Queue
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 moulinette import m18n, Moulinette
Expand All @@ -30,7 +30,7 @@
)
from moulinette.utils import log

logger = log.getLogger("moulinette.interface.api")
logger = logging.getLogger("moulinette.interface.api")


# API helpers ----------------------------------------------------------
Expand Down Expand Up @@ -269,13 +269,14 @@ def setup(self, app):
name="login",
method="POST",
callback=self.login,
skip=["actionsmap"],
skip=[filter_csrf, "actionsmap"],
)
app.route(
"/logout",
name="logout",
method="GET",
callback=self.logout,
# No need to bypass CSRF here because filter allows GET requests
skip=["actionsmap"],
)

Expand Down Expand Up @@ -309,6 +310,9 @@ def _format(value):
return value

def wrapper(*args, **kwargs):
if request.get_header("Content-Type") == "application/json":
return callback((request.method, context.rule), request.json)

params = kwargs
# Format boolean params
for a in args:
Expand Down Expand Up @@ -350,11 +354,21 @@ def login(self):

"""

if "credentials" not in request.params:
raise HTTPResponse("Missing credentials parameter", 400)
credentials = request.params["credentials"]
if request.get_header("Content-Type") == "application/json":
if "credentials" not in request.json:
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)

try:
Expand All @@ -367,13 +381,18 @@ def login(self):
raise HTTPResponse(e.strerror, 401)
else:
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
def authenticate(self, authenticator):
try:
session_infos = authenticator.get_session_cookie()
except Exception:
authenticator.delete_session_cookie()
msg = m18n.g("authentication_required")
raise HTTPResponse(msg, 401)

Expand All @@ -390,7 +409,11 @@ def logout(self):
else:
# Delete cookie and clean the session
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):
"""Listen to the messages WebSocket stream
Expand Down Expand Up @@ -457,6 +480,7 @@ def process(self, _route, arguments={}):

tb = traceback.format_exc()
logs = {"route": _route, "arguments": arguments, "traceback": tb}
print(tb, file=sys.stderr)
return HTTPResponse(json_encode(logs), 500)
else:
return format_for_response(ret)
Expand Down Expand Up @@ -702,9 +726,11 @@ class Interface:

type = "api"

def __init__(self, routes={}, actionsmap=None):
def __init__(self, routes={}, actionsmap=None, allowed_cors_origins=[]):
actionsmap = ActionsMap(actionsmap, ActionsMapParser())

self.allowed_cors_origins = allowed_cors_origins

# Attempt to retrieve log queues from an APIQueueHandler
handler = log.getHandlersByClass(APIQueueHandler, limit=1)
if handler:
Expand All @@ -714,11 +740,22 @@ def __init__(self, routes={}, actionsmap=None):
# TODO: Return OK to 'OPTIONS' xhr requests (l173)
app = Bottle(autojson=True)

# Wrapper which sets proper header
def apiheader(callback):
def cors(callback):
def wrapper(*args, **kwargs):
response.set_header("Access-Control-Allow-Origin", "*")
return callback(*args, **kwargs)
try:
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

Expand All @@ -727,7 +764,7 @@ def api18n(callback):
def wrapper(*args, **kwargs):
try:
locale = request.params.pop("locale")
except KeyError:
except (KeyError, ValueError):
locale = m18n.default_locale
m18n.set_locale(locale)
return callback(*args, **kwargs)
Expand All @@ -736,7 +773,7 @@ def wrapper(*args, **kwargs):

# Install plugins
app.install(filter_csrf)
app.install(apiheader)
app.install(cors)
app.install(api18n)
actionsmapplugin = _ActionsMapPlugin(actionsmap, log_queues)
app.install(actionsmapplugin)
Expand All @@ -745,6 +782,12 @@ def wrapper(*args, **kwargs):
self.display = actionsmapplugin.display
self.prompt = actionsmapplugin.prompt

def handle_options():
return HTTPResponse("", 204)

app.route('/<:re:.*>', method="OPTIONS",
callback=handle_options, skip=["actionsmap"])

# Append additional routes
# TODO: Add optional authentication to those routes?
for (m, p), c in routes.items():
Expand Down
Loading
Loading