frindlymap/server/mailbox_server.py

132 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Friendly Map 보관함 서버 (잠긴 봉투를 잠시 맡아 두는 우체통).
- 봉투 속은 기기끼리만 풀 수 있어서 서버는 내용을 읽을 수 없다. 겉(보낸·받는 번호, 종류, 단위 ID)만 본다.
- 외부 의존 없음 (파이썬 표준 라이브러리만). 실행:
python3 mailbox_server.py --port 8080 --db mailbox.sqlite3
"""
import argparse
import json
import re
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from mailbox_store import MailboxStore, SqliteMailboxStore
from phone_verify import LogSmsSender, Verifier, WebhookSmsSender
MAX_BODY = 1024 * 1024 # 봉투 하나 최대 1MB
KEEP_SECONDS = 30 * 24 * 3600 # 30일 지나도 못 받아 간 봉투는 정리
PHONE = re.compile(r"^\+\d{8,15}$")
REQUIRED = ("id", "from", "to", "kind", "unit", "body", "stamp")
def make_handler(store: MailboxStore, verifier=None, strict=False):
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args): # 조용히
pass
def _send(self, code, obj):
data = json.dumps(obj).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _read_json(self):
n = int(self.headers.get("Content-Length") or 0)
if n <= 0 or n > MAX_BODY:
return None
try:
return json.loads(self.rfile.read(n).decode("utf-8"))
except Exception:
return None
def do_GET(self):
u = urlparse(self.path)
if u.path == "/v1/time":
return self._send(200, {"stamp": int(time.time() * 100)})
if u.path == "/v1/fetch":
to = (parse_qs(u.query).get("to") or [""])[0]
if not PHONE.match(to):
return self._send(400, {"error": "bad phone"})
if not store.check_token(to, self.headers.get("X-Fm-Token", ""), strict):
return self._send(403, {"error": "token"})
items = [json.loads(text) for _, text in store.take(to)]
return self._send(200, {"items": items})
self._send(404, {"error": "not found"})
def do_POST(self):
u = urlparse(self.path)
body = self._read_json()
if body is None:
return self._send(400, {"error": "bad body"})
if u.path == "/v1/send":
if not all(k in body for k in REQUIRED) or not PHONE.match(str(body["to"])):
return self._send(400, {"error": "bad envelope"})
ok = store.put(body["to"], str(body["id"]), json.dumps(body, ensure_ascii=False))
return self._send(200 if ok else 429, {"ok": ok})
if u.path == "/v1/ack":
to = str(body.get("to", ""))
if not PHONE.match(to) or not store.check_token(to, self.headers.get("X-Fm-Token", ""), strict):
return self._send(403, {"error": "token"})
store.ack(to, [str(i) for i in body.get("ids", [])])
return self._send(200, {"ok": True})
if u.path == "/v1/verify/start":
phone = str(body.get("phone", ""))
if verifier is None or not PHONE.match(phone):
return self._send(400, {"error": "unavailable"})
r = verifier.start(phone)
return self._send({"ok": 200, "rate": 429, "fail": 502}[r], {"ok": r == "ok", "result": r})
if u.path == "/v1/verify/confirm":
phone, code, token = str(body.get("phone", "")), str(body.get("code", "")), str(body.get("token", ""))
if verifier is None or not PHONE.match(phone) or len(token) < 16:
return self._send(400, {"error": "bad request"})
if not verifier.confirm(phone, code):
return self._send(403, {"ok": False})
store.bind_token(phone, token)
return self._send(200, {"ok": True})
self._send(404, {"error": "not found"})
return Handler
def make_server(store: MailboxStore, host="127.0.0.1", port=0, verifier=None, strict=False):
return ThreadingHTTPServer((host, port), make_handler(store, verifier, strict))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="0.0.0.0")
ap.add_argument("--port", type=int, default=8080)
ap.add_argument("--db", default="mailbox.sqlite3")
ap.add_argument("--sms-webhook", help="문자 발송 주소 (POST {to,text}). 있으면 문자 확인을 켠다")
ap.add_argument("--sms-webhook-token", default=None)
ap.add_argument("--sms-log", nargs="?", const="", default=None, help="시험용: 문자를 보내지 않고 기록 (실서비스 금지)")
ap.add_argument("--require-verify", action="store_true", help="문자 확인을 마친 기기만 우편함을 열 수 있게 한다")
a = ap.parse_args()
verifier = None
if a.sms_webhook:
verifier = Verifier(WebhookSmsSender(a.sms_webhook, a.sms_webhook_token))
elif a.sms_log is not None:
verifier = Verifier(LogSmsSender(a.sms_log or None))
if a.require_verify and verifier is None:
ap.error("--require-verify 에는 --sms-webhook 이 필요합니다")
store = SqliteMailboxStore(a.db)
srv = make_server(store, a.host, a.port, verifier, a.require_verify)
def cleaner():
while True:
time.sleep(3600)
store.purge(KEEP_SECONDS)
threading.Thread(target=cleaner, daemon=True).start()
print(f"listening {srv.server_address[1]}", flush=True)
srv.serve_forever()
if __name__ == "__main__":
main()