Files
Capsule/src/main/ttpClient.js
Joey Kimsey c0c257d5a4 avatar fix
2026-09-12 15:56:36 -04:00

696 lines
18 KiB
JavaScript

/**
* 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<string, string>} [options.form] - urlencoded body
* @param {Record<string, string|Blob>} [options.multipart] - multipart fields (avatar)
* @param {Record<string, string|number>} [options.query] - query string
* @return {Promise<object>} - 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<object>}
*/
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<string, string>} fields - form fields
* @param {object} [avatar] - IPC file payload
* @return {Promise<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
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<object>}
*/
export async function sendBugreport(siteUrl, token, fields) {
return ttpRequest({
siteUrl,
path: '/api/bugreport',
method: 'POST',
token,
form: fields
})
}