Coverage for webapp/handlers.py: 77%
66 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-18 22:11 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-18 22:11 +0000
1from flask import redirect, render_template, request, session, url_for
2from webapp.config import (
3 SENTRY_DSN,
4 STATUS_BANNER,
5 IS_DEVELOPMENT,
6 VITE_CONFIG,
7)
9from canonicalwebteam.exceptions import (
10 PublisherMacaroonRefreshRequired,
11 StoreApiError,
12 StoreApiResourceNotFound,
13 StoreApiResponseDecodeError,
14 StoreApiResponseError,
15 StoreApiResponseErrorList,
16 StoreApiTimeoutError,
17 StoreApiConnectionError,
18)
20from canonicalwebteam import image_template
22from webapp import authentication, helpers
24CSP = {
25 "default-src": ["'self'"],
26 "img-src": [
27 "'self'",
28 "data: blob:",
29 # This is needed to allow images from
30 # https://www.google.*/ads/ga-audiences to load.
31 "*",
32 ],
33 "script-src-elem": [
34 "'self'",
35 "assets.ubuntu.com",
36 "www.googletagmanager.com",
37 "*.crazyegg.com",
38 "w.usabilla.com",
39 # This is necessary for Google Tag Manager to function properly.
40 "'unsafe-inline'",
41 ],
42 "font-src": [
43 "'self'",
44 "assets.ubuntu.com",
45 ],
46 "script-src": [
47 "'self'",
48 "blob:",
49 "'unsafe-eval'",
50 "'unsafe-hashes'",
51 ],
52 "connect-src": [
53 "'self'",
54 "sentry.is.canonical.com",
55 "*.crazyegg.com",
56 "analytics.google.com",
57 "*.analytics.google.com",
58 "www.google-analytics.com",
59 "stats.g.doubleclick.net",
60 "*.snapcraftcontent.com",
61 ],
62 "frame-src": [
63 "'self'",
64 "td.doubleclick.net",
65 ],
66 "style-src": [
67 "'self'",
68 "'unsafe-inline'",
69 ],
70}
72if IS_DEVELOPMENT:
73 VITE_PORT = VITE_CONFIG["VITE_PORT"]
74 CSP["script-src-elem"].append(f"localhost:{VITE_PORT}")
75 CSP["connect-src"].append(f"localhost:{VITE_PORT}")
76 CSP["connect-src"].append(f"ws://localhost:{VITE_PORT}")
77 CSP["style-src"].append(f"localhost:{VITE_PORT}")
80def charmhub_utility_processor():
81 """
82 This defines the set of properties and functions that will be added
83 to the default context for processing templates. All these items
84 can be used in all templates
85 """
86 if authentication.is_authenticated(session):
87 account = session["account"]
88 else:
89 account = None
90 return {
91 "schedule_banner": helpers.schedule_banner,
92 "account": account,
93 "image": image_template,
94 "SENTRY_DSN": SENTRY_DSN,
95 "STATUS_BANNER": STATUS_BANNER,
96 }
99def set_handlers(app):
100 @app.context_processor
101 def utility_processor():
102 return charmhub_utility_processor()
104 def redirect_to_login():
105 next_url = request.full_path if request.query_string else request.path
106 return redirect(url_for("login.publisher_login", next=next_url))
108 # Error handlers
109 # ===
110 @app.errorhandler(StoreApiTimeoutError)
111 def handle_store_api_timeout(e):
112 status_code = 504
113 return (
114 render_template(
115 "500.html", error_message=str(e), status_code=status_code
116 ),
117 status_code,
118 )
120 @app.errorhandler(StoreApiResourceNotFound)
121 def handle_store_api_circuit_breaker_exception(e):
122 return render_template("404.html", message=str(e)), 404
124 @app.errorhandler(StoreApiResponseErrorList)
125 def handle_store_api_error_list(e):
126 if e.status_code == 401:
127 authentication.empty_session(session)
128 return redirect_to_login()
130 if e.status_code == 404:
131 return render_template("404.html", message="Entity not found"), 404
133 status_code = 502
134 if e.errors:
135 errors = ", ".join([e.get("message") for e in e.errors])
136 return (
137 render_template(
138 "500.html", error_message=errors, status_code=status_code
139 ),
140 status_code,
141 )
143 return (
144 render_template("500.html", status_code=status_code),
145 status_code,
146 )
148 @app.errorhandler(PublisherMacaroonRefreshRequired)
149 def handle_macaroon_refresh_required(_):
150 authentication.empty_session(session)
151 return redirect_to_login()
153 @app.errorhandler(StoreApiResponseDecodeError)
154 @app.errorhandler(StoreApiResponseError)
155 @app.errorhandler(StoreApiConnectionError)
156 @app.errorhandler(StoreApiError)
157 def handle_store_api_error(e):
158 if (
159 isinstance(e, StoreApiResponseError)
160 and getattr(e, "status_code", None) == 401
161 ):
162 authentication.empty_session(session)
163 return redirect_to_login()
165 status_code = 502
166 return (
167 render_template(
168 "500.html", error_message=str(e), status_code=status_code
169 ),
170 status_code,
171 )
173 @app.after_request
174 def add_headers(response):
175 """
176 Security headers to add to all requests
177 - Content-Security-Policy: Restrict resources (e.g., JavaScript, CSS,
178 Images) and URLs
179 - Referrer-Policy: Limit referrer data for security while preserving
180 full referrer for same-origin requests
181 - Cross-Origin-Embedder-Policy: allows embedding cross-origin
182 resources without credentials
183 - Cross-Origin-Opener-Policy: enable the page to open pop-ups while
184 maintaining same-origin policy
185 - Cross-Origin-Resource-Policy: allowing cross-origin requests to
186 access the resource
187 - X-Permitted-Cross-Domain-Policies: disallows cross-domain access to
188 resources
189 """
190 response.headers["Content-Security-Policy"] = helpers.get_csp_as_str(
191 CSP
192 )
193 response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
194 response.headers["Cross-Origin-Embedder-Policy"] = "credentialless"
195 response.headers["Cross-Origin-Opener-Policy"] = (
196 "same-origin-allow-popups"
197 )
198 response.headers["Cross-Origin-Resource-Policy"] = "cross-origin"
199 response.headers["X-Permitted-Cross-Domain-Policies"] = "none"
200 return response