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
+34 -1
View File
@@ -1349,6 +1349,7 @@ function buildLinkItem(l) {
</div>
<div class="link-meta">
${ownerBadge}
${l.has_password?'<span title="Password protected" style="font-size:12px">🔒</span>':''}
<div class="link-clicks"><strong>${l.clicks}</strong> clicks${expNote}</div>
<button class="copy-inline" title="Copy short URL"
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><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">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>
<div class="edit-actions">
@@ -1390,6 +1404,9 @@ function buildLinkItem(l) {
<div class="analytics-header">
<div class="analytics-title">CLICK ANALYTICS</div>
<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">
<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>
@@ -1469,8 +1486,12 @@ async function saveEdit(code) {
const url = document.getElementById(`edit-url-${code}`).value.trim();
const title = document.getElementById(`edit-title-${code}`).value.trim();
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 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();
if (!resp.ok) { toast(data.error||'Save failed','error'); return; }
toast('Link updated!');
@@ -1481,6 +1502,18 @@ async function saveEdit(code) {
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) {
if (!confirm(`Delete /${code}?`)) return;
await authFetch(`${BASE}/api/links/${code}`, {method:'DELETE'});