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:
2025-10-02 23:50:06 -07:00
parent 3bf7488569
commit 30f6a76891
5 changed files with 42393 additions and 93 deletions
+12
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+181 -75
View File
@@ -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
View File
@@ -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) {