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

174 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-12 22:06 +0000

1import flask 

2from flask import make_response, Response 

3 

4import humanize 

5import dns.resolver 

6import re 

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 

15 

16from canonicalwebteam.flask_base.decorators import ( 

17 exclude_xframe_options_header, 

18) 

19from canonicalwebteam.exceptions import StoreApiError 

20from canonicalwebteam.store_api.devicegw import DeviceGW 

21from pybadges import badge 

22 

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

24 

25FIELDS = [ 

26 "title", 

27 "summary", 

28 "description", 

29 "license", 

30 "contact", 

31 "website", 

32 "publisher", 

33 "media", 

34 "download", 

35 "version", 

36 "created-at", 

37 "confinement", 

38 "categories", 

39 "trending", 

40 "unlisted", 

41 "links", 

42] 

43 

44 

45def snap_details_views(store): 

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

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

48 

49 def _get_snap_link_fields(snap_name): 

50 details = device_gateway.get_item_details( 

51 snap_name, api_version=2, fields=FIELDS 

52 ) 

53 context = { 

54 "links": details["snap"].get("links", {}), 

55 } 

56 return context 

57 

58 def _get_context_snap_details(snap_name): 

59 details = device_gateway.get_item_details( 

60 snap_name, fields=FIELDS, api_version=2 

61 ) 

62 # 404 for any snap under quarantine 

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

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

65 

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

67 # responds that the snaps still exists with data. 

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

69 # For example: mir-kiosk-browser 

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

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

72 

73 formatted_description = parse_markdown_description( 

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

75 ) 

76 

77 channel_maps_list = logic.convert_channel_maps( 

78 details.get("channel-map") 

79 ) 

80 

81 latest_channel = logic.get_last_updated_version( 

82 details.get("channel-map") 

83 ) 

84 

85 default_track = ( 

86 details.get("default-track") 

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

88 else "latest" 

89 ) 

90 

91 lowest_risk_available = logic.get_lowest_available_risk( 

92 channel_maps_list, default_track 

93 ) 

94 

95 extracted_info = logic.extract_info_channel_map( 

96 channel_maps_list, default_track, lowest_risk_available 

97 ) 

98 

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

100 updates = logic.get_latest_versions( 

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

102 ) 

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

104 

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

106 screenshots = logic.filter_screenshots( 

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

108 ) 

109 

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

111 

112 publisher_info = helpers.get_yaml( 

113 "{}{}.yaml".format( 

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

115 "PUBLISHER_PAGES" 

116 ], 

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

118 ), 

119 typ="safe", 

120 ) 

121 

122 publisher_snaps = helpers.get_yaml( 

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

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

125 "PUBLISHER_PAGES" 

126 ], 

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

128 ), 

129 typ="safe", 

130 ) 

131 

132 publisher_featured_snaps = None 

133 

134 if publisher_info: 

135 publisher_featured_snaps = publisher_info.get("featured_snaps") 

136 publisher_snaps = logic.get_n_random_snaps( 

137 publisher_snaps["snaps"], 4 

138 ) 

139 

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

141 

142 is_users_snap = False 

143 if authentication.is_authenticated(flask.session): 

144 if ( 

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

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

147 ): 

148 is_users_snap = True 

149 

150 # build list of categories of a snap 

151 categories = logic.get_snap_categories( 

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

153 ) 

154 

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

156 

