116 lines
3.8 KiB
JavaScript
116 lines
3.8 KiB
JavaScript
/**
|
|||
|
|
* Where the interface is running, and what that changes about reaching the server.
|
||
|
|
*
|
||
|
|
* In a browser the interface and the API share an origin: every path is relative
|
||
|
|
* and the session is an httpOnly cookie no script can see. In the iOS app the
|
||
|
|
* interface is bundled into the app and served from capacitor://localhost, and
|
||
|
|
* the server is whichever one its owner typed in. So the app needs three things
|
||
|
|
* a browser never does: the server's address, its own session token, and that
|
||
|
|
* token on every request instead of a cookie.
|
||
|
|
*
|
||
|
|
* Everything that differs between the two lives here, so `api.js` and the socket
|
||
|
|
* ask one question — `apiURL(path)`, `authorize(init)` — and the browser's answer
|
||
|
|
* is exactly what it was before this file existed.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/** True inside the iOS app. Capacitor puts its bridge on `window` before any of
|
||
|
|
* our code runs. */
|
||
|
|
export function isNativeApp() {
|
||
|
|
return !!globalThis.Capacitor?.isNativePlatform?.();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The app's stored settings: which server, and the session on it.
|
||
|
|
*
|
||
|
|
* localStorage for now, behind this one object, so the Keychain plugin can take
|
||
|
|
* its place without anything else changing. The token is a session — whoever
|
||
|
|
* holds it is signed in — and the Keychain is where a phone keeps those; until it
|
||
|
|
* is wired in, this is a WKWebView's own storage, readable only by the app.
|
||
|
|
*/
|
||
|
|
const SERVER_KEY = 'verdande.app.server';
|
||
|
|
const TOKEN_KEY = 'verdande.app.session';
|
||
|
|
|
||
|
|
function read(key) {
|
||
|
|
try {
|
||
|
|
return localStorage.getItem(key) ?? '';
|
||
|
|
} catch {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function write(key, value) {
|
||
|
|
try {
|
||
|
|
if (value) localStorage.setItem(key, value);
|
||
|
|
else localStorage.removeItem(key);
|
||
|
|
} catch {
|
||
|
|
// Storage refused (private mode, quota). The app then asks again next
|
||
|
|
// launch, which is the right failure: a token that seemed saved and was
|
||
|
|
// not would look like being signed out for no reason.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const appSession = {
|
||
|
|
server: () => read(SERVER_KEY),
|
||
|
|
setServer: (url) => write(SERVER_KEY, url),
|
||
|
|
token: () => read(TOKEN_KEY),
|
||
|
|
setToken: (token) => write(TOKEN_KEY, token),
|
||
|
|
/** Signed out: the token goes, the server stays — signing in again is to the
|
||
|
|
* same place nearly every time. */
|
||
|
|
signOut: () => write(TOKEN_KEY, '')
|
||
|
|
};
|
||
|
|
|
||
|
|
/** The address of an API path: relative in a browser, on the chosen server in
|
||
|
|
* the app. */
|
||
|
|
export function apiURL(path) {
|
||
|
|
const base = isNativeApp() ? appSession.server() : '';
|
||
|
|
return `${base}/api/v1${path}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Fills in how a request proves who is asking.
|
||
|
|
*
|
||
|
|
* A browser sends its cookie, same-origin only. The app sends no cookie at all —
|
||
|
|
* `omit`, so nothing the WebView picked up from somewhere rides along — and its
|
||
|
|
* token as a bearer.
|
||
|
|
*/
|
||
|
|
export function authorize(init) {
|
||
|
|
if (!isNativeApp()) {
|
||
|
|
init.credentials = 'same-origin';
|
||
|
|
return init;
|
||
|
|
}
|
||
|
|
init.credentials = 'omit';
|
||
|
|
const token = appSession.token();
|
||
|
|
if (token) {
|
||
|
|
init.headers = { ...(init.headers ?? {}), Authorization: `Bearer ${token}` };
|
||
|
|
}
|
||
|
|
return init;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** What the app calls itself in the list of signed-in devices. */
|
||
|
|
export function deviceName() {
|
||
|
|
const ua = navigator.userAgent;
|
||
|
|
if (/iPad/.test(ua)) return 'iPad';
|
||
|
|
if (/iPhone/.test(ua)) return 'iPhone';
|
||
|
|
return 'Verdande-appen';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Normalises what somebody typed as their server's address.
|
||
|
|
*
|
||
|
|
* "verdande.example.dk" becomes "https://verdande.example.dk"; a trailing slash
|
||
|
|
* and a pasted "/api/v1" are dropped. Plain http is kept when typed on purpose,
|
||
|
|
* because a self-hoster on their own network may have no certificate — iOS will
|
||
|
|
* refuse it unless the app allows it, and that refusal should be the one they see.
|
||
|
|
*/
|
||
|
|
export function normaliseServer(input) {
|
||
|
|
let s = (input ?? '').trim();
|
||
|
|
if (!s) return '';
|
||
|
|
if (!/^https?:\/\//i.test(s)) s = `https://${s}`;
|
||
|
|
try {
|
||
|
|
const u = new URL(s);
|
||
|
|
return `${u.protocol}//${u.host}`;
|
||
|
|
} catch {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
}
|