集成 Turnstile 人机验证 + Cloudflare R2 事件归档

This commit is contained in:
2569718930@qq.com
2026-06-16 03:40:47 +08:00
parent 9d61d9443d
commit 382bc99f0c
8 changed files with 786 additions and 0 deletions
@@ -0,0 +1,93 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const turnstileLibPath = path.join(projectRoot, "lib", "turnstile.ts");
const widgetPath = path.join(projectRoot, "components", "security", "TurnstileWidget.tsx");
const loginPath = path.join(projectRoot, "components", "auth", "LoginClient.tsx");
const feedbackModalPath = path.join(
projectRoot,
"components",
"dashboard",
"scan-terminal",
"UserFeedbackModal.tsx",
);
const feedbackRoutePath = path.join(projectRoot, "app", "api", "feedback", "route.ts");
const paymentIntentRoutePath = path.join(projectRoot, "app", "api", "payments", "intents", "route.ts");
const paymentSubmitRoutePath = path.join(
projectRoot,
"app",
"api",
"payments",
"intents",
"[intentId]",
"submit",
"route.ts",
);
const paymentFlowPath = path.join(projectRoot, "components", "account", "usePaymentFlow.ts");
assert(fs.existsSync(turnstileLibPath), "shared Turnstile verifier module must exist");
assert(fs.existsSync(widgetPath), "Turnstile widget component must exist");
const turnstileLib = fs.readFileSync(turnstileLibPath, "utf8");
const widgetSource = fs.readFileSync(widgetPath, "utf8");
const loginSource = fs.readFileSync(loginPath, "utf8");
const feedbackModalSource = fs.readFileSync(feedbackModalPath, "utf8");
const feedbackRouteSource = fs.readFileSync(feedbackRoutePath, "utf8");
const paymentIntentRouteSource = fs.readFileSync(paymentIntentRoutePath, "utf8");
const paymentSubmitRouteSource = fs.readFileSync(paymentSubmitRoutePath, "utf8");
const paymentFlowSource = fs.readFileSync(paymentFlowPath, "utf8");
assert(
turnstileLib.includes("POLYWEATHER_TURNSTILE_SECRET_KEY") &&
turnstileLib.includes("NEXT_PUBLIC_TURNSTILE_SITE_KEY") &&
turnstileLib.includes("https://challenges.cloudflare.com/turnstile/v0/siteverify") &&
turnstileLib.includes("requireTurnstileForRequest") &&
turnstileLib.includes("POLYWEATHER_TURNSTILE_BYPASS"),
"Turnstile verifier must be optional, use Cloudflare siteverify, and expose a request guard",
);
assert(
widgetSource.includes("NEXT_PUBLIC_TURNSTILE_SITE_KEY") &&
widgetSource.includes("challenges.cloudflare.com/turnstile/v0/api.js") &&
widgetSource.includes("window.turnstile.render") &&
widgetSource.includes("data-turnstile-action"),
"Turnstile widget must render Cloudflare Turnstile only when a site key is configured",
);
assert(
loginSource.includes("TurnstileWidget") &&
loginSource.includes("getTurnstileTokenForAction") &&
loginSource.includes("captchaToken") &&
loginSource.includes("signInWithPassword") &&
loginSource.includes("signUp"),
"login/signup must attach a Turnstile captchaToken to Supabase auth calls",
);
assert(
feedbackModalSource.includes("TurnstileWidget") &&
feedbackModalSource.includes("getTurnstileTokenForAction") &&
feedbackModalSource.includes("turnstile_token"),
"feedback modal must include a Turnstile token in feedback submissions",
);
assert(
feedbackRouteSource.includes('requireTurnstileForRequest(req, "feedback_submit"') &&
feedbackRouteSource.indexOf('requireTurnstileForRequest(req, "feedback_submit"') <
feedbackRouteSource.indexOf("fetch(`${API_BASE}/api/feedback`"),
"feedback POST proxy must verify Turnstile before forwarding user feedback",
);
assert(
paymentIntentRouteSource.includes('requireTurnstileForRequest(req, "payment_intent_create"') &&
paymentSubmitRouteSource.includes('requireTurnstileForRequest(req, "payment_tx_submit"'),
"payment mutation proxies must verify Turnstile before forwarding create and submit requests",
);
assert(
paymentFlowSource.includes("getTurnstileTokenForAction") &&
paymentFlowSource.includes("turnstile_token") &&
paymentFlowSource.includes("payment_intent_create") &&
paymentFlowSource.includes("payment_tx_submit"),
"payment flow must include Turnstile tokens in intent creation and tx submit payloads",
);
}
@@ -0,0 +1,128 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { getConfiguredTurnstileSiteKey, isTurnstileEnabled } from "@/lib/turnstile-client";
const TURNSTILE_SCRIPT_ID = "polyweather-turnstile-script";
const TURNSTILE_SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const TURNSTILE_SITE_KEY_ENV = "NEXT_PUBLIC_TURNSTILE_SITE_KEY";
type TurnstileRenderOptions = {
sitekey: string;
action: string;
callback: (token: string) => void;
"expired-callback": () => void;
"error-callback": () => void;
theme?: "light" | "dark" | "auto";
size?: "normal" | "compact" | "flexible";
};
declare global {
interface Window {
turnstile?: {
render: (container: HTMLElement, options: TurnstileRenderOptions) => string;
reset: (widgetId?: string) => void;
remove: (widgetId?: string) => void;
};
}
}
function ensureTurnstileScript(onReady: () => void, onError: () => void) {
if (typeof window === "undefined") return;
if (window.turnstile) {
onReady();
return;
}
const existing = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
if (existing) {
existing.addEventListener("load", onReady, { once: true });
existing.addEventListener("error", onError, { once: true });
return;
}
const script = document.createElement("script");
script.id = TURNSTILE_SCRIPT_ID;
script.src = TURNSTILE_SCRIPT_SRC;
script.async = true;
script.defer = true;
script.addEventListener("load", onReady, { once: true });
script.addEventListener("error", onError, { once: true });
document.head.appendChild(script);
}
export function TurnstileWidget({
action,
onToken,
onError,
resetKey,
theme = "light",
size = "normal",
}: {
action: string;
onToken: (token: string) => void;
onError?: () => void;
resetKey?: string | number;
theme?: "light" | "dark" | "auto";
size?: "normal" | "compact" | "flexible";
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const widgetIdRef = useRef<string>("");
const [scriptReady, setScriptReady] = useState(false);
const siteKey = getConfiguredTurnstileSiteKey();
useEffect(() => {
if (!isTurnstileEnabled()) return;
ensureTurnstileScript(
() => setScriptReady(true),
() => {
onToken("");
onError?.();
},
);
}, [onError, onToken]);
useEffect(() => {
if (!siteKey || !scriptReady || !containerRef.current || !window.turnstile) return;
if (widgetIdRef.current) {
window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = "";
}
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
action,
theme,
size,
callback: onToken,
"expired-callback": () => onToken(""),
"error-callback": () => {
onToken("");
onError?.();
},
});
return () => {
if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = "";
}
};
}, [action, onError, onToken, scriptReady, siteKey, size, theme]);
useEffect(() => {
if (!resetKey || !widgetIdRef.current || !window.turnstile) return;
onToken("");
window.turnstile.reset(widgetIdRef.current);
}, [onToken, resetKey]);
if (!siteKey) return null;
return (
<div
ref={containerRef}
data-turnstile-action={action}
data-turnstile-site-key-env={TURNSTILE_SITE_KEY_ENV}
className="min-h-[65px] w-full overflow-hidden"
/>
);
}
+23
View File
@@ -0,0 +1,23 @@
export function getConfiguredTurnstileSiteKey() {
return String(process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "").trim();
}
export function isTurnstileEnabled() {
return Boolean(getConfiguredTurnstileSiteKey());
}
export function getTurnstileTokenForAction(
token: string | null | undefined,
action: string,
options?: { message?: string },
) {
if (!isTurnstileEnabled()) return undefined;
const normalized = String(token || "").trim();
if (!normalized) {
throw new Error(
options?.message ||
`Complete the security check before continuing (${action}).`,
);
}
return normalized;
}
+121
View File
@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
const TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const NO_STORE = "no-store, max-age=0";
type TurnstileVerification = {
ok: boolean;
skipped?: boolean;
error?: string;
};
function turnstileSecretKey() {
return String(process.env.POLYWEATHER_TURNSTILE_SECRET_KEY || "").trim();
}
function turnstileSiteKey() {
return String(process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "").trim();
}
export function isTurnstileServerEnabled() {
if (process.env.POLYWEATHER_TURNSTILE_BYPASS === "true") return false;
return Boolean(turnstileSecretKey() && turnstileSiteKey());
}
function enforceTurnstileAction() {
return process.env.POLYWEATHER_TURNSTILE_ENFORCE_ACTION === "true";
}
export function stripTurnstileToken<T>(body: T): T {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const copy = { ...(body as Record<string, unknown>) };
delete copy.turnstile_token;
delete copy.turnstileToken;
return copy as T;
}
function tokenFromBody(body: unknown) {
if (!body || typeof body !== "object") return "";
const record = body as Record<string, unknown>;
return String(record.turnstile_token || record.turnstileToken || "").trim();
}
function remoteIpFromRequest(req: NextRequest) {
const forwardedFor = req.headers.get("x-forwarded-for") || "";
return (
req.headers.get("cf-connecting-ip") ||
forwardedFor.split(",")[0]?.trim() ||
req.headers.get("x-real-ip") ||
""
);
}
export async function verifyTurnstileToken(
token: string,
req: NextRequest,
action: string,
): Promise<TurnstileVerification> {
if (!isTurnstileServerEnabled()) {
return { ok: true, skipped: true };
}
const normalizedToken = String(token || "").trim();
if (!normalizedToken) {
return { ok: false, error: "missing_turnstile_token" };
}
const body = new URLSearchParams();
body.set("secret", turnstileSecretKey());
body.set("response", normalizedToken);
const remoteIp = remoteIpFromRequest(req);
if (remoteIp) body.set("remoteip", remoteIp);
try {
const response = await fetch(TURNSTILE_VERIFY_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
cache: "no-store",
});
const data = (await response.json().catch(() => null)) as {
success?: unknown;
action?: unknown;
"error-codes"?: unknown;
} | null;
if (!response.ok || data?.success !== true) {
return { ok: false, error: "turnstile_siteverify_failed" };
}
const returnedAction = String(data.action || "").trim();
if (enforceTurnstileAction() && returnedAction && returnedAction !== action) {
return { ok: false, error: "turnstile_action_mismatch" };
}
return { ok: true };
} catch {
return { ok: false, error: "turnstile_siteverify_unavailable" };
}
}
export async function requireTurnstileForRequest(
req: NextRequest,
action: string,
body?: unknown,
) {
const token = tokenFromBody(body);
const result = await verifyTurnstileToken(token, req, action);
if (result.ok) return null;
return NextResponse.json(
{
error: "Security verification failed. Please refresh the challenge and retry.",
code: result.error || "turnstile_failed",
},
{
status: 403,
headers: {
"Cache-Control": NO_STORE,
"Cloudflare-CDN-Cache-Control": NO_STORE,
},
},
);
}
+152
View File
@@ -0,0 +1,152 @@
"""Archive realtime observation patch events to Cloudflare R2 as JSONL.
This script is intentionally read-only for Redis/SQLite. It uploads a cold
archive object and does not delete live replay data.
"""
from __future__ import annotations
import argparse
import json
import os
import sqlite3
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable
ROOT = Path(__file__).resolve().parents[1]
def _ensure_repo_path() -> None:
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _default_sqlite_db_path() -> str:
_ensure_repo_path()
from src.database.db_manager import DBManager
return DBManager().db_path
def _parse_date(value: str) -> date:
return datetime.strptime(value, "%Y-%m-%d").date()
def _date_window(day: date) -> tuple[datetime, datetime]:
start = datetime(day.year, day.month, day.day, tzinfo=timezone.utc)
return start, start + timedelta(days=1)
def _decode(value: Any) -> str:
if isinstance(value, bytes):
return value.decode("utf-8")
return str(value or "")
def _load_sqlite_events(db_path: str, archive_day: date) -> list[dict[str, Any]]:
start, end = _date_window(archive_day)
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT revision, schema_type, schema_version, city, source, obs_time,
payload_json, created_at
FROM observation_patch_events
WHERE created_at >= ? AND created_at < ?
ORDER BY revision ASC
""",
(start.isoformat(), end.isoformat()),
).fetchall()
return [
{
"revision": int(row["revision"]),
"schema_type": row["schema_type"],
"schema_version": int(row["schema_version"]),
"type": f"{row['schema_type']}.v{int(row['schema_version'])}",
"city": row["city"],
"source": row["source"],
"obs_time": row["obs_time"],
"created_at": row["created_at"],
"payload": json.loads(row["payload_json"]),
}
for row in rows
]
def _load_redis_events(redis_url: str, stream_key: str, archive_day: date) -> list[dict[str, Any]]:
import redis # type: ignore
start, end = _date_window(archive_day)
min_id = f"{int(start.timestamp() * 1000)}-0"
max_id = f"{int(end.timestamp() * 1000) - 1}-999999"
client = redis.Redis.from_url(redis_url)
rows = client.xrange(stream_key, min=min_id, max=max_id)
events = []
for stream_id, fields in rows:
decoded = {_decode(key): _decode(value) for key, value in dict(fields).items()}
payload_json = decoded.get("payload_json") or "{}"
events.append(
{
"stream_id": _decode(stream_id),
"revision": int(decoded.get("revision") or 0),
"type": decoded.get("type") or "",
"schema_type": decoded.get("schema_type") or "",
"schema_version": int(decoded.get("schema_version") or 0),
"city": decoded.get("city") or "",
"source": decoded.get("source") or "",
"obs_time": decoded.get("obs_time") or "",
"created_at_ms": int(decoded.get("created_at_ms") or 0),
"ts": int(decoded.get("ts") or 0),
"producer_id": decoded.get("producer_id") or "",
"payload": json.loads(payload_json),
}
)
return events
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", choices=["redis", "sqlite"], default=os.getenv("POLYWEATHER_R2_ARCHIVE_SOURCE", "redis"))
parser.add_argument("--date", default=(datetime.now(timezone.utc).date() - timedelta(days=1)).isoformat())
parser.add_argument("--sqlite-db", default=os.getenv("POLYWEATHER_SQLITE_DB_PATH", ""))
parser.add_argument("--redis-url", default=os.getenv("POLYWEATHER_REDIS_URL", "redis://127.0.0.1:6379/0"))
parser.add_argument("--redis-stream-key", default=os.getenv("POLYWEATHER_REDIS_STREAM_KEY", "stream:city_observation"))
parser.add_argument("--dry-run", action="store_true")
return parser
def main(argv: Iterable[str] | None = None) -> int:
args = _build_parser().parse_args(list(argv) if argv is not None else None)
_ensure_repo_path()
from web.r2_archive import (
R2ArchiveConfig,
R2ObjectStore,
build_realtime_archive_key,
events_to_jsonl,
)
archive_day = _parse_date(args.date)
if args.source == "sqlite":
db_path = args.sqlite_db or _default_sqlite_db_path()
events = _load_sqlite_events(db_path, archive_day)
else:
events = _load_redis_events(args.redis_url, args.redis_stream_key, archive_day)
body = events_to_jsonl(events)
key = build_realtime_archive_key(args.source, archive_day.isoformat())
print(json.dumps({"source": args.source, "date": archive_day.isoformat(), "events": len(events), "key": key}, indent=2))
if args.dry_run:
return 0
config = R2ArchiveConfig.from_env()
if config is None:
print("R2 env is not configured; set POLYWEATHER_R2_* variables", file=sys.stderr)
return 2
result = R2ObjectStore(config).put_object(key, body, content_type="application/x-ndjson")
print(json.dumps(result, indent=2))
return 0 if result.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())
+17
View File
@@ -0,0 +1,17 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_frontend_cache_validator_checks_cloudflare_edge_hits():
script = (ROOT / "scripts" / "validate_frontend_cache.sh").read_text(encoding="utf-8")
assert "REQUIRE_CF_CACHE" in script
assert "CF-Cache-Status" in script
assert "cf_cache_status" in script
assert "HIT" in script
assert "MISS" in script
assert "REVALIDATED" in script
assert 'check_cloudflare_cache_hit "/api/cities" "cities edge cache"' in script
assert 'check_cloudflare_cache_hit "/api/scan/terminal?limit=1" "scan terminal edge cache"' in script
+87
View File
@@ -0,0 +1,87 @@
import json
from web.r2_archive import (
R2ArchiveConfig,
R2ObjectStore,
build_realtime_archive_key,
events_to_jsonl,
)
def test_r2_config_is_disabled_until_all_required_env_is_present(monkeypatch):
for key in (
"POLYWEATHER_R2_ACCOUNT_ID",
"POLYWEATHER_R2_BUCKET",
"POLYWEATHER_R2_ACCESS_KEY_ID",
"POLYWEATHER_R2_SECRET_ACCESS_KEY",
):
monkeypatch.delenv(key, raising=False)
assert R2ArchiveConfig.from_env() is None
def test_r2_put_object_signs_cloudflare_r2_request():
captured = {}
def fake_transport(request, timeout):
captured["url"] = request.full_url
captured["method"] = request.get_method()
captured["headers"] = dict(request.header_items())
captured["body"] = request.data
class Response:
status = 200
def read(self):
return b""
def __enter__(self):
return self
def __exit__(self, *_args):
return False
return Response()
config = R2ArchiveConfig(
account_id="acct123",
bucket="polyweather-archive",
access_key_id="access",
secret_access_key="secret",
)
store = R2ObjectStore(config, transport=fake_transport)
store.put_object(
"sse-events/2026/06/16/test.jsonl",
b'{"ok":true}\n',
content_type="application/x-ndjson",
)
assert captured["method"] == "PUT"
assert captured["url"] == (
"https://polyweather-archive.acct123.r2.cloudflarestorage.com/"
"sse-events/2026/06/16/test.jsonl"
)
assert captured["body"] == b'{"ok":true}\n'
assert captured["headers"]["Content-type"] == "application/x-ndjson"
assert captured["headers"]["Host"] == "polyweather-archive.acct123.r2.cloudflarestorage.com"
assert captured["headers"]["Authorization"].startswith("AWS4-HMAC-SHA256 ")
assert "Credential=access/" in captured["headers"]["Authorization"]
def test_realtime_archive_key_and_jsonl_are_stable():
key = build_realtime_archive_key("redis", "2026-06-16", "city_observation_patch")
assert key == "sse-events/2026/06/16/city_observation_patch.redis.jsonl"
body = events_to_jsonl(
[
{"revision": 2, "city": "taipei", "payload": {"temp_c": 31.2}},
{"revision": 3, "city": "seoul", "payload": {"temp_c": 24.5}},
]
)
lines = body.decode("utf-8").splitlines()
assert len(lines) == 2
assert json.loads(lines[0])["revision"] == 2
assert json.loads(lines[1])["city"] == "seoul"
+165
View File
@@ -0,0 +1,165 @@
"""Cloudflare R2 archive helpers for cold PolyWeather event snapshots."""
from __future__ import annotations
import hashlib
import hmac
import json
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable, Iterable, Optional
from urllib.parse import quote, urlparse
from urllib.request import Request, urlopen
R2_REGION = "auto"
R2_SERVICE = "s3"
@dataclass(frozen=True)
class R2ArchiveConfig:
account_id: str
bucket: str
access_key_id: str
secret_access_key: str
endpoint_url: str = ""
region: str = R2_REGION
@classmethod
def from_env(cls, env: Optional[dict[str, str]] = None) -> Optional["R2ArchiveConfig"]:
source = env if env is not None else os.environ
account_id = str(source.get("POLYWEATHER_R2_ACCOUNT_ID") or "").strip()
bucket = str(source.get("POLYWEATHER_R2_BUCKET") or "").strip()
access_key_id = str(source.get("POLYWEATHER_R2_ACCESS_KEY_ID") or "").strip()
secret_access_key = str(source.get("POLYWEATHER_R2_SECRET_ACCESS_KEY") or "").strip()
if not all([account_id, bucket, access_key_id, secret_access_key]):
return None
return cls(
account_id=account_id,
bucket=bucket,
access_key_id=access_key_id,
secret_access_key=secret_access_key,
endpoint_url=str(source.get("POLYWEATHER_R2_ENDPOINT_URL") or "").strip(),
region=str(source.get("POLYWEATHER_R2_REGION") or R2_REGION).strip() or R2_REGION,
)
@property
def base_url(self) -> str:
if self.endpoint_url:
return self.endpoint_url.rstrip("/")
return f"https://{self.bucket}.{self.account_id}.r2.cloudflarestorage.com"
Transport = Callable[[Request, float], Any]
def _default_transport(request: Request, timeout: float) -> Any:
return urlopen(request, timeout=timeout)
def _sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _sign(key: bytes, message: str) -> bytes:
return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest()
def _signature_key(secret: str, date_stamp: str, region: str) -> bytes:
k_date = _sign(("AWS4" + secret).encode("utf-8"), date_stamp)
k_region = _sign(k_date, region)
k_service = _sign(k_region, R2_SERVICE)
return _sign(k_service, "aws4_request")
class R2ObjectStore:
def __init__(
self,
config: R2ArchiveConfig,
*,
transport: Transport = _default_transport,
timeout_sec: float = 30.0,
) -> None:
self.config = config
self.transport = transport
self.timeout_sec = max(1.0, float(timeout_sec))
def put_object(self, key: str, body: bytes, *, content_type: str = "application/octet-stream") -> dict[str, Any]:
object_key = quote(str(key).lstrip("/"), safe="/")
url = f"{self.config.base_url}/{object_key}"
parsed = urlparse(url)
host = parsed.netloc
payload_hash = _sha256_hex(body)
now = datetime.now(timezone.utc)
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
canonical_uri = parsed.path or "/"
canonical_headers = (
f"content-type:{content_type}\n"
f"host:{host}\n"
f"x-amz-content-sha256:{payload_hash}\n"
f"x-amz-date:{amz_date}\n"
)
signed_headers = "content-type;host;x-amz-content-sha256;x-amz-date"
canonical_request = "\n".join(
[
"PUT",
canonical_uri,
"",
canonical_headers,
signed_headers,
payload_hash,
]
)
credential_scope = f"{date_stamp}/{self.config.region}/{R2_SERVICE}/aws4_request"
string_to_sign = "\n".join(
[
"AWS4-HMAC-SHA256",
amz_date,
credential_scope,
_sha256_hex(canonical_request.encode("utf-8")),
]
)
signature = hmac.new(
_signature_key(self.config.secret_access_key, date_stamp, self.config.region),
string_to_sign.encode("utf-8"),
hashlib.sha256,
).hexdigest()
authorization = (
"AWS4-HMAC-SHA256 "
f"Credential={self.config.access_key_id}/{credential_scope}, "
f"SignedHeaders={signed_headers}, "
f"Signature={signature}"
)
request = Request(url, data=body, method="PUT")
request.add_header("Content-Type", content_type)
request.add_header("Host", host)
request.add_header("X-Amz-Content-Sha256", payload_hash)
request.add_header("X-Amz-Date", amz_date)
request.add_header("Authorization", authorization)
with self.transport(request, self.timeout_sec) as response:
response.read()
status = int(getattr(response, "status", 200) or 200)
return {"ok": 200 <= status < 300, "status": status, "key": key}
def build_realtime_archive_key(source: str, archive_date: str, stream_name: str = "city_observation_patch") -> str:
parts = str(archive_date).split("-")
if len(parts) != 3:
raise ValueError("archive_date must be YYYY-MM-DD")
year, month, day = parts
safe_source = quote(str(source or "unknown").strip().lower() or "unknown", safe="")
safe_stream = quote(str(stream_name or "events").strip().lower() or "events", safe="")
return f"sse-events/{year}/{month}/{day}/{safe_stream}.{safe_source}.jsonl"
def events_to_jsonl(events: Iterable[dict[str, Any]]) -> bytes:
lines = [
json.dumps(event, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
for event in events
]
if not lines:
return b""
return ("\n".join(lines) + "\n").encode("utf-8")