Coverage for webapp/decorators.py: 90%
59 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 22:43 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 22:43 +0000
1# Core packages
2import functools
3import logging
4from datetime import datetime, timezone
6# Third party packages
7import flask
9from canonicalwebteam.store_api.dashboard import Dashboard
10from canonicalwebteam.store_api.publishergw import PublisherGW
12from webapp import authentication
13from webapp.helpers import api_publisher_session
15publisher_gateway = PublisherGW(api_publisher_session)
16_dashboard = Dashboard(api_publisher_session)
17logger = logging.getLogger(__name__)
19# Per-<snap_name> endpoints that must stay reachable even when the snap has
20# no published revisions.
21_UNRELEASED_GATE_SKIP_ENDPOINTS = frozenset(
22 {
23 "publisher_snaps.delete_package",
24 "publisher_snaps.get_package_metadata",
25 "publisher_snaps.get_is_user_snap",
26 "publisher_snaps.post_github_webhook",
27 }
28)
31def gate_unreleased_snap_pages():
32 """
33 Block state-changing per-<snap_name> publisher requests when the snap has
34 no published revisions. Read requests pass through so the page can render
35 with a warning banner, but saves are rejected to prevent the dashboard API
36 from returning opaque errors mid-flow.
37 """
39 # Page itself needs to load
40 if flask.request.method in ("GET", "HEAD", "OPTIONS"):
41 return None
42 if not flask.request.view_args:
43 return None
44 snap_name = flask.request.view_args.get("snap_name")
45 if not snap_name:
46 return None
47 if flask.request.endpoint in _UNRELEASED_GATE_SKIP_ENDPOINTS:
48 return None
49 if not authentication.is_authenticated(flask.session):
50 return None
52 try:
53 history = _dashboard.snap_release_history(flask.session, snap_name, 1)
54 except Exception:
55 # If we can't determine release state (dashboard down, network error,
56 # auth issue), don't block the request. Let the downstream handler
57 # produce its normal response.
58 return None
60 revisions = []
61 if isinstance(history, dict):
62 revisions = history.get("revisions") or []
63 elif isinstance(history, list):
64 revisions = history
66 if revisions:
67 return None
69 return (
70 flask.jsonify(
71 {
72 "success": False,
73 "errors": [
74 {
75 "code": "no-releases",
76 "message": (
77 "Publish a first revision before saving "
78 "changes to this snap."
79 ),
80 }
81 ],
82 }
83 ),
84 403,
85 )
88def login_required(func):
89 """
90 Decorator that checks if a user is logged in, and redirects
91 to login page if not.
92 """
94 @functools.wraps(func)
95 def is_user_logged_in(*args, **kwargs):
96 date = datetime.now(timezone.utc)
97 date_str = date.strftime("%Y-%m-%dT%H:%M:%S")
99 if not authentication.is_authenticated(flask.session):
100 authentication.reset_auth_session(flask.session)
102 logger.warning(
103 "User login failed",
104 extra={
105 "datetime": date_str,
106 "appid": "snapcraft-io",
107 "event": "authn_login_fail",
108 },
109 )
111 return flask.redirect(
112 flask.url_for("login.login_handler", next=flask.request.path)
113 )
115 publisher = flask.session.get("publisher")
116 user = publisher["email"]
118 logger.info(
119 f"User {user} login successfully",
120 extra={
121 "datetime": date_str,
122 "appid": "snapcraft-io",
123 "event": f"authn_login_successafterfail:{user}",
124 },
125 )
127 return func(*args, **kwargs)
129 return is_user_logged_in
132def exchange_required(func):
133 @functools.wraps(func)
134 def is_exchanged(*args, **kwargs):
135 if "exchanged_developer_token" not in flask.session:
136 result = publisher_gateway.exchange_dashboard_macaroons(
137 flask.session
138 )
139 flask.session["developer_token"] = result
140 flask.session["exchanged_developer_token"] = True
141 return func(*args, **kwargs)
143 return is_exchanged