import axios from 'axios'; import httpClient from '../config/httpClient'; import environment from '../config/environment'; import logger from '../utils/logger'; export interface VikunjaTask { id: number; title: string; done: boolean; due_date: string | null; priority: number; project_id: number; } export interface VikunjaProject { id: number; title: string; } /** * Validates that a URL is safe to use as an outbound service endpoint. * Rejects non-http(s) protocols and private/loopback IP ranges to prevent SSRF. */ function isValidServiceUrl(raw: string): boolean { let parsed: URL; try { parsed = new URL(raw); } catch { return false; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return false; } const hostname = parsed.hostname.toLowerCase(); // Reject plain loopback / localhost names if (hostname === 'localhost' || hostname === '::1') { return false; } // Reject numeric IPv4 private / loopback / link-local ranges const ipv4Parts = hostname.split('.'); if (ipv4Parts.length === 4) { const [a, b] = ipv4Parts.map(Number); if ( a === 127 || // 127.0.0.0/8 loopback a === 10 || // 10.0.0.0/8 private (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private (a === 192 && b === 168) || // 192.168.0.0/16 private (a === 169 && b === 254) // 169.254.0.0/16 link-local ) { return false; } } return true; } function buildHeaders(): Record { return { 'Authorization': `Bearer ${environment.vikunja.apiToken}`, 'Content-Type': 'application/json', }; } async function getMyTasks(): Promise { const { vikunja } = environment; if (!vikunja.url || !isValidServiceUrl(vikunja.url)) { throw new Error('VIKUNJA_URL is not configured or is not a valid service URL'); } try { const response = await httpClient.get( `${vikunja.url}/api/v1/tasks/all`, { headers: buildHeaders() }, ); return (response.data ?? []).filter((t) => !t.done); } catch (error) { if (axios.isAxiosError(error)) { logger.error('Vikunja getMyTasks failed', { status: error.response?.status, statusText: error.response?.statusText, }); } logger.error('VikunjaService.getMyTasks failed', { error }); throw new Error('Failed to fetch Vikunja tasks'); } } async function getOverdueTasks(): Promise { const tasks = await getMyTasks(); const now = new Date(); return tasks.filter((t) => { if (!t.due_date || t.due_date.startsWith('0001-')) return false; return new Date(t.due_date) < now; }); } async function getProjects(): Promise { const { vikunja } = environment; if (!vikunja.url || !isValidServiceUrl(vikunja.url)) { throw new Error('VIKUNJA_URL is not configured or is not a valid service URL'); } try { const response = await httpClient.get( `${vikunja.url}/api/v1/projects`, { headers: buildHeaders() }, ); return response.data ?? []; } catch (error) { if (axios.isAxiosError(error)) { logger.error('Vikunja getProjects failed', { status: error.response?.status, statusText: error.response?.statusText, }); } logger.error('VikunjaService.getProjects failed', { error }); throw new Error('Failed to fetch Vikunja projects'); } } async function createTask(projectId: number, title: string, dueDate?: string): Promise { const { vikunja } = environment; if (!vikunja.url || !isValidServiceUrl(vikunja.url)) { throw new Error('VIKUNJA_URL is not configured or is not a valid service URL'); } try { const body: Record = { title }; if (dueDate) { body.due_date = dueDate; } const response = await httpClient.put( `${vikunja.url}/api/v1/projects/${projectId}/tasks`, body, { headers: buildHeaders() }, ); return response.data; } catch (error) { if (axios.isAxiosError(error)) { logger.error('Vikunja createTask failed', { status: error.response?.status, statusText: error.response?.statusText, }); } logger.error('VikunjaService.createTask failed', { error }); throw new Error('Failed to create Vikunja task'); } } export default { getMyTasks, getOverdueTasks, getProjects, createTask };