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

147 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2025-08-26 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, supported_architectures=None): 

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

91 default_track, 

92 lowest_risk_available, 

93 supported_architectures, 

94 ) 

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

96 

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

98 screenshots = logic.filter_screenshots( 

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

100 ) 

101 

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

103 

104 publisher_info = helpers.get_yaml( 

105 "{}{}.yaml".format( 

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

107 "PUBLISHER_PAGES" 

108 ], 

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

110 ), 

111 typ="safe", 

112 ) 

113 

114 publisher_snaps = helpers.get_yaml( 

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

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

117 "PUBLISHER_PAGES" 

118 ], 

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

120 ), 

121 typ="safe", 

122 ) 

123 

124 publisher_featured_snaps = None 

125 

126 if publisher_info: 

127 publisher_featured_snaps = publisher_info.get("featured_snaps") 

128 publisher_snaps = logic.get_n_random_snaps( 

129 publisher_snaps["snaps"], 4 

130 ) 

131 

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

133 

134 is_users_snap = False 

135 if authentication.is_authenticated(flask.session): 

136 if ( 

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

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

139 ): 

140 is_users_snap = True 

141 

142 # build list of categories of a snap 

143 categories = logic.get_snap_categories( 

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

145 ) 

146 

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

148 

149 context = { 

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

151 # Data direct from details API 

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

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

154 "categories": categories, 

155 "icon_url": icon_url, 

156 "version": extracted_info["version"], 

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

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

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

160 "screenshots": screenshots, 

161 "video": video, 

162 "publisher_snaps": publisher_snaps, 

163 "publisher_featured_snaps": publisher_featured_snaps, 

164 "has_publisher_page": publisher_info is not None, 

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

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

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

168 "description": formatted_description, 

169 "channel_map": channel_maps_list, 

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

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

172 "default_track": default_track, 

173 "lowest_risk_available": lowest_risk_available, 

174 "confinement": extracted_info["confinement"], 

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

176 # Transformed API data 

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

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

179 "last_updated_raw": last_updated, 

180 "is_users_snap": is_users_snap, 

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

182 "developer": developer, 

183 # TODO: This is horrible and hacky 

184 "appliances": { 

185 "adguard-home": "adguard", 

186 "mosquitto": "mosquitto", 

187 "nextcloud": "nextcloud", 

188 "plexmediaserver": "plex", 

189 "openhab": "openhab", 

190 }, 

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

192 "updates": updates, 

193 } 

194 

195 return context 

196 

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

198 def snap_details(snap_name): 

199 """ 

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

201 

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

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

204 with appropriate sanitation. 

205 """ 

206 

207 error_info = {} 

208 status_code = 200 

209 

210 context = _get_context_snap_details(snap_name) 

211 

212 country_metric_name = "weekly_installed_base_by_country_percent" 

213 os_metric_name = "weekly_installed_base_by_operating_system_normalized" 

214 

215 end = metrics_helper.get_last_metrics_processed_date() 

216 

217 metrics_query_json = [ 

218 metrics_helper.get_filter( 

219 metric_name=country_metric_name, 

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

221 start=end, 

222 end=end, 

223 ), 

224 metrics_helper.get_filter( 

225 metric_name=os_metric_name, 

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

227 start=end, 

228 end=end, 

229 ), 

230 ] 

231 

232 metrics_response = device_gateway.get_public_metrics( 

233 metrics_query_json 

234 ) 

235 

236 os_metrics = None 

237 country_devices = None 

238 if metrics_response: 

239 oses = metrics_helper.find_metric(metrics_response, os_metric_name) 

240 os_metrics = metrics.OsMetric( 

241 name=oses["metric_name"], 

242 series=oses["series"], 

243 buckets=oses["buckets"], 

244 status=oses["status"], 

245 ) 

246 

247 territories = metrics_helper.find_metric( 

248 metrics_response, country_metric_name 

249 ) 

250 country_devices = metrics.CountryDevices( 

251 name=territories["metric_name"], 

252 series=territories["series"], 

253 buckets=territories["buckets"], 

254 status=territories["status"], 

255 private=False, 

256 ) 

257 

258 context.update( 

259 { 

260 "countries": ( 

261 country_devices.country_data if country_devices else None 

262 ), 

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

264 # Context info 

265 "is_linux": ( 

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

267 and "Android" 

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

269 ), 

270 "error_info": error_info, 

271 } 

272 ) 

273 

274 return ( 

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

276 status_code, 

277 ) 

278 

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

280 @exclude_xframe_options_header 

281 def snap_details_embedded(snap_name): 

282 """ 

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

284 

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

286 some of the data through to the template, 

287 with appropriate sanitation. 

288 """ 

289 status_code = 200 

290 

291 context = _get_context_snap_details(snap_name) 

292 

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

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

295 if button and button not in button_variants: 

296 button = "black" 

297 

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

299 

300 context.update( 

301 { 

302 "default_architecture": ( 

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

304 ), 

305 "button": button, 

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

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

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

309 } 

310 ) 

311 

312 return ( 

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

314 status_code, 

315 ) 

316 

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

318 def snap_details_case_sensitive(snap_name): 

319 return flask.redirect( 

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

321 ) 

322 

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

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

325 snap_link = flask.request.url_root + snap_name 

326 

327 svg = badge( 

328 left_text=left_text if show_name else "", 

329 right_text=right_text, 

330 right_color=color, 

331 left_link=snap_link, 

332 right_link=snap_link, 

333 logo=( 

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

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

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

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

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

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

340 ), 

341 ) 

342 return svg 

343 

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

345 def snap_details_badge(snap_name): 

346 context = _get_context_snap_details(snap_name) 

347 

348 # channel with safest risk available in default track 

349 snap_channel = "".join( 

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

351 ) 

352 

353 svg = get_badge_svg( 

354 snap_name=snap_name, 

355 left_text=context["snap_title"], 

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

357 ) 

358 

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

360 

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

362 def snap_install_badge(lang, theme): 

363 base_path = "static/images/badges/" 

364 allowed_langs = helpers.list_folders(base_path) 

365 

366 if lang not in allowed_langs: 

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

368 

369 file_name = ( 

370 "snap-store-white.svg" 

371 if theme == "light" 

372 else "snap-store-black.svg" 

373 ) 

374 

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

376 

377 # Ensure the path is within the base path 

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

379 return Response( 

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

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

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

383 mimetype="image/svg+xml", 

384 status=404, 

385 ) 

386 else: 

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

388 svg_content = svg_file.read() 

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

390 

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

392 def snap_details_badge_trending(snap_name): 

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

394 context = _get_context_snap_details(snap_name) 

395 

396 # default to empty SVG 

397 svg = ( 

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

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

400 ) 

401 

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

403 # on Publicise page 

404 show_as_preview = False 

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

406 show_as_preview = True 

407 

408 if context["trending"] or show_as_preview: 

409 svg = get_badge_svg( 

410 snap_name=snap_name, 

411 left_text=context["snap_title"], 

412 right_text="Trending this week", 

413 color="#FA7041", 

414 ) 

415 

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

417 

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

419 def snap_distro_install(snap_name, distro): 

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

421 distro_data = helpers.get_yaml(filename) 

422 

423 if not distro_data: 

424 flask.abort(404) 

425 

426 supported_archs = distro_data["supported-archs"] 

427 context = _get_context_snap_details(snap_name, supported_archs) 

428 

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

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