132 lines
5.8 KiB
Python
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()
|