@anthusai/papyrus 2.0.0-next.67 → 2.0.0-next.68
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/plugins/reader-steering/config.js +8 -1
- package/amplify/plugins/reader-steering/config.ts +13 -1
- package/amplify/plugins/reader-steering/function/handler.py +168 -17
- package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -1
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -1
- package/amplify/plugins/reader-steering/schema.js +17 -1
- package/amplify/plugins/reader-steering/schema.ts +17 -1
- package/app/api/reader/flag/route.ts +9 -0
- package/lib/reader-api.ts +1 -1
- package/package.json +2 -1
- package/routes.manifest.json +12 -1
|
@@ -5,8 +5,9 @@ export const MODERATION_MODES = ["off", "human", "ai"];
|
|
|
5
5
|
export const AI_KEY_PARAMETER = /^\/papyrus\/[a-z0-9][a-z0-9-]{0,62}\/openai-api-key$/;
|
|
6
6
|
export const AI_MODEL_NAME = /^[a-z0-9][a-z0-9.-]{0,63}$/;
|
|
7
7
|
export const SUBMISSION_KIND_NAME = /^[a-z][a-z0-9-]{0,23}$/;
|
|
8
|
+
export const SUBJECT_TYPES = ["item", "submission", "story", "proposal", "concept"];
|
|
8
9
|
export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
|
|
9
|
-
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation"]);
|
|
10
|
+
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects"]);
|
|
10
11
|
function object(raw, label, known) {
|
|
11
12
|
if (raw === undefined)
|
|
12
13
|
return {};
|
|
@@ -111,6 +112,11 @@ export function resolveReaderSteeringConfig(request) {
|
|
|
111
112
|
throw new Error(`reader-steering plugin moderation.ai.model must be a model name matching ${AI_MODEL_NAME}`);
|
|
112
113
|
}
|
|
113
114
|
}
|
|
115
|
+
const subjects = object(raw.subjects, "subjects", ["types"]);
|
|
116
|
+
const subjectTypes = subjects.types === undefined ? ["item", "submission"] : subjects.types;
|
|
117
|
+
if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => SUBJECT_TYPES.includes(type))) {
|
|
118
|
+
throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
|
|
119
|
+
}
|
|
114
120
|
const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
|
|
115
121
|
const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
|
|
116
122
|
const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
|
|
@@ -154,6 +160,7 @@ export function resolveReaderSteeringConfig(request) {
|
|
|
154
160
|
itemType: lowercaseName(submissions.itemType, "submissions.itemType", "brief"),
|
|
155
161
|
section: lowercaseName(submissions.section, "submissions.section", "unsectioned"),
|
|
156
162
|
},
|
|
163
|
+
subjects: { types: [...new Set(subjectTypes)] },
|
|
157
164
|
moderation: {
|
|
158
165
|
mode: mode,
|
|
159
166
|
preModeration,
|
|
@@ -7,6 +7,7 @@ export const MODERATION_MODES = ["off", "human", "ai"] as const;
|
|
|
7
7
|
export const AI_KEY_PARAMETER = /^\/papyrus\/[a-z0-9][a-z0-9-]{0,62}\/openai-api-key$/;
|
|
8
8
|
export const AI_MODEL_NAME = /^[a-z0-9][a-z0-9.-]{0,63}$/;
|
|
9
9
|
export const SUBMISSION_KIND_NAME = /^[a-z][a-z0-9-]{0,23}$/;
|
|
10
|
+
export const SUBJECT_TYPES = ["item", "submission", "story", "proposal", "concept"] as const;
|
|
10
11
|
export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
|
|
11
12
|
|
|
12
13
|
export type ReaderSteeringConfig = {
|
|
@@ -63,6 +64,11 @@ export type ReaderSteeringConfig = {
|
|
|
63
64
|
/** The section of that draft. */
|
|
64
65
|
section: string;
|
|
65
66
|
};
|
|
67
|
+
/**
|
|
68
|
+
* The kinds of subject a signed-in reader may vote on and flag, by `{type, id}`. Default: items and submissions, which is
|
|
69
|
+
* what readers could vote on before subject types existed. `story`, `proposal` and `concept` are opt-in.
|
|
70
|
+
*/
|
|
71
|
+
subjects: { types: (typeof SUBJECT_TYPES)[number][] };
|
|
66
72
|
moderation: {
|
|
67
73
|
/**
|
|
68
74
|
* `human`: new submissions and comments are public at once and wait in the Administration queue (the default for a pilot).
|
|
@@ -109,7 +115,7 @@ export type ReaderSteeringConfig = {
|
|
|
109
115
|
retirementPoll: boolean;
|
|
110
116
|
};
|
|
111
117
|
|
|
112
|
-
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation"]);
|
|
118
|
+
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects"]);
|
|
113
119
|
|
|
114
120
|
function object(raw: unknown, label: string, known: string[]): Record<string, unknown> {
|
|
115
121
|
if (raw === undefined) return {};
|
|
@@ -206,6 +212,11 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
|
|
|
206
212
|
throw new Error(`reader-steering plugin moderation.ai.model must be a model name matching ${AI_MODEL_NAME}`);
|
|
207
213
|
}
|
|
208
214
|
}
|
|
215
|
+
const subjects = object(raw.subjects, "subjects", ["types"]);
|
|
216
|
+
const subjectTypes = subjects.types === undefined ? ["item", "submission"] : subjects.types;
|
|
217
|
+
if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => (SUBJECT_TYPES as readonly unknown[]).includes(type))) {
|
|
218
|
+
throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
|
|
219
|
+
}
|
|
209
220
|
const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
|
|
210
221
|
const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
|
|
211
222
|
const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
|
|
@@ -249,6 +260,7 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
|
|
|
249
260
|
itemType: lowercaseName(submissions.itemType, "submissions.itemType", "brief"),
|
|
250
261
|
section: lowercaseName(submissions.section, "submissions.section", "unsectioned"),
|
|
251
262
|
},
|
|
263
|
+
subjects: { types: [...new Set(subjectTypes)] as (typeof SUBJECT_TYPES)[number][] },
|
|
252
264
|
moderation: {
|
|
253
265
|
mode: mode as (typeof MODERATION_MODES)[number],
|
|
254
266
|
preModeration,
|
|
@@ -54,6 +54,21 @@ ACTION_RATING = "rating"
|
|
|
54
54
|
ACTION_SUBMIT = "submit"
|
|
55
55
|
ACTION_COMMENT = "comment"
|
|
56
56
|
ACTION_REPORT = "report"
|
|
57
|
+
ACTION_FLAG = "flag"
|
|
58
|
+
SUBJECT_TYPE_ITEM = "item"
|
|
59
|
+
SUBJECT_TYPE_SUBMISSION = "submission"
|
|
60
|
+
SUBJECT_TYPES = (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION, "story", "proposal", "concept")
|
|
61
|
+
DEFAULT_SUBJECT_TYPES = (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION)
|
|
62
|
+
SUBJECT_ID = re.compile(r"[A-Za-z0-9._:-]{1,190}")
|
|
63
|
+
VOTE_DIMENSION = "up"
|
|
64
|
+
VOTE_PAYLOAD_KEYS = frozenset({"subject", "value"})
|
|
65
|
+
FLAG_WRONG = "wrong"
|
|
66
|
+
FLAG_BELONGS_UNDER = "belongs_under"
|
|
67
|
+
FLAG_BETTER_NAME = "better_name"
|
|
68
|
+
FLAG_KINDS = (FLAG_WRONG, FLAG_BELONGS_UNDER, FLAG_BETTER_NAME)
|
|
69
|
+
FLAG_PAYLOAD_KEYS = frozenset({"subject", "kind", "reason", "target", "name"})
|
|
70
|
+
MAXIMUM_FLAG_REASON = 500
|
|
71
|
+
MAXIMUM_SUGGESTED_NAME = 120
|
|
57
72
|
RATE_BUCKET_REPORT = "report"
|
|
58
73
|
REPORT_ID_PREFIX = "rep-"
|
|
59
74
|
MAXIMUM_REPORT_REASON = 500
|
|
@@ -127,6 +142,7 @@ class ReaderSettings:
|
|
|
127
142
|
promote_section: str = "unsectioned"
|
|
128
143
|
ai_model: str = ai_moderation.DEFAULT_MODEL
|
|
129
144
|
ai_key_parameter: str = ""
|
|
145
|
+
subject_types: tuple[str, ...] = DEFAULT_SUBJECT_TYPES
|
|
130
146
|
|
|
131
147
|
|
|
132
148
|
def settings_from_environment(environment: dict[str, str] | None = None) -> ReaderSettings:
|
|
@@ -154,6 +170,7 @@ def settings_from_environment(environment: dict[str, str] | None = None) -> Read
|
|
|
154
170
|
reports_per_day=int((raw.get("moderation") or {}).get("reportsPerDay", defaults.reports_per_day)),
|
|
155
171
|
ai_model=str(((raw.get("moderation") or {}).get("ai") or {}).get("model", defaults.ai_model)),
|
|
156
172
|
ai_key_parameter=str(((raw.get("moderation") or {}).get("ai") or {}).get("apiKeyParameter", defaults.ai_key_parameter)),
|
|
173
|
+
subject_types=tuple((raw.get("subjects") or {}).get("types") or defaults.subject_types),
|
|
157
174
|
promote_item_type=str((raw.get("submissions") or {}).get("itemType", defaults.promote_item_type)),
|
|
158
175
|
promote_section=str((raw.get("submissions") or {}).get("section", defaults.promote_section)),
|
|
159
176
|
)
|
|
@@ -172,6 +189,8 @@ class ReaderStore(Protocol):
|
|
|
172
189
|
|
|
173
190
|
def delete_vote(self, vote_id: str) -> None: ...
|
|
174
191
|
|
|
192
|
+
def put_flag(self, flag: dict[str, Any], now_iso: str) -> None: ...
|
|
193
|
+
|
|
175
194
|
def is_published(self, subject_key: str) -> bool: ...
|
|
176
195
|
|
|
177
196
|
def get_submission(self, submission_id: str) -> dict[str, Any] | None: ...
|
|
@@ -229,6 +248,30 @@ def vote_id(subject_key: str, reader_id: str, dimension: str) -> str:
|
|
|
229
248
|
return f"{subject_key}#{reader_id}#{dimension}"
|
|
230
249
|
|
|
231
250
|
|
|
251
|
+
def subject_key(subject_type: str, subject_id: str) -> str:
|
|
252
|
+
"""The key a vote is stored under: items and submissions keep their bare id (so every vote written before subject types stays valid); other types are prefixed."""
|
|
253
|
+
return subject_id if subject_type in (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION) else f"{subject_type}:{subject_id}"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def legacy_subject_type(key: str) -> str:
|
|
257
|
+
"""The type of a bare subject key: a submission id, otherwise an item."""
|
|
258
|
+
return SUBJECT_TYPE_SUBMISSION if SUBMISSION_ID.fullmatch(key) else SUBJECT_TYPE_ITEM
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def parse_subject(raw: Any, settings: ReaderSettings) -> tuple[tuple[str, str] | None, dict[str, Any] | None]:
|
|
262
|
+
"""``({type, id}) -> (type, id)``, or a refusal. A bare string is a subject written before subject types: its type is read from its shape."""
|
|
263
|
+
if isinstance(raw, str):
|
|
264
|
+
raw = {"type": legacy_subject_type(raw), "id": raw}
|
|
265
|
+
if not isinstance(raw, dict) or set(raw) != {"type", "id"} or not isinstance(raw["type"], str) or not isinstance(raw["id"], str):
|
|
266
|
+
return None, refusal(422, "invalid_subject", "Send the subject as {type, id}.")
|
|
267
|
+
subject_type, subject_id = raw["type"], raw["id"]
|
|
268
|
+
if subject_type not in SUBJECT_TYPES or subject_type not in settings.subject_types:
|
|
269
|
+
return None, refusal(422, "unknown_subject_type", "That kind of subject cannot be voted on here.")
|
|
270
|
+
if not SUBJECT_ID.fullmatch(subject_id):
|
|
271
|
+
return None, refusal(422, "invalid_subject", "Send the id of the subject.")
|
|
272
|
+
return (subject_type, subject_id), None
|
|
273
|
+
|
|
274
|
+
|
|
232
275
|
def valid_site_subject(value: Any, settings: ReaderSettings) -> bool:
|
|
233
276
|
if not isinstance(value, str):
|
|
234
277
|
return False
|
|
@@ -321,13 +364,18 @@ def public_profile(profile: dict[str, Any]) -> dict[str, Any]:
|
|
|
321
364
|
|
|
322
365
|
|
|
323
366
|
def my_state(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any], settings: ReaderSettings) -> dict[str, Any]:
|
|
324
|
-
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
367
|
+
raw_subjects = payload.get("subjects", [])
|
|
368
|
+
if not isinstance(raw_subjects, list) or len(raw_subjects) > MAXIMUM_SUBJECTS:
|
|
369
|
+
return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} subjects.")
|
|
370
|
+
subjects: list[str] = []
|
|
371
|
+
for raw in raw_subjects:
|
|
372
|
+
if isinstance(raw, str) and SUBJECT_KEY.fullmatch(raw):
|
|
373
|
+
subjects.append(raw)
|
|
374
|
+
continue
|
|
375
|
+
parsed, _ = parse_subject(raw, settings) if isinstance(raw, dict) else (None, None)
|
|
376
|
+
if parsed is None:
|
|
377
|
+
return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} subject keys or {{type, id}} subjects.")
|
|
378
|
+
subjects.append(subject_key(*parsed))
|
|
331
379
|
dimensions = [*TOGGLE_DIMENSIONS, *settings.dimensions]
|
|
332
380
|
wanted = {vote_id(subject, profile["readerId"], dimension): (subject, dimension) for subject in subjects for dimension in dimensions}
|
|
333
381
|
found = store.get_vote_values(list(wanted))
|
|
@@ -375,8 +423,11 @@ def react(
|
|
|
375
423
|
now_seconds: float,
|
|
376
424
|
schedule_snapshot: Callable[[], None],
|
|
377
425
|
) -> dict[str, Any]:
|
|
378
|
-
|
|
379
|
-
|
|
426
|
+
parsed, refused = parse_subject(subject, settings)
|
|
427
|
+
if refused:
|
|
428
|
+
return refused
|
|
429
|
+
subject_type, subject_id = parsed
|
|
430
|
+
subject = subject_key(subject_type, subject_id)
|
|
380
431
|
if not isinstance(dimension, str) or dimension not in allowed_dimensions:
|
|
381
432
|
return refusal(422, "invalid_dimension", "That dimension is not available.")
|
|
382
433
|
if profile.get("state") == STATE_BANNED:
|
|
@@ -386,7 +437,7 @@ def react(
|
|
|
386
437
|
limited = within_rate_limit(store, profile, RATE_BUCKET_REACTIONS, action_limit(profile, settings, now_seconds), now_seconds)
|
|
387
438
|
if limited:
|
|
388
439
|
return limited
|
|
389
|
-
if not subject_is_open(store,
|
|
440
|
+
if not subject_is_open(store, subject_type, subject_id):
|
|
390
441
|
return refusal(404, "unknown_subject", "That item is not published.")
|
|
391
442
|
stored_id = vote_id(subject, profile["readerId"], dimension)
|
|
392
443
|
if value is None:
|
|
@@ -396,6 +447,7 @@ def react(
|
|
|
396
447
|
{
|
|
397
448
|
"id": stored_id,
|
|
398
449
|
"subjectKey": subject,
|
|
450
|
+
"subjectType": subject_type,
|
|
399
451
|
"readerId": profile["readerId"],
|
|
400
452
|
"dimension": dimension,
|
|
401
453
|
"value": value,
|
|
@@ -407,12 +459,17 @@ def react(
|
|
|
407
459
|
return reply(200, {"subject": subject, "dimension": dimension, "value": value})
|
|
408
460
|
|
|
409
461
|
|
|
410
|
-
def subject_is_open(store: ReaderStore,
|
|
411
|
-
"""A published item, or a submission that is visible (pending or promoted); hidden submissions cannot be voted on.
|
|
412
|
-
|
|
413
|
-
|
|
462
|
+
def subject_is_open(store: ReaderStore, subject_type: str, subject_id: str) -> bool:
|
|
463
|
+
"""A published item, or a submission that is visible (pending or promoted); hidden submissions cannot be voted on.
|
|
464
|
+
|
|
465
|
+
The other subject types have no record in this plugin (stories, proposals and concepts live in the site's own data), so any well-formed id is open.
|
|
466
|
+
"""
|
|
467
|
+
if subject_type == SUBJECT_TYPE_SUBMISSION:
|
|
468
|
+
submission = store.get_submission(subject_id)
|
|
414
469
|
return submission is not None and submission.get("state") in VISIBLE_SUBMISSION_STATES
|
|
415
|
-
|
|
470
|
+
if subject_type == SUBJECT_TYPE_ITEM:
|
|
471
|
+
return store.is_published(subject_id)
|
|
472
|
+
return True
|
|
416
473
|
|
|
417
474
|
|
|
418
475
|
def clean_text(value: Any, label: str, maximum: int, *, required: bool) -> tuple[str | None, dict[str, Any] | None]:
|
|
@@ -606,7 +663,7 @@ def comment_action(
|
|
|
606
663
|
limited = within_rate_limit(store, profile, RATE_BUCKET_COMMENT, new_account_limit(profile, settings, now_seconds, settings.comments_per_hour), now_seconds)
|
|
607
664
|
if limited:
|
|
608
665
|
return limited
|
|
609
|
-
if not subject_is_open(store, subject):
|
|
666
|
+
if not subject_is_open(store, legacy_subject_type(subject), subject):
|
|
610
667
|
return refusal(404, "unknown_subject", "That item is not published.")
|
|
611
668
|
if parent_id is not None:
|
|
612
669
|
parent = store.get_comment(parent_id)
|
|
@@ -677,6 +734,75 @@ def report_action(
|
|
|
677
734
|
return reply(200, {"reported": True})
|
|
678
735
|
|
|
679
736
|
|
|
737
|
+
def flag_action(
|
|
738
|
+
store: ReaderStore,
|
|
739
|
+
profile: dict[str, Any],
|
|
740
|
+
payload: dict[str, Any],
|
|
741
|
+
settings: ReaderSettings,
|
|
742
|
+
is_staff: bool,
|
|
743
|
+
now_seconds: float,
|
|
744
|
+
schedule_snapshot: Callable[[], None],
|
|
745
|
+
) -> dict[str, Any]:
|
|
746
|
+
"""A reader flags a subject as wrong, as belonging under another subject, or as needing a better name; a reason is required.
|
|
747
|
+
|
|
748
|
+
One flag per reader, subject and kind: flagging again replaces the reason. Flags are stored apart from votes and reach the
|
|
749
|
+
snapshot as counts only. The stored row holds the readerId, never a siteSubject.
|
|
750
|
+
"""
|
|
751
|
+
if set(payload) - FLAG_PAYLOAD_KEYS:
|
|
752
|
+
return refusal(422, "invalid_flag", "Send subject, kind, reason, and target or name for the kind.")
|
|
753
|
+
kind = payload.get("kind")
|
|
754
|
+
if kind not in FLAG_KINDS:
|
|
755
|
+
return refusal(422, "invalid_flag_kind", f"Send kind as one of {', '.join(FLAG_KINDS)}.")
|
|
756
|
+
parsed, refused = parse_subject(payload.get("subject"), settings)
|
|
757
|
+
if refused:
|
|
758
|
+
return refused
|
|
759
|
+
reason = payload.get("reason")
|
|
760
|
+
if not isinstance(reason, str) or not reason.strip() or len(reason.strip()) > MAXIMUM_FLAG_REASON or CONTROL_CHARACTERS.search(reason):
|
|
761
|
+
return refusal(422, "invalid_reason", f"Give a reason in plain text of at most {MAXIMUM_FLAG_REASON} characters.")
|
|
762
|
+
if EMAIL_ADDRESS.search(reason):
|
|
763
|
+
return refusal(422, "invalid_reason", "A reason cannot contain an email address.")
|
|
764
|
+
target_key: str | None = None
|
|
765
|
+
suggested: str | None = None
|
|
766
|
+
if kind == FLAG_BELONGS_UNDER:
|
|
767
|
+
target, refused = parse_subject(payload.get("target"), settings) if isinstance(payload.get("target"), dict) else (None, True)
|
|
768
|
+
if refused or "name" in payload:
|
|
769
|
+
return refusal(422, "invalid_target", "Send target as {type, id} for belongs_under.")
|
|
770
|
+
target_key = subject_key(*target)
|
|
771
|
+
if target_key == subject_key(*parsed):
|
|
772
|
+
return refusal(422, "invalid_target", "A subject cannot belong under itself.")
|
|
773
|
+
elif kind == FLAG_BETTER_NAME:
|
|
774
|
+
suggested, refused = clean_text(payload.get("name"), "name", MAXIMUM_SUGGESTED_NAME, required=True)
|
|
775
|
+
if refused or "target" in payload:
|
|
776
|
+
return refused or refusal(422, "invalid_name", "Send name for better_name.")
|
|
777
|
+
elif "target" in payload or "name" in payload:
|
|
778
|
+
return refusal(422, "invalid_flag", "wrong takes only a reason.")
|
|
779
|
+
if profile.get("state") == STATE_LIMITED:
|
|
780
|
+
return refusal(403, "reader_limited", "This reader cannot flag right now.")
|
|
781
|
+
limited = within_rate_limit(store, profile, RATE_BUCKET_REACTIONS, action_limit(profile, settings, now_seconds), now_seconds)
|
|
782
|
+
if limited:
|
|
783
|
+
return limited
|
|
784
|
+
subject_type, subject_id = parsed
|
|
785
|
+
if not subject_is_open(store, subject_type, subject_id):
|
|
786
|
+
return refusal(404, "unknown_subject", "That subject cannot be flagged.")
|
|
787
|
+
key = subject_key(subject_type, subject_id)
|
|
788
|
+
store.put_flag(
|
|
789
|
+
{
|
|
790
|
+
"id": f"{key}#{profile['readerId']}#{kind}",
|
|
791
|
+
"subjectKey": key,
|
|
792
|
+
"subjectType": subject_type,
|
|
793
|
+
"readerId": profile["readerId"],
|
|
794
|
+
"kind": kind,
|
|
795
|
+
"reason": reason.strip(),
|
|
796
|
+
"targetKey": target_key,
|
|
797
|
+
"suggestedName": suggested,
|
|
798
|
+
"voterRole": ROLE_EDITOR if is_staff else ROLE_READER,
|
|
799
|
+
},
|
|
800
|
+
iso(now_seconds),
|
|
801
|
+
)
|
|
802
|
+
schedule_snapshot()
|
|
803
|
+
return reply(200, {"subject": payload["subject"], "kind": kind})
|
|
804
|
+
|
|
805
|
+
|
|
680
806
|
def own_comment(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any], allowed: frozenset[str]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
681
807
|
if set(payload) - allowed:
|
|
682
808
|
return None, refusal(422, "invalid_comment", "Send only " + ", ".join(sorted(allowed)) + ".")
|
|
@@ -740,7 +866,23 @@ def delete_comment_action(
|
|
|
740
866
|
return reply(200, {"id": comment["id"], "state": COMMENT_DELETED})
|
|
741
867
|
|
|
742
868
|
|
|
869
|
+
def typed_vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
|
|
870
|
+
"""``{subject: {type, id}, value: 1 | -1 | null}``: an up or down vote on any enabled subject type; ``null`` removes the reader's vote."""
|
|
871
|
+
value = payload.get("value")
|
|
872
|
+
if set(payload) - VOTE_PAYLOAD_KEYS or isinstance(value, bool) or value not in (1, -1, None):
|
|
873
|
+
return refusal(422, "invalid_vote", "Send only subject and value, and value as 1, -1 or null.")
|
|
874
|
+
answer = react(
|
|
875
|
+
store, profile, subject=payload.get("subject"), dimension=VOTE_DIMENSION, allowed_dimensions=(VOTE_DIMENSION,),
|
|
876
|
+
value=value, is_staff=is_staff, settings=settings, now_seconds=now_seconds, schedule_snapshot=schedule_snapshot,
|
|
877
|
+
)
|
|
878
|
+
if answer["statusCode"] == 200:
|
|
879
|
+
answer = reply(200, {"subject": payload["subject"], "value": value})
|
|
880
|
+
return answer
|
|
881
|
+
|
|
882
|
+
|
|
743
883
|
def vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
|
|
884
|
+
if isinstance(payload.get("subject"), dict):
|
|
885
|
+
return typed_vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot)
|
|
744
886
|
on = payload.get("on")
|
|
745
887
|
if not isinstance(on, bool):
|
|
746
888
|
return refusal(422, "invalid_vote", "Send on as true or false.")
|
|
@@ -794,6 +936,8 @@ def handle_event(
|
|
|
794
936
|
return delete_comment_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
|
|
795
937
|
if action == ACTION_REPORT:
|
|
796
938
|
return report_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
|
|
939
|
+
if action == ACTION_FLAG:
|
|
940
|
+
return flag_action(store, profile, payload, settings, event["isStaff"], now_seconds, schedule_snapshot)
|
|
797
941
|
return refusal(400, "unknown_action", "Unknown action.")
|
|
798
942
|
|
|
799
943
|
|
|
@@ -832,6 +976,7 @@ class DynamoReaderStore:
|
|
|
832
976
|
report_table: str = "",
|
|
833
977
|
action_table: str = "",
|
|
834
978
|
item_table: str = "",
|
|
979
|
+
flag_table: str = "",
|
|
835
980
|
) -> None:
|
|
836
981
|
self._resource = resource
|
|
837
982
|
self._names = {
|
|
@@ -845,6 +990,7 @@ class DynamoReaderStore:
|
|
|
845
990
|
"report": report_table,
|
|
846
991
|
"action": action_table,
|
|
847
992
|
"item": item_table,
|
|
993
|
+
"flag": flag_table,
|
|
848
994
|
}
|
|
849
995
|
|
|
850
996
|
def _table(self, key: str) -> Any:
|
|
@@ -914,13 +1060,14 @@ class DynamoReaderStore:
|
|
|
914
1060
|
self._table("vote").update_item(
|
|
915
1061
|
Key={"id": vote["id"]},
|
|
916
1062
|
UpdateExpression=(
|
|
917
|
-
"SET #t = :t, subjectKey = :s, readerId = :r, #d = :d, #v = :v, voterRole = :role, "
|
|
1063
|
+
"SET #t = :t, subjectKey = :s, subjectType = :st, readerId = :r, #d = :d, #v = :v, voterRole = :role, "
|
|
918
1064
|
"createdAt = if_not_exists(createdAt, :now), updatedAt = :now"
|
|
919
1065
|
),
|
|
920
1066
|
ExpressionAttributeNames={"#t": "__typename", "#d": "dimension", "#v": "value"},
|
|
921
1067
|
ExpressionAttributeValues={
|
|
922
1068
|
":t": "ReaderVote",
|
|
923
1069
|
":s": vote["subjectKey"],
|
|
1070
|
+
":st": vote.get("subjectType") or legacy_subject_type(vote["subjectKey"]),
|
|
924
1071
|
":r": vote["readerId"],
|
|
925
1072
|
":d": vote["dimension"],
|
|
926
1073
|
":v": vote["value"],
|
|
@@ -932,6 +1079,9 @@ class DynamoReaderStore:
|
|
|
932
1079
|
def delete_vote(self, vote_id_: str) -> None:
|
|
933
1080
|
self._table("vote").delete_item(Key={"id": vote_id_})
|
|
934
1081
|
|
|
1082
|
+
def put_flag(self, flag: dict[str, Any], now_iso: str) -> None:
|
|
1083
|
+
self._table("flag").put_item(Item=_without_none({**flag, "__typename": "ReaderFlag", "createdAt": now_iso, "updatedAt": now_iso}))
|
|
1084
|
+
|
|
935
1085
|
def is_published(self, subject_key: str) -> bool:
|
|
936
1086
|
item = self._table("published").get_item(Key={"id": PUBLISHED_ID_PREFIX + subject_key}, ProjectionExpression="#s", ExpressionAttributeNames={"#s": "status"}).get("Item")
|
|
937
1087
|
return bool(item) and item.get("status") == "published"
|
|
@@ -1259,6 +1409,7 @@ def store_from_environment(environment: dict[str, str] | None = None) -> DynamoR
|
|
|
1259
1409
|
report_table=environment.get("PAPYRUS_READER_REPORT_TABLE", ""),
|
|
1260
1410
|
action_table=environment.get("PAPYRUS_READER_ACTION_TABLE", ""),
|
|
1261
1411
|
item_table=environment.get("PAPYRUS_READER_ITEM_TABLE", ""),
|
|
1412
|
+
flag_table=environment.get("PAPYRUS_READER_FLAG_TABLE", ""),
|
|
1262
1413
|
)
|
|
1263
1414
|
|
|
1264
1415
|
|
|
@@ -14,7 +14,7 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
|
|
|
14
14
|
export const readerSteeringManifest = {
|
|
15
15
|
name: "reader-steering",
|
|
16
16
|
contract: 1,
|
|
17
|
-
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
|
|
17
|
+
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
|
|
18
18
|
mutations: ["moderateReader"],
|
|
19
19
|
guest: { mutations: [] },
|
|
20
20
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
@@ -26,6 +26,7 @@ export function readerActionsSettings(config, allowInterimSubjects) {
|
|
|
26
26
|
dimensions: config.dimensions,
|
|
27
27
|
limits: config.limits,
|
|
28
28
|
submissions: config.submissions,
|
|
29
|
+
subjects: config.subjects,
|
|
29
30
|
comments: config.comments,
|
|
30
31
|
moderation: config.moderation,
|
|
31
32
|
reservedHandles: config.reservedHandles,
|
|
@@ -38,6 +39,7 @@ export function readerSnapshotSettings(config) {
|
|
|
38
39
|
return {
|
|
39
40
|
dimensions: config.dimensions,
|
|
40
41
|
submissions: config.submissions,
|
|
42
|
+
subjects: config.subjects,
|
|
41
43
|
karma: config.karma,
|
|
42
44
|
ranking: config.ranking,
|
|
43
45
|
limits: { newAccountHours: config.limits.newAccountHours },
|
|
@@ -110,6 +112,7 @@ export const readerSteeringPlugin = {
|
|
|
110
112
|
["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
|
|
111
113
|
["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
|
|
112
114
|
["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
|
|
115
|
+
["PAPYRUS_READER_FLAG_TABLE", "ReaderFlag"],
|
|
113
116
|
["PAPYRUS_READER_SUBMISSION_TABLE", "ReaderSubmission"],
|
|
114
117
|
["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
|
|
115
118
|
["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
|
|
@@ -125,6 +128,7 @@ export const readerSteeringPlugin = {
|
|
|
125
128
|
tables.PublishedItem.grantReadData(lambda);
|
|
126
129
|
lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
|
|
127
130
|
tables.ReaderVote.grantReadData(contentActions);
|
|
131
|
+
tables.ReaderFlag.grantReadData(contentActions);
|
|
128
132
|
tables.ReaderProfile.grantReadWriteData(contentActions);
|
|
129
133
|
// The snapshot reads submissions, and the review step writes their signals and moderation state.
|
|
130
134
|
tables.ReaderSubmission.grantReadWriteData(contentActions);
|
|
@@ -21,7 +21,7 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
|
|
|
21
21
|
export const readerSteeringManifest: PluginManifest = {
|
|
22
22
|
name: "reader-steering",
|
|
23
23
|
contract: 1,
|
|
24
|
-
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
|
|
24
|
+
models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
|
|
25
25
|
mutations: ["moderateReader"],
|
|
26
26
|
guest: { mutations: [] },
|
|
27
27
|
newsroomPanels: [{ id: "moderation", label: "Moderation" }],
|
|
@@ -34,6 +34,7 @@ export function readerActionsSettings(config: ReaderSteeringConfig, allowInterim
|
|
|
34
34
|
dimensions: config.dimensions,
|
|
35
35
|
limits: config.limits,
|
|
36
36
|
submissions: config.submissions,
|
|
37
|
+
subjects: config.subjects,
|
|
37
38
|
comments: config.comments,
|
|
38
39
|
moderation: config.moderation,
|
|
39
40
|
reservedHandles: config.reservedHandles,
|
|
@@ -47,6 +48,7 @@ export function readerSnapshotSettings(config: ReaderSteeringConfig) {
|
|
|
47
48
|
return {
|
|
48
49
|
dimensions: config.dimensions,
|
|
49
50
|
submissions: config.submissions,
|
|
51
|
+
subjects: config.subjects,
|
|
50
52
|
karma: config.karma,
|
|
51
53
|
ranking: config.ranking,
|
|
52
54
|
limits: { newAccountHours: config.limits.newAccountHours },
|
|
@@ -129,6 +131,7 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
|
|
|
129
131
|
["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
|
|
130
132
|
["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
|
|
131
133
|
["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
|
|
134
|
+
["PAPYRUS_READER_FLAG_TABLE", "ReaderFlag"],
|
|
132
135
|
["PAPYRUS_READER_SUBMISSION_TABLE", "ReaderSubmission"],
|
|
133
136
|
["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
|
|
134
137
|
["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
|
|
@@ -144,6 +147,7 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
|
|
|
144
147
|
tables.PublishedItem.grantReadData(lambda);
|
|
145
148
|
lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
|
|
146
149
|
tables.ReaderVote.grantReadData(contentActions);
|
|
150
|
+
tables.ReaderFlag.grantReadData(contentActions);
|
|
147
151
|
tables.ReaderProfile.grantReadWriteData(contentActions);
|
|
148
152
|
// The snapshot reads submissions, and the review step writes their signals and moderation state.
|
|
149
153
|
tables.ReaderSubmission.grantReadWriteData(contentActions);
|
|
@@ -3,7 +3,7 @@ import { readerActions } from "./function/resource.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
|
|
5
5
|
* tables directly with its own role, and editors may list them; admins may delete. `id` is the lookup key:
|
|
6
|
-
* the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote and
|
|
6
|
+
* the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote (`subjectKey#readerId#kind` for a flag; the key is the bare id for items and submissions and `type:id` for the rest) and
|
|
7
7
|
* `sub-<sha256 of the canonical URL>` for a submission (so a canonical URL is one submission).
|
|
8
8
|
*/
|
|
9
9
|
const staffOnly = (allow) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
|
|
@@ -32,6 +32,7 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
32
32
|
.model({
|
|
33
33
|
id: a.id().required(),
|
|
34
34
|
subjectKey: a.string().required(),
|
|
35
|
+
subjectType: a.string(),
|
|
35
36
|
readerId: a.string().required(),
|
|
36
37
|
dimension: a.string().required(),
|
|
37
38
|
value: a.integer().required(),
|
|
@@ -39,6 +40,21 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
39
40
|
})
|
|
40
41
|
.disableOperations(["subscriptions"])
|
|
41
42
|
.authorization(staffOnly),
|
|
43
|
+
ReaderFlag: a
|
|
44
|
+
.model({
|
|
45
|
+
id: a.id().required(),
|
|
46
|
+
subjectKey: a.string().required(),
|
|
47
|
+
subjectType: a.string().required(),
|
|
48
|
+
readerId: a.string().required(),
|
|
49
|
+
kind: a.string().required(),
|
|
50
|
+
reason: a.string().required(),
|
|
51
|
+
targetKey: a.string(),
|
|
52
|
+
suggestedName: a.string(),
|
|
53
|
+
voterRole: a.string().required(),
|
|
54
|
+
createdAt: a.datetime().required(),
|
|
55
|
+
})
|
|
56
|
+
.disableOperations(["subscriptions"])
|
|
57
|
+
.authorization(staffOnly),
|
|
42
58
|
ReaderComment: a
|
|
43
59
|
.model({
|
|
44
60
|
id: a.id().required(),
|
|
@@ -4,7 +4,7 @@ import { readerActions } from "./function/resource";
|
|
|
4
4
|
/**
|
|
5
5
|
* Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
|
|
6
6
|
* tables directly with its own role, and editors may list them; admins may delete. `id` is the lookup key:
|
|
7
|
-
* the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote and
|
|
7
|
+
* the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote (`subjectKey#readerId#kind` for a flag; the key is the bare id for items and submissions and `type:id` for the rest) and
|
|
8
8
|
* `sub-<sha256 of the canonical URL>` for a submission (so a canonical URL is one submission).
|
|
9
9
|
*/
|
|
10
10
|
const staffOnly = (allow: any) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
|
|
@@ -34,6 +34,7 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
34
34
|
.model({
|
|
35
35
|
id: a.id().required(),
|
|
36
36
|
subjectKey: a.string().required(),
|
|
37
|
+
subjectType: a.string(),
|
|
37
38
|
readerId: a.string().required(),
|
|
38
39
|
dimension: a.string().required(),
|
|
39
40
|
value: a.integer().required(),
|
|
@@ -41,6 +42,21 @@ export function readerSteeringSchemaDefinitions() {
|
|
|
41
42
|
})
|
|
42
43
|
.disableOperations(["subscriptions"])
|
|
43
44
|
.authorization(staffOnly),
|
|
45
|
+
ReaderFlag: a
|
|
46
|
+
.model({
|
|
47
|
+
id: a.id().required(),
|
|
48
|
+
subjectKey: a.string().required(),
|
|
49
|
+
subjectType: a.string().required(),
|
|
50
|
+
readerId: a.string().required(),
|
|
51
|
+
kind: a.string().required(),
|
|
52
|
+
reason: a.string().required(),
|
|
53
|
+
targetKey: a.string(),
|
|
54
|
+
suggestedName: a.string(),
|
|
55
|
+
voterRole: a.string().required(),
|
|
56
|
+
createdAt: a.datetime().required(),
|
|
57
|
+
})
|
|
58
|
+
.disableOperations(["subscriptions"])
|
|
59
|
+
.authorization(staffOnly),
|
|
44
60
|
ReaderComment: a
|
|
45
61
|
.model({
|
|
46
62
|
id: a.id().required(),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleReaderRequest } from "../../../../lib/reader-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleReaderRequest(request, "flag");
|
|
9
|
+
}
|
package/lib/reader-api.ts
CHANGED
|
@@ -12,7 +12,7 @@ export const READER_BODY_BYTES_BY_ACTION: Partial<Record<ReaderAction, number>>
|
|
|
12
12
|
export const READER_MAXIMUM_SUBJECTS = 50;
|
|
13
13
|
export const READER_SUBJECT_KEY = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
14
14
|
|
|
15
|
-
export type ReaderAction = "me" | "handle" | "vote" | "rating" | "submit" | "comment" | "comment-edit" | "comment-delete" | "report";
|
|
15
|
+
export type ReaderAction = "me" | "handle" | "vote" | "rating" | "submit" | "comment" | "comment-edit" | "comment-delete" | "report" | "flag";
|
|
16
16
|
|
|
17
17
|
export interface ReaderActionEvent {
|
|
18
18
|
publication: string;
|
package/package.json
CHANGED
|
@@ -361,6 +361,7 @@
|
|
|
361
361
|
"./app/api/reader/comment/delete/route": "./app/api/reader/comment/delete/route.ts",
|
|
362
362
|
"./app/api/reader/comment/edit/route": "./app/api/reader/comment/edit/route.ts",
|
|
363
363
|
"./app/api/reader/comment/route": "./app/api/reader/comment/route.ts",
|
|
364
|
+
"./app/api/reader/flag/route": "./app/api/reader/flag/route.ts",
|
|
364
365
|
"./app/api/reader/handle/route": "./app/api/reader/handle/route.ts",
|
|
365
366
|
"./app/api/reader/me/route": "./app/api/reader/me/route.ts",
|
|
366
367
|
"./app/api/reader/rating/route": "./app/api/reader/rating/route.ts",
|
|
@@ -433,7 +434,7 @@
|
|
|
433
434
|
"optional": true
|
|
434
435
|
}
|
|
435
436
|
},
|
|
436
|
-
"version": "2.0.0-next.
|
|
437
|
+
"version": "2.0.0-next.68",
|
|
437
438
|
"dependencies": {
|
|
438
439
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
439
440
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.0.0-next.
|
|
2
|
+
"version": "2.0.0-next.68",
|
|
3
3
|
"routes": [
|
|
4
4
|
{
|
|
5
5
|
"file": "app/%5F_preview/[[...path]]/route.ts",
|
|
@@ -295,6 +295,17 @@
|
|
|
295
295
|
"runtime": "\"nodejs\""
|
|
296
296
|
}
|
|
297
297
|
},
|
|
298
|
+
{
|
|
299
|
+
"file": "app/api/reader/flag/route.ts",
|
|
300
|
+
"hasDefault": false,
|
|
301
|
+
"exports": [
|
|
302
|
+
"POST"
|
|
303
|
+
],
|
|
304
|
+
"config": {
|
|
305
|
+
"dynamic": "\"force-dynamic\"",
|
|
306
|
+
"runtime": "\"nodejs\""
|
|
307
|
+
}
|
|
308
|
+
},
|
|
298
309
|
{
|
|
299
310
|
"file": "app/api/reader/handle/route.ts",
|
|
300
311
|
"hasDefault": false,
|