feat(db): add tile-region mapping datasets
Add regions.csv and tile_region_mapping.csv to provide region metadata and tile-to-region relationships. Update server initialization and auth routes to integrate the new data, and expand .env.example with related configuration variables. This makes region information queryable and prepares scripts for seeding/import.
This commit is contained in:
@@ -11,3 +11,15 @@ JWT_SECRET="your-secret-key"
|
||||
GOOGLE_CLIENT_ID="your-google-client-id"
|
||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||
GOOGLE_CALLBACK_URL="http://localhost:3000/auth/google/callback"
|
||||
|
||||
# Cookie configuration
|
||||
COOKIE_SECURE=true
|
||||
COOKIE_SAME_SITE=Strict
|
||||
# Optional: set to your public domain if needed (e.g., example.com)
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# Bot protection
|
||||
TURNSTILE_SECRET_KEY="your-turnstile-secret"
|
||||
# Development bypass token (set to empty in production)
|
||||
TURNSTILE_BYPASS_TOKEN="turnstile-disabled"
|
||||
|
||||
|
||||
+2560
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+181
-75
@@ -21,9 +21,14 @@ import { fileURLToPath } from "url";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const frontendPath = path.join(__dirname, "..", "frontend");
|
||||
const normalizedFrontendPath = path.resolve(frontendPath);
|
||||
|
||||
const app = new App({
|
||||
noMatchHandler: async (_req, res) => {
|
||||
const html = await fs.readFile("./frontend/404.html", "utf8");
|
||||
const html = await fs.readFile(path.join(frontendPath, "404.html"), "utf8");
|
||||
return res.status(404)
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.send(html);
|
||||
@@ -32,113 +37,213 @@ const app = new App({
|
||||
|
||||
app.use(cors());
|
||||
app.use(cookieParser());
|
||||
app.use((req, _res, next) => {
|
||||
let body = "";
|
||||
req.on("data", chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
req.body = body ? JSON.parse(body) : {};
|
||||
} catch {
|
||||
req.body = {};
|
||||
}
|
||||
|
||||
const MAX_JSON_BODY_BYTES = Number(process.env["MAX_JSON_BODY_BYTES"] ?? 1_048_576);
|
||||
const METHODS_WITH_BODY = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
req.body = {};
|
||||
|
||||
if (!METHODS_WITH_BODY.has(req.method ?? "")) {
|
||||
return next?.();
|
||||
}
|
||||
|
||||
const rawContentType = req.headers["content-type"] ?? "";
|
||||
const contentType = Array.isArray(rawContentType)
|
||||
? rawContentType[0] ?? ""
|
||||
: rawContentType;
|
||||
const isJson = contentType.toLowerCase().startsWith("application/json");
|
||||
|
||||
if (!isJson) {
|
||||
return next?.();
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
let body = "";
|
||||
let requestClosed = false;
|
||||
|
||||
const handleTooLarge = () => {
|
||||
if (requestClosed) {
|
||||
return;
|
||||
}
|
||||
requestClosed = true;
|
||||
res.status(413)
|
||||
.json({ error: "Payload too large", status: 413 });
|
||||
req.destroy();
|
||||
};
|
||||
|
||||
req.on("data", chunk => {
|
||||
if (requestClosed) {
|
||||
return;
|
||||
}
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > MAX_JSON_BODY_BYTES) {
|
||||
handleTooLarge();
|
||||
return;
|
||||
}
|
||||
body += chunk.toString("utf8");
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
if (requestClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
return next?.();
|
||||
}
|
||||
|
||||
try {
|
||||
req.body = JSON.parse(body);
|
||||
} catch {
|
||||
requestClosed = true;
|
||||
return res.status(400)
|
||||
.json({ error: "Invalid JSON payload", status: 400 });
|
||||
}
|
||||
|
||||
return next?.();
|
||||
});
|
||||
|
||||
req.on("error", () => {
|
||||
if (requestClosed) {
|
||||
return;
|
||||
}
|
||||
requestClosed = true;
|
||||
return res.status(400)
|
||||
.json({ error: "Failed to read request body", status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// Logging
|
||||
app.use((req, res, next) => {
|
||||
const inspectOptions = { colors: true, compact: true, breakLength: Number.POSITIVE_INFINITY };
|
||||
const startTime = Date.now();
|
||||
const requestId = req.get("x-forwarded-for");
|
||||
const SENSITIVE_HEADER_KEYS = new Set(["authorization", "cookie", "set-cookie"]);
|
||||
|
||||
console.log(`[${requestId}] [${new Date()
|
||||
.toISOString()}] ${req.method} ${req.url}`);
|
||||
console.log(`[${requestId}] Headers:`, inspect(req.headers, inspectOptions));
|
||||
if (req.body && Object.keys(req.body).length > 0) {
|
||||
console.log(`[${requestId}] Body:`, inspect(req.body, inspectOptions));
|
||||
function sanitizeHeaders(headers: Record<string, unknown>) {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (SENSITIVE_HEADER_KEYS.has(key.toLowerCase())) {
|
||||
result[key] = "[REDACTED]";
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const startTime = Date.now();
|
||||
const requestId = req.get("x-forwarded-for") || req.ip || req.socket.remoteAddress || "unknown";
|
||||
const inspectOptions = { colors: false, compact: true, breakLength: Number.POSITIVE_INFINITY } as const;
|
||||
|
||||
console.log(`[${requestId}] ${req.method} ${req.url}`);
|
||||
console.log(`[${requestId}] Headers:`, inspect(sanitizeHeaders(req.headers as Record<string, unknown>), inspectOptions));
|
||||
|
||||
if (req.body && typeof req.body === "object" && Object.keys(req.body).length > 0) {
|
||||
console.log(`[${requestId}] Body keys:`, Object.keys(req.body));
|
||||
}
|
||||
|
||||
const originalJson = res.json;
|
||||
res.json = function (data) {
|
||||
res.on("finish", () => {
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`[${requestId}] Response JSON (${res.statusCode}) [${duration}ms]:`, inspect(data, inspectOptions));
|
||||
return originalJson.call(this, data);
|
||||
};
|
||||
console.log(`[${requestId}] Response ${res.statusCode} [${duration}ms]`);
|
||||
});
|
||||
|
||||
return next?.();
|
||||
});
|
||||
|
||||
app.use(addPrismaToRequest);
|
||||
|
||||
// Static file serving middleware
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const frontendPath = path.join(__dirname, "..", "frontend");
|
||||
const STATIC_ROUTE_PREFIXES = [
|
||||
"/api",
|
||||
"/auth",
|
||||
"/s0",
|
||||
"/me",
|
||||
"/alliance",
|
||||
"/leaderboard",
|
||||
"/favorite-location",
|
||||
"/purchase",
|
||||
"/flag",
|
||||
"/report-user",
|
||||
"/experiments",
|
||||
"/moderator",
|
||||
"/admin"
|
||||
];
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
// Skip API routes and backend routes
|
||||
if (req.url?.startsWith("/api")
|
||||
|| req.url?.startsWith("/auth")
|
||||
|| req.url?.startsWith("/s0")
|
||||
|| req.url?.startsWith("/me")
|
||||
|| req.url?.startsWith("/alliance")
|
||||
|| req.url?.startsWith("/leaderboard")
|
||||
|| req.url?.startsWith("/favorite-location")
|
||||
|| req.url?.startsWith("/purchase")
|
||||
|| req.url?.startsWith("/flag")
|
||||
|| req.url?.startsWith("/report-user")
|
||||
|| req.url?.startsWith("/experiments")
|
||||
|| req.url?.startsWith("/moderator")
|
||||
|| req.url?.startsWith("/admin")) {
|
||||
const method = req.method?.toUpperCase() ?? "GET";
|
||||
if (!["GET", "HEAD"].includes(method)) {
|
||||
return next?.();
|
||||
}
|
||||
|
||||
const requestUrl = req.url ?? "/";
|
||||
if (STATIC_ROUTE_PREFIXES.some(prefix => requestUrl.startsWith(prefix))) {
|
||||
return next?.();
|
||||
}
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
const parsedUrl = new URL(requestUrl, "http://localhost");
|
||||
pathname = decodeURIComponent(parsedUrl.pathname);
|
||||
} catch {
|
||||
return next?.();
|
||||
}
|
||||
|
||||
const trimmedPath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
||||
const resolvedPath = path.resolve(normalizedFrontendPath, `.${path.sep}${trimmedPath}`);
|
||||
const relativeResolvedPath = path.relative(normalizedFrontendPath, resolvedPath);
|
||||
|
||||
if (relativeResolvedPath.startsWith("..") || path.isAbsolute(relativeResolvedPath)) {
|
||||
return next?.();
|
||||
}
|
||||
|
||||
let filePath = resolvedPath;
|
||||
|
||||
try {
|
||||
// Try to serve static file
|
||||
let filePath = path.join(frontendPath, req.url || "");
|
||||
let stats = await fs.stat(filePath);
|
||||
|
||||
// If it's a directory, try to serve index.html
|
||||
if (stats.isDirectory()) {
|
||||
filePath = path.join(filePath, "index.html");
|
||||
stats = await fs.stat(filePath);
|
||||
}
|
||||
|
||||
if (stats.isFile()) {
|
||||
const content = await fs.readFile(filePath);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".webmanifest": "application/manifest+json"
|
||||
};
|
||||
|
||||
res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream");
|
||||
return res.send(content);
|
||||
if (!stats.isFile()) {
|
||||
return next?.();
|
||||
}
|
||||
} catch {
|
||||
// File not found, continue to next handler
|
||||
}
|
||||
|
||||
return next?.();
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".webmanifest": "application/manifest+json"
|
||||
};
|
||||
|
||||
const content = await fs.readFile(filePath);
|
||||
res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream");
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.setHeader("Pragma", "no-cache");
|
||||
res.setHeader("Expires", "0");
|
||||
|
||||
if (method === "HEAD") {
|
||||
res.setHeader("Content-Length", content.length.toString());
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
return res.send(content);
|
||||
} catch {
|
||||
return next?.();
|
||||
}
|
||||
});
|
||||
|
||||
// Serve admin and moderation HTML pages
|
||||
app.get("/admin", async (_req, res) => {
|
||||
try {
|
||||
const html = await fs.readFile(path.join(frontendPath, "admin.html"), "utf-8");
|
||||
const html = await fs.readFile(path.join(frontendPath, "admin.html"), "utf8");
|
||||
return res.setHeader("Content-Type", "text/html").send(html);
|
||||
} catch (error) {
|
||||
console.error("Error serving admin.html:", error);
|
||||
@@ -148,7 +253,7 @@ app.get("/admin", async (_req, res) => {
|
||||
|
||||
app.get("/moderation", async (_req, res) => {
|
||||
try {
|
||||
const html = await fs.readFile(path.join(frontendPath, "moderation.html"), "utf-8");
|
||||
const html = await fs.readFile(path.join(frontendPath, "moderation.html"), "utf8");
|
||||
return res.setHeader("Content-Type", "text/html").send(html);
|
||||
} catch (error) {
|
||||
console.error("Error serving moderation.html:", error);
|
||||
@@ -173,3 +278,4 @@ const PORT = Number(process.env["PORT"]) || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
|
||||
+119
-18
@@ -12,6 +12,104 @@ const GOOGLE_CLIENT_ID = process.env["GOOGLE_CLIENT_ID"] || "";
|
||||
const GOOGLE_CLIENT_SECRET = process.env["GOOGLE_CLIENT_SECRET"] || "";
|
||||
const GOOGLE_CALLBACK_URL = process.env["GOOGLE_CALLBACK_URL"] || "http://localhost:3000/auth/google/callback";
|
||||
|
||||
const SESSION_COOKIE_NAME = "j";
|
||||
const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
|
||||
const COOKIE_SAME_SITE = process.env["COOKIE_SAME_SITE"] || "Strict";
|
||||
const COOKIE_DOMAIN = process.env["COOKIE_DOMAIN"] || "";
|
||||
const SHOULD_USE_SECURE_COOKIES = (
|
||||
process.env["COOKIE_SECURE"] ?? ""
|
||||
).toLowerCase() !== "false" && (process.env["NODE_ENV"] ?? "production").toLowerCase() !== "development";
|
||||
|
||||
const TURNSTILE_SECRET_KEY = process.env["TURNSTILE_SECRET_KEY"] || "";
|
||||
const TURNSTILE_BYPASS_TOKEN = process.env["TURNSTILE_BYPASS_TOKEN"] || "";
|
||||
|
||||
interface TurnstileVerifyResponse {
|
||||
success: boolean;
|
||||
"error-codes"?: string[];
|
||||
}
|
||||
|
||||
function buildSessionCookie(value: string, maxAgeSeconds: number, expiresAt: Date) {
|
||||
const encodedValue = encodeURIComponent(value);
|
||||
const attributes = [
|
||||
`${SESSION_COOKIE_NAME}=${encodedValue}`,
|
||||
"HttpOnly",
|
||||
"Path=/",
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
`SameSite=${COOKIE_SAME_SITE}`,
|
||||
`Expires=${expiresAt.toUTCString()}`
|
||||
];
|
||||
|
||||
if (COOKIE_DOMAIN) {
|
||||
attributes.push(`Domain=${COOKIE_DOMAIN}`);
|
||||
}
|
||||
|
||||
if (SHOULD_USE_SECURE_COOKIES) {
|
||||
attributes.push("Secure");
|
||||
}
|
||||
|
||||
return attributes.join("; ");
|
||||
}
|
||||
|
||||
function setSessionCookie(res: any, token: string, expiresAt: Date) {
|
||||
res.setHeader("Set-Cookie", buildSessionCookie(token, SESSION_MAX_AGE_SECONDS, expiresAt));
|
||||
}
|
||||
|
||||
function clearSessionCookie(res: any) {
|
||||
const expiresAt = new Date(0);
|
||||
res.setHeader("Set-Cookie", buildSessionCookie("", 0, expiresAt));
|
||||
}
|
||||
|
||||
function getRemoteAddress(req: any) {
|
||||
return (
|
||||
req.headers["cf-connecting-ip"]
|
||||
|| req.headers["x-real-ip"]
|
||||
|| req.ip
|
||||
|| req.socket?.remoteAddress
|
||||
|| ""
|
||||
) as string;
|
||||
}
|
||||
|
||||
async function verifyTurnstileToken(token: string, remoteIp: string) {
|
||||
const payload = new URLSearchParams({
|
||||
secret: TURNSTILE_SECRET_KEY,
|
||||
response: token
|
||||
});
|
||||
|
||||
if (remoteIp) {
|
||||
payload.set("remoteip", remoteIp);
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchFn = typeof globalThis.fetch === "function" ? globalThis.fetch : undefined;
|
||||
if (!fetchFn) {
|
||||
console.error("Fetch API is not available for Turnstile verification");
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetchFn("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
body: payload
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Turnstile verification failed with status", response.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TurnstileVerifyResponse;
|
||||
if (!data.success) {
|
||||
console.warn("Turnstile verification rejected", data["error-codes"]);
|
||||
}
|
||||
return data.success;
|
||||
} catch (error) {
|
||||
console.error("Error verifying Turnstile token", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Configure Google OAuth Strategy
|
||||
if (GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET) {
|
||||
passport.use(new GoogleStrategy({
|
||||
@@ -59,19 +157,29 @@ export default function (app: App) {
|
||||
app.use(passport.initialize() as any);
|
||||
|
||||
// Google OAuth routes
|
||||
app.get("/auth/google", (req: any, res: any, next: any) => {
|
||||
// Check turnstile token (optional - skip validation if turnstile-disabled)
|
||||
const token = req.query.token;
|
||||
app.get("/auth/google", async (req: any, res: any, next: any) => {
|
||||
const token = typeof req.query.token === "string" ? req.query.token : "";
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: "Token required" });
|
||||
}
|
||||
|
||||
// If token is "turnstile-disabled", skip verification (for development)
|
||||
if (token === "turnstile-disabled") {
|
||||
if (TURNSTILE_BYPASS_TOKEN && token === TURNSTILE_BYPASS_TOKEN) {
|
||||
return passport.authenticate("google", { session: false })(req, res, next);
|
||||
}
|
||||
|
||||
// In production, you would verify the turnstile token here
|
||||
if (!TURNSTILE_SECRET_KEY) {
|
||||
console.error("TURNSTILE_SECRET_KEY is not configured; rejecting OAuth attempt");
|
||||
return res.status(500).json({ error: "Bot verification unavailable" });
|
||||
}
|
||||
|
||||
const remoteIp = getRemoteAddress(req);
|
||||
const verified = await verifyTurnstileToken(token, remoteIp);
|
||||
|
||||
if (!verified) {
|
||||
return res.status(400).json({ error: "Failed bot verification" });
|
||||
}
|
||||
|
||||
return passport.authenticate("google", { session: false })(req, res, next);
|
||||
});
|
||||
|
||||
@@ -92,7 +200,7 @@ export default function (app: App) {
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
|
||||
expiresAt: new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -108,10 +216,7 @@ export default function (app: App) {
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
// Set cookie and redirect
|
||||
res.setHeader("Set-Cookie", [
|
||||
`j=${token}; HttpOnly; Path=/; Max-Age=${30 * 24 * 60 * 60}; SameSite=Lax`
|
||||
]);
|
||||
setSessionCookie(res, token, session.expiresAt);
|
||||
|
||||
return res.redirect("/");
|
||||
} catch (error) {
|
||||
@@ -169,7 +274,7 @@ export default function (app: App) {
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
|
||||
expiresAt: new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -184,9 +289,7 @@ export default function (app: App) {
|
||||
JWT_SECRET!
|
||||
);
|
||||
|
||||
res.setHeader("Set-Cookie", [
|
||||
`j=${token}; HttpOnly; Path=/; Max-Age=${30 * 24 * 60 * 60}; SameSite=Lax`
|
||||
]);
|
||||
setSessionCookie(res, token, session.expiresAt);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -204,9 +307,7 @@ export default function (app: App) {
|
||||
});
|
||||
}
|
||||
|
||||
res.setHeader("Set-Cookie", [
|
||||
`j=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`
|
||||
]);
|
||||
clearSessionCookie(res);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user