api docs, generator

This commit is contained in:
Mateusz Gruszczyński
2026-03-03 10:03:34 +01:00
parent b3a16303d2
commit 721ad44960
9 changed files with 1590 additions and 1004 deletions

102
app.py
View File

@@ -3,16 +3,15 @@ GeoIP Ban Generator - Web Application
""" """
from flask import Flask, render_template, request, Response, jsonify from flask import Flask, render_template, request, Response, jsonify
import hashlib from geoip_handler import GeoIPHandler
import os
import sqlite3
from pathlib import Path from pathlib import Path
from functools import wraps from functools import wraps
from datetime import datetime from datetime import datetime
import config
from api import api from api import api
from geoip_handler import GeoIPHandler import hashlib
import os
import sqlite3
import config
app = Flask(__name__, app = Flask(__name__,
static_folder=str(config.STATIC_DIR), static_folder=str(config.STATIC_DIR),
@@ -23,6 +22,18 @@ app.register_blueprint(api)
handler = GeoIPHandler() handler = GeoIPHandler()
CACHEABLE_PAGES = {
"/api-docs",
"/generator",
}
NO_CACHE_PREFIXES = (
"/api/",
)
STATIC_PREFIX = "/static/"
redis_cache = None redis_cache = None
if config.REDIS_ENABLED: if config.REDIS_ENABLED:
try: try:
@@ -105,6 +116,54 @@ def inject_globals():
'redis_connected': redis_cache.health_check()['connected'] if redis_cache else False, '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('/') @app.route('/')
def index(): def index():
"""Main page""" """Main page"""
@@ -118,6 +177,11 @@ def api_docs():
"""API documentation page""" """API documentation page"""
return render_template('api.html') return render_template('api.html')
@app.route("/generator")
def generator():
"""Script gwnerator"""
return render_template("generator.html")
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(): def favicon():
return '', 204 return '', 204
@@ -218,19 +282,27 @@ def cache_control(max_age: int = None):
def add_headers(response): def add_headers(response):
"""Add cache control headers based on request path""" """Add cache control headers based on request path"""
if request.path == '/' or request.path.startswith('/api/'): path = request.path
response.headers['Cache-Control'] = 'no-cache, no-store' if path.startswith(STATIC_PREFIX):
if "Content-Disposition" in response.headers:
del response.headers["Content-Disposition"]
elif request.path.startswith('/static/'):
if 'Content-Disposition' in response.headers:
del response.headers['Content-Disposition']
if config.ENABLE_CACHE_BUSTING: 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: else:
response.headers['Cache-Control'] = f'public, max-age={config.CACHE_TTL_SECONDS}' response.headers["Cache-Control"] = f"public, max-age={config.CACHE_TTL_SECONDS}"
elif request.path == '/api-docs': return response
response.headers['Cache-Control'] = 'public, max-age=300'
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 return response

View File

@@ -1,13 +1,13 @@
html {
scroll-behavior: smooth;
}
body { body {
background-color: #f5f5f5; background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
padding-bottom: 2rem; padding-bottom: 2rem;
} }
html {
scroll-behavior: smooth;
}
.card { .card {
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -407,6 +407,115 @@ html {
color: #e06c75 !important; 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 { @keyframes fadeIn {
from { from {
opacity: 0; 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); }
}

View File

@@ -1,75 +1,342 @@
const baseUrl = window.location.origin; const baseUrl = window.location.origin;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', () => {
document.getElementById('baseUrl').textContent = baseUrl; const baseEl = document.getElementById('baseUrl');
document.querySelectorAll('[id^="curlUrl"]').forEach(element => { if (baseEl) baseEl.textContent = baseUrl;
element.textContent = baseUrl;
document.querySelectorAll('[id^="curlUrl"]').forEach((el) => {
el.textContent = baseUrl;
}); });
}); });
function toggleEndpoint(id) { function toggleEndpoint(id) {
const element = document.getElementById(id); const el = document.getElementById(id);
const bsCollapse = new bootstrap.Collapse(element, { if (!el) return;
toggle: true 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') { function tryPath(path, method = 'GET') {
const url = baseUrl + '/api/' + endpoint; const normalized = String(path || '').trim();
const responseId = 'response-' + endpoint.replace(/\//g, '-'); const key = normalized.replace(/[^\w-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
const responseDiv = document.getElementById(responseId); const responseId = 'response-' + (key || 'path');
const responseBody = document.getElementById(responseId + '-body'); const { body: out } = showResponse(responseId);
responseDiv.style.display = 'block'; const url = baseUrl + normalized;
responseBody.textContent = 'Loading...'; fetch(url, { method })
.then(async (resp) => {
const parsed = await readBodyAuto(resp);
const options = { const meta = [
method: method, `HTTP ${resp.status} ${resp.statusText}`,
headers: { cacheHeaderSummary(resp.headers),
'Content-Type': 'application/json' '\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}`);
} }
};
fetch(url, options) const pretty = parsed.kind === 'json'
.then(response => response.json()) ? JSON.stringify(parsed.data, null, 2)
.then(data => { : String(parsed.data ?? '');
responseBody.textContent = JSON.stringify(data, null, 2);
out.textContent = meta + pretty;
}) })
.catch(error => { .catch((err) => {
responseBody.textContent = 'Error: ' + error.message; 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 { body: out } = showResponse(responseId);
out.textContent = 'Error: ' + e.message;
}
}
function tryInvalidateCountry() { function tryInvalidateCountry() {
const countryInput = document.getElementById('invalidateCountry'); const countryInput = document.getElementById('invalidateCountry');
const country = countryInput.value.trim().toUpperCase(); const country = (countryInput?.value || '').trim().toUpperCase();
const { body: out } = showResponse('response-cache-invalidate');
if (!country || country.length !== 2) { if (!country || country.length !== 2) {
alert('Please enter a valid 2-letter country code (e.g., CN, RU, US)'); out.textContent = 'Error: Please enter a valid 2-letter country code (e.g., CN, RU, US).';
return; return;
} }
const url = baseUrl + '/api/cache/invalidate/' + country; fetch(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', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json' body: '{}'
}
}) })
.then(response => response.json()) .then(async (resp) => {
.then(data => { const parsed = await readBodyAuto(resp);
responseBody.textContent = JSON.stringify(data, null, 2); if (!resp.ok) {
if (data.success) { 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 = ''; countryInput.value = '';
} }
}) })
.catch(error => { .catch((err) => {
responseBody.textContent = 'Error: ' + error.message; 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;
}); });
} }

25
static/js/base.js Normal file
View File

@@ -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");
});
});

