Skip to content

Commit 7ec5f31

Browse files
committed
fix(web): repair bare-domain key redirect, enforce https providers, and polish profile UI
1 parent 6d9e8a0 commit 7ec5f31

8 files changed

Lines changed: 128 additions & 26 deletions

File tree

web/css/app.css

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,15 @@
9494

9595
/* ---- Profile button ----------------------------------------------------- */
9696

97-
/* When a name is set the button widens from a circle into a pill with the name
98-
beside the icon; with no name it stays the plain round icon button. */
97+
/* When a name is set (the .has-name class) the button widens from a circle into
98+
a pill with the icon + name; with no name it stays the plain round icon button.
99+
On narrow viewports the pill collapses back to a round badge showing initials
100+
(see the mobile override) so a long name can't overflow the top bar. */
99101
.profile-button {
100102
gap: 7px;
101103
}
102104

103-
.profile-button:has(.profile-button-name:not([hidden])) {
105+
.profile-button.has-name {
104106
width: auto;
105107
padding: 0 14px 0 11px;
106108
}
@@ -119,6 +121,15 @@
119121
white-space: nowrap;
120122
}
121123

124+
/* Initials badge: hidden by default (the name is shown instead); revealed only
125+
in the narrow-viewport override below. */
126+
.profile-button-initials {
127+
display: none;
128+
font-weight: 700;
129+
font-size: 13px;
130+
line-height: 1;
131+
}
132+
122133
/* ---- Identity card (profile modal) -------------------------------------- */
123134

