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
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-28 22:05 +0000
1import flask
2from flask import make_response, Response
4import humanize
5import dns.resolver
6import re
7import os
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
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
23device_gateway = DeviceGW("snap", helpers.api_session)
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]
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-]*"
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
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))
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))
73 formatted_description = parse_markdown_description(
74 details.get("snap", {}).get("description", "")
75 )
77 channel_maps_list = logic.convert_channel_maps(
78 details.get("channel-map")
79 )
81 latest_channel = logic.get_last_updated_version(
82 details.get("channel-map")
83 )
85 default_track = (
86 details.get("default-track")
87 if details.get("default-track")
88 else "latest"
89 )
91 lowest_risk_available = logic.get_lowest_available_risk(
92 channel_maps_list, default_track
93 )
95 extracted_info = logic.extract_info_channel_map(
96 channel_maps_list, default_track, lowest_risk_available
97 )
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"]
105 # filter out banner and banner-icon images from screenshots
106 screenshots = logic.filter_screenshots(
107 details.get("snap", {}).get("media", [])
108 )
110 icon_url = helpers.get_icon(details.get("snap", {}).get("media", []))
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 )
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 )
132 publisher_featured_snaps = None
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 )
140 video = logic.get_video(details.get("snap", {}).get("media", []))
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
150 # build list of categories of a snap
151 categories = logic.get_snap_categories(
152 details.get("snap", {}).get("categories", [])
153 )
155 developer = logic.get_snap_developer(details["name"])
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 }
203 return context
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)
210 primary_domain = None
212 if "website" in context["links"]:
213 primary_domain = context["links"]["website"][0]
215 if primary_domain:
216 token = helpers.get_dns_verification_token(
217 snap_name, primary_domain
218 )
220 domain = re.compile(r"https?://")
221 domain = domain.sub("", primary_domain).strip().strip("/")
223 try:
224 dns_txt_records = [
225 dns_record.to_text()
226 for dns_record in dns.resolver.resolve(domain, "TXT").rrset
227 ]
229 if f'"SNAPCRAFT_IO_VERIFICATION={token}"' in dns_txt_records:
230 res["primary_domain"] = True
232 except Exception:
233 res["primary_domain"] = False
235 response = make_response(res, 200)
236 response.cache_control.max_age = "3600"
237 return response
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.
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 """
249 error_info = {}
250 status_code = 200
252 context = _get_context_snap_details(snap_name)
254 country_metric_name = "weekly_installed_base_by_country_percent"
255 os_metric_name = "weekly_installed_base_by_operating_system_normalized"
257 end = metrics_helper.get_last_metrics_processed_date()
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 ]
274 metrics_response = device_gateway.get_public_metrics(
275 metrics_query_json
276 )
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 )
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 )
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 )
316 return (
317 flask.render_template("store/snap-details.html", **context),
318 status_code,
319 )
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.
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
333 context = _get_context_snap_details(snap_name)
335 button_variants = ["black", "white"]
336 button = flask.request.args.get("button")
337 if button and button not in button_variants:
338 button = "black"
340 architectures = list(context["channel_map"].keys())
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 )
354 return (
355 flask.render_template("store/snap-embedded-card.html", **context),
356 status_code,
357 )
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 )
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
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
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)
390 # channel with safest risk available in default track
391 snap_channel = "".join(
392 [context["default_track"], "/", context["lowest_risk_available"]]
393 )
395 svg = get_badge_svg(
396 snap_name=snap_name,
397 left_text=context["snap_title"],
398 right_text=snap_channel + " " + context["version"],
399 )
401 return svg, 200, {"Content-Type": "image/svg+xml"}
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)
408 if lang not in allowed_langs:
409 return Response("Invalid language", status=400)
411 file_name = (
412 "snap-store-white.svg"
413 if theme == "light"
414 else "snap-store-black.svg"
415 )
417 svg_path = os.path.normpath(os.path.join(base_path, lang, file_name))
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")
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)
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 )
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
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 )
458 return svg, 200, {"Content-Type": "image/svg+xml"}
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)
465 if not distro_data:
466 flask.abort(404)
468 context = _get_context_snap_details(snap_name)
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
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 )
489 try:
490 featured_snaps_results = device_gateway.get_featured_items(
491 size=13, page=1
492 ).get("results", [])
494 except StoreApiError:
495 featured_snaps_results = []
497 featured_snaps = [
498 snap
499 for snap in featured_snaps_results
500 if snap["package_name"] != snap_name
501 ][:12]
503 for snap in featured_snaps:
504 snap["icon_url"] = helpers.get_icon(snap["media"])
506 context.update({"featured_snaps": featured_snaps})
507 return flask.render_template(
508 "store/snap-distro-install.html", **context
509 )
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 )
524 fields = flask.request.form
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
533 return flask.jsonify({"url": form_url}), 200