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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user