diff --git a/app.py b/app.py index 2c8b7bb..ed367fc 100644 --- a/app.py +++ b/app.py @@ -3,16 +3,15 @@ GeoIP Ban Generator - Web Application """ from flask import Flask, render_template, request, Response, jsonify -import hashlib -import os -import sqlite3 +from geoip_handler import GeoIPHandler from pathlib import Path from functools import wraps from datetime import datetime - -import config from api import api -from geoip_handler import GeoIPHandler +import hashlib +import os +import sqlite3 +import config app = Flask(__name__, static_folder=str(config.STATIC_DIR), @@ -23,6 +22,18 @@ app.register_blueprint(api) handler = GeoIPHandler() +CACHEABLE_PAGES = { + "/api-docs", + "/generator", +} + +NO_CACHE_PREFIXES = ( + "/api/", +) + +STATIC_PREFIX = "/static/" + + redis_cache = None if config.REDIS_ENABLED: try: @@ -105,6 +116,54 @@ def inject_globals(): 'redis_connected': redis_cache.health_check()['connected'] if redis_cache else False, } +from flask import jsonify, render_template, request + +def _wants_json(): + if request.path.startswith("/api/"): + return True + accept = (request.headers.get("Accept") or "").lower() + return "application/json" in accept + +def _render_4xx(code, title, message): + payload = { + "success": False, + "error": title, + "message": message, + "path": request.path, + "status": code, + } + + if _wants_json(): + return jsonify(payload), code + + return render_template( + "error.html", + status=code, + title=title, + message=message, + path=request.path + ), code + +@app.errorhandler(400) +def bad_request(e): + return _render_4xx(400, "Bad Request", "Request is invalid or missing required fields.") + +@app.errorhandler(401) +def unauthorized(e): + return _render_4xx(401, "Unauthorized", "Authentication is required for this resource.") + +@app.errorhandler(403) +def forbidden(e): + return _render_4xx(403, "Forbidden", "You don't have permission to access this resource.") + +@app.errorhandler(404) +def not_found(e): + return _render_4xx(404, "Not Found", "The requested endpoint/page does not exist.") + +@app.errorhandler(405) +def method_not_allowed(e): + return _render_4xx(405, "Method Not Allowed", "The HTTP method is not allowed for this endpoint.") + @app.route('/') def index(): """Main page""" @@ -118,6 +177,11 @@ def api_docs(): """API documentation page""" return render_template('api.html') +@app.route("/generator") +def generator(): + """Script gwnerator""" + return render_template("generator.html") + @app.route('/favicon.ico') def favicon(): return '', 204 @@ -217,21 +281,29 @@ def cache_control(max_age: int = None): @app.after_request def add_headers(response): """Add cache control headers based on request path""" - - if request.path == '/' or request.path.startswith('/api/'): - response.headers['Cache-Control'] = 'no-cache, no-store' - - elif request.path.startswith('/static/'): - if 'Content-Disposition' in response.headers: - del response.headers['Content-Disposition'] + + path = request.path + if path.startswith(STATIC_PREFIX): + if "Content-Disposition" in response.headers: + del response.headers["Content-Disposition"] + if config.ENABLE_CACHE_BUSTING: - response.headers['Cache-Control'] = f'public, max-age={config.CACHE_TTL_SECONDS}, immutable' + response.headers["Cache-Control"] = ( + f"public, max-age={config.CACHE_TTL_SECONDS}, immutable" + ) else: - response.headers['Cache-Control'] = f'public, max-age={config.CACHE_TTL_SECONDS}' - - elif request.path == '/api-docs': - response.headers['Cache-Control'] = 'public, max-age=300' - + response.headers["Cache-Control"] = f"public, max-age={config.CACHE_TTL_SECONDS}" + + return response + + if path in CACHEABLE_PAGES: + response.headers["Cache-Control"] = "public, max-age=300" + return response + + if path == "/" or any(path.startswith(p) for p in NO_CACHE_PREFIXES): + response.headers["Cache-Control"] = "no-cache, no-store" + return response + return response if __name__ == '__main__': diff --git a/static/css/style.css b/static/css/style.css index 72fc762..b5440cf 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,13 +1,13 @@ +html { + scroll-behavior: smooth; +} + body { background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; padding-bottom: 2rem; } -html { - scroll-behavior: smooth; -} - .card { border: 1px solid #e0e0e0; border-radius: 0.5rem; @@ -407,6 +407,115 @@ html { color: #e06c75 !important; } +.api-card pre, +.api-endpoint pre { + background-color: #282c34; + color: #abb2bf; + border-radius: 0.5rem; + border: 1px solid rgba(224, 224, 224, 0.25); + padding: 1rem 1.25rem; + overflow-x: auto; +} + +.api-card pre code, +.api-endpoint pre code { + background: transparent; + color: inherit; + padding: 0; + font-size: 0.9rem; + line-height: 1.6; +} + +.api-card .api-kv code, +.api-card p code, +.api-card td code, +.api-card li code, +.api-card .alert code { + background-color: rgba(13, 110, 253, 0.08); + border: 1px solid rgba(13, 110, 253, 0.12); + color: #0b5ed7; + padding: 0.1rem 0.3rem; + border-radius: 0.35rem; +} + +.api-trybox { + border: 1px dashed rgba(0, 0, 0, 0.18); + border-radius: 0.5rem; + padding: 1rem; + background-color: #fafafa; +} + +.api-trybox pre { + margin-bottom: 0; +} + +.api-card textarea.form-control { + font-family: 'Courier New', Consolas, Monaco, monospace; + font-size: 0.9rem; +} + +.api-download-link { + display: inline-block; + margin-top: 0.5rem; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + transition: background-color 0.3s ease; +} + +.status-dot.bg-success { + background-color: #198754 !important; +} + +.status-dot.bg-danger { + background-color: #dc3545 !important; +} + +.status-dot.bg-secondary { + background-color: #6c757d !important; +} + +#variantDescription { + animation: fadeIn 0.3s ease-in; +} + +.code-wrap { + border-radius: .75rem; + border: 1px solid rgba(0,0,0,.1); + overflow: hidden; +} + +.code-wrap-header { + background: #111827; + color: #e5e7eb; + padding: .5rem .75rem; + display: flex; + align-items: center; + justify-content: space-between; + font-size: .9rem; +} + +.code-wrap-body { + margin: 0; + background: #282c34; + color: #abb2bf; + padding: 1rem 1.25rem; + overflow-x: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: .9rem; + line-height: 1.6; +} + +.code-wrap-body code { + background: transparent; + color: inherit; + padding: 0; +} + @keyframes fadeIn { from { opacity: 0; @@ -486,11 +595,3 @@ html { } } -#variantDescription { - animation: fadeIn 0.3s ease-in; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(-5px); } - to { opacity: 1; transform: translateY(0); } -} \ No newline at end of file diff --git a/static/js/api.js b/static/js/api.js index 2fab749..151cc67 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -1,75 +1,342 @@ const baseUrl = window.location.origin; -document.addEventListener('DOMContentLoaded', function() { - document.getElementById('baseUrl').textContent = baseUrl; - document.querySelectorAll('[id^="curlUrl"]').forEach(element => { - element.textContent = baseUrl; - }); +document.addEventListener('DOMContentLoaded', () => { + const baseEl = document.getElementById('baseUrl'); + if (baseEl) baseEl.textContent = baseUrl; + + document.querySelectorAll('[id^="curlUrl"]').forEach((el) => { + el.textContent = baseUrl; + }); }); function toggleEndpoint(id) { - const element = document.getElementById(id); - const bsCollapse = new bootstrap.Collapse(element, { - toggle: true + const el = document.getElementById(id); + if (!el) return; + new bootstrap.Collapse(el, { toggle: true }); +} + +function showResponse(id) { + const div = document.getElementById(id); + const body = document.getElementById(id + '-body'); + if (div) div.style.display = 'block'; + if (body) body.textContent = 'Loading...'; + return { div, body }; +} + +function formatHeaders(headers) { + const entries = []; + for (const [k, v] of headers.entries()) entries.push([k, v]); + entries.sort((a, b) => a[0].localeCompare(b[0])); + return entries.map(([k, v]) => `${k}: ${v}`).join('\n'); +} + +function cacheHeaderSummary(headers) { + const keys = ['x-from-cache', 'x-cache-type', 'x-generated-at', 'content-type', 'content-disposition']; + const out = []; + for (const k of keys) { + const v = headers.get(k); + if (v !== null) out.push(`${k}: ${v}`); + } + return out.join('\n'); +} + +async function readBodyAuto(response) { + const ct = (response.headers.get('content-type') || '').toLowerCase(); + const isJson = ct.includes('application/json'); + if (isJson) { + try { + return { kind: 'json', data: await response.json(), contentType: ct }; + } catch { + return { kind: 'text', data: await response.text(), contentType: ct }; + } + } + return { kind: 'text', data: await response.text(), contentType: ct }; +} + +function safeParseJsonFromTextarea(textareaId) { + const el = document.getElementById(textareaId); + if (!el) throw new Error(`Missing textarea: ${textareaId}`); + const raw = el.value; + try { + return JSON.parse(raw); + } catch (e) { + throw new Error(`Invalid JSON in textarea "${textareaId}": ${e.message}`); + } +} + +function guessExtensionFromContentType(ct) { + const s = (ct || '').toLowerCase(); + if (s.includes('application/json')) return 'json'; + if (s.includes('text/csv')) return 'csv'; + if (s.includes('application/javascript')) return 'js'; + if (s.includes('text/plain')) return 'txt'; + return 'txt'; +} + +function firstLines(text, maxLines = 80) { + const lines = String(text || '').split(/\r?\n/); + return lines.slice(0, maxLines).join('\n'); +} + +function tryApi(endpoint, method = 'GET', body = null) { + const responseId = 'response-' + endpoint.replace(/\//g, '-'); + const { body: out } = showResponse(responseId); + + const url = baseUrl + '/api/' + endpoint; + const opts = { method, headers: {} }; + + if (method !== 'GET' && method !== 'HEAD') { + opts.headers['Content-Type'] = 'application/json'; + opts.body = body == null ? '{}' : (typeof body === 'string' ? body : JSON.stringify(body)); + } + + fetch(url, opts) + .then(async (resp) => { + const parsed = await readBodyAuto(resp); + + const meta = [ + `HTTP ${resp.status} ${resp.statusText}`, + cacheHeaderSummary(resp.headers), + '\n--- Headers ---\n' + formatHeaders(resp.headers), + '\n--- Body ---\n' + ].filter(Boolean).join('\n'); + + if (!resp.ok) { + const msg = parsed.kind === 'json' + ? (parsed.data?.error || JSON.stringify(parsed.data, null, 2)) + : String(parsed.data || ''); + throw new Error(`${meta}${msg}`); + } + + const pretty = parsed.kind === 'json' + ? JSON.stringify(parsed.data, null, 2) + : String(parsed.data ?? ''); + + out.textContent = meta + pretty; + }) + .catch((err) => { + out.textContent = 'Error: ' + err.message; }); } -function tryEndpoint(endpoint, method = 'GET') { - const url = baseUrl + '/api/' + endpoint; +function tryPath(path, method = 'GET') { + const normalized = String(path || '').trim(); + const key = normalized.replace(/[^\w-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + const responseId = 'response-' + (key || 'path'); + const { body: out } = showResponse(responseId); + + const url = baseUrl + normalized; + fetch(url, { method }) + .then(async (resp) => { + const parsed = await readBodyAuto(resp); + + const meta = [ + `HTTP ${resp.status} ${resp.statusText}`, + cacheHeaderSummary(resp.headers), + '\n--- Headers ---\n' + formatHeaders(resp.headers), + '\n--- Body ---\n' + ].filter(Boolean).join('\n'); + + if (!resp.ok) { + const msg = parsed.kind === 'json' + ? (parsed.data?.error || JSON.stringify(parsed.data, null, 2)) + : String(parsed.data || ''); + throw new Error(`${meta}${msg}`); + } + + const pretty = parsed.kind === 'json' + ? JSON.stringify(parsed.data, null, 2) + : String(parsed.data ?? ''); + + out.textContent = meta + pretty; + }) + .catch((err) => { + out.textContent = 'Error: ' + err.message; + }); +} + +function tryApiJsonTextarea(endpoint, textareaId) { + try { + const body = safeParseJsonFromTextarea(textareaId); + tryApi(endpoint, 'POST', body); + } catch (e) { const responseId = 'response-' + endpoint.replace(/\//g, '-'); - const responseDiv = document.getElementById(responseId); - const responseBody = document.getElementById(responseId + '-body'); - - responseDiv.style.display = 'block'; - responseBody.textContent = 'Loading...'; - - const options = { - method: method, - headers: { - 'Content-Type': 'application/json' - } - }; - - fetch(url, options) - .then(response => response.json()) - .then(data => { - responseBody.textContent = JSON.stringify(data, null, 2); - }) - .catch(error => { - responseBody.textContent = 'Error: ' + error.message; - }); + const { body: out } = showResponse(responseId); + out.textContent = 'Error: ' + e.message; + } } function tryInvalidateCountry() { - const countryInput = document.getElementById('invalidateCountry'); - const country = countryInput.value.trim().toUpperCase(); - - if (!country || country.length !== 2) { - alert('Please enter a valid 2-letter country code (e.g., CN, RU, US)'); - return; - } - - const url = baseUrl + '/api/cache/invalidate/' + country; - const responseDiv = document.getElementById('response-cache-invalidate'); - const responseBody = document.getElementById('response-cache-invalidate-body'); - - responseDiv.style.display = 'block'; - responseBody.textContent = 'Loading...'; - - fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } + const countryInput = document.getElementById('invalidateCountry'); + const country = (countryInput?.value || '').trim().toUpperCase(); + + const { body: out } = showResponse('response-cache-invalidate'); + + if (!country || country.length !== 2) { + out.textContent = 'Error: Please enter a valid 2-letter country code (e.g., CN, RU, US).'; + return; + } + + fetch(baseUrl + '/api/cache/invalidate/' + country, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}' + }) + .then(async (resp) => { + const parsed = await readBodyAuto(resp); + if (!resp.ok) { + const msg = parsed.kind === 'json' + ? (parsed.data?.error || JSON.stringify(parsed.data, null, 2)) + : String(parsed.data || ''); + throw new Error(msg || `HTTP ${resp.status}`); + } + + out.textContent = (parsed.kind === 'json') + ? JSON.stringify(parsed.data, null, 2) + : String(parsed.data ?? ''); + + if (parsed.kind === 'json' && parsed.data?.success && countryInput) { + countryInput.value = ''; + } }) - .then(response => response.json()) - .then(data => { - responseBody.textContent = JSON.stringify(data, null, 2); - if (data.success) { - countryInput.value = ''; - } - }) - .catch(error => { - responseBody.textContent = 'Error: ' + error.message; + .catch((err) => { + out.textContent = 'Error: ' + err.message; }); } + +async function fetchAsBlob(url, method, jsonBody) { + const resp = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(jsonBody ?? {}) + }); + + const headersText = formatHeaders(resp.headers); + const cacheSummary = cacheHeaderSummary(resp.headers); + const ct = resp.headers.get('content-type') || ''; + const cd = resp.headers.get('content-disposition') || ''; + + const blob = await resp.blob(); + + if (!resp.ok) { + let errText = ''; + try { errText = await blob.text(); } catch { errText = ''; } + throw new Error( + `HTTP ${resp.status} ${resp.statusText}\n${cacheSummary}\n\n--- Headers ---\n${headersText}\n\n--- Body ---\n${errText}`.trim() + ); + } + + return { resp, blob, headersText, cacheSummary, contentType: ct, contentDisposition: cd }; +} + +function makeDownloadLink(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.textContent = filename; + a.className = 'link-primary api-download-link'; + a.onclick = () => setTimeout(() => URL.revokeObjectURL(url), 2500); + return a; +} + +function downloadFromApiJsonTextarea(endpoint, textareaId, fileBaseName) { + const responseId = 'response-' + endpoint.replace(/\//g, '-'); + const { body: out } = showResponse(responseId); + + let body; + try { + body = safeParseJsonFromTextarea(textareaId); + } catch (e) { + out.textContent = 'Error: ' + e.message; + return; + } + + const url = baseUrl + '/api/' + endpoint; + + fetchAsBlob(url, 'POST', body) + .then(async ({ blob, headersText, cacheSummary, contentType, contentDisposition }) => { + const ext = guessExtensionFromContentType(contentType); + const filename = `${fileBaseName}.${ext}`; + + let preview = ''; + try { + const txt = await blob.text(); + preview = firstLines(txt, 80); + } catch { + preview = '(binary content)'; + } + + out.textContent = + `OK\n${cacheSummary}\n\n--- Headers ---\n${headersText}\n\n--- Preview (first lines) ---\n${preview}\n\n--- Download ---\n`; + + const link = makeDownloadLink(blob, filename); + out.parentElement.appendChild(link); + }) + .catch((err) => { + out.textContent = 'Error: ' + err.message; + }); +} + + +function previewTextFromGenerate(textareaId) { + const { body: out } = showResponse('response-generate-download'); + + let body; + try { + body = safeParseJsonFromTextarea(textareaId); + } catch (e) { + out.textContent = 'Error: ' + e.message; + return; + } + + fetchAsBlob(baseUrl + '/api/generate', 'POST', body) + .then(async ({ blob, headersText, cacheSummary, contentType }) => { + let text = ''; + try { text = await blob.text(); } catch { text = '(binary content)'; } + + out.textContent = + `OK\n${cacheSummary}\n\n--- Headers ---\n${headersText}\n\n--- Preview (first ~80 lines) ---\n${firstLines(text, 80)}\n`; + }) + .catch((err) => { + out.textContent = 'Error: ' + err.message; + }); +} + +function downloadFromGenerate(textareaId, fileBaseName) { + const { body: out } = showResponse('response-generate-download'); + + let body; + try { + body = safeParseJsonFromTextarea(textareaId); + } catch (e) { + out.textContent = 'Error: ' + e.message; + return; + } + + fetchAsBlob(baseUrl + '/api/generate', 'POST', body) + .then(async ({ blob, headersText, cacheSummary, contentType, contentDisposition }) => { + const ext = guessExtensionFromContentType(contentType); + let filename = `${fileBaseName}.${ext}`; + + const m = /filename="?([^"]+)"?/i.exec(contentDisposition || ''); + if (m && m[1]) filename = m[1]; + + let preview = ''; + try { + const txt = await blob.text(); + preview = firstLines(txt, 80); + } catch { + preview = '(binary content)'; + } + + out.textContent = + `OK\n${cacheSummary}\n\n--- Headers ---\n${headersText}\n\n--- Preview (first lines) ---\n${preview}\n\n--- Download ---\n`; + + const link = makeDownloadLink(blob, filename); + out.parentElement.appendChild(link); + }) + .catch((err) => { + out.textContent = 'Error: ' + err.message; + }); +} \ No newline at end of file diff --git a/static/js/base.js b/static/js/base.js new file mode 100644 index 0000000..9e09263 --- /dev/null +++ b/static/js/base.js @@ -0,0 +1,25 @@ +document.addEventListener("DOMContentLoaded", function () { + + const dot = document.getElementById("apiStatusDot"); + const text = document.getElementById("apiStatusText"); + + if (!dot || !text) return; + + fetch("/health", { method: "GET" }) + .then(response => { + if (!response.ok) throw new Error("API not healthy"); + dot.classList.remove("bg-secondary"); + dot.classList.add("bg-success"); + text.textContent = "API Online"; + text.classList.remove("text-muted"); + text.classList.add("text-success"); + }) + .catch(() => { + dot.classList.remove("bg-secondary"); + dot.classList.add("bg-danger"); + text.textContent = "API Offline"; + text.classList.remove("text-muted"); + text.classList.add("text-danger"); + }); + +}); \ No newline at end of file diff --git a/static/js/generator.js b/static/js/generator.js new file mode 100644 index 0000000..4f84d5c --- /dev/null +++ b/static/js/generator.js @@ -0,0 +1,194 @@ +const baseUrl = window.location.origin; + +const variantsByApp = { + haproxy: [ + { value: "map", label: "map (recommended)" }, + { value: "acl", label: "acl" }, + { value: "lua", label: "lua" }, + ], + apache: [ + { value: "24", label: "24 (recommended)" }, + { value: "22", label: "22 (legacy)" }, + ], + nginx: [ + { value: "geo", label: "geo (recommended)" }, + { value: "deny", label: "deny (recommended)" }, + { value: "map", label: "map (not recommended)" }, + ], +}; + +function $(id) { return document.getElementById(id); } + +function setBaseUrl() { + const el = $("baseUrl"); + if (el) el.textContent = baseUrl; +} + +function normalizeCountries(input) { + return String(input || "") + .split(",") + .map(s => s.trim().toUpperCase()) + .filter(Boolean); +} + +function updateModeUI() { + const mode = $("pyMode").value; + + const rawOn = mode === "raw"; + const genOn = mode === "generate"; + + $("pyRawFormatBox").style.display = rawOn ? "block" : "none"; + $("pyAsJsBox").style.display = rawOn ? "block" : "none"; + $("pyJsVarBox").style.display = rawOn ? "block" : "none"; + + $("pyAppTypeBox").style.display = genOn ? "block" : "none"; + $("pyAppVariantBox").style.display = genOn ? "block" : "none"; + + if (genOn) { + updateVariantOptions(); + } else { + updateRawJsFields(); + } +} + +function updateVariantOptions() { + const app = $("pyAppType").value; + const select = $("pyAppVariant"); + const hint = $("variantHint"); + + select.innerHTML = ""; + (variantsByApp[app] || []).forEach(v => { + const opt = document.createElement("option"); + opt.value = v.value; + opt.textContent = v.label; + select.appendChild(opt); + }); + + if (app === "haproxy") hint.textContent = "Recommended: haproxy + map"; + else if (app === "apache") hint.textContent = "Recommended: apache + 24"; + else if (app === "nginx") hint.textContent = "Recommended: nginx + geo or deny (avoid map)"; + else hint.textContent = ""; +} + +function updateRawJsFields() { + const fmt = $("pyRawFormat").value; + const asJs = $("pyAsJs").value === "true"; + + const allowJs = fmt === "raw-cidr_json"; + $("pyAsJs").disabled = !allowJs; + $("pyJsVar").disabled = !allowJs || !asJs; + + if (!allowJs) { + $("pyAsJs").value = "false"; + } +} + +function buildPythonScript() { + const mode = $("pyMode").value; + const countries = normalizeCountries($("pyCountries").value); + const aggregate = $("pyAggregate").value === "true"; + const useCache = $("pyCache").value === "true"; + + let endpoint = ""; + const payload = { countries, aggregate, use_cache: useCache }; + + if (mode === "raw") { + endpoint = "/api/generate/raw"; + payload.app_type = $("pyRawFormat").value; + + if (payload.app_type === "raw-cidr_json") { + const asJs = $("pyAsJs").value === "true"; + payload.as_js = asJs; + if (asJs) payload.js_var = $("pyJsVar").value || "geoipBlocklist"; + } + } else { + endpoint = "/api/generate"; + payload.app_type = $("pyAppType").value; + payload.app_variant = $("pyAppVariant").value; + } + + const script = `#!/usr/bin/env python3 +import json +import re +import requests + +BASE_URL = ${JSON.stringify(baseUrl)} +ENDPOINT = ${JSON.stringify(endpoint)} + +payload = ${JSON.stringify(payload, null, 4)} + +resp = requests.post(BASE_URL + ENDPOINT, json=payload, timeout=120) + +print("Status:", resp.status_code) +print("X-From-Cache:", resp.headers.get("X-From-Cache")) +print("X-Cache-Type:", resp.headers.get("X-Cache-Type")) +print("X-Generated-At:", resp.headers.get("X-Generated-At")) + +ct = (resp.headers.get("Content-Type") or "").lower() + +if resp.status_code >= 400: + # try show JSON error, else text + try: + print(json.dumps(resp.json(), indent=2)) + except Exception: + print(resp.text) + raise SystemExit(1) + +if "application/json" in ct: + print(json.dumps(resp.json(), indent=2)) +else: + filename = "output" + cd = resp.headers.get("Content-Disposition") or "" + m = re.search(r'filename="?([^"]+)"?', cd) + if m: + filename = m.group(1) + else: + # fallback extension + if "text/csv" in ct: + filename += ".csv" + elif "javascript" in ct: + filename += ".js" + elif "text/plain" in ct: + filename += ".txt" + else: + filename += ".bin" + + with open(filename, "wb") as f: + f.write(resp.content) + + print("Saved to:", filename) +`; + + $("pythonScriptOutput").textContent = script; +} + +async function copyPythonScript() { + const text = $("pythonScriptOutput").textContent || ""; + await navigator.clipboard.writeText(text); +} + +function bind() { + const topCopy = document.getElementById("btnCopyPyTop"); + if (topCopy) topCopy.addEventListener("click", copyPythonScript); + + $("pyMode").addEventListener("change", updateModeUI); + + $("pyAppType").addEventListener("change", updateVariantOptions); + $("pyRawFormat").addEventListener("change", updateRawJsFields); + $("pyAsJs").addEventListener("change", updateRawJsFields); + + $("btnGenPy").addEventListener("click", () => { + updateRawJsFields(); + buildPythonScript(); + }); + + $("btnCopyPy").addEventListener("click", copyPythonScript); + + updateModeUI(); + buildPythonScript(); +} + +document.addEventListener("DOMContentLoaded", () => { + setBaseUrl(); + bind(); +}); \ No newline at end of file diff --git a/templates/api.html b/templates/api.html index bdbc1fc..bb7612b 100644 --- a/templates/api.html +++ b/templates/api.html @@ -3,945 +3,648 @@ {% block title %}API Documentation - {{ app_name }}{% endblock %} {% block content %} -
RESTful API for programmatic access to geo-blocking configuration generation.
-
- /api/countries
- Get available countries
- Returns a list of all available countries with their ISO codes and flag emojis.
- -{
- "success": true,
- "countries": [
- {
- "code": "CN",
- "name": "China",
- "flag": "🇨🇳"
- }
- ]
-}
-
- /api/database/status
- Check database status
- Returns the current status of the MaxMind GeoIP database, including last update time and whether an update is needed.
- -{
- "success": true,
- "exists": true,
- "needs_update": false,
- "last_update": "2026-02-10T08:00:00",
- "file_size": 5242880,
- "auto_update": true
-}
-
- REST API for geo-blocking configuration generation.
- -/api/cache/redis/status
- Redis L1 cache
- Redis (L1: configs/networks)
- -{
- "success": true,
- "enabled": true,
- "health": {
- "connected": true,
- "memory_peak_mb": 474.25,
- "memory_used_mb": 241.38,
- "status": "healthy"
- },
- "stats": {
- "country_keys": 119,
- "config_keys": 0,
- "total_keys": 119,
- "total_size_mb": 240.4
- }
-}
-
-
- | Name | -Type | -Description | -
|---|---|---|
enabled |
- boolean | -Redis configured (REDISENABLED=false) | -
health.connected |
- boolean | -TCP connection OK | -
health.memory_peak_mb |
- float | -Redis peak memory usage | -
stats.country_keys |
- integer | -geobancountry* keys (119 networks cached) |
-
stats.config_keys |
- integer | -geoipconfig* + geobanconfig* (0 configs) |
-
"error": "Connection refused"ConnectionError: Error 111 connecting to localhost:6379
-
+ + Choose the endpoint based on your integration needs. +
+ +| Use Case | +Recommended Endpoint | +
|---|---|
| Programmatic CIDR integration (TXT / CSV / JSON / JS) | +POST /api/generate/raw |
+
| Download ready-to-use application config (nginx / apache / haproxy) | +POST /api/generate |
+
| Web UI preview (admin panel) | +POST /api/generate/preview |
+
/api/generate/preview is intended for internal web UI usage.
+ It returns JSON with a raw config string + metadata and is not recommended for external production integrations.
/api/cache/sqlite/status
- SQLite L2 cache
- SQLite L2 cache (`networks_cache.db`): country networks from MaxMind scans.
- -{
- "success": true,
- "exists": true,
- "file_size_mb": 1439.54,
- "total_countries": 123,
- "total_networks": 13728494,
- "top_countries": [
- {"code": "US", "networks": 5801506}
- ]
-}
-
- | Name | Type | Description |
|---|---|---|
exists | boolean | DB file exists |
file_size_mb | float | DB size on disk |
total_countries | integer | Countries with cached networks |
top_countries | array | Top 5 by network count |
nginx + map for production.
+ Prefer nginx variants geo or deny.
/api/cache/invalidate/<country_code>
- Invalidate country cache
- Clears Redis L1 cache for specific country: networks + all configs containing it. Forces fresh SQLite/MaxMind scan next time.
- -| Name | Type | Description |
|---|---|---|
country_code | string | ISO 3166-1 alpha-2 (e.g. CN, RU) |
{
- "success": true,
- "deleted": 5,
- "country": "CN",
- "details": {
- "country_cache": 1,
- "config_caches": 4
- }
-}
-
- geoban:country:CN - country networksgeoban:config:*[CN] - configs with CNEnter country code (e.g. CN):
-
-
-
curl -X POST /api/cache/invalidate/CN
- /api/database/update
- Update database manually
- Manually triggers a download and update of the MaxMind GeoIP database from configured sources.
- -{
- "success": true,
- "url": "https://github.com/...",
- "size": 5242880
-}
-
- /api/progress
- Get current generation progress
- Returns the current progress status of any active configuration generation process. Poll this endpoint to monitor long-running operations.
- -{
- "active": true,
- "message": "[1/3] CN: Scanning MaxMind: 234 networks found",
- "progress": 30,
- "total": 100
-}
-
- | Name | -Type | -Description | -
|---|---|---|
active |
- boolean | -Whether a generation process is currently active | -
message |
- string | -Current progress message with detailed status | -
progress |
- integer | -Current progress value (0-100) | -
total |
- integer | -Total progress value (always 100) | -
// Poll every 500ms during generation
-const pollProgress = setInterval(async () => {
- const response = await fetch('/api/progress');
- const data = await response.json();
-
- if (data.active) {
- console.log(`Progress: ${data.progress}% - ${data.message}`);
- } else {
- clearInterval(pollProgress);
- console.log('Generation complete!');
- }
-}, 500);
- /api/generate/preview
- Preview configuration (JSON response)
- Generates configuration and returns it as JSON (instead of file download). Perfect for previewing or integrating into other applications.
- -{
- "countries": ["CN", "RU"],
- "app_type": "nginx",
- "app_variant": "map",
- "aggregate": true,
- "use_cache": true
-}
-
- | Name | -Type | -Required | -Description | -
|---|---|---|---|
countries |
- array | -required | -List of ISO 3166-1 alpha-2 country codes | -
app_type |
- string | -required | -One of: nginx, apache, haproxy, raw-cidr |
-
app_variant |
- string | -required | -Configuration style (depends on app_type) | -
aggregate |
- boolean | -optional | -Aggregate IP networks to reduce count (default: true) | -
use_cache |
- boolean | -optional | -Use Redis cache if available (default: true). Set to false to force fresh data from SQLite/MaxMind |
-
{
- "success": true,
- "config": "# Nginx Map Module Configuration\n...",
- "stats": {
- "countries": 2,
- "total_networks": 4567,
- "per_country": {
- "CN": 2834,
- "RU": 1733
- }
- },
- "from_cache": true,
- "cache_type": "redis",
- "generated_at": "2026-02-16T10:30:00"
-}
-
- | Name | -Type | -Description | -
|---|---|---|
from_cache |
- boolean | -Whether the config was served from cache or freshly generated | -
cache_type |
- string | -redis (from Redis cache) or sqlite (from SQLite/fresh scan) |
-
generated_at |
- string | -ISO 8601 timestamp when the config was generated | -
With cache (default):
-curl -X POST /api/generate/preview \
- -H "Content-Type: application/json" \
- -d '{
- "countries": ["CN", "RU"],
- "app_type": "nginx",
- "app_variant": "map",
- "aggregate": true,
- "use_cache": true
- }' | jq .
-
- Force fresh data (bypass cache):
-curl -X POST /api/generate/preview \
- -H "Content-Type: application/json" \
- -d '{
- "countries": ["CN", "RU"],
- "app_type": "nginx",
- "app_variant": "map",
- "aggregate": true,
- "use_cache": false
- }' | jq .
- /api/generate/raw
- Generate raw blocklist (TXT/CSV/JSON/JS)
+ - Generates a raw blocklist without application-specific configuration. - Use this endpoint for programmatic integrations when you need structured output (JSON) or a JS-wrapped JSON payload. -
+ +/health
+ Service health check
+ Simple liveness probe.
-{
- "countries": ["CN", "RU"],
- "app_type": "raw-json",
- "aggregate": true,
- "use_cache": true,
- "as_js": false,
- "js_var": "geoipBlocklist"
+
+
+
+ Returns JSON.
+
+
+
+
+ /api/countries
+ Get available countries
+ Returns list of countries with ISO codes and flag emojis.
+ +{
+ "success": true,
+ "countries": [
+ {"code": "CN", "name": "China", "flag": "🇨🇳"}
+ ]
}
- | Name | -Type | -Required | -Description | -
|---|---|---|---|
countries |
- array | -required | -List of ISO 3166-1 alpha-2 country codes | -
app_type |
- string | -optional | -
- Output format selector. Examples:
- raw-json (JSON), raw-json + as_js=true (JS wrapper).
- |
-
aggregate |
- boolean | -optional | -Aggregate IP networks (default: true) | -
use_cache |
- boolean | -optional | -Use Redis cache if available (default: true) | -
- Only for raw-json.
- When true, wraps JSON into JavaScript and returns application/javascript as:
- const <js_var> = {...};
- |
- |||
js_var |
- string | -optional | -
- Variable name used by as_js wrapper (default: geoipBlocklist).
- |
-
| Mode | -Content-Type | -Body | -
|---|---|---|
raw-cidr_txt |
- text/plain |
- One CIDR per line | -
raw-cidr_csv |
- text/csv |
- CSV export | -
raw-cidr_json |
- application/json |
- JSON object with countries, networks, count, aggregated |
-
raw-cidr_json + as_js=true |
- application/javascript |
- const <js_var> = {...}; |
-
| Mode | -Content-Type | -Body | -
|---|---|---|
raw-cidr_txt |
- text/plain |
- One CIDR per line | -
raw-cidr_csv |
- text/csv |
- CSV export | -
raw-json |
- application/json |
- JSON object with generated_at, countries, networks, total_networks |
-
raw-json + as_js=true |
- application/javascript |
- const <js_var> = {...}; |
-
/api/database/status
+ Check MaxMind DB status
+ Database status including update need and timestamps.
-| Header | -Description | -
|---|---|
X-From-Cache |
- true or false - indicates if served from Redis |
-
X-Cache-Type |
- redis-full or computed cache type (e.g. hybrid) |
-
X-Generated-At |
- Timestamp when config was generated | -
{
+ "success": true,
+ "exists": true,
+ "needs_update": false,
+ "last_update": "2026-02-10T08:00:00",
+ "file_size": 5242880,
+ "auto_update": true
+}
-TXT (default):
-curl -X POST /api/generate/raw \
+
+ /api/database/update
+ Trigger MaxMind DB update
+ Manual database update (download + replace).
+ +{
+ "success": true,
+ "url": "https://github.com/...",
+ "size": 5242880
+}
+
+ /api/cache/redis/status
+ Redis L1 status
+ Redis health + key stats.
+ +/api/cache/sqlite/status
+ SQLite L2 status
+ SQLite cache file stats and top countries.
+ +/api/cache/invalidate/<country_code>
+ Invalidate a single country
+ Clears Redis networks for a country + related cached configs.
+ +curl -X POST /api/cache/invalidate/CN
+ /api/cache/flush
+ Flush Redis cache (all keys)
+ Flushes all Redis keys used by the application.
+ +curl -X POST /api/cache/flush
+ /api/progress
+ Generation progress
+ Poll this endpoint during generation.
+ +/api/stats/summary
+ Combined status summary
+ Aggregated status of MaxMind + SQLite + Redis.
+ +/api/generate/preview
+ UI preview (JSON wrapper)
+ config contains raw text.
+ {
+ "countries": ["SG"],
+ "app_type": "haproxy",
+ "app_variant": "map",
+ "aggregate": true,
+ "use_cache": true
+}
+
+ /api/generate/raw
+ Raw blocklist (TXT / CSV / JSON / JS)
+
+ Output format is selected by app_type.
+ Cache metadata is returned via response headers.
+
| app_type | Content-Type | Notes |
|---|---|---|
raw-cidr_txt | text/plain | default |
raw-cidr_csv | text/csv | CSV export |
raw-cidr_json | application/json | {countries, networks, count, aggregated} |
raw-cidr_json + as_js=true | application/javascript | const <js_var> = {...}; |
{
+ "countries": ["SG"],
+ "networks": ["1.2.3.0/24", "..."],
+ "count": 123,
+ "aggregated": true
+}
+
+ use_cache to false to force fresh scan.
+
+ JSON:
+curl -X POST /api/generate/raw \
-H "Content-Type: application/json" \
- -d '{
- "countries": ["CN", "RU"],
- "app_type": "raw-cidr_txt",
- "aggregate": true,
- "use_cache": true
- }' \
- -o blocklist.txt
-
-CSV:
-curl -X POST /api/generate/raw \
- -H "Content-Type: application/json" \
- -d '{
- "countries": ["CN", "RU"],
- "app_type": "raw-cidr_csv",
- "aggregate": true,
- "use_cache": true
- }' \
- -o blocklist.csv
-
-JSON:
-curl -X POST /api/generate/raw \
- -H "Content-Type: application/json" \
- -d '{
- "countries": ["CN", "RU"],
- "app_type": "raw-json",
- "aggregate": true,
- "use_cache": true
- }' \
+ -d '{"countries":["PL"],"app_type":"raw-cidr_json","aggregate":true,"use_cache":true}' \
-o blocklist.json
-JS-wrapped JSON:
-curl -X POST /api/generate/raw \
+ JS wrapped:
+ curl -X POST /api/generate/raw \
-H "Content-Type: application/json" \
- -d '{
- "countries": ["CN", "RU"],
- "app_type": "raw-json",
- "aggregate": true,
- "use_cache": true,
- "as_js": true,
- "js_var": "geoipBlocklist"
- }' \
+ -d '{"countries":["PL"],"app_type":"raw-cidr_json","as_js":true,"js_var":"geoipBlocklist"}' \
-o blocklist.js
- TXT (default):
+curl -X POST /api/generate/raw \
+ -H "Content-Type: application/json" \
+ -d '{"countries":["PL"]}' \
+ -o blocklist.txt
-
- /api/generate
- Generate application configuration
- Generates application-specific geo-blocking configuration for Nginx, Apache, or HAProxy and returns it as a downloadable file.
- -{
- "countries": ["CN", "RU"],
- "app_type": "nginx",
- "app_variant": "map",
- "aggregate": true,
- "use_cache": true
-}
-
- | Name | -Type | -Required | -Description | -
|---|---|---|---|
countries |
- array | -required | -List of ISO 3166-1 alpha-2 country codes | -
app_type |
- string | -required | -One of: nginx, apache, haproxy |
-
app_variant |
- string | -required | -Configuration style (depends on app_type) | -
aggregate |
- boolean | -optional | -Aggregate IP networks (default: true) | -
use_cache |
- boolean | -optional | -Use Redis cache if available (default: true) | -
geo, map, deny22 (Apache 2.2), 24 (Apache 2.4)acl, lua, mapReturns configuration file as text/plain with Content-Disposition header for download.
| Header | -Description | -
|---|---|
X-From-Cache |
- true or false |
-
X-Cache-Type |
- redis or sqlite |
-
X-Generated-At |
- ISO 8601 timestamp | -
use_cache: true - Check Redis first, return cached config if available (fast, <1s)use_cache: false - Bypass Redis, fetch from SQLite cache or scan MaxMind (slower, 5-30s)With cache (recommended for production):
-curl -X POST /api/generate \
- -H "Content-Type: application/json" \
- -d '{
- "countries": ["CN", "RU"],
- "app_type": "nginx",
- "app_variant": "map",
- "aggregate": true,
- "use_cache": true
- }' \
- -o geoblock.conf
-
-# Check if it was cached:
-curl -I -X POST /api/generate \
- -H "Content-Type: application/json" \
- -d '{"countries":["CN"],"app_type":"nginx","app_variant":"map"}' \
- | grep "X-From-Cache"
-
- Force fresh scan (for testing or updates):
-curl -X POST /api/generate \
- -H "Content-Type: application/json" \
- -d '{
- "countries": ["CN", "RU"],
- "app_type": "nginx",
- "app_variant": "map",
- "aggregate": true,
- "use_cache": false
- }' \
- -o geoblock_fresh.conf
+ CSV:
+curl -X POST /api/generate/raw \
+ -H "Content-Type: application/json" \
+ -d '{"countries":["PL"],"app_type":"raw-cidr_csv"}' \
+ -o blocklist.csv
+ /api/generate
+ Download configuration file
+ Returns a file download (text/plain). Use the download action below.
+ +nginx + map. Prefer nginx geo / deny.
+ | app_type | app_variant | Recommendation |
|---|---|---|
nginx | geo | recommended |
nginx | deny | recommended |
nginx | map | not recommended |
apache | 24 | recommended |
apache | 22 | legacy |
haproxy | map | recommended |
haproxy | acl | OK |
haproxy | lua | OK |
use_cache:false to force fresh scan. Prefer haproxy map or apache 24 / nginx geo.
+
+ HAProxy (map):
+curl -X POST /api/generate \
+ -H "Content-Type: application/json" \
+ -d '{"countries":["SG"],"app_type":"haproxy","app_variant":"map","aggregate":true,"use_cache":true}' \
+ -o geoblock_haproxy_map.cfg
+
+ Apache 2.4:
+curl -X POST /api/generate \
+ -H "Content-Type: application/json" \
+ -d '{"countries":["SG"],"app_type":"apache","app_variant":"24","aggregate":true,"use_cache":true}' \
+ -o geoblock_apache24.conf
+
+ Nginx (geo):
+curl -X POST /api/generate \
+ -H "Content-Type: application/json" \
+ -d '{"countries":["SG"],"app_type":"nginx","app_variant":"geo","aggregate":true,"use_cache":true}' \
+ -o geoblock_nginx_geo.conf