ckanext-webpageviewplus 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ckanext/webpageviewplus/__init__.py +0 -0
- ckanext/webpageviewplus/actions.py +128 -0
- ckanext/webpageviewplus/assets/style.css +227 -0
- ckanext/webpageviewplus/assets/webassets.yml +10 -0
- ckanext/webpageviewplus/assets/webpageviewplus.js +520 -0
- ckanext/webpageviewplus/cli.py +227 -0
- ckanext/webpageviewplus/config_declaration.yaml +211 -0
- ckanext/webpageviewplus/connections.py +157 -0
- ckanext/webpageviewplus/content.py +281 -0
- ckanext/webpageviewplus/downloads.py +100 -0
- ckanext/webpageviewplus/events.py +23 -0
- ckanext/webpageviewplus/frame.py +465 -0
- ckanext/webpageviewplus/frameability.py +228 -0
- ckanext/webpageviewplus/i18n/ckanext-webpageviewplus.pot +299 -0
- ckanext/webpageviewplus/migration/webpage_view_plus/alembic.ini +74 -0
- ckanext/webpageviewplus/migration/webpage_view_plus/env.py +90 -0
- ckanext/webpageviewplus/migration/webpage_view_plus/script.py.mako +24 -0
- ckanext/webpageviewplus/migration/webpage_view_plus/versions/35c91ad0b38c_view_counts.py +41 -0
- ckanext/webpageviewplus/migration/webpage_view_plus/versions/929956801471_connections.py +38 -0
- ckanext/webpageviewplus/plugin.py +326 -0
- ckanext/webpageviewplus/public/.gitignore +0 -0
- ckanext/webpageviewplus/settings.py +282 -0
- ckanext/webpageviewplus/stats.py +144 -0
- ckanext/webpageviewplus/templates/webpageviewplus/form.html +108 -0
- ckanext/webpageviewplus/templates/webpageviewplus/view.html +103 -0
- ckanext/webpageviewplus/tests/__init__.py +0 -0
- ckanext/webpageviewplus/tests/conftest.py +69 -0
- ckanext/webpageviewplus/tests/fixtures/qsv_protocol.html +85 -0
- ckanext/webpageviewplus/tests/test_cli.py +142 -0
- ckanext/webpageviewplus/tests/test_connections.py +186 -0
- ckanext/webpageviewplus/tests/test_content.py +311 -0
- ckanext/webpageviewplus/tests/test_downloads.py +101 -0
- ckanext/webpageviewplus/tests/test_frame.py +307 -0
- ckanext/webpageviewplus/tests/test_frameability.py +210 -0
- ckanext/webpageviewplus/tests/test_plugin.py +728 -0
- ckanext/webpageviewplus/tests/test_stats.py +186 -0
- ckanext/webpageviewplus/validators.py +184 -0
- ckanext_webpageviewplus-0.1.0-py3.12-nspkg.pth +1 -0
- ckanext_webpageviewplus-0.1.0.dist-info/METADATA +410 -0
- ckanext_webpageviewplus-0.1.0.dist-info/RECORD +45 -0
- ckanext_webpageviewplus-0.1.0.dist-info/WHEEL +5 -0
- ckanext_webpageviewplus-0.1.0.dist-info/entry_points.txt +2 -0
- ckanext_webpageviewplus-0.1.0.dist-info/licenses/LICENSE +661 -0
- ckanext_webpageviewplus-0.1.0.dist-info/namespace_packages.txt +1 -0
- ckanext_webpageviewplus-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import ckan.model as model
|
|
6
|
+
import ckan.plugins.toolkit as toolkit
|
|
7
|
+
from ckan.types import Context
|
|
8
|
+
|
|
9
|
+
from ckanext.webpageviewplus import (
|
|
10
|
+
connections,
|
|
11
|
+
events,
|
|
12
|
+
frame,
|
|
13
|
+
frameability,
|
|
14
|
+
settings,
|
|
15
|
+
stats,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def frameability_auth(context: Context, data_dict: dict[str, Any]) -> dict[str, Any]:
|
|
20
|
+
"""Only someone who may add views to the resource may make CKAN fetch a
|
|
21
|
+
URL on its behalf."""
|
|
22
|
+
try:
|
|
23
|
+
toolkit.check_access(
|
|
24
|
+
"resource_view_create",
|
|
25
|
+
context,
|
|
26
|
+
{"resource_id": data_dict.get("resource_id")},
|
|
27
|
+
)
|
|
28
|
+
except toolkit.NotAuthorized:
|
|
29
|
+
return {"success": False}
|
|
30
|
+
return {"success": True}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@toolkit.side_effect_free
|
|
34
|
+
def webpageviewplus_frameability(
|
|
35
|
+
context: Context, data_dict: dict[str, Any]
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
"""Whether the page at ``url`` allows this site to frame it.
|
|
38
|
+
|
|
39
|
+
:param url: the page URL
|
|
40
|
+
:param resource_id: the resource the view is for (for authorization)
|
|
41
|
+
:returns: ``{"verdict": "frameable" | "blocked" | "unknown" | "skipped",
|
|
42
|
+
"reason": "..."}``. Never refuses on the URL itself: the view's own
|
|
43
|
+
validation does that when it's saved.
|
|
44
|
+
"""
|
|
45
|
+
toolkit.check_access("webpageviewplus_frameability", context, data_dict)
|
|
46
|
+
url = toolkit.get_or_bust(data_dict, "url")
|
|
47
|
+
if not settings.frameability_check():
|
|
48
|
+
return {"verdict": "skipped", "reason": "disabled"}
|
|
49
|
+
if not frame.is_http_url(url):
|
|
50
|
+
return {"verdict": "skipped", "reason": "not an http(s) URL"}
|
|
51
|
+
hosts = settings.ckan_hosts()
|
|
52
|
+
if frame.is_ckan_host(url, hosts):
|
|
53
|
+
return {"verdict": "skipped", "reason": "a page on this site"}
|
|
54
|
+
allowed = settings.allowed_hosts()
|
|
55
|
+
if allowed and frame.url_host(url) not in allowed:
|
|
56
|
+
return {"verdict": "skipped", "reason": "host not allowed"}
|
|
57
|
+
identity = frameability.ckan_identity(settings.site_url())
|
|
58
|
+
result = frameability.summary(frameability.check(url, identity))
|
|
59
|
+
if result.get("verdict") == "blocked":
|
|
60
|
+
events.emit("frameability_blocked", host=frame.url_host(url))
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _counted_view(data_dict: dict[str, Any]):
|
|
65
|
+
view_id = toolkit.get_or_bust(data_dict, "id")
|
|
66
|
+
view = model.Session.query(model.ResourceView).get(view_id)
|
|
67
|
+
if view is None or view.view_type != "webpage_view_plus":
|
|
68
|
+
raise toolkit.ObjectNotFound("View not found")
|
|
69
|
+
return view
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def view_stats_auth(context: Context, data_dict: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
"""Anyone who may update the view's resource."""
|
|
74
|
+
try:
|
|
75
|
+
view = _counted_view(data_dict)
|
|
76
|
+
toolkit.check_access("resource_update", context, {"id": view.resource_id})
|
|
77
|
+
except (toolkit.NotAuthorized, toolkit.ObjectNotFound, toolkit.ValidationError):
|
|
78
|
+
return {"success": False}
|
|
79
|
+
return {"success": True}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@toolkit.side_effect_free
|
|
83
|
+
def webpageviewplus_view_stats_show(
|
|
84
|
+
context: Context, data_dict: dict[str, Any]
|
|
85
|
+
) -> dict[str, Any]:
|
|
86
|
+
"""Opt-in view counts for one view.
|
|
87
|
+
|
|
88
|
+
:param id: the view's id
|
|
89
|
+
:param days: how many days back, today included (default 30, at most the
|
|
90
|
+
retention period)
|
|
91
|
+
:returns: ``{"counting": bool, "days": [{"day", "impressions", "seen",
|
|
92
|
+
"loaded", "engaged", "failed"}, ...], "totals": {...}}``. Counts hold
|
|
93
|
+
nothing about viewers.
|
|
94
|
+
"""
|
|
95
|
+
toolkit.check_access("webpageviewplus_view_stats_show", context, data_dict)
|
|
96
|
+
view = _counted_view(data_dict)
|
|
97
|
+
try:
|
|
98
|
+
days = int(data_dict.get("days") or 30)
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
raise toolkit.ValidationError({"days": ["Must be a whole number"]})
|
|
101
|
+
days = max(1, min(days, settings.view_counting_retention_days()))
|
|
102
|
+
result = stats.totals(view.id, days)
|
|
103
|
+
result["counting"] = stats.countable(view.config or {})
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def connections_auth(context: Context, data_dict: dict[str, Any]) -> dict[str, Any]:
|
|
108
|
+
"""Anyone who may update the resource."""
|
|
109
|
+
try:
|
|
110
|
+
toolkit.check_access("resource_update", context, {"id": data_dict.get("id")})
|
|
111
|
+
except (toolkit.NotAuthorized, toolkit.ObjectNotFound):
|
|
112
|
+
return {"success": False}
|
|
113
|
+
return {"success": True}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@toolkit.side_effect_free
|
|
117
|
+
def webpageviewplus_connections_show(
|
|
118
|
+
context: Context, data_dict: dict[str, Any]
|
|
119
|
+
) -> list[dict[str, Any]]:
|
|
120
|
+
"""The hosts an uploaded page was seen contacting, from CSP reports on
|
|
121
|
+
the content origin. Reports can be posted by anyone: a hint, not proof.
|
|
122
|
+
|
|
123
|
+
:param id: the resource's id
|
|
124
|
+
:returns: ``[{"host", "reports", "last_seen"}, ...]``, most reported first
|
|
125
|
+
"""
|
|
126
|
+
toolkit.check_access("webpageviewplus_connections_show", context, data_dict)
|
|
127
|
+
resource_id = toolkit.get_or_bust(data_dict, "id")
|
|
128
|
+
return connections.hosts_for(resource_id)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
.webpageviewplus {
|
|
2
|
+
position: relative;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.webpageviewplus-frame {
|
|
6
|
+
display: block;
|
|
7
|
+
width: 100%;
|
|
8
|
+
border: 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.webpageviewplus-controls {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-wrap: wrap;
|
|
14
|
+
gap: 0.5rem;
|
|
15
|
+
margin-top: 0.5rem;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.webpageviewplus-controls .btn {
|
|
19
|
+
min-height: 24px;
|
|
20
|
+
min-width: 24px;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.webpageviewplus-controls .btn:focus-visible,
|
|
24
|
+
.webpageviewplus-card [data-webpageviewplus-load]:focus-visible,
|
|
25
|
+
.webpageviewplus-narrow-open:focus-visible,
|
|
26
|
+
.webpageviewplus-skip:focus-visible {
|
|
27
|
+
outline: 3px solid currentColor;
|
|
28
|
+
outline-offset: 2px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Hidden until it has keyboard focus. Own rules rather than a Bootstrap
|
|
32
|
+
class, so it works whatever version the site's theme uses. */
|
|
33
|
+
.webpageviewplus-skip {
|
|
34
|
+
position: absolute;
|
|
35
|
+
width: 1px;
|
|
36
|
+
height: 1px;
|
|
37
|
+
overflow: hidden;
|
|
38
|
+
clip-path: inset(50%);
|
|
39
|
+
white-space: nowrap;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.webpageviewplus-skip:focus {
|
|
43
|
+
position: static;
|
|
44
|
+
display: inline-block;
|
|
45
|
+
width: auto;
|
|
46
|
+
height: auto;
|
|
47
|
+
min-height: 24px;
|
|
48
|
+
margin-bottom: 0.5rem;
|
|
49
|
+
overflow: visible;
|
|
50
|
+
clip-path: none;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Only a landing spot for the skip link. */
|
|
54
|
+
.webpageviewplus-after:focus {
|
|
55
|
+
outline: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.webpageviewplus-sr {
|
|
59
|
+
position: absolute;
|
|
60
|
+
width: 1px;
|
|
61
|
+
height: 1px;
|
|
62
|
+
margin: 0;
|
|
63
|
+
overflow: hidden;
|
|
64
|
+
clip-path: inset(50%);
|
|
65
|
+
white-space: nowrap;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Windows High Contrast and other forced-colour modes: system colours. */
|
|
69
|
+
@media (forced-colors: active) {
|
|
70
|
+
.webpageviewplus-controls .btn,
|
|
71
|
+
.webpageviewplus-narrow-open,
|
|
72
|
+
.webpageviewplus-shield span,
|
|
73
|
+
.webpageviewplus-skip {
|
|
74
|
+
border: 1px solid ButtonText;
|
|
75
|
+
color: ButtonText;
|
|
76
|
+
background: ButtonFace;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.webpageviewplus-controls .btn:focus-visible,
|
|
80
|
+
.webpageviewplus-narrow-open:focus-visible,
|
|
81
|
+
.webpageviewplus-skip:focus-visible {
|
|
82
|
+
outline-color: Highlight;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* Responsive height: below the site's narrow_width (the script sets the
|
|
87
|
+
class), aspect-ratio and share-of-window views take 80% of the screen,
|
|
88
|
+
over their inline height. Not in fullscreen or Expand. */
|
|
89
|
+
.webpageviewplus-narrow:not(:fullscreen):not(.webpageviewplus-expanded)
|
|
90
|
+
.webpageviewplus-frame:is([data-height-mode="aspect"], [data-height-mode="viewport"]) {
|
|
91
|
+
height: 80vh !important;
|
|
92
|
+
height: 80svh !important;
|
|
93
|
+
aspect-ratio: auto !important;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Load on tap (Save-Data): the card stands in for the frame. */
|
|
97
|
+
.webpageviewplus-card {
|
|
98
|
+
padding: 1rem;
|
|
99
|
+
border: 1px dashed currentColor;
|
|
100
|
+
border-radius: 4px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.webpageviewplus-card-title {
|
|
104
|
+
margin: 0 0 0.25rem;
|
|
105
|
+
font-weight: bold;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.webpageviewplus-card [data-webpageviewplus-load] {
|
|
109
|
+
min-height: 24px;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* Tap to interact: over the frame (the script sets its top and height),
|
|
113
|
+
so a swipe scrolls the page instead of the framed page. */
|
|
114
|
+
.webpageviewplus-shield {
|
|
115
|
+
position: absolute;
|
|
116
|
+
left: 0;
|
|
117
|
+
right: 0;
|
|
118
|
+
z-index: 1;
|
|
119
|
+
display: flex;
|
|
120
|
+
align-items: flex-end;
|
|
121
|
+
justify-content: center;
|
|
122
|
+
padding: 0 0 1rem;
|
|
123
|
+
border: 0;
|
|
124
|
+
background: transparent;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.webpageviewplus-shield[hidden],
|
|
128
|
+
.webpageviewplus:fullscreen .webpageviewplus-shield,
|
|
129
|
+
.webpageviewplus-expanded .webpageviewplus-shield {
|
|
130
|
+
display: none;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.webpageviewplus-shield span {
|
|
134
|
+
display: inline-flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
min-height: 44px;
|
|
137
|
+
padding: 0 1rem;
|
|
138
|
+
border-radius: 22px;
|
|
139
|
+
color: #fff;
|
|
140
|
+
background: rgba(0, 0, 0, 0.8);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.webpageviewplus-shield:focus-visible span {
|
|
144
|
+
outline: 3px solid #fff;
|
|
145
|
+
outline-offset: 2px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* Reflow (WCAG 1.4.10): at a 320px width, which is 400% zoom on a
|
|
149
|
+
1280px screen, content framed in a short box turns into a small nested
|
|
150
|
+
scroll area, so every height mode gets a tall floor. 600px matches
|
|
151
|
+
FALLBACK_HEIGHT in frame.py. Not in fullscreen or Expand, where the frame
|
|
152
|
+
fills the screen instead. */
|
|
153
|
+
.webpageviewplus-narrow-open {
|
|
154
|
+
display: none;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@media (max-width: 320px) {
|
|
158
|
+
.webpageviewplus:not(:fullscreen):not(.webpageviewplus-expanded) .webpageviewplus-frame {
|
|
159
|
+
min-height: 600px;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.webpageviewplus-narrow-open {
|
|
163
|
+
display: inline-block;
|
|
164
|
+
min-height: 24px;
|
|
165
|
+
margin-top: 0.5rem;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@media (pointer: coarse) {
|
|
170
|
+
.webpageviewplus-controls .btn,
|
|
171
|
+
.webpageviewplus-card [data-webpageviewplus-load],
|
|
172
|
+
.webpageviewplus-narrow-open {
|
|
173
|
+
min-height: 44px;
|
|
174
|
+
min-width: 44px;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* Fullscreen: the frame fills the screen above the control bar, beating its
|
|
179
|
+
inline height. */
|
|
180
|
+
.webpageviewplus:fullscreen {
|
|
181
|
+
display: flex;
|
|
182
|
+
flex-direction: column;
|
|
183
|
+
background: Canvas;
|
|
184
|
+
padding: 0.5rem;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.webpageviewplus:fullscreen .webpageviewplus-frame {
|
|
188
|
+
flex: 1;
|
|
189
|
+
height: auto !important;
|
|
190
|
+
aspect-ratio: auto !important;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* Expand, the fallback where there's no element fullscreen (iPhone
|
|
194
|
+
Safari): the view fills the viewport in place. dvh keeps it clear of the
|
|
195
|
+
address bar; the safe-area insets are 0 unless the site's viewport meta
|
|
196
|
+
has viewport-fit=cover. No transition. */
|
|
197
|
+
.webpageviewplus-expanded {
|
|
198
|
+
position: fixed;
|
|
199
|
+
inset: 0;
|
|
200
|
+
z-index: 2000;
|
|
201
|
+
display: flex;
|
|
202
|
+
flex-direction: column;
|
|
203
|
+
height: 100vh;
|
|
204
|
+
height: 100dvh;
|
|
205
|
+
margin: 0;
|
|
206
|
+
padding: max(0.5rem, env(safe-area-inset-top)) max(0.5rem, env(safe-area-inset-right))
|
|
207
|
+
max(0.5rem, env(safe-area-inset-bottom)) max(0.5rem, env(safe-area-inset-left));
|
|
208
|
+
background: Canvas;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.webpageviewplus-expanded .webpageviewplus-frame {
|
|
212
|
+
flex: 1;
|
|
213
|
+
/* Without this the frame keeps its intrinsic 150px and, on a short
|
|
214
|
+
screen, pushes the control bar (and Close) off it. */
|
|
215
|
+
min-height: 0;
|
|
216
|
+
height: auto !important;
|
|
217
|
+
aspect-ratio: auto !important;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.webpageviewplus-expanded .webpageviewplus-controls {
|
|
221
|
+
flex-shrink: 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.webpageviewplus-noscroll,
|
|
225
|
+
.webpageviewplus-noscroll body {
|
|
226
|
+
overflow: hidden;
|
|
227
|
+
}
|