194
static/js/generator.js Normal file
View File

@@ -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();
});

File diff suppressed because it is too large Load Diff

View File

@@ -19,9 +19,26 @@
{{ app_name }} {{ app_name }}
{% endif %} {% endif %}
</a> </a>
<div> <div class="d-flex align-items-center">
<a href="/" class="btn btn-sm btn-outline-secondary me-2">Home</a>
<a href="/api-docs" class="btn btn-sm btn-outline-primary">API Docs</a> <!-- API status -->
<div class="me-3 d-flex align-items-center">
<span id="apiStatusDot" class="status-dot bg-secondary"></span>
<small id="apiStatusText" class="ms-2 text-muted">API</small>
</div>
<a href="/" class="btn btn-sm btn-outline-secondary me-2">
<i class="fas fa-home me-1"></i>Home
</a>
<a href="/api-docs" class="btn btn-sm btn-outline-primary me-2">
<i class="fas fa-book me-1"></i>API Docs
</a>
<a href="/generator" class="btn btn-sm btn-outline-dark">
<i class="fas fa-code me-1"></i>Script Generator
</a>
</div> </div>
</div> </div>
</nav> </nav>
@@ -42,6 +59,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/app.js') }}?v={{ js_hash }}"></script> <script src="{{ url_for('static', filename='js/app.js') }}?v={{ js_hash }}"></script>
<script src="{{ url_for('static', filename='js/base.js') }}?v={{ js_hash }}"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

62
templates/error.html Normal file
View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}{{ status }} - {{ title }}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row">
<div class="col-lg-8 mx-auto text-center">
<div class="mb-4">
<div class="display-4 fw-bold text-danger">{{ status }}</div>
<h3 class="mb-2">{{ title }}</h3>
<p class="text-muted mb-3">{{ message }}</p>
<div class="alert alert-light border text-start">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<div class="small text-muted">Path</div>
<code>{{ path }}</code>
</div>
<div>
<div class="small text-muted">Hint</div>
{% if status == 405 %}
<span class="badge bg-warning text-dark">Check HTTP method</span>
{% elif status == 400 %}
<span class="badge bg-warning text-dark">Check request body</span>
{% elif status == 401 %}
<span class="badge bg-warning text-dark">Auth required</span>
{% elif status == 403 %}
<span class="badge bg-warning text-dark">Permission denied</span>
{% else %}
<span class="badge bg-secondary">Check URL</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-center gap-2 flex-wrap">
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-1"></i>Home
</a>
<a href="/api/docs" class="btn btn-outline-secondary">
<i class="fas fa-book me-1"></i>API Docs
</a>
<a href="/generator" class="btn btn-outline-dark">
<i class="fas fa-code me-1"></i>Script Generator
</a>
</div>
<div class="mt-4 small text-muted">
{% if path.startswith('/api/') %}
API endpoints return JSON for programmatic clients.
{% else %}
If you expected an API response, use <code>/api/...</code>.
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

