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

183 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-05 22:07 +0000

1import flask 

2from flask import Response 

3import requests 

4 

5import logging 

6import humanize 

7import os 

8 

9import webapp.helpers as helpers 

10import webapp.metrics.helper as metrics_helper 

11import webapp.metrics.metrics as metrics 

12import webapp.store.logic as logic 

13from webapp import authentication 

14from webapp.markdown import parse_markdown_description 

15from cache.cache_utility import redis_cache 

16 

17from canonicalwebteam.flask_base.decorators import ( 

18 exclude_xframe_options_header, 

19) 

20from canonicalwebteam.exceptions import StoreApiError 

21from canonicalwebteam.store_api.devicegw import DeviceGW 

22from pybadges import badge 

23 

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

25device_gateway_sbom = DeviceGW("sbom", helpers.api_session) 

26 

27logger = logging.getLogger(__name__) 

28 

29 

30FIELDS = [ 

31 "title", 

32 "summary", 

33 "description", 

34 "license", 

35 "contact", 

36 "website", 

37 "publisher", 

38 "media", 

39 "download", 

40 "version", 

41 "created-at", 

42 "confinement", 

43 "categories", 

44 "trending", 

45 "unlisted", 

46 "links", 

47 "revision", 

48] 

49 

50FIELDS_EXTRA_DETAILS = [ 

51 "aliases", 

52] 

53 

54 

55def snap_details_views(store): 

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

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

58 

59 def _get_context_snap_details(snap_name, supported_architectures=None): 

60 details = device_gateway.get_item_details( 

61 snap_name, fields=FIELDS, api_version=2 

62 ) 

63 # 404 for any snap under quarantine 

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

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

66 

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

68 # responds that the snaps still exists with data. 

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

70 # For example: mir-kiosk-browser 

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

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

73 

74 formatted_description = parse_markdown_description( 

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

76 ) 

77 

78 channel_maps_list = logic.convert_channel_maps( 

79 details.get("channel-map") 

80 ) 

81 

82 latest_channel = logic.get_last_updated_version( 

83 details.get("channel-map") 

84 ) 

85 

86 revisions = logic.get_revisions(details.get("channel-map")) 

87 

88 default_track = ( 

89 details.get("default-track") 

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

91 else "latest" 

92 ) 

93 

94 lowest_risk_available = logic.get_lowest_available_risk( 

95 channel_maps_list, default_track 

96 ) 

97 

98 extracted_info = logic.extract_info_channel_map( 

99 channel_maps_list, default_track, lowest_risk_available 

100 ) 

101 

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

103 updates = logic.get_latest_versions( 

104 details.get("channel-map"), 

105 default_track, 

106 lowest_risk_available, 

107 supported_architectures, 

108 ) 

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

110 

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

112 screenshots = logic.filter_screenshots( 

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

114 ) 

115 

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

117 

118 publisher_info = helpers.get_yaml( 

119 "{}{}.yaml".format( 

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

121 "PUBLISHER_PAGES" 

122 ], 

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

124 ), 

125 typ="safe", 

126 ) 

127 

128 publisher_snaps = helpers.get_yaml( 

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

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

131 "PUBLISHER_PAGES" 

132 ], 

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

134 ), 

135 typ="safe", 

136 ) 

137 

138 publisher_featured_snaps = None 

139 

140 if publisher_info: 

141 publisher_featured_snaps = publisher_info.get("featured_snaps") 

142 publisher_snaps = logic.get_n_random_snaps( 

143 publisher_snaps["snaps"], 4 

144 ) 

145 

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

147 

148 is_users_snap = False 

149 if authentication.is_authenticated(flask.session): 

150 if ( 

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

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

153 ): 

154 is_users_snap = True 

155 

156 # build list of categories of a snap 

157 categories = logic.get_snap_categories( 

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

159 ) 

160 

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

162 

163 context = { 

164 "snap_id": details.get("snap-id"), 

165 # Data direct from details API 

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

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

168 "categories": categories, 

169 "icon_url": icon_url, 

170 "version": extracted_info["version"], 

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

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

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

174 "screenshots": screenshots, 

175 "video": video, 

176 "publisher_snaps": publisher_snaps, 

177 "publisher_featured_snaps": publisher_featured_snaps, 

178 "has_publisher_page": publisher_info is not None, 

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

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

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

182 "description": formatted_description, 

183 "channel_map": channel_maps_list, 

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

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

186 "default_track": default_track, 

187 "lowest_risk_available": lowest_risk_available, 

188 "confinement": extracted_info["confinement"], 

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

190 # Transformed API data 

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

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

193 "last_updated_raw": last_updated, 

194 "is_users_snap": is_users_snap, 

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

196 "developer": developer, 

197 # TODO: This is horrible and hacky 

198 "appliances": { 

199 "adguard-home": "adguard", 

200 "mosquitto": "mosquitto", 

201 "nextcloud": "nextcloud", 

202 "plexmediaserver": "plex", 

203 "openhab": "openhab", 

204 }, 

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

206 "updates": updates, 

207 "revisions": revisions, 

208 } 

