155 lines
4.5 KiB
TypeScript
155 lines
4.5 KiB
TypeScript
import axios from 'axios';
|
|
import httpClient from '../config/httpClient';
|
|
import toolConfigService, { VikunjaConfig } from './toolConfig.service';
|
|
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(config: VikunjaConfig): Record<string, string> {
|
|
return {
|
|
'Authorization': `Bearer ${config.apiToken}`,
|
|
'Content-Type': 'application/json',
|
|
};
|
|
}
|
|
|
|
async function getMyTasks(): Promise<VikunjaTask[]> {
|
|
const config = await toolConfigService.getVikunjaConfig();
|
|
if (!config.url || !isValidServiceUrl(config.url)) {
|
|
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
|
|
}
|
|
|
|
try {
|
|
const response = await httpClient.get<VikunjaTask[]>(
|
|
`${config.url}/api/v1/tasks/all`,
|
|
{ headers: buildHeaders(config) },
|
|
);
|
|
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<VikunjaTask[]> {
|
|
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<VikunjaProject[]> {
|
|
const config = await toolConfigService.getVikunjaConfig();
|
|
if (!config.url || !isValidServiceUrl(config.url)) {
|
|
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
|
|
}
|
|
|
|
try {
|
|
const response = await httpClient.get<VikunjaProject[]>(
|
|
`${config.url}/api/v1/projects`,
|
|
{ headers: buildHeaders(config) },
|
|
);
|
|
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<VikunjaTask> {
|
|
const config = await toolConfigService.getVikunjaConfig();
|
|
if (!config.url || !isValidServiceUrl(config.url)) {
|
|
throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
|
|
}
|
|
|
|
try {
|
|
const body: Record<string, unknown> = { title };
|
|
if (dueDate) {
|
|
body.due_date = dueDate;
|
|
}
|
|
const response = await httpClient.put<VikunjaTask>(
|
|
`${config.url}/api/v1/projects/${projectId}/tasks`,
|
|
body,
|
|
{ headers: buildHeaders(config) },
|
|
);
|
|
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 };
|