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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 22:07 +0000
1import flask
2from flask import Response
3import requests
5import logging
6import humanize
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
15from cache.cache_utility import redis_cache
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
24device_gateway = DeviceGW("snap", helpers.api_session)
25device_gateway_sbom = DeviceGW("sbom", helpers.api_session)
27logger = logging.getLogger(__name__)
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]
50FIELDS_EXTRA_DETAILS = [
51 "aliases",
52]
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-]*"
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))
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))
74 formatted_description = parse_markdown_description(
75 details.get("snap", {}).get("description", "")
76 )
78 channel_maps_list = logic.convert_channel_maps(
79 details.get("channel-map")
80 )
82 latest_channel = logic.get_last_updated_version(
83 details.get("channel-map")
84 )
86 revisions = logic.get_revisions(details.get("channel-map"))
88 default_track = (
89 details.get("default-track")
90 if details.get("default-track")
91 else "latest"
92 )
94 lowest_risk_available = logic.get_lowest_available_risk(
95 channel_maps_list, default_track
96 )
98 extracted_info = logic.extract_info_channel_map(
99 channel_maps_list, default_track, lowest_risk_available
100 )
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"]
111 # filter out banner and banner-icon images from screenshots
112 screenshots = logic.filter_screenshots(
113 details.get("snap", {}).get("media", [])
114 )
116 icon_url = helpers.get_icon(details.get("snap", {}).get("media", []))
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 )
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 )
138 publisher_featured_snaps = None
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 )
146 video = logic.get_video(details.get("snap", {}).get("media", []))
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
156 # build list of categories of a snap
157 categories = logic.get_snap_categories(
158 details.get("snap", {}).get("categories", [])
159 )
161 developer = logic.get_snap_developer(details["name"])
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
211 def snap_has_sboms(revisions, snap_id):
212 if not revisions:
213 return False
215 sbom_path = f"download/sbom_snap_{snap_id}_{revisions[0]}.spdx2.3.json"
216 endpoint = device_gateway_sbom.get_endpoint_url(sbom_path)
218 res = requests.head(endpoint)
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
225 return False
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)
232 res = requests.get(endpoint)
234 return flask.jsonify(res.json())
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.
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 """
246 error_info = {}
247 status_code = 200
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
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 ]
270 country_metric_name = "weekly_installed_base_by_country_percent"
271 os_metric_name = "weekly_installed_base_by_operating_system_normalized"
273 end = metrics_helper.get_last_metrics_processed_date()
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 ]
290 metrics_response = device_gateway.get_public_metrics(
291 metrics_query_json
292 )
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 )
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 )
316 has_sboms = snap_has_sboms(context["revisions"], context["snap_id"])
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 )
334 context["has_sboms"] = has_sboms
336 return (
337 flask.render_template("store/snap-details.html", **context),
338 status_code,
339 )
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.
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
353 context = _get_context_snap_details(snap_name)
355 button_variants = ["black", "white"]
356 button = flask.request.args.get("button")
357 if button and button not in button_variants:
358 button = "black"
360 architectures = list(context["channel_map"].keys())
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 )
374 return (
375 flask.render_template("store/snap-embedded-card.html", **context),
376 status_code,
377 )
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 )
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
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
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)
410 # channel with safest risk available in default track
411 snap_channel = "".join(
412 [context["default_track"], "/", context["lowest_risk_available"]]
413 )
415 svg = get_badge_svg(
416 snap_name=snap_name,
417 left_text=context["snap_title"],
418 right_text=snap_channel + " " + context["version"],
419 )
421 return svg, 200, {"Content-Type": "image/svg+xml"}
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)
428 if lang not in allowed_langs:
429 return Response("Invalid language", status=400)
431 file_name = (
432 "snap-store-white.svg"
433 if theme == "light"
434 else "snap-store-black.svg"
435 )
437 svg_path = os.path.normpath(os.path.join(base_path, lang, file_name))
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")
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)
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 )
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
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 )
478 return svg, 200, {"Content-Type": "image/svg+xml"}
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)
485 if not distro_data:
486 flask.abort(404)
488 supported_archs = distro_data["supported-archs"]
489 context = _get_context_snap_details(snap_name, supported_archs)
491 if all(arch not in context["channel_map"] for arch in supported_archs):
492 return flask.render_template("404.html"), 404
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", [])
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]
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 )
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 )
549 fields = flask.request.form
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
558 return flask.jsonify({"url": form_url}), 200