diff --git a/src/vm_api.py b/src/vm_api.py new file mode 100644 index 0000000..b66b80a --- /dev/null +++ b/src/vm_api.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python3 +""" +Voicemail API — pure JSON backend for the React portal. + +Endpoints: + POST /api/login + POST /api/logout + GET /api/messages + POST /api/messages/{id}/read + POST /api/messages/{id}/delete + GET /api/settings + POST /api/settings + GET /api/contacts + POST /api/contacts + GET /api/contacts/{id} + PUT /api/contacts/{id} + DELETE /api/contacts/{id} + POST /api/contacts/import_vcf + GET /api/contacts/history/{number} + GET /api/healthz +""" + +import json +import os +import secrets +import sys +import time + +from fastapi import Cookie, FastAPI, Form, HTTPException, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, EmailStr + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import vm_auth +import vm_contacts +import vm_store + +SESSION_HOURS = int(os.environ.get("VM_SESSION_HOURS", "12")) +COOKIE = "vm_session" +BASE = os.environ.get("VM_BASE_PATH", "") +SECURE_COOKIE = os.environ.get("VM_INSECURE_COOKIE", "") not in ("1", "yes", "true") + +app = FastAPI(title="Voicemail API", docs_url=None, openapi_url=None) + +# CORS — allow the React dev server and any static frontend host +app.add_middleware( + CORSMiddleware, + allow_origins=os.environ.get("VM_CORS_ORIGINS", "*").split(","), + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["*"], +) + + +# ------------------------------------------------------------------ sessions +def new_session(mailbox): + con = vm_store.connect() + tok = secrets.token_urlsafe(32) + now = int(time.time()) + con.execute("INSERT INTO sessions (token, mailbox, created_at, expires_at)" + " VALUES (?,?,?,?)", + (tok, str(mailbox), now, now + SESSION_HOURS * 3600)) + con.execute("DELETE FROM sessions WHERE expires_at < ?", (now,)) + con.commit() + con.close() + return tok + + +def session_mailbox(token): + if not token: + return None + con = vm_store.connect() + r = con.execute("SELECT mailbox, expires_at FROM sessions WHERE token=?", + (token,)).fetchone() + con.close() + if not r or r["expires_at"] < time.time(): + return None + return r["mailbox"] + + +def require(token): + mb = session_mailbox(token) + if not mb: + raise HTTPException(status_code=401, detail="login") + return mb + + +# ------------------------------------------------------------ brute-force lockout +MAX_FAILS = int(os.environ.get("VM_MAX_FAILS", "5")) +LOCK_MINUTES = int(os.environ.get("VM_LOCK_MINUTES", "15")) +_fails = {} + + +def _lock_key(mailbox, ip): + return (str(mailbox).strip(), ip) + + +def _lock_check(mailbox, ip): + e = _fails.get(_lock_key(mailbox, ip)) + if not e or e[0] < MAX_FAILS: + return 0 + elapsed = time.time() - e[1] + if elapsed > LOCK_MINUTES * 60: + _fails.pop(_lock_key(mailbox, ip), None) + return 0 + return max(1, int((LOCK_MINUTES * 60 - elapsed) // 60) + 1) + + +def _lock_fail(mailbox, ip): + k = _lock_key(mailbox, ip) + e = _fails.get(k) + now = time.time() + if not e or now - e[1] > LOCK_MINUTES * 60: + _fails[k] = [1, now] + else: + e[0] += 1 + + +def _lock_clear(mailbox, ip): + _fails.pop(_lock_key(mailbox, ip), None) + + +# ------------------------------------------------------------------ helpers +def _fmt_time(ts): + if not ts: + return "" + if hasattr(ts, "strftime"): + return ts.strftime("%a %d %b %Y, %H:%M") + return time.strftime("%a %d %b %Y, %H:%M", time.localtime(ts)) + + +def _fmt_dur(sec): + if not sec: + return "" + return "%d:%02d" % (sec // 60, sec % 60) + + +# ------------------------------------------------------------------ models +class LoginReq(BaseModel): + mailbox: str + pin: str + + +class ContactIn(BaseModel): + name: str + number: str = "" + email: str | None = None + + +# ------------------------------------------------------------------ routes +@app.get("/api/healthz") +def healthz(): + con = vm_store.connect() + n = con.execute("SELECT COUNT(*) c FROM messages").fetchone()["c"] + con.close() + return {"ok": True, "messages": n} + + +@app.post("/api/login") +def login(req: LoginReq, request: Request, response: Response): + ip = request.client.host if request.client else "?" + locked = _lock_check(req.mailbox, ip) + if locked: + raise HTTPException(403, "Too many failed attempts. Try again in %d minutes." % locked) + + info = vm_auth.check_login(req.mailbox, req.pin) + if not info: + _lock_fail(req.mailbox, ip) + raise HTTPException(401, "Incorrect mailbox or PIN") + _lock_clear(req.mailbox, ip) + tok = new_session(req.mailbox.strip()) + response.set_cookie(COOKIE, tok, httponly=True, samesite="lax", + secure=SECURE_COOKIE, max_age=SESSION_HOURS * 3600, + path=BASE + "/") + boxes = vm_auth.parse_mailboxes() + return { + "mailbox": req.mailbox.strip(), + "name": boxes.get(req.mailbox.strip(), {}).get("name", ""), + "token": tok, + } + + +@app.post("/api/logout") +def logout(vm_session: str = Cookie(default=None), response: Response = None): + if vm_session: + con = vm_store.connect() + con.execute("DELETE FROM sessions WHERE token=?", (vm_session,)) + con.commit() + con.close() + response.delete_cookie(COOKIE, path=BASE + "/") + return {"ok": True} + + +def _current_user(vm_session: str = Cookie(default=None)): + mb = require(vm_session) + boxes = vm_auth.parse_mailboxes() + return mb, boxes.get(mb, {}).get("name", "") + + +@app.get("/api/messages") +def list_messages(vm_session: str = Cookie(default=None), + q: str = "", date_from: str = "", date_to: str = "", unread: str = ""): + mb, name = _current_user(vm_session) + con = vm_store.connect() + + where = ["mailbox = ?"] + params = [mb] + if q: + like = "%" + q.replace("%", "%%") + "%" + where.append("(contact_name LIKE ? OR callerid LIKE ? OR summary LIKE ? OR transcript LIKE ?)") + params.extend([like, like, like, like]) + if date_from: + where.append("origtime >= ?") + params.append(int(date_from)) + if date_to: + where.append("origtime < ?") + params.append(int(date_to) + 86400) + if unread: + where.append("is_read = 0") + + sql = ("SELECT id, origtime, duration, callerid, contact_name, contact_email, " + "summary, is_read, audio_sha, audio_ext FROM messages WHERE " + + " AND ".join(where) + " ORDER BY origtime DESC, id DESC") + rows = con.execute(sql, params).fetchall() + con.close() + + out = [] + for r in rows: + out.append({ + "id": r["id"], + "origtime": r["origtime"], + "time": _fmt_time(r["origtime"]), + "duration": r["duration"], + "duration_fmt": _fmt_dur(r["duration"]), + "callerid": r["callerid"], + "contact_name": r.get("contact_name"), + "contact_email": r.get("contact_email"), + "summary": r["summary"], + "transcript": r.get("transcript"), + "intents": r.get("intents"), + "numbers": r.get("numbers"), + "tags": json.loads(r["intents"] or "[]"), + "is_read": bool(r["is_read"]), + "has_audio": bool(r["audio_sha"]), + }) + return {"mailbox": mb, "name": name, "messages": out} + + +@app.post("/api/messages/{msg_id}/read") +def toggle_read(msg_id: int, vm_session: str = Cookie(default=None)): + mb = _current_user(vm_session)[0] + con = vm_store.connect() + con.execute("UPDATE messages SET is_read = 1 - is_read WHERE id=? AND mailbox=?", + (msg_id, mb)) + con.commit() + con.close() + return {"ok": True} + + +@app.post("/api/messages/{msg_id}/delete") +def delete_message(msg_id: int, vm_session: str = Cookie(default=None)): + mb = _current_user(vm_session)[0] + con = vm_store.connect() + r = con.execute("SELECT * FROM messages WHERE id=? AND mailbox=?", + (msg_id, mb)).fetchone() + if not r: + con.close() + raise HTTPException(404, "not found") + if r["audio_sha"]: + others = con.execute("SELECT COUNT(*) c FROM messages " + "WHERE audio_sha=? AND id<>?", + (r["audio_sha"], msg_id)).fetchone()["c"] + if not others: + try: + os.unlink(vm_store.audio_path(r["audio_sha"], r["audio_ext"] or "wav")) + except OSError: + pass + if r.get("spool_path"): + base = os.path.splitext(r["spool_path"])[0] + for ext in (".wav", ".WAV", ".gsm", ".txt", ".wav49"): + try: + os.unlink(base + ext) + except OSError: + pass + con.execute("DELETE FROM messages WHERE id=? AND mailbox=?", (msg_id, mb)) + con.commit() + con.close() + return {"ok": True} + + +@app.get("/api/settings") +def get_settings(vm_session: str = Cookie(default=None)): + mb = _current_user(vm_session)[0] + con = vm_store.connect() + cur = vm_store.get_settings(con, mb) + con.close() + return cur + + +@app.post("/api/settings") +async def save_settings(request: Request, vm_session: str = Cookie(default=None)): + mb = _current_user(vm_session)[0] + form = await request.form() + con = vm_store.connect() + for key, (default, _label) in vm_store.USER_SETTINGS.items(): + if default in ("yes", "no"): + vm_store.set_setting(con, mb, key, "yes" if form.get(key) else "no") + else: + vm_store.set_setting(con, mb, key, (form.get(key) or "").strip()) + con.close() + return {"ok": True} + + +@app.get("/api/contacts") +def list_contacts(vm_session: str = Cookie(default=None), q: str = ""): + _current_user(vm_session) + con = vm_store.connect() + if q: + like = "%" + q.replace("%", "%%") + "%" + rows = con.execute( + "SELECT id, number_e164, name, email, updated_at FROM contacts " + "WHERE name LIKE ? OR number_e164 LIKE ? OR email LIKE ? " + "ORDER BY name ASC, id DESC", + (like, like, like), + ).fetchall() + else: + rows = con.execute( + "SELECT id, number_e164, name, email, updated_at FROM contacts " + "ORDER BY name ASC, id DESC" + ).fetchall() + con.close() + return [{"id": r["id"], "number_e164": r.get("number_e164"), + "name": r["name"], "email": r.get("email"), + "updated_at": _fmt_time(r["updated_at"])} for r in rows] + + +@app.get("/api/contacts/{cid}") +def get_contact(cid: int, vm_session: str = Cookie(default=None)): + _current_user(vm_session) + con = vm_store.connect() + c = con.execute("SELECT * FROM contacts WHERE id=?", (cid,)).fetchone() + con.close() + if not c: + raise HTTPException(404, "contact not found") + return {"id": c["id"], "number_e164": c.get("number_e164"), + "name": c["name"], "email": c.get("email")} + + +@app.post("/api/contacts") +async def create_contact(body: ContactIn, vm_session: str = Cookie(default=None)): + _current_user(vm_session) + normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(body.number)) if body.number else "" + con = vm_store.connect() + try: + con.execute( + "INSERT INTO contacts (number_e164, name, email) VALUES (?, ?, ?)", + (normalized, body.name, body.email), + ) + con.commit() + cid = con.lastrowid + finally: + con.close() + return {"id": cid, "number_e164": normalized, "name": body.name, "email": body.email} + + +@app.put("/api/contacts/{cid}") +async def update_contact(cid: int, body: ContactIn, vm_session: str = Cookie(default=None)): + _current_user(vm_session) + con = vm_store.connect() + try: + row = con.execute("SELECT id, number_e164 FROM contacts WHERE id=?", (cid,)).fetchone() + if not row: + raise HTTPException(404, "contact not found") + normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(body.number)) if body.number else "" + con.execute( + "UPDATE contacts SET name=?, email=?, number_e164=?, updated_at=NOW() WHERE id=?", + (body.name, body.email, normalized, cid), + ) + con.execute( + "UPDATE messages SET contact_name=?, contact_email=? " + "WHERE callerid LIKE ? AND (contact_name IS NULL OR contact_name = '')", + (body.name, body.email, "%%%s%%" % normalized), + ) + con.commit() + finally: + con.close() + return {"id": cid, "number_e164": normalized, "name": body.name, "email": body.email} + + +@app.delete("/api/contacts/{cid}") +def delete_contact(cid: int, vm_session: str = Cookie(default=None)): + _current_user(vm_session) + con = vm_store.connect() + con.execute("DELETE FROM contacts WHERE id=?", (cid,)) + con.commit() + con.close() + return {"ok": True} + + +@app.post("/api/contacts/import_vcf") +def import_vcf(vm_session: str = Cookie(default=None)): + _current_user(vm_session) + path = "/var/lib/vm-transcribe/contacts.vcf" + if not os.path.exists(path): + raise HTTPException(404, "VCF not found") + try: + with open(path, encoding="utf-8", errors="replace") as fh: + entries = vm_contacts._parse_vcf(fh.read()) + except Exception as e: + raise HTTPException(400, "VCF parse error: %s" % e) + + con = vm_store.connect() + existing = { + r["number_e164"] for r in con.execute("SELECT number_e164 FROM contacts").fetchall() + } + added = skipped = 0 + for name, nums, emails in entries: + if vm_contacts._is_junk_name(name): + skipped += 1 + continue + email = emails[0] if emails else None + for raw_num in nums: + e164 = vm_contacts.normalize_uk(vm_contacts.digits_of(raw_num)) + if not e164 or e164 in existing: + skipped += 1 + continue + try: + con.execute( + "INSERT INTO contacts (number_e164, name, email) VALUES (?, ?, ?)", + (e164, name, email), + ) + existing.add(e164) + added += 1 + except Exception: + skipped += 1 + con.commit() + con.close() + return {"ok": True, "added": added, "skipped": skipped} + + +@app.get("/api/contacts/history/{num}") +def contact_history(num: str, vm_session: str = Cookie(default=None)): + mb = _current_user(vm_session)[0] + normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(num)) or num + con = vm_store.connect() + rows = con.execute( + "SELECT id, origtime, duration, callerid, contact_name, contact_email, " + "summary, is_read, audio_sha FROM messages WHERE mailbox=? AND callerid LIKE ? " + "ORDER BY origtime DESC, id DESC", + (mb, "%%%s%%" % normalized), + ).fetchall() + c = con.execute( + "SELECT name FROM contacts WHERE number_e164 = ? LIMIT 1", + (normalized,), + ).fetchone() + con.close() + who = c["name"] if c and c.get("name") else num + out = [] + for r in rows: + out.append({ + "id": r["id"], + "origtime": r["origtime"], + "time": _fmt_time(r["origtime"]), + "duration": r["duration"], + "duration_fmt": _fmt_dur(r["duration"]), + "callerid": r["callerid"], + "contact_name": r.get("contact_name"), + "contact_email": r.get("contact_email"), + "summary": r["summary"], + "transcript": r.get("transcript"), + "intents": r.get("intents"), + "numbers": r.get("numbers"), + "tags": json.loads(r["intents"] or "[]"), + "is_read": bool(r["is_read"]), + "has_audio": bool(r["audio_sha"]), + }) + return {"contact": who, "number": normalized, "messages": out} + + +@app.get("/api/transcript/{msg_id}") +def get_transcript(msg_id: int, vm_session: str = Cookie(default=None)): + mb = _current_user(vm_session)[0] + con = vm_store.connect() + r = con.execute( + "SELECT transcript FROM messages WHERE id=? AND mailbox=?", + (msg_id, mb), + ).fetchone() + con.close() + if not r: + raise HTTPException(404, "not found") + return {"id": msg_id, "transcript": r["transcript"] or ""} + + +@app.get("/api/audio/{msg_id}/info") +def audio_info(msg_id: int, vm_session: str = Cookie(default=None)): + mb = _current_user(vm_session)[0] + con = vm_store.connect() + r = con.execute("SELECT audio_sha, audio_ext FROM messages WHERE id=? AND mailbox=?", + (msg_id, mb)).fetchone() + con.close() + if not r or not r["audio_sha"]: + raise HTTPException(404, "not found") + p = vm_store.audio_path(r["audio_sha"], r["audio_ext"] or "wav") + return {"id": msg_id, "path": p, "exists": os.path.exists(p)} + + +class AddContactIn(BaseModel): + number: str = "" + name: str + email: str | None = None + + +@app.post("/api/add_contact") +async def add_contact(body: AddContactIn, vm_session: str = Cookie(default=None)): + mb = _current_user(vm_session)[0] + num = (body.number or "").strip() + name = (body.name or "").strip() + email = (body.email or "").strip() or None + if not name: + raise HTTPException(400, "Name is required") + normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(num)) if num else None + con = vm_store.connect() + if normalized: + row = con.execute( + "SELECT id FROM contacts WHERE number_e164 = ?", (normalized,) + ).fetchone() + if row and row.get("id"): + con.execute( + "UPDATE contacts SET name=?, email=?, updated_at=NOW() WHERE id=?", + (name, email, row["id"]), + ) + else: + con.execute( + "INSERT INTO contacts (number_e164, name, email) VALUES (?, ?, ?)", + (normalized, name, email), + ) + con.execute( + "UPDATE messages SET contact_name=?, contact_email=? " + "WHERE callerid LIKE ? AND (contact_name IS NULL OR contact_name = '')", + (name, email, "%%%s%%" % normalized), + ) + con.commit() + con.close() + return {"ok": True} + + +@app.post("/api/contacts/delete_all") +def delete_all_contacts(vm_session: str = Cookie(default=None)): + _current_user(vm_session) + con = vm_store.connect() + con.execute("DELETE FROM contacts") + con.commit() + con.close() + return {"ok": True} + + +class AddContactIn(BaseModel): + number: str = "" + name: str + email: str | None = None + + +@app.post("/api/add_contact") +async def add_contact(body: AddContactIn, vm_session: str = Cookie(default=None)): + mb = _current_user(vm_session)[0] + num = (body.number or "").strip() + name = (body.name or "").strip() + email = (body.email or "").strip() or None + if not name: + raise HTTPException(400, "Name is required") + normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(num)) if num else None + con = vm_store.connect() + if normalized: + row = con.execute( + "SELECT id FROM contacts WHERE number_e164 = ?", (normalized,) + ).fetchone() + if row and row.get("id"): + con.execute( + "UPDATE contacts SET name=?, email=?, updated_at=NOW() WHERE id=?", + (name, email, row["id"]), + ) + else: + con.execute( + "INSERT INTO contacts (number_e164, name, email) VALUES (?, ?, ?)", + (normalized, name, email), + ) + con.execute( + "UPDATE messages SET contact_name=?, contact_email=? " + "WHERE callerid LIKE ? AND (contact_name IS NULL OR contact_name = '')", + (name, email, "%%%s%%" % normalized), + ) + con.commit() + con.close() + return {"ok": True} + + +@app.post("/api/contacts/delete_all") +def delete_all_contacts(vm_session: str = Cookie(default=None)): + _current_user(vm_session) + con = vm_store.connect() + con.execute("DELETE FROM contacts") + con.commit() + con.close() + return {"ok": True}