Initial commit
This commit is contained in:
162
src/main/ttpClient.js
Normal file
162
src/main/ttpClient.js
Normal file
@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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
|
||||
* @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 url = `${siteUrl}${path}`
|
||||
const headers = { Accept: 'application/json' }
|
||||
let body
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (form) {
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
body = new URLSearchParams(form).toString()
|
||||
}
|
||||
|
||||
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
|
||||
* @return {string} - message for the login form
|
||||
*/
|
||||
export function apiErrorMessage(code) {
|
||||
switch (code) {
|
||||
case 'malformed input':
|
||||
return 'Username and password are required.'
|
||||
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.'
|
||||
default:
|
||||
return code || 'The site returned an error.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>} - user token
|
||||
*/
|
||||
export async function loginWithPassword(siteUrl, username, password) {
|
||||
const data = await ttpRequest({
|
||||
siteUrl,
|
||||
path: '/api/login',
|
||||
method: 'POST',
|
||||
form: { username, password }
|
||||
})
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(apiErrorMessage(data.error))
|
||||
}
|
||||
|
||||
if (!data.token) {
|
||||
throw new Error('The site did not return a token.')
|
||||
}
|
||||
|
||||
return data.token
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
Reference in New Issue
Block a user