Skip to content

Commit f2ca566

Browse files
authored
Merge pull request #530 from netzbegruenung/test-branch
fix: canvas auto-save, docs updated_at, error logging
2 parents e6fb4ed + c007dfd commit f2ca566

23 files changed

Lines changed: 431 additions & 154 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Add 'draft' to shared_media status CHECK constraint
2+
-- This allows auto-save to create draft entries that are promoted to 'ready' on explicit share
3+
4+
ALTER TABLE shared_media DROP CONSTRAINT IF EXISTS shared_media_status_check;
5+
ALTER TABLE shared_media ADD CONSTRAINT shared_media_status_check
6+
CHECK (status IN ('processing', 'ready', 'failed', 'draft'));

apps/api/database/postgres/schema.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,7 @@ CREATE TABLE IF NOT EXISTS shared_media (
633633
project_id UUID REFERENCES subtitler_projects(id) ON DELETE SET NULL,
634634
image_type TEXT,
635635
image_metadata JSONB DEFAULT '{}',
636-
status VARCHAR(20) DEFAULT 'ready' CHECK (status IN ('processing', 'ready', 'failed')),
636+
status VARCHAR(20) DEFAULT 'ready' CHECK (status IN ('processing', 'ready', 'failed', 'draft')),
637637
download_count INTEGER DEFAULT 0,
638638
view_count INTEGER DEFAULT 0,
639639
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"domhandler": "^5.0.3",
7474
"dotenv": "^17.3.1",
7575
"express": "^5.2.1",
76+
"express-rate-limit": "^8.3.0",
7677
"express-session": "^1.19.0",
7778
"fastembed": "^2.1.0",
7879
"form-data": "^4.0.5",

apps/api/routes/docs/documentController.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -320,17 +320,17 @@ router.put('/:id', async (req: Request, res: Response) => {
320320
if (content !== undefined) {
321321
updates.push(`content = $${paramIndex++}`);
322322
values.push(content);
323+
updates.push(`last_edited_by = $${paramIndex++}`);
324+
values.push(userId);
325+
updates.push(`last_edited_at = CURRENT_TIMESTAMP`);
326+
updates.push(`updated_at = CURRENT_TIMESTAMP`);
323327
}
324328

325-
updates.push(`last_edited_by = $${paramIndex++}`);
326-
values.push(userId);
327-
updates.push(`last_edited_at = CURRENT_TIMESTAMP`);
328-
329329
values.push(id);
330330

331331
const result = (await db.query(
332332
`UPDATE collaborative_documents
333-
SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP
333+
SET ${updates.join(', ')}
334334
WHERE id = $${paramIndex}
335335
RETURNING *`,
336336
values

apps/api/routes/share/shareController.ts

Lines changed: 111 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import fs from 'fs';
77
import path from 'path';
88

99
import express, { type Request, type Response, type Router } from 'express';
10+
import rateLimit from 'express-rate-limit';
1011
import sharp from 'sharp';
1112

1213
import { requireAuth } from '../../middleware/authMiddleware.js';
@@ -19,6 +20,14 @@ import type { SharedMediaRow, ShareResult } from '../../types/media.js';
1920
const fsPromises = fs.promises;
2021
const log = createLogger('share');
2122

23+
// Rate limiters for share mutation routes
24+
const shareWriteLimiter = rateLimit({
25+
windowMs: 15 * 60 * 1000, // 15 minutes
26+
max: 50,
27+
standardHeaders: true,
28+
legacyHeaders: false,
29+
});
30+
2231
// ============================================================================
2332
// Types
2433
// ============================================================================
@@ -57,7 +66,11 @@ interface SharedMediaService {
5766
userId: string,
5867
params: CreatePendingVideoShareParams
5968
): Promise<ShareResult>;
60-
getUserShares(userId: string, type: string | null): Promise<SharedMediaRow[]>;
69+
getUserShares(
70+
userId: string,
71+
type: string | null,
72+
status?: string | null
73+
): Promise<SharedMediaRow[]>;
6174
getUserShareCount(userId: string): Promise<number>;
6275
getShareByToken(shareToken: string): Promise<SharedMediaRow | null>;
6376
recordView(shareToken: string): Promise<void>;
@@ -103,6 +116,7 @@ interface CreateImageShareParams {
103116
imageType: string | null;
104117
metadata: Record<string, unknown>;
105118
originalImage: string | null;
119+
status?: 'ready' | 'draft';
106120
}
107121

108122
interface CreateVideoShareParams {
@@ -134,6 +148,7 @@ interface ImageShareRequest extends AuthenticatedRequest {
134148
imageType?: string;
135149
metadata?: Record<string, unknown>;
136150
originalImage?: string;
151+
status?: 'ready' | 'draft';
137152
};
138153
}
139154

@@ -299,53 +314,111 @@ async function triggerBackgroundRender(
299314

300315
const router: Router = express.Router();
301316

317+
// Override global body parser limit for share routes (canvas images can exceed 10MB)
318+
router.use(express.json({ limit: '50mb' }));
319+
302320
// ============================================================================
303321
// IMAGE SHARE ROUTES
304322
// ============================================================================
305323

306-
router.post('/image', requireAuth, async (req: ImageShareRequest, res: Response<ShareResponse>) => {
307-
try {
308-
const userId = req.user!.id;
309-
const { imageData, title, imageType, metadata, originalImage } = req.body;
324+
router.post(
325+
'/image',
326+
shareWriteLimiter,
327+
requireAuth,
328+
async (req: ImageShareRequest, res: Response<ShareResponse>) => {
329+
try {
330+
const userId = req.user!.id;
331+
const { imageData, title, imageType, metadata, originalImage, status } = req.body;
310332

311-
if (!imageData) {
312-
return res.status(400).json({
333+
if (!imageData) {
334+
return res.status(400).json({
335+
success: false,
336+
error: 'Bilddaten werden benötigt',
337+
});
338+
}
339+
340+
const service = await getSharedMediaService();
341+
const share = await service.createImageShare(userId, {
342+
imageBase64: imageData,
343+
title: title || 'Geteiltes Bild',
344+
imageType: imageType || null,
345+
metadata: metadata || {},
346+
originalImage: originalImage || null,
347+
status: status === 'draft' ? 'draft' : 'ready',
348+
});
349+
350+
log.info(
351+
`Image share created: ${share.shareToken} by user ${userId}${originalImage ? ' (with original)' : ''}`
352+
);
353+
354+
return res.json({
355+
success: true,
356+
share: {
357+
shareToken: share.shareToken,
358+
shareUrl: share.shareUrl,
359+
createdAt: share.createdAt,
360+
mediaType: 'image',
361+
hasOriginalImage: share.hasOriginalImage || false,
362+
},
363+
});
364+
} catch (error) {
365+
log.error('Failed to create image share:', error);
366+
return res.status(500).json({
313367
success: false,
314-
error: 'Bilddaten werden benötigt',
368+
error: 'Bild konnte nicht geteilt werden',
315369
});
316370
}
371+
}
372+
);
317373

318-
const service = await getSharedMediaService();
319-
const share = await service.createImageShare(userId, {
320-
imageBase64: imageData,
321-
title: title || 'Geteiltes Bild',
322-
imageType: imageType || null,
323-
metadata: metadata || {},
324-
originalImage: originalImage || null,
325-
});
374+
// Promote a draft to ready (publish)
375+
router.put(
376+
'/:shareToken/publish',
377+
shareWriteLimiter,
378+
requireAuth,
379+
async (req: Request<ShareTokenParams>, res: Response<ShareResponse>) => {
380+
try {
381+
const userId = (req as AuthenticatedRequest).user!.id;
382+
const { shareToken } = req.params;
326383

327-
log.info(
328-
`Image share created: ${share.shareToken} by user ${userId}${originalImage ? ' (with original)' : ''}`
329-
);
384+
const service = await getSharedMediaService();
385+
const share = await service.getShareByToken(shareToken as string);
330386

331-
return res.json({
332-
success: true,
333-
share: {
334-
shareToken: share.shareToken,
335-
shareUrl: share.shareUrl,
336-
createdAt: share.createdAt,
337-
mediaType: 'image',
338-
hasOriginalImage: share.hasOriginalImage || false,
339-
},
340-
});
341-
} catch (error) {
342-
log.error('Failed to create image share:', error);
343-
return res.status(500).json({
344-
success: false,
345-
error: 'Bild konnte nicht geteilt werden',
346-
});
387+
if (!share) {
388+
return res.status(404).json({ success: false, error: 'Share nicht gefunden' });
389+
}
390+
391+
if (share.user_id !== userId) {
392+
return res.status(403).json({ success: false, error: 'Nicht berechtigt' });
393+
}
394+
395+
const pg = (await import('../../database/services/PostgresService.js')).getPostgresInstance();
396+
await pg.query('UPDATE shared_media SET status = $1 WHERE share_token = $2', [
397+
'ready',
398+
shareToken,
399+
]);
400+
401+
log.info(`Share ${shareToken} published by user ${userId}`);
402+
403+
return res.json({
404+
success: true,
405+
share: {
406+
shareToken: share.share_token,
407+
shareUrl: `/share/${shareToken}`,
408+
createdAt: share.created_at,
409+
mediaType: share.media_type as 'image' | 'video',
410+
status: 'ready',
411+
},
412+
});
413+
} catch (error) {
414+
log.error('Failed to publish share:', error);
415+
return res.status(500).json({
416+
success: false,
417+
error: 'Share konnte nicht veröffentlicht werden',
418+
});
419+
}
347420
}
348-
});
421+
);
349422

350423
// ============================================================================
351424
// VIDEO SHARE ROUTES
@@ -580,9 +653,10 @@ router.get(
580653
try {
581654
const userId = req.user!.id;
582655
const type = req.query.type as string | undefined;
656+
const status = req.query.status as string | undefined;
583657

584658
const service = await getSharedMediaService();
585-
const shares = await service.getUserShares(userId, type || null);
659+
const shares = await service.getUserShares(userId, type || null, status || null);
586660
const count = await service.getUserShareCount(userId);
587661

588662
res.json({

apps/api/server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,13 @@ async function startWorker(): Promise<void> {
553553
let errorMessage = 'Bitte versuchen Sie es später erneut';
554554
let statusCode = 500;
555555

556+
log.error(`[GlobalErrorHandler] ${err.name}: ${err.message}`, {
557+
path: req.path,
558+
method: req.method,
559+
statusCode,
560+
errorCode: (err as NodeJS.ErrnoException).code,
561+
});
562+
556563
if (
557564
err.name === 'AuthenticationError' ||
558565
(err.message && err.message.includes('authentication'))

apps/api/services/sharedMediaService.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,14 @@ class SharedMediaService {
227227
await this.ensureInitialized();
228228
await this.enforceUserLimit(userId);
229229

230-
const { imageBase64, title, imageType, metadata = {}, originalImage = null } = params;
230+
const {
231+
imageBase64,
232+
title,
233+
imageType,
234+
metadata = {},
235+
originalImage = null,
236+
status = 'ready',
237+
} = params;
231238
const shareToken = this.generateShareToken();
232239
const shareDir = getSafeShareDir(shareToken);
233240

@@ -300,7 +307,7 @@ class SharedMediaService {
300307
INSERT INTO shared_media
301308
(user_id, share_token, media_type, title, file_path, file_name, thumbnail_path,
302309
file_size, mime_type, image_type, image_metadata, status)
303-
VALUES ($1, $2, 'image', $3, $4, $5, $6, $7, $8, $9, $10, 'ready')
310+
VALUES ($1, $2, 'image', $3, $4, $5, $6, $7, $8, $9, $10, $11)
304311
RETURNING id, share_token, created_at
305312
`;
306313

@@ -319,6 +326,7 @@ class SharedMediaService {
319326
mimeType,
320327
imageType || null,
321328
JSON.stringify(enrichedMetadata),
329+
status,
322330
]);
323331

324332
console.log(
@@ -480,7 +488,8 @@ class SharedMediaService {
480488

481489
async getUserShares(
482490
userId: string,
483-
mediaType: 'image' | 'video' | null = null
491+
mediaType: 'image' | 'video' | null = null,
492+
status: string | null = null
484493
): Promise<SharedMediaRow[]> {
485494
await this.ensureInitialized();
486495

@@ -492,10 +501,18 @@ class SharedMediaService {
492501
WHERE user_id = $1
493502
`;
494503
const params: unknown[] = [userId];
504+
let paramIndex = 2;
495505

496506
if (mediaType) {
497-
query += ` AND media_type = $2`;
507+
query += ` AND media_type = $${paramIndex}`;
498508
params.push(mediaType);
509+
paramIndex++;
510+
}
511+
512+
if (status) {
513+
query += ` AND status = $${paramIndex}`;
514+
params.push(status);
515+
paramIndex++;
499516
}
500517

501518
query += ` ORDER BY created_at DESC LIMIT 100`;
@@ -905,6 +922,7 @@ class SharedMediaService {
905922
}
906923

907924
const shareDir = getSafeShareDir(shareToken);
925+
await fs.mkdir(shareDir, { recursive: true });
908926

909927
const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, '');
910928
const imageBuffer = Buffer.from(base64Data, 'base64');

apps/api/types/media.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export interface SharedMediaRow {
3636
project_id: string | null;
3737
image_type: string | null;
3838
image_metadata: Record<string, unknown> | null;
39-
status: 'processing' | 'ready' | 'failed';
39+
status: 'processing' | 'ready' | 'failed' | 'draft';
4040
download_count: number;
4141
view_count: number;
4242
is_library_item: boolean;
@@ -90,6 +90,7 @@ export interface CreateImageShareParams {
9090
imageType?: string;
9191
metadata?: Record<string, unknown>;
9292
originalImage?: string | null;
93+
status?: 'ready' | 'draft';
9394
}
9495

9596
/**

apps/docs/server.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,23 @@ app.use(
7878
setHeaders(res, filePath) {
7979
// index.html must not be cached — it references hashed chunks that change on each deploy
8080
if (filePath.endsWith('.html')) {
81-
res.setHeader('Cache-Control', 'no-cache');
81+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
8282
}
8383
},
8484
})
8585
);
8686

87+
// Missing assets should 404, not fall through to SPA (prevents serving index.html as JS)
88+
app.use('/assets', (_req, res) => {
89+
res.status(404).end();
90+
});
91+
app.use('/fonts', (_req, res) => {
92+
res.status(404).end();
93+
});
94+
8795
// SPA fallback - send all non-matched requests to index.html
8896
app.use((_req, res) => {
97+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
8998
res.sendFile(path.join(DIST_PATH, 'index.html'));
9099
});
91100

apps/web/src/features/image-studio/canvas-editor/hooks/useCanvasAutoSave.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ export const useCanvasAutoSave = (
230230
imageType: refs.canvasType,
231231
metadata,
232232
originalImage: originalImageBase64,
233+
status: 'draft',
233234
});
234235
}
235236

0 commit comments

Comments
 (0)