Fix /api/contacts 500 error from datetime formatting

- _fmt_time crashed when PyMySQL returned datetime.datetime objects
- updated_at now renders as 'Thu 13 Aug 2026, 13:49'
This commit is contained in:
jp
2026-08-13 18:18:37 +01:00
parent e390e3a145
commit 41c8b65870

605
src/vm_api.py Normal file
View File

@ -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}