209 return context 

210 

211 def snap_has_sboms(revisions, snap_id): 

212 if not revisions: 

213 return False 

214 

215 sbom_path = f"download/sbom_snap_{snap_id}_{revisions[0]}.spdx2.3.json" 

216 endpoint = device_gateway_sbom.get_endpoint_url(sbom_path) 

217 

218 res = requests.head(endpoint) 

219 

220 # backend returns 302 instead of 200 for a successful request 

221 # adding the check for 200 in case this is changed without us knowing 

222 if res.status_code == 200 or res.status_code == 302: 

223 return True 

224 

225 return False 

226 

227 @store.route("/download/sbom_snap_<snap_id>_<revision>.spdx2.3.json") 

228 def get_sbom(snap_id, revision): 

229 sbom_path = f"download/sbom_snap_{snap_id}_{revision}.spdx2.3.json" 

230 endpoint = device_gateway_sbom.get_endpoint_url(sbom_path) 

231 

232 res = requests.get(endpoint) 

233 

234 return flask.jsonify(res.json()) 

235 

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

237 def snap_details(snap_name): 

238 """ 

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

240 

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

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

243 with appropriate sanitation. 

244 """ 

245 

246 error_info = {} 

247 status_code = 200 

248 

249 context = _get_context_snap_details(snap_name) 

250 try: 

251 # the empty string channel makes the store API not filter by 

252 # the default channel 'latest/stable', which gives errors for 

253 # snaps that don't use that channel 

254 extra_details = device_gateway.get_snap_details( 

255 snap_name, channel="", fields=FIELDS_EXTRA_DETAILS 

256 ) 

257 except Exception: 

258 logger.exception("Details endpoint returned an error") 

259 extra_details = None 

260 

261 if extra_details and extra_details["aliases"]: 

262 context["aliases"] = [ 

263 [ 

264 f"{extra_details['package_name']}.{alias_obj['target']}", 

265 alias_obj["name"], 

266 ] 

267 for alias_obj in extra_details["aliases"] 

268 ] 

269 

270 country_metric_name = "weekly_installed_base_by_country_percent" 

271 os_metric_name = "weekly_installed_base_by_operating_system_normalized" 

272 

273 end = metrics_helper.get_last_metrics_processed_date() 

274 

275 metrics_query_json = [ 

276 metrics_helper.get_filter( 

277 metric_name=country_metric_name, 

278 snap_id=context["snap_id"], 

279 start=end, 

280 end=end, 

281 ), 

282 metrics_helper.get_filter( 

283 metric_name=os_metric_name, 

284 snap_id=context["snap_id"], 

285 start=end, 

286 end=end, 

287 ), 

288 ] 

289 

290 metrics_response = device_gateway.get_public_metrics( 

291 metrics_query_json 

292 ) 

293 

294 os_metrics = None 

295 country_devices = None 

296 if metrics_response: 

297 oses = metrics_helper.find_metric(metrics_response, os_metric_name) 

298 os_metrics = metrics.OsMetric( 

299 name=oses["metric_name"], 

300 series=oses["series"], 

301 buckets=oses["buckets"], 

302 status=oses["status"], 

303 ) 

304 

305 territories = metrics_helper.find_metric( 

306 metrics_response, country_metric_name 

307 ) 

308 country_devices = metrics.CountryDevices( 

309 name=territories["metric_name"], 

310 series=territories["series"], 

311 buckets=territories["buckets"], 

312 status=territories["status"], 

313 private=False, 

314 ) 

315 

316 has_sboms = snap_has_sboms(context["revisions"], context["snap_id"]) 

317 

318 context.update( 

319 { 

320 "countries": ( 

321 country_devices.country_data if country_devices else None 

322 ), 

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

324 # Context info 

325 "is_linux": ( 

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

327 and "Android" 

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

329 ), 

330 "error_info": error_info, 

331 } 

332 ) 

333 

334 context["has_sboms"] = has_sboms 

335 

336 return ( 

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

338 status_code, 

339 ) 

340 

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

