فهرست منبع

Switch SMS code delivery to local file instead of API queue

POST /sms/code now writes the code to $DATA_HOME/sms_code.txt and is
protected by standard OIDC bearer auth. GET /sms/code is removed.
trigger-2fa.ts polls the local file directly, clearing it on read and
before each new challenge trigger.
ZiB 4 ماه پیش
والد
کامیت
6726226c2b

+ 1 - 1
room_cal/api/__init__.py

@@ -21,7 +21,7 @@ def _render(path: Path) -> str:
   return path.read_text().replace("const API_BASE = '';", f"const API_BASE = '{_API_BASE}';", 1)
 
 _HTML_PATHS = {'/dev', '/calendar', '/calc'}
-_PUBLIC_PREFIXES = ('/auth/', '/calc', '/health', '/sms/', '/busy')
+_PUBLIC_PREFIXES = ('/auth/', '/calc', '/health', '/busy')
 _OPEN_PATHS = {'/docs', '/openapi.json', '/redoc'}
 
 

+ 5 - 42
room_cal/api/_routes/sms.py

@@ -1,53 +1,16 @@
-import asyncio
 import os
-from fastapi import APIRouter, Body, Header, HTTPException
-from room_cal.api._auth import AUTH_DISABLED
+from pathlib import Path
+from fastapi import APIRouter, Body, HTTPException
 
 router = APIRouter()
 
-_SMS_SECRET = os.getenv('SMS_GATEWAY_SECRET', '')
-_pending: 'asyncio.Queue[str] | None' = None
-
-
-def _queue() -> 'asyncio.Queue[str]':
-  global _pending
-  if _pending is None:
-    _pending = asyncio.Queue(maxsize=1)
-  return _pending
-
-
-def _check_secret(secret: str | None) -> None:
-  if AUTH_DISABLED:
-    return
-  if _SMS_SECRET and secret != _SMS_SECRET:
-    raise HTTPException(status_code=403, detail='Invalid secret')
+_SMS_CODE_FILE = Path(os.environ.get('ROOM_CAL_DATA_HOME', Path.home() / '.local' / 'room_cal')) / 'sms_code.txt'
 
 
 @router.post('/sms/code', status_code=204)
-async def receive_code(
-  payload: dict = Body(...),
-  x_sms_secret: str | None = Header(None),
-):
+async def receive_code(payload: dict = Body(...)):
   """Receive an SMS challenge code from the SMS gateway."""
-  _check_secret(x_sms_secret)
   code = payload.get('code')
   if not code:
     raise HTTPException(status_code=422, detail='Missing code field')
-  q = _queue()
-  if q.full():
-    q.get_nowait()  # discard stale unread code
-  q.put_nowait(str(code))
-
-
-@router.get('/sms/code')
-async def poll_code(
-  timeout: int = 60,
-  x_sms_secret: str | None = Header(None),
-):
-  """Long-poll for an SMS challenge code. Returns and discards it once delivered."""
-  _check_secret(x_sms_secret)
-  try:
-    code = await asyncio.wait_for(_queue().get(), timeout=timeout)
-    return {'code': code}
-  except asyncio.TimeoutError:
-    raise HTTPException(status_code=404, detail='No code received within timeout')
+  _SMS_CODE_FILE.write_text(str(code))

+ 2 - 1
room_cal/service_api/typescript/booking/trigger-2fa.ts

@@ -3,7 +3,7 @@ import * as path from 'path';
 import * as os from 'os';
 import type { BrowserContext, Page } from 'playwright';
 import { filterCookies, parseOutputMode } from '../shared/cookie-utils';
-import { handleOutput, fetchSmsCode } from '../shared/api-client';
+import { handleOutput, fetchSmsCode, clearSmsCode } from '../shared/api-client';
 import { openPersistentContext, profileDir } from '../shared/browser';
 import type { CredentialsPayload } from '../shared/types';
 
@@ -23,6 +23,7 @@ async function handleAuthAssurance(page: Page, masked: string): Promise<boolean>
 
   // Select-phone page: click "Send verification code" to trigger the SMS.
   try {
+    clearSmsCode(); // discard any stale code before triggering a new challenge
     await page.getByRole('button', { name: /send verification code/i }).click({ timeout: 10_000 });
   } catch {
     // Already past this step.

+ 23 - 15
room_cal/service_api/typescript/shared/api-client.ts

@@ -1,24 +1,32 @@
 import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
 import { upsertEnvLine } from './cookie-utils';
 import type { CredentialsPayload, OutputMode } from './types';
 
+export function smsCodePath(): string {
+  const dataHome = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+  return path.join(dataHome, 'sms_code.txt');
+}
+
+export function clearSmsCode(): void {
+  const p = smsCodePath();
+  if (fs.existsSync(p)) fs.unlinkSync(p);
+}
+
+// TODO: lower timeout for release (120s is generous for human-in-the-loop dev)
 export async function fetchSmsCode(timeoutSeconds = 120): Promise<string | null> {
-  const baseUrl = process.env.ROOM_CAL_API_URL;
-  if (!baseUrl) return null;
-  const url = `${baseUrl.replace(/\/$/, '')}/sms/code?timeout=${timeoutSeconds}`;
-  const headers: Record<string, string> = {};
-  const secret = process.env.SMS_GATEWAY_SECRET;
-  if (secret) headers['X-SMS-Secret'] = secret;
-  try {
-    const res = await fetch(url, { headers, signal: AbortSignal.timeout((timeoutSeconds + 5) * 1000) });
-    if (res.status === 404) return null;
-    if (!res.ok) throw new Error(`GET /sms/code → ${res.status}`);
-    const data = await res.json() as { code: string };
-    return data.code;
-  } catch (err) {
-    console.error('[sms] fetchSmsCode error:', err);
-    return null;
+  const codePath = smsCodePath();
+  const deadline = Date.now() + timeoutSeconds * 1000;
+  while (Date.now() < deadline) {
+    if (fs.existsSync(codePath)) {
+      const code = fs.readFileSync(codePath, 'utf8').trim();
+      fs.unlinkSync(codePath);
+      if (code) return code;
+    }
+    await new Promise(r => setTimeout(r, 1000));
   }
+  return null;
 }
 
 export async function putCredentials(service: string, credentials: CredentialsPayload): Promise<void> {