Coverage for webapp/endpoints/snaps.py: 82%
142 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-20 22:09 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-20 22:09 +0000
1import flask
2from flask import make_response
3from flask.json import jsonify
4import json
6import dns.resolver
7import re
9import webapp.helpers as helpers
10from webapp.decorators import login_required, exchange_required
11from webapp.store import logic
12from webapp.config import LP_MAX_BUILD_PAGES, LP_MAX_RECIPES
13from webapp.api.github import repository_is_public
14from webapp.api.launchpad_provenance import LaunchpadProvenance
15from webapp.endpoints.utils import get_auditable_map_cache_key
16from cache.cache_utility import redis_cache
18from canonicalwebteam.store_api.devicegw import DeviceGW
19from canonicalwebteam.store_api.dashboard import Dashboard
21device_gateway = DeviceGW("snap", helpers.api_session)
22dashboard = Dashboard(helpers.api_session)
23launchpad_provenance = LaunchpadProvenance()
25# Fields needed to resolve the default install revision per architecture.
26AUDITABLE_FIELDS = ["revision", "version", "confinement", "download"]
28# Bounds the retry rate against Launchpad without pinning a transient
29# failure for a full hour.
30FAILED_PROVENANCE_TTL = 60
32FIELDS = [
33 "title",
34 "summary",
35 "description",
36 "license",
37 "contact",
38 "website",
39 "publisher",
40 "media",
41 "download",
42 "version",
43 "created-at",
44 "confinement",
45 "categories",
46 "trending",
47 "unlisted",
48 "links",
49]
50snaps = flask.Blueprint(
51 "snaps",
52 __name__,
53)
56snap_regex = "[a-z0-9-]*[a-z][a-z0-9-]*"
59def _get_snap_link_fields(snap_name):
60 details = device_gateway.get_item_details(
61 snap_name, api_version=2, fields=FIELDS
62 )
63 return {
64 "links": details["snap"].get("links", {}),
65 }
68@snaps.route('/api/<regex("' + snap_regex + '"):snap_name>/verify')
69def dns_verified_status(snap_name):
70 res = {"primary_domain": False, "token": None}
71 context = _get_snap_link_fields(snap_name)
73 primary_domain = None
75 if "website" in context["links"]:
76 primary_domain = context["links"]["website"][0]
78 if primary_domain:
79 token = helpers.get_dns_verification_token(snap_name, primary_domain)
81 domain = re.compile(r"https?://(www\.)?")
82 domain = domain.sub("", primary_domain).strip().strip("/")
84 res["token"] = token
86 try:
87 dns_txt_records = [
88 dns_record.to_text()
89 for dns_record in dns.resolver.resolve(domain, "TXT").rrset
90 ]
92 if f'"SNAPCRAFT_IO_VERIFICATION={token}"' in dns_txt_records:
93 res["primary_domain"] = True
95 except Exception:
96 res["primary_domain"] = False
98 response = make_response(res, 200)
99 response.cache_control.max_age = "3600"
100 return response
103def _get_provenance_map(snap_name):
104 """Return the (cached) Launchpad provenance map for a snap.
106 The map is expensive to build (paginated Launchpad calls), so it is cached
107 for an hour and shared by both auditable endpoints.
109 Failures are cached briefly rather than not at all: every page view
110 fetches provenance, so skipping the cache on failure would send each one
111 back to an already-struggling Launchpad. The repository check lives here
112 so its single GitHub call rides the same cache.
113 """
114 cache_key = get_auditable_map_cache_key(snap_name)
115 cached = redis_cache.get(cache_key, expected_type=dict)
116 if cached is not None:
117 return cached
119 provenance_map = launchpad_provenance.build_provenance_map(
120 snap_name, LP_MAX_BUILD_PAGES, LP_MAX_RECIPES
121 )
122 provenance_map["source_available"] = repository_is_public(
123 provenance_map.get("github_repository")
124 )
125 ttl = FAILED_PROVENANCE_TTL if provenance_map.get("failed") else 3600
126 redis_cache.set(cache_key, provenance_map, ttl=ttl)
127 return provenance_map
130def _resolve_default_install(details):
131 """Resolve the default install option to a single (architecture, revision).
133 Mirrors what the detail page shows next to the Install button: default
134 track, lowest available risk, and a deterministic architecture preference
135 (amd64 if published, otherwise the first architecture sorted).
136 """
137 channel_maps = logic.convert_channel_maps(details.get("channel-map"))
138 if not channel_maps:
139 return None, None
141 default_track = details.get("default-track") or "latest"
142 lowest_risk = logic.get_lowest_available_risk(channel_maps, default_track)
144 architecture = logic.get_default_architecture(channel_maps.keys())
146 releases = channel_maps.get(architecture, {}).get(default_track, [])
147 for release in releases:
148 if release["risk"] == lowest_risk:
149 return architecture, release["revision"]
151 return architecture, None
154@snaps.route('/api/<regex("' + snap_regex + '"):snap_name>/auditable')
155def auditable(snap_name):
156 """Public endpoint backing the provenance badge under the Install button.
158 Returns the git commit the default install revision was built from on
159 Launchpad. The ``status`` field distinguishes every outcome so the badge
160 can react:
162 - ``verified``: built on Launchpad from a public commit (commit returned).
163 - ``unavailable``: a public GitHub recipe exists, but this revision has no
164 matching build (e.g. uploaded manually).
165 - ``not-provided``: no public provenance — no Launchpad recipe, or the
166 recipe's repo is private / non-GitHub.
167 - ``error``: Launchpad couldn't be reached, so retrying may help. A scan
168 that merely hit its bounds is normal, not an error.
170 Never raises to the user.
171 """
172 res = {"auditable": False, "status": "not-provided"}
174 try:
175 details = device_gateway.get_item_details(
176 snap_name, api_version=2, fields=AUDITABLE_FIELDS
177 )
178 architecture, revision = _resolve_default_install(details)
180 if architecture and revision:
181 provenance_map = _get_provenance_map(snap_name)
182 arch_map = provenance_map.get("revisions", {}).get(
183 str(revision), {}
184 )
185 build = arch_map.get(architecture)
186 # The map can span recipes, so prefer the row's own repository.
187 github_repository = (build or {}).get(
188 "github_repository"
189 ) or provenance_map.get("github_repository")
191 base = {
192 "auditable": False,
193 "revision": revision,
194 "architecture": architecture,
195 }
197 source_available = provenance_map.get("source_available", True)
199 if build and build.get("commit_url") and source_available:
200 res = {
201 **base,
202 "auditable": True,
203 "status": "verified",
204 "commit_sha": build["commit_sha"],
205 "github_repository": github_repository,
206 "commit_url": build["commit_url"],
207 "build_id": build.get("build_id"),
208 "build_url": build.get("build_url"),
209 }
210 elif build:
211 # No commit to link (Launchpad-hosted or private source), or
212 # the repository has gone. Both are settled answers, not
213 # failed lookups.
214 res = {**base, "status": "not-provided"}
215 elif provenance_map.get("failed"):
216 # Only a real failure earns the error state, since it is the
217 # only case where "try again later" is true.
218 res = {**base, "status": "error"}
219 elif github_repository:
220 # Public recipe exists, but this revision has no build/commit.
221 res = {
222 **base,
223 "status": "unavailable",
224 "github_repository": github_repository,
225 }
226 else:
227 # No public recipe (private or non-GitHub source).
228 res = {**base, "status": "not-provided"}
229 except Exception:
230 res = {"auditable": False, "status": "error"}
232 response = make_response(res, 200)
233 response.cache_control.max_age = (
234 0 if res.get("status") == "error" else 3600
235 )
236 return response
239@snaps.route(
240 '/api/<regex("' + snap_regex + '"):snap_name>/auditable-revisions'
241)
242def auditable_revisions(snap_name):
243 """Public endpoint backing the Security tab's per-revision commit links.
245 Returns commit links for the snap's recent revisions (bounded by
246 LP_MAX_BUILD_PAGES). Revisions without provenance are simply absent.
247 ``error`` is true when Launchpad couldn't be reached, so the Security
248 tab can distinguish "no provenance" from "couldn't load right now". A
249 truncated scan is not an error: this endpoint is bounded by design.
250 """
251 res = {"github_repository": None, "revisions": {}, "error": False}
253 try:
254 provenance_map = _get_provenance_map(snap_name)
255 res["github_repository"] = provenance_map.get("github_repository")
256 res["error"] = bool(provenance_map.get("failed"))
258 # If the repository is gone, every commit link would 404.
259 if provenance_map.get("source_available", True):
260 for revision, arch_map in provenance_map["revisions"].items():
261 # One architecture build per store revision.
262 for build in arch_map.values():
263 if build.get("commit_url"):
264 res["revisions"][revision] = {
265 "commit_sha": build["commit_sha"],
266 "commit_url": build["commit_url"],
267 "build_id": build.get("build_id"),
268 "build_url": build.get("build_url"),
269 }
270 break
271 except Exception:
272 res = {"github_repository": None, "revisions": {}, "error": True}
274 response = make_response(res, 200)
275 response.cache_control.max_age = 0 if res.get("error") else 3600
276 return response
279@snaps.route("/api/store/<store_id>/snaps")
280@login_required
281@exchange_required
282def get_store_snaps(store_id):
283 snaps = dashboard.get_store_snaps(flask.session, store_id)
284 store = dashboard.get_store(flask.session, store_id)
285 if "store-whitelist" in store:
286 included_stores = []
287 for item in store["store-whitelist"]:
288 try:
289 store_item = dashboard.get_store(flask.session, item)
290 if store_item:
291 included_stores.append(
292 {
293 "id": store_item["id"],
294 "name": store_item["name"],
295 "userHasAccess": True,
296 }
297 )
298 except Exception:
299 included_stores.append(
300 {
301 "id": item,
302 "name": "Private store",
303 "userHasAccess": False,
304 }
305 )
307 if included_stores:
308 snaps.append({"included-stores": included_stores})
309 return jsonify(snaps)
312@snaps.route("/api/store/<store_id>/snaps", methods=["POST"])
313@login_required
314@exchange_required
315def post_manage_store_snaps(store_id):
316 snaps = json.loads(flask.request.form.get("snaps"))
318 res = {}
320 dashboard.update_store_snaps(flask.session, store_id, snaps)
321 res["msg"] = "Changes saved"
323 return jsonify({"success": True})