includio-cms 0.13.3 → 0.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to includio-cms are documented here.
4
4
  Generated from `src/lib/updates/` — do not edit manually.
5
5
 
6
+ ## 0.13.4 — 2026-03-24
7
+
8
+ Video poster regeneration, filename sanitization
9
+
10
+ ### Added
11
+ - Batch regenerate video posters & thumbnails from admin maintenance page with SSE progress
12
+ - Video poster status reporting in media GC endpoint
13
+ - Extracted video processing to reusable module
14
+
15
+ ### Fixed
16
+ - Filename sanitization normalizes Unicode to NFC (prevents NFD/NFC mismatch)
17
+
6
18
  ## 0.13.3 — 2026-03-23
7
19
 
8
20
  Security hardening, per-language content fixes
package/ROADMAP.md CHANGED
@@ -239,6 +239,13 @@
239
239
  - [x] `[fix]` `[P1]` CMS constructor validates non-empty languages array <!-- files: src/lib/core/cms.ts -->
240
240
  - [x] `[fix]` `[P2]` Unhandled promise rejection handlers in admin components
241
241
 
242
+ ## 0.13.4 — Video poster regeneration
243
+
244
+ - [x] `[feature]` `[P1]` Batch video poster regeneration — admin maintenance page with SSE progress <!-- files: src/lib/admin/api/regenerate-posters.ts, src/lib/core/server/media/operations/batchRegenerateVideoPosters.ts, src/lib/admin/client/maintenance/maintenance-page.svelte -->
245
+ - [x] `[feature]` `[P1]` Video poster status reporting in media GC endpoint <!-- files: src/lib/admin/api/media-gc.ts -->
246
+ - [x] `[chore]` `[P1]` Extracted video processing to reusable module <!-- files: src/lib/files-local/video.ts, src/lib/files-local/index.ts -->
247
+ - [x] `[fix]` `[P1]` Filename sanitization normalizes Unicode to NFC <!-- files: src/lib/files-local/sanitizeFilename.ts -->
248
+
242
249
  ## 0.14.0 — SEO module
243
250
 
244
251
  - [ ] `[feature]` `[P1]` SERP preview + character limits for title/description <!-- files: src/lib/admin/components/fields/seo-field.svelte -->
@@ -6,6 +6,7 @@ import * as inviteHandlers from './invite.js';
6
6
  import * as acceptInviteHandlers from './accept-invite.js';
7
7
  import * as mediaGcHandlers from './media-gc.js';
8
8
  import * as generateStylesHandlers from './generate-styles.js';
9
+ import * as regeneratePostersHandlers from './regenerate-posters.js';
9
10
  import * as uploadLimitHandlers from './upload-limit.js';
10
11
  import { requireAuth } from '../remote/middleware/auth.js';
11
12
  import { getCMS } from '../../core/cms.js';
@@ -19,6 +20,7 @@ export function createAdminApiHandler(options) {
19
20
  'accept-invite': acceptInviteHandlers,
20
21
  'media-gc': mediaGcHandlers,
21
22
  'generate-styles': generateStylesHandlers,
23
+ 'regenerate-posters': regeneratePostersHandlers,
22
24
  'upload-limit': uploadLimitHandlers,
23
25
  ...options?.extraRoutes
24
26
  };
@@ -2,12 +2,14 @@ import { requireRole } from '../remote/middleware/auth.js';
2
2
  import { purgeAllImageStyles } from '../../core/server/media/operations/purgeImageStyles.js';
3
3
  import { getReconciliationReport, deleteOrphanedDiskFiles } from '../../core/server/media/operations/reconcileMedia.js';
4
4
  import { getStylesStatus } from '../../core/server/media/styles/operations/batchGenerateStyles.js';
5
+ import { getVideoPosterStatus } from '../../core/server/media/operations/batchRegenerateVideoPosters.js';
5
6
  import { json } from '@sveltejs/kit';
