Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@
"index.copyLink": "2. Copy link",
"index.copyLinkDescription": "Click on the button below to copy the link to your clipboard.",
"index.copyLinkButton": "Copy link to clipboard",
"index.sessionTransferError": "Unable to transfer the session. Please try again.",
"index.transferToSystem": "3. Copy session to new system",
"index.transferToSystemDescription": "Open the copied link in the target browser or device to transfer your session.",
"index.code": "Code",
Expand Down
165 changes: 128 additions & 37 deletions src/static/js/welcome.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import html10n from './vendors/html10n';

const checkmark = '<svg width="28" height="28" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3" stroke="currentColor"><path vector-effect="non-scaling-stroke" stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>';

function getCookie(name: string) {
Expand All @@ -11,46 +13,137 @@ function getCookie(name: string) {

const cp = (window as any).clientVars?.cookiePrefix || '';

const sessionTransferErrorFallback = () =>
html10n.get('index.sessionTransferError') || 'Unable to transfer the session. Please try again.';

const safeJson = async (response: Response): Promise<unknown> => {
try {
return await response.json();
} catch {
return null;
}
};

const responseErrorMessage = (responseData: unknown): string => {
const data = responseData as Record<string, unknown>;
if (
responseData &&
typeof responseData === 'object' &&
'error' in responseData &&
typeof data.error === 'string' &&
data.error.trim() !== ''
) {
return data.error;
}
return sessionTransferErrorFallback();
};

const showSessionTransferError = (element: HTMLElement | null, message: string) => {
if (!element) return;
element.textContent = message;
element.style.display = 'block';
};

const hideSessionTransferError = (element: HTMLElement | null) => {
if (!element) return;
element.textContent = '';
element.style.display = 'none';
};

function handleTransferOfSession() {
const transferNowButton = document.querySelector('[data-l10n-id="index.transferSessionNow"]')! as HTMLButtonElement;

transferNowButton.addEventListener('click', async () => {
const originalButtonContent = transferNowButton.innerHTML;
const copyLinkSection = document.getElementById('copy-link-section');
const errorElement = document.getElementById('transfer-session-error');
hideSessionTransferError(errorElement);
if (copyLinkSection) copyLinkSection.style.display = 'none';
transferNowButton.style.display = 'inline-flex';
transferNowButton.style.alignItems = 'center';
transferNowButton.style.justifyContent = 'center';
transferNowButton.innerHTML = `${checkmark}`;
transferNowButton.disabled = true;

// The author token is HttpOnly (ether/etherpad#6701 PR3) so we cannot
// read it via document.cookie. Send only the JS-readable prefsHttp; the
// server reads the token off the request's own cookie jar.
const responseWithId = await fetch("./tokenTransfer", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
prefsHttp: getCookie(`${cp}prefsHttp`) || getCookie('prefsHttp'),
})
})
try {
// The author token is HttpOnly (ether/etherpad#6701 PR3) so we cannot
// read it via document.cookie. Send only the JS-readable prefsHttp; the
// server reads the token off the request's own cookie jar.
const responseWithId = await fetch("./tokenTransfer", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
prefsHttp: getCookie(`${cp}prefsHttp`) || getCookie('prefsHttp'),
})
});

const copyLinkSection = document.getElementById('copy-link-section')
if (!copyLinkSection) return;
copyLinkSection.style.display = 'block';

const copyButton = document.querySelector('#copy-link-section .btn-secondary') as HTMLButtonElement
const responseData = await responseWithId.json();
copyButton.addEventListener('click', async ()=>{
await navigator.clipboard.writeText(responseData.id);
copyButton.style.display = 'inline-flex';
copyButton.style.alignItems = 'center';
copyButton.style.justifyContent = 'center';
copyButton.innerHTML = `${checkmark}`;
copyButton.disabled = true;
})
const responseData = await safeJson(responseWithId);
if (!responseWithId.ok) {
throw new Error(responseErrorMessage(responseData));
}
const transferData = responseData as Record<string, unknown>;
if (!responseData || typeof responseData !== 'object' ||
!('id' in responseData) || typeof transferData.id !== 'string' ||
transferData.id.trim() === '') {
Comment on lines +86 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. id accepts malformed values 📎 Requirement gap ≡ Correctness

The create flow treats every non-empty string as a valid transfer ID, so a malformed 2xx payload
such as {id: "x"} exposes a copy-success state containing an unusable code. Created IDs are UUIDs
and the redeem flow requires 36 characters, but this validation does not enforce that format.
Agent Prompt
## Issue description
The create-session response accepts any non-empty string as a transfer ID, allowing malformed IDs to trigger the success and copy UI.

## Issue Context
The server creates IDs with `crypto.randomUUID()`, and the receive flow expects a 36-character transfer code. Validate the returned ID against the actual UUID format before exposing it, and add regression coverage for non-empty malformed IDs.

## Fix Focus Areas
- src/static/js/welcome.ts[86-89]
- src/tests/frontend-new/specs/welcome.spec.ts[109-126]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

throw new Error(sessionTransferErrorFallback());
}