144
templates/generator.html Normal file
View File

@@ -0,0 +1,144 @@
{% extends "base.html" %}
{% block title %}Script Generator - {{ app_name }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-lg-10 mx-auto">
<div class="mb-4">
<h2>Script Generator</h2>
<p class="text-muted mb-2">Generate ready-to-use integration scripts for this API.</p>
<div class="alert alert-info">
<strong>Base URL:</strong> <code id="baseUrl"></code>
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-dark text-white">
<strong><i class="fab fa-python me-2"></i>Python Generator</strong>
<span class="ms-2 small text-white-50">raw / generate</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Mode</label>
<select id="pyMode" class="form-select form-select-sm">
<option value="raw">Raw blocklist (TXT / CSV / JSON / JS)</option>
<option value="generate">App config file (download)</option>
</select>
<div class="form-text">Preview endpoint is UI-only — not recommended for external automation.</div>
</div>
<div class="col-md-4">
<label class="form-label">Countries (comma separated)</label>
<input type="text" id="pyCountries" class="form-control form-control-sm" value="PL">
<div class="form-text">Example: <code>PL</code> or <code>CN,RU</code></div>
</div>
<div class="col-md-2">
<label class="form-label">Aggregate</label>
<select id="pyAggregate" class="form-select form-select-sm">
<option value="true">true</option>
<option value="false">false</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">Use cache</label>
<select id="pyCache" class="form-select form-select-sm">
<option value="true">true</option>
<option value="false">false</option>
</select>
</div>
<!-- RAW options -->
<div class="col-md-4" id="pyRawFormatBox">
<label class="form-label">Raw format (app_type)</label>
<select id="pyRawFormat" class="form-select form-select-sm">
<option value="raw-cidr_txt">raw-cidr_txt (TXT)</option>
<option value="raw-cidr_csv">raw-cidr_csv (CSV)</option>
<option value="raw-cidr_json">raw-cidr_json (JSON)</option>
</select>
<div class="form-text">
JS wrapper works only with <code>raw-cidr_json</code>.
</div>
</div>
<div class="col-md-2" id="pyAsJsBox">
<label class="form-label">as_js</label>
<select id="pyAsJs" class="form-select form-select-sm">
<option value="false">false</option>
<option value="true">true</option>
</select>
</div>
<div class="col-md-3" id="pyJsVarBox">
<label class="form-label">js_var</label>
<input type="text" id="pyJsVar" class="form-control form-control-sm" value="geoipBlocklist">
</div>
<!-- GENERATE options -->
<div class="col-md-4" id="pyAppTypeBox" style="display:none;">
<label class="form-label">app_type</label>
<select id="pyAppType" class="form-select form-select-sm">
<option value="haproxy">haproxy</option>
<option value="apache">apache</option>
<option value="nginx">nginx</option>
</select>
<div class="form-text">
Avoid <code>nginx</code> + <code>map</code> for production.
</div>
</div>
<div class="col-md-4" id="pyAppVariantBox" style="display:none;">
<label class="form-label">app_variant</label>
<select id="pyAppVariant" class="form-select form-select-sm"></select>
<div class="form-text" id="variantHint"></div>
</div>
<div class="col-12 mt-2">
<button class="btn btn-primary btn-sm" id="btnGenPy">
<i class="fas fa-code me-1"></i>Generate
</button>
<button class="btn btn-outline-secondary btn-sm ms-2" id="btnCopyPy">
<i class="fas fa-copy me-1"></i>Copy
</button>
</div>
</div>
<hr>
<div class="code-wrap mt-2">
<div class="code-wrap-header">
<span><i class="fab fa-python me-2"></i>generated.py</span>
<button class="btn btn-sm btn-outline-light" type="button" id="btnCopyPyTop">
<i class="fas fa-copy me-1"></i>Copy
</button>
</div>
<pre class="code-wrap-body"><code id="pythonScriptOutput"></code></pre>
</div>
</div>
</div>
<div class="alert alert-secondary">
<strong>Tip:</strong> If you need structured output for integrations, use <code>/api/generate/raw</code>.
For ready-to-use app configs, use <code>/api/generate</code>.
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/generator.js') }}?v={{ js_hash }}"></script>
{% endblock %}