Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ jobs:
sites:
- 'apps/sites/**'
- 'packages/shared/**'
- 'packages/sites-design/**'
docs:
- 'apps/docs/**'
- 'packages/shared/**'
Expand Down
231 changes: 146 additions & 85 deletions apps/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Central routing setup for all API endpoints
*/

import rateLimit from 'express-rate-limit';

import authMiddleware from './middleware/authMiddleware.js';
import antraegeRouter from './routes/antraege/index.js';
import etherpadRoute from './routes/etherpad/etherpadController.js';
Expand Down Expand Up @@ -70,6 +72,27 @@ import { RouteStatsTracker } from './utils/routeStats.js';

import type { Application, Request, Response, NextFunction } from 'express';

/**
* IP-based rate limiters for abuse prevention.
* These are intentionally softer since most routes also have frontend-side throttling.
* Complements the existing Redis-based per-user rate limiter (used for business quotas).
*/
const aiGenerationLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 60, // ~4 per minute average
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many AI generation requests, please try again later.' },
});

const standardMutationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 200,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' },
});

const log = createLogger('Routes');

const { requireAuth } = authMiddleware;
Expand Down Expand Up @@ -176,97 +199,135 @@ export async function setupRoutes(app: Application): Promise<void> {
app.use('/api/scanner', scannerRouter);
app.use('/api/protokoll', protokollRouter);

app.use('/api/claude_social', claudeSocialRoute);
app.use('/api/claude_alttext', claudeAlttextRoute);
app.use('/api/claude_website', claudeWebsiteRoute);
app.use('/api/leichte_sprache', leichteSpracheRoute);
app.use('/api/claude_rede', redeRouter);
app.use('/api/claude_buergeranfragen', buergeranfragenRouter);
app.use('/api/claude_text_improver', claudeTextImproverRoute);
app.use('/api/chat', grueneratorChatRoute);
app.use('/api/chat-service', chatServiceRouter);
app.use('/api/chat-graph', chatGraphRouter);
app.use('/api/chat-deep', chatDeepRouter); // @experimental — DeepAgent route, not production-ready
app.use('/api/claude_social', aiGenerationLimiter, claudeSocialRoute);
app.use('/api/claude_alttext', aiGenerationLimiter, claudeAlttextRoute);
app.use('/api/claude_website', aiGenerationLimiter, claudeWebsiteRoute);
app.use('/api/leichte_sprache', aiGenerationLimiter, leichteSpracheRoute);
app.use('/api/claude_rede', aiGenerationLimiter, redeRouter);
app.use('/api/claude_buergeranfragen', aiGenerationLimiter, buergeranfragenRouter);
app.use('/api/claude_text_improver', aiGenerationLimiter, claudeTextImproverRoute);
app.use('/api/chat', aiGenerationLimiter, grueneratorChatRoute);
app.use('/api/chat-service', aiGenerationLimiter, chatServiceRouter);
app.use('/api/chat-graph', aiGenerationLimiter, chatGraphRouter);
app.use('/api/chat-deep', aiGenerationLimiter, chatDeepRouter); // @experimental — DeepAgent route, not production-ready
app.use('/api/gruen-o-mat', gruenOMatRouter);
app.use('/api/dreizeilen_canvas', sharepicDreizeilenCanvasRoute);
app.use('/api/zitat_canvas', zitatSharepicCanvasRoute);
app.use('/api/zitat_pure_canvas', zitatPureSharepicCanvasRoute);
app.use('/api/info_canvas', infoSharepicCanvasRoute);
app.use('/api/imagine_label_canvas', imagineLabelCanvasRoute);
app.use('/api/campaign_canvas', campaignCanvasRoute);
app.use('/api/veranstaltung_canvas', veranstaltungCanvasRoute);
app.use('/api/profilbild_canvas', profilbildCanvasRoute);
app.use('/api/simple_canvas', simpleCanvasRoute);
app.use('/api/slider_canvas', sliderCanvasRoute);
app.use('/api/campaign_generate', campaignGenerateRoute);
app.use('/api/dreizeilen_claude', sharepicClaudeRoute);
app.use('/api/sharepic/edit-session', editSessionRouter);
app.use('/api/sharepic', promptRoute);
app.use('/api/dreizeilen_canvas', standardMutationLimiter, sharepicDreizeilenCanvasRoute);
app.use('/api/zitat_canvas', standardMutationLimiter, zitatSharepicCanvasRoute);
app.use('/api/zitat_pure_canvas', standardMutationLimiter, zitatPureSharepicCanvasRoute);
app.use('/api/info_canvas', standardMutationLimiter, infoSharepicCanvasRoute);
app.use('/api/imagine_label_canvas', standardMutationLimiter, imagineLabelCanvasRoute);
app.use('/api/campaign_canvas', standardMutationLimiter, campaignCanvasRoute);
app.use('/api/veranstaltung_canvas', standardMutationLimiter, veranstaltungCanvasRoute);
app.use('/api/profilbild_canvas', standardMutationLimiter, profilbildCanvasRoute);
app.use('/api/simple_canvas', standardMutationLimiter, simpleCanvasRoute);
app.use('/api/slider_canvas', standardMutationLimiter, sliderCanvasRoute);
app.use('/api/campaign_generate', aiGenerationLimiter, campaignGenerateRoute);
app.use('/api/dreizeilen_claude', aiGenerationLimiter, sharepicClaudeRoute);
app.use('/api/sharepic/edit-session', standardMutationLimiter, editSessionRouter);
app.use('/api/sharepic', aiGenerationLimiter, promptRoute);

app.post('/api/zitat_claude', async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'zitat');
});
app.post('/api/headline_claude', async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'headline');
});
app.post('/api/info_claude', async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'info');
});
app.post('/api/veranstaltung_claude', async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'veranstaltung');
});
app.post('/api/zitat_pure_claude', async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'zitat_pure');
});
app.post('/api/simple_claude', async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'simple');
});
app.post('/api/slider_claude', async (req: Request, res: Response): Promise<void> => {
if (req.body.smartCount) {
await handleSliderSmartRequest(req as any, res);
} else {
await handleClaudeRequest(req as any, res, 'slider');
app.post(
'/api/zitat_claude',
aiGenerationLimiter,
async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'zitat');
}
});
app.post('/api/default_claude', async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'default');
});
);
app.post(
'/api/headline_claude',
aiGenerationLimiter,
async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'headline');
}
);
app.post(
'/api/info_claude',
aiGenerationLimiter,
async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'info');
}
);
app.post(
'/api/veranstaltung_claude',
aiGenerationLimiter,
async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'veranstaltung');
}
);
app.post(
'/api/zitat_pure_claude',
aiGenerationLimiter,
async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'zitat_pure');
}
);
app.post(
'/api/simple_claude',
aiGenerationLimiter,
async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'simple');
}
);
app.post(
'/api/slider_claude',
aiGenerationLimiter,
async (req: Request, res: Response): Promise<void> => {
if (req.body.smartCount) {
await handleSliderSmartRequest(req as any, res);
} else {
await handleClaudeRequest(req as any, res, 'slider');
}
}
);
app.post(
'/api/default_claude',
aiGenerationLimiter,
async (req: Request, res: Response): Promise<void> => {
await handleClaudeRequest(req as any, res, 'default');
}
);

