includio-cms 0.20.0 → 0.21.0
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/API.md +3 -2
- package/CHANGELOG.md +57 -0
- package/DOCS.md +1 -1
- package/ROADMAP.md +7 -4
- package/dist/admin/api/rest/middleware/apiKey.js +9 -1
- package/dist/admin/api/rest/middleware/generateApiKey.d.ts +7 -0
- package/dist/admin/api/rest/middleware/generateApiKey.js +10 -0
- package/dist/admin/client/users/delete-user-dialog.svelte +2 -2
- package/dist/admin/client/users/lang.d.ts +10 -2
- package/dist/admin/client/users/lang.js +10 -4
- package/dist/core/server/media/operations/uploadFile.js +4 -3
- package/dist/core/server/media/styles/sharp/generateImageStyle.js +3 -2
- package/dist/core/server/media/utils/generateAdminThumbnail.js +3 -2
- package/dist/core/server/media/utils/generateBlurDataUrl.js +2 -1
- package/dist/server/security/csp.d.ts +16 -0
- package/dist/server/security/csp.js +33 -0
- package/dist/server/security/csrf.d.ts +13 -0
- package/dist/server/security/csrf.js +49 -0
- package/dist/server/security/index.d.ts +3 -0
- package/dist/server/security/index.js +3 -0
- package/dist/server/security/rate-limit.d.ts +44 -0
- package/dist/server/security/rate-limit.js +97 -0
- package/dist/server/utils/withTimeout.d.ts +21 -0
- package/dist/server/utils/withTimeout.js +37 -0
- package/dist/sveltekit/server/handle.js +7 -0
- package/dist/sveltekit/server/index.d.ts +1 -0
- package/dist/sveltekit/server/index.js +1 -0
- package/dist/types/cms.d.ts +4 -0
- package/dist/updates/0.21.0/index.d.ts +2 -0
- package/dist/updates/0.21.0/index.js +55 -0
- package/dist/updates/index.js +2 -1
- package/package.json +9 -2
package/API.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# includio-cms — Public API v0.
|
|
1
|
+
# includio-cms — Public API v0.21.0
|
|
2
2
|
|
|
3
3
|
> Auto-generated by `scripts/generate-api-md.ts`. Do not edit by hand.
|
|
4
4
|
|
|
5
|
-
Entry points: **15** · Stable: **
|
|
5
|
+
Entry points: **15** · Stable: **346** · Experimental: **2**
|
|
6
6
|
|
|
7
7
|
Tags:
|
|
8
8
|
- `@public` — frozen for v1.0; semver-protected.
|
|
@@ -273,6 +273,7 @@ Tags:
|
|
|
273
273
|
- `const createConsentLog: <inferred>`
|
|
274
274
|
- `const createFormSubmission: <inferred>`
|
|
275
275
|
- `createRestApiHandler(): <inferred>` — REST API handler factory. Returns `{ GET, POST, PUT, DELETE }` `RequestHandler`s authenticated via `x-api-key` header. Mount in `src/routes/admin/api/rest/[...restPath]/+server.ts`.
|
|
276
|
+
- `generateApiKey(): string` — Generate a cryptographically random API key (32 bytes, base64url-encoded).
|
|
276
277
|
- `getPreviewEntry(event: RequestEvent, options: { language: string }): Promise<Entry | null>` — Resolves the preview entry from a `?preview=<versionId>` query param. Requires an authenticated session — throws `Unauthorized` otherwise.
|
|
277
278
|
- `includioCMS(cmsConfig: CMSConfig): Handle[]` — SvelteKit `Handle[]` array that initializes the CMS, generates runtime artifacts, wires auth/admin guards, and exposes `event.locals.cmsContext`. Compose with `sequence()` in `hooks.server.ts`.
|
|
278
279
|
- `parseFormDataForSubmission(formData: FormData, fields: FormField[]): Promise<Record<string, unknown>>` — Parses multipart `FormData` into a typed record of field values, handling file uploads via the configured files adapter.
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,63 @@
|
|
|
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.21.0 — 2026-04-30
|
|
7
|
+
|
|
8
|
+
Faza 5 część 2 — security finish: form rate-limit DRY refactor, API keys `expiresAt` / `rotatedAt`, sharp timeout 30s, `{@html}` audit close. `KNOWN-RISKS.md` w root jako single source of truth dla zaakceptowanych ryzyk v1.0. Plus: faza 6 setup — vitest coverage (info-only), docker test profile, integration test scaffolding (`tests/`).
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `KNOWN-RISKS.md` w root paczki — 5 ryzyk udokumentowanych: CSP `'unsafe-inline'`, API key rotation opt-in, in-memory rate-limit, sharp 30s timeout, ffmpeg/sharp args audit. Każde z mitygacją i triggerem fix.
|
|
12
|
+
- `ApiKeyConfig.expiresAt?: string` (ISO-8601) — opt-in expiry, enforced w `validateApiKey()`. Wygasły klucz → 401 generic (no leak). Brak `expiresAt` = klucz nigdy nie wygasa (backward compat).
|
|
13
|
+
- `ApiKeyConfig.rotatedAt?: string` — info-only audit-trail, nie enforced.
|
|
14
|
+
- `generateApiKey()` (`includio-cms/sveltekit/server`) — crypto-random 32B base64url. Pełna rotacja = update `cms.config.ts` + redeploy (statyczny model, patrz KNOWN-RISKS §2).
|
|
15
|
+
- `withTimeout<T>(promise, ms, label)` + `TimeoutError` (`$lib/server/utils/withTimeout`). Wrap dookoła wszystkich sharp calls (metadata, toBuffer, blur, admin thumbnail, downscale resize). Default 30s, env override `INCLUDIO_SHARP_TIMEOUT_MS`.
|
|
16
|
+
- Form submit rate-limit: refaktor `src/routes/api/forms/[slug]/submit/+server.ts` — używa shared `MemoryRateLimitStore` z `$lib/server/security/rate-limit.js` (DRY z `/admin/api/*` rate-limit). Limity 5/h per IP zachowane. Env: `INCLUDIO_FORM_RATE_LIMIT_MAX`, `INCLUDIO_FORM_RATE_LIMIT_WINDOW_MS`.
|
|
17
|
+
- Faza 6 setup: docker `test` profile (`db_test` na porcie 5434, tmpfs, `fsync=off`), `tests/helpers/{db,api,auth}.ts`, `tests/setup.ts` z TRUNCATE strategy, vitest project `integration` (sequential, `singleFork`), sanity test `tests/integration/db-sanity.spec.ts`.
|
|
18
|
+
- Vitest coverage config (info-only, bez threshold) — `pnpm test:coverage` generuje `coverage/`. Reporter: text/json/html. Devdep: `@vitest/coverage-v8`.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- `{@html}` w `src/lib/admin/client/users/delete-user-dialog.svelte` (linie 85, 93) zastąpione safe Svelte template `{...}<strong>{...}</strong>{...}`. Defense-in-depth — content był admin-controlled, ale eliminuje wektor regresji XSS gdyby ktoś kiedyś dodał user-supplied input do tych komunikatów.
|
|
22
|
+
|
|
23
|
+
### Breaking
|
|
24
|
+
- Brak hard breakages. `ApiKeyConfig.expiresAt` / `rotatedAt` są opcjonalne — istniejące configi działają bez zmian.
|
|
25
|
+
- `usersLang.deleteWarningDesc` zmienia sygnaturę z `(name: string) => string` na statyczny obiekt `{ before: string; after: string }`. `usersLang.deleteConfirmType` analogicznie ze stringa na `{ before: string; after: string }`. Wpływ tylko na fork'i admin UI z customowym lang — szybki `pnpm check` zwróci błąd typu, fix = przepisz wartości w lang.
|
|
26
|
+
|
|
27
|
+
### Notes
|
|
28
|
+
|
|
29
|
+
## Setup integration tests (opt-in)
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
docker compose --profile test up -d db_test
|
|
33
|
+
pnpm prepack # buduje dist/ wymagany przez drizzle-kit push schema
|
|
34
|
+
pnpm db:test:migrate # drizzle-kit push do test DB
|
|
35
|
+
pnpm test:integration # uruchamia tests/integration/**
|
|
36
|
+
docker compose --profile test down
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## API key expiry (opt-in)
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// cms.config.ts
|
|
43
|
+
apiKeys: [
|
|
44
|
+
{ key: 'sk-...', name: 'ci', role: 'admin', expiresAt: '2027-01-01T00:00:00Z' }
|
|
45
|
+
]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Generowanie nowego klucza:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { generateApiKey } from 'includio-cms/sveltekit/server';
|
|
52
|
+
console.log(generateApiKey()); // → 43-char base64url, 32B entropii
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Pełna rotacja wymaga update'u `cms.config.ts` + redeploy. Pole `rotatedAt` jest info-only (audit trail) — admin ustawia ręcznie przy rotacji.
|
|
56
|
+
|
|
57
|
+
## Known risks
|
|
58
|
+
|
|
59
|
+
Lista zaakceptowanych ryzyk v1.0 — `KNOWN-RISKS.md` w root paczki. 5 sekcji: CSP `unsafe-inline`, API key rotation opt-in, in-memory rate-limit (multi-node = wymaga Redis adapter), sharp 30s timeout, ffmpeg/sharp shell audit (SAFE).
|
|
60
|
+
|
|
61
|
+
Brak SQL migration.
|
|
62
|
+
|
|
6
63
|
## 0.20.0 — 2026-04-29
|
|
7
64
|
|
|
8
65
|
API Surface Lock — exports trim 26→15, JSDoc tagi (`@public`/`@experimental`/`@internal`), autogenerowany `API.md`. Powierzchnia publiczna zamrożona w v1.0 — w 1.x tylko `@experimental` może się zmienić bez breaking semver.
|
package/DOCS.md
CHANGED
package/ROADMAP.md
CHANGED
|
@@ -354,10 +354,13 @@
|
|
|
354
354
|
## Security hardening
|
|
355
355
|
|
|
356
356
|
- [ ] `[feature]` `[P1]` `sanitizeHTML` utility — general HTML sanitization outside richtext (text fields, SEO fields)
|
|
357
|
-
- [
|
|
358
|
-
- [
|
|
359
|
-
- [x] `[feature]` `[P1]` Rate limiting — form submit
|
|
360
|
-
- [x] `[chore]` `[P1]` Security audit — timing attacks, MIME
|
|
357
|
+
- [x] `[feature]` `[P1]` CSP headers — Content-Security-Policy middleware (0.20.0/0.21.0; `unsafe-inline` zaakceptowany — KNOWN-RISKS §1)
|
|
358
|
+
- [x] `[feature]` `[P1]` CSRF protection — origin/referer guard na `/admin/api/*` (0.20.0/0.21.0)
|
|
359
|
+
- [x] `[feature]` `[P1]` Rate limiting — form submit + `/admin/api/*` (form 0.13.3, admin 0.20.0, DRY refactor 0.21.0)
|
|
360
|
+
- [x] `[chore]` `[P1]` Security audit — timing attacks, MIME, demo endpoint, rate limiting, `{@html}` w admin UI, ffmpeg/sharp shell args (0.13.3 + 0.21.0)
|
|
361
|
+
- [x] `[feature]` `[P1]` API key expiry (opt-in `expiresAt`) + `generateApiKey()` helper (0.21.0; pełna rotacja = manual config + redeploy, KNOWN-RISKS §2)
|
|
362
|
+
- [x] `[feature]` `[P1]` Sharp timeout 30s (`withTimeout` + `INCLUDIO_SHARP_TIMEOUT_MS`, 0.21.0)
|
|
363
|
+
- [x] `[chore]` `[P0]` `KNOWN-RISKS.md` — single source of truth dla zaakceptowanych ryzyk v1.0 (0.21.0)
|
|
361
364
|
- [ ] `[chore]` `[P1]` Input sanitization audit — review all fields for XSS
|
|
362
365
|
|
|
363
366
|
## Backlog
|
|
@@ -14,7 +14,15 @@ function safeEqual(a, b) {
|
|
|
14
14
|
}
|
|
15
15
|
export function validateApiKey(key) {
|
|
16
16
|
const cms = getCMS();
|
|
17
|
-
|
|
17
|
+
const found = cms.apiKeys.find((k) => safeEqual(k.key, key));
|
|
18
|
+
if (!found)
|
|
19
|
+
return null;
|
|
20
|
+
if (found.expiresAt) {
|
|
21
|
+
const exp = Date.parse(found.expiresAt);
|
|
22
|
+
if (Number.isFinite(exp) && exp < Date.now())
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return found;
|
|
18
26
|
}
|
|
19
27
|
export function setSyntheticUser(event, apiKey) {
|
|
20
28
|
const name = apiKey.name || 'api';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a cryptographically random API key (32 bytes, base64url-encoded).
|
|
3
|
+
* Use the result for `ApiKeyConfig.key`. Full rotation requires updating
|
|
4
|
+
* `cms.config.ts` and redeploying — see `KNOWN-RISKS.md` §2.
|
|
5
|
+
* @public
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateApiKey(): string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Generate a cryptographically random API key (32 bytes, base64url-encoded).
|
|
4
|
+
* Use the result for `ApiKeyConfig.key`. Full rotation requires updating
|
|
5
|
+
* `cms.config.ts` and redeploying — see `KNOWN-RISKS.md` §2.
|
|
6
|
+
* @public
|
|
7
|
+
*/
|
|
8
|
+
export function generateApiKey() {
|
|
9
|
+
return randomBytes(32).toString('base64url');
|
|
10
|
+
}
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
{lang.deleteWarningTitle}
|
|
83
83
|
</p>
|
|
84
84
|
<p class="mt-1 text-sm" style="color: var(--muted-foreground);">
|
|
85
|
-
{
|
|
85
|
+
{lang.deleteWarningDesc.before}<strong>{user.name}</strong>{lang.deleteWarningDesc.after}
|
|
86
86
|
</p>
|
|
87
87
|
</div>
|
|
88
88
|
</div>
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
<!-- Confirm input -->
|
|
91
91
|
<div class="space-y-2">
|
|
92
92
|
<Label for="delete-confirm">
|
|
93
|
-
{
|
|
93
|
+
{lang.deleteConfirmType.before}<strong>{lang.deleteConfirmWord}</strong>{lang.deleteConfirmType.after}
|
|
94
94
|
</Label>
|
|
95
95
|
<Input
|
|
96
96
|
id="delete-confirm"
|
|
@@ -24,8 +24,16 @@ export declare const usersLang: Record<InterfaceLanguage, {
|
|
|
24
24
|
deleteConfirmTitle: string;
|
|
25
25
|
deleteConfirmDescription: string;
|
|
26
26
|
deleteWarningTitle: string;
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/** Split into `before` / `after`; the user name is rendered between them in template (avoids `{@html}`). */
|
|
28
|
+
deleteWarningDesc: {
|
|
29
|
+
before: string;
|
|
30
|
+
after: string;
|
|
31
|
+
};
|
|
32
|
+
/** Split into `before` / `after`; `deleteConfirmWord` is rendered between them. */
|
|
33
|
+
deleteConfirmType: {
|
|
34
|
+
before: string;
|
|
35
|
+
after: string;
|
|
36
|
+
};
|
|
29
37
|
deleteConfirmWord: string;
|
|
30
38
|
userCreated: string;
|
|
31
39
|
userUpdated: string;
|
|
@@ -24,8 +24,11 @@ export const usersLang = {
|
|
|
24
24
|
deleteConfirmTitle: 'Delete user?',
|
|
25
25
|
deleteConfirmDescription: 'This action cannot be undone. The user will be permanently deleted.',
|
|
26
26
|
deleteWarningTitle: 'This action cannot be undone',
|
|
27
|
-
deleteWarningDesc:
|
|
28
|
-
|
|
27
|
+
deleteWarningDesc: {
|
|
28
|
+
before: 'User ',
|
|
29
|
+
after: ' will be permanently deleted along with all their data.'
|
|
30
|
+
},
|
|
31
|
+
deleteConfirmType: { before: 'Type ', after: ' to confirm' },
|
|
29
32
|
deleteConfirmWord: 'DELETE',
|
|
30
33
|
userCreated: 'User created',
|
|
31
34
|
userUpdated: 'User updated',
|
|
@@ -107,8 +110,11 @@ export const usersLang = {
|
|
|
107
110
|
deleteConfirmTitle: 'Usunąć użytkownika?',
|
|
108
111
|
deleteConfirmDescription: 'Ta akcja jest nieodwracalna. Użytkownik zostanie trwale usunięty.',
|
|
109
112
|
deleteWarningTitle: 'Tej operacji nie można cofnąć',
|
|
110
|
-
deleteWarningDesc:
|
|
111
|
-
|
|
113
|
+
deleteWarningDesc: {
|
|
114
|
+
before: 'Użytkownik ',
|
|
115
|
+
after: ' zostanie trwale usunięty wraz ze wszystkimi danymi.'
|
|
116
|
+
},
|
|
117
|
+
deleteConfirmType: { before: 'Wpisz ', after: ', żeby potwierdzić' },
|
|
112
118
|
deleteConfirmWord: 'USUŃ',
|
|
113
119
|
userCreated: 'Użytkownik utworzony',
|
|
114
120
|
userUpdated: 'Użytkownik zaktualizowany',
|
|
@@ -4,6 +4,7 @@ import { generateAdminThumbnail } from '../utils/generateAdminThumbnail.js';
|
|
|
4
4
|
import { generateDefaultStylesInBackground } from '../styles/operations/generateDefaultStyles.js';
|
|
5
5
|
import { generateDefaultVideoStylesInBackground } from '../styles/operations/generateDefaultVideoStyles.js';
|
|
6
6
|
import sharp from 'sharp';
|
|
7
|
+
import { withTimeout, sharpTimeoutMs } from '../../../../server/utils/withTimeout.js';
|
|
7
8
|
async function maybeDownscale(file) {
|
|
8
9
|
const cms = getCMS();
|
|
9
10
|
const { maxOriginalWidth, maxOriginalHeight } = cms.mediaConfig;
|
|
@@ -14,19 +15,19 @@ async function maybeDownscale(file) {
|
|
|
14
15
|
return file;
|
|
15
16
|
try {
|
|
16
17
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
17
|
-
const metadata = await sharp(buffer).metadata();
|
|
18
|
+
const metadata = await withTimeout(sharp(buffer).metadata(), sharpTimeoutMs(), 'sharp.metadata');
|
|
18
19
|
const w = metadata.width || 0;
|
|
19
20
|
const h = metadata.height || 0;
|
|
20
21
|
const exceedsWidth = maxOriginalWidth && w > maxOriginalWidth;
|
|
21
22
|
const exceedsHeight = maxOriginalHeight && h > maxOriginalHeight;
|
|
22
23
|
if (!exceedsWidth && !exceedsHeight)
|
|
23
24
|
return file;
|
|
24
|
-
const resized = await sharp(buffer)
|
|
25
|
+
const resized = await withTimeout(sharp(buffer)
|
|
25
26
|
.resize(maxOriginalWidth, maxOriginalHeight, {
|
|
26
27
|
fit: 'inside',
|
|
27
28
|
withoutEnlargement: true
|
|
28
29
|
})
|
|
29
|
-
.toBuffer();
|
|
30
|
+
.toBuffer(), sharpTimeoutMs(), 'sharp.resize');
|
|
30
31
|
return new File([new Uint8Array(resized)], file.name, { type: file.type });
|
|
31
32
|
}
|
|
32
33
|
catch (e) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getCMS } from '../../../../cms.js';
|
|
2
2
|
import sharp from 'sharp';
|
|
3
3
|
import { calculateFocalCropRegion } from '../../utils/calculateFocalCropRegion.js';
|
|
4
|
+
import { withTimeout, sharpTimeoutMs } from '../../../../../server/utils/withTimeout.js';
|
|
4
5
|
export async function generateImageStyle(mediaFileId, style) {
|
|
5
6
|
const mediaFile = await getCMS().databaseAdapter.getMediaFile({
|
|
6
7
|
data: {
|
|
@@ -19,7 +20,7 @@ export async function generateImageStyle(mediaFileId, style) {
|
|
|
19
20
|
}
|
|
20
21
|
export async function generateImageStyleFromBuffer(buf, mediaFile, style) {
|
|
21
22
|
// Read EXIF orientation before processing
|
|
22
|
-
const metadata = await sharp(buf).metadata();
|
|
23
|
+
const metadata = await withTimeout(sharp(buf).metadata(), sharpTimeoutMs(), 'sharp.metadata');
|
|
23
24
|
// .rotate() applies EXIF orientation to pixels AND strips the tag from output.
|
|
24
25
|
// Prevents double-rotation in WebP/JPEG where EXIF orientation tag may persist.
|
|
25
26
|
let sharpInstance = sharp(buf).rotate();
|
|
@@ -79,7 +80,7 @@ export async function generateImageStyleFromBuffer(buf, mediaFile, style) {
|
|
|
79
80
|
const originalExt = mediaFile.mimeType?.split('/').pop() ?? mediaFile.url.split('.').pop();
|
|
80
81
|
const format = style.format ?? originalExt ?? 'jpeg';
|
|
81
82
|
sharpInstance = sharpInstance.toFormat(format, style.quality != null ? { quality: Math.max(1, Math.min(100, style.quality)) } : undefined);
|
|
82
|
-
const outputBuffer = await sharpInstance.toBuffer();
|
|
83
|
+
const outputBuffer = await withTimeout(sharpInstance.toBuffer(), sharpTimeoutMs(), 'sharp.toBuffer');
|
|
83
84
|
return getCMS().filesAdapter.uploadFile(new File([new Uint8Array(outputBuffer)], `${mediaFile.id}_${style.name}_${Date.now().toString(36)}.${format}`, {
|
|
84
85
|
type: `image/${format}`
|
|
85
86
|
}));
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { getCMS } from '../../../cms.js';
|
|
2
2
|
import { isProcessableImage } from '../../fields/utils/imageStyles.js';
|
|
3
3
|
import sharp from 'sharp';
|
|
4
|
+
import { withTimeout, sharpTimeoutMs } from '../../../../server/utils/withTimeout.js';
|
|
4
5
|
const THUMB_WIDTH = 400;
|
|
5
6
|
const THUMB_QUALITY = 70;
|
|
6
7
|
export async function generateAdminThumbnail(buffer, mediaFile) {
|
|
7
|
-
const output = await sharp(buffer)
|
|
8
|
+
const output = await withTimeout(sharp(buffer)
|
|
8
9
|
.rotate()
|
|
9
10
|
.resize(THUMB_WIDTH, undefined, { withoutEnlargement: true })
|
|
10
11
|
.toFormat('webp', { quality: THUMB_QUALITY })
|
|
11
|
-
.toBuffer();
|
|
12
|
+
.toBuffer(), sharpTimeoutMs(), 'sharp.adminThumbnail');
|
|
12
13
|
const filename = `${mediaFile.id}_admin_thumb_${Date.now().toString(36)}.webp`;
|
|
13
14
|
const uploaded = await getCMS().filesAdapter.uploadFile(new File([new Uint8Array(output)], filename, { type: 'image/webp' }));
|
|
14
15
|
return uploaded.url;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import sharp from 'sharp';
|
|
2
|
+
import { withTimeout, sharpTimeoutMs } from '../../../../server/utils/withTimeout.js';
|
|
2
3
|
export async function generateBlurDataUrl(buffer) {
|
|
3
|
-
const blurBuffer = await sharp(buffer).resize(20).blur(10).toFormat('webp').toBuffer();
|
|
4
|
+
const blurBuffer = await withTimeout(sharp(buffer).resize(20).blur(10).toFormat('webp').toBuffer(), sharpTimeoutMs(), 'sharp.blurDataUrl');
|
|
4
5
|
return `data:image/webp;base64,${blurBuffer.toString('base64')}`;
|
|
5
6
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface CspOptions {
|
|
2
|
+
scriptSrc?: string[];
|
|
3
|
+
styleSrc?: string[];
|
|
4
|
+
imgSrc?: string[];
|
|
5
|
+
mediaSrc?: string[];
|
|
6
|
+
fontSrc?: string[];
|
|
7
|
+
connectSrc?: string[];
|
|
8
|
+
frameAncestors?: string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Build a Content-Security-Policy header value with v1.0 defaults.
|
|
12
|
+
* `'unsafe-inline'` is allowed on `script-src`/`style-src` because TipTap and
|
|
13
|
+
* paraglide emit inline code; documented in `KNOWN-RISKS.md`.
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export declare function buildCspHeader(opts?: CspOptions): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const DEFAULTS = {
|
|
2
|
+
scriptSrc: ["'self'", "'unsafe-inline'"],
|
|
3
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
4
|
+
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
5
|
+
mediaSrc: ["'self'", 'blob:'],
|
|
6
|
+
fontSrc: ["'self'", 'data:'],
|
|
7
|
+
connectSrc: ["'self'"],
|
|
8
|
+
frameAncestors: ["'self'"]
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Build a Content-Security-Policy header value with v1.0 defaults.
|
|
12
|
+
* `'unsafe-inline'` is allowed on `script-src`/`style-src` because TipTap and
|
|
13
|
+
* paraglide emit inline code; documented in `KNOWN-RISKS.md`.
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export function buildCspHeader(opts = {}) {
|
|
17
|
+
const merge = (key, extra) => {
|
|
18
|
+
const base = DEFAULTS[key];
|
|
19
|
+
return extra && extra.length ? Array.from(new Set([...base, ...extra])) : [...base];
|
|
20
|
+
};
|
|
21
|
+
return [
|
|
22
|
+
`default-src 'self'`,
|
|
23
|
+
`script-src ${merge('scriptSrc', opts.scriptSrc).join(' ')}`,
|
|
24
|
+
`style-src ${merge('styleSrc', opts.styleSrc).join(' ')}`,
|
|
25
|
+
`img-src ${merge('imgSrc', opts.imgSrc).join(' ')}`,
|
|
26
|
+
`media-src ${merge('mediaSrc', opts.mediaSrc).join(' ')}`,
|
|
27
|
+
`font-src ${merge('fontSrc', opts.fontSrc).join(' ')}`,
|
|
28
|
+
`connect-src ${merge('connectSrc', opts.connectSrc).join(' ')}`,
|
|
29
|
+
`object-src 'none'`,
|
|
30
|
+
`base-uri 'self'`,
|
|
31
|
+
`frame-ancestors ${merge('frameAncestors', opts.frameAncestors).join(' ')}`
|
|
32
|
+
].join('; ');
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Handle, RequestEvent } from '@sveltejs/kit';
|
|
2
|
+
/**
|
|
3
|
+
* Returns true when a request is CSRF-safe: a non-mutating method, or a mutating
|
|
4
|
+
* method whose Origin/Referer matches the request URL origin (or the env allowlist).
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
export declare function isCsrfSafe(event: RequestEvent): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* SvelteKit handle that rejects mutating requests under `/admin/api/*` lacking a
|
|
10
|
+
* matching Origin/Referer header. Other paths and safe methods pass through.
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export declare const csrfGuard: Handle;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
2
|
+
function getAllowedOrigins() {
|
|
3
|
+
const env = process.env.INCLUDIO_CSRF_ALLOWED_ORIGINS ?? '';
|
|
4
|
+
return new Set(env
|
|
5
|
+
.split(',')
|
|
6
|
+
.map((s) => s.trim())
|
|
7
|
+
.filter(Boolean));
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Returns true when a request is CSRF-safe: a non-mutating method, or a mutating
|
|
11
|
+
* method whose Origin/Referer matches the request URL origin (or the env allowlist).
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export function isCsrfSafe(event) {
|
|
15
|
+
const method = event.request.method.toUpperCase();
|
|
16
|
+
if (!MUTATING_METHODS.has(method))
|
|
17
|
+
return true;
|
|
18
|
+
const expected = event.url.origin;
|
|
19
|
+
const allowed = getAllowedOrigins();
|
|
20
|
+
const origin = event.request.headers.get('origin');
|
|
21
|
+
if (origin)
|
|
22
|
+
return origin === expected || allowed.has(origin);
|
|
23
|
+
const referer = event.request.headers.get('referer');
|
|
24
|
+
if (referer) {
|
|
25
|
+
try {
|
|
26
|
+
const refOrigin = new URL(referer).origin;
|
|
27
|
+
return refOrigin === expected || allowed.has(refOrigin);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* SvelteKit handle that rejects mutating requests under `/admin/api/*` lacking a
|
|
37
|
+
* matching Origin/Referer header. Other paths and safe methods pass through.
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
export const csrfGuard = async ({ event, resolve }) => {
|
|
41
|
+
if (!event.url.pathname.startsWith('/admin/api/'))
|
|
42
|
+
return resolve(event);
|
|
43
|
+
if (isCsrfSafe(event))
|
|
44
|
+
return resolve(event);
|
|
45
|
+
return new Response(JSON.stringify({ error: 'csrf_rejected' }), {
|
|
46
|
+
status: 403,
|
|
47
|
+
headers: { 'content-type': 'application/json' }
|
|
48
|
+
});
|
|
49
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Handle, RequestEvent } from '@sveltejs/kit';
|
|
2
|
+
export interface RateLimitResult {
|
|
3
|
+
allowed: boolean;
|
|
4
|
+
remaining: number;
|
|
5
|
+
resetAt: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Pluggable storage for {@link rateLimitGuard}. The default implementation is
|
|
9
|
+
* in-memory; multi-node deploys should provide a Redis-backed store.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export interface RateLimitStore {
|
|
13
|
+
hit(key: string, windowMs: number, limit: number): Promise<RateLimitResult>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* In-memory {@link RateLimitStore}. Per-process; not safe across multiple nodes.
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export declare class MemoryRateLimitStore implements RateLimitStore {
|
|
20
|
+
private buckets;
|
|
21
|
+
private cleanupInterval;
|
|
22
|
+
constructor(opts?: {
|
|
23
|
+
cleanupMs?: number;
|
|
24
|
+
});
|
|
25
|
+
hit(key: string, windowMs: number, limit: number): Promise<RateLimitResult>;
|
|
26
|
+
private cleanup;
|
|
27
|
+
reset(key?: string): void;
|
|
28
|
+
dispose(): void;
|
|
29
|
+
}
|
|
30
|
+
export interface RateLimitGuardOptions {
|
|
31
|
+
store?: RateLimitStore;
|
|
32
|
+
limit?: number;
|
|
33
|
+
windowMs?: number;
|
|
34
|
+
pathPrefix?: string;
|
|
35
|
+
key?: (event: RequestEvent) => string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* SvelteKit handle that limits requests under `pathPrefix` (default `/admin/api/`)
|
|
39
|
+
* per key (default: authenticated user id, falling back to client IP). Returns
|
|
40
|
+
* 429 with `Retry-After` when exceeded. Defaults: 200 req / 60s, override via env
|
|
41
|
+
* `INCLUDIO_RATE_LIMIT_MAX` / `INCLUDIO_RATE_LIMIT_WINDOW_MS`.
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
44
|
+
export declare function rateLimitGuard(opts?: RateLimitGuardOptions): Handle;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { json } from '@sveltejs/kit';
|
|
2
|
+
/**
|
|
3
|
+
* In-memory {@link RateLimitStore}. Per-process; not safe across multiple nodes.
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
export class MemoryRateLimitStore {
|
|
7
|
+
buckets = new Map();
|
|
8
|
+
cleanupInterval;
|
|
9
|
+
constructor(opts = {}) {
|
|
10
|
+
const cleanupMs = opts.cleanupMs ?? 60_000;
|
|
11
|
+
if (cleanupMs > 0 && typeof setInterval === 'function') {
|
|
12
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), cleanupMs);
|
|
13
|
+
const i = this.cleanupInterval;
|
|
14
|
+
if (typeof i.unref === 'function')
|
|
15
|
+
i.unref();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async hit(key, windowMs, limit) {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
const bucket = this.buckets.get(key);
|
|
21
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
22
|
+
const fresh = { count: 1, resetAt: now + windowMs };
|
|
23
|
+
this.buckets.set(key, fresh);
|
|
24
|
+
return { allowed: true, remaining: Math.max(0, limit - 1), resetAt: fresh.resetAt };
|
|
25
|
+
}
|
|
26
|
+
if (bucket.count >= limit) {
|
|
27
|
+
return { allowed: false, remaining: 0, resetAt: bucket.resetAt };
|
|
28
|
+
}
|
|
29
|
+
bucket.count += 1;
|
|
30
|
+
return { allowed: true, remaining: limit - bucket.count, resetAt: bucket.resetAt };
|
|
31
|
+
}
|
|
32
|
+
cleanup() {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
for (const [k, b] of this.buckets) {
|
|
35
|
+
if (b.resetAt <= now)
|
|
36
|
+
this.buckets.delete(k);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
reset(key) {
|
|
40
|
+
if (key)
|
|
41
|
+
this.buckets.delete(key);
|
|
42
|
+
else
|
|
43
|
+
this.buckets.clear();
|
|
44
|
+
}
|
|
45
|
+
dispose() {
|
|
46
|
+
if (this.cleanupInterval)
|
|
47
|
+
clearInterval(this.cleanupInterval);
|
|
48
|
+
this.cleanupInterval = undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function num(env, fallback) {
|
|
52
|
+
const n = env ? Number(env) : NaN;
|
|
53
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* SvelteKit handle that limits requests under `pathPrefix` (default `/admin/api/`)
|
|
57
|
+
* per key (default: authenticated user id, falling back to client IP). Returns
|
|
58
|
+
* 429 with `Retry-After` when exceeded. Defaults: 200 req / 60s, override via env
|
|
59
|
+
* `INCLUDIO_RATE_LIMIT_MAX` / `INCLUDIO_RATE_LIMIT_WINDOW_MS`.
|
|
60
|
+
* @internal
|
|
61
|
+
*/
|
|
62
|
+
export function rateLimitGuard(opts = {}) {
|
|
63
|
+
const store = opts.store ?? new MemoryRateLimitStore();
|
|
64
|
+
const limit = opts.limit ?? num(process.env.INCLUDIO_RATE_LIMIT_MAX, 200);
|
|
65
|
+
const windowMs = opts.windowMs ?? num(process.env.INCLUDIO_RATE_LIMIT_WINDOW_MS, 60_000);
|
|
66
|
+
const pathPrefix = opts.pathPrefix ?? '/admin/api/';
|
|
67
|
+
const keyFn = opts.key ??
|
|
68
|
+
((event) => {
|
|
69
|
+
const userId = event.locals?.user?.id;
|
|
70
|
+
if (userId)
|
|
71
|
+
return `u:${userId}`;
|
|
72
|
+
try {
|
|
73
|
+
return `ip:${event.getClientAddress()}`;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return 'ip:unknown';
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return async ({ event, resolve }) => {
|
|
80
|
+
if (!event.url.pathname.startsWith(pathPrefix))
|
|
81
|
+
return resolve(event);
|
|
82
|
+
const result = await store.hit(keyFn(event), windowMs, limit);
|
|
83
|
+
if (!result.allowed) {
|
|
84
|
+
const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
|
|
85
|
+
return json({ error: 'rate_limited', resetAt: result.resetAt }, {
|
|
86
|
+
status: 429,
|
|
87
|
+
headers: {
|
|
88
|
+
'retry-after': String(retryAfter),
|
|
89
|
+
'x-ratelimit-limit': String(limit),
|
|
90
|
+
'x-ratelimit-remaining': '0',
|
|
91
|
+
'x-ratelimit-reset': String(Math.ceil(result.resetAt / 1000))
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return resolve(event);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Race a promise against a deadline. Resolves with the original promise's value
|
|
3
|
+
* if it settles before `ms`; otherwise rejects with {@link TimeoutError}.
|
|
4
|
+
* Cleans up the timer in both success and failure paths so it never holds the
|
|
5
|
+
* event loop.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export declare class TimeoutError extends Error {
|
|
9
|
+
readonly label: string;
|
|
10
|
+
readonly ms: number;
|
|
11
|
+
readonly name = "TimeoutError";
|
|
12
|
+
constructor(label: string, ms: number);
|
|
13
|
+
}
|
|
14
|
+
/** @internal */
|
|
15
|
+
export declare function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T>;
|
|
16
|
+
/**
|
|
17
|
+
* Resolved timeout (ms) for sharp operations. Override via `INCLUDIO_SHARP_TIMEOUT_MS`.
|
|
18
|
+
* Documented in `KNOWN-RISKS.md` §4.
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export declare function sharpTimeoutMs(): number;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Race a promise against a deadline. Resolves with the original promise's value
|
|
3
|
+
* if it settles before `ms`; otherwise rejects with {@link TimeoutError}.
|
|
4
|
+
* Cleans up the timer in both success and failure paths so it never holds the
|
|
5
|
+
* event loop.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export class TimeoutError extends Error {
|
|
9
|
+
label;
|
|
10
|
+
ms;
|
|
11
|
+
name = 'TimeoutError';
|
|
12
|
+
constructor(label, ms) {
|
|
13
|
+
super(`${label} timed out after ${ms}ms`);
|
|
14
|
+
this.label = label;
|
|
15
|
+
this.ms = ms;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** @internal */
|
|
19
|
+
export function withTimeout(promise, ms, label) {
|
|
20
|
+
let timer;
|
|
21
|
+
const timeout = new Promise((_, reject) => {
|
|
22
|
+
timer = setTimeout(() => reject(new TimeoutError(label, ms)), ms);
|
|
23
|
+
});
|
|
24
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
25
|
+
if (timer)
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolved timeout (ms) for sharp operations. Override via `INCLUDIO_SHARP_TIMEOUT_MS`.
|
|
31
|
+
* Documented in `KNOWN-RISKS.md` §4.
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export function sharpTimeoutMs() {
|
|
35
|
+
const n = Number(process.env.INCLUDIO_SHARP_TIMEOUT_MS);
|
|
36
|
+
return Number.isFinite(n) && n > 0 ? n : 30_000;
|
|
37
|
+
}
|
|
@@ -4,6 +4,9 @@ import { getCMS, initCMS } from '../../core/cms.js';
|
|
|
4
4
|
import { generateRuntime } from '../../core/server/generator/generator.js';
|
|
5
5
|
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
|
6
6
|
import { building } from '$app/environment';
|
|
7
|
+
import { csrfGuard } from '../../server/security/csrf.js';
|
|
8
|
+
import { rateLimitGuard } from '../../server/security/rate-limit.js';
|
|
9
|
+
import { buildCspHeader } from '../../server/security/csp.js';
|
|
7
10
|
const adminGuard = async ({ event, resolve }) => {
|
|
8
11
|
const { user, session } = event.locals;
|
|
9
12
|
// Secure the admin routes
|
|
@@ -74,17 +77,21 @@ export function includioCMS(cmsConfig) {
|
|
|
74
77
|
initCMS(cmsConfig);
|
|
75
78
|
const handles = [];
|
|
76
79
|
handles.push(detectBrowserContext);
|
|
80
|
+
handles.push(csrfGuard);
|
|
77
81
|
if (cmsConfig.auth) {
|
|
78
82
|
handles.push(handleAuth);
|
|
79
83
|
}
|
|
84
|
+
handles.push(rateLimitGuard());
|
|
80
85
|
handles.push(adminGuard);
|
|
81
86
|
handles.push(securityHeaders);
|
|
82
87
|
return handles;
|
|
83
88
|
}
|
|
89
|
+
const CSP_VALUE = buildCspHeader();
|
|
84
90
|
const securityHeaders = async ({ event, resolve }) => {
|
|
85
91
|
const response = await resolve(event);
|
|
86
92
|
response.headers.set('X-Content-Type-Options', 'nosniff');
|
|
87
93
|
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
|
|
88
94
|
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
95
|
+
response.headers.set('Content-Security-Policy', CSP_VALUE);
|
|
89
96
|
return response;
|
|
90
97
|
};
|
|
@@ -6,3 +6,4 @@ export { parseFormDataForSubmission } from '../../core/server/forms/submissions/
|
|
|
6
6
|
export { createConsentLog } from '../../core/server/consentLogs/operations/create.js';
|
|
7
7
|
export { getPreviewEntry } from './preview.js';
|
|
8
8
|
export { createRestApiHandler } from '../../admin/api/rest/handler.js';
|
|
9
|
+
export { generateApiKey } from '../../admin/api/rest/middleware/generateApiKey.js';
|
|
@@ -7,3 +7,4 @@ export { createConsentLog } from '../../core/server/consentLogs/operations/creat
|
|
|
7
7
|
export { getPreviewEntry } from './preview.js';
|
|
8
8
|
// Folded from `./admin/api/rest/handler` (dropped as separate export in 0.20.0)
|
|
9
9
|
export { createRestApiHandler } from '../../admin/api/rest/handler.js';
|
|
10
|
+
export { generateApiKey } from '../../admin/api/rest/middleware/generateApiKey.js';
|
package/dist/types/cms.d.ts
CHANGED
|
@@ -60,6 +60,10 @@ export interface ApiKeyConfig {
|
|
|
60
60
|
key: string;
|
|
61
61
|
name?: string;
|
|
62
62
|
role?: 'admin' | 'editor';
|
|
63
|
+
/** Opt-in expiry. ISO-8601 timestamp; past dates → 401 on use. */
|
|
64
|
+
expiresAt?: string;
|
|
65
|
+
/** Audit-trail only; not enforced. ISO-8601 timestamp of last manual rotation. */
|
|
66
|
+
rotatedAt?: string;
|
|
63
67
|
}
|
|
64
68
|
export interface TypographyConfig {
|
|
65
69
|
fixOrphans?: boolean;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.21.0',
|
|
3
|
+
date: '2026-04-30',
|
|
4
|
+
description: 'Faza 5 część 2 — security finish: form rate-limit DRY refactor, API keys `expiresAt` / `rotatedAt`, sharp timeout 30s, `{@html}` audit close. `KNOWN-RISKS.md` w root jako single source of truth dla zaakceptowanych ryzyk v1.0. Plus: faza 6 setup — vitest coverage (info-only), docker test profile, integration test scaffolding (`tests/`).',
|
|
5
|
+
features: [
|
|
6
|
+
'`KNOWN-RISKS.md` w root paczki — 5 ryzyk udokumentowanych: CSP `\'unsafe-inline\'`, API key rotation opt-in, in-memory rate-limit, sharp 30s timeout, ffmpeg/sharp args audit. Każde z mitygacją i triggerem fix.',
|
|
7
|
+
'`ApiKeyConfig.expiresAt?: string` (ISO-8601) — opt-in expiry, enforced w `validateApiKey()`. Wygasły klucz → 401 generic (no leak). Brak `expiresAt` = klucz nigdy nie wygasa (backward compat).',
|
|
8
|
+
'`ApiKeyConfig.rotatedAt?: string` — info-only audit-trail, nie enforced.',
|
|
9
|
+
'`generateApiKey()` (`includio-cms/sveltekit/server`) — crypto-random 32B base64url. Pełna rotacja = update `cms.config.ts` + redeploy (statyczny model, patrz KNOWN-RISKS §2).',
|
|
10
|
+
'`withTimeout<T>(promise, ms, label)` + `TimeoutError` (`$lib/server/utils/withTimeout`). Wrap dookoła wszystkich sharp calls (metadata, toBuffer, blur, admin thumbnail, downscale resize). Default 30s, env override `INCLUDIO_SHARP_TIMEOUT_MS`.',
|
|
11
|
+
'Form submit rate-limit: refaktor `src/routes/api/forms/[slug]/submit/+server.ts` — używa shared `MemoryRateLimitStore` z `$lib/server/security/rate-limit.js` (DRY z `/admin/api/*` rate-limit). Limity 5/h per IP zachowane. Env: `INCLUDIO_FORM_RATE_LIMIT_MAX`, `INCLUDIO_FORM_RATE_LIMIT_WINDOW_MS`.',
|
|
12
|
+
'Faza 6 setup: docker `test` profile (`db_test` na porcie 5434, tmpfs, `fsync=off`), `tests/helpers/{db,api,auth}.ts`, `tests/setup.ts` z TRUNCATE strategy, vitest project `integration` (sequential, `singleFork`), sanity test `tests/integration/db-sanity.spec.ts`.',
|
|
13
|
+
'Vitest coverage config (info-only, bez threshold) — `pnpm test:coverage` generuje `coverage/`. Reporter: text/json/html. Devdep: `@vitest/coverage-v8`.'
|
|
14
|
+
],
|
|
15
|
+
fixes: [
|
|
16
|
+
'`{@html}` w `src/lib/admin/client/users/delete-user-dialog.svelte` (linie 85, 93) zastąpione safe Svelte template `{...}<strong>{...}</strong>{...}`. Defense-in-depth — content był admin-controlled, ale eliminuje wektor regresji XSS gdyby ktoś kiedyś dodał user-supplied input do tych komunikatów.'
|
|
17
|
+
],
|
|
18
|
+
breakingChanges: [
|
|
19
|
+
'Brak hard breakages. `ApiKeyConfig.expiresAt` / `rotatedAt` są opcjonalne — istniejące configi działają bez zmian.',
|
|
20
|
+
'`usersLang.deleteWarningDesc` zmienia sygnaturę z `(name: string) => string` na statyczny obiekt `{ before: string; after: string }`. `usersLang.deleteConfirmType` analogicznie ze stringa na `{ before: string; after: string }`. Wpływ tylko na fork\'i admin UI z customowym lang — szybki `pnpm check` zwróci błąd typu, fix = przepisz wartości w lang.'
|
|
21
|
+
],
|
|
22
|
+
notes: `## Setup integration tests (opt-in)
|
|
23
|
+
|
|
24
|
+
\`\`\`bash
|
|
25
|
+
docker compose --profile test up -d db_test
|
|
26
|
+
pnpm prepack # buduje dist/ wymagany przez drizzle-kit push schema
|
|
27
|
+
pnpm db:test:migrate # drizzle-kit push do test DB
|
|
28
|
+
pnpm test:integration # uruchamia tests/integration/**
|
|
29
|
+
docker compose --profile test down
|
|
30
|
+
\`\`\`
|
|
31
|
+
|
|
32
|
+
## API key expiry (opt-in)
|
|
33
|
+
|
|
34
|
+
\`\`\`ts
|
|
35
|
+
// cms.config.ts
|
|
36
|
+
apiKeys: [
|
|
37
|
+
{ key: 'sk-...', name: 'ci', role: 'admin', expiresAt: '2027-01-01T00:00:00Z' }
|
|
38
|
+
]
|
|
39
|
+
\`\`\`
|
|
40
|
+
|
|
41
|
+
Generowanie nowego klucza:
|
|
42
|
+
|
|
43
|
+
\`\`\`ts
|
|
44
|
+
import { generateApiKey } from 'includio-cms/sveltekit/server';
|
|
45
|
+
console.log(generateApiKey()); // → 43-char base64url, 32B entropii
|
|
46
|
+
\`\`\`
|
|
47
|
+
|
|
48
|
+
Pełna rotacja wymaga update'u \`cms.config.ts\` + redeploy. Pole \`rotatedAt\` jest info-only (audit trail) — admin ustawia ręcznie przy rotacji.
|
|
49
|
+
|
|
50
|
+
## Known risks
|
|
51
|
+
|
|
52
|
+
Lista zaakceptowanych ryzyk v1.0 — \`KNOWN-RISKS.md\` w root paczki. 5 sekcji: CSP \`unsafe-inline\`, API key rotation opt-in, in-memory rate-limit (multi-node = wymaga Redis adapter), sharp 30s timeout, ffmpeg/sharp shell audit (SAFE).
|
|
53
|
+
|
|
54
|
+
Brak SQL migration.`
|
|
55
|
+
};
|
package/dist/updates/index.js
CHANGED
|
@@ -54,7 +54,8 @@ import { update as update0160 } from './0.16.0/index.js';
|
|
|
54
54
|
import { update as update0180 } from './0.18.0/index.js';
|
|
55
55
|
import { update as update0190 } from './0.19.0/index.js';
|
|
56
56
|
import { update as update0200 } from './0.20.0/index.js';
|
|
57
|
-
|
|
57
|
+
import { update as update0210 } from './0.21.0/index.js';
|
|
58
|
+
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, update0160, update0180, update0190, update0200, update0210];
|
|
58
59
|
export const getUpdatesFrom = (fromVersion) => {
|
|
59
60
|
const fromParts = fromVersion.split('.').map(Number);
|
|
60
61
|
return updates.filter((update) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "includio-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "vite dev",
|
|
6
6
|
"build": "vite build && npm run prepack",
|
|
@@ -14,10 +14,16 @@
|
|
|
14
14
|
"test:unit": "vitest",
|
|
15
15
|
"test": "npm run test:unit -- --run && npm run test:e2e",
|
|
16
16
|
"test:e2e": "playwright test",
|
|
17
|
+
"test:coverage": "vitest run --coverage",
|
|
18
|
+
"pretest:integration": "pnpm prepack",
|
|
19
|
+
"test:integration": "TEST_DATABASE_URL=postgres://root:mysecretpassword@localhost:5434/test vitest run --project=integration",
|
|
17
20
|
"db:start": "docker compose up",
|
|
18
21
|
"db:push": "drizzle-kit push",
|
|
19
22
|
"db:migrate": "drizzle-kit migrate",
|
|
20
23
|
"db:studio": "drizzle-kit studio",
|
|
24
|
+
"db:test:up": "docker compose --profile test up -d db_test",
|
|
25
|
+
"db:test:down": "docker compose --profile test down db_test",
|
|
26
|
+
"db:test:migrate": "DATABASE_URL=postgres://root:mysecretpassword@localhost:5434/test drizzle-kit push --force",
|
|
21
27
|
"storybook": "storybook dev -p 6006",
|
|
22
28
|
"build-storybook": "storybook build",
|
|
23
29
|
"create:user": "tsx cli/addUser/index.ts",
|
|
@@ -229,7 +235,8 @@
|
|
|
229
235
|
"typescript": "^5.0.0",
|
|
230
236
|
"typescript-eslint": "^8.20.0",
|
|
231
237
|
"vite": "^7.2.2",
|
|
232
|
-
"vitest": "^3.2.3"
|
|
238
|
+
"vitest": "^3.2.3",
|
|
239
|
+
"@vitest/coverage-v8": "^3.2.3"
|
|
233
240
|
},
|
|
234
241
|
"keywords": [
|
|
235
242
|
"svelte"
|