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:
+34
-1
@@ -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'});
|
||||
|
||||
Reference in New Issue
Block a user