create-nextblock 0.14.0 → 0.14.2
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/docker-template/.dockerignore +23 -23
- package/docker-template/.env.docker.example +69 -69
- package/docker-template/docker/db/init/99-jwt.sql +6 -6
- package/docker-template/docker/db/init/99-roles.sql +25 -25
- package/docker-template/docker/kong/kong.yml +112 -112
- package/docker-template/scripts/docker-setup.mjs +310 -310
- package/libs/db/tsconfig.lib.json +3 -3
- package/libs/editor/tsconfig.lib.json +3 -3
- package/libs/ui/tsconfig.lib.json +3 -3
- package/libs/utils/tsconfig.json +3 -3
- package/package.json +1 -1
- package/project.json +19 -19
- package/templates/nextblock-template/.browserslistrc +11 -11
- package/templates/nextblock-template/.dockerignore +23 -23
- package/templates/nextblock-template/README.md +34 -34
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
- package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +46 -46
- package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -51
- package/templates/nextblock-template/app/ToasterProvider.tsx +17 -17
- package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
- package/templates/nextblock-template/app/actions/consent.ts +57 -57
- package/templates/nextblock-template/app/actions/postActions.ts +146 -146
- package/templates/nextblock-template/app/actions/twoFactorEmail.ts +22 -22
- package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -153
- package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -96
- package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -48
- package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -44
- package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -33
- package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -63
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3490 -3490
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +5749 -5749
- package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +39 -39
- package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -92
- package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -43
- package/templates/nextblock-template/app/api/media/library/route.ts +69 -69
- package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -53
- package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
- package/templates/nextblock-template/app/api/search/route.ts +43 -43
- package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +441 -441
- package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
- package/templates/nextblock-template/app/cart/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
- package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +4 -2
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +90 -90
- package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
- package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
- package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
- package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
- package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
- package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
- package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
- package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
- package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
- package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
- package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
- package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
- package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
- package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
- package/templates/nextblock-template/app/cms/layout.tsx +73 -73
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
- package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
- package/templates/nextblock-template/app/cms/products/new/page.tsx +135 -135
- package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
- package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
- package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
- package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
- package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
- package/templates/nextblock-template/app/cms/revisions/service.ts +19 -19
- package/templates/nextblock-template/app/cms/revisions/utils.ts +132 -132
- package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
- package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
- package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
- package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +688 -688
- package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
- package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
- package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -28
- package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
- package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
- package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -21
- package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +23 -21
- package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -42
- package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -133
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
- package/templates/nextblock-template/app/cms/settings/packages/package-card.tsx +122 -122
- package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +27 -27
- package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
- package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +9 -7
- package/templates/nextblock-template/app/cms/settings/security/page.tsx +33 -33
- package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
- package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
- package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
- package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
- package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
- package/templates/nextblock-template/app/profile/account-links.ts +22 -22
- package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
- package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
- package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
- package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
- package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
- package/templates/nextblock-template/app/setup/SetupWizard.tsx +678 -678
- package/templates/nextblock-template/app/setup/layout.tsx +13 -13
- package/templates/nextblock-template/app/setup/page.tsx +111 -111
- package/templates/nextblock-template/app/sitemap.ts +130 -130
- package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
- package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
- package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
- package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
- package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
- package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
- package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
- package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
- package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
- package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
- package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +249 -249
- package/templates/nextblock-template/components/blocks/types.ts +7 -7
- package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
- package/templates/nextblock-template/components/form-message.tsx +32 -32
- package/templates/nextblock-template/components/media/YouTubeFacade.tsx +105 -105
- package/templates/nextblock-template/components/media/youtube-embed-replace.tsx +32 -32
- package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
- package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
- package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
- package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
- package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +471 -471
- package/templates/nextblock-template/components/submit-button.tsx +23 -23
- package/templates/nextblock-template/components/theme-switcher.tsx +8 -8
- package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
- package/templates/nextblock-template/context/AuthContext.tsx +23 -23
- package/templates/nextblock-template/context/language-rest-client.ts +32 -32
- package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
- package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
- package/templates/nextblock-template/docker/kong/kong.yml +112 -112
- package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
- package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +202 -202
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +246 -246
- package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +176 -176
- package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
- package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
- package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -173
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +170 -170
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +151 -151
- package/templates/nextblock-template/docs/README.md +39 -39
- package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
- package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
- package/templates/nextblock-template/index.d.ts +7 -7
- package/templates/nextblock-template/lib/app-secrets.ts +39 -39
- package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
- package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
- package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
- package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
- package/templates/nextblock-template/lib/blocks/README.md +13 -13
- package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
- package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -2243
- package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
- package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
- package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
- package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
- package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
- package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
- package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
- package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
- package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
- package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
- package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
- package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
- package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
- package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
- package/templates/nextblock-template/lib/email/branding.ts +76 -76
- package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
- package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
- package/templates/nextblock-template/lib/full-backup/server.ts +743 -743
- package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
- package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +54 -54
- package/templates/nextblock-template/lib/media/youtube.ts +99 -99
- package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
- package/templates/nextblock-template/lib/onboarding/status.ts +222 -222
- package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
- package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
- package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
- package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
- package/templates/nextblock-template/lib/privacy/types.ts +69 -69
- package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
- package/templates/nextblock-template/lib/promotions/server.ts +741 -741
- package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
- package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
- package/templates/nextblock-template/lib/search/types.ts +27 -27
- package/templates/nextblock-template/lib/setup/actions.ts +460 -460
- package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
- package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +87 -87
- package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
- package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
- package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
- package/templates/nextblock-template/lib/setup/types.ts +18 -18
- package/templates/nextblock-template/lib/site-url.ts +48 -48
- package/templates/nextblock-template/lib/storage/provider.ts +66 -66
- package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
- package/templates/nextblock-template/lib/updates/check-upstream.ts +441 -441
- package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
- package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
- package/templates/nextblock-template/next-env.d.ts +1 -0
- package/templates/nextblock-template/package.json +6 -6
- package/templates/nextblock-template/postcss.config.js +6 -6
- package/templates/nextblock-template/scripts/backup.js +115 -115
- package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
- package/templates/nextblock-template/scripts/restore.js +385 -385
- package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
- package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
- package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
- package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
- package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
- package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
- package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
- package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
- package/templates/nextblock-template/tailwind.config.js +25 -25
- package/templates/nextblock-template/tools/build-migrate.mjs +209 -209
- package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
- package/templates/nextblock-template/types/jsdom.d.ts +6 -6
- package/tsconfig.base.json +3 -3
|
@@ -1,178 +1,178 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
export const R2_PRESIGNED_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
|
4
|
-
export const R2_PRESIGNED_UPLOAD_EXPIRES_IN_SECONDS = 300;
|
|
5
|
-
export const R2_PRESIGNED_UPLOAD_DEFAULT_FOLDER = 'custom-blocks';
|
|
6
|
-
|
|
7
|
-
export const R2_PRESIGNED_UPLOAD_ALLOWED_CONTENT_TYPES = [
|
|
8
|
-
'image/avif',
|
|
9
|
-
'image/gif',
|
|
10
|
-
'image/jpeg',
|
|
11
|
-
'image/png',
|
|
12
|
-
'image/webp',
|
|
13
|
-
] as const;
|
|
14
|
-
|
|
15
|
-
const EXTENSION_BY_CONTENT_TYPE: Record<
|
|
16
|
-
(typeof R2_PRESIGNED_UPLOAD_ALLOWED_CONTENT_TYPES)[number],
|
|
17
|
-
string
|
|
18
|
-
> = {
|
|
19
|
-
'image/avif': 'avif',
|
|
20
|
-
'image/gif': 'gif',
|
|
21
|
-
'image/jpeg': 'jpg',
|
|
22
|
-
'image/png': 'png',
|
|
23
|
-
'image/webp': 'webp',
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
type R2PresignedUploadPayload = {
|
|
27
|
-
contentType?: unknown;
|
|
28
|
-
filename?: unknown;
|
|
29
|
-
folder?: unknown;
|
|
30
|
-
size?: unknown;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export type ValidR2PresignedUploadPayload = {
|
|
34
|
-
contentType: (typeof R2_PRESIGNED_UPLOAD_ALLOWED_CONTENT_TYPES)[number];
|
|
35
|
-
filename: string;
|
|
36
|
-
folder: string;
|
|
37
|
-
size: number;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export type R2PresignedUploadResult = {
|
|
41
|
-
expiresIn: number;
|
|
42
|
-
headers: Record<string, string>;
|
|
43
|
-
method: 'PUT';
|
|
44
|
-
objectKey: string;
|
|
45
|
-
presignedUrl: string;
|
|
46
|
-
publicUrl: string;
|
|
47
|
-
uploadUrl: string;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export class R2PresignedUploadError extends Error {
|
|
51
|
-
constructor(message: string, public readonly status = 400) {
|
|
52
|
-
super(message);
|
|
53
|
-
this.name = 'R2PresignedUploadError';
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function isAllowedContentType(
|
|
58
|
-
contentType: string
|
|
59
|
-
): contentType is ValidR2PresignedUploadPayload['contentType'] {
|
|
60
|
-
return R2_PRESIGNED_UPLOAD_ALLOWED_CONTENT_TYPES.includes(
|
|
61
|
-
contentType as ValidR2PresignedUploadPayload['contentType']
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function sanitizeR2UploadFolder(input?: unknown) {
|
|
66
|
-
const raw = typeof input === 'string' ? input.trim() : '';
|
|
67
|
-
const fallback = R2_PRESIGNED_UPLOAD_DEFAULT_FOLDER;
|
|
68
|
-
const normalized = (raw || fallback).replace(/\\/g, '/').replace(/^\/+/, '');
|
|
69
|
-
const segments = normalized
|
|
70
|
-
.split('/')
|
|
71
|
-
.map((segment) =>
|
|
72
|
-
segment
|
|
73
|
-
.trim()
|
|
74
|
-
.replace(/\.{2,}/g, '')
|
|
75
|
-
.replace(/[^a-zA-Z0-9_.-]+/g, '-')
|
|
76
|
-
.replace(/^-+|-+$/g, '')
|
|
77
|
-
)
|
|
78
|
-
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
|
79
|
-
|
|
80
|
-
const folder = segments.join('/') || fallback;
|
|
81
|
-
return folder.endsWith('/') ? folder : `${folder}/`;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function sanitizeFilenameBase(filename: string) {
|
|
85
|
-
const lastDotIndex = filename.lastIndexOf('.');
|
|
86
|
-
const base = lastDotIndex > 0 ? filename.slice(0, lastDotIndex) : filename;
|
|
87
|
-
const sanitized = base
|
|
88
|
-
.toLowerCase()
|
|
89
|
-
.replace(/\s+/g, '-')
|
|
90
|
-
.replace(/[^a-z0-9_.-]+/g, '')
|
|
91
|
-
.replace(/^-+|-+$/g, '')
|
|
92
|
-
.slice(0, 120);
|
|
93
|
-
|
|
94
|
-
return sanitized || 'asset';
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function getFileExtension(
|
|
98
|
-
filename: string,
|
|
99
|
-
contentType: ValidR2PresignedUploadPayload['contentType']
|
|
100
|
-
) {
|
|
101
|
-
const lastDotIndex = filename.lastIndexOf('.');
|
|
102
|
-
const extension = lastDotIndex > 0 ? filename.slice(lastDotIndex + 1).toLowerCase() : '';
|
|
103
|
-
const normalizedExtension = extension.replace(/[^a-z0-9]+/g, '');
|
|
104
|
-
const expectedExtension = EXTENSION_BY_CONTENT_TYPE[contentType];
|
|
105
|
-
|
|
106
|
-
if (!normalizedExtension) {
|
|
107
|
-
return expectedExtension;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (['jpeg', 'jpg'].includes(normalizedExtension) && contentType === 'image/jpeg') {
|
|
111
|
-
return normalizedExtension;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return normalizedExtension === expectedExtension ? normalizedExtension : expectedExtension;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function formatUploadTimestamp(date: Date) {
|
|
118
|
-
const parts = [
|
|
119
|
-
date.getUTCFullYear(),
|
|
120
|
-
`${date.getUTCMonth() + 1}`.padStart(2, '0'),
|
|
121
|
-
`${date.getUTCDate()}`.padStart(2, '0'),
|
|
122
|
-
`${date.getUTCHours()}`.padStart(2, '0'),
|
|
123
|
-
`${date.getUTCMinutes()}`.padStart(2, '0'),
|
|
124
|
-
`${date.getUTCSeconds()}`.padStart(2, '0'),
|
|
125
|
-
`${date.getUTCMilliseconds()}`.padStart(3, '0'),
|
|
126
|
-
];
|
|
127
|
-
|
|
128
|
-
return parts.join('');
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function validateR2PresignedUploadPayload(
|
|
132
|
-
payload: R2PresignedUploadPayload
|
|
133
|
-
): ValidR2PresignedUploadPayload {
|
|
134
|
-
const filename = typeof payload.filename === 'string' ? payload.filename.trim() : '';
|
|
135
|
-
const contentType =
|
|
136
|
-
typeof payload.contentType === 'string' ? payload.contentType.trim().toLowerCase() : '';
|
|
137
|
-
const size =
|
|
138
|
-
typeof payload.size === 'number'
|
|
139
|
-
? payload.size
|
|
140
|
-
: typeof payload.size === 'string'
|
|
141
|
-
? Number(payload.size)
|
|
142
|
-
: Number.NaN;
|
|
143
|
-
|
|
144
|
-
if (!filename || filename.length > 255) {
|
|
145
|
-
throw new R2PresignedUploadError('A valid filename is required.');
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (!isAllowedContentType(contentType)) {
|
|
149
|
-
throw new R2PresignedUploadError('Only AVIF, GIF, JPEG, PNG, and WebP images are supported.');
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (!Number.isInteger(size) || size <= 0) {
|
|
153
|
-
throw new R2PresignedUploadError('A positive file size is required.');
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (size > R2_PRESIGNED_UPLOAD_MAX_BYTES) {
|
|
157
|
-
throw new R2PresignedUploadError('Image uploads are limited to 10 MB.');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
contentType,
|
|
162
|
-
filename,
|
|
163
|
-
folder: sanitizeR2UploadFolder(payload.folder),
|
|
164
|
-
size,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export function buildR2UploadObjectKey(
|
|
169
|
-
payload: ValidR2PresignedUploadPayload,
|
|
170
|
-
options: { now?: Date; nonce?: string } = {}
|
|
171
|
-
) {
|
|
172
|
-
const base = sanitizeFilenameBase(payload.filename);
|
|
173
|
-
const extension = getFileExtension(payload.filename, payload.contentType);
|
|
174
|
-
const timestamp = formatUploadTimestamp(options.now ?? new Date());
|
|
175
|
-
const nonce = (options.nonce ?? randomUUID().slice(0, 8)).replace(/[^a-zA-Z0-9-]/g, '');
|
|
176
|
-
|
|
177
|
-
return `${payload.folder}${base}-${timestamp}-${nonce}.${extension}`;
|
|
178
|
-
}
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const R2_PRESIGNED_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
|
4
|
+
export const R2_PRESIGNED_UPLOAD_EXPIRES_IN_SECONDS = 300;
|
|
5
|
+
export const R2_PRESIGNED_UPLOAD_DEFAULT_FOLDER = 'custom-blocks';
|
|
6
|
+
|
|
7
|
+
export const R2_PRESIGNED_UPLOAD_ALLOWED_CONTENT_TYPES = [
|
|
8
|
+
'image/avif',
|
|
9
|
+
'image/gif',
|
|
10
|
+
'image/jpeg',
|
|
11
|
+
'image/png',
|
|
12
|
+
'image/webp',
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
const EXTENSION_BY_CONTENT_TYPE: Record<
|
|
16
|
+
(typeof R2_PRESIGNED_UPLOAD_ALLOWED_CONTENT_TYPES)[number],
|
|
17
|
+
string
|
|
18
|
+
> = {
|
|
19
|
+
'image/avif': 'avif',
|
|
20
|
+
'image/gif': 'gif',
|
|
21
|
+
'image/jpeg': 'jpg',
|
|
22
|
+
'image/png': 'png',
|
|
23
|
+
'image/webp': 'webp',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type R2PresignedUploadPayload = {
|
|
27
|
+
contentType?: unknown;
|
|
28
|
+
filename?: unknown;
|
|
29
|
+
folder?: unknown;
|
|
30
|
+
size?: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ValidR2PresignedUploadPayload = {
|
|
34
|
+
contentType: (typeof R2_PRESIGNED_UPLOAD_ALLOWED_CONTENT_TYPES)[number];
|
|
35
|
+
filename: string;
|
|
36
|
+
folder: string;
|
|
37
|
+
size: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type R2PresignedUploadResult = {
|
|
41
|
+
expiresIn: number;
|
|
42
|
+
headers: Record<string, string>;
|
|
43
|
+
method: 'PUT';
|
|
44
|
+
objectKey: string;
|
|
45
|
+
presignedUrl: string;
|
|
46
|
+
publicUrl: string;
|
|
47
|
+
uploadUrl: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export class R2PresignedUploadError extends Error {
|
|
51
|
+
constructor(message: string, public readonly status = 400) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'R2PresignedUploadError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isAllowedContentType(
|
|
58
|
+
contentType: string
|
|
59
|
+
): contentType is ValidR2PresignedUploadPayload['contentType'] {
|
|
60
|
+
return R2_PRESIGNED_UPLOAD_ALLOWED_CONTENT_TYPES.includes(
|
|
61
|
+
contentType as ValidR2PresignedUploadPayload['contentType']
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sanitizeR2UploadFolder(input?: unknown) {
|
|
66
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
67
|
+
const fallback = R2_PRESIGNED_UPLOAD_DEFAULT_FOLDER;
|
|
68
|
+
const normalized = (raw || fallback).replace(/\\/g, '/').replace(/^\/+/, '');
|
|
69
|
+
const segments = normalized
|
|
70
|
+
.split('/')
|
|
71
|
+
.map((segment) =>
|
|
72
|
+
segment
|
|
73
|
+
.trim()
|
|
74
|
+
.replace(/\.{2,}/g, '')
|
|
75
|
+
.replace(/[^a-zA-Z0-9_.-]+/g, '-')
|
|
76
|
+
.replace(/^-+|-+$/g, '')
|
|
77
|
+
)
|
|
78
|
+
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
|
79
|
+
|
|
80
|
+
const folder = segments.join('/') || fallback;
|
|
81
|
+
return folder.endsWith('/') ? folder : `${folder}/`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sanitizeFilenameBase(filename: string) {
|
|
85
|
+
const lastDotIndex = filename.lastIndexOf('.');
|
|
86
|
+
const base = lastDotIndex > 0 ? filename.slice(0, lastDotIndex) : filename;
|
|
87
|
+
const sanitized = base
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.replace(/\s+/g, '-')
|
|
90
|
+
.replace(/[^a-z0-9_.-]+/g, '')
|
|
91
|
+
.replace(/^-+|-+$/g, '')
|
|
92
|
+
.slice(0, 120);
|
|
93
|
+
|
|
94
|
+
return sanitized || 'asset';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getFileExtension(
|
|
98
|
+
filename: string,
|
|
99
|
+
contentType: ValidR2PresignedUploadPayload['contentType']
|
|
100
|
+
) {
|
|
101
|
+
const lastDotIndex = filename.lastIndexOf('.');
|
|
102
|
+
const extension = lastDotIndex > 0 ? filename.slice(lastDotIndex + 1).toLowerCase() : '';
|
|
103
|
+
const normalizedExtension = extension.replace(/[^a-z0-9]+/g, '');
|
|
104
|
+
const expectedExtension = EXTENSION_BY_CONTENT_TYPE[contentType];
|
|
105
|
+
|
|
106
|
+
if (!normalizedExtension) {
|
|
107
|
+
return expectedExtension;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (['jpeg', 'jpg'].includes(normalizedExtension) && contentType === 'image/jpeg') {
|
|
111
|
+
return normalizedExtension;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return normalizedExtension === expectedExtension ? normalizedExtension : expectedExtension;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatUploadTimestamp(date: Date) {
|
|
118
|
+
const parts = [
|
|
119
|
+
date.getUTCFullYear(),
|
|
120
|
+
`${date.getUTCMonth() + 1}`.padStart(2, '0'),
|
|
121
|
+
`${date.getUTCDate()}`.padStart(2, '0'),
|
|
122
|
+
`${date.getUTCHours()}`.padStart(2, '0'),
|
|
123
|
+
`${date.getUTCMinutes()}`.padStart(2, '0'),
|
|
124
|
+
`${date.getUTCSeconds()}`.padStart(2, '0'),
|
|
125
|
+
`${date.getUTCMilliseconds()}`.padStart(3, '0'),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
return parts.join('');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function validateR2PresignedUploadPayload(
|
|
132
|
+
payload: R2PresignedUploadPayload
|
|
133
|
+
): ValidR2PresignedUploadPayload {
|
|
134
|
+
const filename = typeof payload.filename === 'string' ? payload.filename.trim() : '';
|
|
135
|
+
const contentType =
|
|
136
|
+
typeof payload.contentType === 'string' ? payload.contentType.trim().toLowerCase() : '';
|
|
137
|
+
const size =
|
|
138
|
+
typeof payload.size === 'number'
|
|
139
|
+
? payload.size
|
|
140
|
+
: typeof payload.size === 'string'
|
|
141
|
+
? Number(payload.size)
|
|
142
|
+
: Number.NaN;
|
|
143
|
+
|
|
144
|
+
if (!filename || filename.length > 255) {
|
|
145
|
+
throw new R2PresignedUploadError('A valid filename is required.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!isAllowedContentType(contentType)) {
|
|
149
|
+
throw new R2PresignedUploadError('Only AVIF, GIF, JPEG, PNG, and WebP images are supported.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!Number.isInteger(size) || size <= 0) {
|
|
153
|
+
throw new R2PresignedUploadError('A positive file size is required.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (size > R2_PRESIGNED_UPLOAD_MAX_BYTES) {
|
|
157
|
+
throw new R2PresignedUploadError('Image uploads are limited to 10 MB.');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
contentType,
|
|
162
|
+
filename,
|
|
163
|
+
folder: sanitizeR2UploadFolder(payload.folder),
|
|
164
|
+
size,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function buildR2UploadObjectKey(
|
|
169
|
+
payload: ValidR2PresignedUploadPayload,
|
|
170
|
+
options: { now?: Date; nonce?: string } = {}
|
|
171
|
+
) {
|
|
172
|
+
const base = sanitizeFilenameBase(payload.filename);
|
|
173
|
+
const extension = getFileExtension(payload.filename, payload.contentType);
|
|
174
|
+
const timestamp = formatUploadTimestamp(options.now ?? new Date());
|
|
175
|
+
const nonce = (options.nonce ?? randomUUID().slice(0, 8)).replace(/[^a-zA-Z0-9-]/g, '');
|
|
176
|
+
|
|
177
|
+
return `${payload.folder}${base}-${timestamp}-${nonce}.${extension}`;
|
|
178
|
+
}
|
|
@@ -1,140 +1,140 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
|
|
3
|
-
vi.mock('server-only', () => ({}));
|
|
4
|
-
|
|
5
|
-
const mocks = vi.hoisted(() => ({
|
|
6
|
-
getS3PresignClientMock: vi.fn(),
|
|
7
|
-
getSignedUrlMock: vi.fn(),
|
|
8
|
-
getUserMock: vi.fn(),
|
|
9
|
-
profileSingleMock: vi.fn(),
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
vi.mock('@nextblock-cms/db/server', () => ({
|
|
13
|
-
createClient: () => ({
|
|
14
|
-
auth: {
|
|
15
|
-
getUser: mocks.getUserMock,
|
|
16
|
-
},
|
|
17
|
-
from: () => ({
|
|
18
|
-
eq: () => ({
|
|
19
|
-
single: mocks.profileSingleMock,
|
|
20
|
-
}),
|
|
21
|
-
select: () => ({
|
|
22
|
-
eq: () => ({
|
|
23
|
-
single: mocks.profileSingleMock,
|
|
24
|
-
}),
|
|
25
|
-
}),
|
|
26
|
-
}),
|
|
27
|
-
}),
|
|
28
|
-
}));
|
|
29
|
-
|
|
30
|
-
vi.mock('@nextblock-cms/utils/server', () => ({
|
|
31
|
-
getS3PresignClient: mocks.getS3PresignClientMock,
|
|
32
|
-
}));
|
|
33
|
-
|
|
34
|
-
vi.mock('@aws-sdk/client-s3', () => ({
|
|
35
|
-
PutObjectCommand: class PutObjectCommand {
|
|
36
|
-
constructor(public readonly input: unknown) {}
|
|
37
|
-
},
|
|
38
|
-
}));
|
|
39
|
-
|
|
40
|
-
vi.mock('@aws-sdk/s3-request-presigner', () => ({
|
|
41
|
-
getSignedUrl: mocks.getSignedUrlMock,
|
|
42
|
-
}));
|
|
43
|
-
|
|
44
|
-
import {
|
|
45
|
-
buildR2UploadObjectKey,
|
|
46
|
-
validateR2PresignedUploadPayload,
|
|
47
|
-
} from './custom-block-r2-upload';
|
|
48
|
-
import { POST } from '../app/api/media/r2-presigned/route';
|
|
49
|
-
|
|
50
|
-
describe('custom block R2 presigned upload flow', () => {
|
|
51
|
-
beforeEach(() => {
|
|
52
|
-
vi.clearAllMocks();
|
|
53
|
-
process.env.R2_BUCKET_NAME = 'nextblock-test';
|
|
54
|
-
process.env.NEXT_PUBLIC_R2_BASE_URL = 'https://cdn.nextblock.test';
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('validates and sanitizes image upload keys', () => {
|
|
58
|
-
const payload = validateR2PresignedUploadPayload({
|
|
59
|
-
contentType: 'image/webp',
|
|
60
|
-
filename: 'Hero Portrait!!!.webp',
|
|
61
|
-
folder: '../custom blocks//testimonials',
|
|
62
|
-
size: 1024,
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
expect(payload.folder).toBe('custom-blocks/testimonials/');
|
|
66
|
-
expect(
|
|
67
|
-
buildR2UploadObjectKey(payload, {
|
|
68
|
-
nonce: 'abc123',
|
|
69
|
-
now: new Date('2026-05-28T12:34:56.789Z'),
|
|
70
|
-
})
|
|
71
|
-
).toBe('custom-blocks/testimonials/hero-portrait-20260528123456789-abc123.webp');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('rejects anonymous users before creating an upload URL', async () => {
|
|
75
|
-
mocks.getUserMock.mockResolvedValueOnce({ data: { user: null }, error: null });
|
|
76
|
-
|
|
77
|
-
const response = await POST(
|
|
78
|
-
new Request('https://nextblock.test/api/media/r2-presigned', {
|
|
79
|
-
body: JSON.stringify({}),
|
|
80
|
-
method: 'POST',
|
|
81
|
-
}) as any
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
expect(response.status).toBe(401);
|
|
85
|
-
expect(mocks.getS3PresignClientMock).not.toHaveBeenCalled();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('rejects oversized files for authorized writers', async () => {
|
|
89
|
-
mocks.getUserMock.mockResolvedValueOnce({ data: { user: { id: 'user-1' } }, error: null });
|
|
90
|
-
mocks.profileSingleMock.mockResolvedValueOnce({ data: { role: 'WRITER' }, error: null });
|
|
91
|
-
|
|
92
|
-
const response = await POST(
|
|
93
|
-
new Request('https://nextblock.test/api/media/r2-presigned', {
|
|
94
|
-
body: JSON.stringify({
|
|
95
|
-
contentType: 'image/png',
|
|
96
|
-
filename: 'too-large.png',
|
|
97
|
-
size: 11 * 1024 * 1024,
|
|
98
|
-
}),
|
|
99
|
-
method: 'POST',
|
|
100
|
-
}) as any
|
|
101
|
-
);
|
|
102
|
-
const payload = await response.json();
|
|
103
|
-
|
|
104
|
-
expect(response.status).toBe(400);
|
|
105
|
-
expect(payload.error).toContain('10 MB');
|
|
106
|
-
expect(mocks.getS3PresignClientMock).not.toHaveBeenCalled();
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('returns a direct PUT upload space for authorized writers', async () => {
|
|
110
|
-
mocks.getUserMock.mockResolvedValueOnce({ data: { user: { id: 'user-1' } }, error: null });
|
|
111
|
-
mocks.profileSingleMock.mockResolvedValueOnce({ data: { role: 'ADMIN' }, error: null });
|
|
112
|
-
mocks.getS3PresignClientMock.mockResolvedValueOnce({ send: vi.fn() });
|
|
113
|
-
mocks.getSignedUrlMock.mockResolvedValueOnce('https://r2.example.test/presigned-put');
|
|
114
|
-
|
|
115
|
-
const response = await POST(
|
|
116
|
-
new Request('https://nextblock.test/api/media/r2-presigned', {
|
|
117
|
-
body: JSON.stringify({
|
|
118
|
-
contentType: 'image/jpeg',
|
|
119
|
-
filename: 'Customer Headshot.jpg',
|
|
120
|
-
folder: 'custom-blocks/testimonials',
|
|
121
|
-
size: 512_000,
|
|
122
|
-
}),
|
|
123
|
-
method: 'POST',
|
|
124
|
-
}) as any
|
|
125
|
-
);
|
|
126
|
-
const payload = await response.json();
|
|
127
|
-
|
|
128
|
-
expect(response.status).toBe(200);
|
|
129
|
-
expect(payload).toMatchObject({
|
|
130
|
-
headers: { 'Content-Type': 'image/jpeg' },
|
|
131
|
-
method: 'PUT',
|
|
132
|
-
presignedUrl: 'https://r2.example.test/presigned-put',
|
|
133
|
-
uploadUrl: 'https://r2.example.test/presigned-put',
|
|
134
|
-
});
|
|
135
|
-
expect(payload.objectKey).toMatch(
|
|
136
|
-
/^custom-blocks\/testimonials\/customer-headshot-\d{17}-[a-f0-9-]+\.jpg$/
|
|
137
|
-
);
|
|
138
|
-
expect(payload.publicUrl).toContain('https://cdn.nextblock.test/custom-blocks/testimonials/');
|
|
139
|
-
});
|
|
140
|
-
});
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('server-only', () => ({}));
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
getS3PresignClientMock: vi.fn(),
|
|
7
|
+
getSignedUrlMock: vi.fn(),
|
|
8
|
+
getUserMock: vi.fn(),
|
|
9
|
+
profileSingleMock: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('@nextblock-cms/db/server', () => ({
|
|
13
|
+
createClient: () => ({
|
|
14
|
+
auth: {
|
|
15
|
+
getUser: mocks.getUserMock,
|
|
16
|
+
},
|
|
17
|
+
from: () => ({
|
|
18
|
+
eq: () => ({
|
|
19
|
+
single: mocks.profileSingleMock,
|
|
20
|
+
}),
|
|
21
|
+
select: () => ({
|
|
22
|
+
eq: () => ({
|
|
23
|
+
single: mocks.profileSingleMock,
|
|
24
|
+
}),
|
|
25
|
+
}),
|
|
26
|
+
}),
|
|
27
|
+
}),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('@nextblock-cms/utils/server', () => ({
|
|
31
|
+
getS3PresignClient: mocks.getS3PresignClientMock,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock('@aws-sdk/client-s3', () => ({
|
|
35
|
+
PutObjectCommand: class PutObjectCommand {
|
|
36
|
+
constructor(public readonly input: unknown) {}
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('@aws-sdk/s3-request-presigner', () => ({
|
|
41
|
+
getSignedUrl: mocks.getSignedUrlMock,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
buildR2UploadObjectKey,
|
|
46
|
+
validateR2PresignedUploadPayload,
|
|
47
|
+
} from './custom-block-r2-upload';
|
|
48
|
+
import { POST } from '../app/api/media/r2-presigned/route';
|
|
49
|
+
|
|
50
|
+
describe('custom block R2 presigned upload flow', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.clearAllMocks();
|
|
53
|
+
process.env.R2_BUCKET_NAME = 'nextblock-test';
|
|
54
|
+
process.env.NEXT_PUBLIC_R2_BASE_URL = 'https://cdn.nextblock.test';
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('validates and sanitizes image upload keys', () => {
|
|
58
|
+
const payload = validateR2PresignedUploadPayload({
|
|
59
|
+
contentType: 'image/webp',
|
|
60
|
+
filename: 'Hero Portrait!!!.webp',
|
|
61
|
+
folder: '../custom blocks//testimonials',
|
|
62
|
+
size: 1024,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(payload.folder).toBe('custom-blocks/testimonials/');
|
|
66
|
+
expect(
|
|
67
|
+
buildR2UploadObjectKey(payload, {
|
|
68
|
+
nonce: 'abc123',
|
|
69
|
+
now: new Date('2026-05-28T12:34:56.789Z'),
|
|
70
|
+
})
|
|
71
|
+
).toBe('custom-blocks/testimonials/hero-portrait-20260528123456789-abc123.webp');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects anonymous users before creating an upload URL', async () => {
|
|
75
|
+
mocks.getUserMock.mockResolvedValueOnce({ data: { user: null }, error: null });
|
|
76
|
+
|
|
77
|
+
const response = await POST(
|
|
78
|
+
new Request('https://nextblock.test/api/media/r2-presigned', {
|
|
79
|
+
body: JSON.stringify({}),
|
|
80
|
+
method: 'POST',
|
|
81
|
+
}) as any
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(response.status).toBe(401);
|
|
85
|
+
expect(mocks.getS3PresignClientMock).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rejects oversized files for authorized writers', async () => {
|
|
89
|
+
mocks.getUserMock.mockResolvedValueOnce({ data: { user: { id: 'user-1' } }, error: null });
|
|
90
|
+
mocks.profileSingleMock.mockResolvedValueOnce({ data: { role: 'WRITER' }, error: null });
|
|
91
|
+
|
|
92
|
+
const response = await POST(
|
|
93
|
+
new Request('https://nextblock.test/api/media/r2-presigned', {
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
contentType: 'image/png',
|
|
96
|
+
filename: 'too-large.png',
|
|
97
|
+
size: 11 * 1024 * 1024,
|
|
98
|
+
}),
|
|
99
|
+
method: 'POST',
|
|
100
|
+
}) as any
|
|
101
|
+
);
|
|
102
|
+
const payload = await response.json();
|
|
103
|
+
|
|
104
|
+
expect(response.status).toBe(400);
|
|
105
|
+
expect(payload.error).toContain('10 MB');
|
|
106
|
+
expect(mocks.getS3PresignClientMock).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns a direct PUT upload space for authorized writers', async () => {
|
|
110
|
+
mocks.getUserMock.mockResolvedValueOnce({ data: { user: { id: 'user-1' } }, error: null });
|
|
111
|
+
mocks.profileSingleMock.mockResolvedValueOnce({ data: { role: 'ADMIN' }, error: null });
|
|
112
|
+
mocks.getS3PresignClientMock.mockResolvedValueOnce({ send: vi.fn() });
|
|
113
|
+
mocks.getSignedUrlMock.mockResolvedValueOnce('https://r2.example.test/presigned-put');
|
|
114
|
+
|
|
115
|
+
const response = await POST(
|
|
116
|
+
new Request('https://nextblock.test/api/media/r2-presigned', {
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
contentType: 'image/jpeg',
|
|
119
|
+
filename: 'Customer Headshot.jpg',
|
|
120
|
+
folder: 'custom-blocks/testimonials',
|
|
121
|
+
size: 512_000,
|
|
122
|
+
}),
|
|
123
|
+
method: 'POST',
|
|
124
|
+
}) as any
|
|
125
|
+
);
|
|
126
|
+
const payload = await response.json();
|
|
127
|
+
|
|
128
|
+
expect(response.status).toBe(200);
|
|
129
|
+
expect(payload).toMatchObject({
|
|
130
|
+
headers: { 'Content-Type': 'image/jpeg' },
|
|
131
|
+
method: 'PUT',
|
|
132
|
+
presignedUrl: 'https://r2.example.test/presigned-put',
|
|
133
|
+
uploadUrl: 'https://r2.example.test/presigned-put',
|
|
134
|
+
});
|
|
135
|
+
expect(payload.objectKey).toMatch(
|
|
136
|
+
/^custom-blocks\/testimonials\/customer-headshot-\d{17}-[a-f0-9-]+\.jpg$/
|
|
137
|
+
);
|
|
138
|
+
expect(payload.publicUrl).toContain('https://cdn.nextblock.test/custom-blocks/testimonials/');
|
|
139
|
+
});
|
|
140
|
+
});
|