Compare commits
No commits in common. "0bbdb30719b7d71521f8b556200ce3943132af22" and "bf4d1d16dea792cdf8ed3c49fd9ad4adaad1e9f3" have entirely different histories.
0bbdb30719
...
bf4d1d16de
5
.gitignore
vendored
5
.gitignore
vendored
@ -3,8 +3,3 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
# Local recorder database (record.py) — dev-only, never committed or deployed.
|
# Local recorder database (record.py) — dev-only, never committed or deployed.
|
||||||
data/
|
data/
|
||||||
# Secrets — API credentials must never be committed or deployed (server-side only).
|
|
||||||
openskycredentials.json
|
|
||||||
*credentials*.json
|
|
||||||
*.secret
|
|
||||||
.env
|
|
||||||
|
|||||||
@ -4,11 +4,11 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>GODSIGH ▸ World View</title>
|
<title>GODSIGH ▸ World View</title>
|
||||||
<script>window.CESIUM_BASE_URL = 'https://cdn.jsdelivr.net/npm/cesium@1.143.0/Build/Cesium/';</script>
|
<script>window.CESIUM_BASE_URL = 'https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/';</script>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cesium@1.143.0/Build/Cesium/Widgets/widgets.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/Widgets/widgets.css" />
|
||||||
<link rel="stylesheet" href="css/style.css" />
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/cesium@1.143.0/Build/Cesium/Cesium.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/Cesium.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/satellite.js@5.0.0/dist/satellite.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/satellite.js@5/dist/satellite.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="cesiumContainer"></div>
|
<div id="cesiumContainer"></div>
|
||||||
|
|||||||
11
js/config.js
11
js/config.js
@ -88,15 +88,6 @@ export const CONFIG = {
|
|||||||
cap: 500,
|
cap: 500,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Military aircraft — ADS-B Exchange /v2/mil/ (REAL unfiltered military, the
|
|
||||||
// OSINT prize OpenSky hides). Paid RapidAPI feed; the same-origin proxy injects
|
|
||||||
// the key server-side AND 5-min-caches it, so poll cadence == cache TTL keeps
|
|
||||||
// the ~10k/month quota safe (~8.6k/month worst case).
|
|
||||||
adsbx: {
|
|
||||||
proxyMil: 'proxy/adsbx-mil', // RELATIVE — works at "/" and "/godsigh/"
|
|
||||||
pollMs: 300_000, // 5 min
|
|
||||||
},
|
|
||||||
|
|
||||||
// Optional live AIS. Leave blank to use the demo fleet only (roadmap feature).
|
// Optional live AIS. Leave blank to use the demo fleet only (roadmap feature).
|
||||||
AISSTREAM_API_KEY: '',
|
AISSTREAM_API_KEY: '',
|
||||||
|
|
||||||
@ -117,8 +108,6 @@ export const CONFIG = {
|
|||||||
aircraftMid: '#ffe74d',
|
aircraftMid: '#ffe74d',
|
||||||
aircraftHigh: '#4dd2ff',
|
aircraftHigh: '#4dd2ff',
|
||||||
aircraftMil: '#ff5964', // military-callsign tint (overrides altitude band)
|
aircraftMil: '#ff5964', // military-callsign tint (overrides altitude band)
|
||||||
militaryReal: '#ff453a', // REAL military (ADS-B Exchange feed)
|
|
||||||
militaryEmerg: '#ff2d95', // military squawking 7500/7600/7700 or emergency
|
|
||||||
// earthquakes (magnitude ramp: amber → red)
|
// earthquakes (magnitude ramp: amber → red)
|
||||||
quakeMin: '#ffcf50',
|
quakeMin: '#ffcf50',
|
||||||
quakeMax: '#ff3b30',
|
quakeMax: '#ff3b30',
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export default function create(ctx) {
|
|||||||
if (!loc) continue;
|
if (!loc) continue;
|
||||||
const title = ev.title || 'Wildfire';
|
const title = ev.title || 'Wildfire';
|
||||||
const category = (ev.categories && ev.categories[0] && ev.categories[0].title) || 'Wildfires';
|
const category = (ev.categories && ev.categories[0] && ev.categories[0].title) || 'Wildfires';
|
||||||
const link = lib.safeUrl(ev.link || (ev.id ? `https://eonet.gsfc.nasa.gov/api/v3/events/${encodeURIComponent(ev.id)}` : ''));
|
const link = ev.link || (ev.id ? `https://eonet.gsfc.nasa.gov/api/v3/events/${ev.id}` : null);
|
||||||
const dateTxt = loc.date ? new Date(loc.date).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—';
|
const dateTxt = loc.date ? new Date(loc.date).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—';
|
||||||
|
|
||||||
ds.entities.add({
|
ds.entities.add({
|
||||||
@ -88,11 +88,11 @@ export default function create(ctx) {
|
|||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
`<table class="cesium-infoBox-defaultTable"><tbody>` +
|
`<table class="cesium-infoBox-defaultTable"><tbody>` +
|
||||||
`<tr><th>Event</th><td>${lib.escapeHtml(title)}</td></tr>` +
|
`<tr><th>Event</th><td>${title}</td></tr>` +
|
||||||
`<tr><th>Category</th><td>${lib.escapeHtml(category)}</td></tr>` +
|
`<tr><th>Category</th><td>${category}</td></tr>` +
|
||||||
`<tr><th>Last report</th><td>${dateTxt}</td></tr>` +
|
`<tr><th>Last report</th><td>${dateTxt}</td></tr>` +
|
||||||
`</tbody></table>` +
|
`</tbody></table>` +
|
||||||
(link ? `<p><a href="${lib.escapeHtml(link)}" target="_blank" rel="noopener">Open event on EONET ↗</a></p>` : '') +
|
(link ? `<p><a href="${link}" target="_blank" rel="noopener">Open event on EONET ↗</a></p>` : '') +
|
||||||
`<p style="opacity:.7">Active wildfire event from NASA EONET (real data).</p>`,
|
`<p style="opacity:.7">Active wildfire event from NASA EONET (real data).</p>`,
|
||||||
});
|
});
|
||||||
count++;
|
count++;
|
||||||
|
|||||||
@ -1,138 +0,0 @@
|
|||||||
// Military aircraft (REAL) — ADS-B Exchange /v2/mil/ feed via the paid RapidAPI
|
|
||||||
// proxy. This is the unfiltered military traffic OpenSky/FR24 hide (C-17 Reach
|
|
||||||
// flights, tankers, the Area 51 "Janet" shuttle, etc.) — the OSINT prize.
|
|
||||||
//
|
|
||||||
// BillboardCollection primitive like the civilian aircraft layer, but a distinct
|
|
||||||
// red so it never blends in. Live-only (no history feed for this), polled every
|
|
||||||
// 5 min to match the server-side cache and respect the ~10k/month quota.
|
|
||||||
|
|
||||||
export default function create(ctx) {
|
|
||||||
const { viewer, CONFIG, lib, ui, Cesium } = ctx;
|
|
||||||
const A = CONFIG.adsbx;
|
|
||||||
|
|
||||||
const bc = viewer.scene.primitives.add(new Cesium.BillboardCollection());
|
|
||||||
const glyph = lib.aircraftGlyph(); // shared triangle, tinted per-billboard
|
|
||||||
|
|
||||||
let enabled = true;
|
|
||||||
let isLive = true;
|
|
||||||
let inFlight = false;
|
|
||||||
let timer = null;
|
|
||||||
let delay = A.pollMs;
|
|
||||||
|
|
||||||
ui.addLayer('military', 'Military (ADS-B Exchange)', true, (on) => {
|
|
||||||
enabled = on;
|
|
||||||
bc.show = on && isLive;
|
|
||||||
if (on && isLive) kick();
|
|
||||||
});
|
|
||||||
ui.setStatus('military', 'loading…', 'warn');
|
|
||||||
|
|
||||||
// Transponder emergency codes → the highlight label.
|
|
||||||
const EMERG = { '7500': 'HIJACK', '7600': 'RADIO FAIL', '7700': 'EMERGENCY' };
|
|
||||||
|
|
||||||
function canPoll() { return enabled && isLive && !document.hidden; }
|
|
||||||
function schedule(ms) { if (timer) clearTimeout(timer); timer = setTimeout(tick, ms); }
|
|
||||||
function kick() { if (!canPoll() || timer || inFlight) return; schedule(0); }
|
|
||||||
|
|
||||||
async function tick() {
|
|
||||||
timer = null;
|
|
||||||
if (!canPoll() || inFlight) return;
|
|
||||||
inFlight = true;
|
|
||||||
try { await poll(); } finally { inFlight = false; }
|
|
||||||
if (canPoll()) schedule(delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ADS-B Exchange altitude is in FEET (and "ground" for on-ground aircraft).
|
|
||||||
function altFeet(a) { return typeof a.alt_baro === 'number' ? a.alt_baro : 0; }
|
|
||||||
|
|
||||||
async function poll() {
|
|
||||||
let res;
|
|
||||||
try {
|
|
||||||
res = await fetch(A.proxyMil);
|
|
||||||
} catch (err) {
|
|
||||||
ui.setStatus('military', 'network error — retrying', 'warn');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (res.status === 503) {
|
|
||||||
// Key not configured (e.g. prod before the key is installed) — degrade quietly.
|
|
||||||
bc.removeAll();
|
|
||||||
ui.setStatus('military', 'ADS-B Exchange not configured', 'warn');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
ui.setStatus('military', `HTTP ${res.status} — retrying`, 'warn');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = await res.json();
|
|
||||||
} catch (err) {
|
|
||||||
ui.setStatus('military', 'bad response — retrying', 'warn');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = Array.isArray(data && data.ac) ? data.ac : [];
|
|
||||||
bc.removeAll();
|
|
||||||
let count = 0;
|
|
||||||
let emergencies = 0;
|
|
||||||
for (const a of list) {
|
|
||||||
if (a.lat == null || a.lon == null) continue;
|
|
||||||
const sq = a.squawk;
|
|
||||||
const isEmerg = !!EMERG[sq] || (a.emergency && a.emergency !== 'none');
|
|
||||||
if (isEmerg) emergencies++;
|
|
||||||
bc.add({
|
|
||||||
position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, altFeet(a) * 0.3048),
|
|
||||||
image: glyph,
|
|
||||||
color: lib.cz(isEmerg ? CONFIG.colors.militaryEmerg : CONFIG.colors.militaryReal),
|
|
||||||
rotation: lib.headingToRotation(a.track), // lib negates — track may be absent (→0)
|
|
||||||
scale: isEmerg ? 1.05 : 0.82, // bolder than civilian; emergencies bigger still
|
|
||||||
id: {
|
|
||||||
layer: 'military',
|
|
||||||
hex: a.hex,
|
|
||||||
callsign: (a.flight || '').trim(),
|
|
||||||
type: a.t,
|
|
||||||
reg: a.r,
|
|
||||||
altFt: altFeet(a),
|
|
||||||
gs: a.gs,
|
|
||||||
track: a.track,
|
|
||||||
squawk: sq,
|
|
||||||
emerg: isEmerg ? (EMERG[sq] || String(a.emergency).toUpperCase()) : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
delay = A.pollMs;
|
|
||||||
const stamp = data.now ? new Date(data.now).toLocaleTimeString() : new Date().toLocaleTimeString();
|
|
||||||
const em = emergencies ? ` · ${emergencies}⚠` : '';
|
|
||||||
ui.setStatus('military', `${count} military · ${stamp}${em}`, emergencies ? 'err' : 'ok');
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClockTick(currentTime, live) {
|
|
||||||
isLive = live;
|
|
||||||
if (!isLive) { bc.show = false; return; } // live-only feed; pause when scrubbed
|
|
||||||
bc.show = enabled;
|
|
||||||
if (enabled) kick();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePick(picked) {
|
|
||||||
if (picked?.id?.layer !== 'military') return false;
|
|
||||||
const id = picked.id;
|
|
||||||
const rows = [
|
|
||||||
['Type', id.type || '—'],
|
|
||||||
['Reg', id.reg || '—'],
|
|
||||||
['Altitude', `${Math.round(id.altFt || 0)} ft`],
|
|
||||||
['Speed', `${Math.round(id.gs || 0)} kt`],
|
|
||||||
['Squawk', id.squawk || '—'],
|
|
||||||
];
|
|
||||||
if (id.emerg) rows.unshift(['⚠ Status', id.emerg]);
|
|
||||||
ui.showPickOverlay('✈ MIL ' + (id.callsign || id.hex || '—'), rows);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPick() { ui.hidePickOverlay(); }
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) kick(); });
|
|
||||||
kick();
|
|
||||||
|
|
||||||
return { id: 'military', onClockTick, handlePick, clearPick };
|
|
||||||
}
|
|
||||||
@ -64,8 +64,7 @@ export default function create(ctx) {
|
|||||||
const depth = coords[2];
|
const depth = coords[2];
|
||||||
const mag = p.mag;
|
const mag = p.mag;
|
||||||
const timeMs = p.time;
|
const timeMs = p.time;
|
||||||
const place = p.place || 'Unknown location'; // raw: used in name/label (text contexts)
|
const place = p.place || 'Unknown location';
|
||||||
const url = lib.safeUrl(p.url);
|
|
||||||
const size = baseSize(mag);
|
const size = baseSize(mag);
|
||||||
const color = magColor(mag);
|
const color = magColor(mag);
|
||||||
const recentAtBuild = typeof timeMs === 'number' && (Date.now() - timeMs) < 3_600_000;
|
const recentAtBuild = typeof timeMs === 'number' && (Date.now() - timeMs) < 3_600_000;
|
||||||
@ -94,11 +93,11 @@ export default function create(ctx) {
|
|||||||
description:
|
description:
|
||||||
`<table class="cesium-infoBox-defaultTable"><tbody>` +
|
`<table class="cesium-infoBox-defaultTable"><tbody>` +
|
||||||
`<tr><th>Magnitude</th><td>M${mag.toFixed(1)}</td></tr>` +
|
`<tr><th>Magnitude</th><td>M${mag.toFixed(1)}</td></tr>` +
|
||||||
`<tr><th>Place</th><td>${lib.escapeHtml(place)}</td></tr>` +
|
`<tr><th>Place</th><td>${place}</td></tr>` +
|
||||||
`<tr><th>Depth</th><td>${depth != null ? depth.toFixed(1) + ' km' : '—'}</td></tr>` +
|
`<tr><th>Depth</th><td>${depth != null ? depth.toFixed(1) + ' km' : '—'}</td></tr>` +
|
||||||
`<tr><th>Time (UTC)</th><td>${timeMs != null ? new Date(timeMs).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—'}</td></tr>` +
|
`<tr><th>Time (UTC)</th><td>${timeMs != null ? new Date(timeMs).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—'}</td></tr>` +
|
||||||
`</tbody></table>` +
|
`</tbody></table>` +
|
||||||
(url ? `<p><a href="${lib.escapeHtml(url)}" target="_blank" rel="noopener">USGS event page ↗</a></p>` : ''),
|
(p.url ? `<p><a href="${p.url}" target="_blank" rel="noopener">USGS event page ↗</a></p>` : ''),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Labels only for the notable quakes, to keep the globe legible.
|
// Labels only for the notable quakes, to keep the globe legible.
|
||||||
|
|||||||
18
js/lib.js
18
js/lib.js
@ -77,24 +77,6 @@ export function altitudeColor(CONFIG, altMeters) {
|
|||||||
return c.aircraftHigh;
|
return c.aircraftHigh;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape a string for safe interpolation into InfoBox description HTML.
|
|
||||||
// Feeds like USGS/EONET are trusted, but this is defense-in-depth for the
|
|
||||||
// public deploy (and the InfoBox is a real HTML sink).
|
|
||||||
export function escapeHtml(s) {
|
|
||||||
return String(s == null ? '' : s)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only allow http(s) URLs into href attributes (blocks javascript:/data: URLs).
|
|
||||||
export function safeUrl(u) {
|
|
||||||
const s = String(u == null ? '' : u).trim();
|
|
||||||
return /^https?:\/\//i.test(s) ? s : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSS hex → Cesium.Color, optional alpha.
|
// CSS hex → Cesium.Color, optional alpha.
|
||||||
export function cz(hex, alpha) {
|
export function cz(hex, alpha) {
|
||||||
const col = Cesium.Color.fromCssColorString(hex);
|
const col = Cesium.Color.fromCssColorString(hex);
|
||||||
|
|||||||
@ -172,7 +172,6 @@ const LAYER_MODULES = [
|
|||||||
'./layers/fires.js',
|
'./layers/fires.js',
|
||||||
'./layers/ships.js',
|
'./layers/ships.js',
|
||||||
'./layers/aircraft.js',
|
'./layers/aircraft.js',
|
||||||
'./layers/military.js',
|
|
||||||
];
|
];
|
||||||
const activeLayers = [];
|
const activeLayers = [];
|
||||||
|
|
||||||
|
|||||||
46
serve.py
46
serve.py
@ -42,22 +42,6 @@ OPENSKY_CACHE_FRESH_SEC = 120
|
|||||||
HISTORY_DB = Path(__file__).resolve().parent / "data" / "history.db"
|
HISTORY_DB = Path(__file__).resolve().parent / "data" / "history.db"
|
||||||
HISTORY_TOLERANCE_SEC = 600
|
HISTORY_TOLERANCE_SEC = 600
|
||||||
|
|
||||||
# ---- ADS-B Exchange military feed (paid RapidAPI, ~10k req/month) ------------
|
|
||||||
# proxy/adsbx-mil injects the RapidAPI key server-side (never in the browser) and
|
|
||||||
# HARD-caches the /v2/mil/ response for 5 min — the quota guard: even with many
|
|
||||||
# open tabs, at most ~288 upstream calls/day (~8.6k/month) are spent.
|
|
||||||
ADSBX_CREDS = Path(__file__).resolve().parent / "adsbxcredentials.json"
|
|
||||||
ADSBX_CACHE_SEC = 300
|
|
||||||
_adsbx_cache = {"body": None, "ts": 0.0}
|
|
||||||
|
|
||||||
|
|
||||||
def load_adsbx_creds():
|
|
||||||
try:
|
|
||||||
d = json.loads(ADSBX_CREDS.read_text())
|
|
||||||
return d.get("host"), d.get("key")
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def opensky_cache_file():
|
def opensky_cache_file():
|
||||||
override = os.environ.get("OPENSKY_CACHE_FILE")
|
override = os.environ.get("OPENSKY_CACHE_FILE")
|
||||||
@ -126,8 +110,6 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
if not self.path.startswith("/proxy/"):
|
if not self.path.startswith("/proxy/"):
|
||||||
return super().do_GET()
|
return super().do_GET()
|
||||||
name, _, query = self.path[len("/proxy/"):].partition("?")
|
name, _, query = self.path[len("/proxy/"):].partition("?")
|
||||||
if name == "adsbx-mil":
|
|
||||||
return self._serve_adsbx_mil()
|
|
||||||
base = UPSTREAMS.get(name)
|
base = UPSTREAMS.get(name)
|
||||||
if not base:
|
if not base:
|
||||||
return self.send_error(404, f"unknown upstream {name!r}")
|
return self.send_error(404, f"unknown upstream {name!r}")
|
||||||
@ -223,34 +205,6 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
out.write_bytes(png)
|
out.write_bytes(png)
|
||||||
return self._send_json(200, {"ok": True, "path": f"docs/{name}.png", "bytes": len(png)})
|
return self._send_json(200, {"ok": True, "path": f"docs/{name}.png", "bytes": len(png)})
|
||||||
|
|
||||||
def _serve_adsbx_mil(self):
|
|
||||||
host, key = load_adsbx_creds()
|
|
||||||
if not host or not key:
|
|
||||||
# Not configured (e.g. no key file) — the layer degrades gracefully.
|
|
||||||
return self._send_json(503, {"error": "ADS-B Exchange not configured"})
|
|
||||||
now = time.time()
|
|
||||||
if _adsbx_cache["body"] is not None and now - _adsbx_cache["ts"] < ADSBX_CACHE_SEC:
|
|
||||||
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "hit"})
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"https://{host}/v2/mil/",
|
|
||||||
headers={"x-rapidapi-host": host, "x-rapidapi-key": key,
|
|
||||||
"User-Agent": "godsigh-dev/0.1"},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as r:
|
|
||||||
body = r.read()
|
|
||||||
_adsbx_cache["body"] = body
|
|
||||||
_adsbx_cache["ts"] = now
|
|
||||||
return self._send(200, "application/json", body, {"X-Godsigh-Cache": "miss"})
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
if _adsbx_cache["body"] is not None:
|
|
||||||
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "stale"})
|
|
||||||
return self._send_json(e.code, {"error": f"adsbx upstream {e.code}"})
|
|
||||||
except Exception as e:
|
|
||||||
if _adsbx_cache["body"] is not None:
|
|
||||||
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "stale"})
|
|
||||||
return self._send_json(502, {"error": str(e)})
|
|
||||||
|
|
||||||
def _send_json(self, status, obj, extra=None):
|
def _send_json(self, status, obj, extra=None):
|
||||||
self._send(status, "application/json", json.dumps(obj).encode(), extra)
|
self._send(status, "application/json", json.dumps(obj).encode(), extra)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user