6
7
  export const GET = async ({ url }) => {
7
8
  requireRole('admin');
8
- const [stylesStatus, report] = await Promise.all([
9
+ const [stylesStatus, report, videoPosterStatus] = await Promise.all([
9
10
  getStylesStatus(),
10
- getReconciliationReport()
11
+ getReconciliationReport(),
12
+ getVideoPosterStatus()
11
13
  ]);
12
14
  return json({
13
15
  imageStylesCount: stylesStatus.existingStyles,
@@ -15,7 +17,10 @@ export const GET = async ({ url }) => {
15
17
  expectedStylesCount: stylesStatus.expectedStyles,
16
18
  missingStylesCount: stylesStatus.missingStyles,
17
19
  orphanedDiskFiles: report.orphanedDisk,
18
- missingDiskRecords: report.missingDisk
20
+ missingDiskRecords: report.missingDisk,
21
+ videosCount: videoPosterStatus.videosCount,
22
+ videosWithPosters: videoPosterStatus.videosWithPosters,
23
+ videosMissingPosters: videoPosterStatus.videosMissingPosters
19
24
  });
20
25
  };
21
26
  export const DELETE = async ({ url }) => {
@@ -0,0 +1,2 @@
1
+ import type { RequestHandler } from '@sveltejs/kit';
2
+ export declare const POST: RequestHandler;
@@ -0,0 +1,32 @@
1
+ import { requireRole } from '../remote/middleware/auth.js';
2
+ import { batchRegenerateVideoPosters } from '../../core/server/media/operations/batchRegenerateVideoPosters.js';
3
+ export const POST = async () => {
4
+ requireRole('admin');
5
+ const abort = new AbortController();
6
+ const encoder = new TextEncoder();
7
+ const stream = new ReadableStream({
8
+ async start(controller) {
9
+ try {
10
+ for await (const event of batchRegenerateVideoPosters(abort.signal)) {
11
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
12
+ }
13
+ }
14
+ catch (e) {
15
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', error: e instanceof Error ? e.message : String(e) })}\n\n`));
16
+ }
17
+ finally {
18
+ controller.close();
19
+ }
20
+ },
21
+ cancel() {
22
+ abort.abort();
23
+ }
24
+ });
25
+ return new Response(stream, {
26
+ headers: {
27
+ 'Content-Type': 'text/event-stream',
28
+ 'Cache-Control': 'no-cache',
29
+ Connection: 'keep-alive'
30
+ }
31
+ });
32
+ };
@@ -8,6 +8,7 @@
8
8
  import PlayerPlay from '@tabler/icons-svelte/icons/player-play';
9
9
  import PlayerStop from '@tabler/icons-svelte/icons/player-stop';
10
10
  import CircleCheck from '@tabler/icons-svelte/icons/circle-check';
11
+ import Video from '@tabler/icons-svelte/icons/video';
11
12
  import Button from '../../../components/ui/button/button.svelte';
12
13
  import * as Card from '../../../components/ui/card/index.js';
13
14
  import { toast } from 'svelte-sonner';
@@ -19,6 +20,9 @@
19
20
  missingStylesCount: number;
20
21
  orphanedDiskFiles: string[];
21
22
  missingDiskRecords: { table: string; id: string; url: string }[];
23
+ videosCount: number;
24
+ videosWithPosters: number;
25
+ videosMissingPosters: number;
22
26
  }
23
27
 
24
28
  let report = $state<GcReport | null>(null);
@@ -38,6 +42,18 @@
38
42
 
39
43
  let genPercent = $derived(genTotal > 0 ? Math.round((genProcessed / genTotal) * 100) : 0);
40
44
 
45
+ // Video poster generation state
46
+ let posterGenerating = $state(false);
47
+ let posterTotal = $state(0);
48
+ let posterProcessed = $state(0);
49
+ let posterCreated = $state(0);
50
+ let posterSkipped = $state(0);
51
+ let posterCurrentFile = $state('');
52
+ let posterErrors = $state(0);
53
+ let posterAbort: AbortController | null = null;
54
+
55
+ let posterPercent = $derived(posterTotal > 0 ? Math.round((posterProcessed / posterTotal) * 100) : 0);
56
+
41
57
  async function loadReport() {
42
58
  loading = true;
43
59
  try {
@@ -152,6 +168,77 @@
152
168
  genAbort?.abort();
153
169
  }
154
170
 
171
+ async function startPosterGenerate() {
172
+ posterGenerating = true;
173
+ posterTotal = 0;
174
+ posterProcessed = 0;
175
+ posterCreated = 0;
176
+ posterSkipped = 0;
177
+ posterCurrentFile = '';
178
+ posterErrors = 0;
179
+ posterAbort = new AbortController();
180
+
181
+ try {
182
+ const res = await fetch('/admin/api/regenerate-posters', {
183
+ method: 'POST',
184
+ signal: posterAbort.signal
185
+ });
186
+
187
+ if (!res.ok) throw new Error('Failed to start');
188
+ if (!res.body) throw new Error('No response body');
189
+
190
+ const reader = res.body.getReader();
191
+ const decoder = new TextDecoder();
192
+ let buffer = '';
193
+
194
+ while (true) {
195
+ const { done, value } = await reader.read();
196
+ if (done) break;
197
+
198
+ buffer += decoder.decode(value, { stream: true });
199
+ const chunks = buffer.split('\n\n');
200
+ buffer = chunks.pop() || '';
201
+
202
+ for (const chunk of chunks) {
203
+ if (!chunk.startsWith('data: ')) continue;
204
+ const event = JSON.parse(chunk.slice(6));
205
+
206
+ posterTotal = event.total ?? posterTotal;
207
+ posterProcessed = event.processed ?? posterProcessed;
208
+ posterCreated = event.created ?? posterCreated;
209
+ posterSkipped = event.skipped ?? posterSkipped;
210
+ posterCurrentFile = event.currentFile ?? posterCurrentFile;
211
+
212
+ if (event.type === 'error') {
213
+ posterErrors++;
214
+ }
215
+
216
+ if (event.type === 'done') {
217
+ const parts = [`Przetworzono ${posterTotal} filmów`];
218
+ if (posterCreated > 0) parts.push(`utworzono ${posterCreated} posterów`);
219
+ if (posterSkipped > 0) parts.push(`pominięto ${posterSkipped} (już istnieją)`);
220
+ if (posterErrors > 0) parts.push(`${posterErrors} błędów`);
221
+ toast.success(parts.join(', '));
222
+ }
223
+ }
224
+ }
225
+ } catch (e) {
226
+ if (e instanceof DOMException && e.name === 'AbortError') {
227
+ toast.info(`Przerwano po ${posterProcessed}/${posterTotal} filmów`);
228
+ } else {
229
+ toast.error('Błąd podczas generowania posterów');
230
+ }
231
+ } finally {
232
+ posterGenerating = false;
233
+ posterAbort = null;
234
+ await loadReport();
235
+ }
236
+ }
237
+
238
+ function cancelPosterGenerate() {
239
+ posterAbort?.abort();
240
+ }
241
+
155
242
  $effect(() => {
156
243
  loadReport();
157
244
  });
@@ -260,6 +347,72 @@
260
347
  </Card.Content>
261
348
  </Card.Root>
262
349
 
350
+ <!-- Video posters -->
351
+ <Card.Root>
352
+ <Card.Header>
353
+ <div class="flex items-center gap-2">
354
+ <Video class="size-5" style="color: var(--primary);" />
355
+ <Card.Title>Postery video</Card.Title>
356
+ </div>
357
+ <Card.Description>
358
+ Miniaturki i postery generowane z plików wideo (ffmpeg)
359
+ </Card.Description>
360
+ </Card.Header>
361
+ <Card.Content>
362
+ <p class="mb-1 text-3xl font-bold" style="color: var(--primary);">
363
+ {report.videosWithPosters}
364
+ <span class="text-base font-normal" style="color: var(--muted-foreground);">/ {report.videosCount}</span>
365
+ </p>
366
+ <p class="mb-4 text-xs" style="color: var(--muted-foreground);">
367
+ {report.videosCount} filmów, {report.videosMissingPosters} bez posterów
368
+ </p>
369
+
370
+ {#if posterGenerating}
371
+ <div class="mb-4">
372
+ <div class="mb-1 flex items-center justify-between text-xs" style="color: var(--muted-foreground);">
373
+ <span>{posterProcessed}/{posterTotal} filmów</span>
374
+ <span>{posterPercent}%</span>
375
+ </div>
376
+ <div class="h-2 w-full overflow-hidden rounded-full" style="background: var(--muted, #e5e7eb);">
377
+ <div
378
+ class="h-full rounded-full transition-all duration-300"
379
+ style="width: {posterPercent}%; background: var(--primary);"
380
+ ></div>
381
+ </div>
382
+ <p class="mt-1 text-xs" style="color: var(--muted-foreground);">
383
+ Utworzono: {posterCreated}, pominięto: {posterSkipped}{posterErrors > 0 ? `, błędów: ${posterErrors}` : ''}
384
+ </p>
385
+ <p class="mt-0.5 truncate text-xs" style="color: var(--muted-foreground);">
386
+ {posterCurrentFile}
387
+ </p>
388
+ <Button
389
+ variant="outline"
390
+ size="sm"
391
+ onclick={cancelPosterGenerate}
392
+ class="mt-2"
393
+ >
394
+ <PlayerStop class="size-4" />
395
+ Anuluj
396
+ </Button>
397
+ </div>
398
+ {:else if report.videosCount > 0}
399
+ <Button
400
+ variant="default"
401
+ size="sm"
402
+ onclick={startPosterGenerate}
403
+ >
404
+ <PlayerPlay class="size-4" />
405
+ Generuj brakujące postery
406
+ </Button>
407
+ {:else}
408
+ <div class="mb-3 flex items-center gap-1.5 text-sm" style="color: var(--success, #3A8A5C);">
409
+ <CircleCheck class="size-4" />
410
+ Brak plików wideo
411
+ </div>
412
+ {/if}
413
+ </Card.Content>
414
+ </Card.Root>
415
+
263
416
  <!-- Orphaned disk files -->
264
417
  <Card.Root>
265
418
  <Card.Header>
@@ -0,0 +1,15 @@
1
+ export type PosterBatchProgress = {
2
+ type: 'progress' | 'error' | 'done';
3
+ total: number;
4
+ processed: number;
5
+ created: number;
6
+ skipped: number;
7
+ currentFile?: string;
8
+ error?: string;
9
+ };
10
+ export declare function batchRegenerateVideoPosters(signal?: AbortSignal): AsyncGenerator<PosterBatchProgress>;
11
+ export declare function getVideoPosterStatus(): Promise<{
12
+ videosCount: number;
13
+ videosWithPosters: number;
14
+ videosMissingPosters: number;
15
+ }>;
@@ -0,0 +1,112 @@
1
+ import { getCMS } from '../../../cms.js';
2
+ import { processVideo } from '../../../../files-local/video.js';
3
+ import { fullDir } from '../../../../files-local/index.js';
4
+ import { resolve } from 'node:path';
5
+ import { unlink } from 'node:fs/promises';
6
+ const VIDEO_MIME_TYPES = [
7
+ 'video/mp4',
8
+ 'video/quicktime',
9
+ 'video/webm',
10
+ 'video/x-msvideo',
11
+ 'video/x-matroska'
12
+ ];
13
+ export async function* batchRegenerateVideoPosters(signal) {
14
+ const cms = getCMS();
15
+ const allFiles = await cms.databaseAdapter.getMediaFiles({
16
+ data: { mimeTypes: VIDEO_MIME_TYPES }
17
+ });
18
+ const total = allFiles.length;
19
+ let totalCreated = 0;
20
+ let totalSkipped = 0;
21
+ for (let i = 0; i < allFiles.length; i++) {
22
+ if (signal?.aborted)
23
+ return;
24
+ const file = allFiles[i];
25
+ yield {
26
+ type: 'progress',
27
+ total,
28
+ processed: i,
29
+ created: totalCreated,
30
+ skipped: totalSkipped,
31
+ currentFile: file.name
32
+ };
33
+ // Check if poster and thumbnail already exist on disk
34
+ const posterFilename = file.posterUrl?.split('/').pop();
35
+ const thumbFilename = file.thumbnailUrl?.split('/').pop();
36
+ const hasPoster = posterFilename ? await fileExists(resolve(fullDir, posterFilename)) : false;
37
+ const hasThumb = thumbFilename ? await fileExists(resolve(fullDir, thumbFilename)) : false;
38
+ if (hasPoster && hasThumb) {
39
+ totalSkipped++;
40
+ continue;
41
+ }
42
+ try {
43
+ // Get video filepath
44
+ const videoFilename = file.url.split('/').pop();
45
+ if (!videoFilename) {
46
+ throw new Error('No video filename');
47
+ }
48
+ const videoPath = resolve(fullDir, videoFilename);
49
+ // Delete old poster/thumb if they exist
50
+ if (posterFilename) {
51
+ try {
52
+ await unlink(resolve(fullDir, posterFilename));
53
+ }
54
+ catch { /* ok */ }
55
+ }
56
+ if (thumbFilename) {
57
+ try {
58
+ await unlink(resolve(fullDir, thumbFilename));
59
+ }
60
+ catch { /* ok */ }
61
+ }
62
+ // Regenerate
63
+ const meta = await processVideo(videoPath, file.name, fullDir);
64
+ // Update DB
65
+ await cms.databaseAdapter.updateMediaFile({
66
+ id: file.id,
67
+ data: {
68
+ posterUrl: meta.posterUrl,
69
+ thumbnailUrl: meta.thumbnailUrl,
70
+ ...(meta.width != null && { width: meta.width }),
71
+ ...(meta.height != null && { height: meta.height }),
72
+ ...(meta.duration != null && { duration: meta.duration })
73
+ }
74
+ });
75
+ totalCreated++;
76
+ }
77
+ catch (e) {
78
+ yield {
79
+ type: 'error',
80
+ total,
81
+ processed: i + 1,
82
+ created: totalCreated,
83
+ skipped: totalSkipped,
84
+ currentFile: file.name,
85
+ error: e instanceof Error ? e.message : String(e)
86
+ };
87
+ continue;
88
+ }
89
+ }
90
+ yield { type: 'done', total, processed: total, created: totalCreated, skipped: totalSkipped };
91
+ }
92
+ async function fileExists(path) {
93
+ try {
94
+ const { stat } = await import('node:fs/promises');
95
+ await stat(path);
96
+ return true;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ export async function getVideoPosterStatus() {
103
+ const allFiles = await getCMS().databaseAdapter.getMediaFiles({
104
+ data: { mimeTypes: VIDEO_MIME_TYPES }
105
+ });
106
+ const videosWithPosters = allFiles.filter((f) => f.posterUrl != null).length;
107
+ return {
108
+ videosCount: allFiles.length,
109
+ videosWithPosters,
110
+ videosMissingPosters: allFiles.length - videosWithPosters
111
+ };
112
+ }
@@ -1,2 +1,3 @@
1
1
  import type { FilesAdapter } from '../types/adapters/files.js';
2
+ export declare const fullDir: string;
2
3
  export declare function local(): FilesAdapter;
@@ -2,152 +2,15 @@ import { basename, extname, resolve } from 'node:path';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { readFile, writeFile, rename, unlink, mkdir, readdir } from 'node:fs/promises';
4
4
  import sharp from 'sharp';
5
- import ffmpeg from 'fluent-ffmpeg';
6
5
  import { fileURLToPath } from 'url';
7
6
  import { dirname } from 'path';
8
7
  import { sanitizeFilename } from './sanitizeFilename.js';
8
+ import { processVideo } from './video.js';
9
9
  const __filename = fileURLToPath(import.meta.url);
10
10
  const __dirname = dirname(__filename);
11
11
  global.__dirname = __dirname ?? '';
12
- const fullDir = process.env.NODE_ENV === 'production' ? `/data/uploads` : `./static/uploads`;
12
+ export const fullDir = process.env.NODE_ENV === 'production' ? `/data/uploads` : `./static/uploads`;
13
13
  const privateDir = process.env.NODE_ENV === 'production' ? `/data/private-uploads` : `./data/private-uploads`;
14
- // Ustawienie ścieżek do ffmpeg
15
- const ffmpegPath = process.env.FFMPEG_PATH || '/usr/bin/ffmpeg';
16
- const ffprobePath = process.env.FFPROBE_PATH || '/usr/bin/ffprobe';
17
- ffmpeg.setFfmpegPath(ffmpegPath);
18
- ffmpeg.setFfprobePath(ffprobePath);
19
- function withTimeout(promise, ms, label) {
20
- return new Promise((resolve, reject) => {
21
- const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
22
- promise.then((val) => {
23
- clearTimeout(timer);
24
- resolve(val);
25
- }, (err) => {
26
- clearTimeout(timer);
27
- reject(err);
28
- });
29
- });
30
- }
31
- function probeVideo(filepath) {
32
- return new Promise((resolve, reject) => {
33
- ffmpeg.ffprobe(filepath, (err, metadata) => {
34
- if (err) {
35
- reject(new Error(`ffprobe failed: ${err.message || err}`));
36
- return;
37
- }
38
- const videoStream = metadata.streams.find((s) => s.codec_type === 'video');
39
- const duration = metadata.format.duration ?? null;
40
- resolve({
41
- width: videoStream?.width ?? null,
42
- height: videoStream?.height ?? null,
43
- duration: duration ? Math.round(duration * 100) / 100 : null
44
- });
45
- });
46
- });
47
- }
48
- function takeScreenshot(filepath, config) {
49
- return new Promise((resolve, reject) => {
50
- ffmpeg(filepath)
51
- .on('error', (err) => reject(new Error(`Screenshot failed: ${err.message || err}`)))
52
- .on('end', () => resolve())
53
- .screenshots({
54
- timestamps: [config.timestamp],
55
- filename: config.filename,
56
- folder: config.folder,
57
- size: config.size
58
- });
59
- });
60
- }
61
- function getSafeTimestamp(duration) {
62
- if (duration === null || duration <= 0)
63
- return '00:00:00.000';
64
- if (duration < 1)
65
- return '00:00:00.000';
66
- if (duration < 3) {
67
- const t = duration * 0.1;
68
- return formatTimestamp(t);
69
- }
70
- return formatTimestamp(Math.min(1, duration * 0.1));
71
- }
72
- function formatTimestamp(seconds) {
73
- const h = Math.floor(seconds / 3600);
74
- const m = Math.floor((seconds % 3600) / 60);
75
- const s = seconds % 60;
76
- return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`;
77
- }
78
- let ffmpegAvailable = null;
79
- async function checkFfmpegAvailable() {
80
- if (ffmpegAvailable !== null)
81
- return ffmpegAvailable;
82
- return new Promise((resolve) => {
83
- ffmpeg.ffprobe('/dev/null', (err) => {
84
- if (err && (String(err).includes('ENOENT') || String(err).includes('spawn'))) {
85
- console.warn('[video] ffmpeg/ffprobe not found — video thumbnails disabled');
86
- ffmpegAvailable = false;
87
- }
88
- else {
89
- ffmpegAvailable = true;
90
- }
91
- resolve(ffmpegAvailable);
92
- });
93
- });
94
- }
95
- async function processVideo(filepath, filename) {
96
- if (!(await checkFfmpegAvailable())) {
97
- console.warn(`[video] Skipping processing for ${filename}: ffmpeg not available`);
98
- return { width: null, height: null, duration: null, thumbnailUrl: null, posterUrl: null };
99
- }
100
- const thumbnailFilename = `${filename}_thumb.jpg`;
101
- const thumbnailUrl = `/uploads/${thumbnailFilename}`;
102
- const posterFilename = `${filename}_poster.jpg`;
103
- const posterUrl = `/uploads/${posterFilename}`;
104
- // 1. Probe — get metadata first
105
- let probe;
106
- try {
107
- probe = await withTimeout(probeVideo(filepath), 10_000, 'ffprobe');
108
- }
109
- catch (err) {
110
- console.warn(`[video] ffprobe failed for ${filename}:`, err);
111
- return { width: null, height: null, duration: null, thumbnailUrl: null, posterUrl: null };
112
- }
113
- const timestamp = getSafeTimestamp(probe.duration);
114
- // 2. Thumbnail (320px wide, maintain aspect ratio)
115
- let finalThumbnailUrl = null;
116
- try {
117
- await withTimeout(takeScreenshot(filepath, {
118
- timestamp,
119
- filename: thumbnailFilename,
120
- folder: fullDir,
121
- size: '320x?'
122
- }), 15_000, 'thumbnail');
123
- finalThumbnailUrl = thumbnailUrl;
124
- }
125
- catch (err) {
126
- console.warn(`[video] Thumbnail failed for ${filename}:`, err);
127
- }
128
- // 3. Poster (up to 1280px wide, no upscale)
129
- let finalPosterUrl = null;
130
- const posterWidth = Math.min(1280, probe.width ?? 1280);
131
- try {
132
- await withTimeout(takeScreenshot(filepath, {
133
- timestamp,
134
- filename: posterFilename,
135
- folder: fullDir,
136
- size: `${posterWidth}x?`
137
- }), 15_000, 'poster');
138
- finalPosterUrl = posterUrl;
139
- }
140
- catch (err) {
141
- console.warn(`[video] Poster failed for ${filename}:`, err);
142
- }
143
- return {
144
- width: probe.width,
145
- height: probe.height,
146
- duration: probe.duration ? Math.round(probe.duration) : null,
147
- thumbnailUrl: finalThumbnailUrl,
148
- posterUrl: finalPosterUrl
149
- };
150
- }
151
14
  async function ensureDir(dir) {
152
15
  try {
153
16
  await mkdir(dir, { recursive: true });
@@ -228,7 +91,7 @@ export function local() {
228
91
  else if (file.type.startsWith('video/')) {
229
92
  type = 'video';
230
93
  try {
231
- const videoMeta = await processVideo(filepath, filename);
94
+ const videoMeta = await processVideo(filepath, filename, fullDir);
232
95
  width = videoMeta.width;
233
96
  height = videoMeta.height;
234
97
  duration = videoMeta.duration;
@@ -3,7 +3,8 @@
3
3
  * Prevents path traversal, null byte injection, and other attacks.
4
4
  */
5
5
  export function sanitizeFilename(name) {
6
- let sanitized = name;
6
+ // Normalize Unicode to NFC (composed form) to prevent NFD/NFC mismatch
7
+ let sanitized = name.normalize('NFC');
7
8
  // Remove null bytes
8
9
  sanitized = sanitized.replace(/\0/g, '');
9
10
  // Remove path separators and traversal patterns
@@ -0,0 +1,9 @@
1
+ export interface VideoMetadata {
2
+ width: number | null;
3
+ height: number | null;
4
+ duration: number | null;
5
+ thumbnailUrl: string | null;
6
+ posterUrl: string | null;
7
+ }
8
+ export declare function checkFfmpegAvailable(): Promise<boolean>;
9
+ export declare function processVideo(filepath: string, filename: string, outputDir: string): Promise<VideoMetadata>;
@@ -0,0 +1,145 @@
1
+ import ffmpeg from 'fluent-ffmpeg';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname } from 'path';
4
+ const __filename = fileURLToPath(import.meta.url);
5
+ const __dirname = dirname(__filename);
6
+ if (typeof globalThis.__dirname === 'undefined') {
7
+ globalThis.__dirname = __dirname ?? '';
8
+ }
9
+ // Ustawienie ścieżek do ffmpeg
10
+ const ffmpegPath = process.env.FFMPEG_PATH || '/usr/bin/ffmpeg';
11
+ const ffprobePath = process.env.FFPROBE_PATH || '/usr/bin/ffprobe';
12
+ ffmpeg.setFfmpegPath(ffmpegPath);
13
+ ffmpeg.setFfprobePath(ffprobePath);
14
+ function withTimeout(promise, ms, label) {
15
+ return new Promise((resolve, reject) => {
16
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
17
+ promise.then((val) => {
18
+ clearTimeout(timer);
19
+ resolve(val);
20
+ }, (err) => {
21
+ clearTimeout(timer);
22
+ reject(err);
23
+ });
24
+ });
25
+ }
26
+ function probeVideo(filepath) {
27
+ return new Promise((resolve, reject) => {
28
+ ffmpeg.ffprobe(filepath, (err, metadata) => {
29
+ if (err) {
30
+ reject(new Error(`ffprobe failed: ${err.message || err}`));
31
+ return;
32
+ }
33
+ const videoStream = metadata.streams.find((s) => s.codec_type === 'video');
34
+ const duration = metadata.format.duration ?? null;
35
+ resolve({
36
+ width: videoStream?.width ?? null,
37
+ height: videoStream?.height ?? null,
38
+ duration: duration ? Math.round(duration * 100) / 100 : null
39
+ });
40
+ });
41
+ });
42
+ }
43
+ function takeScreenshot(filepath, config) {
44
+ return new Promise((resolve, reject) => {
45
+ ffmpeg(filepath)
46
+ .on('error', (err) => reject(new Error(`Screenshot failed: ${err.message || err}`)))
47
+ .on('end', () => resolve())
48
+ .screenshots({
49
+ timestamps: [config.timestamp],
50
+ filename: config.filename,
51
+ folder: config.folder,
52
+ size: config.size
53
+ });
54
+ });
55
+ }
56
+ function getSafeTimestamp(duration) {
57
+ if (duration === null || duration <= 0)
58
+ return '00:00:00.000';
59
+ if (duration < 1)
60
+ return '00:00:00.000';
61
+ if (duration < 3) {
62
+ const t = duration * 0.1;
63
+ return formatTimestamp(t);
64
+ }
65
+ return formatTimestamp(Math.min(1, duration * 0.1));
66
+ }
67
+ function formatTimestamp(seconds) {
68
+ const h = Math.floor(seconds / 3600);
69
+ const m = Math.floor((seconds % 3600) / 60);
70
+ const s = seconds % 60;
71
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`;
72
+ }
73
+ let ffmpegAvailable = null;
74
+ export async function checkFfmpegAvailable() {
75
+ if (ffmpegAvailable !== null)
76
+ return ffmpegAvailable;
77
+ return new Promise((resolve) => {
78
+ ffmpeg.ffprobe('/dev/null', (err) => {
79
+ if (err && (String(err).includes('ENOENT') || String(err).includes('spawn'))) {
80
+ console.warn('[video] ffmpeg/ffprobe not found — video thumbnails disabled');
81
+ ffmpegAvailable = false;
82
+ }
83
+ else {
84
+ ffmpegAvailable = true;
85
+ }
86
+ resolve(ffmpegAvailable);
87
+ });
88
+ });
89
+ }
90
+ export async function processVideo(filepath, filename, outputDir) {
91
+ if (!(await checkFfmpegAvailable())) {
92
+ console.warn(`[video] Skipping processing for ${filename}: ffmpeg not available`);
93
+ return { width: null, height: null, duration: null, thumbnailUrl: null, posterUrl: null };
94
+ }
95
+ const thumbnailFilename = `${filename}_thumb.jpg`;
96
+ const thumbnailUrl = `/uploads/${thumbnailFilename}`;
97
+ const posterFilename = `${filename}_poster.jpg`;
98
+ const posterUrl = `/uploads/${posterFilename}`;
99
+ // 1. Probe — get metadata first
100
+ let probe;
101
+ try {
102
+ probe = await withTimeout(probeVideo(filepath), 10_000, 'ffprobe');
103
+ }
104
+ catch (err) {
105
+ console.warn(`[video] ffprobe failed for ${filename}:`, err);
106
+ return { width: null, height: null, duration: null, thumbnailUrl: null, posterUrl: null };
107
+ }
108
+ const timestamp = getSafeTimestamp(probe.duration);
109
+ // 2. Thumbnail (320px wide, maintain aspect ratio)
110
+ let finalThumbnailUrl = null;
111
+ try {
112
+ await withTimeout(takeScreenshot(filepath, {
113
+ timestamp,
114
+ filename: thumbnailFilename,
115
+ folder: outputDir,
116
+ size: '320x?'
117
+ }), 15_000, 'thumbnail');
118
+ finalThumbnailUrl = thumbnailUrl;
119
+ }
120
+ catch (err) {
121
+ console.warn(`[video] Thumbnail failed for ${filename}:`, err);
122
+ }
123
+ // 3. Poster (up to 1280px wide, no upscale)
124
+ let finalPosterUrl = null;
125
+ const posterWidth = Math.min(1280, probe.width ?? 1280);
126
+ try {
127
+ await withTimeout(takeScreenshot(filepath, {
128
+ timestamp,
129
+ filename: posterFilename,
130
+ folder: outputDir,
131
+ size: `${posterWidth}x?`
132
+ }), 15_000, 'poster');
133
+ finalPosterUrl = posterUrl;
134
+ }
135
+ catch (err) {
136
+ console.warn(`[video] Poster failed for ${filename}:`, err);
137
+ }
138
+ return {
139
+ width: probe.width,
140
+ height: probe.height,
141
+ duration: probe.duration ? Math.round(probe.duration) : null,
142
+ thumbnailUrl: finalThumbnailUrl,
143
+ posterUrl: finalPosterUrl
144
+ };
145
+ }
@@ -0,0 +1,2 @@
1
+ import type { CmsUpdate } from '../index.js';
2
+ export declare const update: CmsUpdate;
@@ -0,0 +1,14 @@
1
+ export const update = {
2
+ version: '0.13.4',
3
+ date: '2026-03-24',
4
+ description: 'Video poster regeneration, filename sanitization',
5
+ features: [
6
+ 'Batch regenerate video posters & thumbnails from admin maintenance page with SSE progress',
7
+ 'Video poster status reporting in media GC endpoint',
8
+ 'Extracted video processing to reusable module'
9
+ ],
10
+ fixes: [
11
+ 'Filename sanitization normalizes Unicode to NFC (prevents NFD/NFC mismatch)'
12
+ ],
13
+ breakingChanges: []
14
+ };
@@ -36,7 +36,8 @@ import { update as update0130 } from './0.13.0/index.js';
36
36
  import { update as update0131 } from './0.13.1/index.js';
37
37
  import { update as update0132 } from './0.13.2/index.js';
38
38
  import { update as update0133 } from './0.13.3/index.js';
39
- export const updates = [update0065, update0066, update0067, update0068, update0069, update010, update011, update012, update013, update014, update015, update020, update022, update050, update051, update052, update053, update054, update055, update056, update057, update058, update060, update061, update062, update070, update071, update072, update073, update080, update090, update0100, update0110, update0120, update0130, update0131, update0132, update0133];
39
+ import { update as update0134 } from './0.13.4/index.js';
40
+ export const updates = [update0065, update0066, update0067, update0068, update0069, update010, update011, update012, update013, update014, update015, update020, update022, update050, update051, update052, update053, update054, update055, update056, update057, update058, update060, update061, update062, update070, update071, update072, update073, update080, update090, update0100, update0110, update0120, update0130, update0131, update0132, update0133, update0134];
40
41
  export const getUpdatesFrom = (fromVersion) => {
41
42
  const fromParts = fromVersion.split('.').map(Number);
42
43
  return updates.filter((update) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "includio-cms",
3
- "version": "0.13.3",
3
+ "version": "0.13.4",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",