This commit is contained in:
Mateusz Gruszczyński
2025-08-29 22:04:29 +02:00
parent eac2002f56
commit 0d35b3e654
4 changed files with 205 additions and 57 deletions

64
app.py
View File

@@ -73,20 +73,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 +89,29 @@ 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"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
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"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
return response
@@ -319,6 +329,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)
@@ -329,7 +347,11 @@ 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")
raw_ip = request.args.get("ip", "127.0.0.1")
try:
target_ip = validate_ip(raw_ip)
except ValueError:
target_ip = "127.0.0.1"
if url_param:
try:
@@ -437,7 +459,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}")
@@ -561,7 +591,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))

View File

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

View File

@@ -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 {
@@ -34,32 +87,75 @@
const out = $('#generated-link');
const openBtn = $('#open-link');
function updatePreview() {
const link = buildLink(urlInput.value.trim(), ipInput.value.trim());
out.value = link || '';
if (link) {
openBtn.setAttribute('href', link);
openBtn.setAttribute('aria-disabled', 'false');
} else {
openBtn.setAttribute('href', '#');
openBtn.setAttribute('aria-disabled', 'true');
}
$('.result-box')?.setAttribute('data-state', link ? 'ready' : 'empty');
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 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');
}
$('.result-box')?.setAttribute('data-state', 'empty');
return;
}
const normalized = normalizeUrlMaybe(rawUrl); // poprawny http/https lub ''
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');
}
$('.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');
} else {
openBtn.setAttribute('href', '#');
openBtn.setAttribute('aria-disabled', 'true');
}
}
$('.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 +170,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 +180,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 +203,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 +240,34 @@
}
})();
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;
// usuń/ustaw inline display spójnie z hidden
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;

View File

@@ -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>
@@ -119,7 +118,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>