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
« prev ^ index » next coverage.py v7.10.5, created at 2025-08-26 22:06 +0000
1import flask
2from flask import Response
4import humanize
5import os
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
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
21device_gateway = DeviceGW("snap", helpers.api_session)
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]
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-]*"
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))
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))
62 formatted_description = parse_markdown_description(
63 details.get("snap", {}).get("description", "")
64 )
66 channel_maps_list = logic.convert_channel_maps(
67 details.get("channel-map")
68 )
70 latest_channel = logic.get_last_updated_version(
71 details.get("channel-map")
72 )
74 default_track = (
75 details.get("default-track")
76 if details.get("default-track")
77 else "latest"
78 )
80 lowest_risk_available = logic.get_lowest_available_risk(
81 channel_maps_list, default_track
82 )
84 extracted_info = logic.extract_info_channel_map(
85 channel_maps_list, default_track, lowest_risk_available
86 )
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"]
97 # filter out banner and banner-icon images from screenshots
98 screenshots = logic.filter_screenshots(
99 details.get("snap", {}).get("media", [])
100 )
102 icon_url = helpers.get_icon(details.get("snap", {}).get("media", []))
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 )
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 )
124 publisher_featured_snaps = None
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 )
132 video = logic.get_video(details.get("snap", {}).get("media", []))
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
142 # build list of categories of a snap
143 categories = logic.get_snap_categories(
144 details.get("snap", {}).get("categories", [])
145 )
147 developer = logic.get_snap_developer(details["name"])
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 }
195 return context
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.
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 """
207 error_info = {}
208 status_code = 200
210 context = _get_context_snap_details(snap_name)
212 country_metric_name = "weekly_installed_base_by_country_percent"
213 os_metric_name = "weekly_installed_base_by_operating_system_normalized"
215 end = metrics_helper.get_last_metrics_processed_date()
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 ]
232 metrics_response = device_gateway.get_public_metrics(
233 metrics_query_json
234 )
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 )
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 )
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 )
274 return (
275 flask.render_template("store/snap-details.html", **context),
276 status_code,
277 )
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.
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
291 context = _get_context_snap_details(snap_name)
293 button_variants = ["black", "white"]
294 button = flask.request.args.get("button")
295 if button and button not in button_variants:
296 button = "black"
298 architectures = list(context["channel_map"].keys())
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 )
312 return (
313 flask.render_template("store/snap-embedded-card.html", **context),
314 status_code,
315 )
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 )
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
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
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)
348 # channel with safest risk available in default track
349 snap_channel = "".join(
350 [context["default_track"], "/", context["lowest_risk_available"]]
351 )
353 svg = get_badge_svg(
354 snap_name=snap_name,
355 left_text=context["snap_title"],
356 right_text=snap_channel + " " + context["version"],
357 )
359 return svg, 200, {"Content-Type": "image/svg+xml"}
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)
366 if lang not in allowed_langs:
367 return Response("Invalid language", status=400)
369 file_name = (
370 "snap-store-white.svg"
371 if theme == "light"
372 else "snap-store-black.svg"
373 )
375 svg_path = os.path.normpath(os.path.join(base_path, lang, file_name))
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")
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)
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 )
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
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 )
416 return svg, 200, {"Content-Type": "image/svg+xml"}
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)
423 if not distro_data:
424 flask.abort(404)
426 supported_archs = distro_data["supported-archs"]
427 context = _get_context_snap_details(snap_name, supported_archs)
429 if all(arch not in context["channel_map"] for arch in supported_archs):
430 return flask.render_template("404.html"), 404
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 )
444 try:
445 featured_snaps_results = device_gateway.get_featured_items(
446 size=13, page=1
447 ).get("results", [])
449 except StoreApiError:
450 featured_snaps_results = []
452 featured_snaps = [
453 snap
454 for snap in featured_snaps_results
455 if snap["package_name"] != snap_name
456 ][:12]
458 for snap in featured_snaps:
459 snap["icon_url"] = helpers.get_icon(snap["media"])
461 context.update({"featured_snaps": featured_snaps})
462 return flask.render_template(
463 "store/snap-distro-install.html", **context
464 )
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 )
479 fields = flask.request.form
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
488 return flask.jsonify({"url": form_url}), 200