Coverage for webapp/store/snap_details_views.py: 80%

147 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-08-05 22:06 +0000

1import flask 

2from flask import Response 

3 

4import humanize 

5import os 

6 

7import webapp.helpers as helpers 

8import webapp.metrics.helper as metrics_helper 

9import webapp.metrics.metrics as metrics 

10import webapp.store.logic as logic 

11from webapp import authentication 

12from webapp.markdown import parse_markdown_description 

13 

14from canonicalwebteam.flask_base.decorators import ( 

15 exclude_xframe_options_header, 

16) 

17from canonicalwebteam.exceptions import StoreApiError 

18from canonicalwebteam.store_api.devicegw import DeviceGW 

19from pybadges import badge 

20 

21device_gateway = DeviceGW("snap", helpers.api_session) 

22 

23FIELDS = [ 

24 "title", 

25 "summary", 

26 "description", 

27 "license", 

28 "contact", 

29 "website", 

30 "publisher", 

31 "media", 

32 "download", 

33 "version", 

34 "created-at", 

35 "confinement", 

36 "categories", 

37 "trending", 

38 "unlisted", 

39 "links", 

40] 

41 

42 

43def snap_details_views(store): 

44 snap_regex = "[a-z0-9-]*[a-z][a-z0-9-]*" 

45 snap_regex_upercase = "[A-Za-z0-9-]*[A-Za-z][A-Za-z0-9-]*" 

46 

47 def _get_context_snap_details(snap_name): 

48 details = device_gateway.get_item_details( 

49 snap_name, fields=FIELDS, api_version=2 

50 ) 

51 # 404 for any snap under quarantine 

52 if details["snap"]["publisher"]["username"] == "snap-quarantine": 

53 flask.abort(404, "No snap named {}".format(snap_name)) 

54 

55 # When removing all the channel maps of an existing snap the API, 

56 # responds that the snaps still exists with data. 

57 # Return a 404 if not channel maps, to avoid having a error. 

58 # For example: mir-kiosk-browser 

59 if not details.get("channel-map"): 

60 flask.abort(404, "No snap named {}".format(snap_name)) 

61 

62 formatted_description = parse_markdown_description( 

63 details.get("snap", {}).get("description", "") 

64 ) 

65 

66 channel_maps_list = logic.convert_channel_maps( 

67 details.get("channel-map") 

68 ) 

69 

70 latest_channel = logic.get_last_updated_version( 

71 details.get("channel-map") 

72 ) 

73 

74 default_track = ( 

75 details.get("default-track") 

76 if details.get("default-track") 

77 else "latest" 

78 ) 

79 

80 lowest_risk_available = logic.get_lowest_available_risk( 

81 channel_maps_list, default_track 

82 ) 

83 

84 extracted_info = logic.extract_info_channel_map( 

85 channel_maps_list, default_track, lowest_risk_available 

86 ) 

87 

88 last_updated = latest_channel["channel"]["released-at"] 

89 updates = logic.get_latest_versions( 

90 details.get("channel-map"), default_track, lowest_risk_available 

91 ) 

92 binary_filesize = latest_channel["download"]["size"] 

93 

94 # filter out banner and banner-icon images from screenshots 

95 screenshots = logic.filter_screenshots( 

96 details.get("snap", {}).get("media", []) 

97 ) 

98 

99 icon_url = helpers.get_icon(details.get("snap", {}).get("media", [])) 

100 

101 publisher_info = helpers.get_yaml( 

102 "{}{}.yaml".format( 

103 flask.current_app.config["CONTENT_DIRECTORY"][ 

104 "PUBLISHER_PAGES" 

105 ], 

106 details["snap"]["publisher"]["username"], 

107 ), 

108 typ="safe", 

109 ) 

110 

111 publisher_snaps = helpers.get_yaml( 

112 "{}{}-snaps.yaml".format( 

113 flask.current_app.config["CONTENT_DIRECTORY"][ 

114 "PUBLISHER_PAGES" 

115 ], 

116 details["snap"]["publisher"]["username"], 

117 ), 

118 typ="safe", 

119 ) 

120 

121 publisher_featured_snaps = None 

122 

123 if publisher_info: 

124 publisher_featured_snaps = publisher_info.get("featured_snaps") 

125 publisher_snaps = logic.get_n_random_snaps( 

126 publisher_snaps["snaps"], 4 

127 ) 

