@anthusai/papyrus 5.19.1 → 5.20.0
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.
- package/amplify/data/resource.js +1 -0
- package/amplify/data/resource.ts +1 -0
- package/amplify/data/schema.js +1 -0
- package/amplify/data/schema.ts +1 -0
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +3 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/plugins/reader-steering/config.js +111 -0
- package/amplify/plugins/reader-steering/config.ts +158 -0
- package/amplify/plugins/reader-steering/function/handler.py +544 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +139 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +162 -0
- package/amplify/plugins/reader-steering/schema.js +40 -0
- package/amplify/plugins/reader-steering/schema.ts +42 -0
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/plugins/types.ts +3 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/components/reference-cyclotron-priming.tsx +54 -15
- package/infra/amplify-app-shell.js +15 -1
- package/infra/amplify-app-shell.ts +17 -1
- package/infra/site-config.js +12 -2
- package/infra/site-config.ts +19 -2
- package/lib/editorial-checks.ts +6 -2
- package/lib/prospectus-session.ts +1 -0
- package/lib/prospectus-sign-in-routes.ts +2 -2
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +143 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/package.json +9 -1
- package/routes.manifest.json +56 -1
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""reader-actions: the one function behind the reader API of the reader-steering plugin.
|
|
2
|
+
|
|
3
|
+
The CMS app's reader routes invoke it with ``{publication, siteSubject, isStaff, action, payload}`` after they
|
|
4
|
+
have checked the session, the Origin and the body size. It validates every field, rate limits per reader,
|
|
5
|
+
and writes the ReaderProfile, ReaderHandle and ReaderVote tables directly with its own role. It answers
|
|
6
|
+
``{statusCode, body}``; the body never contains the siteSubject.
|
|
7
|
+
|
|
8
|
+
The siteSubject is the per-site pairwise id that Prospectus returns. The global person id never reaches this
|
|
9
|
+
function. Only ``ss1_`` subjects are accepted, plus ``ssdev_`` ones when the site was synthesized for
|
|
10
|
+
development or test (docs/reader-steering.md).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
from typing import Any, Callable, Protocol
|
|
24
|
+
|
|
25
|
+
SITE_SUBJECT = re.compile(r"ss1_[A-Za-z0-9_-]{43}")
|
|
26
|
+
INTERIM_SUBJECT = re.compile(r"ssdev_[A-Za-z0-9_-]{43}")
|
|
27
|
+
SUBJECT_KEY = re.compile(r"[A-Za-z0-9._:-]{1,200}")
|
|
28
|
+
HANDLE = re.compile(r"[a-z0-9_-]{3,24}")
|
|
29
|
+
TOGGLE_DIMENSIONS = ("up", "used-it")
|
|
30
|
+
PUBLISHED_ID_PREFIX = "published-"
|
|
31
|
+
MAXIMUM_SUBJECTS = 50
|
|
32
|
+
MINIMUM_RATING = 1
|
|
33
|
+
MAXIMUM_RATING = 5
|
|
34
|
+
HOUR_SECONDS = 3600
|
|
35
|
+
HANDLE_CHANGES_PER_HOUR = 10
|
|
36
|
+
STATE_ACTIVE = "active"
|
|
37
|
+
STATE_LIMITED = "limited"
|
|
38
|
+
STATE_BANNED = "banned"
|
|
39
|
+
ROLE_READER = "reader"
|
|
40
|
+
ROLE_EDITOR = "editor"
|
|
41
|
+
ACTION_ME = "me"
|
|
42
|
+
ACTION_HANDLE = "handle"
|
|
43
|
+
ACTION_VOTE = "vote"
|
|
44
|
+
ACTION_RATING = "rating"
|
|
45
|
+
RATE_BUCKET_REACTIONS = "react"
|
|
46
|
+
RATE_BUCKET_HANDLE = "handle"
|
|
47
|
+
SNAPSHOT_TASK = {"projectionTask": {"task": "reader-snapshot"}}
|
|
48
|
+
SNAPSHOT_SCHEDULE_PREFIX = "papyrus-reader-snapshot-"
|
|
49
|
+
RESERVED_HANDLES = frozenset(
|
|
50
|
+
"admin administrator root system sysadmin moderator mod mods staff editor editors support help security "
|
|
51
|
+
"official anonymous anon guest null undefined none me you everyone nobody deleted reader readers "
|
|
52
|
+
"papyrus anthus prospectus api www mail abuse postmaster webmaster".split()
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ReaderSettings:
|
|
58
|
+
dimensions: tuple[str, ...] = ()
|
|
59
|
+
actions_per_hour: int = 120
|
|
60
|
+
new_account_hours: int = 24
|
|
61
|
+
new_account_divisor: int = 3
|
|
62
|
+
reserved_handles: frozenset[str] = frozenset()
|
|
63
|
+
allow_interim_subjects: bool = False
|
|
64
|
+
snapshot_debounce_seconds: int = 60
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def settings_from_environment(environment: dict[str, str] | None = None) -> ReaderSettings:
|
|
68
|
+
environment = os.environ if environment is None else environment
|
|
69
|
+
raw = json.loads(environment.get("PAPYRUS_READER_SETTINGS") or "{}")
|
|
70
|
+
limits = raw.get("limits") or {}
|
|
71
|
+
defaults = ReaderSettings()
|
|
72
|
+
return ReaderSettings(
|
|
73
|
+
dimensions=tuple(raw.get("dimensions") or ()),
|
|
74
|
+
actions_per_hour=int(limits.get("actionsPerHour", defaults.actions_per_hour)),
|
|
75
|
+
new_account_hours=int(limits.get("newAccountHours", defaults.new_account_hours)),
|
|
76
|
+
new_account_divisor=int(limits.get("newAccountDivisor", defaults.new_account_divisor)),
|
|
77
|
+
reserved_handles=frozenset(raw.get("reservedHandles") or ()),
|
|
78
|
+
allow_interim_subjects=bool(raw.get("allowInterimSubjects", False)),
|
|
79
|
+
snapshot_debounce_seconds=int(raw.get("snapshotDebounceSeconds", defaults.snapshot_debounce_seconds)),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ReaderStore(Protocol):
|
|
84
|
+
def get_profile(self, site_subject: str) -> dict[str, Any] | None: ...
|
|
85
|
+
|
|
86
|
+
def create_profile(self, profile: dict[str, Any]) -> bool: ...
|
|
87
|
+
|
|
88
|
+
def claim_handle(self, site_subject: str, reader_id: str, handle: str, now_iso: str) -> str: ...
|
|
89
|
+
|
|
90
|
+
def get_vote_values(self, vote_ids: list[str]) -> dict[str, int]: ...
|
|
91
|
+
|
|
92
|
+
def put_vote(self, vote: dict[str, Any], now_iso: str) -> None: ...
|
|
93
|
+
|
|
94
|
+
def delete_vote(self, vote_id: str) -> None: ...
|
|
95
|
+
|
|
96
|
+
def is_published(self, subject_key: str) -> bool: ...
|
|
97
|
+
|
|
98
|
+
def count_action(self, reader_id: str, bucket: str, window_start: int, limit: int, expires_at: int) -> bool: ...
|
|
99
|
+
|
|
100
|
+
def claim_snapshot_slot(self, now_seconds: float, debounce_seconds: int) -> int | None: ...
|
|
101
|
+
|
|
102
|
+
def release_snapshot_slot(self, locked_until: int) -> None: ...
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def reply(status: int, body: dict[str, Any]) -> dict[str, Any]:
|
|
106
|
+
return {"statusCode": status, "body": body}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def refusal(status: int, error: str, message: str, **extra: Any) -> dict[str, Any]:
|
|
110
|
+
return reply(status, {"error": error, "message": message, **extra})
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def iso(now_seconds: float) -> str:
|
|
114
|
+
return datetime.fromtimestamp(now_seconds, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def seconds_of(iso_text: str) -> float:
|
|
118
|
+
return datetime.strptime(iso_text, "%Y-%m-%dT%H:%M:%S.000Z").replace(tzinfo=timezone.utc).timestamp()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def vote_id(subject_key: str, reader_id: str, dimension: str) -> str:
|
|
122
|
+
return f"{subject_key}#{reader_id}#{dimension}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def valid_site_subject(value: Any, settings: ReaderSettings) -> bool:
|
|
126
|
+
if not isinstance(value, str):
|
|
127
|
+
return False
|
|
128
|
+
if SITE_SUBJECT.fullmatch(value):
|
|
129
|
+
return True
|
|
130
|
+
return settings.allow_interim_subjects and INTERIM_SUBJECT.fullmatch(value) is not None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def action_limit(profile: dict[str, Any], settings: ReaderSettings, now_seconds: float) -> int:
|
|
134
|
+
age_hours = (now_seconds - seconds_of(profile["createdAt"])) / HOUR_SECONDS
|
|
135
|
+
if age_hours < settings.new_account_hours:
|
|
136
|
+
return max(1, settings.actions_per_hour // settings.new_account_divisor)
|
|
137
|
+
return settings.actions_per_hour
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def within_rate_limit(
|
|
141
|
+
store: ReaderStore, profile: dict[str, Any], bucket: str, limit: int, now_seconds: float
|
|
142
|
+
) -> dict[str, Any] | None:
|
|
143
|
+
window_start = int(now_seconds // HOUR_SECONDS) * HOUR_SECONDS
|
|
144
|
+
window_end = window_start + HOUR_SECONDS
|
|
145
|
+
if store.count_action(profile["readerId"], bucket, window_start, limit, window_end + HOUR_SECONDS):
|
|
146
|
+
return None
|
|
147
|
+
return refusal(429, "rate_limited", "Too many actions. Try again later.", retryAfter=max(1, int(window_end - now_seconds)))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ensure_profile(store: ReaderStore, site_subject: str, now_seconds: float) -> dict[str, Any]:
|
|
151
|
+
profile = store.get_profile(site_subject)
|
|
152
|
+
if profile is not None:
|
|
153
|
+
return profile
|
|
154
|
+
created = {
|
|
155
|
+
"id": site_subject,
|
|
156
|
+
"siteSubject": site_subject,
|
|
157
|
+
"readerId": str(uuid.uuid4()),
|
|
158
|
+
"state": STATE_ACTIVE,
|
|
159
|
+
"karma": 0,
|
|
160
|
+
"createdAt": iso(now_seconds),
|
|
161
|
+
}
|
|
162
|
+
if store.create_profile(created):
|
|
163
|
+
return created
|
|
164
|
+
existing = store.get_profile(site_subject)
|
|
165
|
+
if existing is None:
|
|
166
|
+
raise RuntimeError("The reader profile could not be created.")
|
|
167
|
+
return existing
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def public_profile(profile: dict[str, Any]) -> dict[str, Any]:
|
|
171
|
+
return {
|
|
172
|
+
"readerId": profile["readerId"],
|
|
173
|
+
"handle": profile.get("handle"),
|
|
174
|
+
"karma": int(profile.get("karma") or 0),
|
|
175
|
+
"state": profile.get("state", STATE_ACTIVE),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def my_state(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any], settings: ReaderSettings) -> dict[str, Any]:
|
|
180
|
+
subjects = payload.get("subjects", [])
|
|
181
|
+
if (
|
|
182
|
+
not isinstance(subjects, list)
|
|
183
|
+
or len(subjects) > MAXIMUM_SUBJECTS
|
|
184
|
+
or not all(isinstance(subject, str) and SUBJECT_KEY.fullmatch(subject) for subject in subjects)
|
|
185
|
+
):
|
|
186
|
+
return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} item keys.")
|
|
187
|
+
dimensions = [*TOGGLE_DIMENSIONS, *settings.dimensions]
|
|
188
|
+
wanted = {vote_id(subject, profile["readerId"], dimension): (subject, dimension) for subject in subjects for dimension in dimensions}
|
|
189
|
+
found = store.get_vote_values(list(wanted))
|
|
190
|
+
votes: dict[str, dict[str, int]] = {}
|
|
191
|
+
for stored_id, value in found.items():
|
|
192
|
+
subject, dimension = wanted[stored_id]
|
|
193
|
+
votes.setdefault(subject, {})[dimension] = value
|
|
194
|
+
return reply(200, {**public_profile(profile), "votes": votes})
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def set_handle(
|
|
198
|
+
store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any], settings: ReaderSettings, now_seconds: float
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
requested = payload.get("handle")
|
|
201
|
+
if not isinstance(requested, str):
|
|
202
|
+
return refusal(422, "invalid_handle", "Send a handle.")
|
|
203
|
+
handle = requested.lower()
|
|
204
|
+
if not HANDLE.fullmatch(handle):
|
|
205
|
+
return refusal(422, "invalid_handle", "A handle has 3 to 24 letters, digits, hyphens or underscores.")
|
|
206
|
+
if handle in RESERVED_HANDLES or handle in settings.reserved_handles:
|
|
207
|
+
return refusal(422, "reserved_handle", "That handle is reserved.")
|
|
208
|
+
if profile.get("handle"):
|
|
209
|
+
return refusal(409, "handle_already_set", "The handle is set once and cannot be changed.")
|
|
210
|
+
limited = within_rate_limit(store, profile, RATE_BUCKET_HANDLE, HANDLE_CHANGES_PER_HOUR, now_seconds)
|
|
211
|
+
if limited:
|
|
212
|
+
return limited
|
|
213
|
+
outcome = store.claim_handle(profile["siteSubject"], profile["readerId"], handle, iso(now_seconds))
|
|
214
|
+
if outcome == "taken":
|
|
215
|
+
return refusal(409, "handle_taken", "That handle is taken.")
|
|
216
|
+
if outcome == "already_set":
|
|
217
|
+
return refusal(409, "handle_already_set", "The handle is set once and cannot be changed.")
|
|
218
|
+
return reply(200, {**public_profile({**profile, "handle": handle})})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def react(
|
|
222
|
+
store: ReaderStore,
|
|
223
|
+
profile: dict[str, Any],
|
|
224
|
+
*,
|
|
225
|
+
subject: Any,
|
|
226
|
+
dimension: Any,
|
|
227
|
+
allowed_dimensions: tuple[str, ...],
|
|
228
|
+
value: int | None,
|
|
229
|
+
is_staff: bool,
|
|
230
|
+
settings: ReaderSettings,
|
|
231
|
+
now_seconds: float,
|
|
232
|
+
schedule_snapshot: Callable[[], None],
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
if not isinstance(subject, str) or not SUBJECT_KEY.fullmatch(subject):
|
|
235
|
+
return refusal(422, "invalid_subject", "Send the key of a published item.")
|
|
236
|
+
if not isinstance(dimension, str) or dimension not in allowed_dimensions:
|
|
237
|
+
return refusal(422, "invalid_dimension", "That dimension is not available.")
|
|
238
|
+
if profile.get("state") == STATE_BANNED:
|
|
239
|
+
return refusal(403, "reader_banned", "This reader cannot take part.")
|
|
240
|
+
if profile.get("state") == STATE_LIMITED:
|
|
241
|
+
return refusal(403, "reader_limited", "This reader cannot vote or rate right now.")
|
|
242
|
+
limited = within_rate_limit(store, profile, RATE_BUCKET_REACTIONS, action_limit(profile, settings, now_seconds), now_seconds)
|
|
243
|
+
if limited:
|
|
244
|
+
return limited
|
|
245
|
+
if not store.is_published(subject):
|
|
246
|
+
return refusal(404, "unknown_subject", "That item is not published.")
|
|
247
|
+
stored_id = vote_id(subject, profile["readerId"], dimension)
|
|
248
|
+
if value is None:
|
|
249
|
+
store.delete_vote(stored_id)
|
|
250
|
+
else:
|
|
251
|
+
store.put_vote(
|
|
252
|
+
{
|
|
253
|
+
"id": stored_id,
|
|
254
|
+
"subjectKey": subject,
|
|
255
|
+
"readerId": profile["readerId"],
|
|
256
|
+
"dimension": dimension,
|
|
257
|
+
"value": value,
|
|
258
|
+
"voterRole": ROLE_EDITOR if is_staff else ROLE_READER,
|
|
259
|
+
},
|
|
260
|
+
iso(now_seconds),
|
|
261
|
+
)
|
|
262
|
+
schedule_snapshot()
|
|
263
|
+
return reply(200, {"subject": subject, "dimension": dimension, "value": value})
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
|
|
267
|
+
on = payload.get("on")
|
|
268
|
+
if not isinstance(on, bool):
|
|
269
|
+
return refusal(422, "invalid_vote", "Send on as true or false.")
|
|
270
|
+
return react(
|
|
271
|
+
store, profile, subject=payload.get("subject"), dimension=payload.get("dimension"), allowed_dimensions=TOGGLE_DIMENSIONS,
|
|
272
|
+
value=1 if on else None, is_staff=is_staff, settings=settings, now_seconds=now_seconds, schedule_snapshot=schedule_snapshot,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def rating_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
|
|
277
|
+
value = payload.get("value")
|
|
278
|
+
if value is not None and (isinstance(value, bool) or not isinstance(value, int) or not MINIMUM_RATING <= value <= MAXIMUM_RATING):
|
|
279
|
+
return refusal(422, "invalid_rating", f"Send a whole number from {MINIMUM_RATING} to {MAXIMUM_RATING}, or null.")
|
|
280
|
+
return react(
|
|
281
|
+
store, profile, subject=payload.get("subject"), dimension=payload.get("dimension"), allowed_dimensions=settings.dimensions,
|
|
282
|
+
value=value, is_staff=is_staff, settings=settings, now_seconds=now_seconds, schedule_snapshot=schedule_snapshot,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def handle_event(
|
|
287
|
+
event: dict[str, Any],
|
|
288
|
+
*,
|
|
289
|
+
store: ReaderStore,
|
|
290
|
+
settings: ReaderSettings,
|
|
291
|
+
now_seconds: float,
|
|
292
|
+
schedule_snapshot: Callable[[], None],
|
|
293
|
+
) -> dict[str, Any]:
|
|
294
|
+
site_subject, action, payload = event.get("siteSubject"), event.get("action"), event.get("payload")
|
|
295
|
+
if not valid_site_subject(site_subject, settings):
|
|
296
|
+
return refusal(403, "reader_identity_invalid", "The reader identity is not valid for this site.")
|
|
297
|
+
if not isinstance(payload, dict) or not isinstance(event.get("isStaff"), bool):
|
|
298
|
+
return refusal(400, "malformed_request", "The request is malformed.")
|
|
299
|
+
profile = ensure_profile(store, site_subject, now_seconds)
|
|
300
|
+
if action == ACTION_ME:
|
|
301
|
+
return my_state(store, profile, payload, settings)
|
|
302
|
+
if profile.get("state") == STATE_BANNED:
|
|
303
|
+
return refusal(403, "reader_banned", "This reader cannot take part.")
|
|
304
|
+
if action == ACTION_HANDLE:
|
|
305
|
+
return set_handle(store, profile, payload, settings, now_seconds)
|
|
306
|
+
if action == ACTION_VOTE:
|
|
307
|
+
return vote_action(store, profile, payload, settings, event["isStaff"], now_seconds, schedule_snapshot)
|
|
308
|
+
if action == ACTION_RATING:
|
|
309
|
+
return rating_action(store, profile, payload, settings, event["isStaff"], now_seconds, schedule_snapshot)
|
|
310
|
+
return refusal(400, "unknown_action", "Unknown action.")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def plain(value: Any) -> Any:
|
|
314
|
+
if isinstance(value, Decimal):
|
|
315
|
+
return int(value) if value == value.to_integral_value() else float(value)
|
|
316
|
+
if isinstance(value, dict):
|
|
317
|
+
return {key: plain(item) for key, item in value.items()}
|
|
318
|
+
if isinstance(value, list):
|
|
319
|
+
return [plain(item) for item in value]
|
|
320
|
+
return value
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def is_conditional_failure(error: Exception) -> bool:
|
|
324
|
+
return str(getattr(error, "response", {}).get("Error", {}).get("Code", "")) == "ConditionalCheckFailedException"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class DynamoReaderStore:
|
|
328
|
+
"""The Amplify model tables written directly: items carry ``__typename``, ``createdAt`` and ``updatedAt`` so staff can list them."""
|
|
329
|
+
|
|
330
|
+
def __init__(self, *, resource: Any, profile_table: str, handle_table: str, vote_table: str, published_item_table: str, rate_table: str) -> None:
|
|
331
|
+
self._resource = resource
|
|
332
|
+
self._names = {"profile": profile_table, "handle": handle_table, "vote": vote_table, "published": published_item_table, "rate": rate_table}
|
|
333
|
+
|
|
334
|
+
def _table(self, key: str) -> Any:
|
|
335
|
+
return self._resource.Table(self._names[key])
|
|
336
|
+
|
|
337
|
+
def get_profile(self, site_subject: str) -> dict[str, Any] | None:
|
|
338
|
+
item = self._table("profile").get_item(Key={"id": site_subject}, ConsistentRead=True).get("Item")
|
|
339
|
+
return plain(item) if item else None
|
|
340
|
+
|
|
341
|
+
def create_profile(self, profile: dict[str, Any]) -> bool:
|
|
342
|
+
item = {**profile, "__typename": "ReaderProfile", "updatedAt": profile["createdAt"]}
|
|
343
|
+
try:
|
|
344
|
+
self._table("profile").put_item(Item=item, ConditionExpression="attribute_not_exists(id)")
|
|
345
|
+
except Exception as error:
|
|
346
|
+
if is_conditional_failure(error):
|
|
347
|
+
return False
|
|
348
|
+
raise
|
|
349
|
+
return True
|
|
350
|
+
|
|
351
|
+
def claim_handle(self, site_subject: str, reader_id: str, handle: str, now_iso: str) -> str:
|
|
352
|
+
client = self._resource.meta.client
|
|
353
|
+
try:
|
|
354
|
+
client.transact_write_items(
|
|
355
|
+
TransactItems=[
|
|
356
|
+
{
|
|
357
|
+
"Put": {
|
|
358
|
+
"TableName": self._names["handle"],
|
|
359
|
+
"Item": {"id": handle, "__typename": "ReaderHandle", "readerId": reader_id, "createdAt": now_iso, "updatedAt": now_iso},
|
|
360
|
+
"ConditionExpression": "attribute_not_exists(id)",
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
"Update": {
|
|
365
|
+
"TableName": self._names["profile"],
|
|
366
|
+
"Key": {"id": site_subject},
|
|
367
|
+
"UpdateExpression": "SET #h = :h, updatedAt = :now",
|
|
368
|
+
"ConditionExpression": "attribute_exists(id) AND attribute_not_exists(#h)",
|
|
369
|
+
"ExpressionAttributeNames": {"#h": "handle"},
|
|
370
|
+
"ExpressionAttributeValues": {":h": handle, ":now": now_iso},
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
]
|
|
374
|
+
)
|
|
375
|
+
except Exception as error:
|
|
376
|
+
reasons = getattr(error, "response", {}).get("CancellationReasons") or []
|
|
377
|
+
codes = [str(reason.get("Code", "")) for reason in reasons]
|
|
378
|
+
if codes[:1] == ["ConditionalCheckFailed"]:
|
|
379
|
+
return "taken"
|
|
380
|
+
if codes[1:2] == ["ConditionalCheckFailed"]:
|
|
381
|
+
return "already_set"
|
|
382
|
+
raise
|
|
383
|
+
return "claimed"
|
|
384
|
+
|
|
385
|
+
def get_vote_values(self, vote_ids: list[str]) -> dict[str, int]:
|
|
386
|
+
found: dict[str, int] = {}
|
|
387
|
+
table_name = self._names["vote"]
|
|
388
|
+
for start in range(0, len(vote_ids), 100):
|
|
389
|
+
pending = {table_name: {"Keys": [{"id": stored_id} for stored_id in vote_ids[start : start + 100]], "ProjectionExpression": "id, #v", "ExpressionAttributeNames": {"#v": "value"}}}
|
|
390
|
+
while pending:
|
|
391
|
+
response = self._resource.batch_get_item(RequestItems=pending)
|
|
392
|
+
for item in response.get("Responses", {}).get(table_name, []):
|
|
393
|
+
found[item["id"]] = int(item["value"])
|
|
394
|
+
pending = response.get("UnprocessedKeys") or {}
|
|
395
|
+
return found
|
|
396
|
+
|
|
397
|
+
def put_vote(self, vote: dict[str, Any], now_iso: str) -> None:
|
|
398
|
+
self._table("vote").update_item(
|
|
399
|
+
Key={"id": vote["id"]},
|
|
400
|
+
UpdateExpression=(
|
|
401
|
+
"SET #t = :t, subjectKey = :s, readerId = :r, #d = :d, #v = :v, voterRole = :role, "
|
|
402
|
+
"createdAt = if_not_exists(createdAt, :now), updatedAt = :now"
|
|
403
|
+
),
|
|
404
|
+
ExpressionAttributeNames={"#t": "__typename", "#d": "dimension", "#v": "value"},
|
|
405
|
+
ExpressionAttributeValues={
|
|
406
|
+
":t": "ReaderVote",
|
|
407
|
+
":s": vote["subjectKey"],
|
|
408
|
+
":r": vote["readerId"],
|
|
409
|
+
":d": vote["dimension"],
|
|
410
|
+
":v": vote["value"],
|
|
411
|
+
":role": vote["voterRole"],
|
|
412
|
+
":now": now_iso,
|
|
413
|
+
},
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def delete_vote(self, vote_id_: str) -> None:
|
|
417
|
+
self._table("vote").delete_item(Key={"id": vote_id_})
|
|
418
|
+
|
|
419
|
+
def is_published(self, subject_key: str) -> bool:
|
|
420
|
+
item = self._table("published").get_item(Key={"id": PUBLISHED_ID_PREFIX + subject_key}, ProjectionExpression="#s", ExpressionAttributeNames={"#s": "status"}).get("Item")
|
|
421
|
+
return bool(item) and item.get("status") == "published"
|
|
422
|
+
|
|
423
|
+
def count_action(self, reader_id: str, bucket: str, window_start: int, limit: int, expires_at: int) -> bool:
|
|
424
|
+
try:
|
|
425
|
+
self._table("rate").update_item(
|
|
426
|
+
Key={"pk": f"{reader_id}#{bucket}#{window_start}"},
|
|
427
|
+
UpdateExpression="ADD hits :one SET expiresAt = :expires",
|
|
428
|
+
ConditionExpression="attribute_not_exists(hits) OR hits < :limit",
|
|
429
|
+
ExpressionAttributeValues={":one": 1, ":expires": expires_at, ":limit": limit},
|
|
430
|
+
)
|
|
431
|
+
except Exception as error:
|
|
432
|
+
if is_conditional_failure(error):
|
|
433
|
+
return False
|
|
434
|
+
raise
|
|
435
|
+
return True
|
|
436
|
+
|
|
437
|
+
def claim_snapshot_slot(self, now_seconds: float, debounce_seconds: int) -> int | None:
|
|
438
|
+
"""Opens a debounce window and returns when it closes, or ``None`` when a window is already open."""
|
|
439
|
+
locked_until = int(now_seconds) + debounce_seconds
|
|
440
|
+
try:
|
|
441
|
+
self._table("rate").put_item(
|
|
442
|
+
Item={"pk": "snapshot-debounce", "lockedUntil": locked_until, "expiresAt": locked_until + debounce_seconds + HOUR_SECONDS},
|
|
443
|
+
ConditionExpression="attribute_not_exists(pk) OR lockedUntil <= :now",
|
|
444
|
+
ExpressionAttributeValues={":now": int(now_seconds)},
|
|
445
|
+
)
|
|
446
|
+
except Exception as error:
|
|
447
|
+
if is_conditional_failure(error):
|
|
448
|
+
return None
|
|
449
|
+
raise
|
|
450
|
+
return locked_until
|
|
451
|
+
|
|
452
|
+
def release_snapshot_slot(self, locked_until: int) -> None:
|
|
453
|
+
"""Reopens the window this call opened, so the next write can schedule the run that failed to schedule."""
|
|
454
|
+
try:
|
|
455
|
+
self._table("rate").delete_item(
|
|
456
|
+
Key={"pk": "snapshot-debounce"},
|
|
457
|
+
ConditionExpression="lockedUntil = :until",
|
|
458
|
+
ExpressionAttributeValues={":until": locked_until},
|
|
459
|
+
)
|
|
460
|
+
except Exception as error:
|
|
461
|
+
if not is_conditional_failure(error):
|
|
462
|
+
raise
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def snapshot_schedule_name(locked_until: int) -> str:
|
|
466
|
+
return f"{SNAPSHOT_SCHEDULE_PREFIX}{locked_until}"
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def snapshot_scheduler(
|
|
470
|
+
store: ReaderStore, settings: ReaderSettings, now_seconds: float, target: "SnapshotTarget | None", scheduler_client: Any
|
|
471
|
+
) -> Callable[[], None]:
|
|
472
|
+
"""Trailing-edge debounce: the first write in a window schedules one run at the end of the window.
|
|
473
|
+
|
|
474
|
+
Later writes in the window schedule nothing, because the run at the end of the window reads them all. A failure never
|
|
475
|
+
fails the vote; it reopens the window so the next write tries again.
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
def schedule() -> None:
|
|
479
|
+
if target is None:
|
|
480
|
+
return
|
|
481
|
+
locked_until = None
|
|
482
|
+
try:
|
|
483
|
+
locked_until = store.claim_snapshot_slot(now_seconds, settings.snapshot_debounce_seconds)
|
|
484
|
+
if locked_until is None:
|
|
485
|
+
return
|
|
486
|
+
scheduler_client.create_schedule(
|
|
487
|
+
Name=snapshot_schedule_name(locked_until),
|
|
488
|
+
ScheduleExpression="at(" + time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(locked_until)) + ")",
|
|
489
|
+
ScheduleExpressionTimezone="UTC",
|
|
490
|
+
FlexibleTimeWindow={"Mode": "OFF"},
|
|
491
|
+
ActionAfterCompletion="DELETE",
|
|
492
|
+
Target={
|
|
493
|
+
"Arn": target.function_arn,
|
|
494
|
+
"RoleArn": target.role_arn,
|
|
495
|
+
"Input": json.dumps(SNAPSHOT_TASK),
|
|
496
|
+
"RetryPolicy": {"MaximumRetryAttempts": 0},
|
|
497
|
+
},
|
|
498
|
+
)
|
|
499
|
+
except Exception as error:
|
|
500
|
+
print(json.dumps({"readerSnapshotScheduleFailed": type(error).__name__}))
|
|
501
|
+
if locked_until is not None and type(error).__name__ != "ConflictException":
|
|
502
|
+
try:
|
|
503
|
+
store.release_snapshot_slot(locked_until)
|
|
504
|
+
except Exception as release_error:
|
|
505
|
+
print(json.dumps({"readerSnapshotReleaseFailed": type(release_error).__name__}))
|
|
506
|
+
|
|
507
|
+
return schedule
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@dataclass(frozen=True)
|
|
511
|
+
class SnapshotTarget:
|
|
512
|
+
function_arn: str
|
|
513
|
+
role_arn: str
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def snapshot_target_from_environment(environment: dict[str, str] | None = None) -> SnapshotTarget | None:
|
|
517
|
+
environment = os.environ if environment is None else environment
|
|
518
|
+
function_arn = environment.get("PAPYRUS_READER_SNAPSHOT_FUNCTION_ARN", "")
|
|
519
|
+
role_arn = environment.get("PAPYRUS_READER_SCHEDULER_ROLE_ARN", "")
|
|
520
|
+
return SnapshotTarget(function_arn, role_arn) if function_arn and role_arn else None
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def store_from_environment(environment: dict[str, str] | None = None) -> DynamoReaderStore:
|
|
524
|
+
import boto3
|
|
525
|
+
|
|
526
|
+
environment = os.environ if environment is None else environment
|
|
527
|
+
return DynamoReaderStore(
|
|
528
|
+
resource=boto3.resource("dynamodb"),
|
|
529
|
+
profile_table=environment["PAPYRUS_READER_PROFILE_TABLE"],
|
|
530
|
+
handle_table=environment["PAPYRUS_READER_HANDLE_TABLE"],
|
|
531
|
+
vote_table=environment["PAPYRUS_READER_VOTE_TABLE"],
|
|
532
|
+
published_item_table=environment["PAPYRUS_READER_PUBLISHED_ITEM_TABLE"],
|
|
533
|
+
rate_table=environment["PAPYRUS_READER_RATE_TABLE"],
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
|
|
538
|
+
import boto3
|
|
539
|
+
|
|
540
|
+
settings = settings_from_environment()
|
|
541
|
+
store = store_from_environment()
|
|
542
|
+
now_seconds = time.time()
|
|
543
|
+
scheduler = snapshot_scheduler(store, settings, now_seconds, snapshot_target_from_environment(), boto3.client("scheduler"))
|
|
544
|
+
return handle_event(event, store=store, settings=settings, now_seconds=now_seconds, schedule_snapshot=scheduler)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineFunction } from "@aws-amplify/backend";
|
|
2
|
+
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
3
|
+
import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
|
|
4
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import { Architecture, Code, Function, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
export const readerActions = defineFunction((scope) => {
|
|
10
|
+
const rateTable = new Table(scope, "PapyrusReaderRateWindows", {
|
|
11
|
+
partitionKey: { name: "pk", type: AttributeType.STRING },
|
|
12
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
13
|
+
timeToLiveAttribute: "expiresAt",
|
|
14
|
+
removalPolicy: RemovalPolicy.DESTROY,
|
|
15
|
+
});
|
|
16
|
+
const lambda = new Function(scope, "papyrus-reader-actions", {
|
|
17
|
+
runtime: Runtime.PYTHON_3_12,
|
|
18
|
+
architecture: Architecture.ARM_64,
|
|
19
|
+
handler: "handler.handler",
|
|
20
|
+
timeout: Duration.seconds(10),
|
|
21
|
+
memorySize: 256,
|
|
22
|
+
code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
|
|
23
|
+
environment: { PAPYRUS_READER_RATE_TABLE: rateTable.tableName },
|
|
24
|
+
});
|
|
25
|
+
lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem", "dynamodb:PutItem"], resources: [rateTable.tableArn] }));
|
|
26
|
+
return lambda;
|
|
27
|
+
}, { resourceGroupName: "data" });
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defineFunction } from "@aws-amplify/backend";
|
|
2
|
+
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
3
|
+
import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
|
|
4
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import { Architecture, Code, Function, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
6
|
+
import type { Construct } from "constructs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
export const readerActions = defineFunction(
|
|
13
|
+
(scope: Construct) => {
|
|
14
|
+
const rateTable = new Table(scope, "PapyrusReaderRateWindows", {
|
|
15
|
+
partitionKey: { name: "pk", type: AttributeType.STRING },
|
|
16
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
17
|
+
timeToLiveAttribute: "expiresAt",
|
|
18
|
+
removalPolicy: RemovalPolicy.DESTROY,
|
|
19
|
+
});
|
|
20
|
+
const lambda = new Function(scope, "papyrus-reader-actions", {
|
|
21
|
+
runtime: Runtime.PYTHON_3_12,
|
|
22
|
+
architecture: Architecture.ARM_64,
|
|
23
|
+
handler: "handler.handler",
|
|
24
|
+
timeout: Duration.seconds(10),
|
|
25
|
+
memorySize: 256,
|
|
26
|
+
code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
|
|
27
|
+
environment: { PAPYRUS_READER_RATE_TABLE: rateTable.tableName },
|
|
28
|
+
});
|
|
29
|
+
lambda.addToRolePolicy(
|
|
30
|
+
new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem", "dynamodb:PutItem"], resources: [rateTable.tableArn] }),
|
|
31
|
+
);
|
|
32
|
+
return lambda;
|
|
33
|
+
},
|
|
34
|
+
{ resourceGroupName: "data" },
|
|
35
|
+
);
|