Skip to content

src/app.ts

190 lines · 10.1 KB · TypeScript
Raw
/**
 * The Connect page. It is the only place that creates an Amazon login link,
 * so the link always comes from this locked package's reviewed code.
 */

import { packageContext } from 'kody:runtime'
import completeAuth from './complete-auth.ts'
import { disconnect } from './disconnect.ts'
import { listLocaleCodes, resolveLocale } from './locales.ts'
import startAuth from './start-auth.ts'
import authStatus from './status.ts'

type Status = Awaited<ReturnType<typeof authStatus>>

/** What the page shows: the current status, a login in progress, or an outcome. */
type View =
	| { step: 'status' }
	| { step: 'login'; loginUrl: string; authSessionId: string }
	| { step: 'done'; message: string; previousDevice: string }
	| { step: 'disconnected'; deviceRemoved: boolean; secretsDeleted: boolean; locale: string | null }
	| { step: 'error'; message: string }

function paths() {
	const base = packageContext?.appBasePath || '/packages/audible-auth'
	const hosted = packageContext?.hostedUrl || `https://kody.kody.run${base}`
	const mount = base.endsWith('/') ? base : `${base}/`
	return { base, mount, origin: new URL(hosted).origin }
}

/** Amazon's Manage Your Content and Devices list, where old virtual devices can be removed. */
function amazonDevicesUrl(locale: string | null): string {
	return `https://www.amazon.${resolveLocale(locale ?? 'us').domain}/hz/mycd/digital-console/alldevices`
}

function esc(value: unknown): string {
	return String(value ?? '')
		.replaceAll('&', '&amp;')
		.replaceAll('<', '&lt;')
		.replaceAll('>', '&gt;')
		.replaceAll('"', '&quot;')
}

/** A "back to the app" link, accepted only for an app on this same Kody host. */
function returnUrl(raw: unknown, origin: string): string | null {
	if (typeof raw !== 'string' || !raw) return null
	try {
		const url = new URL(raw)
		return url.origin === origin && url.pathname.startsWith('/packages/') ? url.toString() : null
	} catch {
		return null
	}
}