157 context = { 

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

159 # Data direct from details API 

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

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

162 "categories": categories, 

163 "icon_url": icon_url, 

164 "version": extracted_info["version"], 

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

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

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

168 "screenshots": screenshots, 

169 "video": video, 

170 "publisher_snaps": publisher_snaps, 

171 "publisher_featured_snaps": publisher_featured_snaps, 

172 "has_publisher_page": publisher_info is not None, 

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

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

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

176 "description": formatted_description, 

177 "channel_map": channel_maps_list, 

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

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

180 "default_track": default_track, 

181 "lowest_risk_available": lowest_risk_available, 

182 "confinement": extracted_info["confinement"], 

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

184 # Transformed API data 

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

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

187 "last_updated_raw": last_updated, 

188 "is_users_snap": is_users_snap, 

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

190 "developer": developer, 

191 # TODO: This is horrible and hacky 

192 "appliances": { 

193 "adguard-home": "adguard", 

194 "mosquitto": "mosquitto", 

195 "nextcloud": "nextcloud", 

196 "plexmediaserver": "plex", 

197 "openhab": "openhab", 

198 }, 

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

200 "updates": updates, 

201 } 

202 

203 return context 

204 

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

206 def dns_verified_status(snap_name): 

207 res = {"primary_domain": False, "token": None} 

208 context = _get_snap_link_fields(snap_name) 

209 

210 primary_domain = None 

211 

212 if "website" in context["links"]: 

213 primary_domain = context["links"]["website"][0] 

214 

215 if primary_domain: 

216 token = helpers.get_dns_verification_token( 

217 snap_name, primary_domain 

218 ) 

219 

220 domain = re.compile(r"https?://") 

221 domain = domain.sub("", primary_domain).strip().strip("/") 

222 

223 res["token"] = token 

224 

225 try: 

226 dns_txt_records = [ 

227 dns_record.to_text() 

228 for dns_record in dns.resolver.resolve(domain, "TXT").rrset 

229 ] 

230 

231 if f'"SNAPCRAFT_IO_VERIFICATION={token}"' in dns_txt_records: 

232 res["primary_domain"] = True 

233 

234 except Exception: 

235 res["primary_domain"] = False 

236 

237 response = make_response(res, 200) 

238 response.cache_control.max_age = "3600" 

239 return response 

240 

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

242 def snap_details(snap_name): 

243 """ 

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

245 

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

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

248 with appropriate sanitation. 

249 """ 

250 

251 error_info = {} 

252 status_code = 200 

253 

254 context = _get_context_snap_details(snap_name) 

255 

256 country_metric_name = "weekly_installed_base_by_country_percent" 

257 os_metric_name = "weekly_installed_base_by_operating_system_normalized" 

258 

259 end = metrics_helper.get_last_metrics_processed_date() 

260 

261 metrics_query_json = [ 

262 metrics_helper.get_filter( 

263 metric_name=country_metric_name, 

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

265 start=end, 

266 end=end, 

267 ), 

268 metrics_helper.get_filter( 

269 metric_name=os_metric_name, 

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

271 start=end, 

272 end=end, 

273 ), 

274 ] 

275 

276 metrics_response = device_gateway.get_public_metrics( 

277 metrics_query_json 

278 ) 

279 

280 os_metrics = None 

281 country_devices = None 

282 if metrics_response: 

283 oses = metrics_helper.find_metric(metrics_response, os_metric_name) 

284 os_metrics = metrics.OsMetric( 

285 name=oses["metric_name"], 

286 series=oses["series"], 

287 buckets=oses["buckets"], 

288 status=oses["status"], 

289 ) 

290 

291 territories = metrics_helper.find_metric( 

292 metrics_response, country_metric_name 

293 ) 

294 country_devices = metrics.CountryDevices( 

295 name=territories["metric_name"], 

296 series=territories["series"], 

297 buckets=territories["buckets"], 

298 status=territories["status"], 

299 private=False, 

300 ) 

301 

302 context.update( 

303 { 

304 "countries": ( 

305 country_devices.country_data if country_devices else None 

306 ), 

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

308 # Context info 

309 "is_linux": ( 

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

311 and "Android" 

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

313 ), 

314 "error_info": error_info, 

315 } 

316 ) 

317 

318 return ( 

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

320 status_code, 

321 ) 

322 

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

324 @exclude_xframe_options_header 

325 def snap_details_embedded(snap_name): 

326 """ 

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

328 

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

330 some of the data through to the template, 

331 with appropriate sanitation. 

332 """ 

