Skip to content

Commit 7fc9163

Browse files
aster-voidclaude
andcommitted
旧 utcode.net 互換性修正と認証セキュリティ強化
URL リダイレクト: - /articles/{N} のページネーション数字が記事に誤リダイレクトされる問題を修正 - /articles/YYYY/MM-DD-slug (ハイフン区切り) を format2 で救済 - /members/YYYY/<slug> → /members/<slug> (301) - /projects/(festival|hackathon)/<x>/<slug> → /projects/<slug> (301) - /projects/<kind> → /projects?category=<kind> (301) - /contact, /welcome-events*, /activities/<sub> の 301 追加 - /sitemap-{index,0}.xml → /sitemap.xml (301) - /projects/[slug] 未存在時を soft-404 から hard-404 に SEO/レンダリング: - サムネ未設定記事の `+/images/...` フォールバックを正規化 - og:locale (ja_JP), twitter:site, keywords を復活 認証: - /orgs/ut-code/memberships/{login} API に切替 (private membership 対応) - メンバーシップキャッシュ TTL を 24h → 1h に短縮 - 非メンバー (403) のセッションを cleanup して再ログイン強制 その他: - scripts/check-parity.ts: 互換性検証スクリプトを追加 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 51fa253 commit 7fc9163

13 files changed

Lines changed: 617 additions & 39 deletions

File tree

