Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ count.txt
.output
.vinxi
todos.json
tokens.json
SPEC.md
21 changes: 17 additions & 4 deletions src/hooks/useRemoteConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,22 @@ export const useRemoteConnection = () => {
let isMounted = true;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const wsUrl = `${protocol}//${host}/ws`;

// Get token from URL params (passed via QR code) or localStorage
const urlParams = new URLSearchParams(window.location.search);
const urlToken = urlParams.get('token');
const storedToken = localStorage.getItem('rein_auth_token');
const token = urlToken || storedToken;

// Persist URL token to localStorage for future reconnections
if (urlToken && urlToken !== storedToken) {
localStorage.setItem('rein_auth_token', urlToken);
}

let wsUrl = `${protocol}//${host}/ws`;
if (token) {
wsUrl += `?token=${encodeURIComponent(token)}`;
}

let reconnectTimer: NodeJS.Timeout;

Expand All @@ -24,7 +39,6 @@ export const useRemoteConnection = () => {
wsRef.current = null;
}

console.log(`Connecting to ${wsUrl}`);
setStatus('connecting');
const socket = new WebSocket(wsUrl);

Expand All @@ -37,8 +51,7 @@ export const useRemoteConnection = () => {
reconnectTimer = setTimeout(connect, 3000);
}
};
socket.onerror = (e) => {
console.error("WS Error", e);
socket.onerror = () => {
socket.close();
};

Expand Down
72 changes: 57 additions & 15 deletions src/routes/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,61 @@ function SettingsPage() {
const [qrData, setQrData] = useState('');

// Load initial state (IP is not stored in localStorage; only sensitivity, invert, theme are client settings)
const [authToken, setAuthToken] = useState(() => {
if (typeof window === 'undefined') return '';
return localStorage.getItem('rein_auth_token') || '';
});

// Derive URLs once at the top
const appPort = String(CONFIG.FRONTEND_PORT);
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'http:';
const shareUrl = ip ? `${protocol}//${ip}:${appPort}/trackpad${authToken ? `?token=${encodeURIComponent(authToken)}` : ''}` : '';

useEffect(() => {
const defaultIp = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
setIp(defaultIp);
setFrontendPort(String(CONFIG.FRONTEND_PORT));
}, []);

// Auto-generate token on settings page load (localhost only)
useEffect(() => {
if (typeof window === 'undefined') return;

let isMounted = true;

const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
const socket = new WebSocket(wsUrl);

socket.onopen = () => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'generate-token' }));
}
};

socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'token-generated' && data.token) {
if (isMounted) {
setAuthToken(data.token);
localStorage.setItem('rein_auth_token', data.token);
}
socket.close();
}
} catch (e) {
console.error(e);
}
};

return () => {
isMounted = false;
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
socket.close();
}
};
}, []);

// Effect: Update LocalStorage when settings change
useEffect(() => {
localStorage.setItem('rein_sensitivity', String(sensitivity));
Expand All @@ -57,43 +106,39 @@ function SettingsPage() {
localStorage.setItem('rein_invert', JSON.stringify(invertScroll));
}, [invertScroll]);

// Effect: Theme
useEffect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(APP_CONFIG.THEME_STORAGE_KEY, theme);
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);

// Generate QR when IP changes (IP is not persisted to localStorage)
// Generate QR when IP changes or Token changes
useEffect(() => {
if (!ip || typeof window === 'undefined') return;
const appPort = String(CONFIG.FRONTEND_PORT);
const protocol = window.location.protocol;
const shareUrl = `${protocol}//${ip}:${appPort}/trackpad`;
if (!ip || typeof window === 'undefined' || !shareUrl) return;

QRCode.toDataURL(shareUrl)
.then(setQrData)
.catch((e) => console.error('QR Error:', e));
}, [ip]);
}, [ip, authToken, shareUrl]);

// Effect: Auto-detect LAN IP from Server (only if on localhost)
useEffect(() => {
if (typeof window === 'undefined') return;
if (window.location.hostname !== 'localhost') return;

console.log('Attempting to auto-detect IP...');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
const socket = new WebSocket(wsUrl);

socket.onopen = () => {
console.log('Connected to local server for IP detection');
socket.send(JSON.stringify({ type: 'get-ip' }));
};

socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'server-ip' && data.ip) {
console.log('Auto-detected IP:', data.ip);
setIp(data.ip);
socket.close();
}
Expand All @@ -107,10 +152,6 @@ function SettingsPage() {
};
}, []);

