File size: 2,867 Bytes
da1822e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
"""
Help-centre feedback — the "Was this page helpful?" votes.

Public (no sign-in), so it stores no user id, name or email: only the page, the
vote, an optional reason and comment, and an anonymous per-browser-session id.
Comments are de-identified after 30 days by scripts/deidentify_help_feedback.py.
Writes go through here (never straight from the browser); firestore.rules deny
all client access to help_feedback.
"""

import time
import threading
from collections import defaultdict
from typing import Literal, Optional

from fastapi import APIRouter, HTTPException
from firebase_admin import firestore
from pydantic import BaseModel, Field

router = APIRouter()

REASONS = {"not_found", "steps_failed", "out_of_date", "unclear", "other"}
RETENTION_SECONDS = 30 * 24 * 3600

# In-memory, per process (same trade-off as utils/rate_limiter.py). Keyed by the
# anonymous session id, plus a global cap so one client can't flood the collection.
PER_SESSION_LIMIT = (30, 3600)   # 30 votes per hour
GLOBAL_LIMIT = (600, 3600)       # 600 votes per hour, all sessions
_lock = threading.Lock()
_hits: dict[str, list[float]] = defaultdict(list)


def _allow(key: str, limit: tuple[int, int]) -> bool:
    count, period = limit
    now = time.time()
    with _lock:
        recent = [t for t in _hits[key] if now - t < period]
        if len(recent) >= count:
            _hits[key] = recent
            return False
        recent.append(now)
        _hits[key] = recent
        return True


class FeedbackIn(BaseModel):
    pageId: str = Field(min_length=1, max_length=200, pattern=r"^[a-z0-9/_-]+$")
    pageVersion: str = Field(min_length=1, max_length=40)
    vote: Literal["yes", "no"]
    reason: Optional[str] = Field(default=None, max_length=30)
    comment: Optional[str] = Field(default=None, max_length=1000)
    sessionId: str = Field(min_length=1, max_length=64)
    device: Literal["mobile", "desktop"] = "desktop"


@router.post("/feedback")
def submit_feedback(body: FeedbackIn):
    if not _allow(f"s:{body.sessionId}", PER_SESSION_LIMIT) or not _allow("global", GLOBAL_LIMIT):
        raise HTTPException(status_code=429, detail="Too many requests. Try again later.")

    reason = body.reason if body.reason in REASONS else None
    now = time.time()
    doc = {
        "pageId": body.pageId,
        "pageVersion": body.pageVersion,
        "vote": body.vote,
        "reason": reason,
        "comment": (body.comment or "").strip() or None,
        "sessionId": body.sessionId,
        "device": body.device,
        "at": firestore.SERVER_TIMESTAMP,
        "deidentifyAfter": now + RETENTION_SECONDS,
        "deidentified": False,
    }
    try:
        firestore.client().collection("help_feedback").add(doc)
    except Exception:
        raise HTTPException(status_code=500, detail="Could not save feedback.")
    return {"success": True}