/** * HTTP calls to a TTP site. Runs in the main process so CORS does not apply. */ /** * Normalize a TTP site URL to scheme + host + optional path, no trailing slash. * * @param {string} raw - what the user typed * @return {string} - canonical site base */ export function normalizeSiteUrl(raw) { const trimmed = String(raw ?? '').trim() if (!trimmed) { throw new Error('Enter the site URL.') } let parsed try { parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`) } catch { throw new Error('Enter a valid site URL.') } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error('Site URL must start with http:// or https://.') } const path = parsed.pathname.replace(/\/+$/, '') return `${parsed.origin}${path === '/' ? '' : path}` } /** * Call a TTP API path and parse JSON. * * @param {object} options - request * @param {string} options.siteUrl - canonical site base * @param {string} options.path - path starting with /api/ * @param {string} [options.method='GET'] - HTTP method * @param {string} [options.token] - Bearer token * @param {Record} [options.form] - urlencoded body * @param {Record} [options.multipart] - multipart fields (avatar) * @param {Record} [options.query] - query string * @return {Promise} - parsed JSON */ export async function ttpRequest(options) { const siteUrl = options.siteUrl const path = options.path const method = options.method || 'GET' const token = options.token const form = options.form const multipart = options.multipart const headers = { Accept: 'application/json' } let url = `${siteUrl}${path}` let body if (options.query && typeof options.query === 'object') { const qs = new URLSearchParams() Object.entries(options.query).forEach(([key, value]) => { if (value === undefined || value === null || value === '') { return } qs.set(key, String(value)) }) const encoded = qs.toString() if (encoded) { url += (path.includes('?') ? '&' : '?') + encoded } } if (token) { headers.Authorization = `Bearer ${token}` } if (multipart instanceof FormData) { body = multipart } else if (multipart) { const data = new FormData() Object.entries(multipart).forEach(([key, value]) => { if (value === undefined || value === null || value === '') { return } data.append(key, value) }) body = data } else if (form) { headers['Content-Type'] = 'application/x-www-form-urlencoded' body = new URLSearchParams(form).toString() } else if (method === 'POST') { headers['Content-Type'] = 'application/x-www-form-urlencoded' body = 'submit=1' } let response try { response = await fetch(url, { method, headers, body, redirect: 'follow' }) } catch { throw new Error('Could not reach that site.') } const text = await response.text() let data try { data = JSON.parse(text) } catch { throw new Error(`The site did not return API JSON (${response.status}).`) } return data } /** * Map a TTP API error string to a short user-facing line. * * @param {string} code - API `error` value * @param {unknown} [errors] - optional Check user errors * @return {string} - message for the login form */ export function apiErrorMessage(code, errors) { switch (code) { case 'malformed input': return firstUserError(errors) || 'Check the form and try again.' case 'bad credentials': return 'Those credentials were not accepted.' case 'invalid token': case 'invalid secret': return 'That API token was not accepted.' case 'token expired': return 'That API token has expired.' case 'IRDK': return 'The site could not refresh this token.' case 'no valid MFA methods': return 'This account has no usable MFA method. Contact support.' case 'invalid MFA': return 'That authentication code was not accepted.' case 'MFA expired': return 'This sign-in challenge expired. Sign in again.' case 'Could not send MFA code': return 'Could not send an authentication code. Try another method.' case 'Choose an MFA method': return 'Choose how you want to authenticate.' case 'user token required': return 'This action needs a personal (user) token, not an app token.' case 'not found': return 'This site is missing that Capsule API action. Pull the latest TTP on the server.' default: return code || 'The site returned an error.' } } /** * First string from an API `errors` blob. * * @param {unknown} errors - Check user errors * @return {string} */ function firstUserError(errors) { if (!errors) { return '' } if (typeof errors === 'string') { return errors } if (Array.isArray(errors)) { for (const item of errors) { const text = firstUserError(item) if (text) { return text } } return '' } if (typeof errors === 'object') { for (const value of Object.values(errors)) { const text = firstUserError(value) if (text) { return text } } } return '' } /** * Token, MFA challenge, or a thrown Error from an API auth body. * * @param {object} data - parsed JSON * @param {string} [missingToken] - error when neither token nor mfa is present * @return {{token?: string, mfa?: object}} */ function authResult(data, missingToken) { if (data.error) { throw new Error(apiErrorMessage(data.error, data.errors)) } if (data.mfa && typeof data.mfa === 'object') { return { mfa: data.mfa } } if (!data.token) { throw new Error(missingToken || 'The site did not return a token.') } return { token: data.token } } /** * Sign in with username and password. POST api/login. * * @param {string} siteUrl - canonical site base * @param {string} username - TTP username * @param {string} password - TTP password * @return {Promise<{token?: string, mfa?: object}>} - token or MFA challenge */ export async function loginWithPassword(siteUrl, username, password) { const data = await ttpRequest({ siteUrl, path: '/api/login', method: 'POST', form: { username, password } }) if (data.error === 'malformed input' && !data.errors) { throw new Error('Username and password are required.') } return authResult(data) } /** * Path for a pending MFA challenge. * * @param {string} loginCode - capability id from api/login * @param {string} [suffix] - extra path (e.g. /reset) * @return {string} */ function mfaPath(loginCode, suffix) { const base = `/api/login/mfa/${encodeURIComponent(loginCode)}` return suffix ? `${base}${suffix}` : base } /** * Submit a 6-digit MFA code. POST api/login/mfa/{loginCode}. * * @param {string} siteUrl - canonical site base * @param {string} loginCode - pending challenge id * @param {string} authCode - submitted code * @return {Promise<{token?: string, mfa?: object}>} */ export async function submitMfaCode(siteUrl, loginCode, authCode) { const data = await ttpRequest({ siteUrl, path: mfaPath(loginCode), method: 'POST', form: { auth_code: authCode } }) return authResult(data) } /** * Pick an MFA method. POST api/login/mfa/{loginCode}. * * @param {string} siteUrl - canonical site base * @param {string} loginCode - pending challenge id * @param {string} method - mfa_phone, mfa_email, or mfa_app * @return {Promise<{token?: string, mfa?: object}>} */ export async function selectMfaMethod(siteUrl, loginCode, method) { const data = await ttpRequest({ siteUrl, path: mfaPath(loginCode), method: 'POST', form: { mfaMethodSelect: method } }) return authResult(data) } /** * Clear the chosen MFA method. POST api/login/mfa/{loginCode}/reset. * * @param {string} siteUrl - canonical site base * @param {string} loginCode - pending challenge id * @return {Promise<{token?: string, mfa?: object}>} */ export async function resetMfaMethod(siteUrl, loginCode) { const data = await ttpRequest({ siteUrl, path: mfaPath(loginCode, '/reset'), method: 'POST', form: { submit: '1' } }) return authResult(data) } /** * Look up a user id. GET api/users/find/{id|username}. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {string} idOrUsername - user id or username * @return {Promise<{userId: number|string|null, error: string}>} - id on success */ export async function findUser(siteUrl, token, idOrUsername) { const path = `/api/users/find/${encodeURIComponent(idOrUsername)}` const data = await ttpRequest({ siteUrl, path, method: 'GET', token }) if (data.error) { return { userId: null, error: String(data.error) } } if (data.data == null) { return { userId: null, error: 'No user found.' } } return { userId: data.data, error: '' } } /** * True when the API rejected the stored Bearer token itself. * * @param {string} error - API `error` value * @return {boolean} - true when the session should be cleared */ export function isDeadTokenError(error) { return error === 'invalid token' || error === 'invalid secret' || error === 'token expired' } /** * Throw a user-facing Error from an API JSON body when `error` is set. * * @param {object} data - parsed JSON * @return {object} - data when there is no error */ export function unwrapApi(data) { if (data?.error) { const err = new Error(apiErrorMessage(data.error, data.errors)) if (isDeadTokenError(data.error)) { err.code = 'DEAD_TOKEN' } err.apiError = data.error throw err } return data } /** * Bytes for an avatar sent over IPC. * * @param {unknown} data - ArrayBuffer, typed array, Buffer, or serialized Buffer * @return {Buffer|null} */ function avatarBytes(data) { if (!data) { return null } if (Buffer.isBuffer(data)) { return data } if (data instanceof ArrayBuffer) { return Buffer.from(data) } if (ArrayBuffer.isView(data)) { return Buffer.from(data.buffer, data.byteOffset, data.byteLength) } if (data.type === 'Buffer' && Array.isArray(data.data)) { return Buffer.from(data.data) } if (Array.isArray(data)) { return Buffer.from(data) } try { const bytes = Buffer.from(data) return bytes.length ? bytes : null } catch { return null } } /** * File/Blob for an avatar sent over IPC. * * @param {object} [avatar] - name, type, data (ArrayBuffer or typed array) * @return {Blob|null} */ export function avatarBlob(avatar) { const bytes = avatarBytes(avatar?.data) if (!bytes) { return null } const type = avatar.type || 'application/octet-stream' const name = avatar.name || 'avatar.jpg' if (typeof File === 'function') { return new File([bytes], name, { type }) } return new Blob([bytes], { type }) } /** * Current user. GET api/profile. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @return {Promise} */ export async function getProfile(siteUrl, token) { return ttpRequest({ siteUrl, path: '/api/profile', method: 'GET', token }) } /** * Save name, prefs, optional avatar. POST api/profile/update. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {Record} fields - form fields * @param {object} [avatar] - IPC file payload * @return {Promise} */ export async function updateProfile(siteUrl, token, fields, avatar) { const multipart = { ...(fields || {}) } const file = avatarBlob(avatar) if (file) { const data = new FormData() Object.entries(multipart).forEach(([key, value]) => { if (value === undefined || value === null || value === '') { return } data.append(key, value) }) if (typeof File === 'function' && file instanceof File) { data.append('avatar', file) } else { data.append('avatar', file, avatar.name || 'avatar.jpg') } return ttpRequest({ siteUrl, path: '/api/profile/update', method: 'POST', token, multipart: data }) } return ttpRequest({ siteUrl, path: '/api/profile/update', method: 'POST', token, form: fields }) } /** * Paged notifications. GET api/notifications. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {number} [page=1] - pager page * @return {Promise} */ export async function listNotifications(siteUrl, token, page = 1) { return ttpRequest({ siteUrl, path: '/api/notifications', method: 'GET', token, query: { page } }) } /** * Mark a notification read. POST api/notifications/read/{id}. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {string|number} id - notification id * @return {Promise} */ export async function readNotification(siteUrl, token, id) { return ttpRequest({ siteUrl, path: `/api/notifications/read/${encodeURIComponent(id)}`, method: 'POST', token }) } /** * Soft-delete a notification. POST api/notifications/delete/{id}. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {string|number} id - notification id * @return {Promise} */ export async function deleteNotification(siteUrl, token, id) { return ttpRequest({ siteUrl, path: `/api/notifications/delete/${encodeURIComponent(id)}`, method: 'POST', token }) } /** * Paged inbox. GET api/messages. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {number} [page=1] - pager page * @return {Promise} */ export async function listMessages(siteUrl, token, page = 1) { return ttpRequest({ siteUrl, path: '/api/messages', method: 'GET', token, query: { page } }) } /** * Recent conversations for the header dropdown. GET api/messages/recent. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {number} [limit=5] - max rows * @return {Promise} */ export async function recentMessages(siteUrl, token, limit = 5) { return ttpRequest({ siteUrl, path: '/api/messages/recent', method: 'GET', token, query: { limit } }) } /** * One conversation. GET api/messages/view/{id}. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {string|number} id - conversation id * @param {boolean} [markRead=true] - false sends markRead=0 * @return {Promise} */ export async function viewMessage(siteUrl, token, id, markRead = true) { return ttpRequest({ siteUrl, path: `/api/messages/view/${encodeURIComponent(id)}`, method: 'GET', token, query: markRead ? undefined : { markRead: 0 } }) } /** * Start a conversation. POST api/messages/create. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {string} toUser - username * @param {string} message - body * @return {Promise} */ export async function createMessage(siteUrl, token, toUser, message) { return ttpRequest({ siteUrl, path: '/api/messages/create', method: 'POST', token, form: { toUser, message } }) } /** * Reply in a conversation. POST api/messages/reply/{id}. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {string|number} id - conversation id * @param {string} message - body * @return {Promise} */ export async function replyMessage(siteUrl, token, id, message) { return ttpRequest({ siteUrl, path: `/api/messages/reply/${encodeURIComponent(id)}`, method: 'POST', token, form: { message } }) } /** * Mark a conversation read. POST api/messages/read/{id}. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {string|number} id - conversation id * @return {Promise} */ export async function readMessage(siteUrl, token, id) { return ttpRequest({ siteUrl, path: `/api/messages/read/${encodeURIComponent(id)}`, method: 'POST', token }) } /** * Mark a conversation unread. POST api/messages/unread/{id}. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {string|number} id - conversation id * @return {Promise} */ export async function unreadMessage(siteUrl, token, id) { return ttpRequest({ siteUrl, path: `/api/messages/unread/${encodeURIComponent(id)}`, method: 'POST', token }) } /** * Hide a conversation. POST api/messages/delete/{id}. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {string|number} id - conversation id * @return {Promise} */ export async function deleteMessage(siteUrl, token, id) { return ttpRequest({ siteUrl, path: `/api/messages/delete/${encodeURIComponent(id)}`, method: 'POST', token }) } /** * Site search. GET api/search. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {object} query - q, resource, page, results * @return {Promise} */ export async function searchSite(siteUrl, token, query) { return ttpRequest({ siteUrl, path: '/api/search', method: 'GET', token, query }) } /** * Contact form. POST api/contact. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {object} fields - name, entry, optional email * @return {Promise} */ export async function sendContact(siteUrl, token, fields) { return ttpRequest({ siteUrl, path: '/api/contact', method: 'POST', token, form: fields }) } /** * Bug report. POST api/bugreport. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {object} fields - url, ourl, repeat, entry * @return {Promise} */ export async function sendBugreport(siteUrl, token, fields) { return ttpRequest({ siteUrl, path: '/api/bugreport', method: 'POST', token, form: fields }) }