const displayUrl = typeof window !== 'undefined'
? `${window.location.protocol}//${ip}:${CONFIG.FRONTEND_PORT}/trackpad`
: `http://${ip}:${CONFIG.FRONTEND_PORT}/trackpad`;

return (
<div className="h-full overflow-y-auto w-full">
<div className="p-6 pb-safe max-w-5xl mx-auto min-h-full">
Expand Down Expand Up @@ -161,6 +202,7 @@ function SettingsPage() {
<span>Slow</span>
<span>Default</span>
<span>Fast</span>

</div>
</div>

Expand Down Expand Up @@ -273,9 +315,9 @@ function SettingsPage() {

<a
className="link link-primary mt-2 break-all text-lg font-mono bg-base-100 px-4 py-2 rounded-lg inline-block max-w-full overflow-hidden text-ellipsis"
href={displayUrl}
href={shareUrl}
>
{ip}:{CONFIG.FRONTEND_PORT}/trackpad
{shareUrl.replace(`${protocol}//`, '')}
</a>
</div>
</div>
Expand Down
131 changes: 131 additions & 0 deletions src/server/tokenStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import crypto from 'crypto';
import fs from 'fs';
import { writeFile } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';

interface TokenEntry {
token: string;
createdAt: number;
lastUsed: number;
}

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TOKENS_FILE = path.resolve(__dirname, '../tokens.json');
const EXPIRY_MS = 10 * 24 * 60 * 60 * 1000; // 10 days

let tokens: TokenEntry[] = [];
let lastSaveTime = 0;
let isSaving = false;
const SAVE_THROTTLE_MS = 60 * 1000; // 1 minute

function validateTokens(data: any): TokenEntry[] {
if (!Array.isArray(data)) return [];
return data.filter(t =>
typeof t.token === 'string' &&
typeof t.lastUsed === 'number' &&
typeof t.createdAt === 'number'
);
}

function load(): void {
try {
if (fs.existsSync(TOKENS_FILE)) {
const raw = fs.readFileSync(TOKENS_FILE, 'utf-8');
tokens = validateTokens(JSON.parse(raw));
}
} catch {
tokens = [];
}
}

async function save(force = false): Promise<void> {
const now = Date.now();
if (!force && (now - lastSaveTime) < SAVE_THROTTLE_MS) return;
if (isSaving) return;

isSaving = true;
try {
await writeFile(TOKENS_FILE, JSON.stringify(tokens, null, 2), {
encoding: 'utf-8',
mode: 0o600 // Restricted to owner only
});
lastSaveTime = now;
} catch (e) {
console.error('Failed to persist tokens:', e);
} finally {
isSaving = false;
}
}

function purgeExpired(): void {
const now = Date.now();
const before = tokens.length;
tokens = tokens.filter(t => (now - t.lastUsed) < EXPIRY_MS);
if (tokens.length !== before) save(true);
}

/** Constant-time string comparison to prevent timing attacks. */
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
} catch {
return false;
}
}

/**
* Store a token upon successful connection.
* If it already exists, refresh its lastUsed timestamp.
*/
export function storeToken(token: string): void {
purgeExpired();
const existing = tokens.find(t => timingSafeEqual(t.token, token));
if (existing) {
existing.lastUsed = Date.now();
} else {
const now = Date.now();
tokens.push({ token, createdAt: now, lastUsed: now });
}
save(true);
}

/** Check if a token is already known/stored on the server. */
export function isKnownToken(token: string): boolean {
purgeExpired();
return tokens.some(t => timingSafeEqual(t.token, token));
}

/** Refresh the lastUsed timestamp for a token. */
export function touchToken(token: string): void {
const entry = tokens.find(t => timingSafeEqual(t.token, token));
if (entry) {
entry.lastUsed = Date.now();
save(); // Throttled internally
}
}

/** Returns the most recently used active token, if any. */
export function getActiveToken(): string | null {
purgeExpired();
if (tokens.length === 0) return null;
// Return the one used most recently
const sorted = [...tokens].sort((a, b) => b.lastUsed - a.lastUsed);
return sorted[0].token;
}

/** Check if any tokens exist yet (first-run detection). */
export function hasTokens(): boolean {
purgeExpired();
return tokens.length > 0;
}

/** Generate a cryptographically random token. */
export function generateToken(): string {
return crypto.randomUUID();
}

// Load persisted tokens on startup
load();
Loading