diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc4af2..f5a731d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Electron + electron-vite desktop shell with a login view and a connected workspace. -- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA. +- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA. POSTs send session CSRF (`X-CSRF-Token`); HTTP uses Electron `net.fetch` so the PHP session cookie persists. - Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token. -- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, name, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site. +- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site. - After sign-in, Capsule loads `GET /api/profile` plus notifications, the messages inbox, and `GET /api/messages/recent` for the header dropdown (avatars + unread count). Inbox rows can mark read/unread or hide. Compose and reply follow `canSend`. Search uses the matching user-token endpoint. Contact and bug reports live on footer pages (`#/contact`, `#/bugreport`) instead of the dashboard. Disabled plugins show an unavailable note instead of demo data. - Footer chrome matches TTP copy and socials. The upper band keeps a dark-mode toggle, Privacy Policy, Terms of Service, Contact, and Report a Bug. There is no subscribe box. ### Fixed +- Settings no longer posts a display-name field. User CP has no such option; `users.name` is leftover, and `Check::name()` rejected typical values (`Invalid name.` / `malformed input`). - Avatar file previews are allowed (`blob:` on `img-src`). Choosing a photo no longer shows the missing-image icon. A `{ "error": "not found" }` from the site is reported as a missing Capsule API action (the live TTP install still needs `POST /api/profile/update`). diff --git a/README.md b/README.md index 1bd5d47..583e851 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,17 @@ npm run dev ## How auth works -All HTTP runs in the **main process**. The renderer never sees the token and never talks to the site directly, so TTP?s same-origin CORS policy does not apply. +All HTTP runs in the **main process** (`net.fetch`, so the PHP session cookie sticks). The renderer never sees the token and never talks to the site directly, so TTP's same-origin CORS policy does not apply. POSTs send `X-CSRF-Token` (and POST `token`) after a GET harvests `csrf`. | Action | Endpoint | Notes | |--------|----------|-------| -| Password sign-in | `POST /api/login` | `username` + `password`, `application/x-www-form-urlencoded`. Same limiter as browser login. No CSRF, no Turnstile. MFA accounts return `{ mfa }` instead of a token. | +| Password sign-in | `POST /api/login` | `username` + `password`, `application/x-www-form-urlencoded`. Same limiter as browser login. CSRF from `GET /api/login`. No Turnstile. MFA accounts return `{ mfa }` instead of a token. | | MFA code / method | `POST /api/login/mfa/{loginCode}` | `auth_code` or `mfaMethodSelect`. `loginCode` stays in the main process. | | MFA reset | `POST /api/login/mfa/{loginCode}/reset` | Clears the chosen method so the picker shows again. | | Confirm identity | `GET /api/profile` | Bearer user token. Also used to hydrate username after login. `GET /api/users/find/{username}` remains available. | | Workspace | `GET /api/notifications`, `GET /api/messages`, `GET /api/messages/recent` | First inbox page plus the header dropdown after connect. Plugin-off responses show as unavailable. | | Search | `GET /api/search` | Header search. `q`, `resource`, `page`. | -| Profile save | `POST /api/profile/update` | Name, avatar, prefs. | +| Profile save | `POST /api/profile/update` | Avatar and prefs. | | Mail / notices | `POST /api/messages/?`, `POST /api/notifications/?` | View, reply, create, read, unread, delete. | | Contact / bugs | `POST /api/contact`, `POST /api/bugreport` | Footer pages when those plugins are enabled. | | Existing token | Admin ? Tokens | Personal or app token. A user token hydrates the workspace; an app token can connect but cannot call the user API. | diff --git a/src/main/sessionIpc.js b/src/main/sessionIpc.js index 1441ccc..bbbe83b 100644 --- a/src/main/sessionIpc.js +++ b/src/main/sessionIpc.js @@ -10,7 +10,8 @@ import { normalizeSiteUrl, resetMfaMethod, selectMfaMethod, - submitMfaCode + submitMfaCode, + clearCsrf } from './ttpClient.js' import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js' @@ -193,6 +194,7 @@ export function registerSessionIpc() { } clearPendingMfa() + clearCsrf(siteUrl) const result = await loginWithPassword(siteUrl, username, password) if (result.mfa) { return rememberPendingMfa(siteUrl, username, result.mfa) @@ -263,6 +265,7 @@ export function registerSessionIpc() { } clearPendingMfa() + clearCsrf(siteUrl) return persistConnection({ siteUrl, token, username, authMethod: 'token' }) }) @@ -299,6 +302,9 @@ export function registerSessionIpc() { const lastSiteUrl = pendingMfa?.siteUrl || session?.siteUrl || session?.lastSiteUrl || '' clearPendingMfa() writeLastSite(lastSiteUrl) + if (lastSiteUrl) { + clearCsrf(lastSiteUrl) + } return publicSession(readSession()) }) } diff --git a/src/main/ttpClient.js b/src/main/ttpClient.js index 6fa3fa4..6a1911e 100644 --- a/src/main/ttpClient.js +++ b/src/main/ttpClient.js @@ -1,7 +1,13 @@ /** * HTTP calls to a TTP site. Runs in the main process so CORS does not apply. + * Uses Electron net.fetch so the PHP session cookie (CSRF) persists. */ +import { net, session as electronSession } from 'electron' + +/** Session CSRF tokens keyed by canonical site URL. */ +const csrfBySite = new Map() + /** * Normalize a TTP site URL to scheme + host + optional path, no trailing slash. * @@ -29,6 +35,100 @@ export function normalizeSiteUrl(raw) { return `${parsed.origin}${path === '/' ? '' : path}` } +/** + * Forget stored CSRF for one site, or all sites. + * + * @param {string} [siteUrl] - canonical site base + * @return {void} + */ +export function clearCsrf(siteUrl) { + if (siteUrl) { + csrfBySite.delete(siteUrl) + return + } + csrfBySite.clear() +} + +/** + * Store a CSRF value from a response header or JSON body. + * + * @param {string} siteUrl - canonical site base + * @param {Response} response - fetch response + * @param {object} [data] - parsed JSON + * @return {void} + */ +function rememberCsrf(siteUrl, response, data) { + const header = response?.headers?.get?.('x-csrf-token') || '' + const body = typeof data?.csrf === 'string' ? data.csrf : '' + const value = body || header + if (value && siteUrl) { + csrfBySite.set(siteUrl, value) + } +} + +/** + * True when the API rejected the POST for a missing or stale CSRF token. + * + * @param {object} data - parsed JSON + * @return {boolean} + */ +function isCsrfFailure(data) { + if (data?.error !== 'malformed input') { + return false + } + const errors = data.errors + const text = typeof errors === 'string' ? errors : JSON.stringify(errors || '') + return text.includes('Invalid Token') +} + +/** + * GET a CSRF token when this site has none yet. + * + * @param {string} siteUrl - canonical site base + * @param {string} [token] - Bearer token + * @return {Promise} + */ +async function primeCsrf(siteUrl, token) { + if (!siteUrl || csrfBySite.get(siteUrl)) { + return + } + if (token) { + await ttpRequest({ siteUrl, path: '/api/profile', method: 'GET', token, skipCsrfPrime: true }) + return + } + await ttpRequest({ siteUrl, path: '/api/login', method: 'GET', skipCsrfPrime: true }) +} + +/** + * Attach the stored CSRF token to headers and the POST body. + * + * @param {string} siteUrl - canonical site base + * @param {Record} headers - request headers + * @param {Record} [form] - urlencoded fields + * @param {FormData|Record} [multipart] - multipart fields + * @return {{form?: Record, multipart?: FormData|Record}} + */ +function attachCsrf(siteUrl, headers, form, multipart) { + const csrf = csrfBySite.get(siteUrl) + if (!csrf) { + return { form, multipart } + } + headers['X-CSRF-Token'] = csrf + if (multipart instanceof FormData) { + if (!multipart.has('token')) { + multipart.append('token', csrf) + } + return { form, multipart } + } + if (multipart && typeof multipart === 'object') { + return { form, multipart: { ...multipart, token: csrf } } + } + if (form && typeof form === 'object') { + return { form: { ...form, token: csrf }, multipart } + } + return { form: { token: csrf, submit: '1' }, multipart } +} + /** * Call a TTP API path and parse JSON. * @@ -40,6 +140,8 @@ export function normalizeSiteUrl(raw) { * @param {Record} [options.form] - urlencoded body * @param {Record} [options.multipart] - multipart fields (avatar) * @param {Record} [options.query] - query string + * @param {boolean} [options.skipCsrfPrime] - skip the GET that harvests CSRF + * @param {boolean} [options.retriedCsrf] - already retried after Invalid Token * @return {Promise} - parsed JSON */ export async function ttpRequest(options) { @@ -47,12 +149,19 @@ export async function ttpRequest(options) { 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 form = options.form + let multipart = options.multipart let body + if (method === 'POST' && !options.skipCsrfPrime) { + await primeCsrf(siteUrl, token) + const attached = attachCsrf(siteUrl, headers, form, multipart) + form = attached.form + multipart = attached.multipart + } + if (options.query && typeof options.query === 'object') { const qs = new URLSearchParams() Object.entries(options.query).forEach(([key, value]) => { @@ -92,7 +201,12 @@ export async function ttpRequest(options) { let response try { - response = await fetch(url, { method, headers, body, redirect: 'follow' }) + response = await net.fetch(url, { + method, + headers, + body, + session: electronSession.defaultSession + }) } catch { throw new Error('Could not reach that site.') } @@ -105,6 +219,13 @@ export async function ttpRequest(options) { throw new Error(`The site did not return API JSON (${response.status}).`) } + rememberCsrf(siteUrl, response, data) + + if (method === 'POST' && isCsrfFailure(data) && !options.retriedCsrf) { + clearCsrf(siteUrl) + return ttpRequest({ ...options, retriedCsrf: true }) + } + return data } @@ -403,7 +524,7 @@ export async function getProfile(siteUrl, token) { } /** - * Save name, prefs, optional avatar. POST api/profile/update. + * Save prefs and optional avatar. POST api/profile/update. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token diff --git a/src/renderer/index.html b/src/renderer/index.html index 4471420..ed0a821 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -668,10 +668,6 @@ -
- - -