Keep showing a guest link's URL so it can be copied again
The URL was shown once, in a box under the create button, and then gone: only a hash of the token was stored, so the app genuinely could not produce it a second time. Lose the message you sent the sitter and the only way back was to mint a new link — which strands whoever is already holding the old one. Settings now lists every live link with its URL and a Copy button, so re-sending one is just copying it again. That means keeping the token rather than only its hash, and it is worth being plain about the trade. It is not the trade you would make for a password, which the user has probably reused, or a session token, which grants everything indefinitely. A guest link grants a strict subset of what the same database already holds in plaintext, expires on a date the owner picked, and can be revoked in one tap — so an attacker who can read puppy.db gains very little by also being able to open it as a guest. The lookup column stays a hash and remains the key redeem matches against; the secret sits in a new column beside it, which also keeps the migration additive. Links created before this have an empty secret. They keep working and stay revocable — the migration touches nothing but the new column — and the list says why their URL is missing rather than rendering a broken one. The two tests that asserted the old contract now assert the new one: a listing hands back a secret that really opens the link, and the lookup column is still a hash. Added one for the legacy row, since "still works, just cannot be shown" is the part a future change is most likely to break quietly.
This commit is contained in:
+56
-27
@@ -3402,9 +3402,6 @@
|
||||
const guestLabelIn = document.getElementById("guest-label");
|
||||
const guestCreate = document.getElementById("guest-create");
|
||||
const guestError = document.getElementById("guest-error");
|
||||
const guestNew = document.getElementById("guest-new");
|
||||
const guestNewURL = document.getElementById("guest-new-url");
|
||||
const guestCopy = document.getElementById("guest-copy");
|
||||
const guestListEl = document.getElementById("guest-list");
|
||||
const guestEmpty = document.getElementById("guest-empty");
|
||||
const guestExpires = document.getElementById("guest-expires");
|
||||
@@ -3463,10 +3460,12 @@
|
||||
? `last used ${formatRelative(l.lastUsed)}`
|
||||
: "never used";
|
||||
li.innerHTML = `
|
||||
<span class="guest-item-main">
|
||||
<span class="guest-item-label"></span>
|
||||
<span class="guest-item-sub"></span>
|
||||
</span>
|
||||
<div class="guest-item-row">
|
||||
<span class="guest-item-main">
|
||||
<span class="guest-item-label"></span>
|
||||
<span class="guest-item-sub"></span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
li.querySelector(".guest-item-label").textContent = l.label;
|
||||
li.querySelector(".guest-item-sub").textContent = `expires ${formatWhen(l.expires)} · ${used}`;
|
||||
@@ -3475,13 +3474,42 @@
|
||||
revoke.className = "linklike guest-revoke";
|
||||
revoke.textContent = "Revoke";
|
||||
revoke.addEventListener("click", () => revokeGuestLink(l.id, l.label));
|
||||
li.appendChild(revoke);
|
||||
li.querySelector(".guest-item-row").appendChild(revoke);
|
||||
|
||||
// The URL stays available for as long as the link does, so it can be
|
||||
// re-sent without minting a new one and stranding whoever holds the old.
|
||||
if (l.token) {
|
||||
const url = guestURL(l.token);
|
||||
const box = document.createElement("div");
|
||||
box.className = "guest-item-url";
|
||||
const code = document.createElement("code");
|
||||
code.className = "guest-url";
|
||||
code.textContent = url;
|
||||
code.title = url; // the line is truncated; the tooltip isn't
|
||||
// Tapping the URL selects the whole of it, so it can be copied by hand
|
||||
// where the clipboard API isn't available (it needs a secure context).
|
||||
code.addEventListener("click", () => selectContents(code));
|
||||
const copy = document.createElement("button");
|
||||
copy.type = "button";
|
||||
copy.className = "ghost guest-copy";
|
||||
copy.textContent = "Copy";
|
||||
copy.addEventListener("click", () => copyGuestURL(copy, code));
|
||||
box.appendChild(code);
|
||||
box.appendChild(copy);
|
||||
li.appendChild(box);
|
||||
} else {
|
||||
// Links minted before the URL was kept: only their hash was stored, so
|
||||
// there is nothing to show. Revoking and making a new one is the fix.
|
||||
const note = document.createElement("p");
|
||||
note.className = "settings-hint guest-item-url";
|
||||
note.textContent = "Made before links could be re-shown — revoke it and create a new one to get a copyable URL.";
|
||||
li.appendChild(note);
|
||||
}
|
||||
guestListEl.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGuestLinks() {
|
||||
guestNew.hidden = true;
|
||||
guestError.hidden = true;
|
||||
try {
|
||||
const res = await fetch("api/shares");
|
||||
@@ -3515,15 +3543,12 @@
|
||||
body: JSON.stringify({ label: guestLabelIn.value.trim(), expires }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`);
|
||||
const link = await res.json();
|
||||
await res.json();
|
||||
guestLabelIn.value = "";
|
||||
resetGuestExpiry();
|
||||
// The refresh clears any previously shown URL, so reveal this one after
|
||||
// it, not before.
|
||||
// Nothing special to do with the new link: the list shows every link's
|
||||
// URL, so it simply appears there like the rest.
|
||||
await refreshGuestLinks();
|
||||
guestNewURL.textContent = guestURL(link.token);
|
||||
guestCopy.textContent = "Copy link";
|
||||
guestNew.hidden = false;
|
||||
} catch (err) {
|
||||
guestError.textContent = err.message || "Couldn't create a link";
|
||||
guestError.hidden = false;
|
||||
@@ -3532,21 +3557,25 @@
|
||||
}
|
||||
});
|
||||
|
||||
guestCopy.addEventListener("click", async () => {
|
||||
function selectContents(el) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
async function copyGuestURL(btn, code) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(guestNewURL.textContent);
|
||||
guestCopy.textContent = "Copied";
|
||||
setTimeout(() => { guestCopy.textContent = "Copy link"; }, 1500);
|
||||
await navigator.clipboard.writeText(code.textContent);
|
||||
btn.textContent = "Copied";
|
||||
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
|
||||
} catch {
|
||||
// Clipboard needs a secure context and can be refused; the URL is on
|
||||
// screen either way, so select it and let them copy by hand.
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(guestNewURL);
|
||||
const sel = getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
// The clipboard API needs a secure context and can be refused outright.
|
||||
// The URL is on screen either way, so select it and let them copy by hand.
|
||||
selectContents(code);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeGuestLink(id, label) {
|
||||
if (!confirm(`Turn off the link for “${label}”? Whoever has it loses access straight away.`)) return;
|
||||
|
||||
Reference in New Issue
Block a user