124135
.identity-card {
@@ -723,6 +734,27 @@ textarea:focus::placeholder {
723734
gap: 6px;
724735
}
725736

737+
/* Collapse the named profile pill back to a round badge so a long name can't
738+
overflow the bar: hide the name, hide the person icon, show the initials
739+
centred in the circular button. */
740+
.profile-button.has-name {
741+
width: 38px;
742+
padding: 0;
743+
gap: 0;
744+
}
745+
746+
.profile-button.has-name .profile-button-name {
747+
display: none;
748+
}
749+
750+
.profile-button.has-name .profile-button-icon {
751+
display: none;
752+
}
753+
754+
.profile-button.has-name .profile-button-initials {
755+
display: inline-flex;
756+
}
757+
726758
.app-container {
727759
padding: 20px 18px;
728760
border-radius: var(--radius-md);

web/css/objects.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,21 @@
7070
height: 38px;
7171
border: 1px solid var(--border-color);
7272
background: var(--surface-2);
73+
/* Explicit colour so SVG icons (stroke: currentColor) and any button text
74+
stay legible in both themes; without it they inherited an ambient colour
75+
that washed out against the dark surface. */
76+
color: var(--text-color);
7377
border-radius: var(--radius-pill);
7478
cursor: pointer;
7579
font-size: 16px;
7680
text-decoration: none;
77-
transition: background 0.2s var(--ease), transform 0.2s var(--ease), border-color 0.2s var(--ease);
81+
transition: background 0.2s var(--ease), transform 0.2s var(--ease), border-color 0.2s var(--ease), color 0.2s var(--ease);
7882
}
7983

8084
.topbar-icon-button:hover {
8185
background: var(--surface-color);
8286
border-color: var(--border-strong);
87+
color: var(--text-color);
8388
transform: translateY(-1px);
8489
}
8590

web/docs/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,7 @@ <h3>Opening the app</h3>
610610
<tr><th>Parameter</th><th>Required</th><th>Description</th></tr>
611611
</thead>
612612
<tbody>
613-
<tr><td><code>provider</code></td><td>Yes</td><td>Your provider URL (HTTPS, or <code>http</code> only for loopback hosts). A bare domain is promoted to HTTPS.</td></tr>
613+
<tr><td><code>provider</code></td><td>Yes</td><td>Your provider URL. The scheme is optional: a bare host like <code>auth.example.com</code> is promoted to HTTPS automatically. If you do include a scheme it must be <code>https://</code> (only loopback hosts may use <code>http://</code>, for local development); any other scheme is rejected.</td></tr>
614614
<tr><td><code>provider_ecc</code></td><td>No</td><td>Set (e.g. <code>provider_ecc=1</code>) to request the compact X25519 key up front instead of the hybrid one. Use this when you know your server caps request URLs below ~4&nbsp;KB, to skip the hybrid attempt. Omit it to get the stronger hybrid key with automatic fallback.</td></tr>
615615
</tbody>
616616
</table>

web/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
<button type="button" id="settings-button" class="topbar-icon-button profile-button"
6868
title="Your profile: identity, key, password, and encryption options" aria-label="Open profile">
6969
<svg class="profile-button-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="4"></circle><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"></path></svg>
70+
<span id="profile-button-initials" class="profile-button-initials" aria-hidden="true"></span>
7071
<span id="profile-button-name" class="profile-button-name" hidden></span>
7172
</button>
7273
</nav>

web/js/main.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ const publinkCopyButton = document.getElementById("publink-copy-button");
2121
const publinkShareButton = document.getElementById("publink-share-button");
2222
const modeBadge = document.getElementById("mode-badge");
2323
const modeBadgeText = document.getElementById("mode-badge-text");
24-
const { xk, xt, xn, redirecting } = initParams();
2524

2625
// Matches a bare host (optionally with a port/path) that has no URL scheme,
2726
// e.g. "alice.com" or "alice.com/keys". Mirrors domainRegex in resolver.go.
@@ -115,6 +114,11 @@ function initParams() {
115114
return { xk: null, xt: null };
116115
}
117116

117+
// Resolve URL params now that initParams and the regexes/helpers it relies on are
118+
// all declared. (Calling it earlier hit the const temporal dead zone for the
119+
// regexes, throwing on bare-domain refs like ?xk=shib.me and halting startup.)
120+
const { xk, xt, xn, redirecting } = initParams();
121+
118122
function getEncryptionTarget() {
119123
if (!xk) {
120124
return "with your Key";
@@ -478,6 +482,13 @@ function setupModeUI() {
478482
? `Encrypting for ${xn}`
479483
: "Encrypting for the shared recipient";
480484
modeBadge.hidden = false;
485+
// Reflect the recipient in the page title so the tab is identifiable,
486+
// preferring the resolved name when the key URL provided one.
487+
if (xn) {
488+
document.title = `Encrypting for ${xn} · Xipher`;
489+
} else {
490+
document.title = "Encrypting a secret · Xipher";
491+
}
481492
// The visitor's own receive-link is not relevant in this flow.
482493
if (linkSection) {
483494
linkSection.hidden = true;

web/js/provider.js

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,27 +56,43 @@ function buildProviderUrl(providerUrl, publicKey, state) {
5656
return target.toString();
5757
}
5858

59-
// normalizeProviderUrl applies the same scheme rules as key URLs (see main.js):
60-
// https anywhere, http only for loopback hosts, bare domains promoted to https.
61-
// Returns the absolute URL string, or null if not permitted.
59+
// normalizeProviderUrl resolves a provider reference to an absolute URL the app
60+
// will redirect to. A scheme is optional: a schemeless host is promoted to
61+
// https (http for loopback hosts during local development), so "shib.me" and
62+
// "shib.me/issue" both work. If a scheme IS given it must be https, since the
63+
// provider receives the ephemeral public key and we never want it sent in the
64+
// clear; the only exception is http to a loopback host for development.
65+
// Returns { url } on success or { error } with a short reason otherwise.
6266
function normalizeProviderUrl(value) {
6367
if (!value) {
64-
return null;
68+
return { error: "invalid" };
6569
}
6670
value = value.trim();
67-
if (isFetchableUrl(value)) {
68-
return value;
69-
}
70-
if (!SCHEME_REGEX.test(value)) {
71-
const host = value.split("/")[0].split(":")[0];
72-
if (isLoopbackHost(host)) {
73-
return "http://" + value;
71+
72+
if (SCHEME_REGEX.test(value)) {
73+
let u;
74+
try {
75+
u = new URL(value);
76+
} catch (e) {
77+
return { error: "invalid" };
7478
}
75-
if (DOMAIN_REGEX.test(value)) {
76-
return "https://" + value;
79+
if (u.protocol === "https:" || (u.protocol === "http:" && isLoopbackHost(u.hostname))) {
80+
return { url: value };
7781
}
82+
// A scheme was supplied but it isn't allowed (e.g. http to a public host).
83+
return { error: "insecure" };
7884
}
79-
return null;
85+
86+
// Schemeless: promote to a scheme. Loopback hosts use http for local dev;
87+
// everything else uses https.
88+
const host = value.split("/")[0].split(":")[0];
89+
if (isLoopbackHost(host)) {
90+
return { url: "http://" + value };
91+
}
92+
if (DOMAIN_REGEX.test(value)) {
93+
return { url: "https://" + value };
94+
}
95+
return { error: "invalid" };
8096
}
8197

8298
// Maps a provider-returned error code to a user-facing message.
@@ -111,6 +127,12 @@ const providerModalClose = document.getElementById("provider-modal-close");
111127
// opts: { title, message, confirmLabel, confirmClass, detailLabel, detailValue }.
112128
function askProviderConsent(opts) {
113129
return new Promise((resolve) => {
130+
// The provider flow runs during startup while the preloader is still up
131+
// (z-index 2000). Hide it first so this modal (z-index 1500) is actually
132+
// visible and clickable, otherwise the consent sits behind the loader.
133+
if (typeof hidePreloader === "function") {
134+
hidePreloader();
135+
}
114136
providerModalTitle.textContent = opts.title;
115137
providerModalMessage.textContent = opts.message;
116138
providerModalConfirm.textContent = opts.confirmLabel || "Continue";
@@ -167,12 +189,18 @@ function clearProviderUrl() {
167189
// forceEcc is true the request uses the compact X25519 key directly. Returns
168190
// "redirecting" if it navigates away to the provider, otherwise null.
169191
async function initiateProviderFlow(rawProviderUrl, forceEcc) {
170-
const providerUrl = normalizeProviderUrl(rawProviderUrl);
171-
if (!providerUrl) {
172-
showToast("That credential provider URL is not valid.", "error");
192+
const resolved = normalizeProviderUrl(rawProviderUrl);
193+
if (resolved.error) {
194+
showToast(
195+
resolved.error === "insecure"
196+
? "Credential providers must use https. That provider URL was rejected."
197+
: "That credential provider URL is not valid.",
198+
"error"
199+
);
173200
clearProviderUrl();
174201
return null;
175202
}
203+
const providerUrl = resolved.url;
176204

177205
const host = new URL(providerUrl).host;
178206
const ok = await askProviderConsent({

web/js/service-worker.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
const CACHE_NAME = 'xipher-cache-v4';
1+
// The release workflow rewrites "xipher-cache" to "xipher-v<release-version>"
2+
// (see .github/workflows/release.yaml), so the cache name tracks the release and
3+
// busts on every version. Leave this placeholder as-is; do not hand-version it.
4+
const CACHE_NAME = 'xipher-cache';
25
const urlsToCache = [
36
'/',
47
'/index.html',

web/js/ui.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,23 +114,45 @@ const keySecretInput = document.getElementById("key-secret-input");
114114
const keySecretReveal = document.getElementById("key-secret-reveal");
115115
const keySecretGenerate = document.getElementById("key-secret-generate");
116116
const quantumSafeToggle = document.getElementById("quantum-safe-toggle");
117+
const profileButton = document.getElementById("settings-button");
117118
const profileButtonName = document.getElementById("profile-button-name");
119+
const profileButtonInitials = document.getElementById("profile-button-initials");
118120
const identityName = document.getElementById("identity-name");
119121
const identityContact = document.getElementById("identity-contact");
120122
const identityProvider = document.getElementById("identity-provider");
121123
const identityNameInput = document.getElementById("identity-name-input");
122124
const identityNameManaged = document.getElementById("identity-name-managed");
123125

124-
// Reflects the current identity onto the topbar profile button: shows the name
125-
// next to the icon when one is set, otherwise just the icon.
126+
// Derives up to two uppercase initials from a display name (e.g. "Alice Example"
127+
// -> "AE", "shibme" -> "S"). Falls back to "" when there's nothing usable.
128+
function initialsFromName(name) {
129+
const words = (name || "").trim().split(/\s+/).filter(Boolean);
130+
if (words.length === 0) {
131+
return "";
132+
}
133+
const first = Array.from(words[0])[0] || "";
134+
const last = words.length > 1 ? (Array.from(words[words.length - 1])[0] || "") : "";
135+
return (first + last).toUpperCase();
136+
}
137+
138+
// Reflects the current identity onto the topbar profile button. With a name set,
139+
// the button shows the name beside the icon on wider screens; on narrow viewports
140+
// it collapses to a round badge with the initials (see the has-name styles). With
141+
// no name it stays the plain round icon button.
126142
function renderProfileButton() {
127143
const { name } = getIdentity();
128144
if (name) {
129145
profileButtonName.textContent = name;
130146
profileButtonName.hidden = false;
147+
profileButtonInitials.textContent = initialsFromName(name);
148+
profileButton.classList.add("has-name");
149+
profileButton.setAttribute("title", `${name} · open profile`);
131150
} else {
132151
profileButtonName.textContent = "";
133152
profileButtonName.hidden = true;
153+
profileButtonInitials.textContent = "";
154+
profileButton.classList.remove("has-name");
155+
profileButton.setAttribute("title", "Your profile: identity, key, password, and encryption options");
134156
}
135157
}
136158

0 commit comments

Comments
 (0)