128 

129 video = logic.get_video(details.get("snap", {}).get("media", [])) 

130 

131 is_users_snap = False 

132 if authentication.is_authenticated(flask.session): 

133 if ( 

134 flask.session.get("publisher").get("nickname") 

135 == details["snap"]["publisher"]["username"] 

136 ): 

137 is_users_snap = True 

138 

139 # build list of categories of a snap 

140 categories = logic.get_snap_categories( 

141 details.get("snap", {}).get("categories", []) 

142 ) 

143 

144 developer = logic.get_snap_developer(details["name"]) 

145 

146 context = { 

147 "snap-id": details.get("snap-id"), 

148 # Data direct from details API 

149 "snap_title": details["snap"]["title"], 

150 "package_name": details["name"], 

151 "categories": categories, 

152 "icon_url": icon_url, 

153 "version": extracted_info["version"], 

154 "license": details["snap"]["license"], 

155 "publisher": details["snap"]["publisher"]["display-name"], 

156 "username": details["snap"]["publisher"]["username"], 

157 "screenshots": screenshots, 

158 "video": video, 

159 "publisher_snaps": publisher_snaps, 

160 "publisher_featured_snaps": publisher_featured_snaps, 

161 "has_publisher_page": publisher_info is not None, 

162 "contact": details["snap"].get("contact"), 

163 "website": details["snap"].get("website"), 

164 "summary": details["snap"]["summary"], 

165 "description": formatted_description, 

166 "channel_map": channel_maps_list, 

167 "has_stable": logic.has_stable(channel_maps_list), 

168 "developer_validation": details["snap"]["publisher"]["validation"], 

169 "default_track": default_track, 

170 "lowest_risk_available": lowest_risk_available, 

171 "confinement": extracted_info["confinement"], 

172 "trending": details.get("snap", {}).get("trending", False), 

173 # Transformed API data 

174 "filesize": humanize.naturalsize(binary_filesize), 

175 "last_updated": logic.convert_date(last_updated), 

176 "last_updated_raw": last_updated, 

177 "is_users_snap": is_users_snap, 

178 "unlisted": details.get("snap", {}).get("unlisted", False), 

179 "developer": developer, 

180 # TODO: This is horrible and hacky 

181 "appliances": { 

182 "adguard-home": "adguard", 

183 "mosquitto": "mosquitto", 

184 "nextcloud": "nextcloud", 

185 "plexmediaserver": "plex", 

186 "openhab": "openhab", 

187 }, 

188 "links": details["snap"].get("links"), 

189 "updates": updates, 

190 } 

191 

192 return context 

193 

194 @store.route('/<regex("' + snap_regex + '"):snap_name>') 

195 def snap_details(snap_name): 

196 """ 

197 A view to display the snap details page for specific snaps. 

198 

199 This queries the snapcraft API (api.snapcraft.io) and passes 

200 some of the data through to the snap-details.html template, 

201 with appropriate sanitation. 

202 """ 

203 

204 error_info = {} 

205 status_code = 200 

206 

207 context = _get_context_snap_details(snap_name) 

208 

209 country_metric_name = "weekly_installed_base_by_country_percent" 

210 os_metric_name = "weekly_installed_base_by_operating_system_normalized" 

211 

212 end = metrics_helper.get_last_metrics_processed_date() 

213 

214 metrics_query_json = [ 

215 metrics_helper.get_filter( 

216 metric_name=country_metric_name, 

217 snap_id=context["snap-id"], 

218 start=end, 

219 end=end, 

220 ), 

221 metrics_helper.get_filter( 

222 metric_name=os_metric_name, 

223 snap_id=context["snap-id"], 

224 start=end, 

225 end=end, 

226 ), 

227 ] 

228 

229 metrics_response = device_gateway.get_public_metrics( 

230 metrics_query_json 

231 ) 

232 

233 os_metrics = None 

234 country_devices = None 

235 if metrics_response: 

236 oses = metrics_helper.find_metric(metrics_response, os_metric_name) 

237 os_metrics = metrics.OsMetric( 

238 name=oses["metric_name"], 

239 series=oses["series"], 

240 buckets=oses["buckets"], 

241 status=oses["status"], 

242 ) 

243 

244 territories = metrics_helper.find_metric( 

245 metrics_response, country_metric_name 

246 ) 

