#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""Prospective-collaborator invite requests at `<state>/requests.json`.The intake queue behind the login page's "Request an invite" form. A visitorwith **no account** submits their name, email and remarks; the record lands hereand the operator reviews it (folded into `users list`) and works through it with`users process-requests` — accept (invite + provision), ignore (leave), ordismiss (drop). Submitting creates **zero** authorization state — it is a request,not a grant; only the sudo admin path (`users process-requests` → `users add`)mints an invite and provisions an instance.This store is the **system of record** for requests: the owner-notificationemail (:meth:`AuthService.notify_invite_request`) is a best-effort nudge layeredon top, so a wedged mail relay loses a notification, never the request.Guard rails, since the form is unauthenticated:- every field is length-capped and control-char-stripped on the way in (:func:`sanitize`);- records are deduped by normalised email (a resubmit updates in place, refreshes the TTL and bumps `submissions`);- a **global** cap of :data:`MAX_REQUESTS` distinct emails and a **hard per-IP** sub-cap of :data:`MAX_PER_IP` bound how much one source can queue — the client IP is stored only as a keyed hash (:data:`_secret`), never in the clear;- records expire :data:`REQUEST_TTL_SECONDS` after their last submission and sweep on every access, so an unworked queue does not grow without bound.The public handler answers the same generic message whether the request stored,deduped, or was dropped (cap or TTL) — no capacity or membership signal."""from__future__importannotations__all__=['MAX_PER_IP','MAX_REQUESTS','NAME_MAX','REMARKS_MAX','REQUEST_TTL_SECONDS','RequestRecord','RequestStore','sanitize']importhmacimporttimefromdatetimeimportdatetime,timezonefromhashlibimportsha256frompathlibimportPathfromtypingimportAny,NamedTuplefromeuler_site.auth.storageimportload_json,save_jsonfromeuler_site.auth.usersimportnormalize_emailfromeuler_site.storeimportsanitize#: Distinct emails the queue holds before further *new* requests are dropped (a#: resubmit from an already-queued email always updates in place). Bounds the#: disk an unauthenticated form can consume.MAX_REQUESTS:int=500#: Hard per-IP sub-cap: at most this many *live* queued requests may share one#: source IP. Stops a single client filling the queue with distinct emails even#: while under the global cap (the rate limiter only bounds burst, not standing total).MAX_PER_IP:int=50#: A queued request lives this long after its last submission, then sweeps — an#: unworked request is stale after a month and the person can simply re-request.REQUEST_TTL_SECONDS:int=30*24*3600#: Length caps for the free-text fields (characters, after sanitising).NAME_MAX:int=100REMARKS_MAX:int=1000def_now_iso()->str:"""Current UTC time in ISO-8601 (the store's display timestamp)."""returndatetime.now(timezone.utc).isoformat(timespec='seconds')classRequestRecord(NamedTuple):"""One prospective collaborator's invite request (the display view)."""name:stremail:strremarks:strcreated:str# first-seen ISO-8601updated:str# last-resubmit ISO-8601submissions:int# how many times this email has submitteddefsummary(self)->dict[str,Any]:"""A view for the admin listing (already sanitised at rest; no IP, no expiry)."""return{'name':self.name,'email':self.email,'remarks':self.remarks,'created':self.created,'updated':self.updated,'submissions':self.submissions}classRequestStore:"""Single-writer store over the invite-request queue, keyed by normalised email. *secret* keys the HMAC used to fingerprint the submitter IP for the per-IP cap, so the raw address never lands on disk (the file is euler-auth-private, but the IP still need not be retained in the clear). """def__init__(self,path:Path,secret:bytes)->None:self._path=pathself._secret=secretdef_ip_hash(self,client_ip:str)->str:"""A stable, non-reversible fingerprint of *client_ip* for the per-IP cap."""returnhmac.new(self._secret,client_ip.encode(),sha256).hexdigest()[:16]def_load(self)->dict[str,dict[str,Any]]:"""Live records only — expired ones (TTL) are swept on every access."""now=time.time()records=load_json(self._path)return{key:recforkey,recinrecords.items()ifisinstance(rec,dict)andfloat(rec.get('expiry',0))>now}def_save(self,records:dict[str,dict[str,Any]])->None:save_json(self._path,records)@staticmethoddef_to_record(raw:dict[str,Any])->RequestRecord:returnRequestRecord(name=str(raw.get('name','')),email=str(raw.get('email','')),remarks=str(raw.get('remarks','')),created=str(raw.get('created','')),updated=str(raw.get('updated','')),submissions=int(raw.get('submissions',1)))defsubmit(self,name:str,email:str,remarks:str,client_ip:str)->bool:"""Queue (or refresh) a request; return False on a bad email or a hit cap. A resubmit from an already-queued email updates its name/remarks, refreshes the TTL and bumps `submissions` in place — it never counts against either cap. A brand-new email is dropped (False) once the global queue is full (:data:`MAX_REQUESTS`) or its source IP already holds :data:`MAX_PER_IP` live requests. Fields are sanitised here so every caller stores clean values. """key=normalize_email(email)if'@'notinkey:returnFalserecords=self._load()now=time.time()clean_name=sanitize(name,NAME_MAX)clean_remarks=sanitize(remarks,REMARKS_MAX,allow_newlines=True)ip_hash=self._ip_hash(client_ip)existing=records.get(key)ifexistingisnotNone:existing.update(name=clean_name,remarks=clean_remarks,updated=_now_iso(),expiry=now+REQUEST_TTL_SECONDS,ip_hash=ip_hash,submissions=int(existing.get('submissions',1))+1)else:iflen(records)>=MAX_REQUESTS:returnFalseifsum(1forrecinrecords.values()ifrec.get('ip_hash')==ip_hash)>=MAX_PER_IP:returnFalseiso=_now_iso()records[key]={'name':clean_name,'email':key,'remarks':clean_remarks,'created':iso,'updated':iso,'submissions':1,'ip_hash':ip_hash,'expiry':now+REQUEST_TTL_SECONDS}self._save(records)returnTruedefdismiss(self,email:str)->bool:"""Drop the request for *email*; return whether one was there."""records=self._load()ifrecords.pop(normalize_email(email),None)isNone:returnFalseself._save(records)returnTruedefall(self)->list[RequestRecord]:"""Every live request, oldest first (for the admin listing)."""returnsorted((self._to_record(raw)forrawinself._load().values()),key=lambdarecord:record.created)