Coverage for webapp/store/snap_details_views.py: 80%
147 statements
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-05 22:06 +0000
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-05 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):
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"), default_track, lowest_risk_available
91 )
92 binary_filesize = latest_channel["download"]["size"]
94 # filter out banner and banner-icon images from screenshots
95 screenshots = logic.filter_screenshots(
96 details.get("snap", {}).get("media", [])
97 )
99 icon_url = helpers.get_icon(details.get("snap", {}).get("media", []))
101 publisher_info = helpers.get_yaml(
102 "{}{}.yaml".format(
103 flask.current_app.config["CONTENT_DIRECTORY"][
104 "PUBLISHER_PAGES"
105 ],
106 details["snap"]["publisher"]["username"],
107 ),
108 typ="safe",
109 )
111 publisher_snaps = helpers.get_yaml(
112 "{}{}-snaps.yaml".format(
113 flask.current_app.config["CONTENT_DIRECTORY"][
114 "PUBLISHER_PAGES"
115 ],
116 details["snap"]["publisher"]["username"],
117 ),
118 typ="safe",
119 )
121 publisher_featured_snaps = None
123 if publisher_info:
124 publisher_featured_snaps = publisher_info.get("featured_snaps")
125 publisher_snaps = logic.get_n_random_snaps(
126 publisher_snaps["snaps"], 4
127 )
129 video = logic.get_video(details.get("snap", {}).get("media", []))
131 is_users_snap = False
132 if authentication.is_authenticated(flask.session):
133 if (
134 flask.session.get("publisher").get("nickname")
135 == details["snap"]["publisher"]["username"]
136 ):
137 is_users_snap = True
139 # build list of categories of a snap
140 categories = logic.get_snap_categories(
141 details.get("snap", {}).get("categories", [])
142 )
144 developer = logic.get_snap_developer(details["name"])
146 context = {
147 "snap-id": details.get("snap-id"),
148 # Data direct from details API
149 "snap_title": details["snap"]["title"],
150 "package_name": details["name"],
151 "categories": categories,
152 "icon_url": icon_url,
153 "version": extracted_info["version"],
154 "license": details["snap"]["license"],
155 "publisher": details["snap"]["publisher"]["display-name"],
156 "username": details["snap"]["publisher"]["username"],
157 "screenshots": screenshots,
158 "video": video,
159 "publisher_snaps": publisher_snaps,
160 "publisher_featured_snaps": publisher_featured_snaps,
161 "has_publisher_page": publisher_info is not None,
162 "contact": details["snap"].get("contact"),
163 "website": details["snap"].get("website"),
164 "summary": details["snap"]["summary"],
165 "description": formatted_description,
166 "channel_map": channel_maps_list,
167 "has_stable": logic.has_stable(channel_maps_list),
168 "developer_validation": details["snap"]["publisher"]["validation"],
169 "default_track": default_track,
170 "lowest_risk_available": lowest_risk_available,
171 "confinement": extracted_info["confinement"],
172 "trending": details.get("snap", {}).get("trending", False),
173 # Transformed API data
174 "filesize": humanize.naturalsize(binary_filesize),
175 "last_updated": logic.convert_date(last_updated),
176 "last_updated_raw": last_updated,
177 "is_users_snap": is_users_snap,
178 "unlisted": details.get("snap", {}).get("unlisted", False),
179 "developer": developer,
180 # TODO: This is horrible and hacky
181 "appliances": {
182 "adguard-home": "adguard",
183 "mosquitto": "mosquitto",
184 "nextcloud": "nextcloud",
185 "plexmediaserver": "plex",
186 "openhab": "openhab",
187 },
188 "links": details["snap"].get("links"),
189 "updates": updates,
190 }
192 return context
194 @store.route('/<regex("' + snap_regex + '"):snap_name>')
195 def snap_details(snap_name):
196 """
197 A view to display the snap details page for specific snaps.
199 This queries the snapcraft API (api.snapcraft.io) and passes
200 some of the data through to the snap-details.html template,
201 with appropriate sanitation.
202 """
204 error_info = {}
205 status_code = 200
207 context = _get_context_snap_details(snap_name)
209 country_metric_name = "weekly_installed_base_by_country_percent"
210 os_metric_name = "weekly_installed_base_by_operating_system_normalized"
212 end = metrics_helper.get_last_metrics_processed_date()
214 metrics_query_json = [
215 metrics_helper.get_filter(
216 metric_name=country_metric_name,
217 snap_id=context["snap-id"],
218 start=end,
219 end=end,
220 ),
221 metrics_helper.get_filter(
222 metric_name=os_metric_name,
223 snap_id=context["snap-id"],
224 start=end,
225 end=end,
226 ),
227 ]
229 metrics_response = device_gateway.get_public_metrics(
230 metrics_query_json
231 )
233 os_metrics = None
234 country_devices = None
235 if metrics_response:
236 oses = metrics_helper.find_metric(metrics_response, os_metric_name)
237 os_metrics = metrics.OsMetric(
238 name=oses["metric_name"],
239 series=oses["series"],
240 buckets=oses["buckets"],
241 status=oses["status"],
242 )
244 territories = metrics_helper.find_metric(
245 metrics_response, country_metric_name
246 )
247 country_devices = metrics.CountryDevices(
248 name=territories["metric_name"],
249 series=territories["series"],
250 buckets=territories["buckets"],
251 status=territories["status"],
252 private=False,
253 )
255 context.update(
256 {
257 "countries": (
258 country_devices.country_data if country_devices else None
259 ),
260 "normalized_os": os_metrics.os if os_metrics else None,
261 # Context info
262 "is_linux": (
263 "Linux" in flask.request.headers.get("User-Agent", "")
264 and "Android"
265 not in flask.request.headers.get("User-Agent", "")
266 ),
267 "error_info": error_info,
268 }
269 )
271 return (
272 flask.render_template("store/snap-details.html", **context),
273 status_code,
274 )
276 @store.route('/<regex("' + snap_regex + '"):snap_name>/embedded')
277 @exclude_xframe_options_header
278 def snap_details_embedded(snap_name):
279 """
280 A view to display the snap embedded card for specific snaps.
282 This queries the snapcraft API (api.snapcraft.io) and passes
283 some of the data through to the template,
284 with appropriate sanitation.
285 """
286 status_code = 200
288 context = _get_context_snap_details(snap_name)
290 button_variants = ["black", "white"]
291 button = flask.request.args.get("button")
292 if button and button not in button_variants:
293 button = "black"
295 architectures = list(context["channel_map"].keys())
297 context.update(
298 {
299 "default_architecture": (
300 "amd64" if "amd64" in architectures else architectures[0]
301 ),
302 "button": button,
303 "show_channels": flask.request.args.get("channels"),
304 "show_summary": flask.request.args.get("summary"),
305 "show_screenshot": flask.request.args.get("screenshot"),
306 }
307 )
309 return (
310 flask.render_template("store/snap-embedded-card.html", **context),
311 status_code,
312 )
314 @store.route('/<regex("' + snap_regex_upercase + '"):snap_name>')
315 def snap_details_case_sensitive(snap_name):
316 return flask.redirect(
317 flask.url_for(".snap_details", snap_name=snap_name.lower())
318 )
320 def get_badge_svg(snap_name, left_text, right_text, color="#0e8420"):
321 show_name = flask.request.args.get("name", default=1, type=int)
322 snap_link = flask.request.url_root + snap_name
324 svg = badge(
325 left_text=left_text if show_name else "",
326 right_text=right_text,
327 right_color=color,
328 left_link=snap_link,
329 right_link=snap_link,
330 logo=(
331 "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' "
332 "viewBox='0 0 32 32'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23f"
333 "ff%7D%3C/style%3E%3C/defs%3E%3Cpath class='cls-1' d='M18.03 1"
334 "8.03l5.95-5.95-5.95-2.65v8.6zM6.66 29.4l10.51-10.51-3.21-3.18"
335 "-7.3 13.69zM2.5 3.6l15.02 14.94V9.03L2.5 3.6zM27.03 9.03h-8.6"
336 "5l11.12 4.95-2.47-4.95z'/%3E%3C/svg%3E"
337 ),
338 )
339 return svg
341 @store.route('/<regex("' + snap_regex + '"):snap_name>/badge.svg')
342 def snap_details_badge(snap_name):
343 context = _get_context_snap_details(snap_name)
345 # channel with safest risk available in default track
346 snap_channel = "".join(
347 [context["default_track"], "/", context["lowest_risk_available"]]
348 )
350 svg = get_badge_svg(
351 snap_name=snap_name,
352 left_text=context["snap_title"],
353 right_text=snap_channel + " " + context["version"],
354 )
356 return svg, 200, {"Content-Type": "image/svg+xml"}
358 @store.route("/<lang>/<theme>/install.svg")
359 def snap_install_badge(lang, theme):
360 base_path = "static/images/badges/"
361 allowed_langs = helpers.list_folders(base_path)
363 if lang not in allowed_langs:
364 return Response("Invalid language", status=400)
366 file_name = (
367 "snap-store-white.svg"
368 if theme == "light"
369 else "snap-store-black.svg"
370 )
372 svg_path = os.path.normpath(os.path.join(base_path, lang, file_name))
374 # Ensure the path is within the base path
375 if not svg_path.startswith(base_path) or not os.path.exists(svg_path):
376 return Response(
377 '<svg height="20" width="1" '
378 'xmlns="http://www.w3.org/2000/svg" '
379 'xmlns:xlink="http://www.w3.org/1999/xlink"></svg>',
380 mimetype="image/svg+xml",
381 status=404,
382 )
383 else:
384 with open(svg_path, "r") as svg_file:
385 svg_content = svg_file.read()
386 return Response(svg_content, mimetype="image/svg+xml")
388 @store.route('/<regex("' + snap_regex + '"):snap_name>/trending.svg')
389 def snap_details_badge_trending(snap_name):
390 is_preview = flask.request.args.get("preview", default=0, type=int)
391 context = _get_context_snap_details(snap_name)
393 # default to empty SVG
394 svg = (
395 '<svg height="20" width="1" xmlns="http://www.w3.org/2000/svg" '
396 'xmlns:xlink="http://www.w3.org/1999/xlink"></svg>'
397 )
399 # publishers can see preview of trending badge of their own snaps
400 # on Publicise page
401 show_as_preview = False
402 if is_preview and authentication.is_authenticated(flask.session):
403 show_as_preview = True
405 if context["trending"] or show_as_preview:
406 svg = get_badge_svg(
407 snap_name=snap_name,
408 left_text=context["snap_title"],
409 right_text="Trending this week",
410 color="#FA7041",
411 )
413 return svg, 200, {"Content-Type": "image/svg+xml"}
415 @store.route('/install/<regex("' + snap_regex + '"):snap_name>/<distro>')
416 def snap_distro_install(snap_name, distro):
417 filename = f"store/content/distros/{distro}.yaml"
418 distro_data = helpers.get_yaml(filename)
420 if not distro_data:
421 flask.abort(404)
423 context = _get_context_snap_details(snap_name)
425 if distro == "raspbian":
426 if (
427 "armhf" not in context["channel_map"]
428 and "arm64" not in context["channel_map"]
429 ):
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