includio-cms 0.15.4 → 0.15.5
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 +13 -0
- package/DOCS.md +1 -1
- package/dist/admin/components/fields/media-field.svelte +54 -2
- package/dist/admin/components/media/file/file-details.svelte +65 -0
- package/dist/admin/remote/media.remote.d.ts +1 -0
- package/dist/admin/remote/media.remote.js +5 -0
- package/dist/cmp/types.d.ts +25 -0
- package/dist/cmp/types.js +1 -0
- package/dist/core/cms.d.ts +2 -0
- package/dist/core/cms.js +2 -0
- package/dist/core/server/cmp/getCountryFromHeaders.d.ts +10 -0
- package/dist/core/server/cmp/getCountryFromHeaders.js +30 -0
- package/dist/core/server/cmp/operations/create.d.ts +17 -0
- package/dist/core/server/cmp/operations/create.js +38 -0
- package/dist/core/server/cmp/operations/get.d.ts +2 -0
- package/dist/core/server/cmp/operations/get.js +8 -0
- package/dist/core/server/cmp/operations/list.d.ts +3 -0
- package/dist/core/server/cmp/operations/list.js +15 -0
- package/dist/core/server/cmp/truncateIpAddress.d.ts +7 -0
- package/dist/core/server/cmp/truncateIpAddress.js +57 -0
- package/dist/core/server/fields/resolveImageFields.d.ts +5 -0
- package/dist/core/server/fields/resolveImageFields.js +9 -1
- package/dist/core/server/generator/generator.js +22 -6
- package/dist/core/server/media/operations/backgroundMaintenance.js +51 -20
- package/dist/core/server/media/operations/findMediaReferences.d.ts +16 -0
- package/dist/core/server/media/operations/findMediaReferences.js +60 -0
- package/dist/db-postgres/index.js +46 -1
- package/dist/db-postgres/schema/consentLog.d.ts +17 -0
- package/dist/db-postgres/schema/consentLog.js +4 -1
- package/dist/sveltekit/server/handle.js +1 -1
- package/dist/types/adapters/db.d.ts +7 -1
- package/dist/types/cms.d.ts +3 -0
- package/dist/types/consent.d.ts +11 -0
- package/dist/updates/0.15.5/index.d.ts +2 -0
- package/dist/updates/0.15.5/index.js +15 -0
- package/dist/updates/index.js +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,19 @@
|
|
|
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.15.5 — 2026-04-23
|
|
7
|
+
|
|
8
|
+
Media field recovers from orphan references; delete dialog shows usage breakdown + replace hint. X-Frame-Options relaxed to SAMEORIGIN. Background maintenance no longer duplicates on Vite HMR. Runtime generator no longer triggers an infinite SSR reload loop in dev.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Admin media library: delete dialog (`FileDetails`) teraz pokazuje breakdown użyć pliku per kolekcja/single ("Plik jest używany w: Strony: 1") oraz hint wskazujący na funkcję **Zamień plik** (która zachowuje ID → wszystkie referencje). Dane ładowane asynchronicznie przez nowy remote query `findMediaReferences(id)` uruchamiany w momencie otwarcia dialogu. Scope (MVP): tylko najnowsza wersja per entry; historia poza zakresem. Walker po schematach (`media`, `file`, `seo.ogImage`, `object`, `blocks`, `content` z inline blocks) wyekstrahowany z `resolveImageFields.ts` jako reużywalne `extractMediaIdsFromData(data, fields)`.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- `media-field.svelte` nie blokuje już edycji entry gdy pole media wskazuje na usunięty plik (orphan reference). Wcześniej render wchodził w `{:else if singleFile}` z `singleFile=null` po nieudanym `getFileById`, co wyrzucało UI do pustego diva bez przycisków Zmień/Usuń — user zablokowany, nie mógł wstawić nowego obrazu. Nowa gałąź `{:else}` renderuje dashed warning placeholder ("Brakujący plik" / "Missing file") z zachowanymi kontrolkami. Analogicznie dla multi-media: per-item fallback pozwala usunąć pojedynczy orphan z tablicy bez czyszczenia pozostałych.
|
|
15
|
+
- `X-Frame-Options` zmieniony z `DENY` na `SAMEORIGIN` w middleware `securityHeaders`. Wcześniej `DENY` blokował nawet same-origin framing, przez co admin CMS nie mógł załadować preview entry w iframe (`previewUrl` w konfiguracji kolekcji/single). Safari egzekwuje to rygorystycznie ("Refused to display ... in a frame because it set X-Frame-Options to DENY"). `SAMEORIGIN` dalej chroni przed clickjackingiem z obcych domen.
|
|
16
|
+
- `startBackgroundMaintenance()` jest teraz idempotentne. Wcześniej każde wywołanie tworzyło nowy `setTimeout`/`setInterval` bez czyszczenia poprzedniego — w `pnpm dev` Vite HMR re-executuje `hooks.server.ts` (i tym samym `initCMS()`) przy każdej zmianie, więc po kilku edycjach działało N równoległych przebiegów maintenance. Stan timerów (`pendingTimeout`, `timer`, `running`, `lastResult`, `nextRunAt`) przeniesiony na `globalThis[Symbol.for("includio.maintenance.state")]`, żeby przeżył re-eval modułu w dev. Dodany guard: gdy timer już zaplanowany, kolejne `start()` loguje `already scheduled, skipping` i wychodzi. `stopBackgroundMaintenance()` teraz czyści również initial 30s `setTimeout`, nie tylko interval. Dodany `import.meta.hot.dispose()` czyszczący timery gdy Vite unlinkuje moduł.
|
|
17
|
+
- `generateRuntime()` (`generator.ts`) nie zapisuje już plików gdy treść się nie zmieniła. Wcześniej każdy wywołanie `includioCMS()` (a więc każdy SSR reload Vite) bezwarunkowo `writeFileSync` na 5 plikach w `src/lib/cms/runtime/` (`api.ts`, `types.ts`, `schemas.ts`, `schema.ts`, `remote.ts`) — co aktualizowało mtime, Vite wykrywał zmianę, robił `(ssr) page reload`, znów wołał `includioCMS()` → znów zapis → nieskończona pętla reload w `pnpm dev`, blokująca pracę. Nowy helper `writeIfChanged(filePath, content)` najpierw czyta plik i zapisuje wyłącznie gdy treść się różni.
|
|
18
|
+
|
|
6
19
|
## 0.15.4 — 2026-04-21
|
|
7
20
|
|
|
8
21
|
Forms: auto-scaffolded public submission endpoint + decoupled notification emails from submission success.
|
package/DOCS.md
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
a11yMissingAudioDesc: string;
|
|
31
31
|
a11yMissingBoth: string;
|
|
32
32
|
a11yHint: string;
|
|
33
|
+
missingFile: string;
|
|
33
34
|
}
|
|
34
35
|
> = {
|
|
35
36
|
pl: {
|
|
@@ -41,7 +42,8 @@
|
|
|
41
42
|
a11yMissingTranscript: 'Brakuje transkrypcji',
|
|
42
43
|
a11yMissingAudioDesc: 'Brakuje audiodeskrypcji',
|
|
43
44
|
a11yMissingBoth: 'Brakuje transkrypcji i audiodeskrypcji',
|
|
44
|
-
a11yHint: 'Uzupełnij w bibliotece mediów'
|
|
45
|
+
a11yHint: 'Uzupełnij w bibliotece mediów',
|
|
46
|
+
missingFile: 'Brakujący plik'
|
|
45
47
|
},
|
|
46
48
|
en: {
|
|
47
49
|
selectMedia: 'Select media',
|
|
@@ -52,7 +54,8 @@
|
|
|
52
54
|
a11yMissingTranscript: 'Transcript missing',
|
|
53
55
|
a11yMissingAudioDesc: 'Audio description missing',
|
|
54
56
|
a11yMissingBoth: 'Transcript and audio description missing',
|
|
55
|
-
a11yHint: 'Add it in the media library'
|
|
57
|
+
a11yHint: 'Add it in the media library',
|
|
58
|
+
missingFile: 'Missing file'
|
|
56
59
|
}
|
|
57
60
|
};
|
|
58
61
|
|
|
@@ -195,6 +198,17 @@
|
|
|
195
198
|
</button>
|
|
196
199
|
{/snippet}
|
|
197
200
|
|
|
201
|
+
{#snippet missingPlaceholder()}
|
|
202
|
+
<div
|
|
203
|
+
class="flex aspect-square w-full flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-warning/50 bg-warning/5 p-6"
|
|
204
|
+
>
|
|
205
|
+
<div class="rounded-full bg-warning/10 p-3">
|
|
206
|
+
<AlertTriangle class="h-6 w-6 text-warning" />
|
|
207
|
+
</div>
|
|
208
|
+
<span class="text-sm text-warning">{lang[interfaceLanguage.current].missingFile}</span>
|
|
209
|
+
</div>
|
|
210
|
+
{/snippet}
|
|
211
|
+
|
|
198
212
|
{#snippet mediaActions(onRemove: () => void)}
|
|
199
213
|
<div class="flex items-center justify-between gap-2 mt-1.5">
|
|
200
214
|
<Button size="sm" variant="secondary" class="h-8" onclick={openPicker}>
|
|
@@ -224,6 +238,9 @@
|
|
|
224
238
|
{@render imagePreview(file)}
|
|
225
239
|
{/if}
|
|
226
240
|
{@render mediaActions(() => { value = ''; })}
|
|
241
|
+
{:else}
|
|
242
|
+
{@render missingPlaceholder()}
|
|
243
|
+
{@render mediaActions(() => { value = ''; })}
|
|
227
244
|
{/if}
|
|
228
245
|
{:else if Array.isArray(value) && value.length > 0}
|
|
229
246
|
{@const valueArr = value}
|
|
@@ -262,7 +279,42 @@
|
|
|
262
279
|
{/if}
|
|
263
280
|
</div>
|
|
264
281
|
{@render mediaActions(() => { value = field.multiple ? [] : ''; })}
|
|
282
|
+
{:else}
|
|
283
|
+
<div class="relative">
|
|
284
|
+
{@render missingPlaceholder()}
|
|
285
|
+
{#if valueArr.length > 1}
|
|
286
|
+
<div class="absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-between px-1 pointer-events-none">
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
class="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full bg-white/80 backdrop-blur shadow-md transition hover:bg-white hover:scale-105 dark:bg-background/80 dark:hover:bg-background"
|
|
290
|
+
onclick={() => { currentIndex = currentIndex > 0 ? currentIndex - 1 : valueArr.length - 1; }}
|
|
291
|
+
>
|
|
292
|
+
<ChevronLeft class="h-5 w-5" />
|
|
293
|
+
</button>
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
class="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full bg-white/80 backdrop-blur shadow-md transition hover:bg-white hover:scale-105 dark:bg-background/80 dark:hover:bg-background"
|
|
297
|
+
onclick={() => { currentIndex = currentIndex < valueArr.length - 1 ? currentIndex + 1 : 0; }}
|
|
298
|
+
>
|
|
299
|
+
<ChevronRight class="h-5 w-5" />
|
|
300
|
+
</button>
|
|
301
|
+
</div>
|
|
302
|
+
<div class="absolute top-2 right-2 rounded-full bg-plum-darker/60 px-2 py-0.5 text-xs font-medium text-white backdrop-blur">
|
|
303
|
+
{currentIndex + 1} / {valueArr.length}
|
|
304
|
+
</div>
|
|
305
|
+
{/if}
|
|
306
|
+
</div>
|
|
307
|
+
{@render mediaActions(() => {
|
|
308
|
+
if (Array.isArray(value)) {
|
|
309
|
+
const next = value.filter((_, i) => i !== currentIndex);
|
|
310
|
+
value = field.multiple ? next : next[0] ?? '';
|
|
311
|
+
if (currentIndex >= next.length) currentIndex = Math.max(0, next.length - 1);
|
|
312
|
+
}
|
|
313
|
+
})}
|
|
265
314
|
{/if}
|
|
315
|
+
{:else}
|
|
316
|
+
{@render missingPlaceholder()}
|
|
317
|
+
{@render mediaActions(() => { value = field.multiple ? [] : ''; })}
|
|
266
318
|
{/if}
|
|
267
319
|
{/if}
|
|
268
320
|
</div>
|
|
@@ -31,6 +31,19 @@
|
|
|
31
31
|
let deleteDialogOpen = $state(false);
|
|
32
32
|
let videoError = $state(false);
|
|
33
33
|
|
|
34
|
+
type ReferenceResult = {
|
|
35
|
+
total: number;
|
|
36
|
+
byCollection: Array<{ collection: string; label: string; count: number; kind: 'collection' | 'single' }>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const referencesQuery = $derived(
|
|
40
|
+
deleteDialogOpen ? remotes.findMediaReferences(file.id) : null
|
|
41
|
+
);
|
|
42
|
+
const referencesLoading = $derived(!!referencesQuery && !referencesQuery.ready);
|
|
43
|
+
const references = $derived(
|
|
44
|
+
referencesQuery && referencesQuery.ready ? (referencesQuery.current as ReferenceResult) : null
|
|
45
|
+
);
|
|
46
|
+
|
|
34
47
|
$effect(() => {
|
|
35
48
|
file.url;
|
|
36
49
|
videoError = false;
|
|
@@ -43,6 +56,12 @@
|
|
|
43
56
|
deleteConfirmTitle: string;
|
|
44
57
|
deleteConfirmDesc: string;
|
|
45
58
|
deleteCancel: string;
|
|
59
|
+
usedInLoading: string;
|
|
60
|
+
usedInTitle: string;
|
|
61
|
+
missingAfterDelete: string;
|
|
62
|
+
replaceHintBefore: string;
|
|
63
|
+
replaceHintCta: string;
|
|
64
|
+
replaceHintAfter: string;
|
|
46
65
|
fileNameLabel: string;
|
|
47
66
|
fileUrlLabel: string;
|
|
48
67
|
fileAltLabel: string;
|
|
@@ -81,6 +100,12 @@
|
|
|
81
100
|
deleteConfirmTitle: 'Usunąć plik?',
|
|
82
101
|
deleteConfirmDesc: 'Plik zostanie trwale usunięty.',
|
|
83
102
|
deleteCancel: 'Anuluj',
|
|
103
|
+
usedInLoading: 'Liczenie użyć…',
|
|
104
|
+
usedInTitle: 'Plik jest używany w:',
|
|
105
|
+
missingAfterDelete: 'Po usunięciu te pola zostaną oznaczone jako brakujące.',
|
|
106
|
+
replaceHintBefore: 'Jeśli chcesz tylko zmienić obraz, użyj funkcji',
|
|
107
|
+
replaceHintCta: 'Zamień plik',
|
|
108
|
+
replaceHintAfter: ' — zachowa wszystkie referencje.',
|
|
84
109
|
replaceFileLabel: 'Zamień plik',
|
|
85
110
|
fileNameLabel: 'Nazwa pliku',
|
|
86
111
|
fileUrlLabel: 'URL',
|
|
@@ -118,6 +143,12 @@
|
|
|
118
143
|
deleteConfirmTitle: 'Delete file?',
|
|
119
144
|
deleteConfirmDesc: 'The file will be permanently deleted.',
|
|
120
145
|
deleteCancel: 'Cancel',
|
|
146
|
+
usedInLoading: 'Counting usages…',
|
|
147
|
+
usedInTitle: 'This file is used in:',
|
|
148
|
+
missingAfterDelete: 'After deletion these fields will be marked as missing.',
|
|
149
|
+
replaceHintBefore: 'If you only want to change the image, use the',
|
|
150
|
+
replaceHintCta: 'Replace file',
|
|
151
|
+
replaceHintAfter: ' action — it keeps all references intact.',
|
|
121
152
|
fileNameLabel: 'File name',
|
|
122
153
|
fileUrlLabel: 'URL',
|
|
123
154
|
fileAltLabel: 'Alt text',
|
|
@@ -565,6 +596,40 @@
|
|
|
565
596
|
<AlertDialog.Content>
|
|
566
597
|
<AlertDialog.Title>{lang[interfaceLanguage.current].deleteConfirmTitle}</AlertDialog.Title>
|
|
567
598
|
<AlertDialog.Description>{lang[interfaceLanguage.current].deleteConfirmDesc}</AlertDialog.Description>
|
|
599
|
+
|
|
600
|
+
{#if referencesLoading}
|
|
601
|
+
<div class="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
|
|
602
|
+
<div class="h-3 w-3 animate-pulse rounded-full bg-muted"></div>
|
|
603
|
+
<span>{lang[interfaceLanguage.current].usedInLoading}</span>
|
|
604
|
+
</div>
|
|
605
|
+
{:else if references && references.total > 0}
|
|
606
|
+
<div class="mt-2 space-y-3 text-sm">
|
|
607
|
+
<div class="rounded-md border border-warning/40 bg-warning/5 p-3">
|
|
608
|
+
<div class="flex items-start gap-2">
|
|
609
|
+
<AlertTriangle class="h-4 w-4 text-warning mt-0.5 shrink-0" />
|
|
610
|
+
<div class="flex-1 min-w-0">
|
|
611
|
+
<p class="font-medium text-foreground">{lang[interfaceLanguage.current].usedInTitle}</p>
|
|
612
|
+
<ul class="mt-1.5 space-y-0.5">
|
|
613
|
+
{#each references.byCollection as ref}
|
|
614
|
+
<li class="text-muted-foreground">
|
|
615
|
+
<span class="font-medium text-foreground">{ref.label}</span>: {ref.count}
|
|
616
|
+
</li>
|
|
617
|
+
{/each}
|
|
618
|
+
</ul>
|
|
619
|
+
<p class="mt-2 text-xs text-muted-foreground">{lang[interfaceLanguage.current].missingAfterDelete}</p>
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
</div>
|
|
623
|
+
<p class="text-xs text-muted-foreground">
|
|
624
|
+
{lang[interfaceLanguage.current].replaceHintBefore}
|
|
625
|
+
<span class="inline-flex items-center gap-1 rounded border border-border bg-muted/50 px-1.5 py-0.5 font-medium text-foreground">
|
|
626
|
+
<Replace class="h-3 w-3" />
|
|
627
|
+
{lang[interfaceLanguage.current].replaceHintCta}
|
|
628
|
+
</span>{lang[interfaceLanguage.current].replaceHintAfter}
|
|
629
|
+
</p>
|
|
630
|
+
</div>
|
|
631
|
+
{/if}
|
|
632
|
+
|
|
568
633
|
<AlertDialog.Footer>
|
|
569
634
|
<AlertDialog.Cancel>{lang[interfaceLanguage.current].deleteCancel}</AlertDialog.Cancel>
|
|
570
635
|
<AlertDialog.Action
|
|
@@ -33,6 +33,7 @@ export declare const getMediaTagsWithCounts: import("@sveltejs/kit").RemoteQuery
|
|
|
33
33
|
count: number;
|
|
34
34
|
}[]>;
|
|
35
35
|
export declare const getFileById: import("@sveltejs/kit").RemoteQueryFunction<string, MediaFile | null>;
|
|
36
|
+
export declare const findMediaReferences: import("@sveltejs/kit").RemoteQueryFunction<string, import("../../core/server/media/operations/findMediaReferences.js").MediaReferenceResult>;
|
|
36
37
|
export declare const deleteMediaFile: import("@sveltejs/kit").RemoteCommand<string, Promise<void>>;
|
|
37
38
|
export declare const bulkDeleteMediaFiles: import("@sveltejs/kit").RemoteCommand<{
|
|
38
39
|
ids: string[];
|
|
@@ -2,6 +2,7 @@ import { command, query } from '$app/server';
|
|
|
2
2
|
import { setAlt, renameMediaFile as renameMediaFileOperation, updateMediaAccessibility as updateMediaAccessibilityOp } from '../../core/server/media/operations/updateFile.js';
|
|
3
3
|
import z from 'zod';
|
|
4
4
|
import { deleteMediaFile as deleteMediaFileFn, bulkDeleteMediaFiles as bulkDeleteMediaFilesFn } from '../../core/server/media/operations/deleteMediaFile.js';
|
|
5
|
+
import { findMediaReferences as findMediaReferencesFn } from '../../core/server/media/operations/findMediaReferences.js';
|
|
5
6
|
import { getFile, getFiles, countFiles, getMediaTagsWithCounts as getMediaTagsWithCountsFn } from '../../core/server/media/operations/getFiles.js';
|
|
6
7
|
import { getMediaTags as getMediaTagsFn, createMediaTag as createMediaTagFn, updateMediaTag as updateMediaTagFn, deleteMediaTag as deleteMediaTagFn, setMediaFileTags as setMediaFileTagsFn, bulkSetMediaFileTags as bulkSetMediaFileTagsFn } from '../../core/server/media/operations/tags.js';
|
|
7
8
|
import { requireAuth } from './middleware/auth.js';
|
|
@@ -70,6 +71,10 @@ export const getMediaTagsWithCounts = query(async () => {
|
|
|
70
71
|
export const getFileById = query(z.string().uuid(), async (id) => {
|
|
71
72
|
return getFile(id);
|
|
72
73
|
});
|
|
74
|
+
export const findMediaReferences = query(z.string().uuid(), async (id) => {
|
|
75
|
+
requireAuth();
|
|
76
|
+
return findMediaReferencesFn(id);
|
|
77
|
+
});
|
|
73
78
|
export const deleteMediaFile = command(z.string().uuid(), async (id) => {
|
|
74
79
|
requireAuth();
|
|
75
80
|
return deleteMediaFileFn(id);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface ResolvedCmpConfig {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
version: string;
|
|
4
|
+
policyVersion: string;
|
|
5
|
+
gtmConsentMode: boolean;
|
|
6
|
+
categories: {
|
|
7
|
+
analytics: boolean;
|
|
8
|
+
marketing: boolean;
|
|
9
|
+
preferences: boolean;
|
|
10
|
+
};
|
|
11
|
+
strings: Record<string, CmpStrings>;
|
|
12
|
+
}
|
|
13
|
+
export interface CmpStrings {
|
|
14
|
+
bannerTitle?: string;
|
|
15
|
+
bannerBody?: string;
|
|
16
|
+
acceptAll?: string;
|
|
17
|
+
rejectAll?: string;
|
|
18
|
+
customize?: string;
|
|
19
|
+
settingsTitle?: string;
|
|
20
|
+
save?: string;
|
|
21
|
+
necessary?: string;
|
|
22
|
+
analytics?: string;
|
|
23
|
+
marketing?: string;
|
|
24
|
+
preferences?: string;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/core/cms.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { AIAdapter } from '../types/adapters/ai.js';
|
|
|
10
10
|
import type { EmailAdapter } from '../types/adapters/email.js';
|
|
11
11
|
import { betterAuth } from 'better-auth';
|
|
12
12
|
import type { ResolvedShopConfig } from '../shop/types.js';
|
|
13
|
+
import type { ResolvedCmpConfig } from '../cmp/types.js';
|
|
13
14
|
export declare class CMS implements ICMS {
|
|
14
15
|
private config;
|
|
15
16
|
databaseAdapter: DatabaseAdapter;
|
|
@@ -26,6 +27,7 @@ export declare class CMS implements ICMS {
|
|
|
26
27
|
typographyConfig: TypographyConfig;
|
|
27
28
|
sidebarHelp: boolean;
|
|
28
29
|
shopConfig: ResolvedShopConfig | null;
|
|
30
|
+
cmpConfig: ResolvedCmpConfig | null;
|
|
29
31
|
plugins: PluginConfig[];
|
|
30
32
|
customFields: Map<string, CustomFieldDefinition>;
|
|
31
33
|
apiKeys: ApiKeyConfig[];
|
package/dist/core/cms.js
CHANGED
|
@@ -20,6 +20,7 @@ export class CMS {
|
|
|
20
20
|
typographyConfig;
|
|
21
21
|
sidebarHelp;
|
|
22
22
|
shopConfig;
|
|
23
|
+
cmpConfig;
|
|
23
24
|
plugins = [];
|
|
24
25
|
customFields = new Map();
|
|
25
26
|
apiKeys = [];
|
|
@@ -34,6 +35,7 @@ export class CMS {
|
|
|
34
35
|
this.typographyConfig = config.typography || {};
|
|
35
36
|
this.sidebarHelp = config.sidebarHelp ?? true;
|
|
36
37
|
this.shopConfig = config.shop ?? null;
|
|
38
|
+
this.cmpConfig = config.cmp ?? null;
|
|
37
39
|
this.collections = {};
|
|
38
40
|
this.singles = {};
|
|
39
41
|
this.forms = {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract 2-letter ISO country code from request headers.
|
|
3
|
+
* Priority:
|
|
4
|
+
* 1. `cf-ipcountry` (Cloudflare)
|
|
5
|
+
* 2. `x-vercel-ip-country` (Vercel)
|
|
6
|
+
* 3. `x-country-code` (generic proxy)
|
|
7
|
+
* 4. Fallback: parse `Accept-Language` region subtag (e.g. `pl-PL` → `PL`)
|
|
8
|
+
* Returns 'XX' when no reliable source available.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getCountryFromHeaders(headers: Headers): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract 2-letter ISO country code from request headers.
|
|
3
|
+
* Priority:
|
|
4
|
+
* 1. `cf-ipcountry` (Cloudflare)
|
|
5
|
+
* 2. `x-vercel-ip-country` (Vercel)
|
|
6
|
+
* 3. `x-country-code` (generic proxy)
|
|
7
|
+
* 4. Fallback: parse `Accept-Language` region subtag (e.g. `pl-PL` → `PL`)
|
|
8
|
+
* Returns 'XX' when no reliable source available.
|
|
9
|
+
*/
|
|
10
|
+
export function getCountryFromHeaders(headers) {
|
|
11
|
+
const providerCountry = headers.get('cf-ipcountry') ??
|
|
12
|
+
headers.get('x-vercel-ip-country') ??
|
|
13
|
+
headers.get('x-country-code');
|
|
14
|
+
if (providerCountry) {
|
|
15
|
+
const normalized = providerCountry.trim().toUpperCase();
|
|
16
|
+
if (/^[A-Z]{2}$/.test(normalized))
|
|
17
|
+
return normalized;
|
|
18
|
+
}
|
|
19
|
+
const acceptLanguage = headers.get('accept-language');
|
|
20
|
+
if (acceptLanguage) {
|
|
21
|
+
const first = acceptLanguage.split(',')[0].trim();
|
|
22
|
+
const region = first.split('-')[1] ?? first.split('_')[1];
|
|
23
|
+
if (region) {
|
|
24
|
+
const normalized = region.split(';')[0].trim().toUpperCase();
|
|
25
|
+
if (/^[A-Z]{2}$/.test(normalized))
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return 'XX';
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RequestEvent } from '@sveltejs/kit';
|
|
2
|
+
import type { ConsentLogData } from '../../../../types/consent.js';
|
|
3
|
+
export interface CreateCmpConsentLogInput {
|
|
4
|
+
consents: ConsentLogData['consents'];
|
|
5
|
+
consentModeStatus: ConsentLogData['consentModeStatus'];
|
|
6
|
+
parentLogId?: string | null;
|
|
7
|
+
}
|
|
8
|
+
export interface CreateCmpConsentLogResult {
|
|
9
|
+
id: string;
|
|
10
|
+
timestamp: Date;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Creates a consent log from a SvelteKit request event.
|
|
14
|
+
* Extracts IP/UA/URL/language from headers, truncates the IP,
|
|
15
|
+
* pulls cmpVersion/policyVersion from the CMS cmpConfig.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createCmpConsentLog(event: RequestEvent, input: CreateCmpConsentLogInput): Promise<CreateCmpConsentLogResult>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import { getCMS } from '../../../cms.js';
|
|
3
|
+
import { truncateIpAddress } from '../truncateIpAddress.js';
|
|
4
|
+
import { getCountryFromHeaders } from '../getCountryFromHeaders.js';
|
|
5
|
+
/**
|
|
6
|
+
* Creates a consent log from a SvelteKit request event.
|
|
7
|
+
* Extracts IP/UA/URL/language from headers, truncates the IP,
|
|
8
|
+
* pulls cmpVersion/policyVersion from the CMS cmpConfig.
|
|
9
|
+
*/
|
|
10
|
+
export async function createCmpConsentLog(event, input) {
|
|
11
|
+
const cms = getCMS();
|
|
12
|
+
if (!cms.cmpConfig) {
|
|
13
|
+
throw new Error('CMP is not configured. Pass `cmp: defineCmp({...})` to your CMS config.');
|
|
14
|
+
}
|
|
15
|
+
const rawIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
|
16
|
+
event.request.headers.get('x-real-ip') ??
|
|
17
|
+
event.getClientAddress();
|
|
18
|
+
const ipAddressTruncated = truncateIpAddress(rawIp);
|
|
19
|
+
const countryCode = getCountryFromHeaders(event.request.headers);
|
|
20
|
+
const language = event.request.headers.get('accept-language')?.split(',')[0]?.trim() ?? 'unknown';
|
|
21
|
+
const userAgent = event.request.headers.get('user-agent') ?? 'unknown';
|
|
22
|
+
const url = event.request.headers.get('referer') ?? event.url.toString();
|
|
23
|
+
const data = {
|
|
24
|
+
id: uuidv4(),
|
|
25
|
+
ipAddressTruncated,
|
|
26
|
+
countryCode,
|
|
27
|
+
language,
|
|
28
|
+
userAgent,
|
|
29
|
+
url,
|
|
30
|
+
consents: input.consents,
|
|
31
|
+
consentModeStatus: input.consentModeStatus,
|
|
32
|
+
cmpVersion: cms.cmpConfig.version,
|
|
33
|
+
policyVersion: cms.cmpConfig.policyVersion,
|
|
34
|
+
parentLogId: input.parentLogId ?? null
|
|
35
|
+
};
|
|
36
|
+
await cms.databaseAdapter.createConsentLog(data);
|
|
37
|
+
return { id: data.id, timestamp: new Date() };
|
|
38
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { getCMS } from '../../../cms.js';
|
|
2
|
+
export async function getConsentLog(id) {
|
|
3
|
+
const adapter = getCMS().databaseAdapter;
|
|
4
|
+
if (!adapter.getConsentLog) {
|
|
5
|
+
throw new Error('Database adapter does not implement getConsentLog. Use includio-cms db-postgres ≥ 0.16.0 or implement the method.');
|
|
6
|
+
}
|
|
7
|
+
return adapter.getConsentLog(id);
|
|
8
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ConsentLogRecord, GetConsentLogsFilters } from '../../../../types/consent.js';
|
|
2
|
+
export declare function getConsentLogs(filters?: GetConsentLogsFilters): Promise<ConsentLogRecord[]>;
|
|
3
|
+
export declare function countConsentLogs(filters?: Omit<GetConsentLogsFilters, 'limit' | 'offset'>): Promise<number>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getCMS } from '../../../cms.js';
|
|
2
|
+
export async function getConsentLogs(filters = {}) {
|
|
3
|
+
const adapter = getCMS().databaseAdapter;
|
|
4
|
+
if (!adapter.getConsentLogs) {
|
|
5
|
+
throw new Error('Database adapter does not implement getConsentLogs. Use includio-cms db-postgres ≥ 0.16.0 or implement the method.');
|
|
6
|
+
}
|
|
7
|
+
return adapter.getConsentLogs(filters);
|
|
8
|
+
}
|
|
9
|
+
export async function countConsentLogs(filters = {}) {
|
|
10
|
+
const adapter = getCMS().databaseAdapter;
|
|
11
|
+
if (!adapter.countConsentLogs) {
|
|
12
|
+
throw new Error('Database adapter does not implement countConsentLogs. Use includio-cms db-postgres ≥ 0.16.0 or implement the method.');
|
|
13
|
+
}
|
|
14
|
+
return adapter.countConsentLogs(filters);
|
|
15
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GDPR-compliant IP anonymization.
|
|
3
|
+
* IPv4: zero last octet (e.g. 192.168.1.42 → 192.168.1.0)
|
|
4
|
+
* IPv6: zero last 80 bits / keep /48 prefix (e.g. 2001:db8:abcd:1234::1 → 2001:db8:abcd::)
|
|
5
|
+
* Invalid input → 'unknown'
|
|
6
|
+
*/
|
|
7
|
+
export declare function truncateIpAddress(ip: string | null | undefined): string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GDPR-compliant IP anonymization.
|
|
3
|
+
* IPv4: zero last octet (e.g. 192.168.1.42 → 192.168.1.0)
|
|
4
|
+
* IPv6: zero last 80 bits / keep /48 prefix (e.g. 2001:db8:abcd:1234::1 → 2001:db8:abcd::)
|
|
5
|
+
* Invalid input → 'unknown'
|
|
6
|
+
*/
|
|
7
|
+
export function truncateIpAddress(ip) {
|
|
8
|
+
if (!ip)
|
|
9
|
+
return 'unknown';
|
|
10
|
+
const trimmed = ip.trim();
|
|
11
|
+
if (!trimmed)
|
|
12
|
+
return 'unknown';
|
|
13
|
+
if (trimmed.includes('.') && !trimmed.includes(':')) {
|
|
14
|
+
return truncateIpv4(trimmed);
|
|
15
|
+
}
|
|
16
|
+
if (trimmed.includes(':')) {
|
|
17
|
+
return truncateIpv6(trimmed);
|
|
18
|
+
}
|
|
19
|
+
return 'unknown';
|
|
20
|
+
}
|
|
21
|
+
function truncateIpv4(ip) {
|
|
22
|
+
const parts = ip.split('.');
|
|
23
|
+
if (parts.length !== 4)
|
|
24
|
+
return 'unknown';
|
|
25
|
+
for (let i = 0; i < 4; i++) {
|
|
26
|
+
const n = Number(parts[i]);
|
|
27
|
+
if (!Number.isInteger(n) || n < 0 || n > 255)
|
|
28
|
+
return 'unknown';
|
|
29
|
+
}
|
|
30
|
+
return `${parts[0]}.${parts[1]}.${parts[2]}.0`;
|
|
31
|
+
}
|
|
32
|
+
function truncateIpv6(ip) {
|
|
33
|
+
const stripped = ip.split('%')[0];
|
|
34
|
+
const doubleColon = stripped.indexOf('::');
|
|
35
|
+
let groups;
|
|
36
|
+
if (doubleColon !== -1) {
|
|
37
|
+
const left = stripped.slice(0, doubleColon);
|
|
38
|
+
const right = stripped.slice(doubleColon + 2);
|
|
39
|
+
const leftGroups = left ? left.split(':') : [];
|
|
40
|
+
const rightGroups = right ? right.split(':') : [];
|
|
41
|
+
const missing = 8 - leftGroups.length - rightGroups.length;
|
|
42
|
+
if (missing < 0)
|
|
43
|
+
return 'unknown';
|
|
44
|
+
groups = [...leftGroups, ...Array(missing).fill('0'), ...rightGroups];
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
groups = stripped.split(':');
|
|
48
|
+
}
|
|
49
|
+
if (groups.length !== 8)
|
|
50
|
+
return 'unknown';
|
|
51
|
+
for (const g of groups) {
|
|
52
|
+
if (!/^[0-9a-fA-F]{0,4}$/.test(g))
|
|
53
|
+
return 'unknown';
|
|
54
|
+
}
|
|
55
|
+
const prefix = groups.slice(0, 3).map((g) => g.toLowerCase().replace(/^0+(?=.)/, '') || '0');
|
|
56
|
+
return `${prefix.join(':')}::`;
|
|
57
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
import type { EntryData, PopulatedEntryData } from '../../../types/entries.js';
|
|
2
2
|
import type { Field } from '../../../types/fields.js';
|
|
3
|
+
/**
|
|
4
|
+
* Walk entry data according to the field schema and collect all media file UUID references.
|
|
5
|
+
* Covers media/file/seo/object/blocks/content (incl. inline blocks inside content).
|
|
6
|
+
*/
|
|
7
|
+
export declare function extractMediaIdsFromData(data: EntryData, fields: Field[]): string[];
|
|
3
8
|
export declare function resolveMediaFields(data: EntryData, fields: Field[]): Promise<PopulatedEntryData>;
|
|
@@ -4,7 +4,11 @@ import { getCMS } from '../../cms.js';
|
|
|
4
4
|
import z from 'zod';
|
|
5
5
|
import { getImageStyles } from './utils/imageStyles.js';
|
|
6
6
|
import { extractMediaIds as extractMediaIdsFromDoc, walkMediaNodes, walkInlineBlockNodes, cloneDoc } from '../../../admin/components/tiptap/structured-content-utils.js';
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Walk entry data according to the field schema and collect all media file UUID references.
|
|
9
|
+
* Covers media/file/seo/object/blocks/content (incl. inline blocks inside content).
|
|
10
|
+
*/
|
|
11
|
+
export function extractMediaIdsFromData(data, fields) {
|
|
8
12
|
const mediaIds = [];
|
|
9
13
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
14
|
const collectIds = (value, fields) => {
|
|
@@ -71,6 +75,10 @@ export async function resolveMediaFields(data, fields) {
|
|
|
71
75
|
}
|
|
72
76
|
};
|
|
73
77
|
collectIds(data, fields);
|
|
78
|
+
return mediaIds;
|
|
79
|
+
}
|
|
80
|
+
export async function resolveMediaFields(data, fields) {
|
|
81
|
+
const mediaIds = extractMediaIdsFromData(data, fields);
|
|
74
82
|
if (mediaIds.length === 0)
|
|
75
83
|
return data;
|
|
76
84
|
const media = await getCMS().databaseAdapter.getMediaFiles({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { generateTsTypeFromFields, generateFlatTsTypeFromFields, generateInlineBlockTypeString, setGeneratorCustomFields } from './fields.js';
|
|
4
4
|
import { generateTsTypeFromFormFields } from './formFields.js';
|
|
@@ -9,6 +9,22 @@ function createCmsRuntimeDir() {
|
|
|
9
9
|
const cmsDir = join(process.cwd(), 'src/lib/cms/runtime');
|
|
10
10
|
mkdirSync(cmsDir, { recursive: true });
|
|
11
11
|
}
|
|
12
|
+
// Avoids touching mtime when content is unchanged — otherwise Vite's file
|
|
13
|
+
// watcher triggers an SSR reload, which re-runs the generator, which rewrites
|
|
14
|
+
// the same content, looping forever in dev.
|
|
15
|
+
function writeIfChanged(filePath, content) {
|
|
16
|
+
if (existsSync(filePath)) {
|
|
17
|
+
try {
|
|
18
|
+
const current = readFileSync(filePath, 'utf8');
|
|
19
|
+
if (current === content)
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// fall through and write
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
writeFileSync(filePath, content);
|
|
27
|
+
}
|
|
12
28
|
function generateTypesStringForRecords(type, records) {
|
|
13
29
|
const recordTypeString = type.charAt(0).toUpperCase() + type.slice(1);
|
|
14
30
|
const singleSlugs = records.map((s) => s.slug) || [];
|
|
@@ -156,7 +172,7 @@ function generateTypes(config) {
|
|
|
156
172
|
}
|
|
157
173
|
}
|
|
158
174
|
code += `\n export type SiteLanguage = ${config.languages.map((lang) => JSON.stringify(lang)).join(' | ')};\n\n`;
|
|
159
|
-
|
|
175
|
+
writeIfChanged(filePath, code);
|
|
160
176
|
}
|
|
161
177
|
function generateAPI(config) {
|
|
162
178
|
const cmsDir = join(process.cwd(), 'src/lib/cms/runtime');
|
|
@@ -232,7 +248,7 @@ function generateAPI(config) {
|
|
|
232
248
|
: ''}
|
|
233
249
|
|
|
234
250
|
`;
|
|
235
|
-
|
|
251
|
+
writeIfChanged(filePath, code);
|
|
236
252
|
}
|
|
237
253
|
function generateSchemas(config) {
|
|
238
254
|
const cmsDir = join(process.cwd(), 'src/lib/cms/runtime');
|
|
@@ -247,7 +263,7 @@ function generateSchemas(config) {
|
|
|
247
263
|
export const ${varName}FormSchema = ${generateZodSchemaStringFromFormFieldsAsString(form.fields)} \n
|
|
248
264
|
`;
|
|
249
265
|
});
|
|
250
|
-
|
|
266
|
+
writeIfChanged(filePath, code);
|
|
251
267
|
}
|
|
252
268
|
function generateDrizzleSchema(config) {
|
|
253
269
|
const cmsDir = join(process.cwd(), 'src/lib/cms/runtime');
|
|
@@ -259,7 +275,7 @@ function generateDrizzleSchema(config) {
|
|
|
259
275
|
if (config.shop) {
|
|
260
276
|
code += `export * from 'includio-cms/db-postgres/schema-shop';\n`;
|
|
261
277
|
}
|
|
262
|
-
|
|
278
|
+
writeIfChanged(filePath, code);
|
|
263
279
|
}
|
|
264
280
|
function generateRemote(config) {
|
|
265
281
|
if (!config.forms || config.forms.length === 0)
|
|
@@ -282,7 +298,7 @@ function generateRemote(config) {
|
|
|
282
298
|
code += `\t}\n`;
|
|
283
299
|
code += `);\n\n`;
|
|
284
300
|
});
|
|
285
|
-
|
|
301
|
+
writeIfChanged(filePath, code);
|
|
286
302
|
}
|
|
287
303
|
export function generateRuntime(config) {
|
|
288
304
|
// Build custom fields map from plugins for type generation
|
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
import { getCMS } from '../../../cms.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
// State held on globalThis so it survives Vite HMR module re-evaluation in dev;
|
|
3
|
+
// without this, each re-eval would leak a new timer while the old one kept ticking.
|
|
4
|
+
const STATE_KEY = Symbol.for('includio.maintenance.state');
|
|
5
|
+
function getState() {
|
|
6
|
+
const g = globalThis;
|
|
7
|
+
if (!g[STATE_KEY]) {
|
|
8
|
+
g[STATE_KEY] = {
|
|
9
|
+
running: false,
|
|
10
|
+
pendingTimeout: null,
|
|
11
|
+
timer: null,
|
|
12
|
+
lastResult: null,
|
|
13
|
+
nextRunAt: null
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
return g[STATE_KEY];
|
|
17
|
+
}
|
|
6
18
|
export function getMaintenanceStatus() {
|
|
19
|
+
const state = getState();
|
|
7
20
|
return {
|
|
8
|
-
running,
|
|
9
|
-
lastRun: lastResult?.ranAt ?? null,
|
|
10
|
-
nextRun: nextRunAt,
|
|
11
|
-
lastResult
|
|
21
|
+
running: state.running,
|
|
22
|
+
lastRun: state.lastResult?.ranAt ?? null,
|
|
23
|
+
nextRun: state.nextRunAt,
|
|
24
|
+
lastResult: state.lastResult
|
|
12
25
|
};
|
|
13
26
|
}
|
|
14
27
|
async function runMaintenance() {
|
|
15
|
-
|
|
28
|
+
const state = getState();
|
|
29
|
+
if (state.running) {
|
|
16
30
|
console.info('[maintenance] Skipping — previous run still active');
|
|
17
31
|
return;
|
|
18
32
|
}
|
|
19
|
-
running = true;
|
|
33
|
+
state.running = true;
|
|
20
34
|
console.info('[maintenance] Starting background maintenance...');
|
|
21
35
|
const result = {
|
|
22
36
|
stylesCreated: 0,
|
|
@@ -59,8 +73,8 @@ async function runMaintenance() {
|
|
|
59
73
|
console.warn('[maintenance] Error during background maintenance:', err);
|
|
60
74
|
}
|
|
61
75
|
finally {
|
|
62
|
-
running = false;
|
|
63
|
-
lastResult = result;
|
|
76
|
+
state.running = false;
|
|
77
|
+
state.lastResult = result;
|
|
64
78
|
const parts = [];
|
|
65
79
|
if (result.stylesCreated > 0)
|
|
66
80
|
parts.push(`${result.stylesCreated} styles`);
|
|
@@ -79,27 +93,44 @@ async function runMaintenance() {
|
|
|
79
93
|
}
|
|
80
94
|
}
|
|
81
95
|
export function startBackgroundMaintenance() {
|
|
96
|
+
const state = getState();
|
|
97
|
+
if (state.pendingTimeout || state.timer) {
|
|
98
|
+
console.info('[maintenance] Already scheduled, skipping');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
82
101
|
const config = getCMS().mediaConfig?.maintenance;
|
|
83
102
|
if (config?.autoRun === false)
|
|
84
103
|
return;
|
|
85
104
|
const intervalHours = config?.intervalHours ?? 6;
|
|
86
105
|
const intervalMs = intervalHours * 60 * 60 * 1000;
|
|
87
106
|
// First run after 30s delay
|
|
88
|
-
setTimeout(() => {
|
|
107
|
+
state.pendingTimeout = setTimeout(() => {
|
|
108
|
+
state.pendingTimeout = null;
|
|
89
109
|
runMaintenance();
|
|
90
110
|
// Then repeat on interval
|
|
91
|
-
nextRunAt = new Date(Date.now() + intervalMs);
|
|
92
|
-
timer = setInterval(() => {
|
|
93
|
-
nextRunAt = new Date(Date.now() + intervalMs);
|
|
111
|
+
state.nextRunAt = new Date(Date.now() + intervalMs);
|
|
112
|
+
state.timer = setInterval(() => {
|
|
113
|
+
state.nextRunAt = new Date(Date.now() + intervalMs);
|
|
94
114
|
runMaintenance();
|
|
95
115
|
}, intervalMs);
|
|
96
116
|
}, 30_000);
|
|
97
117
|
console.info(`[maintenance] Scheduled: first run in 30s, then every ${intervalHours}h`);
|
|
98
118
|
}
|
|
99
119
|
export function stopBackgroundMaintenance() {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
120
|
+
const state = getState();
|
|
121
|
+
if (state.pendingTimeout) {
|
|
122
|
+
clearTimeout(state.pendingTimeout);
|
|
123
|
+
state.pendingTimeout = null;
|
|
124
|
+
}
|
|
125
|
+
if (state.timer) {
|
|
126
|
+
clearInterval(state.timer);
|
|
127
|
+
state.timer = null;
|
|
104
128
|
}
|
|
129
|
+
state.nextRunAt = null;
|
|
130
|
+
}
|
|
131
|
+
// Clean up on Vite HMR so timers don't accumulate across module re-evaluations.
|
|
132
|
+
if (import.meta.hot) {
|
|
133
|
+
import.meta.hot.dispose(() => {
|
|
134
|
+
stopBackgroundMaintenance();
|
|
135
|
+
});
|
|
105
136
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface MediaReferenceBreakdown {
|
|
2
|
+
collection: string;
|
|
3
|
+
label: string;
|
|
4
|
+
count: number;
|
|
5
|
+
kind: 'collection' | 'single';
|
|
6
|
+
}
|
|
7
|
+
export interface MediaReferenceResult {
|
|
8
|
+
total: number;
|
|
9
|
+
byCollection: MediaReferenceBreakdown[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Find how many entries (across all collections + singles) reference a given media file.
|
|
13
|
+
* Scans only the latest version per entry per language (MVP — history is not inspected).
|
|
14
|
+
* Grouping is by collection/single slug with a human-readable label.
|
|
15
|
+
*/
|
|
16
|
+
export declare function findMediaReferences(fileId: string): Promise<MediaReferenceResult>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getCMS } from '../../../cms.js';
|
|
2
|
+
import { getFieldsFromConfig } from '../../../fields/layoutUtils.js';
|
|
3
|
+
import { extractMediaIdsFromData } from '../../fields/resolveImageFields.js';
|
|
4
|
+
function pickLabel(lang, localized, fallback) {
|
|
5
|
+
if (!localized)
|
|
6
|
+
return fallback ?? '';
|
|
7
|
+
return localized[lang] ?? Object.values(localized)[0] ?? fallback ?? '';
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Find how many entries (across all collections + singles) reference a given media file.
|
|
11
|
+
* Scans only the latest version per entry per language (MVP — history is not inspected).
|
|
12
|
+
* Grouping is by collection/single slug with a human-readable label.
|
|
13
|
+
*/
|
|
14
|
+
export async function findMediaReferences(fileId) {
|
|
15
|
+
const cms = getCMS();
|
|
16
|
+
const lang = cms.languages[0];
|
|
17
|
+
const db = cms.databaseAdapter;
|
|
18
|
+
const breakdown = [];
|
|
19
|
+
let total = 0;
|
|
20
|
+
const scanSlug = async (slug, label, fields, kind) => {
|
|
21
|
+
const entries = await db.getEntries({ slug });
|
|
22
|
+
if (entries.length === 0)
|
|
23
|
+
return;
|
|
24
|
+
const alive = entries.filter((e) => e.archivedAt == null);
|
|
25
|
+
if (alive.length === 0)
|
|
26
|
+
return;
|
|
27
|
+
const versions = await db.getEntryVersions({
|
|
28
|
+
entryIds: alive.map((e) => e.id),
|
|
29
|
+
lang
|
|
30
|
+
});
|
|
31
|
+
// Pick the latest version per entryId.
|
|
32
|
+
const latestByEntry = new Map();
|
|
33
|
+
for (const v of versions) {
|
|
34
|
+
const prev = latestByEntry.get(v.entryId);
|
|
35
|
+
if (!prev || v.versionNumber > prev.versionNumber) {
|
|
36
|
+
latestByEntry.set(v.entryId, v);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
let count = 0;
|
|
40
|
+
for (const v of latestByEntry.values()) {
|
|
41
|
+
const ids = extractMediaIdsFromData(v.data, fields);
|
|
42
|
+
if (ids.includes(fileId))
|
|
43
|
+
count += 1;
|
|
44
|
+
}
|
|
45
|
+
if (count > 0) {
|
|
46
|
+
breakdown.push({ collection: slug, label, count, kind });
|
|
47
|
+
total += count;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
for (const col of Object.values(cms.collections)) {
|
|
51
|
+
const label = pickLabel(lang, col.labels?.plural, col.slug);
|
|
52
|
+
await scanSlug(col.slug, label, getFieldsFromConfig(col), 'collection');
|
|
53
|
+
}
|
|
54
|
+
for (const single of Object.values(cms.singles)) {
|
|
55
|
+
const label = pickLabel(lang, single.label, single.slug);
|
|
56
|
+
await scanSlug(single.slug, label, getFieldsFromConfig(single), 'single');
|
|
57
|
+
}
|
|
58
|
+
breakdown.sort((a, b) => b.count - a.count);
|
|
59
|
+
return { total, byCollection: breakdown };
|
|
60
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
2
2
|
import postgres from 'postgres';
|
|
3
3
|
import * as schema from './schema/index.js';
|
|
4
|
-
import { eq, and, inArray, sql, isNull, desc, asc, SQL, ilike, or, count } from 'drizzle-orm';
|
|
4
|
+
import { eq, and, inArray, sql, isNull, desc, asc, SQL, ilike, or, count, gte, lte } from 'drizzle-orm';
|
|
5
5
|
const SAFE_JSON_KEY = /^[a-zA-Z0-9_]+$/;
|
|
6
6
|
function validateJsonPathKeys(path) {
|
|
7
7
|
for (const key of path) {
|
|
@@ -527,6 +527,51 @@ export function pg(config) {
|
|
|
527
527
|
createConsentLog: async (data) => {
|
|
528
528
|
await db.insert(schema.consentLogsTable).values(data);
|
|
529
529
|
},
|
|
530
|
+
getConsentLogs: async (filters) => {
|
|
531
|
+
const conditions = [];
|
|
532
|
+
if (filters.startDate) {
|
|
533
|
+
conditions.push(gte(schema.consentLogsTable.timestamp, filters.startDate));
|
|
534
|
+
}
|
|
535
|
+
if (filters.endDate) {
|
|
536
|
+
conditions.push(lte(schema.consentLogsTable.timestamp, filters.endDate));
|
|
537
|
+
}
|
|
538
|
+
if (filters.country) {
|
|
539
|
+
conditions.push(eq(schema.consentLogsTable.countryCode, filters.country.toUpperCase()));
|
|
540
|
+
}
|
|
541
|
+
const rows = await db
|
|
542
|
+
.select()
|
|
543
|
+
.from(schema.consentLogsTable)
|
|
544
|
+
.where(conditions.length ? and(...conditions) : undefined)
|
|
545
|
+
.orderBy(desc(schema.consentLogsTable.timestamp))
|
|
546
|
+
.limit(filters.limit ?? 50)
|
|
547
|
+
.offset(filters.offset ?? 0);
|
|
548
|
+
return rows;
|
|
549
|
+
},
|
|
550
|
+
countConsentLogs: async (filters) => {
|
|
551
|
+
const conditions = [];
|
|
552
|
+
if (filters.startDate) {
|
|
553
|
+
conditions.push(gte(schema.consentLogsTable.timestamp, filters.startDate));
|
|
554
|
+
}
|
|
555
|
+
if (filters.endDate) {
|
|
556
|
+
conditions.push(lte(schema.consentLogsTable.timestamp, filters.endDate));
|
|
557
|
+
}
|
|
558
|
+
if (filters.country) {
|
|
559
|
+
conditions.push(eq(schema.consentLogsTable.countryCode, filters.country.toUpperCase()));
|
|
560
|
+
}
|
|
561
|
+
const [row] = await db
|
|
562
|
+
.select({ value: count() })
|
|
563
|
+
.from(schema.consentLogsTable)
|
|
564
|
+
.where(conditions.length ? and(...conditions) : undefined);
|
|
565
|
+
return row?.value ?? 0;
|
|
566
|
+
},
|
|
567
|
+
getConsentLog: async (id) => {
|
|
568
|
+
const [row] = await db
|
|
569
|
+
.select()
|
|
570
|
+
.from(schema.consentLogsTable)
|
|
571
|
+
.where(eq(schema.consentLogsTable.id, id))
|
|
572
|
+
.limit(1);
|
|
573
|
+
return row ?? null;
|
|
574
|
+
},
|
|
530
575
|
// --- Media Tags ---
|
|
531
576
|
getMediaTags: async () => {
|
|
532
577
|
return db.select().from(schema.mediaTagsTable).orderBy(schema.mediaTagsTable.name);
|
|
@@ -189,6 +189,23 @@ export declare const consentLogsTable: import("drizzle-orm/pg-core/table", { wit
|
|
|
189
189
|
identity: undefined;
|
|
190
190
|
generated: undefined;
|
|
191
191
|
}, {}, {}>;
|
|
192
|
+
parentLogId: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
|
|
193
|
+
name: "parent_log_id";
|
|
194
|
+
tableName: "consent_logs";
|
|
195
|
+
dataType: "string";
|
|
196
|
+
columnType: "PgUUID";
|
|
197
|
+
data: string;
|
|
198
|
+
driverParam: string;
|
|
199
|
+
notNull: false;
|
|
200
|
+
hasDefault: false;
|
|
201
|
+
isPrimaryKey: false;
|
|
202
|
+
isAutoincrement: false;
|
|
203
|
+
hasRuntimeDefault: false;
|
|
204
|
+
enumValues: undefined;
|
|
205
|
+
baseColumn: never;
|
|
206
|
+
identity: undefined;
|
|
207
|
+
generated: undefined;
|
|
208
|
+
}, {}, {}>;
|
|
192
209
|
};
|
|
193
210
|
dialect: "pg";
|
|
194
211
|
}>;
|
|
@@ -10,5 +10,8 @@ export const consentLogsTable = pgTable('consent_logs', {
|
|
|
10
10
|
consents: jsonb('consents').notNull(),
|
|
11
11
|
consentModeStatus: jsonb('consent_mode_status').notNull(),
|
|
12
12
|
cmpVersion: text('cmp_version').notNull(),
|
|
13
|
-
policyVersion: text('policy_version').notNull()
|
|
13
|
+
policyVersion: text('policy_version').notNull(),
|
|
14
|
+
parentLogId: uuid('parent_log_id').references(() => consentLogsTable.id, {
|
|
15
|
+
onDelete: 'set null'
|
|
16
|
+
})
|
|
14
17
|
});
|
|
@@ -80,7 +80,7 @@ export function includioCMS(cmsConfig) {
|
|
|
80
80
|
const securityHeaders = async ({ event, resolve }) => {
|
|
81
81
|
const response = await resolve(event);
|
|
82
82
|
response.headers.set('X-Content-Type-Options', 'nosniff');
|
|
83
|
-
response.headers.set('X-Frame-Options', '
|
|
83
|
+
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
|
|
84
84
|
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
85
85
|
return response;
|
|
86
86
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConsentLogData } from '../consent.js';
|
|
1
|
+
import type { ConsentLogData, ConsentLogRecord, GetConsentLogsFilters } from '../consent.js';
|
|
2
2
|
import type { DbEntry, DbEntryInsert, DbEntryVersion, DbEntryVersionInsert, GetDbEntriesOptions, GetDbEntryVersionsOptions, GetPaginatedEntriesOptions, PaginatedEntryRow, PaginationOptions } from '../entries.js';
|
|
3
3
|
import type { ImageFieldStyle } from '../fields.js';
|
|
4
4
|
import type { FormSubmission } from '../forms.js';
|
|
@@ -23,6 +23,9 @@ export interface DatabaseAdapter {
|
|
|
23
23
|
getPaginatedEntries?: GetPaginatedEntries;
|
|
24
24
|
countPaginatedEntries?: CountPaginatedEntries;
|
|
25
25
|
createConsentLog: CreateConsentLog;
|
|
26
|
+
getConsentLogs?: GetConsentLogs;
|
|
27
|
+
countConsentLogs?: CountConsentLogs;
|
|
28
|
+
getConsentLog?: GetConsentLog;
|
|
26
29
|
getMediaTags: GetMediaTags;
|
|
27
30
|
createMediaTag: CreateMediaTag;
|
|
28
31
|
updateMediaTag: UpdateMediaTag;
|
|
@@ -86,6 +89,9 @@ export type GetFormSubmission = (id: string) => Promise<FormSubmission | null>;
|
|
|
86
89
|
export type UpdateFormSubmission = (id: string, data: Partial<FormSubmission>) => Promise<FormSubmission>;
|
|
87
90
|
export type DeleteFormSubmission = (id: string) => Promise<void>;
|
|
88
91
|
export type CreateConsentLog = (data: ConsentLogData) => Promise<void>;
|
|
92
|
+
export type GetConsentLogs = (filters: GetConsentLogsFilters) => Promise<ConsentLogRecord[]>;
|
|
93
|
+
export type CountConsentLogs = (filters: Omit<GetConsentLogsFilters, 'limit' | 'offset'>) => Promise<number>;
|
|
94
|
+
export type GetConsentLog = (id: string) => Promise<ConsentLogRecord | null>;
|
|
89
95
|
export interface GetMediaFilesOptions {
|
|
90
96
|
data: {
|
|
91
97
|
tagIds?: string[];
|
package/dist/types/cms.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { FormConfig } from './forms.js';
|
|
|
8
8
|
import type { AIAdapter } from './adapters/ai.js';
|
|
9
9
|
import type { EmailAdapter } from './adapters/email.js';
|
|
10
10
|
import type { ResolvedShopConfig } from '../shop/types.js';
|
|
11
|
+
import type { ResolvedCmpConfig } from '../cmp/types.js';
|
|
11
12
|
export interface VideoTranscodeConfig {
|
|
12
13
|
/** Enable/disable auto-transcoding on upload (default: true) */
|
|
13
14
|
transcode?: boolean;
|
|
@@ -79,6 +80,7 @@ export interface CMSConfig {
|
|
|
79
80
|
typography?: TypographyConfig;
|
|
80
81
|
sidebarHelp?: boolean;
|
|
81
82
|
shop?: ResolvedShopConfig;
|
|
83
|
+
cmp?: ResolvedCmpConfig;
|
|
82
84
|
}
|
|
83
85
|
export interface ICMS {
|
|
84
86
|
collections: Record<string, CollectionConfigWithType>;
|
|
@@ -96,4 +98,5 @@ export interface ICMS {
|
|
|
96
98
|
apiKeys: ApiKeyConfig[];
|
|
97
99
|
typographyConfig: TypographyConfig;
|
|
98
100
|
shopConfig: ResolvedShopConfig | null;
|
|
101
|
+
cmpConfig: ResolvedCmpConfig | null;
|
|
99
102
|
}
|
package/dist/types/consent.d.ts
CHANGED
|
@@ -19,4 +19,15 @@ export interface ConsentLogData {
|
|
|
19
19
|
};
|
|
20
20
|
cmpVersion: string;
|
|
21
21
|
policyVersion: string;
|
|
22
|
+
parentLogId?: string | null;
|
|
23
|
+
}
|
|
24
|
+
export interface ConsentLogRecord extends ConsentLogData {
|
|
25
|
+
timestamp: Date;
|
|
26
|
+
}
|
|
27
|
+
export interface GetConsentLogsFilters {
|
|
28
|
+
startDate?: Date;
|
|
29
|
+
endDate?: Date;
|
|
30
|
+
country?: string;
|
|
31
|
+
limit?: number;
|
|
32
|
+
offset?: number;
|
|
22
33
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.15.5',
|
|
3
|
+
date: '2026-04-23',
|
|
4
|
+
description: 'Media field recovers from orphan references; delete dialog shows usage breakdown + replace hint. X-Frame-Options relaxed to SAMEORIGIN. Background maintenance no longer duplicates on Vite HMR. Runtime generator no longer triggers an infinite SSR reload loop in dev.',
|
|
5
|
+
features: [
|
|
6
|
+
'Admin media library: delete dialog (`FileDetails`) teraz pokazuje breakdown użyć pliku per kolekcja/single ("Plik jest używany w: Strony: 1") oraz hint wskazujący na funkcję **Zamień plik** (która zachowuje ID → wszystkie referencje). Dane ładowane asynchronicznie przez nowy remote query `findMediaReferences(id)` uruchamiany w momencie otwarcia dialogu. Scope (MVP): tylko najnowsza wersja per entry; historia poza zakresem. Walker po schematach (`media`, `file`, `seo.ogImage`, `object`, `blocks`, `content` z inline blocks) wyekstrahowany z `resolveImageFields.ts` jako reużywalne `extractMediaIdsFromData(data, fields)`.'
|
|
7
|
+
],
|
|
8
|
+
fixes: [
|
|
9
|
+
'`media-field.svelte` nie blokuje już edycji entry gdy pole media wskazuje na usunięty plik (orphan reference). Wcześniej render wchodził w `{:else if singleFile}` z `singleFile=null` po nieudanym `getFileById`, co wyrzucało UI do pustego diva bez przycisków Zmień/Usuń — user zablokowany, nie mógł wstawić nowego obrazu. Nowa gałąź `{:else}` renderuje dashed warning placeholder ("Brakujący plik" / "Missing file") z zachowanymi kontrolkami. Analogicznie dla multi-media: per-item fallback pozwala usunąć pojedynczy orphan z tablicy bez czyszczenia pozostałych.',
|
|
10
|
+
'`X-Frame-Options` zmieniony z `DENY` na `SAMEORIGIN` w middleware `securityHeaders`. Wcześniej `DENY` blokował nawet same-origin framing, przez co admin CMS nie mógł załadować preview entry w iframe (`previewUrl` w konfiguracji kolekcji/single). Safari egzekwuje to rygorystycznie ("Refused to display ... in a frame because it set X-Frame-Options to DENY"). `SAMEORIGIN` dalej chroni przed clickjackingiem z obcych domen.',
|
|
11
|
+
'`startBackgroundMaintenance()` jest teraz idempotentne. Wcześniej każde wywołanie tworzyło nowy `setTimeout`/`setInterval` bez czyszczenia poprzedniego — w `pnpm dev` Vite HMR re-executuje `hooks.server.ts` (i tym samym `initCMS()`) przy każdej zmianie, więc po kilku edycjach działało N równoległych przebiegów maintenance. Stan timerów (`pendingTimeout`, `timer`, `running`, `lastResult`, `nextRunAt`) przeniesiony na `globalThis[Symbol.for("includio.maintenance.state")]`, żeby przeżył re-eval modułu w dev. Dodany guard: gdy timer już zaplanowany, kolejne `start()` loguje `already scheduled, skipping` i wychodzi. `stopBackgroundMaintenance()` teraz czyści również initial 30s `setTimeout`, nie tylko interval. Dodany `import.meta.hot.dispose()` czyszczący timery gdy Vite unlinkuje moduł.',
|
|
12
|
+
'`generateRuntime()` (`generator.ts`) nie zapisuje już plików gdy treść się nie zmieniła. Wcześniej każdy wywołanie `includioCMS()` (a więc każdy SSR reload Vite) bezwarunkowo `writeFileSync` na 5 plikach w `src/lib/cms/runtime/` (`api.ts`, `types.ts`, `schemas.ts`, `schema.ts`, `remote.ts`) — co aktualizowało mtime, Vite wykrywał zmianę, robił `(ssr) page reload`, znów wołał `includioCMS()` → znów zapis → nieskończona pętla reload w `pnpm dev`, blokująca pracę. Nowy helper `writeIfChanged(filePath, content)` najpierw czyta plik i zapisuje wyłącznie gdy treść się różni.'
|
|
13
|
+
],
|
|
14
|
+
breakingChanges: []
|
|
15
|
+
};
|
package/dist/updates/index.js
CHANGED
|
@@ -49,7 +49,8 @@ import { update as update0151 } from './0.15.1/index.js';
|
|
|
49
49
|
import { update as update0152 } from './0.15.2/index.js';
|
|
50
50
|
import { update as update0153 } from './0.15.3/index.js';
|
|
51
51
|
import { update as update0154 } from './0.15.4/index.js';
|
|
52
|
-
|
|
52
|
+
import { update as update0155 } from './0.15.5/index.js';
|
|
53
|
+
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, update0140, update0141, update0142, update0143, update0144, update0145, update0146, update0150, update0151, update0152, update0153, update0154, update0155];
|
|
53
54
|
export const getUpdatesFrom = (fromVersion) => {
|
|
54
55
|
const fromParts = fromVersion.split('.').map(Number);
|
|
55
56
|
return updates.filter((update) => {
|