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
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-12 22:06 +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, "token": None}
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 res["token"] = token
225 try:
226 dns_txt_records = [
227 dns_record.to_text()
228 for dns_record in dns.resolver.resolve(domain, "TXT").rrset
229 ]
231 if f'"SNAPCRAFT_IO_VERIFICATION={token}"' in dns_txt_records:
232 res["primary_domain"] = True
234 except Exception:
235 res["primary_domain"] = False
237 response = make_response(res, 200)
238 response.cache_control.max_age = "3600"
239 return response
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.
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 """
251 error_info = {}
252 status_code = 200
254 context = _get_context_snap_details(snap_name)
256 country_metric_name = "weekly_installed_base_by_country_percent"
257 os_metric_name = "weekly_installed_base_by_operating_system_normalized"
259 end = metrics_helper.get_last_metrics_processed_date()
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 ]
276 metrics_response = device_gateway.get_public_metrics(
277 metrics_query_json
278 )
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 )
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 )
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 )
318 return (
319 flask.render_template("store/snap-details.html", **context),
320 status_code,
321 )
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.
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
335 context = _get_context_snap_details(snap_name)
337 button_variants = ["black", "white"]
338 button = flask.request.args.get("button")
339 if button and button not in button_variants:
340 button = "black"
342 architectures = list(context["channel_map"].keys())
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 )
356 return (
357 flask.render_template("store/snap-embedded-card.html", **context),
358 status_code,
359 )
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 )
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
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
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)
392 # channel with safest risk available in default track
393 snap_channel = "".join(
394 [context["default_track"], "/", context["lowest_risk_available"]]
395 )
397 svg = get_badge_svg(
398 snap_name=snap_name,
399 left_text=context["snap_title"],
400 right_text=snap_channel + " " + context["version"],
401 )
403 return svg, 200, {"Content-Type": "image/svg+xml"}
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)
410 if lang not in allowed_langs:
411 return Response("Invalid language", status=400)
413 file_name = (
414 "snap-store-white.svg"
415 if theme == "light"
416 else "snap-store-black.svg"
417 )
419 svg_path = os.path.normpath(os.path.join(base_path, lang, file_name))
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")
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)
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 )
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
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 )
460 return svg, 200, {"Content-Type": "image/svg+xml"}
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)
467 if not distro_data:
468 flask.abort(404)
470 context = _get_context_snap_details(snap_name)
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
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 )
491 try:
492 featured_snaps_results = device_gateway.get_featured_items(
493 size=13, page=1
494 ).get("results", [])
496 except StoreApiError:
497 featured_snaps_results = []
499 featured_snaps = [
500 snap
501 for snap in featured_snaps_results
502 if snap["package_name"] != snap_name
503 ][:12]
505 for snap in featured_snaps:
506 snap["icon_url"] = helpers.get_icon(snap["media"])
508 context.update({"featured_snaps": featured_snaps})
509 return flask.render_template(
510 "store/snap-distro-install.html", **context
511 )
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 )
526 fields = flask.request.form
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
535 return flask.jsonify({"url": form_url}), 200