resolve issues with new features

This commit is contained in:
Matthias Hochmeister
2026-03-12 11:37:25 +01:00
parent d5be68ca63
commit 71a04aee89
38 changed files with 699 additions and 108 deletions

View File

@@ -85,6 +85,7 @@ import bookstackRoutes from './routes/bookstack.routes';
import vikunjaRoutes from './routes/vikunja.routes';
import configRoutes from './routes/config.routes';
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
import settingsRoutes from './routes/settings.routes';
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
@@ -103,6 +104,7 @@ app.use('/api/bookstack', bookstackRoutes);
app.use('/api/vikunja', vikunjaRoutes);
app.use('/api/config', configRoutes);
app.use('/api/admin', serviceMonitorRoutes);
app.use('/api/admin/settings', settingsRoutes);
// 404 handler
app.use(notFoundHandler);

View File

@@ -7,6 +7,7 @@ import logger from '../utils/logger';
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
import { getUserRole } from '../middleware/rbac.middleware';
import pool from '../config/database';
/**
* Extract given_name and family_name from Authentik userinfo.
@@ -372,10 +373,17 @@ class AuthController {
// Generate new access token
const role = await getUserRole(user.id);
// Fetch groups from DB so refreshed tokens retain group info
const groupsResult = await pool.query(
'SELECT authentik_groups FROM users WHERE id = $1',
[user.id]
);
const groups: string[] = groupsResult.rows[0]?.authentik_groups ?? [];
const accessToken = tokenService.generateToken({
userId: user.id,
email: user.email,
authentikSub: user.authentik_sub,
groups,
role,
});

View File

@@ -1,13 +1,23 @@
import { Request, Response } from 'express';
import environment from '../config/environment';
import settingsService from '../services/settings.service';
class ConfigController {
async getExternalLinks(_req: Request, res: Response): Promise<void> {
const links: Record<string, string> = {};
if (environment.nextcloudUrl) links.nextcloud = environment.nextcloudUrl;
if (environment.bookstack.url) links.bookstack = environment.bookstack.url;
if (environment.vikunja.url) links.vikunja = environment.vikunja.url;
res.status(200).json({ success: true, data: links });
const envLinks: Record<string, string> = {};
if (environment.nextcloudUrl) envLinks.nextcloud = environment.nextcloudUrl;
if (environment.bookstack.url) envLinks.bookstack = environment.bookstack.url;
if (environment.vikunja.url) envLinks.vikunja = environment.vikunja.url;
const customLinks = await settingsService.getExternalLinks();
res.status(200).json({
success: true,
data: {
...envLinks,
customLinks,
},
});
}
}

View File

@@ -115,7 +115,12 @@ class NextcloudController {
}
const messages = await nextcloudService.getMessages(token, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: messages });
} catch (error) {
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(200).json({ success: true, data: { connected: false } });
return;
}
logger.error('getMessages error', { error });
res.status(500).json({ success: false, message: 'Nachrichten konnten nicht geladen werden' });
}
@@ -140,7 +145,12 @@ class NextcloudController {
}
await nextcloudService.sendMessage(token, message.trim(), credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: null });
} catch (error) {
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(200).json({ success: true, data: { connected: false } });
return;
}
logger.error('sendMessage error', { error });
res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' });
}
@@ -160,7 +170,12 @@ class NextcloudController {
}
await nextcloudService.markAsRead(token, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: null });
} catch (error) {
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(200).json({ success: true, data: { connected: false } });
return;
}
logger.error('markRoomAsRead error', { error });
res.status(500).json({ success: false, message: 'Raum konnte nicht als gelesen markiert werden' });
}

View File

@@ -0,0 +1,62 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import settingsService from '../services/settings.service';
import logger from '../utils/logger';
const updateSchema = z.object({
value: z.any(),
});
const externalLinkSchema = z.array(z.object({
name: z.string().min(1).max(200),
url: z.string().url().max(500),
}));
class SettingsController {
async getAll(_req: Request, res: Response): Promise<void> {
try {
const settings = await settingsService.getAll();
res.json({ success: true, data: settings });
} catch (error) {
logger.error('Failed to get settings', { error });
res.status(500).json({ success: false, message: 'Failed to get settings' });
}
}
async get(req: Request, res: Response): Promise<void> {
try {
const setting = await settingsService.get(req.params.key as string);
if (!setting) {
res.status(404).json({ success: false, message: 'Setting not found' });
return;
}
res.json({ success: true, data: setting });
} catch (error) {
logger.error('Failed to get setting', { error });
res.status(500).json({ success: false, message: 'Failed to get setting' });
}
}
async update(req: Request, res: Response): Promise<void> {
try {
const { value } = updateSchema.parse(req.body);
// Validate external_links specifically
if ((req.params.key as string) === 'external_links') {
externalLinkSchema.parse(value);
}
const setting = await settingsService.set(req.params.key as string, value, req.user!.id);
res.json({ success: true, data: setting });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
return;
}
logger.error('Failed to update setting', { error });
res.status(500).json({ success: false, message: 'Failed to update setting' });
}
}
}
export default new SettingsController();

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES users(id) ON DELETE SET NULL
);
-- Seed default external links (empty array)
INSERT INTO app_settings (key, value) VALUES
('external_links', '[]'::jsonb),
('refresh_intervals', '{"dashboard": 300000, "admin_services": 15000}'::jsonb)
ON CONFLICT (key) DO NOTHING;

View File

@@ -124,6 +124,16 @@ export function requirePermission(permission: string) {
(req as Request & { userRole?: AppRole }).userRole = role;
if (!hasPermission(role, permission)) {
// Fallback: dashboard_admin group grants admin:access
if (permission === 'admin:access') {
const userGroups: string[] = req.user?.groups ?? [];
if (userGroups.includes('dashboard_admin')) {
(req as Request & { userRole?: AppRole }).userRole = 'admin';
next();
return;
}
}
logger.warn('Permission denied', {
userId: req.user.id,
role,

View File

@@ -0,0 +1,13 @@
import { Router } from 'express';
import settingsController from '../controllers/settings.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
const auth = [authenticate, requirePermission('admin:access')] as const;
router.get('/', ...auth, settingsController.getAll.bind(settingsController));
router.get('/:key', ...auth, settingsController.get.bind(settingsController));
router.put('/:key', ...auth, settingsController.update.bind(settingsController));
export default router;

View File

@@ -98,7 +98,7 @@ async function getRecentPages(): Promise<BookStackPage[]> {
const pages: BookStackPage[] = response.data?.data ?? [];
return pages.map((p) => ({
...p,
url: `${bookstack.url}/books/${p.book_slug}/page/${p.slug}`,
url: p.url && p.url.startsWith('http') ? p.url : `${bookstack.url}/books/${p.book_slug}/page/${p.slug}`,
}));
} catch (error) {
if (axios.isAxiosError(error)) {
@@ -122,7 +122,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
const response = await axios.get(
`${bookstack.url}/api/search`,
{
params: { query, count: 8 },
params: { query, count: 50 },
headers: buildHeaders(),
},
);
@@ -189,7 +189,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/${page.book_slug}/page/${page.slug}`,
url: page.url && page.url.startsWith('http') ? page.url : `${bookstack.url}/books/${page.book_slug}/page/${page.slug}`,
book: page.book,
createdBy: page.created_by,
updatedBy: page.updated_by,

View File

@@ -200,30 +200,47 @@ async function getMessages(token: string, loginName: string, appPassword: string
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
const response = await axios.get(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/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',
try {
const response = await axios.get(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/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 ?? '',
}));
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> {
@@ -232,18 +249,35 @@ async function sendMessage(token: string, message: string, loginName: string, ap
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
await axios.post(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
{ message },
{
headers: {
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
'OCS-APIRequest': 'true',
'Accept': 'application/json',
'Content-Type': 'application/json',
try {
await axios.post(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/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> {
@@ -252,16 +286,33 @@ async function markAsRead(token: string, loginName: string, appPassword: string)
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
}
await axios.delete(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}/read`,
{
headers: {
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
'OCS-APIRequest': 'true',
'Accept': 'application/json',
try {
await axios.delete(
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/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> {

View File

@@ -0,0 +1,43 @@
import pool from '../config/database';
export interface AppSetting {
key: string;
value: any;
updated_at: string;
updated_by: string | null;
}
class SettingsService {
async getAll(): Promise<AppSetting[]> {
const result = await pool.query('SELECT * FROM app_settings ORDER BY key');
return result.rows;
}
async get(key: string): Promise<AppSetting | null> {
const result = await pool.query('SELECT * FROM app_settings WHERE key = $1', [key]);
return result.rows[0] ?? null;
}
async set(key: string, value: any, userId: string): Promise<AppSetting> {
const result = await pool.query(
`INSERT INTO app_settings (key, value, updated_by, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_by = $3, updated_at = NOW()
RETURNING *`,
[key, JSON.stringify(value), userId]
);
return result.rows[0];
}
async delete(key: string): Promise<boolean> {
const result = await pool.query('DELETE FROM app_settings WHERE key = $1', [key]);
return (result.rowCount ?? 0) > 0;
}
async getExternalLinks(): Promise<Array<{name: string; url: string}>> {
const setting = await this.get('external_links');
return Array.isArray(setting?.value) ? setting.value : [];
}
}
export default new SettingsService();