Bug fixes: - Remove non-existent `role` column from admin users SQL query (A1) - Fix Nextcloud Talk chat API path v4 → v1 for messages/send/read (A2) - Fix ServiceModeTab sync: useState → useEffect to reflect DB state (A3) - Guard BookStack book_slug with book_id fallback to avoid broken URLs (A4) Layout & UI: - Chat panel: sticky full-height positioning, main content scrolls independently (B1) - Vehicle booking datetime inputs: explicit text color for dark mode (B2) - AnnouncementBanner moved into grid with full-width span (B3) Features: - Per-user widget visibility preferences stored in users.preferences JSONB (C1) - Link collections: grouped external links in admin UI and dashboard widget (C2) - Admin ping history: migration 026, checked_at timestamps, expandable history rows (C4) - Service mode end date picker with scheduled deactivation display (C5) - Vikunja startup config logging and configured:false warnings (C7) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
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<LoginFlowResult> {
|
|
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<LoginFlowCredentials | null> {
|
|
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<NextcloudConversation[]> {
|
|
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<NextcloudChatMessage[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<ConversationsResult> {
|
|
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 };
|