333 status_code = 200 

334 

335 context = _get_context_snap_details(snap_name) 

336 

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

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

339 if button and button not in button_variants: 

340 button = "black" 

341 

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

343 

344 context.update( 

345 { 

346 "default_architecture": ( 

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

348 ), 

349 "button": button, 

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

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

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

353 } 

354 ) 

355 

356 return ( 

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

358 status_code, 

359 ) 

360 

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

362 def snap_details_case_sensitive(snap_name): 

363 return flask.redirect( 

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

365 ) 

366 

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

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

369 snap_link = flask.request.url_root + snap_name 

370 

371 svg = badge( 

372 left_text=left_text if show_name else "", 

373 right_text=right_text, 

374 right_color=color, 

375 left_link=snap_link, 

376 right_link=snap_link, 

377 logo=( 

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

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

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

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

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

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

384 ), 

385 ) 

386 return svg 

387 

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

389 def snap_details_badge(snap_name): 

390 context = _get_context_snap_details(snap_name) 

391 

392 # channel with safest risk available in default track 

393 snap_channel = "".join( 

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

395 ) 

396 

397 svg = get_badge_svg( 

398 snap_name=snap_name, 

399 left_text=context["snap_title"], 

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

401 ) 

402 

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

404 

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

406 def snap_install_badge(lang, theme): 

407 base_path = "static/images/badges/" 

408 allowed_langs = helpers.list_folders(base_path) 

409 

410 if lang not in allowed_langs: 

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

412 

413 file_name = ( 

414 "snap-store-white.svg" 

415 if theme == "light" 

416 else "snap-store-black.svg" 

417 ) 

418 

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

420 

421 # Ensure the path is within the base path 

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

423 return Response( 

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

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

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

427 mimetype="image/svg+xml", 

428 status=404, 

429 ) 

430 else: 

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

432 svg_content = svg_file.read() 

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

434 

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

436 def snap_details_badge_trending(snap_name): 

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

438 context = _get_context_snap_details(snap_name) 

439 

440 # default to empty SVG 

441 svg = ( 

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

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

444 ) 

445 

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

447 # on Publicise page 

448 show_as_preview = False 

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

450 show_as_preview = True 

451 

452 if context["trending"] or show_as_preview: 

453 svg = get_badge_svg( 

454 snap_name=snap_name, 

455 left_text=context["snap_title"], 

456 right_text="Trending this week", 

457 color="#FA7041", 

458 ) 

459 

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

461 

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

463 def snap_distro_install(snap_name, distro): 

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

465 distro_data = helpers.get_yaml(filename) 

466 

467 if not distro_data: 

468 flask.abort(404) 

469 

470 context = _get_context_snap_details(snap_name) 

471 

472 if distro == "raspbian": 

473 if ( 

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

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

476 ): 

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

478 

479 context.update( 

480 { 

481 "distro": distro, 

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

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

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

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

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

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

488 } 

489 ) 

490 

491 try: 

492 featured_snaps_results = device_gateway.get_featured_items( 

493 size=13, page=1 

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

495 

496 except StoreApiError: 

497 featured_snaps_results = [] 

498 

499 featured_snaps = [ 

500 snap 

501 for snap in featured_snaps_results 

502 if snap["package_name"] != snap_name 

503 ][:12] 

504 

505 for snap in featured_snaps: 

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

507 

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

509 return flask.render_template( 

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

511 ) 

512 

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

514 def report_snap(): 

515 form_url = "/".join( 

516 [ 

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

518 "forms", 

519 "d", 

520 "e", 

521 "1FAIpQLSc5w1Ow6hRGs-VvBXmDtPOZaadYHEpsqCl2RbKEenluBvaw3Q", 

522 "formResponse", 

523 ] 

524 ) 

525 

526 fields = flask.request.form 

527 

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

529 # say "OK" to avoid spam 

530 if ( 

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

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

533 return "", 200 

534 

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