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:
Alexander Heldt
2026-09-07 19:25:20 +00:00
parent babed44c25
commit 66f89b35a9
8 changed files with 183 additions and 76 deletions
+31 -19
View File
@@ -984,34 +984,20 @@ button.linklike:hover { text-decoration: underline; filter: none; }
}
/* ---------- guest links ---------- */
/* The one-time URL. Shown once and never again, so it gets a box of its own
rather than sitting inline where it could be missed. */
.guest-new {
background: var(--accent-soft);
border-radius: var(--radius);
padding: 10px 12px;
margin: 12px 0;
}
.guest-new .settings-hint { margin: 0 0 6px; }
.guest-url {
display: block;
font-size: 0.8rem;
word-break: break-all;
margin-bottom: 8px;
line-height: 1.4;
}
.guest-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
}
/* A link is two stacked rows: who and when on top, the URL underneath. */
.guest-item {
padding: 10px 0;
border-top: 1px solid var(--border);
}
.guest-item-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-top: 1px solid var(--border);
}
.guest-item-main {
flex: 1;
@@ -1029,6 +1015,32 @@ button.linklike:hover { text-decoration: underline; filter: none; }
}
button.guest-revoke { color: var(--danger); flex: none; }
/* The URL, always available so the link can be re-sent. It is 90-odd characters
of hex, so it gets one truncated line rather than wrapping into a wall of it
— the Copy button is the way it is meant to be taken, and tapping the text
selects the whole thing for anywhere the clipboard API is unavailable. */
.guest-item-url {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0 0;
}
.guest-url {
flex: 1;
min-width: 0;
font-size: 0.75rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: text;
}
button.guest-copy {
flex: none;
padding: 4px 10px;
font-size: 0.75rem;
}
/* Only a guest ever sees this, directly under the header. No bottom margin:
main's own top padding provides the gap to the first panel. */
.guest-banner {