function render(status: Status, view: View, back: string | null): Response {
	const { mount } = paths()
	const hidden = back ? `<input type="hidden" name="return" value="${esc(back)}">` : ''
	const locales = listLocaleCodes()
		.map((code) => `<option value="${code}"${code === (status.locale ?? 'us') ? ' selected' : ''}>${code.toUpperCase()}</option>`)
		.join('')

	const state = !status.connected
		? 'Not connected.'
		: status.ready
			? `Connected (${esc(status.locale)}).`
			: `Connected (${esc(status.locale)}). One step left: allow the tokens’ hosts below.`

	const approvals = status.connected && !status.ready && status.hostApprovals.length
		? `<section class="box"><h2>Allow each token’s hosts</h2>
<p>Each button opens Kody with the secret and its hosts filled in. Click <strong>Allow</strong> there, then reload this page.</p>
<ul>${status.hostApprovals
				.map((a) => `<li>${a.url ? `<a class="btn" href="${esc(a.url)}" target="_blank" rel="noopener">Allow ${esc(a.secret)}</a>` : `<code>${esc(a.secret)}</code>`}<div>Hosts: ${a.hosts.map(esc).join(', ')}</div></li>`)
				.join('')}</ul></section>`
		: ''

	const flow =
		view.step === 'login'
			? `<section class="box"><h2>2. Sign in on Amazon</h2>
<p><a class="btn primary" href="${esc(view.loginUrl)}" target="_blank" rel="noopener">Open Amazon sign-in</a></p>
<p>Amazon ends on an error page at <code>…/ap/maplanding?…</code>. That page is expected. Copy its full address.</p>
<form method="post" action="${esc(mount)}complete">${hidden}
<input type="hidden" name="authSessionId" value="${esc(view.authSessionId)}">
<label>3. Paste the maplanding URL<textarea name="redirectUrl" required></textarea></label>
<button class="btn primary" type="submit">Finish connect</button></form></section>`
			: `<form method="post" action="${esc(mount)}start" class="box">${hidden}
<h2>1. Choose your marketplace</h2>
<select name="locale">${locales}</select>
<button class="btn primary" type="submit">${status.connected ? 'Connect again' : 'Connect Audible'}</button></form>`

	const flash =
		view.step === 'done'
			? `<p class="ok">${esc(view.message)} ${view.previousDevice === 'removed' ? 'The previous virtual device was removed from your Amazon account.' : view.previousDevice === 'not-removed' ? 'The previous virtual device could not be removed; remove it in your Amazon account.' : ''}</p>`
			: view.step === 'disconnected'
				? `<p class="${view.deviceRemoved && view.secretsDeleted ? 'ok' : 'err'}">Disconnected. ${view.deviceRemoved ? 'The virtual device was removed from your Amazon account, so its tokens no longer work.' : 'The virtual device could not be removed: remove it in your Amazon account’s device list.'} ${view.secretsDeleted ? 'Both token secrets were deleted from Kody.' : 'Delete the token secrets in Kody yourself.'}</p>`
				: view.step === 'error'
					? `<p class="err">${esc(view.message)}</p>`
					: ''

	const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connect Audible</title>
<style>
body{font:16px/1.45 system-ui,sans-serif;max-width:40rem;margin:0 auto;padding:1rem;color:#1d1d1f;background:#f4f4f6}
h1{font-size:1.4rem}h2{font-size:1rem;margin:0 0 .5rem}
.box{background:#fff;border-radius:14px;padding:1rem;margin:1rem 0}
.btn{display:inline-block;padding:.6rem 1rem;border-radius:10px;border:1px solid #ccc;background:#fff;color:inherit;text-decoration:none;font:inherit;cursor:pointer}
.primary{background:#e8952f;border-color:#e8952f;font-weight:600}
select,textarea{display:block;width:100%;box-sizing:border-box;margin:.5rem 0;padding:.5rem;font:inherit;border-radius:10px;border:1px solid #ccc}
textarea{min-height:6rem}ul{list-style:none;padding:0}li{margin:.75rem 0}
.ok{color:#1a7f37}.err{color:#b42318}.muted{color:#6e6e73;font-size:.9rem}
</style></head><body>
<h1>Connect Audible</h1>
<p class="muted">This page belongs to the locked package that holds your Audible credentials. Start Connect only here: a login link from anywhere else may belong to someone else. Credentials never appear on this page.</p>
${flash}
<p><strong>${state}</strong></p>
${approvals}
${flow}
${status.connected ? `<section class="box"><h2>NAS archive: ${status.archiveEnabled ? 'on' : 'off'}</h2>
<p>NAS archive lets an app save Books you own to your NAS. Audible allows those downloads only with this device's signing key.</p>
<p>${status.archiveEnabled
		? status.archiveReady
			? 'This setup keeps the signing key, so apps can save Books to your NAS.'
			: 'The feature is on, but this device has no signing key yet. Tap <strong>Connect again</strong> to get one.'
		: 'This setup keeps no signing key, so no app can save Books to your NAS. To turn the feature on, ask your agent to follow “Turning NAS archive on” in this package’s README.'}</p></section>` : ''}
${status.connected || view.step === 'disconnected' ? `<section class="box"><h2>Your Amazon devices</h2>
<p>Each Connect adds a virtual device named like <em>${esc(status.deviceName ?? 'Your Audible for iPhone')}</em>. ${status.connected ? `Keep <strong>${esc(status.deviceName ?? 'the newest one')}</strong>; remove older “Audible for iPhone” devices from earlier setups.` : 'Remove any “Audible for iPhone” device left from this setup.'}</p>
<p><a class="btn" href="${esc(amazonDevicesUrl(view.step === 'disconnected' ? view.locale : status.locale))}" target="_blank" rel="noopener">Open your Amazon devices</a></p></section>` : ''}
${status.connected && view.step !== 'login' ? `<form method="post" action="${esc(mount)}disconnect" class="box" onsubmit="return confirm('Disconnect Audible? Apps that use this package stop working until you connect again.')">${hidden}
<h2>Disconnect</h2>
<p>Removes the virtual device from your Amazon account and deletes both token secrets.</p>
<button class="btn" type="submit">Disconnect Audible</button></form>` : ''}
${back ? `<p><a class="btn" href="${esc(back)}">Back to the app</a></p>` : ''}
</body></html>`
	return new Response(html, {
		status: view.step === 'error' ? 400 : 200,
		headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
	})
}

async function readForm(request: Request): Promise<Record<string, string>> {
	const form = await request.formData()
	const fields: Record<string, string> = {}
	for (const [key, value] of form) if (typeof value === 'string') fields[key] = value
	return fields
}

export default {
	async fetch(request: Request): Promise<Response> {
		const { base, origin } = paths()
		const url = new URL(request.url)
		let path = url.pathname.startsWith(base) ? url.pathname.slice(base.length) : url.pathname
		path = path.replace(/\/+$/, '') || '/'
		const method = request.method.toUpperCase()

		if (method === 'POST') {
			// The app session cookie rides along with browser posts. Refuse cross-site forms.
			const from = request.headers.get('origin')
			const site = request.headers.get('sec-fetch-site')
			if ((from && from !== url.origin) || (site && site !== 'same-origin' && site !== 'none')) {
				return new Response('Cross-origin POST denied', { status: 403 })
			}
		}

		if (method === 'GET' && path === '/') {
			return render(await authStatus(), { step: 'status' }, returnUrl(url.searchParams.get('return'), origin))
		}

		if (method === 'POST' && (path === '/start' || path === '/complete' || path === '/disconnect')) {
			const form = await readForm(request)
			const back = returnUrl(form.return, origin)
			try {
				if (path === '/disconnect') {
					const { locale } = await authStatus()
					const result = await disconnect()
					return render(await authStatus(), { step: 'disconnected', ...result, locale }, back)
				}
				if (path === '/start') {
					const started = await startAuth({ locale: form.locale })
					return render(await authStatus(), { step: 'login', loginUrl: started.loginUrl, authSessionId: started.authSessionId }, back)
				}
				const result = await completeAuth({ redirectUrl: form.redirectUrl ?? '', authSessionId: form.authSessionId })
				return render(result.status, { step: 'done', message: result.message, previousDevice: result.previousDevice }, back)
			} catch (error) {
				const message = error instanceof Error ? error.message : String(error)
				return render(await authStatus(), { step: 'error', message }, back)
			}
		}

		return new Response('Not found', { status: 404 })
	},
}