import axios from 'axios'; import environment from '../config/environment'; import logger from '../utils/logger'; interface NextcloudLastMessage { text: string; author: string; timestamp: number; } interface NextcloudConversation { token: string; displayName: string; unreadMessages: number; lastActivity: number; lastMessage: NextcloudLastMessage | null; type: number; url: string; } interface ConversationsResult { totalUnread: number; conversations: NextcloudConversation[]; } interface LoginFlowResult { loginUrl: string; pollToken: string; pollEndpoint: string; } interface LoginFlowCredentials { loginName: string; appPassword: 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; } async function initiateLoginFlow(): Promise { const baseUrl = environment.nextcloudUrl; if (!baseUrl || !isValidServiceUrl(baseUrl)) { throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); } try { const response = await axios.post(`${baseUrl}/index.php/login/v2`); return { loginUrl: response.data.login, pollToken: response.data.poll.token, pollEndpoint: response.data.poll.endpoint, }; } catch (error) { if (axios.isAxiosError(error)) { logger.error('Nextcloud Login Flow v2 initiation failed', { status: error.response?.status, statusText: error.response?.statusText, }); } logger.error('NextcloudService.initiateLoginFlow failed', { error }); throw new Error('Failed to initiate Nextcloud login flow'); } } async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise { if (!isValidServiceUrl(pollEndpoint)) { throw new Error('pollEndpoint is not a valid service URL'); } try { const response = await axios.post(pollEndpoint, `token=${pollToken}`, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); return { loginName: response.data.loginName, appPassword: response.data.appPassword, }; } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 404) { return null; } if (axios.isAxiosError(error)) { logger.error('Nextcloud Login Flow v2 poll failed', { status: error.response?.status, statusText: error.response?.statusText, }); } logger.error('NextcloudService.pollLoginFlow failed', { error }); throw new Error('Failed to poll Nextcloud login flow'); } } interface NextcloudChatMessage { id: number; token: string; actorType: string; actorId: string; actorDisplayName: string; message: string; timestamp: number; messageType: string; systemMessage: string; } async function getAllConversations(loginName: string, appPassword: string): Promise { const baseUrl = environment.nextcloudUrl; if (!baseUrl || !isValidServiceUrl(baseUrl)) { throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); } try { const response = await axios.get( `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room?format=json`, { headers: { 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, 'OCS-APIRequest': 'true', 'Accept': 'application/json', }, }, ); const rooms: any[] = response.data?.ocs?.data ?? []; return rooms .filter((r: any) => r.type !== 4) .sort((a: any, b: any) => (b.lastActivity ?? 0) - (a.lastActivity ?? 0)) .map((r: any) => ({ token: r.token, displayName: r.displayName, unreadMessages: r.unreadMessages ?? 0, lastActivity: r.lastActivity ?? 0, lastMessage: r.lastMessage ? { text: r.lastMessage.message ?? '', author: r.lastMessage.actorDisplayName ?? '', timestamp: r.lastMessage.timestamp ?? 0, } : null, type: r.type, url: `${baseUrl}/call/${r.token}`, })); } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 401) { const err = new Error('Nextcloud app password is invalid'); (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; throw err; } if (axios.isAxiosError(error)) { logger.error('Nextcloud getAllConversations failed', { status: error.response?.status, statusText: error.response?.statusText, }); throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); } logger.error('NextcloudService.getAllConversations failed', { error }); throw new Error('Failed to fetch Nextcloud conversations'); } } async function getMessages(token: string, loginName: string, appPassword: string): Promise { const baseUrl = environment.nextcloudUrl; if (!baseUrl || !isValidServiceUrl(baseUrl)) { throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); } try { const response = await axios.get( `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`, { params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 }, headers: { 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, 'OCS-APIRequest': 'true', 'Accept': 'application/json', }, }, ); const messages: any[] = response.data?.ocs?.data ?? []; return messages.map((m: any) => ({ id: m.id, token: m.token, actorType: m.actorType, actorId: m.actorId, actorDisplayName: m.actorDisplayName, message: m.message, timestamp: m.timestamp, messageType: m.messageType ?? '', systemMessage: m.systemMessage ?? '', })); } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 401) { const err = new Error('Nextcloud authentication invalid'); (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; throw err; } if (axios.isAxiosError(error)) { logger.error('NextcloudService.getMessages failed', { status: error.response?.status, statusText: error.response?.statusText, }); throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); } logger.error('NextcloudService.getMessages failed', { error }); throw new Error('Failed to fetch messages'); } } async function sendMessage(token: string, message: string, loginName: string, appPassword: string): Promise { const baseUrl = environment.nextcloudUrl; if (!baseUrl || !isValidServiceUrl(baseUrl)) { throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); } try { await axios.post( `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`, { message }, { headers: { 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, 'OCS-APIRequest': 'true', 'Accept': 'application/json', 'Content-Type': 'application/json', }, }, ); } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 401) { const err = new Error('Nextcloud authentication invalid'); (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; throw err; } if (axios.isAxiosError(error)) { logger.error('NextcloudService.sendMessage failed', { status: error.response?.status, statusText: error.response?.statusText, }); throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); } logger.error('NextcloudService.sendMessage failed', { error }); throw new Error('Failed to send message'); } } async function markAsRead(token: string, loginName: string, appPassword: string): Promise { const baseUrl = environment.nextcloudUrl; if (!baseUrl || !isValidServiceUrl(baseUrl)) { throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); } try { await axios.delete( `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}/read`, { headers: { 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, 'OCS-APIRequest': 'true', 'Accept': 'application/json', }, }, ); } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 401) { const err = new Error('Nextcloud authentication invalid'); (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; throw err; } if (axios.isAxiosError(error)) { logger.error('NextcloudService.markAsRead failed', { status: error.response?.status, statusText: error.response?.statusText, }); throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); } logger.error('NextcloudService.markAsRead failed', { error }); throw new Error('Failed to mark conversation as read'); } } async function getConversations(loginName: string, appPassword: string): Promise { const baseUrl = environment.nextcloudUrl; if (!baseUrl || !isValidServiceUrl(baseUrl)) { throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); } try { const response = await axios.get( `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room?format=json`, { headers: { 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, 'OCS-APIRequest': 'true', 'Accept': 'application/json', }, }, ); const rooms: any[] = response.data?.ocs?.data ?? []; const filtered = rooms.filter((r: any) => r.type !== 4); const totalUnread = filtered.reduce( (sum: number, r: any) => sum + (r.unreadMessages ?? 0), 0, ); const sorted = [...filtered].sort( (a: any, b: any) => (b.lastActivity ?? 0) - (a.lastActivity ?? 0), ); const conversations: NextcloudConversation[] = sorted.slice(0, 3).map((r: any) => ({ token: r.token, displayName: r.displayName, unreadMessages: r.unreadMessages ?? 0, lastActivity: r.lastActivity ?? 0, lastMessage: r.lastMessage ? { text: r.lastMessage.message ?? '', author: r.lastMessage.actorDisplayName ?? '', timestamp: r.lastMessage.timestamp ?? 0, } : null, type: r.type, url: `${baseUrl}/call/${r.token}`, })); return { totalUnread, conversations }; } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 401) { const err = new Error('Nextcloud app password is invalid'); (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; throw err; } if (axios.isAxiosError(error)) { logger.error('Nextcloud API request failed', { status: error.response?.status, statusText: error.response?.statusText, }); throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); } logger.error('NextcloudService.getConversations failed', { error }); throw new Error('Failed to fetch Nextcloud conversations'); } } export type { NextcloudChatMessage }; export default { initiateLoginFlow, pollLoginFlow, getConversations, getAllConversations, getMessages, sendMessage, markAsRead };