Files
falah-mobile/src/app/api/feedback/route.ts
T
pi-agent de48918acf
Deploy Staging / build (push) Failing after 26m15s
security: fix critical webhook direct upgrade path, seed auth, CORS Vary header
- Webhook polar/route: block direct ?userId=&tier= upgrades in production (NODE_ENV guard)
- Seed route: require ADMIN_SECRET via x-admin-secret header
- Feedback route: require FEEDBACK_ADMIN_TOKEN env var in production
- CORS middleware: add Vary: Origin header for caching correctness
- MOCK_PAYMENTS: add NODE_ENV production guard
- Wallet top-up: add production guard for mock mode when POLAR_ACCESS_TOKEN unset
2026-06-28 01:15:45 +08:00

117 lines
3.4 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
const DATA_DIR = "/app/data";
const FEEDBACK_FILE = join(DATA_DIR, "feedback.jsonl");
interface FeedbackEntry {
id: string;
timestamp: string;
url: string;
error: string;
message: string;
userAgent: string;
}
function generateId(): string {
const ts = Date.now().toString(36);
const rand = Math.random().toString(36).substring(2, 8);
return `fb-${ts}-${rand}`;
}
function sanitizeUA(ua: string): string {
// Strip personal info from UA string
return ua.replace(/\(.*?\)/g, "(redacted)").substring(0, 200);
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { url, error, message, userAgent } = body;
if (!url || !error) {
return NextResponse.json(
{ error: "url and error are required" },
{ status: 400 }
);
}
const entry: FeedbackEntry = {
id: generateId(),
timestamp: new Date().toISOString(),
url: url.substring(0, 500),
error: error.substring(0, 1000),
message: (message || "").substring(0, 2000),
userAgent: sanitizeUA(userAgent || ""),
};
// Ensure data dir exists
await mkdir(DATA_DIR, { recursive: true });
// Append to JSONL file
await writeFile(FEEDBACK_FILE, JSON.stringify(entry) + "\n", {
flag: "a",
});
return NextResponse.json({ id: entry.id });
} catch (err) {
// Silent failure — don't throw errors from error-reporting endpoint
console.error("Feedback save error:", err);
return NextResponse.json({ id: null }, { status: 200 });
}
}
export async function GET(request: NextRequest) {
// Admin-only endpoint — return paginated feedback
const authHeader = request.headers.get("authorization") || "";
const adminToken = process.env.FEEDBACK_ADMIN_TOKEN;
// Require token to be set in production
if (!adminToken) {
if (process.env.NODE_ENV === "production") {
console.error("FEEDBACK_ADMIN_TOKEN not configured");
return NextResponse.json({ error: "Server configuration error" }, { status: 500 });
}
// Dev fallback only
return NextResponse.json({ error: "Unauthorized — configure FEEDBACK_ADMIN_TOKEN" }, { status: 401 });
}
// Simple token auth
if (authHeader !== `Bearer ${adminToken}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 200);
const offset = parseInt(searchParams.get("offset") || "0");
try {
if (!existsSync(FEEDBACK_FILE)) {
return NextResponse.json({ entries: [], total: 0 });
}
const raw = await readFile(FEEDBACK_FILE, "utf-8");
const lines = raw.trim().split("\n").filter(Boolean);
const entries = lines
.map((line) => {
try {
return JSON.parse(line) as FeedbackEntry;
} catch {
return null;
}
})
.filter(Boolean)
.reverse(); // newest first
const total = entries.length;
const page = entries.slice(offset, offset + limit);
return NextResponse.json({ entries: page, total });
} catch (err) {
console.error("Feedback read error:", err);
return NextResponse.json({ entries: [], total: 0 });
}
}