Compare commits

...

23 Commits

Author SHA1 Message Date
Mateusz Gruszczyński
46d986ce80 zmiany w js i html 2025-08-31 11:21:58 +02:00
gru
ec07782444 Update static/css/main.css 2025-08-31 10:44:04 +02:00
gru
69adc272ff Update static/css/main.css 2025-08-30 18:18:29 +02:00
gru
68e45a7477 Update static/css/main.css 2025-08-30 18:16:15 +02:00
gru
da4f9907ae Update static/css/main.css 2025-08-30 18:15:10 +02:00
gru
fec9d9c5dd Update static/css/main.css 2025-08-30 18:11:46 +02:00
gru
6c58d7f524 Update static/css/main.css 2025-08-30 18:11:01 +02:00
gru
ae98702806 Update static/css/main.css 2025-08-30 18:10:24 +02:00
gru
163df6233b Update static/css/main.css 2025-08-30 18:08:55 +02:00
gru
78cf869354 Update static/css/main.css 2025-08-30 18:06:54 +02:00
gru
79b7ea77f4 Update app.py 2025-08-30 17:59:25 +02:00
gru
6347a96db8 Update app.py 2025-08-30 09:54:53 +02:00
gru
94d5130470 Merge pull request 'refactor' (#1) from refactor into master
Reviewed-on: #1
2025-08-30 00:19:49 +02:00
gru
aaea5cdeef Update app.py 2025-08-29 23:57:39 +02:00
gru
a2febd82c1 Update templates/form.html 2025-08-29 23:55:32 +02:00
gru
e065f5892d Update app.py 2025-08-29 23:49:32 +02:00
gru
1afaea2d00 Update app.py 2025-08-29 23:47:57 +02:00
gru
809b1168e1 Update templates/form.html 2025-08-29 23:45:23 +02:00
Mateusz Gruszczyński
259d716e0f poprawki 2025-08-29 22:36:41 +02:00
Mateusz Gruszczyński
43c9a7006c poprawki 2025-08-29 22:32:22 +02:00
Mateusz Gruszczyński
0419ca1d9d poprawki 2025-08-29 22:24:36 +02:00
Mateusz Gruszczyński
7b6dd96d68 poprawki 2025-08-29 22:08:11 +02:00
Mateusz Gruszczyński
0d35b3e654 poprawki 2025-08-29 22:04:29 +02:00
6 changed files with 299 additions and 102 deletions

105
app.py
View File

@@ -6,6 +6,7 @@ import time
import json import json
import hashlib import hashlib
import ipaddress import ipaddress
import hmac, ipaddress
from datetime import datetime from datetime import datetime
from urllib.parse import urlparse, quote, unquote, urljoin from urllib.parse import urlparse, quote, unquote, urljoin
from functools import wraps from functools import wraps
@@ -73,20 +74,7 @@ def track_request_data():
@app.after_request @app.after_request
def add_cache_headers(response): def finalize_response(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):
elapsed = time.perf_counter() - g.start_time elapsed = time.perf_counter() - g.start_time
redis_client.incrbyfloat("stats:processing_time_total", elapsed) redis_client.incrbyfloat("stats:processing_time_total", elapsed)
redis_client.incr("stats:processing_time_count") redis_client.incr("stats:processing_time_count")
@@ -102,6 +90,25 @@ def after_request(response):
redis_client.set("stats:processing_time_max", elapsed) redis_client.set("stats:processing_time_max", elapsed)
except Exception: except Exception:
redis_client.set("stats:processing_time_max", elapsed) 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 return response
@@ -269,16 +276,34 @@ def track_url_request(url):
def add_recent_link(url, target_ip): def add_recent_link(url, target_ip):
ts = datetime.now().isoformat() 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: with redis_client.pipeline() as pipe:
pipe.lpush("recent_links", link_data) pipe.delete(key)
pipe.ltrim("recent_links", 0, 9) pipe.lpush(key, new_item)
if filtered:
pipe.rpush(key, *filtered[:99])
pipe.ltrim(key, 0, 99)
pipe.execute() pipe.execute()
redis_client.incr("stats:recent_links_added") redis_client.incr("stats:recent_links_added")
def get_recent_links(): def get_recent_links():
links = redis_client.lrange("recent_links", 0, 9) links = redis_client.lrange("recent_links", 0, 99)
out = [] out = []
for link in links: for link in links:
parts = link.decode().split("|") parts = link.decode().split("|")
@@ -319,6 +344,14 @@ def add_recent_convert():
redis_client.ltrim("recent_converts", 0, 99) 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"]) @app.route("/favicon.ico", methods=["GET"])
def favicon(): def favicon():
return Response(status=204) return Response(status=204)
@@ -326,27 +359,11 @@ def favicon():
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
def index(): def index():
generated_link = None
recent_links = get_recent_links() 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: try:
return render_template( return render_template(
"form.html", "form.html",
generated_link=generated_link,
recent_links=recent_links, recent_links=recent_links,
client_ip=get_client_ip(), client_ip=get_client_ip(),
user_agent=request.headers.get("User-Agent", "Unknown"), user_agent=request.headers.get("User-Agent", "Unknown"),
@@ -354,7 +371,6 @@ def index():
except Exception: except Exception:
return jsonify( return jsonify(
{ {
"generated_link": generated_link,
"recent_links": recent_links, "recent_links": recent_links,
"client_ip": get_client_ip(), "client_ip": get_client_ip(),
"user_agent": request.headers.get("User-Agent", "Unknown"), "user_agent": request.headers.get("User-Agent", "Unknown"),
@@ -365,7 +381,7 @@ def index():
@app.route("/convert") @app.route("/convert")
@limiter.limit(config.RATE_LIMIT_CONVERT) @limiter.limit(config.RATE_LIMIT_CONVERT)
def convert(): def convert():
import hmac, ipaddress
def is_private_client_ip() -> bool: def is_private_client_ip() -> bool:
ip = get_client_ip() ip = get_client_ip()
@@ -414,8 +430,8 @@ def convert():
return resp return resp
try: try:
redis_client.incr("stats:convert_requests")
add_recent_convert() add_recent_convert()
redis_client.incr("stats:convert_requests")
if debug_mode: if debug_mode:
d("Start /convert w trybie debug") d("Start /convert w trybie debug")
@@ -437,7 +453,15 @@ def convert():
redis_client.incr("stats:errors_400") redis_client.incr("stats:errors_400")
abort(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: if debug_mode:
d(f"URL (encoded): {encoded_url}") d(f"URL (encoded): {encoded_url}")
d(f"URL (decoded): {decoded_url}") d(f"URL (decoded): {decoded_url}")
@@ -485,11 +509,13 @@ def convert():
if debug_mode: if debug_mode:
d("Upstream 304 zwracam 304") d("Upstream 304 zwracam 304")
r.close() r.close()
add_recent_link(normalized_url, target_ip)
return debug_response(status=304) return debug_response(status=304)
resp = Response(status=304) resp = Response(status=304)
resp.headers.update(cache_headers(etag, r.headers.get("Last-Modified"))) resp.headers.update(cache_headers(etag, r.headers.get("Last-Modified")))
resp.direct_passthrough = True resp.direct_passthrough = True
r.close() r.close()
add_recent_link(normalized_url, target_ip)
return resp return resp
up_etag = r.headers.get("ETag") up_etag = r.headers.get("ETag")
@@ -536,6 +562,7 @@ def convert():
resp.headers.update(cache_headers(etag, up_lm)) resp.headers.update(cache_headers(etag, up_lm))
resp.direct_passthrough = True resp.direct_passthrough = True
redis_client.incr("stats:conversions_success") redis_client.incr("stats:conversions_success")
add_recent_link(normalized_url, target_ip)
return resp return resp
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
@@ -561,7 +588,7 @@ def convert_head():
abort(400) abort(400)
decoded_url = unquote(encoded_url) decoded_url = unquote(encoded_url)
validate_and_normalize_url(decoded_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) etag = build_etag(None, None, target_ip)
resp = Response(status=200) resp = Response(status=200)
resp.headers.update(cache_headers(etag, None)) resp.headers.update(cache_headers(etag, None))

View File

@@ -41,8 +41,8 @@ body {
margin: 0; margin: 0;
font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Arial, sans-serif; font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Arial, sans-serif;
background: background:
radial-gradient(1200px 600px at 10% -10%, rgba(91, 157, 255, .08), 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%), radial-gradient(900px 500px at 110% 0%, rgba(123, 212, 255, .10), transparent 60%),*/
var(--bg); var(--bg);
color: var(--text); color: var(--text);
} }
@@ -89,7 +89,7 @@ body {
} }
.card { .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: 1px solid var(--border);
border-radius: 16px; border-radius: 16px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -194,7 +194,8 @@ select:focus {
.form-actions { .form-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center align-items: center;
justify-content: flex-end;
} }
/* Result */ /* Result */
@@ -324,14 +325,20 @@ select:focus {
color: #fff; color: #fff;
box-shadow: 0 10px 20px color-mix(in srgb, var(--brand) 35%, transparent); box-shadow: 0 10px 20px color-mix(in srgb, var(--brand) 35%, transparent);
transition: transform .04s ease, filter .15s ease, box-shadow .15s ease; transition: transform .04s ease, filter .15s ease, box-shadow .15s ease;
display: inline-block;
text-decoration: none;
display: inline-block;
} }
.btn:hover { .btn:hover {
filter: brightness(1.05) transition: transform 0.15s ease;
display: inline-block;
transform: scale(1.05);
} }
.btn:active { .btn:active {
transform: translateY(1px) transform: scale(0.95);
} }
.btn.outline { .btn.outline {
@@ -357,6 +364,30 @@ select:focus {
border-radius: 14px 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 */
#toast { #toast {
position: fixed; position: fixed;
@@ -661,6 +692,16 @@ select:focus {
background: var(--bg-elev) 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 { #error-dump {
margin: 0; margin: 0;
padding: 12px; padding: 12px;
@@ -673,6 +714,7 @@ select:focus {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@media (max-width:720px) { @media (max-width:720px) {
.error-card { .error-card {
padding: 12px padding: 12px
@@ -685,4 +727,18 @@ select:focus {
#error-dump { #error-dump {
max-height: 300px 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%);
} }

View File

@@ -1,25 +1,9 @@
(function () { (function () {
const t = localStorage.getItem('theme') || 'dark'; const t = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', t); document.documentElement.setAttribute('data-theme', t);
// prosty "try again"
document.querySelector('[data-action="try-again"]')?.addEventListener('click', () => { document.querySelector('[data-action="try-again"]')?.addEventListener('click', () => {
location.reload(); 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);
}
});
});
})(); })();

View File

@@ -2,7 +2,14 @@
const $ = (q, c = document) => c.querySelector(q); const $ = (q, c = document) => c.querySelector(q);
const $$ = (q, c = document) => Array.from(c.querySelectorAll(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 toast = (msg) => {
const el = $('#toast'); if (!el) return; const el = $('#toast'); if (!el) return;
el.textContent = msg; el.classList.add('show'); el.textContent = msg; el.classList.add('show');
@@ -10,6 +17,52 @@
}; };
const host = () => `${location.protocol}//${location.host}`; 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) { function buildLink(url, ip) {
if (!url || !ip) return ''; if (!url || !ip) return '';
try { try {
@@ -33,33 +86,85 @@
const ipPreset = $('#ip-preset'); const ipPreset = $('#ip-preset');
const out = $('#generated-link'); const out = $('#generated-link');
const openBtn = $('#open-link'); const openBtn = $('#open-link');
const copyBtn = $('#copy-btn');
function updatePreview() { function showError(input, msg) {
const link = buildLink(urlInput.value.trim(), ipInput.value.trim()); const id = input.getAttribute('id');
out.value = link || ''; const box = document.querySelector(`.error[data-error-for="${id}"]`);
if (link) { if (box) box.textContent = msg || '';
openBtn.setAttribute('href', link); input.setAttribute('aria-invalid', msg ? 'true' : 'false');
openBtn.setAttribute('aria-disabled', 'false');
} else {
openBtn.setAttribute('href', '#');
openBtn.setAttribute('aria-disabled', 'true');
}
$('.result-box')?.setAttribute('data-state', link ? 'ready' : 'empty');
} }
function updatePreview() {
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');
}
}
if (copyBtn) copyBtn.toggleAttribute('disabled', !ok);
$('.result-box')?.setAttribute('data-state', ok ? 'ready' : 'empty');
}
// live update
['input', 'change', 'blur'].forEach(evt => { ['input', 'change', 'blur'].forEach(evt => {
urlInput?.addEventListener(evt, updatePreview); urlInput?.addEventListener(evt, updatePreview);
ipInput?.addEventListener(evt, updatePreview); ipInput?.addEventListener(evt, updatePreview);
}); });
// presets
ipPreset?.addEventListener('change', () => { ipPreset?.addEventListener('change', () => {
const v = ipPreset.value; const v = ipPreset.value;
if (!v) return; if (!v) return;
if (v !== 'custom') ipInput.value = v; if (v !== 'custom') ipInput.value = v;
ipInput.focus(); ipInput.focus();
const ok = isValidIP(ipInput.value.trim());
showError(ipInput, ok ? '' : 'Invalid IP address');
updatePreview(); updatePreview();
}); });
// event delegation
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
let t = e.target; let t = e.target;
@@ -74,7 +179,6 @@
btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 1200); btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 1200);
toast('Link copied'); toast('Link copied');
}).catch(() => { }).catch(() => {
// Fallback
const range = document.createRange(); range.selectNodeContents(el); const range = document.createRange(); range.selectNodeContents(el);
const selObj = getSelection(); selObj.removeAllRanges(); selObj.addRange(range); const selObj = getSelection(); selObj.removeAllRanges(); selObj.addRange(range);
try { document.execCommand('copy'); toast('Link copied'); } catch { } try { document.execCommand('copy'); toast('Link copied'); } catch { }
@@ -85,15 +189,19 @@
if (t.closest('[data-action="copy-text"]')) { if (t.closest('[data-action="copy-text"]')) {
e.preventDefault(); e.preventDefault();
const btn = t.closest('[data-action="copy-text"]'); 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) return;
if (text.startsWith('/')) text = host() + text;
navigator.clipboard?.writeText(text).then(() => toast('Copied')); navigator.clipboard?.writeText(text).then(() => toast('Copied'));
} }
if (t.closest('[data-action="clear"]')) { if (t.closest('[data-action="clear"]')) {
e.preventDefault(); e.preventDefault();
urlInput.value = ''; urlInput.value = '';
if (ipInput) ipInput.value = '';
if (ipPreset) ipPreset.value = '';
showError(urlInput, '');
showError(ipInput, '');
updatePreview(); updatePreview();
urlInput.focus(); urlInput.focus();
} }
@@ -104,33 +212,33 @@
const panel = $('#' + (btn.getAttribute('aria-controls') || '')); const panel = $('#' + (btn.getAttribute('aria-controls') || ''));
if (!panel) return; if (!panel) return;
const expanded = btn.getAttribute('aria-expanded') === 'true'; const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', expanded ? 'false' : 'true'); const next = !expanded;
panel.style.display = expanded ? 'none' : ''; 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) { // field-level validation
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');
}
urlInput?.addEventListener('blur', () => { urlInput?.addEventListener('blur', () => {
const v = urlInput.value.trim(); const raw = urlInput.value.trim();
if (!v) return showError(urlInput, ''); if (!raw) return showError(urlInput, '');
try { new URL(v); showError(urlInput, ''); } const normalized = normalizeUrlMaybe(raw);
catch { showError(urlInput, 'Invalid URL'); } showError(urlInput, normalized ? '' : 'Invalid URL');
}); });
ipInput?.addEventListener('blur', () => { ipInput?.addEventListener('blur', () => {
const v = ipInput.value.trim(); const v = ipInput.value.trim();
if (!v) return showError(ipInput, ''); if (!v) return showError(ipInput, '');
const ok = /^\b\d{1,3}(?:\.\d{1,3}){3}\b$/.test(v); const ok = isValidIP(v);
showError(ipInput, ok ? '' : 'Invalid IPv4 address'); showError(ipInput, ok ? '' : 'Invalid IP address');
if (!ok) $('.result-box')?.setAttribute('data-state', 'empty');
}); });
// init (preview + recent-list sync)
(function init() { (function init() {
const serverLink = out?.value?.trim(); const serverLink = out?.value?.trim();
if (serverLink) { 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) => { document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
const text = out?.value?.trim(); if (!text) return; const text = out?.value?.trim(); if (!text) return;
navigator.clipboard?.writeText(text).then(() => toast('Link copied')); navigator.clipboard?.writeText(text).then(() => toast('Link copied'));
} }
}); });
})(); })();
// --- theme color sync (poza IIFE) ---
function updateThemeColor() { function updateThemeColor() {
const meta = document.querySelector('meta[name="theme-color"]'); const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) return; if (!meta) return;

View File

@@ -65,8 +65,7 @@ user_ip: {{ request.remote_addr if request else '' }}
user_agent: {{ request.headers.get('User-Agent') if request else '' }} user_agent: {{ request.headers.get('User-Agent') if request else '' }}
</pre> </pre>
<div class="details-actions"> <div class="details-actions">
<button class="btn tiny" type="button" data-action="copy-text"
data-target="#error-dump">Copy</button>
</div> </div>
</div> </div>
</details> </details>

View File

@@ -53,11 +53,10 @@
<div class="form-group col-6"> <div class="form-group col-6">
<label for="ip-input">Target IP</label> <label for="ip-input">Target IP</label>
<input id="ip-input" type="text" name="ip" pattern="^\d{1,3}(?:\.\d{1,3}){3}$" <input id="ip-input" type="text" name="ip" value="195.187.6.34" required inputmode="text"
value="195.187.6.34" required inputmode="numeric" autocomplete="off" autocomplete="off" aria-describedby="ip-help" spellcheck="false">
aria-describedby="ip-help">
<small id="ip-help" class="hint">Common choices: <code>0.0.0.0</code>, <code>127.0.0.1</code>, <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 class="error" data-error-for="ip-input"></div>
</div> </div>
@@ -68,12 +67,12 @@
<option value="0.0.0.0">0.0.0.0 (blackhole)</option> <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="127.0.0.1">127.0.0.1 (localhost)</option>
<option value="195.187.6.34">195.187.6.34 (current)</option> <option value="195.187.6.34">195.187.6.34 (current)</option>
<option value="custom">Custom…</option> <option value="custom">Custom…</option>
</select> </select>
</div> </div>
<div class="form-actions col-12"> <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> <button class="btn ghost" type="button" data-action="clear">Clear</button>
</div> </div>
</div> </div>
@@ -84,12 +83,17 @@
<input id="generated-link" type="text" value="{{ generated_link or '' }}" readonly <input id="generated-link" type="text" value="{{ generated_link or '' }}" readonly
placeholder="Link will appear here…"> placeholder="Link will appear here…">
<div class="result-buttons"> <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" <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> rel="noopener" aria-disabled="{{ 'false' if generated_link else 'true' }}">Open</a>
</div> </div>
</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> </div>
</section> </section>
@@ -119,7 +123,7 @@
<span class="timestamp">{{ link_data[0]|datetimeformat }}</span> <span class="timestamp">{{ link_data[0]|datetimeformat }}</span>
<div class="link-actions"> <div class="link-actions">
<button class="btn tiny" type="button" data-action="copy-text" <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" <a class="btn tiny outline"
href="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}" target="_blank" href="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}" target="_blank"
rel="noopener">Open</a> rel="noopener">Open</a>