if (!copyLinkSection) throw new Error(sessionTransferErrorFallback());
copyLinkSection.style.display = 'block';

const copyButton = document.querySelector('#copy-link-section .btn-secondary') as HTMLButtonElement;
copyButton.disabled = false;
copyButton.onclick = async () => {
await navigator.clipboard.writeText(transferData.id as string);
copyButton.style.display = 'inline-flex';
copyButton.style.alignItems = 'center';
copyButton.style.justifyContent = 'center';
copyButton.innerHTML = `${checkmark}`;
copyButton.disabled = true;
};
transferNowButton.innerHTML = `${checkmark}`;
} catch (err) {
if (copyLinkSection) copyLinkSection.style.display = 'none';
transferNowButton.innerHTML = originalButtonContent;
transferNowButton.disabled = false;
showSessionTransferError(
errorElement,
err instanceof Error && err.message ? err.message : sessionTransferErrorFallback());
}
});
}

const isValidTransferCode = (code: string) => code.length === 36;

async function redeemTransferCode(
code: string,
transferSessionButton: HTMLButtonElement,
errorElement: HTMLElement | null) {
hideSessionTransferError(errorElement);
transferSessionButton.disabled = true;

try {
const response = await fetch("./tokenTransfer/"+code, {
method: 'GET'
});
const responseData = await safeJson(response);
if (!response.ok) {
throw new Error(responseErrorMessage(responseData));
}
const transferData = responseData as Record<string, unknown>;
if (!responseData || typeof responseData !== 'object' ||
!('ok' in responseData) || transferData.ok !== true) {
throw new Error(sessionTransferErrorFallback());
}
window.location.reload()
} catch (err) {
transferSessionButton.disabled = !isValidTransferCode(code);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Stale code enables submit 🐞 Bug ≡ Correctness

When redemption fails, redeemTransferCode() restores the button from the captured submitted
code, not the code currently in the editable input. If the user changes the input to an invalid
value while the request is pending, the catch block overrides the input listener and enables the
button, allowing an invalid redemption request.
Agent Prompt
## Issue description
A failed redeem request re-enables the transfer button according to the originally submitted code, even when the user has since edited the input to an invalid value.

## Issue Context
The input remains editable while the fetch is pending. Its input listener correctly updates the disabled state from the current value, but the request catch block can later overwrite that state using stale data.

## Fix Focus Areas
- src/static/js/welcome.ts[119-145]
- src/static/js/welcome.ts[182-201]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

showSessionTransferError(
errorElement,
err instanceof Error && err.message ? err.message : sessionTransferErrorFallback());
}
}

const handleSettingsButtonClick = () => {
const settingsButton = document.querySelector('.settings-button')!;
Expand Down Expand Up @@ -86,24 +179,22 @@ const handleMenuBarClicked = () => {
});
})

const transferSessionButton = document.getElementById('transferSessionButton')
const transferSessionButton = document.getElementById('transferSessionButton') as HTMLButtonElement | null;
const codeInputField = document.getElementById('codeInput') as HTMLInputElement
if (transferSessionButton) {
transferSessionButton.addEventListener('click', ()=>{
const code = codeInputField.value
fetch("./tokenTransfer/"+code, {
method: 'GET'
})
.then(res => res.json())
.then(()=>{
window.location.reload()
})
const code = codeInputField.value;
redeemTransferCode(
code,
transferSessionButton,
document.getElementById('receive-session-error'));
});
}

if (codeInputField) {
codeInputField.addEventListener('input', (e)=>{
if ((e.target as HTMLInputElement).value?.length === 36) {
hideSessionTransferError(document.getElementById('receive-session-error'));
if (isValidTransferCode((e.target as HTMLInputElement).value)) {
transferSessionButton?.removeAttribute('disabled');
} else {
transferSessionButton?.setAttribute('disabled', 'true');
Expand Down
2 changes: 2 additions & 0 deletions src/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ <h1>Etherpad</h1>
<h3 data-l10n-id="index.transferSession"></h3>
<div data-l10n-id="index.transferSessionDescription"></div>
<button type="button" class="btn-secondary" style="margin-top: 20px" data-l10n-id="index.transferSessionNow"></button>
<div id="transfer-session-error" role="alert" aria-live="assertive" style="display: none; color: #b00020; margin-top: 10px;"></div>

<!-- Copy link button -->
<div style="display: none" id="copy-link-section">
Expand All @@ -202,6 +203,7 @@ <h3 data-l10n-id="index.transferToSystem"></h3>
</div>

<button data-l10n-id="index.transferSessionTitle" id="transferSessionButton" disabled></button>
<div id="receive-session-error" role="alert" aria-live="assertive" style="display: none; color: #b00020; margin-top: 10px;"></div>
</div>

<div>
Expand Down
Loading