#!/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()