247 country_devices = metrics.CountryDevices( 

248 name=territories["metric_name"], 

249 series=territories["series"], 

250 buckets=territories["buckets"], 

251 status=territories["status"], 

252 private=False, 

253 ) 

254 

255 context.update( 

256 { 

257 "countries": ( 

258 country_devices.country_data if country_devices else None 

259 ), 

260 "normalized_os": os_metrics.os if os_metrics else None, 

261 # Context info 

262 "is_linux": ( 

263 "Linux" in flask.request.headers.get("User-Agent", "") 

264 and "Android" 

265 not in flask.request.headers.get("User-Agent", "") 

266 ), 

267 "error_info": error_info, 

268 } 

269 ) 

270 

271 return ( 

272 flask.render_template("store/snap-details.html", **context), 

273 status_code, 

274 ) 

275 

276 @store.route('/<regex("' + snap_regex + '"):snap_name>/embedded') 

277 @exclude_xframe_options_header 

278 def snap_details_embedded(snap_name): 

279 """ 

280 A view to display the snap embedded card for specific snaps. 

281 

282 This queries the snapcraft API (api.snapcraft.io) and passes 

283 some of the data through to the template, 

284 with appropriate sanitation. 

285 """ 

286 status_code = 200 

287 

288 context = _get_context_snap_details(snap_name) 

289 

290 button_variants = ["black", "white"] 

291 button = flask.request.args.get("button") 

292 if button and button not in button_variants: 

293 button = "black" 

294 

295 architectures = list(context["channel_map"].keys()) 

296 

297 context.update( 

298 { 

299 "default_architecture": ( 

300 "amd64" if "amd64" in architectures else architectures[0] 

301 ), 

302 "button": button, 

303 "show_channels": flask.request.args.get("channels"), 

304 "show_summary": flask.request.args.get("summary"), 

305 "show_screenshot": flask.request.args.get("screenshot"), 

306 } 

307 ) 

308 

309 return ( 

310 flask.render_template("store/snap-embedded-card.html", **context), 

311 status_code, 

312 ) 

313 

314 @store.route('/<regex("' + snap_regex_upercase + '"):snap_name>') 

315 def snap_details_case_sensitive(snap_name): 

316 return flask.redirect( 

317 flask.url_for(".snap_details", snap_name=snap_name.lower()) 

318 ) 

319 

320 def get_badge_svg(snap_name, left_text, right_text, color="#0e8420"): 

321 show_name = flask.request.args.get("name", default=1, type=int) 

322 snap_link = flask.request.url_root + snap_name 

323 

324 svg = badge( 

325 left_text=left_text if show_name else "", 

326 right_text=right_text, 

327 right_color=color, 

328 left_link=snap_link, 

329 right_link=snap_link, 

330 logo=( 

331 "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' " 

332 "viewBox='0 0 32 32'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23f" 

333 "ff%7D%3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M18.03 1" 

334 "8.03l5.95-5.95-5.95-2.65v8.6zM6.66 29.4l10.51-10.51-3.21-3.18" 

335 "-7.3 13.69zM2.5 3.6l15.02 14.94V9.03L2.5 3.6zM27.03 9.03h-8.6" 

336 "5l11.12 4.95-2.47-4.95z'/%3E%3C/svg%3E" 

337 ), 

338 ) 

339 return svg 

340 

341 @store.route('/<regex("' + snap_regex + '"):snap_name>/badge.svg') 

342 def snap_details_badge(snap_name): 

343 context = _get_context_snap_details(snap_name) 

344 

345 # channel with safest risk available in default track 

346 snap_channel = "".join( 

347 [context["default_track"], "/", context["lowest_risk_available"]] 

348 ) 

349 

350 svg = get_badge_svg( 

351 snap_name=snap_name, 

352 left_text=context["snap_title"], 

353 right_text=snap_channel + " " + context["version"], 

354 ) 

355 

356 return svg, 200, {"Content-Type": "image/svg+xml"} 

357 

358 @store.route("/<lang>/<theme>/install.svg") 

359 def snap_install_badge(lang, theme): 

360 base_path = "static/images/badges/" 

361 allowed_langs = helpers.list_folders(base_path) 

362 

363 if lang not in allowed_langs: 

364 return Response("Invalid language", status=400) 

365 

366 file_name = ( 

367 "snap-store-white.svg" 

368 if theme == "light" 

369 else "snap-store-black.svg" 

370 ) 

