import { ApiError, Invite, JoinResponse, Member, PROTOCOL_VERSION, ServerInfo, Space, TokenResponse, Version, VersionFiles, } from './protocol'; export class SyncApiError extends Error { constructor(readonly status: number, readonly code: string, message: string) { super(message); } } const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']); /** * The server address as the extension keeps it: http(s), no trailing slash. * Plain http is refused except on this machine; tokens travel on it. */ export function checkServerUrl(raw: string): { url?: string; problem?: string } { let url: URL; try { url = new URL(raw.trim()); } catch { return { problem: 'Enter the server address, for example https://archify.example.com' }; } if (url.protocol !== 'https:' && url.protocol !== 'http:') return { problem: 'The address starts with https://' }; if (url.protocol === 'http:' && !LOOPBACK.has(url.hostname)) return { problem: 'Use https://. Plain http:// is only for a server on this machine.' }; return { url: `${url.origin}${url.pathname.replace(/\/+$/, '')}` }; } export function syncSocketUrl(server: string, space: string): string { return `${server.replace(/^http/, 'ws')}/v1/spaces/${space}/sync?v=${PROTOCOL_VERSION}`; } /** Reads `server` and `invite` from an invite link: `https://…/join/` or the extension's `…/join?server=&invite=`. */ export function parseInviteLink(raw: string): { server: string; invite: string } | undefined { try { const url = new URL(raw.trim()); const server = url.searchParams.get('server'); const invite = url.searchParams.get('invite'); if (server && invite) return { server: server.replace(/\/+$/, ''), invite }; const match = /^(.*)\/join\/([A-Za-z0-9_-]{20,})\/?$/.exec(url.pathname); if (match && (url.protocol === 'https:' || url.protocol === 'http:')) return { server: `${url.origin}${match[1]}`, invite: match[2] }; } catch { // not a URL } return undefined; } /** The server's HTTP API. `token` gives a fresh access token for calls that need one. */ export class SyncApi { constructor(readonly server: string, private readonly token?: (force?: boolean) => Promise) {} private async request(method: string, path: string, body?: unknown, signedIn = true): Promise { const send = async (force: boolean) => { const headers: Record = { accept: 'application/json' }; if (body !== undefined) headers['content-type'] = 'application/json'; if (signedIn) { const token = await this.token?.(force); if (!token) throw new SyncApiError(401, 'unauthorized', 'Sign in to the sync server first.'); headers.authorization = `Bearer ${token}`; } return fetch(`${this.server}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); }; let response = await send(false); // The access token may have expired between the check and the call. if (response.status === 401 && signedIn) response = await send(true); const text = await response.text(); if (!response.ok) { let error: Partial = {}; try { error = JSON.parse(text) as ApiError; } catch { // not JSON } throw new SyncApiError(response.status, error.error ?? 'http', error.message ?? `The server answered ${response.status}.`); } return (text ? JSON.parse(text) : undefined) as T; } info = () => this.request('GET', '/v1/info', undefined, false); exchangeCode = (code: string, codeVerifier: string, redirectUri: string) => this.request('POST', '/v1/auth/token', { grant_type: 'authorization_code', code, code_verifier: codeVerifier, redirect_uri: redirectUri }, false); refresh = (refreshToken: string) => this.request('POST', '/v1/auth/token', { grant_type: 'refresh_token', refresh_token: refreshToken }, false); logout = (refreshToken: string) => this.request('POST', '/v1/auth/logout', { refresh_token: refreshToken }, false); spaces = () => this.request('GET', '/v1/spaces'); createSpace = (name: string) => this.request('POST', '/v1/spaces', { name }); members = (space: string) => this.request('GET', `/v1/spaces/${space}/members`); invite = (space: string) => this.request('POST', `/v1/spaces/${space}/invites`, {}); acceptInvite = (token: string) => this.request('POST', `/v1/invites/${encodeURIComponent(token)}/accept`); versions = (space: string) => this.request('GET', `/v1/spaces/${space}/versions`); publish = (space: string, name: string) => this.request('POST', `/v1/spaces/${space}/versions`, { name }); versionFiles = (space: string, version: number) => this.request('GET', `/v1/spaces/${space}/versions/${version}/files`); restore = (space: string, version: number) => this.request('POST', `/v1/spaces/${space}/versions/${version}/restore`); }