Initial commit

This commit is contained in:
Joey Kimsey
2026-09-11 18:14:10 -04:00
commit f785af89cd
15 changed files with 4273 additions and 0 deletions

162
src/main/ttpClient.js Normal file
View 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'
}