feat(admin): centralize tool & module settings in Werkzeuge tab with per-tool permissions, DB-backed config, connection tests, and cog-button navigation
This commit is contained in:
@@ -109,6 +109,7 @@ import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
|
||||
import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
|
||||
import buchhaltungRoutes from './routes/buchhaltung.routes';
|
||||
import personalEquipmentRoutes from './routes/personalEquipment.routes';
|
||||
import toolConfigRoutes from './routes/toolConfig.routes';
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
@@ -140,6 +141,7 @@ app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
|
||||
app.use('/api/ausruestung-typen', ausruestungTypRoutes);
|
||||
app.use('/api/buchhaltung', buchhaltungRoutes);
|
||||
app.use('/api/persoenliche-ausruestung', personalEquipmentRoutes);
|
||||
app.use('/api/admin/tools', toolConfigRoutes);
|
||||
|
||||
// Static file serving for uploads (authenticated)
|
||||
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
||||
|
||||
175
backend/src/controllers/toolConfig.controller.ts
Normal file
175
backend/src/controllers/toolConfig.controller.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Request, Response } from 'express';
|
||||
import httpClient from '../config/httpClient';
|
||||
import toolConfigService from '../services/toolConfig.service';
|
||||
import settingsService from '../services/settings.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const TOOL_SETTINGS_KEYS: Record<string, string> = {
|
||||
bookstack: 'tool_config_bookstack',
|
||||
vikunja: 'tool_config_vikunja',
|
||||
nextcloud: 'tool_config_nextcloud',
|
||||
};
|
||||
|
||||
const MASKED_FIELDS = ['tokenSecret', 'apiToken'];
|
||||
|
||||
function maskValue(value: string): string {
|
||||
if (!value || value.length <= 4) return '****';
|
||||
return '*'.repeat(value.length - 4) + value.slice(-4);
|
||||
}
|
||||
|
||||
function maskConfig(config: Record<string, string>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(config)) {
|
||||
result[k] = MASKED_FIELDS.includes(k) ? maskValue(v) : v;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
if (hostname === 'localhost' || hostname === '::1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ipv4Parts = hostname.split('.');
|
||||
if (ipv4Parts.length === 4) {
|
||||
const [a, b] = ipv4Parts.map(Number);
|
||||
if (
|
||||
a === 127 ||
|
||||
a === 10 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
class ToolConfigController {
|
||||
async getConfig(req: Request, res: Response): Promise<void> {
|
||||
const tool = req.params.tool as string;
|
||||
if (!TOOL_SETTINGS_KEYS[tool]) {
|
||||
res.status(400).json({ success: false, message: 'Unbekanntes Tool' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let config: Record<string, string>;
|
||||
if (tool === 'bookstack') {
|
||||
config = await toolConfigService.getBookstackConfig() as unknown as Record<string, string>;
|
||||
} else if (tool === 'vikunja') {
|
||||
config = await toolConfigService.getVikunjaConfig() as unknown as Record<string, string>;
|
||||
} else {
|
||||
config = await toolConfigService.getNextcloudConfig() as unknown as Record<string, string>;
|
||||
}
|
||||
res.status(200).json({ success: true, data: maskConfig(config) });
|
||||
} catch (error) {
|
||||
logger.error('ToolConfigController.getConfig error', { error, tool });
|
||||
res.status(500).json({ success: false, message: 'Konfiguration konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateConfig(req: Request, res: Response): Promise<void> {
|
||||
const tool = req.params.tool as string;
|
||||
const settingsKey = TOOL_SETTINGS_KEYS[tool];
|
||||
if (!settingsKey) {
|
||||
res.status(400).json({ success: false, message: 'Unbekanntes Tool' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
await settingsService.set(settingsKey, req.body, userId);
|
||||
toolConfigService.clearCache();
|
||||
res.status(200).json({ success: true, message: 'Konfiguration gespeichert' });
|
||||
} catch (error) {
|
||||
logger.error('ToolConfigController.updateConfig error', { error, tool });
|
||||
res.status(500).json({ success: false, message: 'Konfiguration konnte nicht gespeichert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(req: Request, res: Response): Promise<void> {
|
||||
const tool = req.params.tool as string;
|
||||
if (!TOOL_SETTINGS_KEYS[tool]) {
|
||||
res.status(400).json({ success: false, message: 'Unbekanntes Tool' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let url: string;
|
||||
let requestConfig: { headers?: Record<string, string> } = {};
|
||||
|
||||
if (tool === 'bookstack') {
|
||||
const current = await toolConfigService.getBookstackConfig();
|
||||
const merged = { ...current, ...req.body };
|
||||
url = merged.url;
|
||||
if (!url || !isValidServiceUrl(url)) {
|
||||
res.status(200).json({ success: false, message: 'Ungültige URL', latencyMs: 0 });
|
||||
return;
|
||||
}
|
||||
requestConfig.headers = {
|
||||
'Authorization': `Token ${merged.tokenId}:${merged.tokenSecret}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
url = `${url}/api/books?count=1`;
|
||||
} else if (tool === 'vikunja') {
|
||||
const current = await toolConfigService.getVikunjaConfig();
|
||||
const merged = { ...current, ...req.body };
|
||||
url = merged.url;
|
||||
if (!url || !isValidServiceUrl(url)) {
|
||||
res.status(200).json({ success: false, message: 'Ungültige URL', latencyMs: 0 });
|
||||
return;
|
||||
}
|
||||
requestConfig.headers = {
|
||||
'Authorization': `Bearer ${merged.apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
url = `${url}/api/v1/info`;
|
||||
} else {
|
||||
const current = await toolConfigService.getNextcloudConfig();
|
||||
const merged = { ...current, ...req.body };
|
||||
url = merged.url;
|
||||
if (!url || !isValidServiceUrl(url)) {
|
||||
res.status(200).json({ success: false, message: 'Ungültige URL', latencyMs: 0 });
|
||||
return;
|
||||
}
|
||||
url = `${url}/status.php`;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
await httpClient.get(url, { ...requestConfig, timeout: 10_000 });
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
res.status(200).json({ success: true, message: 'Verbindung erfolgreich', latencyMs });
|
||||
} catch (error: any) {
|
||||
const latencyMs = 0;
|
||||
const status = error?.response?.status;
|
||||
const message = status
|
||||
? `Verbindung fehlgeschlagen (HTTP ${status})`
|
||||
: 'Verbindung fehlgeschlagen';
|
||||
logger.error('ToolConfigController.testConnection error', { error, tool });
|
||||
res.status(200).json({ success: false, message, latencyMs });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ToolConfigController();
|
||||
@@ -0,0 +1,29 @@
|
||||
-- =============================================================================
|
||||
-- Migration 092: Tool Configure Permissions + Nextcloud Feature Group
|
||||
-- =============================================================================
|
||||
|
||||
-- Feature group: nextcloud
|
||||
INSERT INTO feature_groups (id, label, sort_order)
|
||||
VALUES ('nextcloud', 'Nextcloud', 11)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Configure permissions for wissen, vikunja, nextcloud
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('wissen:configure', 'wissen', 'Konfigurieren', 'BookStack-Verbindung konfigurieren', 10),
|
||||
('vikunja:configure', 'vikunja', 'Konfigurieren', 'Vikunja-Verbindung konfigurieren', 10),
|
||||
('nextcloud:configure', 'nextcloud', 'Konfigurieren', 'Nextcloud-Verbindung konfigurieren', 1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Grant all 3 configure permissions to dashboard_kommando
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
('dashboard_kommando', 'wissen:configure'),
|
||||
('dashboard_kommando', 'vikunja:configure'),
|
||||
('dashboard_kommando', 'nextcloud:configure')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Seed default app_settings for tool configs
|
||||
INSERT INTO app_settings (key, value) VALUES
|
||||
('tool_config_bookstack', '{}'::jsonb),
|
||||
('tool_config_vikunja', '{}'::jsonb),
|
||||
('tool_config_nextcloud', '{}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- =============================================================================
|
||||
-- Migration 093: Module Configure Permissions (kalender, fahrzeuge)
|
||||
-- =============================================================================
|
||||
|
||||
-- Configure permissions for kalender, fahrzeuge
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('kalender:configure', 'kalender', 'Konfigurieren', 'Kalender-Kategorien verwalten', 10),
|
||||
('fahrzeuge:configure', 'fahrzeuge', 'Konfigurieren', 'Fahrzeugtypen verwalten', 10)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Grant both configure permissions to dashboard_kommando
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
('dashboard_kommando', 'kalender:configure'),
|
||||
('dashboard_kommando', 'fahrzeuge:configure')
|
||||
ON CONFLICT DO NOTHING;
|
||||
32
backend/src/routes/toolConfig.routes.ts
Normal file
32
backend/src/routes/toolConfig.routes.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import toolConfigController from '../controllers/toolConfig.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { requirePermission } from '../middleware/rbac.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||
bookstack: 'wissen:configure',
|
||||
vikunja: 'vikunja:configure',
|
||||
nextcloud: 'nextcloud:configure',
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware that resolves the permission from the :tool param
|
||||
* and delegates to requirePermission().
|
||||
*/
|
||||
function requireToolPermission(req: Request, res: Response, next: NextFunction): void {
|
||||
const tool = req.params.tool as string;
|
||||
const permission = TOOL_PERMISSION_MAP[tool];
|
||||
if (!permission) {
|
||||
res.status(400).json({ success: false, message: 'Unbekanntes Tool' });
|
||||
return;
|
||||
}
|
||||
requirePermission(permission)(req, res, next);
|
||||
}
|
||||
|
||||
router.get('/config/:tool', authenticate, requireToolPermission, toolConfigController.getConfig.bind(toolConfigController));
|
||||
router.put('/config/:tool', authenticate, requireToolPermission, toolConfigController.updateConfig.bind(toolConfigController));
|
||||
router.post('/config/:tool/test', authenticate, requireToolPermission, toolConfigController.testConnection.bind(toolConfigController));
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import httpClient from '../config/httpClient';
|
||||
import environment from '../config/environment';
|
||||
import toolConfigService, { BookstackConfig } from './toolConfig.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export interface BookStackPage {
|
||||
@@ -74,10 +74,9 @@ function isValidServiceUrl(raw: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
const { bookstack } = environment;
|
||||
function buildHeaders(config: BookstackConfig): Record<string, string> {
|
||||
return {
|
||||
'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`,
|
||||
'Authorization': `Token ${config.tokenId}:${config.tokenSecret}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
@@ -95,11 +94,11 @@ async function getBookSlugMap(): Promise<Map<number, string>> {
|
||||
if (bookSlugMapCache && Date.now() < bookSlugMapCache.expiresAt) {
|
||||
return bookSlugMapCache.map;
|
||||
}
|
||||
const { bookstack } = environment;
|
||||
const config = await toolConfigService.getBookstackConfig();
|
||||
try {
|
||||
const response = await httpClient.get(
|
||||
`${bookstack.url}/api/books`,
|
||||
{ params: { count: 500 }, headers: buildHeaders() },
|
||||
`${config.url}/api/books`,
|
||||
{ params: { count: 500 }, headers: buildHeaders(config) },
|
||||
);
|
||||
const books: Array<{ id: number; slug: string }> = response.data?.data ?? [];
|
||||
const map = new Map(books.map((b) => [b.id, b.slug]));
|
||||
@@ -111,18 +110,18 @@ async function getBookSlugMap(): Promise<Map<number, string>> {
|
||||
}
|
||||
|
||||
async function getRecentPages(): Promise<BookStackPage[]> {
|
||||
const { bookstack } = environment;
|
||||
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
|
||||
const config = await toolConfigService.getBookstackConfig();
|
||||
if (!config.url || !isValidServiceUrl(config.url)) {
|
||||
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
const [response, bookSlugMap] = await Promise.all([
|
||||
httpClient.get(
|
||||
`${bookstack.url}/api/pages`,
|
||||
`${config.url}/api/pages`,
|
||||
{
|
||||
params: { sort: '-updated_at', count: 20 },
|
||||
headers: buildHeaders(),
|
||||
headers: buildHeaders(config),
|
||||
},
|
||||
),
|
||||
getBookSlugMap(),
|
||||
@@ -130,7 +129,7 @@ async function getRecentPages(): Promise<BookStackPage[]> {
|
||||
const pages: BookStackPage[] = response.data?.data ?? [];
|
||||
return pages.map((p) => ({
|
||||
...p,
|
||||
url: `${bookstack.url}/books/${bookSlugMap.get(p.book_id) || p.book_slug || p.book_id}/page/${p.slug}`,
|
||||
url: `${config.url}/books/${bookSlugMap.get(p.book_id) || p.book_slug || p.book_id}/page/${p.slug}`,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -145,17 +144,17 @@ async function getRecentPages(): Promise<BookStackPage[]> {
|
||||
}
|
||||
|
||||
async function searchPages(query: string): Promise<BookStackSearchResult[]> {
|
||||
const { bookstack } = environment;
|
||||
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
|
||||
const config = await toolConfigService.getBookstackConfig();
|
||||
if (!config.url || !isValidServiceUrl(config.url)) {
|
||||
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpClient.get(
|
||||
`${bookstack.url}/api/search`,
|
||||
`${config.url}/api/search`,
|
||||
{
|
||||
params: { query, count: 50 },
|
||||
headers: buildHeaders(),
|
||||
headers: buildHeaders(config),
|
||||
},
|
||||
);
|
||||
const bookSlugMap = await getBookSlugMap();
|
||||
@@ -167,7 +166,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
|
||||
slug: item.slug,
|
||||
book_id: item.book_id ?? 0,
|
||||
book_slug: item.book_slug ?? '',
|
||||
url: `${bookstack.url}/books/${bookSlugMap.get(item.book_id) || item.book_slug || item.book_id}/page/${item.slug}`,
|
||||
url: `${config.url}/books/${bookSlugMap.get(item.book_id) || item.book_slug || item.book_id}/page/${item.slug}`,
|
||||
preview_html: item.preview_html ?? { content: '' },
|
||||
tags: item.tags ?? [],
|
||||
}));
|
||||
@@ -201,16 +200,16 @@ export interface BookStackPageDetail {
|
||||
}
|
||||
|
||||
async function getPageById(id: number): Promise<BookStackPageDetail> {
|
||||
const { bookstack } = environment;
|
||||
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
|
||||
const config = await toolConfigService.getBookstackConfig();
|
||||
if (!config.url || !isValidServiceUrl(config.url)) {
|
||||
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
const [response, bookSlugMap] = await Promise.all([
|
||||
httpClient.get(
|
||||
`${bookstack.url}/api/pages/${id}`,
|
||||
{ headers: buildHeaders() },
|
||||
`${config.url}/api/pages/${id}`,
|
||||
{ headers: buildHeaders(config) },
|
||||
),
|
||||
getBookSlugMap(),
|
||||
]);
|
||||
@@ -226,7 +225,7 @@ async function getPageById(id: number): Promise<BookStackPageDetail> {
|
||||
html: page.html ?? '',
|
||||
created_at: page.created_at,
|
||||
updated_at: page.updated_at,
|
||||
url: `${bookstack.url}/books/${bookSlug}/page/${page.slug}`,
|
||||
url: `${config.url}/books/${bookSlug}/page/${page.slug}`,
|
||||
book: page.book,
|
||||
createdBy: page.created_by,
|
||||
updatedBy: page.updated_by,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import httpClient from '../config/httpClient';
|
||||
import environment from '../config/environment';
|
||||
import toolConfigService from './toolConfig.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
interface NextcloudLastMessage {
|
||||
@@ -77,7 +77,7 @@ function isValidServiceUrl(raw: string): boolean {
|
||||
}
|
||||
|
||||
async function initiateLoginFlow(): Promise<LoginFlowResult> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -145,7 +145,7 @@ interface NextcloudChatMessage {
|
||||
}
|
||||
|
||||
async function getAllConversations(loginName: string, appPassword: string): Promise<NextcloudConversation[]> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -206,7 +206,7 @@ interface GetMessagesOptions {
|
||||
}
|
||||
|
||||
async function getMessages(token: string, loginName: string, appPassword: string, options?: GetMessagesOptions): Promise<NextcloudChatMessage[]> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -294,7 +294,7 @@ async function getMessages(token: string, loginName: string, appPassword: string
|
||||
}
|
||||
|
||||
async function sendMessage(token: string, message: string, loginName: string, appPassword: string, replyTo?: number): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -331,7 +331,7 @@ async function sendMessage(token: string, message: string, loginName: string, ap
|
||||
}
|
||||
|
||||
async function markAsRead(token: string, loginName: string, appPassword: string): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -368,7 +368,7 @@ async function markAsRead(token: string, loginName: string, appPassword: string)
|
||||
}
|
||||
|
||||
async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -441,7 +441,7 @@ async function uploadFileToTalk(
|
||||
loginName: string,
|
||||
appPassword: string,
|
||||
): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -503,7 +503,7 @@ async function downloadFile(
|
||||
loginName: string,
|
||||
appPassword: string,
|
||||
): Promise<any> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -544,7 +544,7 @@ async function getFilePreview(
|
||||
loginName: string,
|
||||
appPassword: string,
|
||||
): Promise<any> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -580,7 +580,7 @@ async function getFilePreview(
|
||||
}
|
||||
|
||||
async function searchUsers(query: string, loginName: string, appPassword: string): Promise<any[]> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -616,7 +616,7 @@ async function searchUsers(query: string, loginName: string, appPassword: string
|
||||
}
|
||||
|
||||
async function createRoom(roomType: number, invite: string, roomName: string | undefined, loginName: string, appPassword: string): Promise<{ token: string }> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -654,7 +654,7 @@ async function createRoom(roomType: number, invite: string, roomName: string | u
|
||||
}
|
||||
|
||||
async function addReaction(token: string, messageId: number, reaction: string, loginName: string, appPassword: string): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -691,7 +691,7 @@ async function addReaction(token: string, messageId: number, reaction: string, l
|
||||
}
|
||||
|
||||
async function removeReaction(token: string, messageId: number, reaction: string, loginName: string, appPassword: string): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -727,7 +727,7 @@ async function removeReaction(token: string, messageId: number, reaction: string
|
||||
}
|
||||
|
||||
async function getReactions(token: string, messageId: number, loginName: string, appPassword: string): Promise<Record<string, any>> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
@@ -763,7 +763,7 @@ async function getReactions(token: string, messageId: number, loginName: string,
|
||||
}
|
||||
|
||||
async function getPollDetails(token: string, pollId: number, loginName: string, appPassword: string): Promise<Record<string, any>> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
const { url: baseUrl } = await toolConfigService.getNextcloudConfig();
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
92
backend/src/services/toolConfig.service.ts
Normal file
92
backend/src/services/toolConfig.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import settingsService from './settings.service';
|
||||
import environment from '../config/environment';
|
||||
|
||||
export interface BookstackConfig {
|
||||
url: string;
|
||||
tokenId: string;
|
||||
tokenSecret: string;
|
||||
}
|
||||
|
||||
export interface VikunjaConfig {
|
||||
url: string;
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
export interface NextcloudConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 60_000; // 60 seconds
|
||||
|
||||
let bookstackCache: CacheEntry<BookstackConfig> | null = null;
|
||||
let vikunjaCache: CacheEntry<VikunjaConfig> | null = null;
|
||||
let nextcloudCache: CacheEntry<NextcloudConfig> | null = null;
|
||||
|
||||
async function getDbConfig(key: string): Promise<Record<string, string>> {
|
||||
const setting = await settingsService.get(key);
|
||||
if (!setting?.value || typeof setting.value !== 'object') return {};
|
||||
return setting.value as Record<string, string>;
|
||||
}
|
||||
|
||||
async function getBookstackConfig(): Promise<BookstackConfig> {
|
||||
if (bookstackCache && Date.now() < bookstackCache.expiresAt) {
|
||||
return bookstackCache.data;
|
||||
}
|
||||
|
||||
const db = await getDbConfig('tool_config_bookstack');
|
||||
const config: BookstackConfig = {
|
||||
url: db.url || environment.bookstack.url,
|
||||
tokenId: db.tokenId || environment.bookstack.tokenId,
|
||||
tokenSecret: db.tokenSecret || environment.bookstack.tokenSecret,
|
||||
};
|
||||
|
||||
bookstackCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||
return config;
|
||||
}
|
||||
|
||||
async function getVikunjaConfig(): Promise<VikunjaConfig> {
|
||||
if (vikunjaCache && Date.now() < vikunjaCache.expiresAt) {
|
||||
return vikunjaCache.data;
|
||||
}
|
||||
|
||||
const db = await getDbConfig('tool_config_vikunja');
|
||||
const config: VikunjaConfig = {
|
||||
url: db.url || environment.vikunja.url,
|
||||
apiToken: db.apiToken || environment.vikunja.apiToken,
|
||||
};
|
||||
|
||||
vikunjaCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||
return config;
|
||||
}
|
||||
|
||||
async function getNextcloudConfig(): Promise<NextcloudConfig> {
|
||||
if (nextcloudCache && Date.now() < nextcloudCache.expiresAt) {
|
||||
return nextcloudCache.data;
|
||||
}
|
||||
|
||||
const db = await getDbConfig('tool_config_nextcloud');
|
||||
const config: NextcloudConfig = {
|
||||
url: db.url || environment.nextcloudUrl,
|
||||
};
|
||||
|
||||
nextcloudCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||
return config;
|
||||
}
|
||||
|
||||
function clearCache(): void {
|
||||
bookstackCache = null;
|
||||
vikunjaCache = null;
|
||||
nextcloudCache = null;
|
||||
}
|
||||
|
||||
export default {
|
||||
getBookstackConfig,
|
||||
getVikunjaConfig,
|
||||
getNextcloudConfig,
|
||||
clearCache,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import httpClient from '../config/httpClient';
|
||||
import environment from '../config/environment';
|
||||
import toolConfigService, { VikunjaConfig } from './toolConfig.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export interface VikunjaTask {
|
||||
@@ -58,23 +58,23 @@ function isValidServiceUrl(raw: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
function buildHeaders(config: VikunjaConfig): Record<string, string> {
|
||||
return {
|
||||
'Authorization': `Bearer ${environment.vikunja.apiToken}`,
|
||||
'Authorization': `Bearer ${config.apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async function getMyTasks(): Promise<VikunjaTask[]> {
|
||||
const { vikunja } = environment;
|
||||
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
|
||||
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[]>(
|
||||
`${vikunja.url}/api/v1/tasks/all`,
|
||||
{ headers: buildHeaders() },
|
||||
`${config.url}/api/v1/tasks/all`,
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
return (response.data ?? []).filter((t) => !t.done);
|
||||
} catch (error) {
|
||||
@@ -99,15 +99,15 @@ async function getOverdueTasks(): Promise<VikunjaTask[]> {
|
||||
}
|
||||
|
||||
async function getProjects(): Promise<VikunjaProject[]> {
|
||||
const { vikunja } = environment;
|
||||
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
|
||||
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[]>(
|
||||
`${vikunja.url}/api/v1/projects`,
|
||||
{ headers: buildHeaders() },
|
||||
`${config.url}/api/v1/projects`,
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
return response.data ?? [];
|
||||
} catch (error) {
|
||||
@@ -123,8 +123,8 @@ async function getProjects(): Promise<VikunjaProject[]> {
|
||||
}
|
||||
|
||||
async function createTask(projectId: number, title: string, dueDate?: string): Promise<VikunjaTask> {
|
||||
const { vikunja } = environment;
|
||||
if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -134,9 +134,9 @@ async function createTask(projectId: number, title: string, dueDate?: string): P
|
||||
body.due_date = dueDate;
|
||||
}
|
||||
const response = await httpClient.put<VikunjaTask>(
|
||||
`${vikunja.url}/api/v1/projects/${projectId}/tasks`,
|
||||
`${config.url}/api/v1/projects/${projectId}/tasks`,
|
||||
body,
|
||||
{ headers: buildHeaders() },
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user