| from __future__ import annotations |
|
|
| import re |
| import secrets |
| from datetime import UTC, datetime |
|
|
|
|
| SEED_MAX_CHARS = 500 |
| FRAGMENT_MAX_CHARS = 500 |
| STORY_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{6,32}$") |
|
|
| _DISALLOWED_PATTERNS = [ |
| re.compile(pattern, re.IGNORECASE) |
| for pattern in ( |
| r"\bsexual\s+content\s+involving\s+minors\b", |
| r"\bminor\s+sexual\b", |
| r"\bchild\s+sexual\b", |
| r"\bhow\s+to\s+make\s+(a\s+)?bomb\b", |
| r"\binstructions?\s+to\s+(kill|poison|stab|shoot)\b", |
| r"\bprivate\s+(ssn|social\s+security|passport|credit\s+card)\b", |
| ) |
| ] |
|
|
|
|
| class InputValidationError(ValueError): |
| pass |
|
|
|
|
| def utc_now_iso() -> str: |
| return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") |
|
|
|
|
| def new_story_id() -> str: |
| return secrets.token_urlsafe(6) |
|
|
|
|
| def new_graft_id() -> str: |
| return secrets.token_urlsafe(8) |
|
|
|
|
| def validate_story_id(story_id: str | None) -> str: |
| if not isinstance(story_id, str): |
| raise InputValidationError("Story ID is required.") |
| value = story_id.strip() |
| if not STORY_ID_PATTERN.fullmatch(value): |
| raise InputValidationError("Story ID format is invalid.") |
| return value |
|
|
|
|
| def validate_seed(seed: str) -> str: |
| return _validate_text(seed, SEED_MAX_CHARS, "Seed") |
|
|
|
|
| def validate_fragment(fragment: str) -> str: |
| return _validate_text(fragment, FRAGMENT_MAX_CHARS, "Fragment") |
|
|
|
|
| def _validate_text(value: str, max_chars: int, label: str) -> str: |
| if not isinstance(value, str): |
| raise InputValidationError(f"{label} is required.") |
| text = value.strip() |
| if not text: |
| raise InputValidationError(f"{label} cannot be empty.") |
| if len(text) > max_chars: |
| raise InputValidationError(f"{label} must be {max_chars} characters or fewer.") |
| for pattern in _DISALLOWED_PATTERNS: |
| if pattern.search(text): |
| raise InputValidationError( |
| "This app keeps contributions PG-13. Please revise the text and try again." |
| ) |
| return text |
|
|
|
|
| def require_open_for_graft(status: str, graft_count: int, max_grafts: int) -> None: |
| if status != "open" or graft_count >= max_grafts: |
| raise InputValidationError("This manuscript is sealed and cannot accept new grafts.") |
|
|