diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4f1aee..a40e711c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -153,6 +153,19 @@ real world uses." ### Added +- **npm wrapper installs cleanly on OpenHarmony / HarmonyPC** + (#1072, harvested from PR #1499 by **@CrepuscularIRIS / + autoghclaw**). `os.platform()` returns `openharmony` on + HarmonyPC and on OpenHarmony's Linux ABI-compatible userspace, + but the npm wrapper's platform-asset matrix only covered + `linux` / `darwin` / `win32`, so `npm i -g deepseek-tui` would + abort with `Unsupported platform: openharmony` even though the + Linux x64 / arm64 binaries run unchanged on that environment. + Added a `PLATFORM_ALIASES` mapping that resolves `openharmony` + to the `linux` asset family before lookup so install succeeds + on those hosts. The error message for genuinely unsupported + platforms still reports the raw platform name (`freebsd`, + etc.) so OS-mismatch reports stay diagnostic. - **Startup empty-state shows useful context instead of repeating the header** (harvested from PR #1444 by **@reidliu41**). The center of the welcome view used to repeat diff --git a/npm/deepseek-tui/scripts/artifacts.js b/npm/deepseek-tui/scripts/artifacts.js index ebf18fd0..080585ec 100644 --- a/npm/deepseek-tui/scripts/artifacts.js +++ b/npm/deepseek-tui/scripts/artifacts.js @@ -17,14 +17,21 @@ const ASSET_MATRIX = { }, }; +// HarmonyPC (openharmony) is an x86_64 Linux-compatible environment; map it to +// the linux binary family so npm install succeeds without a separate build target. +const PLATFORM_ALIASES = { + openharmony: "linux", +}; + function detectBinaryNames() { - const platform = os.platform(); + const rawPlatform = os.platform(); + const platform = PLATFORM_ALIASES[rawPlatform] || rawPlatform; const arch = os.arch(); const defaults = ASSET_MATRIX[platform]; if (!defaults) { const supported = Object.keys(ASSET_MATRIX).map(p => `'${p}'`).join(', '); throw new Error( - `Unsupported platform: ${platform}. Supported platforms: ${supported}.\n\n` + + `Unsupported platform: ${rawPlatform}. Supported platforms: ${supported}.\n\n` + unsupportedBuildHint(), ); } diff --git a/npm/deepseek-tui/test/artifacts.test.js b/npm/deepseek-tui/test/artifacts.test.js new file mode 100644 index 00000000..0ef0d35d --- /dev/null +++ b/npm/deepseek-tui/test/artifacts.test.js @@ -0,0 +1,66 @@ +const assert = require("node:assert/strict"); +const path = require("node:path"); +const test = require("node:test"); +const os = require("os"); + +const ARTIFACTS_PATH = path.join(__dirname, "..", "scripts", "artifacts.js"); + +function withMockedOs(platform, arch, fn) { + const origPlatform = os.platform; + const origArch = os.arch; + os.platform = () => platform; + os.arch = () => arch; + delete require.cache[ARTIFACTS_PATH]; + try { + return fn(); + } finally { + os.platform = origPlatform; + os.arch = origArch; + delete require.cache[ARTIFACTS_PATH]; + } +} + +test("openharmony x64 resolves to linux x64 binaries", () => { + withMockedOs("openharmony", "x64", () => { + const { detectBinaryNames } = require(ARTIFACTS_PATH); + const result = detectBinaryNames(); + assert.equal(result.deepseek, "deepseek-linux-x64"); + assert.equal(result.tui, "deepseek-tui-linux-x64"); + }); +}); + +test("openharmony arm64 resolves to linux arm64 binaries", () => { + withMockedOs("openharmony", "arm64", () => { + const { detectBinaryNames } = require(ARTIFACTS_PATH); + const result = detectBinaryNames(); + assert.equal(result.deepseek, "deepseek-linux-arm64"); + assert.equal(result.tui, "deepseek-tui-linux-arm64"); + }); +}); + +test("genuinely unsupported platform throws with raw platform name", () => { + withMockedOs("freebsd", "x64", () => { + const { detectBinaryNames } = require(ARTIFACTS_PATH); + assert.throws( + () => detectBinaryNames(), + (err) => { + assert.match(err.message, /Unsupported platform: freebsd/); + return true; + }, + ); + }); +}); + +test("known platforms are unaffected by alias map", () => { + for (const [platform, arch, expectedDeepseek] of [ + ["linux", "x64", "deepseek-linux-x64"], + ["darwin", "arm64", "deepseek-macos-arm64"], + ["win32", "x64", "deepseek-windows-x64.exe"], + ]) { + withMockedOs(platform, arch, () => { + const { detectBinaryNames } = require(ARTIFACTS_PATH); + const result = detectBinaryNames(); + assert.equal(result.deepseek, expectedDeepseek); + }); + } +}); diff --git a/web/app/api/admin/post/route.ts b/web/app/api/admin/post/route.ts index b6d79e27..a74102b8 100644 --- a/web/app/api/admin/post/route.ts +++ b/web/app/api/admin/post/route.ts @@ -24,8 +24,18 @@ async function checkAuth(req: Request, env: CommunityAgentEnv): Promise<{ ok: bo return { ok: true }; } +const ALLOWED_ACTIONS = new Set(["post", "discard"]); +const ALLOWED_ORIGINS = new Set(["https://deepseek-tui.com", "https://www.deepseek-tui.com"]); +const MAX_BODY_BYTES = 65_536; + export async function POST(req: Request) { const env = await getAgentEnv(); + + const origin = req.headers.get("origin"); + if (origin && !ALLOWED_ORIGINS.has(origin)) { + return NextResponse.json({ error: "forbidden origin" }, { status: 403 }); + } + const auth = await checkAuth(req, env); if (!auth.ok) { return NextResponse.json( @@ -34,11 +44,25 @@ export async function POST(req: Request) { ); } + const contentLength = Number(req.headers.get("content-length") ?? "0"); + if (contentLength > MAX_BODY_BYTES) { + return NextResponse.json({ error: "payload too large" }, { status: 413 }); + } + const body = await req.json() as { action: string; draftKey: string; editedBody?: string; lang?: "en" | "zh" }; const { action, draftKey, editedBody, lang } = body; - if (!draftKey) { - return NextResponse.json({ error: "missing draftKey" }, { status: 400 }); + if (!ALLOWED_ACTIONS.has(action)) { + return NextResponse.json({ error: "unknown action" }, { status: 400 }); + } + if (typeof draftKey !== "string" || !draftKey || draftKey.length > 256) { + return NextResponse.json({ error: "missing or invalid draftKey" }, { status: 400 }); + } + if (editedBody !== undefined && (typeof editedBody !== "string" || editedBody.length > MAX_BODY_BYTES)) { + return NextResponse.json({ error: "editedBody too long" }, { status: 413 }); + } + if (lang !== undefined && lang !== "en" && lang !== "zh") { + return NextResponse.json({ error: "invalid lang" }, { status: 400 }); } const draft = await getDraft(env.CURATED_KV, draftKey); @@ -92,5 +116,6 @@ export async function POST(req: Request) { return NextResponse.json({ ok: true, action: "posted" }); } + // ALLOWED_ACTIONS guard above means this is unreachable. return NextResponse.json({ error: "unknown action" }, { status: 400 }); } diff --git a/web/app/api/cron/route.ts b/web/app/api/cron/route.ts index 30c9c0f8..34db6a62 100644 --- a/web/app/api/cron/route.ts +++ b/web/app/api/cron/route.ts @@ -11,6 +11,7 @@ import { } from "@/lib/community-agent-tasks"; import { runFactsDrift } from "@/lib/facts-drift"; import { runLinkCheck, runSemanticDrift } from "@/lib/content-watch"; +import { safeEqual } from "@/lib/community-agent"; export const dynamic = "force-dynamic"; @@ -37,8 +38,8 @@ export async function GET(req: Request) { ); } - const auth = req.headers.get("x-cron-secret"); - if (auth !== env.CRON_SECRET) { + const auth = req.headers.get("x-cron-secret") ?? ""; + if (!(await safeEqual(auth, env.CRON_SECRET))) { return NextResponse.json({ error: "unauthorized" }, { status: 401 }); } diff --git a/web/lib/deepseek.ts b/web/lib/deepseek.ts index bfa4f233..6b97d5f1 100644 --- a/web/lib/deepseek.ts +++ b/web/lib/deepseek.ts @@ -100,5 +100,20 @@ export async function curate( ); const parsed = JSON.parse(raw) as Omit; - return { ...parsed, generatedAt: new Date().toISOString() }; + return { ...sanitizeDispatch(parsed), generatedAt: new Date().toISOString() }; +} + +const SAFE_HREF_RE = /^https:\/\/(?:github\.com|api\.github\.com|deepseek-tui\.com|crates\.io|www\.npmjs\.com|docs\.rs)\//; +const FALLBACK_HREF = "https://github.com/Hmbown/deepseek-tui"; + +function safeHref(u: unknown): string { + return typeof u === "string" && SAFE_HREF_RE.test(u) ? u : FALLBACK_HREF; +} + +function sanitizeDispatch(d: Omit): Omit { + return { + ...d, + highlights: (d.highlights ?? []).map((h) => ({ ...h, href: safeHref(h.href) })), + movers: (d.movers ?? []).map((m) => ({ ...m, href: safeHref(m.href) })), + }; } diff --git a/web/lib/facts.generated.ts b/web/lib/facts.generated.ts index aceaad37..8c7ff726 100644 --- a/web/lib/facts.generated.ts +++ b/web/lib/facts.generated.ts @@ -18,8 +18,13 @@ export interface RepoFacts { } export const FACTS: RepoFacts = { +<<<<<<< Updated upstream "generatedAt": "2026-05-12T03:14:50.815Z", "version": "0.8.30", +======= + "generatedAt": "2026-05-10T15:06:44.698Z", + "version": "0.8.27", +>>>>>>> Stashed changes "crates": [ "agent", "app-server", diff --git a/web/middleware.ts b/web/middleware.ts index e39ddb41..c1000992 100644 --- a/web/middleware.ts +++ b/web/middleware.ts @@ -3,6 +3,19 @@ import { locales, defaultLocale } from "@/lib/i18n/config"; const COOKIE = "NEXT_LOCALE"; +const SECURITY_HEADERS: Record = { + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()", + "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload", +}; + +function applySecurityHeaders(res: NextResponse): NextResponse { + for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.headers.set(k, v); + return res; +} + function detectLocale(req: NextRequest): string { // 1. Cookie const cookie = req.cookies.get(COOKIE)?.value; @@ -18,22 +31,21 @@ function detectLocale(req: NextRequest): string { export function middleware(req: NextRequest) { const { pathname } = req.nextUrl; - // Skip API routes, static files, _next + // Skip API routes, static files, _next (but still apply security headers). if ( pathname.startsWith("/api/") || pathname.startsWith("/_next/") || pathname.includes(".") ) { - return NextResponse.next(); + return applySecurityHeaders(NextResponse.next()); } // Check if locale is already in path const seg = pathname.split("/")[1]; if (locales.includes(seg as typeof locales[number])) { - // Ensure cookie is set const res = NextResponse.next(); res.cookies.set(COOKIE, seg, { path: "/", maxAge: 60 * 60 * 24 * 365 }); - return res; + return applySecurityHeaders(res); } // Redirect bare paths to detected locale @@ -42,9 +54,11 @@ export function middleware(req: NextRequest) { url.pathname = `/${locale}${pathname}`; const res = NextResponse.redirect(url); res.cookies.set(COOKIE, locale, { path: "/", maxAge: 60 * 60 * 24 * 365 }); - return res; + return applySecurityHeaders(res); } export const config = { - matcher: ["/((?!_next|api|favicon.ico|icon.svg|.*\\..*).*)"], + // Match everything so security headers apply globally; the function + // bypasses redirect/locale logic for /_next, /api, and dotted paths. + matcher: ["/:path*"], }; diff --git a/web/next.config.ts b/web/next.config.ts index 3fa1051c..827dcdee 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,5 +1,8 @@ import type { NextConfig } from "next"; +// Security headers are set in middleware.ts (more reliable under OpenNext on +// Cloudflare than next.config.ts headers(), which doesn't always apply to +// prerendered/cached responses). const nextConfig: NextConfig = { reactStrictMode: true, images: {