feat: per-link redirect type, password-protected links, branded visitor pages

- Redirects default to 302 so browsers stop permanently caching the hop —
  click analytics stay accurate and destination edits take effect for
  returning visitors. 301 is a per-link opt-in (create/edit/API).
- Links can carry a password: visitors get a branded unlock page at /:code
  and POST to /:code/unlock. Unlocks always redirect with 302.
- Missing, expired, and quota-limited links now show branded standalone
  visitor pages (404/410/429) instead of bouncing to the marketing site
  with ?error= query params.
- Clicks are recorded through the async pipeline with bot/crawler tagging
  (UA heuristics), GeoLite2 country lookup (ip-api fallback gated behind
  GEO_API_FALLBACK), and the IP_ANONYMIZE policy applied at write time.
- Deletes stamp deleted_at for later hard-purge.
- Dashboard: redirect-type selector and password set/replace/remove in the
  edit form, lock badge on protected links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-03 01:39:50 -05:00
parent d0e99b5244
commit 14d6e8c50e
2 changed files with 252 additions and 59 deletions
+204 -44
View File
@@ -637,6 +637,21 @@ def parse_referrer(ref):
if 'instagram' in r: return 'Instagram' if 'instagram' in r: return 'Instagram'
return 'Other' return 'Other'
_BOT_UA_MARKERS = (
'bot', 'crawler', 'spider', 'slurp', 'curl', 'wget', 'python-requests',
'python-urllib', 'httpclient', 'go-http-client', 'okhttp', 'scrapy',
'headless', 'phantomjs', 'facebookexternalhit', 'whatsapp', 'telegrambot',
'discordbot', 'slackbot', 'linkedinbot', 'twitterbot', 'pinterest',
'preview', 'monitor', 'pingdom', 'uptime', 'postmanruntime', 'java/',
)
def is_bot_ua(ua):
"""Heuristic bot/crawler detection from the User-Agent string."""
if not ua:
return True
u = ua.lower()
return any(m in u for m in _BOT_UA_MARKERS)
def get_client_ip(): def get_client_ip():
"""Return the real client IP, honouring X-Forwarded-For from trusted proxies.""" """Return the real client IP, honouring X-Forwarded-For from trusted proxies."""
xff = request.headers.get('X-Forwarded-For', '') xff = request.headers.get('X-Forwarded-For', '')
@@ -644,42 +659,71 @@ def get_client_ip():
return xff.split(',')[0].strip() return xff.split(',')[0].strip()
return request.remote_addr or '' return request.remote_addr or ''
def get_country_for_request(): def anonymize_ip(ip):
"""Return a 2-letter ISO country code for the current request. """Apply the configured IP_ANONYMIZE policy before an IP is stored.
'truncate' zeroes the host part; 'hash' keeps unique-visitor counting
possible without retaining the address itself."""
if not ip or IP_ANONYMIZE == 'none':
return ip
if IP_ANONYMIZE == 'hash':
return hashlib.sha256(ip.encode()).hexdigest()[:16]
if IP_ANONYMIZE == 'truncate':
if ':' in ip:
parts = ip.split(':')
return ':'.join(parts[:3]) + '::'
parts = ip.split('.')
if len(parts) == 4:
return '.'.join(parts[:3]) + '.0'
return ip
# Local GeoLite2 country database (preferred over any network lookup).
# Download GeoLite2-Country.mmdb from MaxMind and mount it at GEOIP_DB_PATH.
_geoip_reader = None
try:
import geoip2.database as _geoip2_db
if os.path.exists(GEOIP_DB_PATH):
_geoip_reader = _geoip2_db.Reader(GEOIP_DB_PATH)
except Exception:
_geoip_reader = None
def get_country_for_ip(ip):
"""Return a 2-letter ISO country code for an IP address.
Priority: Priority:
1. CF-IPCountry header (Cloudflare — zero-latency, most reliable) 1. Local MaxMind GeoLite2 database (offline, no rate limits, commercial-safe)
2. ip-api.com free JSON API (1-second timeout, fails gracefully) 2. ip-api.com free JSON API — only if GEO_API_FALLBACK=true (their free
Returns 'XX' if the IP is private/loopback, 'Unknown' on any failure. tier is rate-limited and licensed for non-commercial use only)
Returns 'XX' for private/loopback IPs, 'Unknown' on any failure.
Called from the background click writer, never from the request path.
""" """
import urllib.request as _ureq, json as _json if not ip:
return 'Unknown'
cf = request.headers.get('CF-IPCountry', '').strip().upper()
if cf and len(cf) == 2 and cf.isalpha() and cf != 'XX':
return cf
ip = get_client_ip()
if not ip or ip in ('127.0.0.1', '::1'):
return 'XX'
# Skip RFC-1918 / loopback ranges — no point querying for private IPs
try: try:
import ipaddress import ipaddress
parsed = ipaddress.ip_address(ip) parsed = ipaddress.ip_address(ip)
if parsed.is_private or parsed.is_loopback or parsed.is_link_local: if parsed.is_private or parsed.is_loopback or parsed.is_link_local:
return 'XX' return 'XX'
except ValueError: except ValueError:
pass return 'Unknown'
if _geoip_reader:
try: try:
return _geoip_reader.country(ip).country.iso_code or 'Unknown'
except Exception:
return 'Unknown'
if GEO_API_FALLBACK:
try:
import urllib.request as _ureq
req = _ureq.Request( req = _ureq.Request(
f'http://ip-api.com/json/{ip}?fields=countryCode', f'http://ip-api.com/json/{ip}?fields=countryCode',
headers={'User-Agent': 'QRknit/1.0'} headers={'User-Agent': 'QRknit/1.0'}
) )
with _ureq.urlopen(req, timeout=1) as resp: with _ureq.urlopen(req, timeout=2) as resp:
data = _json.loads(resp.read()) data = json.loads(resp.read())
code = data.get('countryCode', '') code = data.get('countryCode', '')
return code if code else 'Unknown' return code if code else 'Unknown'
except Exception: except Exception:
return 'Unknown' return 'Unknown'
return 'Unknown'
def get_link_tags(conn, link_id): def get_link_tags(conn, link_id):
rows = conn.execute( rows = conn.execute(
@@ -718,6 +762,8 @@ def format_link(row, conn):
'clicks': row['clicks'], 'clicks': row['clicks'],
'is_active': row['is_active'], 'is_active': row['is_active'],
'is_pinned': row['is_pinned'], 'is_pinned': row['is_pinned'],
'redirect_type': row['redirect_type'] or 302,
'has_password': bool(row['password_hash']),
'short_url': f"{BASE_URL}/{row['code']}", 'short_url': f"{BASE_URL}/{row['code']}",
'qr_url': f"{BASE_URL}/api/qr/{row['code']}", 'qr_url': f"{BASE_URL}/api/qr/{row['code']}",
'tags': get_link_tags(conn, row['id']), 'tags': get_link_tags(conn, row['id']),
@@ -855,6 +901,8 @@ def shorten():
title = (data.get('title') or '').strip() title = (data.get('title') or '').strip()
expires_at = data.get('expires_at') expires_at = data.get('expires_at')
tags = data.get('tags', []) tags = data.get('tags', [])
redirect_type = data.get('redirect_type', 302)
password = data.get('password') or ''
if not long_url: if not long_url:
return jsonify({'error': 'URL is required'}), 400 return jsonify({'error': 'URL is required'}), 400
@@ -862,6 +910,9 @@ def shorten():
return jsonify({'error': 'URL must start with http:// or https://'}), 400 return jsonify({'error': 'URL must start with http:// or https://'}), 400
if custom_code and not re.match(r'^[a-zA-Z0-9]{1,20}$', custom_code): if custom_code and not re.match(r'^[a-zA-Z0-9]{1,20}$', custom_code):
return jsonify({'error': 'Custom code must be 120 alphanumeric characters'}), 400 return jsonify({'error': 'Custom code must be 120 alphanumeric characters'}), 400
if redirect_type not in (301, 302, '301', '302'):
return jsonify({'error': 'redirect_type must be 301 or 302'}), 400
redirect_type = int(redirect_type)
code = custom_code or generate_code(long_url) code = custom_code or generate_code(long_url)
@@ -883,10 +934,12 @@ def shorten():
code = generate_code(long_url + str(time.time())) code = generate_code(long_url + str(time.time()))
conn.execute( conn.execute(
'INSERT INTO links (code,long_url,title,created_at,expires_at,user_id) VALUES (?,?,?,?,?,?)', 'INSERT INTO links (code,long_url,title,created_at,expires_at,user_id,redirect_type,password_hash) '
'VALUES (?,?,?,?,?,?,?,?)',
(code, long_url, title or None, (code, long_url, title or None,
datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
expires_at or None, session.get('user_id')) expires_at or None, session.get('user_id'), redirect_type,
generate_password_hash(password) if password else None)
) )
link_id = conn.execute('SELECT id FROM links WHERE code=?', (code,)).fetchone()['id'] link_id = conn.execute('SELECT id FROM links WHERE code=?', (code,)).fetchone()['id']
if tags: if tags:
@@ -898,6 +951,8 @@ def shorten():
'long_url': long_url, 'long_url': long_url,
'title': title, 'title': title,
'tags': tags, 'tags': tags,
'redirect_type': redirect_type,
'has_password': bool(password),
'qr_url': f"{BASE_URL}/api/qr/{code}", 'qr_url': f"{BASE_URL}/api/qr/{code}",
}), 201 }), 201
@@ -1005,6 +1060,14 @@ def edit_link(code):
if 'title' in data: updates['title'] = data['title'].strip() or None if 'title' in data: updates['title'] = data['title'].strip() or None
if 'expires_at' in data: updates['expires_at'] = data['expires_at'] or None if 'expires_at' in data: updates['expires_at'] = data['expires_at'] or None
if 'is_pinned' in data: updates['is_pinned'] = 1 if data['is_pinned'] else 0 if 'is_pinned' in data: updates['is_pinned'] = 1 if data['is_pinned'] else 0
if 'redirect_type' in data:
if data['redirect_type'] not in (301, 302, '301', '302'):
return jsonify({'error': 'redirect_type must be 301 or 302'}), 400
updates['redirect_type'] = int(data['redirect_type'])
if 'password' in data:
# Non-empty string sets/updates the password; empty/null removes it
pw = data['password'] or ''
updates['password_hash'] = generate_password_hash(pw) if pw else None
if updates: if updates:
set_clause = ', '.join(f'{k}=?' for k in updates) set_clause = ', '.join(f'{k}=?' for k in updates)
@@ -1024,7 +1087,7 @@ def delete_link(code):
link = conn.execute('SELECT * FROM links WHERE code=?', (code,)).fetchone() link = conn.execute('SELECT * FROM links WHERE code=?', (code,)).fetchone()
if not link or not _can_access_link(link, conn): if not link or not _can_access_link(link, conn):
return jsonify({'error': 'Not found'}), 404 return jsonify({'error': 'Not found'}), 404
conn.execute('UPDATE links SET is_active=0 WHERE code=?', (code,)) conn.execute('UPDATE links SET is_active=0, deleted_at=? WHERE code=?', (now_iso(), code))
return jsonify({'success': True}) return jsonify({'success': True})
@@ -1380,11 +1443,12 @@ def bulk_links():
with get_db() as conn: with get_db() as conn:
if action == 'delete': if action == 'delete':
if is_admin: if is_admin:
conn.execute(f'UPDATE links SET is_active=0 WHERE code IN ({placeholders})', codes) conn.execute(f'UPDATE links SET is_active=0, deleted_at=? WHERE code IN ({placeholders})',
[now_iso()] + list(codes))
else: else:
conn.execute( conn.execute(
f'UPDATE links SET is_active=0 WHERE code IN ({placeholders}) AND user_id=?', f'UPDATE links SET is_active=0, deleted_at=? WHERE code IN ({placeholders}) AND user_id=?',
list(codes) + [user_id] [now_iso()] + list(codes) + [user_id]
) )
return jsonify({'deleted': len(codes)}) return jsonify({'deleted': len(codes)})
@@ -1810,9 +1874,95 @@ def sitemap_xml():
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# Redirect # Redirect — branded visitor pages, password gate, async click logging
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def _visitor_page(heading, message, body_html='', status=200):
"""Branded standalone page shown to link visitors (expired, missing,
password-protected, …) — they never see the app or marketing site."""
html = f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex"><title>{heading}{APP_NAME}</title>
<style>
:root {{ --bg:#0a0a12; --card:#12121e; --border:#23233a; --text:#e8e8f0; --muted:#6b6b80; --accent:#00b8d4; }}
* {{ box-sizing:border-box; margin:0; padding:0; }}
body {{ background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui,sans-serif;
min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px; }}
.card {{ background:var(--card); border:1px solid var(--border); border-radius:12px;
padding:40px 36px; max-width:420px; width:100%; text-align:center; }}
.brand {{ font-family:monospace; font-size:13px; letter-spacing:.14em; color:var(--muted);
text-transform:uppercase; margin-bottom:22px; }}
.brand b {{ color:var(--accent); }}
h1 {{ font-size:21px; margin-bottom:10px; }}
p {{ color:var(--muted); font-size:14px; line-height:1.6; }}
form {{ margin-top:22px; display:flex; flex-direction:column; gap:10px; }}
input[type=password] {{ background:var(--bg); border:1px solid var(--border); border-radius:8px;
color:var(--text); padding:11px 14px; font-size:14px; outline:none; }}
input[type=password]:focus {{ border-color:var(--accent); }}
button {{ background:var(--accent); border:none; border-radius:8px; color:#001318;
font-weight:600; padding:11px; font-size:14px; cursor:pointer; }}
.err {{ color:#ff5470; font-size:13px; margin-top:10px; }}
</style></head><body>
<div class="card">
<div class="brand"><b>▮</b> {APP_NAME}</div>
<h1>{heading}</h1>
<p>{message}</p>
{body_html}
</div>
</body></html>"""
return Response(html, status=status, mimetype='text/html',
headers={'Cache-Control': 'no-store'})
def _password_form(code, error=''):
err_html = f'<div class="err">{error}</div>' if error else ''
return _visitor_page(
'This link is password protected',
'Enter the password to continue to the destination.',
f'''<form method="POST" action="/{code}/unlock">
<input type="password" name="password" placeholder="Password" autofocus autocomplete="off">
<button type="submit">Unlock →</button>
</form>{err_html}''',
status=200
)
def _record_click(link_row):
"""Capture request details and enqueue the click for the background writer."""
ua = request.headers.get('User-Agent', '')[:500]
cf = request.headers.get('CF-IPCountry', '').strip().upper()
item = {
'link_id': link_row['id'],
'ts': now_iso(),
'referrer': request.referrer,
'ua': ua,
'ip': get_client_ip()[:45],
'cf_country': cf if (len(cf) == 2 and cf.isalpha() and cf != 'XX') else '',
'is_bot': 1 if is_bot_ua(ua) else 0,
}
try:
_click_queue.put_nowait(item)
except queue.Full:
pass
def _check_link_gates(conn, link):
"""Expiry and quota checks shared by the redirect and unlock routes.
Returns an error Response, or None if the link may be followed."""
if link['expires_at'] and link['expires_at'] < now_iso():
return _visitor_page('Link expired',
'This short link has passed its expiry date and is no longer available.',
status=410)
if link['user_id']:
plan = get_user_plan(conn, link['user_id'])
click_limit = PLAN_LIMITS[plan]['monthly_clicks']
if click_limit is not None and get_user_monthly_clicks(conn, link['user_id']) >= click_limit:
return _visitor_page('Link temporarily unavailable',
'This link has reached its monthly click limit. Please try again later.',
status=429)
return None
@app.route('/<code>') @app.route('/<code>')
def redirect_link(code): def redirect_link(code):
if code in ('static', 'api', 'favicon.ico', 'robots.txt', 'sitemap.xml'): if code in ('static', 'api', 'favicon.ico', 'robots.txt', 'sitemap.xml'):
@@ -1820,27 +1970,37 @@ def redirect_link(code):
with get_db() as conn: with get_db() as conn:
link = conn.execute('SELECT * FROM links WHERE code=? AND is_active=1', (code,)).fetchone() link = conn.execute('SELECT * FROM links WHERE code=? AND is_active=1', (code,)).fetchone()
if not link: if not link:
return redirect('/?error=not_found') return _visitor_page('Link not found',
if link['expires_at'] and link['expires_at'] < datetime.now(timezone.utc).replace(tzinfo=None).isoformat(): "This short link doesn't exist or has been removed.",
return redirect('/?error=expired') status=404)
gate = _check_link_gates(conn, link)
if gate:
return gate
if link['password_hash']:
return _password_form(code)
_record_click(link)
# 302 by default so browsers don't permanently cache the hop —
# keeps analytics accurate and destination edits effective. 301 is per-link opt-in.
return redirect(link['long_url'], code=link['redirect_type'] or 302)
# Monthly click quota check (skip for links with no owner)
if link['user_id']:
plan = get_user_plan(conn, link['user_id'])
click_limit = PLAN_LIMITS[plan]['monthly_clicks']
if click_limit is not None and get_user_monthly_clicks(conn, link['user_id']) >= click_limit:
return redirect('/?error=quota_exceeded')
country = get_country_for_request() @app.route('/<code>/unlock', methods=['POST'])
client_ip = get_client_ip() def unlock_link(code):
conn.execute( with get_db() as conn:
'INSERT INTO clicks (link_id,clicked_at,referrer,user_agent,ip_address,country) VALUES (?,?,?,?,?,?)', link = conn.execute('SELECT * FROM links WHERE code=? AND is_active=1', (code,)).fetchone()
(link['id'], datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), if not link or not link['password_hash']:
request.referrer, request.headers.get('User-Agent','')[:500], return _visitor_page('Link not found',
client_ip[:45], country) "This short link doesn't exist or has been removed.",
) status=404)
conn.execute('UPDATE links SET clicks=clicks+1 WHERE id=?', (link['id'],)) gate = _check_link_gates(conn, link)
return redirect(link['long_url'], code=301) if gate:
return gate
password = request.form.get('password', '')
if not check_password_hash(link['password_hash'], password):
return _password_form(code, error='Incorrect password — try again.')
_record_click(link)
# Always 302 for password links — the unlock must never be cached away
return redirect(link['long_url'], code=302)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
+34 -1
View File
@@ -1349,6 +1349,7 @@ function buildLinkItem(l) {
</div> </div>
<div class="link-meta"> <div class="link-meta">
${ownerBadge} ${ownerBadge}
${l.has_password?'<span title="Password protected" style="font-size:12px">🔒</span>':''}
<div class="link-clicks"><strong>${l.clicks}</strong> clicks${expNote}</div> <div class="link-clicks"><strong>${l.clicks}</strong> clicks${expNote}</div>
<button class="copy-inline" title="Copy short URL" <button class="copy-inline" title="Copy short URL"
onclick="event.stopPropagation();copyToClipboard('${l.short_url}')">copy</button> onclick="event.stopPropagation();copyToClipboard('${l.short_url}')">copy</button>
@@ -1379,6 +1380,19 @@ function buildLinkItem(l) {
<div class="edit-full"><label class="field-label">DESTINATION URL</label><input class="field-input" id="edit-url-${l.code}" value="${escHtml(l.long_url)}" onblur="autoFetchTitleForEdit('${l.code}')"></div> <div class="edit-full"><label class="field-label">DESTINATION URL</label><input class="field-input" id="edit-url-${l.code}" value="${escHtml(l.long_url)}" onblur="autoFetchTitleForEdit('${l.code}')"></div>
<div><label class="field-label">TITLE</label><input class="field-input" id="edit-title-${l.code}" value="${escHtml(l.title||'')}"></div> <div><label class="field-label">TITLE</label><input class="field-input" id="edit-title-${l.code}" value="${escHtml(l.title||'')}"></div>
<div><label class="field-label">EXPIRY DATE</label><input class="field-input" type="date" id="edit-expiry-${l.code}" value="${(l.expires_at||'').slice(0,10)}"></div> <div><label class="field-label">EXPIRY DATE</label><input class="field-input" type="date" id="edit-expiry-${l.code}" value="${(l.expires_at||'').slice(0,10)}"></div>
<div><label class="field-label">REDIRECT TYPE</label>
<select class="field-input" id="edit-rt-${l.code}">
<option value="302"${l.redirect_type==301?'':' selected'}>302 — Temporary (recommended)</option>
<option value="301"${l.redirect_type==301?' selected':''}>301 — Permanent (browsers cache)</option>
</select>
</div>
<div><label class="field-label">PASSWORD ${l.has_password?'<span style="color:var(--accent)">● set</span>':''}</label>
<div style="display:flex;gap:6px">
<input class="field-input" type="password" id="edit-pw-${l.code}" autocomplete="new-password" style="flex:1"
placeholder="${l.has_password?'Unchanged — type to replace':'None — type to protect'}">
${l.has_password?`<button class="btn-sm danger" onclick="removeLinkPassword('${l.code}')" title="Remove password protection">✕</button>`:''}
</div>
</div>
<div class="edit-full"><label class="field-label">TAGS (comma separated)</label><input class="field-input" id="edit-tags-${l.code}" value="${(l.tags||[]).map(t=>t.name).join(', ')}"></div> <div class="edit-full"><label class="field-label">TAGS (comma separated)</label><input class="field-input" id="edit-tags-${l.code}" value="${(l.tags||[]).map(t=>t.name).join(', ')}"></div>
</div> </div>
<div class="edit-actions"> <div class="edit-actions">
@@ -1390,6 +1404,9 @@ function buildLinkItem(l) {
<div class="analytics-header"> <div class="analytics-header">
<div class="analytics-title">CLICK ANALYTICS</div> <div class="analytics-title">CLICK ANALYTICS</div>
<div style="display:flex;align-items:center;gap:8px"> <div style="display:flex;align-items:center;gap:8px">
<label style="display:flex;align-items:center;gap:4px;font-size:10px;font-family:var(--font-mono);color:var(--muted);cursor:pointer;letter-spacing:.04em">
<input type="checkbox" id="bots-${l.code}" onchange="loadAnalytics('${l.code}', analyticsDaysByCode['${l.code}']||7)"> NO BOTS
</label>
<div class="analytics-days"> <div class="analytics-days">
<button class="day-btn active" onclick="loadAnalytics('${l.code}',7,this)">7d</button> <button class="day-btn active" onclick="loadAnalytics('${l.code}',7,this)">7d</button>
<button class="day-btn" onclick="loadAnalytics('${l.code}',30,this)">30d</button> <button class="day-btn" onclick="loadAnalytics('${l.code}',30,this)">30d</button>
@@ -1469,8 +1486,12 @@ async function saveEdit(code) {
const url = document.getElementById(`edit-url-${code}`).value.trim(); const url = document.getElementById(`edit-url-${code}`).value.trim();
const title = document.getElementById(`edit-title-${code}`).value.trim(); const title = document.getElementById(`edit-title-${code}`).value.trim();
const expiry = document.getElementById(`edit-expiry-${code}`).value; const expiry = document.getElementById(`edit-expiry-${code}`).value;
const rt = parseInt(document.getElementById(`edit-rt-${code}`).value);
const pw = document.getElementById(`edit-pw-${code}`).value;
const tags = document.getElementById(`edit-tags-${code}`).value.split(',').map(t=>t.trim()).filter(Boolean); const tags = document.getElementById(`edit-tags-${code}`).value.split(',').map(t=>t.trim()).filter(Boolean);
const resp = await authFetch(`${BASE}/api/links/${code}`, {method:'PATCH',body:JSON.stringify({url,title,expires_at:expiry||null,tags})}); const body = {url, title, expires_at: expiry||null, tags, redirect_type: rt};
if (pw) body.password = pw; // omit = leave password unchanged
const resp = await authFetch(`${BASE}/api/links/${code}`, {method:'PATCH',body:JSON.stringify(body)});
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) { toast(data.error||'Save failed','error'); return; } if (!resp.ok) { toast(data.error||'Save failed','error'); return; }
toast('Link updated!'); toast('Link updated!');
@@ -1481,6 +1502,18 @@ async function saveEdit(code) {
loadStats(); loadTags(); loadStats(); loadTags();
} }
async function removeLinkPassword(code) {
if (!confirm(`Remove password protection from /${code}?`)) return;
const resp = await authFetch(`${BASE}/api/links/${code}`, {method:'PATCH', body:JSON.stringify({password:''})});
const data = await resp.json();
if (!resp.ok) { toast(data.error||'Failed','error'); return; }
toast('Password removed');
const old = document.getElementById(`item-${code}`);
const fresh = buildLinkItem(data);
fresh.classList.add('expanded');
old.parentNode.replaceChild(fresh, old);
}
async function deleteLink(code) { async function deleteLink(code) {
if (!confirm(`Delete /${code}?`)) return; if (!confirm(`Delete /${code}?`)) return;
await authFetch(`${BASE}/api/links/${code}`, {method:'DELETE'}); await authFetch(`${BASE}/api/links/${code}`, {method:'DELETE'});