scripts/check-parity.ts

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
#!/usr/bin/env bun
2+
// utcode.net (旧) → cms.utcode.net (新) の互換性検証スクリプト。
3+
//
4+
// 検証範囲:
5+
// - URL の後方互換性 (リダイレクト仕様 + 旧 sitemap サンプリング)
6+
// - soft-404 の検出
7+
// - SEO メタタグ (og:locale, twitter:site)
8+
// - サムネ未設定記事のフォールバック画像 (`+/images/...` の残骸)
9+
// - 認可ガード (admin/api 系の未認証アクセス)
10+
//
11+
// 使い方:
12+
// bun scripts/check-parity.ts
13+
// NEW_BASE=http://localhost:5173 bun scripts/check-parity.ts
14+
15+
const NEW_BASE = process.env.NEW_BASE ?? "https://cms.utcode.net";
16+
const OLD_BASE = process.env.OLD_BASE ?? "https://utcode.net";
17+
18+
type Result = { category: string; name: string; pass: boolean; detail: string };
19+
const results: Result[] = [];
20+
21+
function record(category: string, name: string, pass: boolean, detail: string) {
22+
results.push({ category, name, pass, detail });
23+
process.stdout.write(`${pass ? "✓" : "✗"} [${category}] ${name}${detail}\n`);
24+
}
25+
26+
type ManualResp = { status: number; location: string | null; body: string; error?: string };
27+
type FollowResp = { status: number; finalUrl: string; body: string; error?: string };
28+
29+
// SSE 等の長時間レスポンスでハングしないよう個別タイムアウト。
30+
const REQUEST_TIMEOUT_MS = 10_000;
31+
32+
async function fetchManual(path: string): Promise<ManualResp> {
33+
try {
34+
const res = await fetch(`${NEW_BASE}${path}`, {
35+
redirect: "manual",
36+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
37+
});
38+
// body は読まずに即 cancel (SSE 等のストリームでハングしないため)。
39+
// ヘッダだけ確認したいケースが多いので、必要時のみ body 読みに切替。
40+
const isRedirect = res.status >= 300 && res.status < 400;
41+
const body = isRedirect ? "" : await res.text().catch(() => "");
42+
return { status: res.status, location: res.headers.get("location"), body };
43+
} catch (e) {
44+
return { status: 0, location: null, body: "", error: (e as Error).message };
45+
}
46+
}
47+
48+
async function fetchFollow(path: string): Promise<FollowResp> {
49+
try {
50+
const res = await fetch(`${NEW_BASE}${path}`, {
51+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
52+
});
53+
return { status: res.status, finalUrl: res.url, body: await res.text().catch(() => "") };
54+
} catch (e) {
55+
return { status: 0, finalUrl: "", body: "", error: (e as Error).message };
56+
}
57+
}
58+
59+
type Expect =
60+
| { kind: "redirect"; status: 301 | 302 | 308; location: RegExp }
61+
| { kind: "status"; status: number }
62+
| { kind: "notRedirect301" }; // 誤リダイレクトされていないことの確認
63+
64+
async function checkCase(category: string, path: string, expect: Expect) {
65+
const r = await fetchManual(path);
66+
const got = `${r.status}${r.location ? ` → ${r.location}` : ""}`;
67+
switch (expect.kind) {
68+
case "redirect": {
69+
if (r.status !== expect.status) {
70+
record(category, path, false, `expected ${expect.status}, got ${got}`);
71+
return;
72+
}
73+
const loc = r.location ?? "";
74+
if (!expect.location.test(loc)) {
75+
record(category, path, false, `expected location ${expect.location}, got ${got}`);
76+
return;
77+
}
78+
record(category, path, true, got);
79+
return;
80+
}
81+
case "status": {
82+
if (r.status !== expect.status) {
83+
record(category, path, false, `expected ${expect.status}, got ${got}`);
84+
return;
85+
}
86+
record(category, path, true, got);
87+
return;
88+
}
89+
case "notRedirect301": {
90+
if (r.status === 301) {
91+
record(category, path, false, `was incorrectly 301-redirected to ${r.location}`);
92+
return;
93+
}
94+
record(category, path, true, got);
95+
return;
96+
}
97+
}
98+
}
99+
100+
// === 1. リダイレクト仕様の検証 ===
101+
async function checkRedirectSpecs() {
102+
// ページネーション URL は誤リダイレクトされないこと (旧 utcode.net の /articles/{2..8,10} 対策)
103+
for (const n of [2, 3, 4, 5, 6, 7, 8, 10]) {
104+
await checkCase("PAGINATION", `/articles/${n}`, { kind: "notRedirect301" });
105+
}
106+
107+
// articles format2 の dash separator 拡張
108+
await checkCase("ARTICLES", "/articles/2024/05-14-may-festival/", {
109+
kind: "redirect",
110+
status: 301,
111+
location: /\/articles\/2024-05-14-may-festival/,
112+
});
113+
114+
// members
115+
await checkCase("MEMBERS", "/members/2025/yosei-iwasaki/", {
116+
kind: "redirect",
117+
status: 301,
118+
location: /\/members\/yosei-iwasaki/,
119+
});
120+
await checkCase("MEMBERS", "/members/2024/some-slug/", {
121+
kind: "redirect",
122+
status: 301,
123+
location: /\/members\/some-slug/,
124+
});
125+
126+
// projects (festival/hackathon マルチセグメント)
127+
await checkCase("PROJECTS", "/projects/festival/kf73/code-vs-code/", {
128+
kind: "redirect",
129+
status: 301,
130+
location: /\/projects\/code-vs-code/,
131+
});
132+
await checkCase("PROJECTS", "/projects/hackathon/2024-06-08/bowling/", {
133+
kind: "redirect",
134+
status: 301,
135+
location: /\/projects\/bowling/,
136+
});
137+
138+
// projects kind
139+
await checkCase("PROJECTS_KIND", "/projects/festival", {
140+
kind: "redirect",
141+
status: 301,
142+
location: /\/projects\?category=festival/,
143+
});
144+
await checkCase("PROJECTS_KIND", "/projects/hackathon", {
145+
kind: "redirect",
146+
status: 301,
147+
location: /\/projects\?category=hackathon/,
148+
});
149+
await checkCase("PROJECTS_KIND", "/projects/long-term", {
150+
kind: "redirect",
151+
status: 301,
152+
location: /\/projects(\?|$)/,
153+
});
154+
155+
// contact / welcome / activities
156+
await checkCase("MISC", "/contact/", {
157+
kind: "redirect",
158+
status: 301,
159+
location: /\/about/,
160+
});
161+
await checkCase("MISC", "/welcome-events/", {
162+
kind: "redirect",
163+
status: 301,
164+
location: /\/join\/welcome-events/,
165+
});
166+
await checkCase("MISC", "/welcome-events-2025-summer/", {
167+
kind: "redirect",
168+
status: 301,
169+
location: /\/join\/welcome-events/,
170+
});
171+
for (const sub of ["learn", "share", "develop"]) {
172+
await checkCase("MISC", `/activities/${sub}/`, {
173+
kind: "redirect",
174+
status: 301,
175+
location: /\/activities/,
176+
});
177+
}
178+
179+
// sitemap
180+
await checkCase("SITEMAP", "/sitemap-index.xml", {
181+
kind: "redirect",
182+
status: 301,
183+
location: /\/sitemap\.xml/,
184+
});
185+
await checkCase("SITEMAP", "/sitemap-0.xml", {
186+
kind: "redirect",
187+
status: 301,
188+
location: /\/sitemap\.xml/,
189+
});
190+
191+
// 既存 articles format2 (sanity check)
192+
await checkCase("ARTICLES", "/articles/2024/07-13_s-specialized-seminar-report-2/", {
193+
kind: "redirect",
194+
status: 301,
195+
location: /\/articles\/2024-07-13-s-specialized-seminar-report-2/,
196+
});
197+
}
198+
199+
// === 2. soft-404 の検出 ===
200+
async function checkSoft404() {
201+
// 存在しない project slug は 404 を返すべき (旧実装は 200 で「見つかりません」テンプレ)
202+
const r = await fetchFollow("/projects/this-project-totally-does-not-exist-xyz123");
203+
record("SOFT_404", "/projects/<missing>", r.status === 404, `status=${r.status}`);
204+
}
205+
206+
// === 3. SEO メタタグ ===
207+
async function checkMeta() {
208+
const r = await fetchFollow("/");
209+
const ogLocale = /(?:property|name)=["']og:locale["'][^>]*content=["']ja_JP["']/i.test(r.body) ||
210+
/content=["']ja_JP["'][^>]*(?:property|name)=["']og:locale["']/i.test(r.body);
211+
const twitterSite = /name=["']twitter:site["'][^>]*content=["']@[^"']+["']/i.test(r.body) ||
212+
/content=["']@[^"']+["'][^>]*name=["']twitter:site["']/i.test(r.body);
213+
record("META", "og:locale=ja_JP", ogLocale, ogLocale ? "found" : "missing on /");
214+
record("META", "twitter:site", twitterSite, twitterSite ? "found" : "missing on /");
215+
}
216+
217+
// === 4. no-image fallback ===
218+
async function checkNoImage() {
219+
// 旧 28 件のうち代表サンプル
220+
const samples = [
221+
"/articles/2019-11-03-utcode-lectures-01",
222+
"/articles/2021-09-24-tuk-programming-workshop",
223+
"/articles/2022-07-15-summer-events",
224+
"/articles/2019-09-30-2019a-schedule",
225+
];
226+
for (const path of samples) {
227+
const r = await fetchFollow(path);
228+
if (r.status !== 200) {
229+
record("NO_IMAGE", path, false, `article not reachable (status=${r.status})`);
230+
continue;
231+
}
232+
const broken = r.body.includes("+/images/no-image.svg") ||
233+
r.body.includes("%2B/images/no-image.svg") ||
234+
r.body.includes("+%2Fimages%2Fno-image.svg");
235+
record("NO_IMAGE", path, !broken, broken ? "found broken `+/images/no-image.svg`" : "ok");
236+
}
237+
}
238+
239+
// === 5. 認可ガード ===
240+
async function checkAuth() {
241+
const admin = await fetchManual("/admin");
242+
const adminOk = (admin.status === 302 || admin.status === 303 || admin.status === 307) &&
243+
/\/login/.test(admin.location ?? "");
244+
record(
245+
"AUTH",
246+
"/admin redirects to /login",
247+
adminOk,
248+
`${admin.status}${admin.location ? ` → ${admin.location}` : ""}`,
249+
);
250+
251+
const sse = await fetchManual("/api/migration/events");
252+
record("AUTH", "/api/migration/events 401", sse.status === 401, `status=${sse.status}`);
253+
}
254+
255+
// === 6. 旧 sitemap サンプリング ===
256+
async function checkSitemapSamples() {
257+
let xml: string;
258+
try {
259+
const res = await fetch(`${OLD_BASE}/sitemap-0.xml`);
260+
if (!res.ok) {
261+
record("SITEMAP_SAMPLE", "fetch old sitemap", false, `${res.status}`);
262+
return;
263+
}
264+
xml = await res.text();
265+
} catch (e) {
266+
record("SITEMAP_SAMPLE", "fetch old sitemap", false, `${(e as Error).message}`);
267+
return;
268+
}
269+
270+
const allPaths = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)]
271+
.map((m) => {
272+
try {
273+
return new URL(m[1]).pathname;
274+
} catch {
275+
return null;
276+
}
277+
})
278+
.filter((p): p is string => p !== null);
279+
280+
// カテゴリ別にサンプリング: 各プレフィックスから最大 N 件
281+
const buckets: Record<string, string[]> = {};
282+
for (const p of allPaths) {
283+
const key = p.split("/").slice(0, 3).join("/") || "/";
284+
(buckets[key] ??= []).push(p);
285+
}
286+
const sampled: string[] = [];
287+
for (const paths of Object.values(buckets)) {
288+
sampled.push(...paths.slice(0, 3));
289+
}
290+
291+
let pass = 0;
292+
let fail = 0;
293+
const failed: string[] = [];
294+
295+
for (const path of sampled) {
296+
const r = await fetchManual(path);
297+
if (r.status === 200) {
298+
pass++;
299+
continue;
300+
}
301+
if (r.status === 301 || r.status === 308) {
302+
const followUrl = r.location?.startsWith("http") ? r.location : `${NEW_BASE}${r.location ?? ""}`;
303+
try {
304+
const followed = await fetch(followUrl, { redirect: "follow" });
305+
if (followed.ok) {
306+
pass++;
307+
} else {
308+
fail++;
309+
failed.push(`${path}${r.location}${followed.status}`);
310+
}
311+
} catch (e) {
312+
fail++;
313+
failed.push(`${path}${r.location}${(e as Error).message}`);
314+
}
315+
continue;
316+
}
317+
fail++;
318+
failed.push(`${path}${r.status}`);
319+
}
320+
321+
const detail = `${pass}/${sampled.length} reachable${
322+
failed.length > 0 ? `\n - ${failed.slice(0, 20).join("\n - ")}${failed.length > 20 ? `\n - ... (${failed.length - 20} more)` : ""}` : ""
323+
}`;
324+
record("SITEMAP_SAMPLE", `${sampled.length} URLs across ${Object.keys(buckets).length} buckets`, fail === 0, detail);
325+
}
326+
327+
async function main() {
328+
process.stdout.write(`Checking ${NEW_BASE} (old reference: ${OLD_BASE})\n\n`);
329+
330+
process.stdout.write("--- 1. URL リダイレクト仕様 ---\n");
331+
await checkRedirectSpecs();
332+
333+
process.stdout.write("\n--- 2. Soft-404 ---\n");
334+
await checkSoft404();
335+
336+
process.stdout.write("\n--- 3. SEO メタタグ ---\n");
337+
await checkMeta();
338+
339+
process.stdout.write("\n--- 4. no-image フォールバック ---\n");
340+
await checkNoImage();
341+
342+
process.stdout.write("\n--- 5. 認可ガード ---\n");
343+
await checkAuth();
344+
345+
process.stdout.write("\n--- 6. 旧 sitemap サンプリング ---\n");
346+
await checkSitemapSamples();
347+
348+
const total = results.length;
349+
const passed = results.filter((r) => r.pass).length;
350+
const failed = total - passed;
351+
process.stdout.write(`\n=== Summary ===\n`);
352+
process.stdout.write(`Total: ${total}, Passed: ${passed}, Failed: ${failed}\n`);
353+
if (failed > 0) {
354+
process.stdout.write("\nFailed checks:\n");
355+
for (const r of results.filter((r) => !r.pass)) {
356+
process.stdout.write(` ✗ [${r.category}] ${r.name}${r.detail}\n`);
357+
}
358+
process.exit(1);
359+
}
360+
}
361+
362+
await main();

0 commit comments

Comments
 (0)