create-nextblock 0.9.95 → 0.9.99
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/package.json +1 -1
- package/templates/nextblock-template/app/[slug]/page.tsx +9 -2
- package/templates/nextblock-template/app/actions/package-actions.ts +9 -4
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3 -2
- package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +5 -1
- package/templates/nextblock-template/app/api/draft/route.ts +5 -1
- package/templates/nextblock-template/app/api/media/library/route.ts +6 -2
- package/templates/nextblock-template/app/api/process-image/route.ts +58 -43
- package/templates/nextblock-template/app/api/revalidate/route.ts +21 -18
- package/templates/nextblock-template/app/api/upload/presigned-url/route.ts +20 -8
- package/templates/nextblock-template/app/api/upload/proxy/route.ts +34 -28
- package/templates/nextblock-template/app/article/[slug]/page.tsx +4 -13
- package/templates/nextblock-template/app/checkout/success/actions.ts +3 -2
- package/templates/nextblock-template/app/cms/media/actions.ts +47 -31
- package/templates/nextblock-template/app/cms/orders/actions.ts +29 -29
- package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +28 -28
- package/templates/nextblock-template/app/cms/users/actions.ts +119 -118
- package/templates/nextblock-template/app/cms/users/page.tsx +3 -3
- package/templates/nextblock-template/app/layout.tsx +10 -7
- package/templates/nextblock-template/app/lib/site-settings.ts +7 -4
- package/templates/nextblock-template/app/page.tsx +2 -1
- package/templates/nextblock-template/app/product/[slug]/page.tsx +9 -2
- package/templates/nextblock-template/app/robots.txt/route.ts +6 -3
- package/templates/nextblock-template/app/setup/SetupWizard.tsx +55 -8
- package/templates/nextblock-template/app/setup/page.tsx +17 -1
- package/templates/nextblock-template/app/sitemap.ts +5 -3
- package/templates/nextblock-template/context/language-rest-client.ts +3 -2
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +125 -32
- package/templates/nextblock-template/lib/app-secrets.ts +39 -0
- package/templates/nextblock-template/lib/auth/crypto.ts +4 -1
- package/templates/nextblock-template/lib/custom-block-r2-upload.ts +23 -3
- package/templates/nextblock-template/lib/setup/actions.ts +16 -0
- package/templates/nextblock-template/lib/setup/env-status.ts +44 -5
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +172 -0
- package/templates/nextblock-template/lib/setup/schema-apply.ts +23 -7
- package/templates/nextblock-template/lib/site-url.ts +48 -0
- package/templates/nextblock-template/lib/storage/provider.ts +66 -0
- package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -0
- package/templates/nextblock-template/lib/visual-editing/draft-content.ts +1 -1
- package/templates/nextblock-template/next.config.js +37 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +39 -4
|
@@ -16,6 +16,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import postgres from 'postgres';
|
|
18
18
|
import { isLocalWritableEnv } from './env-status';
|
|
19
|
+
import { MIGRATIONS_BUNDLE } from './migrations-bundle';
|
|
19
20
|
|
|
20
21
|
export interface SchemaApplyResult {
|
|
21
22
|
ok: boolean;
|
|
@@ -238,14 +239,29 @@ export async function resetDatabase(): Promise<{ ok: boolean; error?: string }>
|
|
|
238
239
|
|
|
239
240
|
export async function applyMigrations(): Promise<SchemaApplyResult> {
|
|
240
241
|
const migrationsDir = resolveMigrationsDir();
|
|
241
|
-
if (!migrationsDir) {
|
|
242
|
-
return { ok: false, applied: 0, error: 'Could not locate the migrations directory.' };
|
|
243
|
-
}
|
|
244
242
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
243
|
+
let files: string[];
|
|
244
|
+
let readSql: (file: string) => Promise<string>;
|
|
245
|
+
|
|
246
|
+
if (migrationsDir) {
|
|
247
|
+
// Local dev / Docker: read the canonical .sql files from disk (always current).
|
|
248
|
+
files = (await readdir(migrationsDir))
|
|
249
|
+
.filter((name) => /^\d+_.*\.sql$/.test(name))
|
|
250
|
+
.sort();
|
|
251
|
+
readSql = (file: string) => readFile(path.join(migrationsDir, file), 'utf8');
|
|
252
|
+
} else if (MIGRATIONS_BUNDLE.length > 0) {
|
|
253
|
+
// Serverless (Vercel): libs/db isn't on the function filesystem, so fall back to the
|
|
254
|
+
// build-time embedded bundle (npm run generate:migrations-bundle).
|
|
255
|
+
files = MIGRATIONS_BUNDLE.map((m) => m.name).sort();
|
|
256
|
+
const sqlByName = new Map(MIGRATIONS_BUNDLE.map((m) => [m.name, m.sql]));
|
|
257
|
+
readSql = async (file: string) => sqlByName.get(file) ?? '';
|
|
258
|
+
} else {
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
applied: 0,
|
|
262
|
+
error: 'Could not locate the migrations (no directory and no embedded bundle).',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
249
265
|
|
|
250
266
|
const ref = resolveProjectRef();
|
|
251
267
|
const token = process.env.SUPABASE_ACCESS_TOKEN?.trim();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Resolve the canonical public site URL across deploy channels WITHOUT requiring
|
|
2
|
+
// NEXT_PUBLIC_URL to be set. On Vercel it falls back to the auto-provisioned
|
|
3
|
+
// production URL, so a one-click deploy needs no URL input at all.
|
|
4
|
+
//
|
|
5
|
+
// Dependency-free and safe to import anywhere — server components, route handlers,
|
|
6
|
+
// or client components — like lib/setup/env-status.ts. It only reads `process.env`:
|
|
7
|
+
// NEXT_PUBLIC_* names are inlined into the browser bundle at build time, the others
|
|
8
|
+
// resolve server-side only (and are simply absent — harmless — in the browser).
|
|
9
|
+
|
|
10
|
+
const stripTrailingSlash = (value: string): string => value.replace(/\/+$/, '');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Vercel always sets a production domain (even on preview deployments):
|
|
14
|
+
* `VERCEL_PROJECT_PRODUCTION_URL` server-side, and Vercel exposes the
|
|
15
|
+
* framework-prefixed `NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL` to the browser
|
|
16
|
+
* bundle at build time. Neither includes the protocol. We prefer the production URL
|
|
17
|
+
* over the per-deployment `VERCEL_URL` so absolute links (sitemap, canonical, OG)
|
|
18
|
+
* stay stable across deploys.
|
|
19
|
+
*/
|
|
20
|
+
function vercelProductionUrl(): string {
|
|
21
|
+
const host =
|
|
22
|
+
process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL?.trim() ||
|
|
23
|
+
process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
|
|
24
|
+
return host ? `https://${stripTrailingSlash(host)}` : '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Canonical absolute site origin (e.g. `https://example.com`), no trailing slash.
|
|
29
|
+
* Precedence: explicit `NEXT_PUBLIC_URL` → Vercel production URL → `fallback`.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveSiteUrl(fallback = 'http://localhost:3000'): string {
|
|
32
|
+
const explicit = process.env.NEXT_PUBLIC_URL?.trim();
|
|
33
|
+
if (explicit) return stripTrailingSlash(explicit);
|
|
34
|
+
|
|
35
|
+
const vercel = vercelProductionUrl();
|
|
36
|
+
if (vercel) return vercel;
|
|
37
|
+
|
|
38
|
+
return stripTrailingSlash(fallback);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True when a real, production-intended site URL is available (an explicit
|
|
43
|
+
* `NEXT_PUBLIC_URL` or the Vercel production URL) — i.e. {@link resolveSiteUrl}
|
|
44
|
+
* is NOT returning the local-dev fallback. Use this to gate "URL not set" warnings.
|
|
45
|
+
*/
|
|
46
|
+
export function hasResolvedSiteUrl(): boolean {
|
|
47
|
+
return Boolean(process.env.NEXT_PUBLIC_URL?.trim() || vercelProductionUrl());
|
|
48
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { resolveSupabaseServiceKey, resolveSupabaseUrl } from '../setup/env-status';
|
|
4
|
+
|
|
5
|
+
// NextBlock has two storage backends:
|
|
6
|
+
//
|
|
7
|
+
// 's3' — the existing S3-compatible path (Cloudflare R2, self-hosted MinIO, or
|
|
8
|
+
// even Supabase Storage's S3 endpoint when you create S3 access keys).
|
|
9
|
+
// Driven by the R2_* env vars + @aws-sdk/client-s3. Unchanged.
|
|
10
|
+
// 'supabase' — native Supabase Storage via the supabase-js client and the injected
|
|
11
|
+
// service-role/secret key. NO S3 access keys required. This is what makes
|
|
12
|
+
// the one-click Vercel deploy work with zero storage configuration: the
|
|
13
|
+
// Marketplace integration injects the Supabase URL + secret key but NOT S3
|
|
14
|
+
// keys, so the S3 path can't authenticate — the native API can.
|
|
15
|
+
//
|
|
16
|
+
// The S3 path always wins when its keys are present, so local dev, Docker/MinIO, and BYO
|
|
17
|
+
// Cloudflare R2 are completely unaffected. The native path is only chosen as the zero-key
|
|
18
|
+
// fallback for a connected Supabase project.
|
|
19
|
+
|
|
20
|
+
export type StorageBackend = 's3' | 'supabase';
|
|
21
|
+
|
|
22
|
+
/** Which storage backend to use. Explicit STORAGE_PROVIDER wins; otherwise inferred. */
|
|
23
|
+
export function getStorageBackend(): StorageBackend {
|
|
24
|
+
const explicit = process.env.STORAGE_PROVIDER?.trim().toLowerCase();
|
|
25
|
+
if (explicit === 'supabase') return 'supabase';
|
|
26
|
+
if (explicit === 's3' || explicit === 'r2' || explicit === 'minio') return 's3';
|
|
27
|
+
|
|
28
|
+
// S3/R2/MinIO wins whenever its credentials exist (local, Docker, BYO R2, or
|
|
29
|
+
// Supabase-S3-with-keys). Only fall back to native Supabase Storage when there are no
|
|
30
|
+
// S3 keys but a Supabase project IS connected (service key + URL) — the Vercel case.
|
|
31
|
+
const hasS3Keys = Boolean(
|
|
32
|
+
process.env.R2_ACCESS_KEY_ID && process.env.R2_SECRET_ACCESS_KEY,
|
|
33
|
+
);
|
|
34
|
+
if (hasS3Keys) return 's3';
|
|
35
|
+
if (resolveSupabaseServiceKey() && resolveSupabaseUrl()) return 'supabase';
|
|
36
|
+
return 's3';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The bucket name to read/write. Defaults to "media" on the native Supabase backend. */
|
|
40
|
+
export function getStorageBucket(): string {
|
|
41
|
+
const explicit = process.env.STORAGE_BUCKET || process.env.R2_BUCKET_NAME;
|
|
42
|
+
if (explicit) return explicit;
|
|
43
|
+
return getStorageBackend() === 'supabase' ? 'media' : '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The public base URL objects are served from — what resolveMediaUrl() joins object keys
|
|
48
|
+
* onto. An explicit R2 public/base URL always wins; otherwise, on the native Supabase
|
|
49
|
+
* backend, derive Supabase Storage's public object endpoint
|
|
50
|
+
* (`<project>/storage/v1/object/public/<bucket>`) so every display call site works with
|
|
51
|
+
* zero extra config. Kept in sync with the CJS computation in next.config.js.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveMediaBaseUrl(): string {
|
|
54
|
+
const explicit =
|
|
55
|
+
process.env.NEXT_PUBLIC_R2_BASE_URL || process.env.NEXT_PUBLIC_R2_PUBLIC_URL;
|
|
56
|
+
if (explicit) return explicit.replace(/\/+$/, '');
|
|
57
|
+
|
|
58
|
+
if (getStorageBackend() === 'supabase') {
|
|
59
|
+
const url = resolveSupabaseUrl();
|
|
60
|
+
const bucket = getStorageBucket();
|
|
61
|
+
if (url && bucket) {
|
|
62
|
+
return `${url.replace(/\/+$/, '')}/storage/v1/object/public/${bucket}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
4
|
+
|
|
5
|
+
import { getStorageBucket } from './provider';
|
|
6
|
+
|
|
7
|
+
// Native Supabase Storage operations, authenticated with the service-role/secret key.
|
|
8
|
+
// Used only when getStorageBackend() === 'supabase' (the zero-S3-key Vercel path). These
|
|
9
|
+
// mirror what the S3 routes do via @aws-sdk/client-s3, so the upload → process → display
|
|
10
|
+
// → delete pipeline works identically without any S3 access keys.
|
|
11
|
+
|
|
12
|
+
// The public bucket is created once per process. Cache the in-flight promise (not a bare
|
|
13
|
+
// boolean) so concurrent uploads share a SINGLE createBucket call instead of racing — and
|
|
14
|
+
// clear it on failure so a later request can retry rather than inheriting a cached rejection.
|
|
15
|
+
let bucketEnsurePromise: Promise<void> | null = null;
|
|
16
|
+
|
|
17
|
+
/** Idempotently ensure a PUBLIC bucket exists for media. Safe to call concurrently. */
|
|
18
|
+
export function ensureStorageBucket(): Promise<void> {
|
|
19
|
+
if (bucketEnsurePromise) return bucketEnsurePromise;
|
|
20
|
+
|
|
21
|
+
bucketEnsurePromise = (async () => {
|
|
22
|
+
const bucket = getStorageBucket();
|
|
23
|
+
if (!bucket) throw new Error('No storage bucket name is configured.');
|
|
24
|
+
|
|
25
|
+
const admin = getServiceRoleSupabaseClient();
|
|
26
|
+
// public: true is REQUIRED — media is served from /storage/v1/object/public/<bucket>,
|
|
27
|
+
// which returns 401 for a private bucket. Do not drop this flag.
|
|
28
|
+
const { error } = await admin.storage.createBucket(bucket, { public: true });
|
|
29
|
+
if (error && !/already exists|duplicate|resource already exists/i.test(error.message)) {
|
|
30
|
+
// Non-"exists" error — confirm via getBucket before treating it as fatal.
|
|
31
|
+
const { data } = await admin.storage.getBucket(bucket);
|
|
32
|
+
if (!data) {
|
|
33
|
+
throw new Error(`Could not ensure storage bucket "${bucket}": ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
})();
|
|
37
|
+
|
|
38
|
+
// On failure, drop the cached promise so the next caller retries (don't cache a rejection).
|
|
39
|
+
bucketEnsurePromise.catch(() => {
|
|
40
|
+
bucketEnsurePromise = null;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return bucketEnsurePromise;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Upload (or overwrite) an object. */
|
|
47
|
+
export async function supabaseUploadObject(
|
|
48
|
+
key: string,
|
|
49
|
+
body: Buffer,
|
|
50
|
+
contentType: string,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
await ensureStorageBucket();
|
|
53
|
+
const admin = getServiceRoleSupabaseClient();
|
|
54
|
+
const { error } = await admin.storage
|
|
55
|
+
.from(getStorageBucket())
|
|
56
|
+
.upload(key, body, { contentType, upsert: true });
|
|
57
|
+
if (error) {
|
|
58
|
+
throw new Error(`Supabase Storage upload failed for "${key}": ${error.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Download an object as a Buffer (used by the image processor to fetch the original). */
|
|
63
|
+
export async function supabaseDownloadObject(key: string): Promise<Buffer> {
|
|
64
|
+
const admin = getServiceRoleSupabaseClient();
|
|
65
|
+
const { data, error } = await admin.storage.from(getStorageBucket()).download(key);
|
|
66
|
+
if (error || !data) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Supabase Storage download failed for "${key}": ${error?.message ?? 'no data returned'}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return Buffer.from(await data.arrayBuffer());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a signed upload URL for a NEW object key. The browser PUTs the file bytes to the
|
|
76
|
+
* returned URL directly (the same shape as an S3 presigned PUT), so large uploads never
|
|
77
|
+
* pass through the serverless function. The token is embedded in the URL.
|
|
78
|
+
*/
|
|
79
|
+
export async function supabaseCreateSignedUpload(
|
|
80
|
+
key: string,
|
|
81
|
+
): Promise<{ signedUrl: string; token: string }> {
|
|
82
|
+
await ensureStorageBucket();
|
|
83
|
+
const admin = getServiceRoleSupabaseClient();
|
|
84
|
+
const { data, error } = await admin.storage
|
|
85
|
+
.from(getStorageBucket())
|
|
86
|
+
.createSignedUploadUrl(key);
|
|
87
|
+
if (error || !data?.signedUrl) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Supabase Storage signed upload URL failed for "${key}": ${error?.message ?? 'no URL returned'}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return { signedUrl: data.signedUrl, token: data.token };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Remove one or more objects. No-op for an empty list. */
|
|
96
|
+
export async function supabaseRemoveObjects(keys: string[]): Promise<void> {
|
|
97
|
+
if (keys.length === 0) return;
|
|
98
|
+
const admin = getServiceRoleSupabaseClient();
|
|
99
|
+
const { error } = await admin.storage.from(getStorageBucket()).remove(keys);
|
|
100
|
+
if (error) {
|
|
101
|
+
throw new Error(`Supabase Storage remove failed: ${error.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -372,7 +372,7 @@ export function updateDraftBlockContent(
|
|
|
372
372
|
}
|
|
373
373
|
|
|
374
374
|
export function createVerificationClient() {
|
|
375
|
-
if (process.env.SUPABASE_SERVICE_ROLE_KEY) {
|
|
375
|
+
if (process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY) {
|
|
376
376
|
return getServiceRoleSupabaseClient();
|
|
377
377
|
}
|
|
378
378
|
|
|
@@ -96,8 +96,24 @@ const nextConfig = {
|
|
|
96
96
|
optimizePackageImports: ['@nextblock-cms/ui', '@nextblock-cms/utils'],
|
|
97
97
|
},
|
|
98
98
|
env: {
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
// Bridge every alias the Vercel Supabase integration may inject into the public
|
|
100
|
+
// vars the app + browser client read, so the build inlines a value whichever copy
|
|
101
|
+
// is present. The Marketplace integration injects the new publishable key
|
|
102
|
+
// (NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) rather than the legacy anon key — without
|
|
103
|
+
// this bridge the client bundle would ship an empty anon key. Keep the alias order
|
|
104
|
+
// in sync with apps/nextblock/lib/setup/env-status.ts.
|
|
105
|
+
NEXT_PUBLIC_SUPABASE_URL:
|
|
106
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL,
|
|
107
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY:
|
|
108
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
|
|
109
|
+
process.env.SUPABASE_ANON_KEY ||
|
|
110
|
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
|
|
111
|
+
process.env.SUPABASE_PUBLISHABLE_KEY,
|
|
112
|
+
// On a zero-key Supabase deploy, media is served from Supabase Storage's public
|
|
113
|
+
// object endpoint — inline that as the base URL so every resolveMediaUrl() display
|
|
114
|
+
// call site works with no manual storage config (see lib/storage/provider.ts).
|
|
115
|
+
NEXT_PUBLIC_R2_BASE_URL:
|
|
116
|
+
process.env.NEXT_PUBLIC_R2_BASE_URL || computeSupabaseMediaBaseUrl(),
|
|
101
117
|
},
|
|
102
118
|
images: {
|
|
103
119
|
formats: ['image/avif', 'image/webp'],
|
|
@@ -147,6 +163,25 @@ module.exports = shouldEnableVercelToolbarPlugin
|
|
|
147
163
|
? withVercelToolbar()(nextConfig)
|
|
148
164
|
: nextConfig;
|
|
149
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Mirror of lib/storage/provider.ts resolveMediaBaseUrl() for the native Supabase backend,
|
|
168
|
+
* in CJS so it can run at build time. Returns undefined unless this is a zero-S3-key
|
|
169
|
+
* Supabase deploy (no R2 keys, a connected Supabase project), in which case it returns
|
|
170
|
+
* Supabase Storage's public object endpoint for the media bucket.
|
|
171
|
+
* @returns {string | undefined}
|
|
172
|
+
*/
|
|
173
|
+
function computeSupabaseMediaBaseUrl() {
|
|
174
|
+
if (process.env.R2_ACCESS_KEY_ID && process.env.R2_SECRET_ACCESS_KEY) return undefined;
|
|
175
|
+
const explicit = (process.env.STORAGE_PROVIDER || '').toLowerCase();
|
|
176
|
+
if (explicit && explicit !== 'supabase') return undefined;
|
|
177
|
+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
|
|
178
|
+
const hasSecret =
|
|
179
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY;
|
|
180
|
+
if (!url || !hasSecret) return undefined;
|
|
181
|
+
const bucket = process.env.STORAGE_BUCKET || process.env.R2_BUCKET_NAME || 'media';
|
|
182
|
+
return `${url.replace(/\/+$/, '')}/storage/v1/object/public/${bucket}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
150
185
|
function getRemotePatterns() {
|
|
151
186
|
/** @type {RemotePattern[]} */
|
|
152
187
|
const patterns = [];
|
|
@@ -2,6 +2,7 @@ import { createServerClient, type CookieOptions } from '@supabase/ssr';
|
|
|
2
2
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
3
3
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
4
4
|
import type { Database } from '@nextblock-cms/db';
|
|
5
|
+
import { resolveSupabaseAnonKey, resolveSupabaseUrl } from './lib/setup/env-status';
|
|
5
6
|
|
|
6
7
|
type Profile = Database['public']['Tables']['profiles']['Row'];
|
|
7
8
|
type UserRole = Database['public']['Enums']['user_role'];
|
|
@@ -55,7 +56,12 @@ function isSetupAllowlisted(pathname: string): boolean {
|
|
|
55
56
|
pathname.startsWith('/api/setup') ||
|
|
56
57
|
pathname.startsWith('/auth/') ||
|
|
57
58
|
pathname.startsWith('/_next/') ||
|
|
58
|
-
pathname.startsWith('/favicon')
|
|
59
|
+
pathname.startsWith('/favicon') ||
|
|
60
|
+
// Crawler-facing static routes: keep them reachable while unprovisioned so a fresh
|
|
61
|
+
// deploy never redirects robots.txt / the sitemap to /setup (which would let crawlers
|
|
62
|
+
// treat the wizard as the canonical entry point).
|
|
63
|
+
pathname === '/robots.txt' ||
|
|
64
|
+
pathname.startsWith('/sitemap')
|
|
59
65
|
);
|
|
60
66
|
}
|
|
61
67
|
|
|
@@ -63,12 +69,32 @@ function isSetupAllowlisted(pathname: string): boolean {
|
|
|
63
69
|
// modules persist across requests in a worker, so this avoids a per-request DB hit.
|
|
64
70
|
let provisionedAdminCache: { value: boolean; expires: number } | null = null;
|
|
65
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A Supabase/PostgREST error that means the table itself is absent — i.e. the schema
|
|
74
|
+
* was never applied. This is NOT a transient hiccup: it's the signature of a fresh,
|
|
75
|
+
* unprovisioned deploy (env injected, migrations not yet run), so the caller treats it
|
|
76
|
+
* as "no admin" and funnels traffic to /setup instead of failing open.
|
|
77
|
+
* 42P01 = undefined_table (Postgres); PGRST205 = PostgREST "table not in schema cache".
|
|
78
|
+
*/
|
|
79
|
+
function isSchemaMissingError(error: { code?: string; message?: string } | null): boolean {
|
|
80
|
+
if (!error) return false;
|
|
81
|
+
const code = error.code ?? '';
|
|
82
|
+
if (code === '42P01' || code === 'PGRST205') return true;
|
|
83
|
+
const message = (error.message ?? '').toLowerCase();
|
|
84
|
+
return (
|
|
85
|
+
message.includes('does not exist') ||
|
|
86
|
+
message.includes('schema cache') ||
|
|
87
|
+
message.includes('could not find the table')
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
/**
|
|
67
92
|
* Returns true once the system has a first admin (site_settings.is_admin_created).
|
|
68
93
|
* `is_admin_created` is a non-sensitive, anon-readable key, so the request-scoped
|
|
69
94
|
* anon client can read it. Cached aggressively once true (it never reverts) and
|
|
70
95
|
* briefly while false (so the gate releases promptly after the wizard runs).
|
|
71
|
-
*
|
|
96
|
+
* A missing-schema error returns false (unprovisioned → /setup); any OTHER read error
|
|
97
|
+
* fails open (returns true) so a transient hiccup never traps the whole site.
|
|
72
98
|
*/
|
|
73
99
|
async function hasProvisionedAdmin(supabase: SupabaseClient): Promise<boolean> {
|
|
74
100
|
const now = Date.now();
|
|
@@ -84,6 +110,12 @@ async function hasProvisionedAdmin(supabase: SupabaseClient): Promise<boolean> {
|
|
|
84
110
|
.maybeSingle();
|
|
85
111
|
|
|
86
112
|
if (error) {
|
|
113
|
+
// Schema not applied yet → definitively unprovisioned: send traffic to /setup
|
|
114
|
+
// (otherwise the homepage 404s on a fresh deploy because no content exists).
|
|
115
|
+
if (isSchemaMissingError(error)) {
|
|
116
|
+
provisionedAdminCache = { value: false, expires: now + 3_000 };
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
87
119
|
return true;
|
|
88
120
|
}
|
|
89
121
|
|
|
@@ -307,8 +339,11 @@ function createContentSecurityPolicy(nonceValue: string, supabaseUrl: string | u
|
|
|
307
339
|
|
|
308
340
|
export async function proxy(request: NextRequest) {
|
|
309
341
|
const { pathname } = request.nextUrl;
|
|
310
|
-
|
|
311
|
-
|
|
342
|
+
// Resolve Supabase creds under every alias the Vercel integration may inject (prefixed,
|
|
343
|
+
// non-prefixed, and the new publishable key) so the gate doesn't bounce a configured
|
|
344
|
+
// deploy to /setup just because the credentials arrived under a different name.
|
|
345
|
+
const supabaseUrl = resolveSupabaseUrl();
|
|
346
|
+
const supabaseAnonKey = resolveSupabaseAnonKey();
|
|
312
347
|
const configured = Boolean(supabaseUrl && supabaseAnonKey);
|
|
313
348
|
process.env.NEXTBLOCK_UNCONFIGURED = configured ? 'false' : 'true';
|
|
314
349
|
|