Files
dashboard/backend/src/services/vikunja.service.ts
Matthias Hochmeister 215528a521 update
2026-03-16 14:41:08 +01:00

155 lines
4.4 KiB
TypeScript

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<string, string> {
return {
'Authorization': `Bearer ${environment.vikunja.apiToken}`,
'Content-Type': 'application/json',
};
}
async function getMyTasks(): Promise<VikunjaTask[]> {
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<VikunjaTask[]>(
`${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<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 { 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<VikunjaProject[]>(
`${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<VikunjaTask> {
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<string, unknown> = { title };
if (dueDate) {
body.due_date = dueDate;
}
const response = await httpClient.put<VikunjaTask>(
`${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 };