371 

372 svg_path = os.path.normpath(os.path.join(base_path, lang, file_name)) 

373 

374 # Ensure the path is within the base path 

375 if not svg_path.startswith(base_path) or not os.path.exists(svg_path): 

376 return Response( 

377 '<svg height="20" width="1" ' 

378 'xmlns="http://www.w3.org/2000/svg" ' 

379 'xmlns:xlink="http://www.w3.org/1999/xlink"></svg>', 

380 mimetype="image/svg+xml", 

381 status=404, 

382 ) 

383 else: 

384 with open(svg_path, "r") as svg_file: 

385 svg_content = svg_file.read() 

386 return Response(svg_content, mimetype="image/svg+xml") 

387 

388 @store.route('/<regex("' + snap_regex + '"):snap_name>/trending.svg') 

389 def snap_details_badge_trending(snap_name): 

390 is_preview = flask.request.args.get("preview", default=0, type=int) 

391 context = _get_context_snap_details(snap_name) 

392 

393 # default to empty SVG 

394 svg = ( 

395 '<svg height="20" width="1" xmlns="http://www.w3.org/2000/svg" ' 

396 'xmlns:xlink="http://www.w3.org/1999/xlink"></svg>' 

397 ) 

398 

399 # publishers can see preview of trending badge of their own snaps 

400 # on Publicise page 

401 show_as_preview = False 

402 if is_preview and authentication.is_authenticated(flask.session): 

403 show_as_preview = True 

404 

405 if context["trending"] or show_as_preview: 

406 svg = get_badge_svg( 

407 snap_name=snap_name, 

408 left_text=context["snap_title"], 

409 right_text="Trending this week", 

410 color="#FA7041", 

411 ) 

412 

413 return svg, 200, {"Content-Type": "image/svg+xml"} 

414 

415 @store.route('/install/<regex("' + snap_regex + '"):snap_name>/<distro>') 

416 def snap_distro_install(snap_name, distro): 

417 filename = f"store/content/distros/{distro}.yaml" 

418 distro_data = helpers.get_yaml(filename) 

419 

420 if not distro_data: 

421 flask.abort(404) 

422 

423 context = _get_context_snap_details(snap_name) 

424 

425 if distro == "raspbian": 

426 if ( 

427 "armhf" not in context["channel_map"] 

428 and "arm64" not in context["channel_map"] 

429 ): 

430 return flask.render_template("404.html"), 404 

431 

432 context.update( 

433 { 

434 "distro": distro, 

435 "distro_name": distro_data["name"], 

436 "distro_logo": distro_data["logo"], 

437 "distro_logo_mono": distro_data["logo-mono"], 

438 "distro_color_1": distro_data["color-1"], 

439 "distro_color_2": distro_data["color-2"], 

440 "distro_install_steps": distro_data["install"], 

441 } 

442 ) 

443 

444 try: 

445 featured_snaps_results = device_gateway.get_featured_items( 

446 size=13, page=1 

447 ).get("results", []) 

448 

449 except StoreApiError: 

450 featured_snaps_results = [] 

451 

452 featured_snaps = [ 

453 snap 

454 for snap in featured_snaps_results 

455 if snap["package_name"] != snap_name 

456 ][:12] 

457 

458 for snap in featured_snaps: 

459 snap["icon_url"] = helpers.get_icon(snap["media"]) 

460 

461 context.update({"featured_snaps": featured_snaps}) 

462 return flask.render_template( 

463 "store/snap-distro-install.html", **context 

464 ) 

465 

466 @store.route("/report", methods=["POST"]) 

467 def report_snap(): 

468 form_url = "/".join( 

469 [ 

470 "https://docs.google.com", 

471 "forms", 

472 "d", 

473 "e", 

474 "1FAIpQLSc5w1Ow6hRGs-VvBXmDtPOZaadYHEpsqCl2RbKEenluBvaw3Q", 

475 "formResponse", 

476 ] 

477 ) 

478 

479 fields = flask.request.form 

480 

481 # If the honeypot is activated or a URL is included in the message, 

482 # say "OK" to avoid spam 

483 if ( 

484 "entry.13371337" in fields and fields["entry.13371337"] == "on" 

485 ) or "http" in fields["entry.1974584359"]: 

486 return "", 200 

487 

488 return flask.jsonify({"url": form_url}), 200