Coverage for webapp/handlers.py: 77%

66 statements  

« 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) 

8 

9from canonicalwebteam.exceptions import ( 

10 PublisherMacaroonRefreshRequired, 

11 StoreApiError, 

12 StoreApiResourceNotFound, 

13 StoreApiResponseDecodeError, 

14 StoreApiResponseError, 

15 StoreApiResponseErrorList, 

16 StoreApiTimeoutError, 

17 StoreApiConnectionError, 

18) 

19 

20from canonicalwebteam import image_template 

21 

22from webapp import authentication, helpers 

23 

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} 

71 

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}") 

78 

79 

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 } 

97 

98 

99def set_handlers(app): 

100 @app.context_processor 

101 def utility_processor(): 

102 return charmhub_utility_processor() 

103 

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)) 

107 

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 ) 

119 

120 @app.errorhandler(StoreApiResourceNotFound) 

121 def handle_store_api_circuit_breaker_exception(e): 

122 return render_template("404.html", message=str(e)), 404 

123 

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() 

129 

130 if e.status_code == 404: 

131 return render_template("404.html", message="Entity not found"), 404 

132 

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 ) 

142 

143 return ( 

144 render_template("500.html", status_code=status_code), 

145 status_code, 

146 ) 

147 

148 @app.errorhandler(PublisherMacaroonRefreshRequired) 

149 def handle_macaroon_refresh_required(_): 

150 authentication.empty_session(session) 

151 return redirect_to_login() 

152 

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() 

164 

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 ) 

172 

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