342 @exclude_xframe_options_header 

343 def snap_details_embedded(snap_name): 

344 """ 

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

346 

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

348 some of the data through to the template, 

349 with appropriate sanitation. 

350 """ 

351 status_code = 200 

352 

353 context = _get_context_snap_details(snap_name) 

354 

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

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

357 if button and button not in button_variants: 

358 button = "black" 

359 

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

361 

362 context.update( 

363 { 

364 "default_architecture": ( 

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

366 ), 

367 "button": button, 

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

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

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

371 } 

372 ) 

373 

374 return ( 

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

376 status_code, 

377 ) 

378 

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

380 def snap_details_case_sensitive(snap_name): 

381 return flask.redirect( 

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

383 ) 

384 

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

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

387 snap_link = flask.request.url_root + snap_name 

388 

389 svg = badge( 

390 left_text=left_text if show_name else "", 

391 right_text=right_text, 

392 right_color=color, 

393 left_link=snap_link, 

394 right_link=snap_link, 

395 logo=( 

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

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

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

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

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

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

402 ), 

403 ) 

404 return svg 

405 

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

407 def snap_details_badge(snap_name): 

408 context = _get_context_snap_details(snap_name) 

409 

410 # channel with safest risk available in default track 

411 snap_channel = "".join( 

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

413 ) 

414 

415 svg = get_badge_svg( 

416 snap_name=snap_name, 

417 left_text=context["snap_title"], 

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

419 ) 

420 

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

422 

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

424 def snap_install_badge(lang, theme): 

425 base_path = "static/images/badges/" 

426 allowed_langs = helpers.list_folders(base_path) 

427 

428 if lang not in allowed_langs: 

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

430 

431 file_name = ( 

432 "snap-store-white.svg" 

433 if theme == "light" 

434 else "snap-store-black.svg" 

435 ) 

436 

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

438 

439 # Ensure the path is within the base path 

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

441 return Response( 

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

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

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

445 mimetype="image/svg+xml", 

446 status=404, 

447 ) 

448 else: 

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

450 svg_content = svg_file.read() 

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

452 

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

454 def snap_details_badge_trending(snap_name): 

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

456 context = _get_context_snap_details(snap_name) 

457 

458 # default to empty SVG 

459 svg = ( 

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

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

462 ) 

463 

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

465 # on Publicise page 

466 show_as_preview = False 

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

468 show_as_preview = True 

469 

470 if context["trending"] or show_as_preview: 

471 svg = get_badge_svg( 

472 snap_name=snap_name, 

473 left_text=context["snap_title"], 

474 right_text="Trending this week", 

475 color="#FA7041", 

476 ) 

477 

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

479 

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

481 def snap_distro_install(snap_name, distro): 

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

483 distro_data = helpers.get_yaml(filename) 

484 

485 if not distro_data: 

486 flask.abort(404) 

487 

488 supported_archs = distro_data["supported-archs"] 

489 context = _get_context_snap_details(snap_name, supported_archs) 

490 

491 if all(arch not in context["channel_map"] for arch in supported_archs): 

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

493 

494 context.update( 

495 { 

496 "distro": distro, 

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

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

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

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

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

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

503 } 

504 ) 

505 cached_featured_snaps = redis_cache.get( 

506 "featured_snaps_install_pages", expected_type=list 

507 ) 

508 if cached_featured_snaps: 

509 context.update({"featured_snaps": cached_featured_snaps}) 

510 return flask.render_template( 

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

512 ) 

513 try: 

514 featured_snaps_results = device_gateway.get_featured_items( 

515 size=13, page=1 

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

517 

518 except StoreApiError: 

519 featured_snaps_results = [] 

520 featured_snaps = [ 

521 snap 

522 for snap in featured_snaps_results 

523 if snap["package_name"] != snap_name 

524 ][:12] 

525 

526 for snap in featured_snaps: 

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

528 redis_cache.set( 

529 "featured_snaps_install_pages", featured_snaps, ttl=3600 

530 ) 

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

532 return flask.render_template( 

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

534 ) 

535 

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

537 def report_snap(): 

538 form_url = "/".join( 

539 [ 

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

541 "forms", 

542 "d", 

543 "e", 

544 "1FAIpQLSc5w1Ow6hRGs-VvBXmDtPOZaadYHEpsqCl2RbKEenluBvaw3Q", 

545 "formResponse", 

546 ] 

547 ) 

548 

549 fields = flask.request.form 

550 

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

552 # say "OK" to avoid spam 

553 if ( 

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

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

556 return "", 200 

557 

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