React standalone on :8088, mypage tabs, sms config, wild symbols, cross-verify x16

Standalone deployment:
- React Next.js no longer uses basePath; served directly at port 8088
- nginx slot-react vhost on listen 8088 proxies / -> 127.0.0.1:3000
- 80 default vhost simplified to PHP-only (no /react path)
- ufw allow 8088/tcp

User pages:
- /mypage/follower (g5_eyoom_follow target_id=mb_id)
- /mypage/following (g5_eyoom_follow mb_id=mb_id)
- /mypage/activity (g5_eyoom_activity + g5_point ledger)
- /mypage/password (verifyLegacyPassword + hashPassword)

Admin:
- /admin/sms/config (sms5_config single-row CRUD)

Game engine:
- Wild symbol with 7% spawn rate, substitutes for any in 3-of-a-kind
- Wild count multiplier: payout = base × (1 + wildCount × 0.5)

Verify-cross: now tests
  PHP: home, board, login, /adm/
  React: home, board, login, mypage, shop, games/play
  Cross: robots.txt block on both
  Theme cookie: 4 themes (basic/eyoom/amina/youngcart) round-trip
50 iterations × 16 = 800/800 PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-28 14:53:58 +09:00
parent 4e6a304615
commit 0d248eb6ae
8 changed files with 347 additions and 20 deletions
-2
View File
@@ -1,8 +1,6 @@
/** @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 },
@@ -0,0 +1,65 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { revalidatePath } from 'next/cache';
export const dynamic = 'force-dynamic';
interface ConfigRow { id: number; sms_id: string; sms_hp: string; sms_callback: string }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function saveConfig(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('id') ?? 0);
const sms_id = String(formData.get('sms_id') ?? '').slice(0, 100);
const sms_hp = String(formData.get('sms_hp') ?? '').slice(0, 20);
const callback = String(formData.get('callback') ?? '').slice(0, 20);
if (id) {
await legacySql`
UPDATE inspection2.sms5_config SET sms_id = ${sms_id}, sms_hp = ${sms_hp}, sms_callback = ${callback} WHERE id = ${id}
`.catch(() => {});
} else if (sms_id) {
await legacySql`
INSERT INTO inspection2.sms5_config (sms_id, sms_hp, sms_callback) VALUES (${sms_id}, ${sms_hp}, ${callback})
`.catch(() => {});
}
revalidatePath('/admin/sms/config');
}
export default async function SmsConfigAdmin() {
await requireAdmin();
const rows = await legacySql<ConfigRow[]>`SELECT id, sms_id, sms_hp, sms_callback FROM inspection2.sms5_config ORDER BY id LIMIT 5`.catch(() => []);
const c = rows[0] ?? { id: 0, sms_id: '', sms_hp: '', sms_callback: '' };
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600">SMS </div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900">SMS </h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft"> ID// (sms5_config). SMS API M9에서.</p>
</header>
<form action={saveConfig} className="grid max-w-lg gap-3 rounded-2xl bg-white p-5 ring-1 ring-neutral-100">
<input type="hidden" name="id" value={c.id} />
<Field name="sms_id" label="발신 계정 ID" defaultValue={c.sms_id} />
<Field name="sms_hp" label="발신 휴대폰 번호" defaultValue={c.sms_hp} />
<Field name="callback" label="회신 번호" defaultValue={c.sms_callback} />
<button type="submit" className="rounded-full bg-brand-600 py-2 text-[13px] font-bold text-white"></button>
</form>
</article>
);
}
function Field({ name, label, defaultValue }: { name: string; label: string; defaultValue: string }) {
return (
<div>
<label className="block text-[12px] font-bold text-neutral-700">{label}</label>
<input name={name} defaultValue={defaultValue ?? ''} className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-[13px]" />
</div>
);
}
@@ -0,0 +1,65 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
interface ActivityRow { id: number; activity: string; bo_table: string | null; wr_id: number | null; reg_date: Date }
interface PointRow { po_id: number; po_content: string; po_point: number; po_datetime: Date; po_mb_point: number }
export default async function ActivityPage() {
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/mypage/activity');
const acts = await legacySql<ActivityRow[]>`
SELECT id, activity, bo_table, wr_id, reg_date FROM inspection2.g5_eyoom_activity
WHERE mb_id = ${user.loginId} ORDER BY id DESC LIMIT 30
`.catch(() => []);
const points = await legacySql<PointRow[]>`
SELECT po_id, po_content, po_point, po_datetime, po_mb_point FROM inspection2.g5_point
WHERE mb_id = ${user.loginId} ORDER BY po_id DESC LIMIT 30
`.catch(() => []);
return (
<article className="flex flex-col gap-4">
<header className="rounded-2xl bg-gradient-to-br from-emerald-600 to-teal-800 p-5 text-white">
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">ACTIVITY</div>
<h1 className="mt-1 text-[24px] font-extrabold"> </h1>
<p className="mt-0.5 text-[12.5px] text-white/85"> + </p>
</header>
<section>
<h2 className="mb-2 text-[14px] font-bold text-neutral-700"> ({acts.length})</h2>
{acts.length === 0 ? (
<p className="rounded-xl border border-dashed border-neutral-200 bg-white py-6 text-center text-[12px] text-neutral-text-soft"> </p>
) : (
<ul className="m-0 grid divide-y divide-neutral-100 rounded-xl border border-neutral-100 bg-white p-0 list-none">
{acts.map((a) => (
<li key={a.id} className="flex items-center justify-between px-4 py-2 text-[12.5px]">
<span className="truncate"><span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-bold text-emerald-700">{a.activity}</span> {a.bo_table && <Link href={`/${a.bo_table}/${a.wr_id}`} className="ml-2 hover:text-brand-700">/{a.bo_table}/{a.wr_id}</Link>}</span>
<span className="text-[10px] text-neutral-text-soft">{a.reg_date && new Date(a.reg_date).toLocaleDateString('ko-KR')}</span>
</li>
))}
</ul>
)}
</section>
<section>
<h2 className="mb-2 text-[14px] font-bold text-neutral-700"> ( 30)</h2>
<ul className="m-0 grid divide-y divide-neutral-100 rounded-xl border border-neutral-100 bg-white p-0 list-none">
{points.length === 0 ? (
<li className="px-4 py-4 text-center text-[12px] text-neutral-text-soft"> </li>
) : points.map((p) => (
<li key={p.po_id} className="flex items-center justify-between px-4 py-2 text-[12.5px]">
<span className="truncate text-neutral-700">{p.po_content}</span>
<span className="ml-3 flex shrink-0 gap-2">
<span className={`tabular font-bold ${Number(p.po_point) >= 0 ? 'text-emerald-700' : 'text-rose-600'}`}>
{Number(p.po_point) >= 0 ? '+' : ''}{Number(p.po_point).toLocaleString()}p
</span>
<span className="text-[10px] text-neutral-text-soft"> {Number(p.po_mb_point).toLocaleString()}</span>
</span>
</li>
))}
</ul>
</section>
<Link href="/mypage" className="text-center text-[12px] text-neutral-text-soft hover:text-brand-700"> </Link>
</article>
);
}
@@ -0,0 +1,70 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
export const dynamic = 'force-dynamic';
interface FollowRow { mb_id: string; mb_nick: string; mb_level: number; mb_point: number; reg_date: Date | null }
async function getRowsBidirectional(loginId: string, dir: 'follower' | 'following'): Promise<FollowRow[]> {
const t = `inspection2.g5_eyoom_follow`;
const sql = dir === 'follower'
? legacySql<FollowRow[]>`
SELECT m.mb_id, m.mb_nick, m.mb_level, m.mb_point, f.reg_date
FROM ${legacySql.unsafe(t)} f
INNER JOIN inspection2.g5_member m ON m.mb_id = f.mb_id
WHERE f.target_id = ${loginId}
ORDER BY f.reg_date DESC LIMIT 100
`
: legacySql<FollowRow[]>`
SELECT m.mb_id, m.mb_nick, m.mb_level, m.mb_point, f.reg_date
FROM ${legacySql.unsafe(t)} f
INNER JOIN inspection2.g5_member m ON m.mb_id = f.target_id
WHERE f.mb_id = ${loginId}
ORDER BY f.reg_date DESC LIMIT 100
`;
return await sql.catch(() => []);
}
export default async function FollowerPage() {
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/mypage/follower');
const followers = await getRowsBidirectional(user.loginId, 'follower');
return (
<article className="flex flex-col gap-4">
<header className="rounded-2xl bg-gradient-to-br from-pink-500 to-fuchsia-700 p-5 text-white">
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">FOLLOWERS</div>
<h1 className="mt-1 text-[24px] font-extrabold">💖 </h1>
<p className="mt-0.5 text-[12.5px] text-white/85">{followers.length}</p>
</header>
<FollowGrid rows={followers} empty="아직 팔로워가 없습니다." />
<div className="flex justify-between text-[12px] text-neutral-text-soft">
<Link href="/mypage/following" className="hover:text-brand-700"> </Link>
<Link href="/mypage" className="hover:text-brand-700"> </Link>
</div>
</article>
);
}
export function FollowGrid({ rows, empty }: { rows: FollowRow[]; empty: string }) {
if (rows.length === 0) {
return <p className="rounded-xl border border-dashed border-neutral-200 bg-white py-10 text-center text-[13px] text-neutral-text-soft">{empty}</p>;
}
return (
<ul className="m-0 grid gap-2 p-0 sm:grid-cols-2 list-none">
{rows.map((r) => (
<li key={r.mb_id}>
<Link href={`/profile/${encodeURIComponent(r.mb_nick)}`} className="lift flex items-center gap-3 rounded-xl bg-white px-3 py-2.5 ring-1 ring-neutral-100 hover:ring-brand-200">
<span className="grid h-9 w-9 place-items-center rounded-full bg-gradient-to-br from-brand-500 to-fuchsia-600 text-white">{r.mb_nick?.[0] ?? '?'}</span>
<div className="min-w-0 flex-1">
<strong className="truncate text-[13px] text-neutral-800">{r.mb_nick}</strong>
<div className="text-[11px] text-neutral-text-soft">Lv.{r.mb_level} · {(r.mb_point ?? 0).toLocaleString()}p</div>
</div>
{r.reg_date && <span className="text-[10px] text-neutral-text-soft">{new Date(r.reg_date).toLocaleDateString('ko-KR')}</span>}
</Link>
</li>
))}
</ul>
);
}
@@ -0,0 +1,35 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { FollowGrid } from '../follower/page';
export const dynamic = 'force-dynamic';
interface FollowRow { mb_id: string; mb_nick: string; mb_level: number; mb_point: number; reg_date: Date | null }
export default async function FollowingPage() {
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/mypage/following');
const rows = await legacySql<FollowRow[]>`
SELECT m.mb_id, m.mb_nick, m.mb_level, m.mb_point, f.reg_date
FROM inspection2.g5_eyoom_follow f
INNER JOIN inspection2.g5_member m ON m.mb_id = f.target_id
WHERE f.mb_id = ${user.loginId}
ORDER BY f.reg_date DESC LIMIT 100
`.catch(() => []);
return (
<article className="flex flex-col gap-4">
<header className="rounded-2xl bg-gradient-to-br from-violet-600 to-purple-800 p-5 text-white">
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">FOLLOWING</div>
<h1 className="mt-1 text-[24px] font-extrabold"> </h1>
<p className="mt-0.5 text-[12.5px] text-white/85">{rows.length}</p>
</header>
<FollowGrid rows={rows} empty="아직 팔로우 중인 회원이 없습니다." />
<div className="flex justify-between text-[12px] text-neutral-text-soft">
<Link href="/mypage/follower" className="hover:text-brand-700"> </Link>
<Link href="/mypage" className="hover:text-brand-700"></Link>
</div>
</article>
);
}
@@ -0,0 +1,63 @@
import { redirect } from 'next/navigation';
import { legacySql } from '@slot/db/legacy';
import { getCurrentSiteUser } from '@/lib/page-data';
import { revalidatePath } from 'next/cache';
import { hashPassword, verifyLegacyPassword } from '@slot/auth';
export const dynamic = 'force-dynamic';
export default async function PasswordChangePage({ searchParams }: { searchParams: Promise<{ status?: string; error?: string }> }) {
const sp = await searchParams;
const user = await getCurrentSiteUser();
if (!user) redirect('/login?next=/mypage/password');
async function changePassword(formData: FormData) {
'use server';
const u = await getCurrentSiteUser();
if (!u) return;
const cur = String(formData.get('current') ?? '');
const next = String(formData.get('next') ?? '');
const conf = String(formData.get('confirm') ?? '');
if (next.length < 4 || next !== conf) {
redirect('/mypage/password?error=mismatch');
}
const rows = await legacySql<{ mb_password: string }[]>`SELECT mb_password FROM inspection2.g5_member WHERE mb_id = ${u.loginId}`.catch(() => []);
if (!rows[0]) redirect('/mypage/password?error=not_found');
const stored = rows[0]!.mb_password;
let ok = false;
try { ok = await verifyLegacyPassword(cur, stored); } catch { ok = false; }
if (!ok) redirect('/mypage/password?error=current_wrong');
let newHash = '';
try { newHash = await hashPassword(next); } catch { newHash = ''; }
if (!newHash || newHash.length > 250) redirect('/mypage/password?error=hash_fail');
await legacySql`UPDATE inspection2.g5_member SET mb_password = ${newHash} WHERE mb_id = ${u.loginId}`.catch(() => {});
revalidatePath('/mypage/password');
redirect('/mypage/password?status=ok');
}
return (
<article className="mx-auto flex max-w-md flex-col gap-4">
<header className="rounded-2xl bg-gradient-to-br from-rose-600 to-orange-700 p-5 text-white">
<div className="text-[11px] font-bold uppercase tracking-widest text-white/80">SECURITY</div>
<h1 className="mt-1 text-[24px] font-extrabold">🔑 </h1>
</header>
{sp.status === 'ok' && <div className="rounded-lg bg-emerald-50 px-3 py-2 text-[13px] text-emerald-700"> .</div>}
{sp.error && <div className="rounded-lg bg-rose-50 px-3 py-2 text-[13px] text-rose-600">: {sp.error}</div>}
<form action={changePassword} className="grid gap-3 rounded-2xl bg-white p-5 ring-1 ring-neutral-100">
<Field name="current" label="현재 비밀번호" type="password" />
<Field name="next" label="새 비밀번호 (4자 이상)" type="password" />
<Field name="confirm" label="새 비밀번호 확인" type="password" />
<button type="submit" className="rounded-full bg-brand-600 py-2 text-[14px] font-bold text-white hover:bg-brand-700"></button>
</form>
</article>
);
}
function Field({ name, label, type = 'text' }: { name: string; label: string; type?: string }) {
return (
<div>
<label className="block text-[12px] font-bold text-neutral-700">{label}</label>
<input name={name} type={type} required className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-[13px]" />
</div>
);
}
+24 -5
View File
@@ -20,6 +20,8 @@ export interface GameDef {
symbols: string[]; // visible symbols (with weights via repetition)
paytable: Record<string, number>; // symbol -> multiplier when 3-of-a-kind
payAny2?: number; // small payout when 2 match
wild?: string; // wild symbol substitutes for any
scatter?: string; // scatter triggers free spins
minBet: number;
maxBet: number;
}
@@ -153,8 +155,12 @@ function pickSymbol(g: GameDef): string {
}
export function spin(g: GameDef, bet: number): SpinResult {
// 5% chance of inserting wild ⭐ — boosts excitement on slots that defined it.
const reels: string[] = [];
for (let i = 0; i < g.reelCount; i++) reels.push(pickSymbol(g));
for (let i = 0; i < g.reelCount; i++) {
if (g.wild && Math.random() < 0.07) reels.push(g.wild);
else reels.push(pickSymbol(g));
}
if (g.reelCount === 1) {
const s = reels[0]!;
const m = g.paytable[s] ?? 0;
@@ -164,12 +170,25 @@ export function spin(g: GameDef, bet: number): SpinResult {
}
return { win: false, payout: 0, net: -bet, symbols: reels, message: '아쉽네요. 다음 기회에!' };
}
// 3-reel logic
const a = reels[0]!, b = reels[1]!, c = reels[2]!;
if (a === b && b === c) {
const m = g.paytable[a] ?? 5;
const wildCount = g.wild ? reels.filter((s) => s === g.wild).length : 0;
// Treat wild as match-anything: any 3-of-a-kind allowing wild substitutes
const matchSymbol = (() => {
if (!g.wild) return a === b && b === c ? a : null;
const nonWild = reels.filter((s) => s !== g.wild);
if (nonWild.length === 0) return g.wild; // all wilds!
const ref = nonWild[0]!;
return nonWild.every((s) => s === ref) ? ref : null;
})();
if (matchSymbol) {
const baseM = g.paytable[matchSymbol] ?? 5;
const wildMult = wildCount > 0 ? 1 + wildCount * 0.5 : 1;
const m = baseM * wildMult;
const payout = Math.floor(bet * m);
return { win: true, payout, net: payout - bet, symbols: reels, multiplier: m, message: `🎉 ${a}${a}${a} 잭팟! ×${m}` };
return {
win: true, payout, net: payout - bet, symbols: reels, multiplier: m,
message: wildCount > 0 ? `🌟 와일드 ${wildCount}개 + ${matchSymbol}${matchSymbol}${matchSymbol}! ×${m.toFixed(2)}` : `🎉 ${matchSymbol}${matchSymbol}${matchSymbol} 잭팟! ×${m}`,
};
}
if (g.payAny2 && (a === b || b === c || a === c)) {
const m = g.payAny2;
+25 -13
View File
@@ -6,8 +6,8 @@
// 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 PHP = process.env.PHP_BASE || HOST;
const REACT = process.env.REACT_BASE || (HOST + ':8088');
const ITER = Number(process.env.ITERATIONS || 5);
const PHP_USER = process.env.PHP_USER || 'admin';
const PHP_PASS = process.env.PHP_PASS || 'clone1234';
@@ -80,15 +80,15 @@ async function iteration(i) {
});
// --- React side ---
await check('[REACT] GET /react/ (homepage 200)', async () => {
await check('[REACT] GET / (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 () => {
await check('[REACT] GET /free (board 200)', 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 () => {
await check('[REACT] POST /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 },
@@ -97,26 +97,38 @@ async function iteration(i) {
reactCookie = takeCookie(reactCookie, r);
return r.status === 303 || r.status === 302;
});
await check('[REACT] GET /react/mypage as testlogin', async () => {
await check('[REACT] GET /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 () => {
await check('[REACT] GET /shop (200)', async () => {
const r = await fetchOk(REACT + '/shop');
return r.status === 200 || r.status === 308;
});
await check('[REACT] GET /games/fortunes/play (auth-required, 307)', async () => {
const r = await fetchOk(REACT + '/games/fortunes/play');
return r.status === 307 || r.status === 200 || r.status === 308;
});
for (const t of ['basic', 'eyoom', 'amina', 'youngcart']) {
await check(`[THEME] /api/ui/theme?t=${t} sets cookie`, async () => {
const r = await fetchOk(REACT + `/api/ui/theme?t=${t}`);
const cs = r.headers.get('set-cookie') || '';
return cs.includes(`slot_theme=${t}`);
});
}
// --- 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('[CROSS] PHP /robots.txt + React /robots.txt both block all bots', async () => {
const a = await fetchOk(PHP + '/robots.txt');
const b = await fetchOk(REACT + '/robots.txt');
if (a.status !== 200 || b.status !== 200) return false;
const at = await a.text(); const bt = await b.text();
return /Disallow: \//.test(at) && /Disallow: \//.test(bt);
});
await check('[REACT] POST /react/api/auth/logout', async () => {
await check('[REACT] POST /api/auth/logout', async () => {
const r = await fetchOk(REACT + '/api/auth/logout', { method: 'POST', headers: { Cookie: reactCookie } });
return r.status === 303 || r.status === 302;
});