React behind /react basePath; nginx restores 80 to PHP; cross-verify suite
Co-existence on 201: 80=PHP gnuboard5 (default vhost), /react=Next.js (basePath) - Dockerfile takes ARG NEXT_PUBLIC_BASE_PATH, passes to Next build for asset/route prefixing - next.config.mjs reads NEXT_PUBLIC_BASE_PATH at build time - nginx slot-clone vhost: location /react proxies to 3000 unchanged; / and /adm/ go to 8090 - /robots.txt remains nginx-served (all-bot block) Design polish: - Header MegaPanel sub-link: gradient hover + white text + shadow on hover (was hover:bg-brand-50 only) - Top-level mega menu: bigger padding + bold + bottom-bar hover indicator - StatStrip: 8 narrow cards → 4 large cards (sm:grid-cols-4) with 12 size icon, 28px value, blur halo Verify: - scripts/verify-cross.mjs: parallel PHP (admin/clone1234) + React (testlogin/test1234) flow - 50 iterations × 11 checks = 550/550 PASS Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ RUN apt-get update -qq && apt-get install -y --no-install-recommends ca-certific
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
FROM base AS build
|
||||
ARG NEXT_PUBLIC_BASE_PATH=""
|
||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
...(basePath ? { basePath, assetPrefix: basePath } : {}),
|
||||
transpilePackages: ['@slot/themes', '@slot/db', '@slot/auth'],
|
||||
serverExternalPackages: ['postgres', '@node-rs/argon2'],
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
|
||||
@@ -166,7 +166,7 @@ function MegaPanel({ items }: { items: MenuItem[] }) {
|
||||
<li key={ci}>
|
||||
<Link
|
||||
href={c.href || '#'}
|
||||
className="block rounded-lg px-2.5 py-1.5 text-[13px] text-neutral-700 hover:bg-brand-50 hover:text-brand-700 whitespace-nowrap"
|
||||
className="block rounded-lg px-3 py-2 text-[13.5px] font-medium text-neutral-800 transition hover:bg-gradient-to-r hover:from-brand-600 hover:to-fuchsia-600 hover:text-white hover:shadow-[0_4px_14px_rgba(124,82,224,0.25)] whitespace-nowrap"
|
||||
>
|
||||
{c.label}
|
||||
</Link>
|
||||
@@ -195,7 +195,7 @@ function MegaNav({ menus, isDark: _isDark }: { menus: MenuItem[]; isDark: boolea
|
||||
>
|
||||
<Link
|
||||
href={m.href || '#'}
|
||||
className="flex items-center gap-1.5 whitespace-nowrap px-3 py-3.5 text-[14px] font-semibold tracking-tight hover:bg-white/10"
|
||||
className="flex items-center gap-1.5 whitespace-nowrap px-4 py-3.5 text-[14.5px] font-bold tracking-tight text-white transition hover:bg-white/20 hover:shadow-[inset_0_-3px_0_0_rgba(255,255,255,0.85)]"
|
||||
>
|
||||
{m.icon && <span aria-hidden>{m.icon}</span>}
|
||||
<span>{m.label}</span>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function StatStrip({ members, posts, comments, visitsToday, visit
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="grid grid-cols-2 gap-2.5 sm:grid-cols-4 lg:grid-cols-8">
|
||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{stats.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
@@ -41,17 +41,17 @@ export default function StatStrip({ members, posts, comments, visitsToday, visit
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.04, duration: 0.4 }}
|
||||
className="lift relative overflow-hidden rounded-2xl bg-white p-3.5 ring-1 ring-neutral-100"
|
||||
className="lift relative overflow-hidden rounded-3xl bg-white p-5 ring-1 ring-neutral-100 shadow-[0_8px_24px_rgba(124,82,224,0.06)]"
|
||||
>
|
||||
<div className={`absolute -top-6 -right-6 h-20 w-20 rounded-full bg-gradient-to-br ${s.tone} opacity-20 blur-2xl`} />
|
||||
<div className={`absolute -top-8 -right-8 h-32 w-32 rounded-full bg-gradient-to-br ${s.tone} opacity-25 blur-3xl`} />
|
||||
<div className="flex items-start justify-between">
|
||||
<span className={`grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br ${s.tone} text-white shadow-[0_6px_14px_rgba(0,0,0,0.10)]`}>
|
||||
<Icon size={16} />
|
||||
<span className={`grid h-12 w-12 place-items-center rounded-2xl bg-gradient-to-br ${s.tone} text-white shadow-[0_8px_22px_rgba(0,0,0,0.18)]`}>
|
||||
<Icon size={20} />
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-neutral-text-soft">{s.sub}</span>
|
||||
<span className="rounded-full bg-neutral-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-neutral-600">{s.sub}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[20px] font-extrabold tabular text-neutral-900">{s.value}</div>
|
||||
<div className="text-[11px] font-medium text-neutral-text-soft">{s.label}</div>
|
||||
<div className="mt-3 text-[28px] font-extrabold tabular text-neutral-900">{s.value}</div>
|
||||
<div className="text-[12px] font-semibold text-neutral-text-soft">{s.label}</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
// Cross-verify PHP gnuboard clone (port 80, default vhost) and React app
|
||||
// (path /react). Same DB underneath (MariaDB clone vs PG slot — both restored
|
||||
// from the same 207 dump), so headline counts should match.
|
||||
//
|
||||
// Usage:
|
||||
// ITERATIONS=5 node scripts/verify-cross.mjs
|
||||
const HOST = process.env.HOST || 'http://103.31.14.201';
|
||||
const PHP = HOST;
|
||||
const REACT = HOST + '/react';
|
||||
const ITER = Number(process.env.ITERATIONS || 5);
|
||||
const PHP_USER = process.env.PHP_USER || 'admin';
|
||||
const PHP_PASS = process.env.PHP_PASS || 'clone1234';
|
||||
const REACT_USER = process.env.REACT_USER || 'testlogin';
|
||||
const REACT_PASS = process.env.REACT_PASS || 'test1234';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const failures = [];
|
||||
async function check(label, fn) {
|
||||
try {
|
||||
const ok = await fn();
|
||||
if (ok) { pass++; console.log(` ✅ ${label}`); }
|
||||
else { fail++; console.log(` ❌ ${label}`); failures.push(label); }
|
||||
} catch (e) {
|
||||
fail++; console.log(` ❌ ${label} (threw: ${e.message})`);
|
||||
failures.push(`${label} (${e.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOk(url, opts = {}) {
|
||||
const r = await fetch(url, { redirect: 'manual', ...opts });
|
||||
return r;
|
||||
}
|
||||
|
||||
let phpCookie = '', reactCookie = '';
|
||||
function takeCookie(jar, resp) {
|
||||
const c = resp.headers.get('set-cookie');
|
||||
if (!c) return jar;
|
||||
const parts = c.split(',').map(s => s.split(';')[0]).filter(Boolean);
|
||||
let cur = jar;
|
||||
for (const p of parts) {
|
||||
const eq = p.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const name = p.slice(0, eq).trim();
|
||||
const val = p.slice(eq + 1).trim();
|
||||
const others = cur.split('; ').filter(s => s && !s.startsWith(name + '='));
|
||||
others.push(`${name}=${val}`);
|
||||
cur = others.join('; ');
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
async function iteration(i) {
|
||||
console.log(`\n=== ITERATION ${i} of ${ITER} ===`);
|
||||
phpCookie = ''; reactCookie = '';
|
||||
|
||||
// --- PHP side ---
|
||||
await check('[PHP] GET / (homepage 200)', async () => {
|
||||
const r = await fetchOk(PHP + '/');
|
||||
return r.status === 200 || r.status === 308;
|
||||
});
|
||||
await check('[PHP] GET /bbs/board.php?bo_table=free (200)', async () => {
|
||||
const r = await fetchOk(PHP + '/bbs/board.php?bo_table=free');
|
||||
return r.status === 200 || r.status === 308;
|
||||
});
|
||||
await check('[PHP] POST /bbs/login_check.php as admin', async () => {
|
||||
const r = await fetchOk(PHP + '/bbs/login_check.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': phpCookie, 'Referer': PHP + '/bbs/login.php' },
|
||||
body: new URLSearchParams({ url: '', mb_id: PHP_USER, mb_password: PHP_PASS, auto_login: '' }).toString(),
|
||||
});
|
||||
phpCookie = takeCookie(phpCookie, r);
|
||||
return r.status === 302 || r.status === 303 || r.status === 200;
|
||||
});
|
||||
await check('[PHP] GET /adm/ as admin', async () => {
|
||||
const r = await fetchOk(PHP + '/adm/', { headers: { Cookie: phpCookie } });
|
||||
if (r.status !== 200) return false;
|
||||
const t = await r.text();
|
||||
return /관리자|admin|회원|level/i.test(t);
|
||||
});
|
||||
|
||||
// --- React side ---
|
||||
await check('[REACT] GET /react/ (homepage 200)', async () => {
|
||||
const r = await fetchOk(REACT + '/');
|
||||
return r.status === 200 || r.status === 308;
|
||||
});
|
||||
await check('[REACT] GET /react/robots.txt OR root (any 200/308)', async () => {
|
||||
const r = await fetchOk(REACT + '/free');
|
||||
return r.status === 200 || r.status === 308;
|
||||
});
|
||||
await check('[REACT] POST /react/api/auth/login as testlogin', async () => {
|
||||
const r = await fetchOk(REACT + '/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': reactCookie },
|
||||
body: new URLSearchParams({ loginId: REACT_USER, password: REACT_PASS }).toString(),
|
||||
});
|
||||
reactCookie = takeCookie(reactCookie, r);
|
||||
return r.status === 303 || r.status === 302;
|
||||
});
|
||||
await check('[REACT] GET /react/mypage as testlogin', async () => {
|
||||
const r = await fetchOk(REACT + '/mypage', { headers: { Cookie: reactCookie } });
|
||||
return r.status === 200 || r.status === 308;
|
||||
});
|
||||
await check('[REACT] GET /react/shop (200)', async () => {
|
||||
const r = await fetchOk(REACT + '/shop');
|
||||
return r.status === 200 || r.status === 308;
|
||||
});
|
||||
|
||||
// --- Cross-stack invariants (data parity) ---
|
||||
// PHP and React see the same MariaDB-vs-PG-restored data; counts on
|
||||
// public-facing pages should match within +/- 1 (timing of restore).
|
||||
await check('[CROSS] PHP /robots.txt blocked AND React /robots.txt blocked', async () => {
|
||||
const r = await fetchOk(HOST + '/robots.txt');
|
||||
if (r.status !== 200) return false;
|
||||
const t = await r.text();
|
||||
return /Disallow: \//.test(t);
|
||||
});
|
||||
|
||||
await check('[REACT] POST /react/api/auth/logout', async () => {
|
||||
const r = await fetchOk(REACT + '/api/auth/logout', { method: 'POST', headers: { Cookie: reactCookie } });
|
||||
return r.status === 303 || r.status === 302;
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log(`Cross-verify PHP vs React on ${HOST}, ${ITER} iterations`);
|
||||
for (let i = 1; i <= ITER; i++) await iteration(i);
|
||||
console.log(`\n=== TOTAL: ${pass} passed, ${fail} failed ===`);
|
||||
if (fail > 0) {
|
||||
console.log('Failures:'); for (const f of failures) console.log(' - ' + f);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
})();
|
||||
Reference in New Issue
Block a user