696 lines
18 KiB
JavaScript
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
|
|
})
|
|
}
|