app.post('/api/generate-sharepic', async (req: Request, res: Response): Promise<void> => {
try {
const { type, ...requestBody } = req.body;
if (!type) {
res.status(400).json({ success: false, error: 'Sharepic type is required' });
return;
app.post(
'/api/generate-sharepic',
aiGenerationLimiter,
async (req: Request, res: Response): Promise<void> => {
try {
const { type, ...requestBody } = req.body;
if (!type) {
res.status(400).json({ success: false, error: 'Sharepic type is required' });
return;
}
const result = await generateSharepicForChat(req as any, type, requestBody);
res.json({ success: true, ...result.content.sharepic, metadata: result.content.metadata });
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
console.error('[UnifiedSharepic] Error:', err);
res
.status(500)
.json({ success: false, error: err.message || 'Failed to generate sharepic' });
}
const result = await generateSharepicForChat(req as any, type, requestBody);
res.json({ success: true, ...result.content.sharepic, metadata: result.content.metadata });
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
console.error('[UnifiedSharepic] Error:', err);
res.status(500).json({ success: false, error: err.message || 'Failed to generate sharepic' });
}
});
);

app.use('/api/ai-image-modification', aiImageModificationRouter);
app.use('/api/imageupload', imageUploadRouter);
app.use('/api/processText', processTextRouter);
app.use('/api/claude_text_adjustment', claudeTextAdjustmentRoute);
app.use('/api/etherpad', etherpadRoute);
app.use('/api/claude_wahlprogramm', wahlprogrammRouter);
app.use('/api/claude_universal', universalRouter);
app.use('/api/texte/smart', smartTexteRouter);
app.use('/api/generate-content-title', contentTitleRouter);
app.use('/api/claude_gruene_jugend', claudeGrueneJugendRoute);
app.use('/api/claude_gruenerator_ask', claudeGrueneratorAskRoute);
app.use('/api/ai-image-modification', aiGenerationLimiter, aiImageModificationRouter);
app.use('/api/imageupload', standardMutationLimiter, imageUploadRouter);
app.use('/api/processText', aiGenerationLimiter, processTextRouter);
app.use('/api/claude_text_adjustment', aiGenerationLimiter, claudeTextAdjustmentRoute);
app.use('/api/etherpad', standardMutationLimiter, etherpadRoute);
app.use('/api/claude_wahlprogramm', aiGenerationLimiter, wahlprogrammRouter);
app.use('/api/claude_universal', aiGenerationLimiter, universalRouter);
app.use('/api/texte/smart', aiGenerationLimiter, smartTexteRouter);
app.use('/api/generate-content-title', aiGenerationLimiter, contentTitleRouter);
app.use('/api/claude_gruene_jugend', aiGenerationLimiter, claudeGrueneJugendRoute);
app.use('/api/claude_gruenerator_ask', aiGenerationLimiter, claudeGrueneratorAskRoute);
app.use('/api/custom_generator', customGeneratorRoute);
app.use('/api/auth/custom_generator', customGeneratorRoute);
app.use('/api/generate_generator_config', generatorConfiguratorRoute);
app.use('/api/custom_prompt', customPromptRoute);
app.use('/api/auth/custom_prompt', customPromptRoute);
app.use('/api/claude/generate-short-subtitles', claudeSubtitlesRoute);
app.use('/api/subtitler', subtitlerRouter);
app.use('/api/subtitler', subtitlerSocialRouter);
app.use('/api/claude/generate-short-subtitles', aiGenerationLimiter, claudeSubtitlesRoute);
app.use('/api/subtitler', standardMutationLimiter, subtitlerRouter);
app.use('/api/subtitler', standardMutationLimiter, subtitlerSocialRouter);
app.use('/api/subtitler/projects', subtitlerProjectRouter);
app.use('/api/subtitler/share', subtitlerShareRouter);
app.use('/api/share', shareRouter);
Expand All @@ -284,7 +345,7 @@ export async function setupRoutes(app: Application): Promise<void> {
app.use('/api/unsplash', unsplashRouter);
app.use('/api/web-search', webSearchRouter);
app.use('/api/research', requireAuth, researchRouter);
app.use('/api/image-generation', imageGenerationRouter);
app.use('/api/image-generation', aiGenerationLimiter, imageGenerationRouter);
app.use('/api/rate-limit', rateLimitRouter);

// Debug: log all requests to /api/releases/*
Expand Down Expand Up @@ -320,11 +381,11 @@ export async function setupRoutes(app: Application): Promise<void> {

app.use('/api/video', requireAuth, videoRouter);
app.use('/api/nextcloud', nextcloudApiRouter);
app.use('/api/sites/generate-from-flyer', flyerController);
app.use('/api/sites', sitesRouter);
app.use('/api/flux/green-edit', fluxImageEditingRoute);
app.use('/api/imagine/create', imagineCreateRoute);
app.use('/api/imagine/pure', imaginePureRoute);
app.use('/api/sites/generate-from-flyer', aiGenerationLimiter, flyerController);
app.use('/api/sites', standardMutationLimiter, sitesRouter);
app.use('/api/flux/green-edit', aiGenerationLimiter, fluxImageEditingRoute);
app.use('/api/imagine/create', aiGenerationLimiter, imagineCreateRoute);
app.use('/api/imagine/pure', aiGenerationLimiter, imaginePureRoute);

// Web redirect to frontend imagine (KI image studio)
app.get('/web', (req: Request, res: Response) => {
Expand Down
1 change: 1 addition & 0 deletions apps/docs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
COPY packages/shared ./packages/shared
COPY packages/docs ./packages/docs
COPY apps/web/src/assets/styles ./apps/web/src/assets/styles
COPY apps/web/src/assets/fonts ./apps/web/src/assets/fonts
COPY apps/docs ./apps/docs

# Build the Vite app
Expand Down
2 changes: 2 additions & 0 deletions apps/sites/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ COPY tsconfig.base.json ./

# Copy only package.json files needed by pnpm filter (better layer caching)
COPY packages/shared/package.json ./packages/shared/
COPY packages/sites-design/package.json ./packages/sites-design/
COPY apps/sites/package.json ./apps/sites/

# Install only sites + transitive workspace deps
Expand All @@ -25,6 +26,7 @@ RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \

# Copy source (changes here don't bust the install cache)
COPY packages/shared ./packages/shared
COPY packages/sites-design ./packages/sites-design
COPY apps/sites ./apps/sites

# Build environment variables (can be overridden at build time)
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function PopoverContent({
className,
align = 'center',
sideOffset = 4,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
Expand All @@ -23,6 +24,7 @@ function PopoverContent({
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
className={cn(
'bg-background-pure z-50 w-72 origin-(--radix-popover-content-transform-origin) overflow-hidden rounded-md border border-grey-200 p-1 shadow-lg outline-hidden dark:border-grey-700 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/features/research/ResearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ function ResearchPage() {
<ErrorBoundary>
<div className="gallery-layout">
<div className="gallery-header">
<h1>Research</h1>
<h1>Recherche</h1>
<p>
Durchsuche alle gescrapten Dokumente und Programme direkt in den Qdrant-Kollektionen.
</p>
Expand Down Expand Up @@ -241,4 +241,4 @@ function ResearchPage() {
);
}

export default withAuthRequired(ResearchPage, { title: 'Research' });
export default withAuthRequired(ResearchPage, { title: 'Recherche' });
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,10 @@ export default function ResearchFilterPanel({
)}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[22rem] max-h-[28rem] overflow-y-auto p-3">
<PopoverContent
align="start"
className="w-[22rem] sm:w-[30rem] max-h-[28rem] sm:max-h-[32rem] overflow-y-auto p-3"
>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export default function ResearchSettingsPanel({
<PopoverContent
align="end"
sideOffset={8}
className="w-[22rem] max-h-[32rem] overflow-y-auto p-3"
className="w-[22rem] sm:w-[30rem] max-h-[32rem] sm:max-h-[36rem] overflow-y-auto p-3"
>
<div className="space-y-3">
<div className="flex items-center justify-between">
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/tools/ToolsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const tools: ToolEntry[] = [
},
{
id: 'research',
title: 'Research',
title: 'Recherche',
description: 'Manuelle Suche über alle gescrapten Dokumente und Programme.',
path: '/research',
tags: ['Dokumente', 'Qdrant'],
Expand Down
Loading