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

173 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-28 22:05 +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} 

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 try: 

224 dns_txt_records = [ 

225 dns_record.to_text() 

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

227 ] 

228 

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

230 res["primary_domain"] = True 

231 

232 except Exception: 

233 res["primary_domain"] = False 

234 

235 response = make_response(res, 200) 

236 response.cache_control.max_age = "3600" 

237 return response 

238 

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

240 def snap_details(snap_name): 

241 """ 

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

243 

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

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

246 with appropriate sanitation. 

247 """ 

248 

249 error_info = {} 

250 status_code = 200 

251 

252 context = _get_context_snap_details(snap_name) 

253 

254 country_metric_name = "weekly_installed_base_by_country_percent" 

255 os_metric_name = "weekly_installed_base_by_operating_system_normalized" 

256 

257 end = metrics_helper.get_last_metrics_processed_date() 

258 

259 metrics_query_json = [ 

260 metrics_helper.get_filter( 

261 metric_name=country_metric_name, 

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

263 start=end, 

264 end=end, 

265 ), 

266 metrics_helper.get_filter( 

267 metric_name=os_metric_name, 

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

269 start=end, 

270 end=end, 

271 ), 

272 ] 

273 

274 metrics_response = device_gateway.get_public_metrics( 

275 metrics_query_json 

276 ) 

277 

278 os_metrics = None 

279 country_devices = None 

280 if metrics_response: 

281 oses = metrics_helper.find_metric(metrics_response, os_metric_name) 

282 os_metrics = metrics.OsMetric( 

283 name=oses["metric_name"], 

284 series=oses["series"], 

285 buckets=oses["buckets"], 

286 status=oses["status"], 

287 ) 

288 

289 territories = metrics_helper.find_metric( 

290 metrics_response, country_metric_name 

291 ) 

292 country_devices = metrics.CountryDevices( 

293 name=territories["metric_name"], 

294 series=territories["series"], 

295 buckets=territories["buckets"], 

296 status=territories["status"], 

297 private=False, 

298 ) 

299 

300 context.update( 

301 { 

302 "countries": ( 

303 country_devices.country_data if country_devices else None 

304 ), 

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

306 # Context info 

307 "is_linux": ( 

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

309 and "Android" 

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

311 ), 

312 "error_info": error_info, 

313 } 

314 ) 

315 

316 return ( 

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

318 status_code, 

319 ) 

320 

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

322 @exclude_xframe_options_header 

323 def snap_details_embedded(snap_name): 

324 """ 

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

326 

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

328 some of the data through to the template, 

329 with appropriate sanitation. 

330 """ 

331 status_code = 200 

332 

333 context = _get_context_snap_details(snap_name) 

334 

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

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

337 if button and button not in button_variants: 

338 button = "black" 

339 

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

341 

342 context.update( 

343 { 

344 "default_architecture": ( 

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

346 ), 

347 "button": button, 

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

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

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

351 } 

352 ) 

353 

354 return ( 

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

356 status_code, 

357 ) 

358 

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

360 def snap_details_case_sensitive(snap_name): 

361 return flask.redirect( 

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

363 ) 

364 

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

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

367 snap_link = flask.request.url_root + snap_name 

368 

369 svg = badge( 

370 left_text=left_text if show_name else "", 

371 right_text=right_text, 

372 right_color=color, 

373 left_link=snap_link, 

374 right_link=snap_link, 

375 logo=( 

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

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

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

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

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

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

382 ), 

383 ) 

384 return svg 

385 

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

387 def snap_details_badge(snap_name): 

388 context = _get_context_snap_details(snap_name) 

389 

390 # channel with safest risk available in default track 

391 snap_channel = "".join( 

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

393 ) 

394 

395 svg = get_badge_svg( 

396 snap_name=snap_name, 

397 left_text=context["snap_title"], 

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

399 ) 

400 

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

402 

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

404 def snap_install_badge(lang, theme): 

405 base_path = "static/images/badges/" 

406 allowed_langs = helpers.list_folders(base_path) 

407 

408 if lang not in allowed_langs: 

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

410 

411 file_name = ( 

412 "snap-store-white.svg" 

413 if theme == "light" 

414 else "snap-store-black.svg" 

415 ) 

416 

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

418 

419 # Ensure the path is within the base path 

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

421 return Response( 

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

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

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

425 mimetype="image/svg+xml", 

426 status=404, 

427 ) 

428 else: 

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

430 svg_content = svg_file.read() 

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

432 

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

434 def snap_details_badge_trending(snap_name): 

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

436 context = _get_context_snap_details(snap_name) 

437 

438 # default to empty SVG 

439 svg = ( 

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

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

442 ) 

443 

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

445 # on Publicise page 

446 show_as_preview = False 

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

448 show_as_preview = True 

449 

450 if context["trending"] or show_as_preview: 

451 svg = get_badge_svg( 

452 snap_name=snap_name, 

453 left_text=context["snap_title"], 

454 right_text="Trending this week", 

455 color="#FA7041", 

456 ) 

457 

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

459 

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

461 def snap_distro_install(snap_name, distro): 

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

463 distro_data = helpers.get_yaml(filename) 

464 

465 if not distro_data: 

466 flask.abort(404) 

467 

468 context = _get_context_snap_details(snap_name) 

469 

470 if distro == "raspbian": 

471 if ( 

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

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

474 ): 

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

476 

477 context.update( 

478 { 

479 "distro": distro, 

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

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

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

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

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

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

486 } 

487 ) 

488 

489 try: 

490 featured_snaps_results = device_gateway.get_featured_items( 

491 size=13, page=1 

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

493 

494 except StoreApiError: 

495 featured_snaps_results = [] 

496 

497 featured_snaps = [ 

498 snap 

499 for snap in featured_snaps_results 

500 if snap["package_name"] != snap_name 

501 ][:12] 

502 

503 for snap in featured_snaps: 

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

505 

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

507 return flask.render_template( 

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

509 ) 

510 

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

512 def report_snap(): 

513 form_url = "/".join( 

514 [ 

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

516 "forms", 

517 "d", 

518 "e", 

519 "1FAIpQLSc5w1Ow6hRGs-VvBXmDtPOZaadYHEpsqCl2RbKEenluBvaw3Q", 

520 "formResponse", 

521 ] 

522 ) 

523 

524 fields = flask.request.form 

525 

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

527 # say "OK" to avoid spam 

528 if ( 

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

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

531 return "", 200 

532 

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