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
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
@@ -218,19 +282,27 @@ def cache_control(max_age: int = None):
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'
path = request.path
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:
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}'
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'
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

View File

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

View File

@@ -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 tryEndpoint(endpoint, method = 'GET') {
const url = baseUrl + '/api/' + endpoint;
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 responseDiv = document.getElementById(responseId);
const responseBody = document.getElementById(responseId + '-body');
const { body: out } = showResponse(responseId);
responseDiv.style.display = 'block';
responseBody.textContent = 'Loading...';
const url = baseUrl + '/api/' + endpoint;
const opts = { method, headers: {} };
const options = {
method: method,
headers: {
'Content-Type': 'application/json'
if (method !== 'GET' && method !== 'HEAD') {
opts.headers['Content-Type'] = 'application/json';
opts.body = body == null ? '{}' : (typeof body === 'string' ? body : JSON.stringify(body));
}
};
fetch(url, options)
.then(response => response.json())
.then(data => {
responseBody.textContent = JSON.stringify(data, null, 2);
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(error => {
responseBody.textContent = 'Error: ' + error.message;
.catch((err) => {
out.textContent = 'Error: ' + err.message;
});
}
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 { body: out } = showResponse(responseId);
out.textContent = 'Error: ' + e.message;
}
}
function tryInvalidateCountry() {
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) {
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;
}
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, {
fetch(baseUrl + '/api/cache/invalidate/' + country, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
headers: { 'Content-Type': 'application/json' },
body: '{}'
})
.then(response => response.json())
.then(data => {
responseBody.textContent = JSON.stringify(data, null, 2);
if (data.success) {
.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 = '';
}
})
.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;
});
}

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 }}
{% endif %}
</a>
<div>
<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>
<div class="d-flex align-items-center">
<!-- 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>
</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="{{ 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 %}
</body>
</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 %}