Compare commits
23 Commits
eac2002f56
...
master
Author | SHA1 | Date | |
---|---|---|---|
![]() |
46d986ce80 | ||
ec07782444 | |||
69adc272ff | |||
68e45a7477 | |||
da4f9907ae | |||
fec9d9c5dd | |||
6c58d7f524 | |||
ae98702806 | |||
163df6233b | |||
78cf869354 | |||
79b7ea77f4 | |||
6347a96db8 | |||
94d5130470 | |||
aaea5cdeef | |||
a2febd82c1 | |||
e065f5892d | |||
1afaea2d00 | |||
809b1168e1 | |||
![]() |
259d716e0f | ||
![]() |
43c9a7006c | ||
![]() |
0419ca1d9d | ||
![]() |
7b6dd96d68 | ||
![]() |
0d35b3e654 |
105
app.py
105
app.py
@@ -6,6 +6,7 @@ import time
|
||||
import json
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import hmac, ipaddress
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse, quote, unquote, urljoin
|
||||
from functools import wraps
|
||||
@@ -73,20 +74,7 @@ def track_request_data():
|
||||
|
||||
|
||||
@app.after_request
|
||||
def add_cache_headers(response):
|
||||
if request.path.startswith("/static/"):
|
||||
|
||||
response.headers.pop("Content-Disposition", None)
|
||||
|
||||
if request.path.endswith((".css", ".js")):
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
else:
|
||||
response.headers["Cache-Control"] = "public, max-age=86400"
|
||||
return response
|
||||
|
||||
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
def finalize_response(response):
|
||||
elapsed = time.perf_counter() - g.start_time
|
||||
redis_client.incrbyfloat("stats:processing_time_total", elapsed)
|
||||
redis_client.incr("stats:processing_time_count")
|
||||
@@ -102,6 +90,25 @@ def after_request(response):
|
||||
redis_client.set("stats:processing_time_max", elapsed)
|
||||
except Exception:
|
||||
redis_client.set("stats:processing_time_max", elapsed)
|
||||
|
||||
path = request.path or "/"
|
||||
|
||||
if response.status_code >= 400:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
if path.startswith("/static/"):
|
||||
response.headers.pop("Content-Disposition", None)
|
||||
if path.endswith((".css", ".js")):
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
else:
|
||||
response.headers["Cache-Control"] = "public, max-age=86400"
|
||||
return response
|
||||
|
||||
if path == "/":
|
||||
response.headers["Cache-Control"] = "private, no-store"
|
||||
return response
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -269,16 +276,34 @@ def track_url_request(url):
|
||||
|
||||
def add_recent_link(url, target_ip):
|
||||
ts = datetime.now().isoformat()
|
||||
link_data = f"{ts}|{url}|{target_ip}"
|
||||
new_item = f"{ts}|{url}|{target_ip}"
|
||||
key = "recent_links"
|
||||
|
||||
current = redis_client.lrange(key, 0, -1)
|
||||
filtered = []
|
||||
for raw in current:
|
||||
try:
|
||||
s = raw.decode()
|
||||
parts = s.split("|")
|
||||
if len(parts) >= 3 and parts[1] == url and parts[2] == target_ip:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
filtered.append(raw)
|
||||
|
||||
with redis_client.pipeline() as pipe:
|
||||
pipe.lpush("recent_links", link_data)
|
||||
pipe.ltrim("recent_links", 0, 9)
|
||||
pipe.delete(key)
|
||||
pipe.lpush(key, new_item)
|
||||
if filtered:
|
||||
pipe.rpush(key, *filtered[:99])
|
||||
pipe.ltrim(key, 0, 99)
|
||||
pipe.execute()
|
||||
|
||||
redis_client.incr("stats:recent_links_added")
|
||||
|
||||
|
||||
def get_recent_links():
|
||||
links = redis_client.lrange("recent_links", 0, 9)
|
||||
links = redis_client.lrange("recent_links", 0, 99)
|
||||
out = []
|
||||
for link in links:
|
||||
parts = link.decode().split("|")
|
||||
@@ -319,6 +344,14 @@ def add_recent_convert():
|
||||
redis_client.ltrim("recent_converts", 0, 99)
|
||||
|
||||
|
||||
def validate_ip(value: str) -> str:
|
||||
v = (value or "").strip()
|
||||
try:
|
||||
return str(ipaddress.ip_address(v))
|
||||
except Exception:
|
||||
raise ValueError("Invalid IP address")
|
||||
|
||||
|
||||
@app.route("/favicon.ico", methods=["GET"])
|
||||
def favicon():
|
||||
return Response(status=204)
|
||||
@@ -326,27 +359,11 @@ def favicon():
|
||||
|
||||
@app.route("/", methods=["GET"])
|
||||
def index():
|
||||
generated_link = None
|
||||
recent_links = get_recent_links()
|
||||
url_param = request.args.get("url", config.DEFAULT_SOURCE_URL)
|
||||
target_ip = request.args.get("ip", "127.0.0.1")
|
||||
|
||||
if url_param:
|
||||
try:
|
||||
normalized = validate_and_normalize_url(unquote(url_param))
|
||||
encoded = quote(normalized, safe="")
|
||||
generated_link = urljoin(
|
||||
request.host_url, f"convert?url={encoded}&ip={target_ip}"
|
||||
)
|
||||
add_recent_link(normalized, target_ip)
|
||||
recent_links = get_recent_links()
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error processing URL: {str(e)}")
|
||||
|
||||
try:
|
||||
return render_template(
|
||||
"form.html",
|
||||
generated_link=generated_link,
|
||||
recent_links=recent_links,
|
||||
client_ip=get_client_ip(),
|
||||
user_agent=request.headers.get("User-Agent", "Unknown"),
|
||||
@@ -354,7 +371,6 @@ def index():
|
||||
except Exception:
|
||||
return jsonify(
|
||||
{
|
||||
"generated_link": generated_link,
|
||||
"recent_links": recent_links,
|
||||
"client_ip": get_client_ip(),
|
||||
"user_agent": request.headers.get("User-Agent", "Unknown"),
|
||||
@@ -365,7 +381,7 @@ def index():
|
||||
@app.route("/convert")
|
||||
@limiter.limit(config.RATE_LIMIT_CONVERT)
|
||||
def convert():
|
||||
import hmac, ipaddress
|
||||
|
||||
|
||||
def is_private_client_ip() -> bool:
|
||||
ip = get_client_ip()
|
||||
@@ -414,8 +430,8 @@ def convert():
|
||||
return resp
|
||||
|
||||
try:
|
||||
redis_client.incr("stats:convert_requests")
|
||||
add_recent_convert()
|
||||
redis_client.incr("stats:convert_requests")
|
||||
if debug_mode:
|
||||
d("Start /convert w trybie debug")
|
||||
|
||||
@@ -437,7 +453,15 @@ def convert():
|
||||
redis_client.incr("stats:errors_400")
|
||||
abort(400)
|
||||
|
||||
target_ip = request.args.get("ip", "127.0.0.1")
|
||||
try:
|
||||
target_ip = validate_ip(request.args.get("ip", "127.0.0.1"))
|
||||
except ValueError:
|
||||
if debug_mode:
|
||||
d("Bad parametr ?ip")
|
||||
return debug_response(status=400)
|
||||
redis_client.incr("stats:errors_400")
|
||||
abort(400, description="Invalid IP")
|
||||
|
||||
if debug_mode:
|
||||
d(f"URL (encoded): {encoded_url}")
|
||||
d(f"URL (decoded): {decoded_url}")
|
||||
@@ -485,11 +509,13 @@ def convert():
|
||||
if debug_mode:
|
||||
d("Upstream 304 – zwracam 304")
|
||||
r.close()
|
||||
add_recent_link(normalized_url, target_ip)
|
||||
return debug_response(status=304)
|
||||
resp = Response(status=304)
|
||||
resp.headers.update(cache_headers(etag, r.headers.get("Last-Modified")))
|
||||
resp.direct_passthrough = True
|
||||
r.close()
|
||||
add_recent_link(normalized_url, target_ip)
|
||||
return resp
|
||||
|
||||
up_etag = r.headers.get("ETag")
|
||||
@@ -536,6 +562,7 @@ def convert():
|
||||
resp.headers.update(cache_headers(etag, up_lm))
|
||||
resp.direct_passthrough = True
|
||||
redis_client.incr("stats:conversions_success")
|
||||
add_recent_link(normalized_url, target_ip)
|
||||
return resp
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
@@ -561,7 +588,7 @@ def convert_head():
|
||||
abort(400)
|
||||
decoded_url = unquote(encoded_url)
|
||||
validate_and_normalize_url(decoded_url)
|
||||
target_ip = request.args.get("ip", "127.0.0.1")
|
||||
target_ip = validate_ip(request.args.get("ip", "127.0.0.1"))
|
||||
etag = build_etag(None, None, target_ip)
|
||||
resp = Response(status=200)
|
||||
resp.headers.update(cache_headers(etag, None))
|
||||
|
@@ -41,8 +41,8 @@ body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
background:
|
||||
radial-gradient(1200px 600px at 10% -10%, rgba(91, 157, 255, .08), transparent 60%),
|
||||
radial-gradient(900px 500px at 110% 0%, rgba(123, 212, 255, .10), transparent 60%),
|
||||
/*radial-gradient(1200px 600px at 10% -10%, rgba(91, 157, 255, .08), transparent 60%),
|
||||
radial-gradient(900px 500px at 110% 0%, rgba(123, 212, 255, .10), transparent 60%),*/
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ body {
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(180deg, var(--card), color-mix(in srgb, var(--card) 80%, #000 20%));
|
||||
background: linear-gradient(180deg, var(--card), color-mix(in srgb, var(--card) 90%, #000 10%));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
@@ -194,7 +194,8 @@ select:focus {
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Result */
|
||||
@@ -324,14 +325,20 @@ select:focus {
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 20px color-mix(in srgb, var(--brand) 35%, transparent);
|
||||
transition: transform .04s ease, filter .15s ease, box-shadow .15s ease;
|
||||
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
filter: brightness(1.05)
|
||||
transition: transform 0.15s ease;
|
||||
display: inline-block;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px)
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.btn.outline {
|
||||
@@ -357,6 +364,30 @@ select:focus {
|
||||
border-radius: 14px
|
||||
}
|
||||
|
||||
a.btn:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
a.btn[aria-disabled="true"] {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
filter: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
a.btn[aria-disabled="true"]:hover {
|
||||
filter: none;
|
||||
transform: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
#toast {
|
||||
position: fixed;
|
||||
@@ -661,6 +692,16 @@ select:focus {
|
||||
background: var(--bg-elev)
|
||||
}
|
||||
|
||||
.result-box .hint {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: .9rem;
|
||||
color: var(--muted);
|
||||
opacity: 0.5;
|
||||
opacity: 1.1;
|
||||
text-shadow: 0 0 4px rgba(123, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
#error-dump {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
@@ -673,6 +714,7 @@ select:focus {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width:720px) {
|
||||
.error-card {
|
||||
padding: 12px
|
||||
@@ -686,3 +728,17 @@ select:focus {
|
||||
max-height: 300px
|
||||
}
|
||||
}
|
||||
|
||||
.result-box {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-elev);
|
||||
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--brand) 50%, transparent 50%);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.result-box:hover {
|
||||
box-shadow: 0 0 12px 4px color-mix(in srgb, var(--brand) 70%, transparent 30%);
|
||||
}
|
@@ -1,25 +1,9 @@
|
||||
(function () {
|
||||
const t = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
// prosty "try again"
|
||||
document.querySelector('[data-action="try-again"]')?.addEventListener('click', () => {
|
||||
location.reload();
|
||||
});
|
||||
// kopiowanie logs
|
||||
document.querySelector('[data-action="copy-text"]')?.addEventListener('click', (e) => {
|
||||
const sel = e.currentTarget.getAttribute('data-target');
|
||||
const el = sel && document.querySelector(sel);
|
||||
if (!el) return;
|
||||
const txt = el.textContent || '';
|
||||
navigator.clipboard.writeText(txt).then(() => {
|
||||
const toast = document.getElementById('toast');
|
||||
if (toast) {
|
||||
toast.textContent = 'Copied!';
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 1200);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
|
@@ -2,7 +2,14 @@
|
||||
|
||||
const $ = (q, c = document) => c.querySelector(q);
|
||||
const $$ = (q, c = document) => Array.from(c.querySelectorAll(q));
|
||||
const setTheme = (t) => { document.documentElement.setAttribute('data-theme', t); try { localStorage.setItem('theme', t) } catch { } };
|
||||
|
||||
// --- theme ---
|
||||
const setTheme = (t) => {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
try { localStorage.setItem('theme', t) } catch { }
|
||||
};
|
||||
window.setTheme = setTheme;
|
||||
|
||||
const toast = (msg) => {
|
||||
const el = $('#toast'); if (!el) return;
|
||||
el.textContent = msg; el.classList.add('show');
|
||||
@@ -10,6 +17,52 @@
|
||||
};
|
||||
const host = () => `${location.protocol}//${location.host}`;
|
||||
|
||||
// --- IP validators ---
|
||||
function isValidIPv4(ip) {
|
||||
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(ip)) return false;
|
||||
return ip.split('.').every(oct => {
|
||||
if (oct.length > 1 && oct[0] === '0') return oct === '0';
|
||||
const n = Number(oct);
|
||||
return n >= 0 && n <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
function isValidIPv6(ip) {
|
||||
let work = ip;
|
||||
const idx = work.lastIndexOf(':');
|
||||
if (idx !== -1) {
|
||||
const tail = work.slice(idx + 1);
|
||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(tail)) {
|
||||
if (!isValidIPv4(tail)) return false;
|
||||
work = work.slice(0, idx) + ':0:0';
|
||||
}
|
||||
}
|
||||
if (work.split('::').length > 2) return false;
|
||||
const hasCompress = work.includes('::');
|
||||
const parts = work.split(':').filter(Boolean);
|
||||
if ((!hasCompress && parts.length !== 8) || (hasCompress && parts.length > 7)) return false;
|
||||
return parts.every(g => /^[0-9a-fA-F]{1,4}$/.test(g));
|
||||
}
|
||||
|
||||
function isValidIP(ip) {
|
||||
const v = (ip || '').trim();
|
||||
return isValidIPv4(v) || isValidIPv6(v);
|
||||
}
|
||||
|
||||
// --- URL helpers ---
|
||||
function normalizeUrlMaybe(v) {
|
||||
const raw = (v || '').trim();
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const test = raw.includes('://') ? raw : `https://${raw}`;
|
||||
const u = new URL(test);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('scheme');
|
||||
return u.toString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildLink(url, ip) {
|
||||
if (!url || !ip) return '';
|
||||
try {
|
||||
@@ -33,33 +86,85 @@
|
||||
const ipPreset = $('#ip-preset');
|
||||
const out = $('#generated-link');
|
||||
const openBtn = $('#open-link');
|
||||
const copyBtn = $('#copy-btn');
|
||||
|
||||
function showError(input, msg) {
|
||||
const id = input.getAttribute('id');
|
||||
const box = document.querySelector(`.error[data-error-for="${id}"]`);
|
||||
if (box) box.textContent = msg || '';
|
||||
input.setAttribute('aria-invalid', msg ? 'true' : 'false');
|
||||
}
|
||||
|
||||
|
||||
function updatePreview() {
|
||||
const link = buildLink(urlInput.value.trim(), ipInput.value.trim());
|
||||
out.value = link || '';
|
||||
if (link) {
|
||||
const rawUrl = (urlInput?.value || '').trim();
|
||||
const ip = (ipInput?.value || '').trim();
|
||||
|
||||
if (!ip || !isValidIP(ip)) {
|
||||
if (out) out.value = '';
|
||||
if (openBtn) {
|
||||
openBtn.setAttribute('href', '#');
|
||||
openBtn.setAttribute('aria-disabled', 'true');
|
||||
openBtn.setAttribute('disabled', 'true');
|
||||
}
|
||||
if (copyBtn) copyBtn.setAttribute('disabled', 'true');
|
||||
$('.result-box')?.setAttribute('data-state', 'empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeUrlMaybe(rawUrl);
|
||||
const guessed = rawUrl ? (rawUrl.includes('://') ? rawUrl : `https://${rawUrl}`) : '';
|
||||
const previewUrl = normalized || guessed;
|
||||
|
||||
if (!previewUrl) {
|
||||
if (out) out.value = '';
|
||||
if (openBtn) {
|
||||
openBtn.setAttribute('href', '#');
|
||||
openBtn.setAttribute('aria-disabled', 'true');
|
||||
openBtn.setAttribute('disabled', 'true');
|
||||
}
|
||||
if (copyBtn) copyBtn.setAttribute('disabled', 'true');
|
||||
$('.result-box')?.setAttribute('data-state', 'empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const link = buildLink(previewUrl, ip);
|
||||
if (out) out.value = link;
|
||||
|
||||
const ok = !!normalized;
|
||||
if (openBtn) {
|
||||
if (ok) {
|
||||
openBtn.setAttribute('href', link);
|
||||
openBtn.setAttribute('aria-disabled', 'false');
|
||||
openBtn.removeAttribute('disabled');
|
||||
} else {
|
||||
openBtn.setAttribute('href', '#');
|
||||
openBtn.setAttribute('aria-disabled', 'true');
|
||||
openBtn.setAttribute('disabled', 'true');
|
||||
}
|
||||
$('.result-box')?.setAttribute('data-state', link ? 'ready' : 'empty');
|
||||
}
|
||||
if (copyBtn) copyBtn.toggleAttribute('disabled', !ok);
|
||||
$('.result-box')?.setAttribute('data-state', ok ? 'ready' : 'empty');
|
||||
}
|
||||
|
||||
// live update
|
||||
['input', 'change', 'blur'].forEach(evt => {
|
||||
urlInput?.addEventListener(evt, updatePreview);
|
||||
ipInput?.addEventListener(evt, updatePreview);
|
||||
});
|
||||
|
||||
// presets
|
||||
ipPreset?.addEventListener('change', () => {
|
||||
const v = ipPreset.value;
|
||||
if (!v) return;
|
||||
if (v !== 'custom') ipInput.value = v;
|
||||
ipInput.focus();
|
||||
const ok = isValidIP(ipInput.value.trim());
|
||||
showError(ipInput, ok ? '' : 'Invalid IP address');
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// event delegation
|
||||
document.addEventListener('click', (e) => {
|
||||
let t = e.target;
|
||||
|
||||
@@ -74,7 +179,6 @@
|
||||
btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 1200);
|
||||
toast('Link copied');
|
||||
}).catch(() => {
|
||||
// Fallback
|
||||
const range = document.createRange(); range.selectNodeContents(el);
|
||||
const selObj = getSelection(); selObj.removeAllRanges(); selObj.addRange(range);
|
||||
try { document.execCommand('copy'); toast('Link copied'); } catch { }
|
||||
@@ -85,15 +189,19 @@
|
||||
if (t.closest('[data-action="copy-text"]')) {
|
||||
e.preventDefault();
|
||||
const btn = t.closest('[data-action="copy-text"]');
|
||||
const text = btn.getAttribute('data-text') || '';
|
||||
let text = btn.getAttribute('data-text') || '';
|
||||
if (!text) return;
|
||||
if (text.startsWith('/')) text = host() + text;
|
||||
navigator.clipboard?.writeText(text).then(() => toast('Copied'));
|
||||
}
|
||||
|
||||
if (t.closest('[data-action="clear"]')) {
|
||||
e.preventDefault();
|
||||
urlInput.value = '';
|
||||
|
||||
if (ipInput) ipInput.value = '';
|
||||
if (ipPreset) ipPreset.value = '';
|
||||
showError(urlInput, '');
|
||||
showError(ipInput, '');
|
||||
updatePreview();
|
||||
urlInput.focus();
|
||||
}
|
||||
@@ -104,33 +212,33 @@
|
||||
const panel = $('#' + (btn.getAttribute('aria-controls') || ''));
|
||||
if (!panel) return;
|
||||
const expanded = btn.getAttribute('aria-expanded') === 'true';
|
||||
btn.setAttribute('aria-expanded', expanded ? 'false' : 'true');
|
||||
panel.style.display = expanded ? 'none' : '';
|
||||
const next = !expanded;
|
||||
btn.setAttribute('aria-expanded', next ? 'true' : 'false');
|
||||
panel.hidden = !next;
|
||||
panel.style.display = next ? '' : 'none';
|
||||
const newLabel = next ? 'Collapse' : 'Expand';
|
||||
btn.textContent = newLabel;
|
||||
btn.setAttribute('aria-label', newLabel);
|
||||
}
|
||||
});
|
||||
|
||||
function showError(input, msg) {
|
||||
const id = input.getAttribute('id');
|
||||
const box = document.querySelector(`.error[data-error-for="${id}"]`);
|
||||
if (box) box.textContent = msg || '';
|
||||
input.setAttribute('aria-invalid', msg ? 'true' : 'false');
|
||||
}
|
||||
|
||||
// field-level validation
|
||||
urlInput?.addEventListener('blur', () => {
|
||||
const v = urlInput.value.trim();
|
||||
if (!v) return showError(urlInput, '');
|
||||
try { new URL(v); showError(urlInput, ''); }
|
||||
catch { showError(urlInput, 'Invalid URL'); }
|
||||
const raw = urlInput.value.trim();
|
||||
if (!raw) return showError(urlInput, '');
|
||||
const normalized = normalizeUrlMaybe(raw);
|
||||
showError(urlInput, normalized ? '' : 'Invalid URL');
|
||||
});
|
||||
|
||||
ipInput?.addEventListener('blur', () => {
|
||||
const v = ipInput.value.trim();
|
||||
if (!v) return showError(ipInput, '');
|
||||
const ok = /^\b\d{1,3}(?:\.\d{1,3}){3}\b$/.test(v);
|
||||
showError(ipInput, ok ? '' : 'Invalid IPv4 address');
|
||||
const ok = isValidIP(v);
|
||||
showError(ipInput, ok ? '' : 'Invalid IP address');
|
||||
if (!ok) $('.result-box')?.setAttribute('data-state', 'empty');
|
||||
});
|
||||
|
||||
|
||||
// init (preview + recent-list sync)
|
||||
(function init() {
|
||||
const serverLink = out?.value?.trim();
|
||||
if (serverLink) {
|
||||
@@ -141,14 +249,33 @@
|
||||
}
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = document.querySelector('[data-action="collapse"]');
|
||||
const panelId = btn?.getAttribute('aria-controls') || '';
|
||||
const panel = panelId ? document.getElementById(panelId) : null;
|
||||
if (!btn || !panel) return;
|
||||
|
||||
const expanded = btn.getAttribute('aria-expanded') === 'true';
|
||||
panel.hidden = !expanded;
|
||||
panel.style.display = expanded ? '' : 'none';
|
||||
|
||||
const label = expanded ? 'Collapse' : 'Expand';
|
||||
btn.textContent = label;
|
||||
btn.setAttribute('aria-label', label);
|
||||
});
|
||||
|
||||
form?.addEventListener('submit', (e) => e.preventDefault());
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
|
||||
const text = out?.value?.trim(); if (!text) return;
|
||||
navigator.clipboard?.writeText(text).then(() => toast('Link copied'));
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
// --- theme color sync (poza IIFE) ---
|
||||
function updateThemeColor() {
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (!meta) return;
|
||||
|
@@ -65,8 +65,7 @@ user_ip: {{ request.remote_addr if request else '' }}
|
||||
user_agent: {{ request.headers.get('User-Agent') if request else '' }}
|
||||
</pre>
|
||||
<div class="details-actions">
|
||||
<button class="btn tiny" type="button" data-action="copy-text"
|
||||
data-target="#error-dump">Copy</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
@@ -53,11 +53,10 @@
|
||||
|
||||
<div class="form-group col-6">
|
||||
<label for="ip-input">Target IP</label>
|
||||
<input id="ip-input" type="text" name="ip" pattern="^\d{1,3}(?:\.\d{1,3}){3}$"
|
||||
value="195.187.6.34" required inputmode="numeric" autocomplete="off"
|
||||
aria-describedby="ip-help">
|
||||
<input id="ip-input" type="text" name="ip" value="195.187.6.34" required inputmode="text"
|
||||
autocomplete="off" aria-describedby="ip-help" spellcheck="false">
|
||||
<small id="ip-help" class="hint">Common choices: <code>0.0.0.0</code>, <code>127.0.0.1</code>,
|
||||
or your device IP.</small>
|
||||
your device IP, supports IPv4 and IPv6.</small>
|
||||
<div class="error" data-error-for="ip-input"></div>
|
||||
</div>
|
||||
|
||||
@@ -68,12 +67,12 @@
|
||||
<option value="0.0.0.0">0.0.0.0 (blackhole)</option>
|
||||
<option value="127.0.0.1">127.0.0.1 (localhost)</option>
|
||||
<option value="195.187.6.34">195.187.6.34 (current)</option>
|
||||
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-actions col-12">
|
||||
<button type="submit" class="btn primary">Generate convert link</button>
|
||||
<button class="btn ghost" type="button" data-action="clear">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,12 +83,17 @@
|
||||
<input id="generated-link" type="text" value="{{ generated_link or '' }}" readonly
|
||||
placeholder="Link will appear here…">
|
||||
<div class="result-buttons">
|
||||
<button class="btn" type="button" data-action="copy" data-target="#generated-link">Copy</button>
|
||||
|
||||
<button class="btn" type="button" id="copy-btn" data-action="copy"
|
||||
data-target="#generated-link">Copy</button>
|
||||
|
||||
<a class="btn outline" id="open-link" href="{{ generated_link or '#' }}" target="_blank"
|
||||
rel="noopener" aria-disabled="{{ 'false' if generated_link else 'true' }}">Open</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<small class="hint">The preview updates live while you type.</small>
|
||||
<small class="hint">Paste this link in your Mikrotik (IP -> DNS -> Adlist) or other DNS server /
|
||||
ad blocking tool</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -119,7 +123,7 @@
|
||||
<span class="timestamp">{{ link_data[0]|datetimeformat }}</span>
|
||||
<div class="link-actions">
|
||||
<button class="btn tiny" type="button" data-action="copy-text"
|
||||
data-text="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}">Copy</button>
|
||||
data-text="{{ url_for('convert', _external=True) }}?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}">Copy</button>
|
||||
<a class="btn tiny outline"
|
||||
href="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}" target="_blank"
|
||||
rel="noopener">Open</a>
|
||||
|
Reference in New Issue
Block a user