Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# -------------------------------------------------------
# -------------------------------------------------------
# Supabase
# Project Settings → API → Project URL
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
Expand Down Expand Up @@ -75,3 +75,12 @@ GROQ_API_KEY=your_groq_api_key
# Higher values = faster builds but more resource usage
# WARNING: Do not exceed 100 without load testing — risks memory exhaustion
LEADERBOARD_USER_CONCURRENCY=5
# -------------------------------------------------------
# Cron / Scheduled-sync endpoints
# Shared secret supplied by the scheduler (e.g. Vercel Cron) in every request:
# Authorization: Bearer <CRON_SECRET>
# Required in ALL environments - cron routes fail closed when this is absent.
# Local development: set any non-empty value and pass the matching header when
# calling a sync endpoint manually (e.g. curl -H "Authorization: Bearer ...").
# Generate with: openssl rand -hex 32
CRON_SECRET=your_cron_secret
31 changes: 12 additions & 19 deletions src/app/api/notifications/discord-sync/route.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { supabaseAdmin } from "@/lib/supabase";
import { sendMilestoneReached, sendStreakAtRisk, sendWeeklySummary } from "@/lib/discord";
import { fetchPublicStreak, fetchPublicContributions } from "@/lib/public-profile-data";
import { toDateStr } from "@/lib/dateUtils";
import { validateCronRequest } from "@/lib/cron-auth";

export const dynamic = "force-dynamic";

export async function GET(req: Request) {
const authHeader = req.headers.get("authorization");
const cronSecret = process.env.CRON_SECRET;

if (!cronSecret) {
return NextResponse.json({ error: "CRON_SECRET is not configured" }, { status: 500 });
}

if (authHeader !== `Bearer ${cronSecret}` && process.env.NODE_ENV !== "development") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const authError = validateCronRequest(req);
if (authError) return authError;

const { data: users, error } = await supabaseAdmin
.from("users")
Expand All @@ -29,7 +22,7 @@ export async function GET(req: Request) {

const token = process.env.GITHUB_TOKEN;
const now = new Date();

let processed = 0;
let notificationsSent = 0;

Expand All @@ -39,17 +32,17 @@ export async function GET(req: Request) {
const tz = user.timezone || "UTC";
let localHour: number;
let isSunday = false;

try {
const formatter = new Intl.DateTimeFormat("en-US", { timeZone: tz, hour: "numeric", hour12: false, weekday: "short" });
const parts = formatter.formatToParts(now);
const hourPart = parts.find(p => p.type === "hour")?.value;
const weekdayPart = parts.find(p => p.type === "weekday")?.value;
localHour = parseInt(hourPart || "0", 10);

// Handle "24" meaning midnight in some Intl implementations
if (localHour === 24) localHour = 0;

isSunday = weekdayPart === "Sun";
} catch (e) {
localHour = now.getUTCHours();
Expand Down Expand Up @@ -79,11 +72,11 @@ export async function GET(req: Request) {
try {
const streakData = await fetchPublicStreak(user.github_login, token);
let sentSomething = false;
// Determine "today" in the user's timezone, or UTC if fallback

// Determine "today" in the user`s timezone, or UTC if fallback
let todayStr: string;
try {
const dFmt = new Intl.DateTimeFormat("en-US", { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' });
const dFmt = new Intl.DateTimeFormat("en-US", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" });
const [{value: mo},,{value: da},,{value: ye}] = dFmt.formatToParts(now);
todayStr = `${ye}-${mo}-${da}`;
} catch (e) {
Expand Down Expand Up @@ -127,4 +120,4 @@ export async function GET(req: Request) {
}

return NextResponse.json({ success: true, processed, notificationsSent });
}
}
82 changes: 25 additions & 57 deletions src/app/api/sponsors/sync/route.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,12 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { supabaseAdmin } from "@/lib/supabase";
import { validateCronRequest } from "@/lib/cron-auth";

export const dynamic = "force-dynamic";

// Sponsor identity must be tied to GitHub's immutable numeric account ID
// (databaseId / github_id) rather than the mutable login name. A user can
// rename their GitHub account at any time, and GitHub recycles usernames
// after a grace period. Matching on login would allow a new account that
// claims a recycled username to inherit sponsor privileges.

interface SponsorIdentity {
githubId: string; // immutable numeric ID stringified, matches users.github_id
login: string; // current login, kept for logging/response only
}

export async function GET(req: Request) {
const authHeader = req.headers.get("authorization");
const cronSecret = process.env.CRON_SECRET;

if (!cronSecret) {
return NextResponse.json({ error: "CRON_SECRET is not configured" }, { status: 500 });
}

if (authHeader !== `Bearer ${cronSecret}` && process.env.NODE_ENV !== "development") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const authError = validateCronRequest(req);
if (authError) return authError;

const token = process.env.GITHUB_TOKEN;
if (!token) {
Expand All @@ -34,20 +16,16 @@ export async function GET(req: Request) {
const targetOwner = "Priyanshu-byte-coder";

try {
// Request databaseId alongside login so we can match on the immutable
// GitHub numeric account identifier rather than the mutable username.
const query = `
query {
user(login: "${targetOwner}") {
sponsorshipsAsMaintainer(first: 100) {
nodes {
sponsorEntity {
... on User {
databaseId
login
}
... on Organization {
databaseId
login
}
}
Expand Down Expand Up @@ -84,76 +62,66 @@ export async function GET(req: Request) {
return NextResponse.json({ error: "GraphQL query returned no user data" }, { status: 502 });
}

// Build the authoritative sponsor list keyed on immutable GitHub IDs.
const currentSponsors: SponsorIdentity[] = [];
const sponsorLogins: string[] = [];

if (data.user.sponsorshipsAsMaintainer?.nodes) {
for (const node of data.user.sponsorshipsAsMaintainer.nodes) {
const entity = node.sponsorEntity;
if (entity?.databaseId) {
currentSponsors.push({
githubId: String(entity.databaseId),
login: entity.login ?? "",
});
const nodes = data.user.sponsorshipsAsMaintainer.nodes;
for (const node of nodes) {
if (node.sponsorEntity?.login) {
sponsorLogins.push(node.sponsorEntity.login);
}
}
}

const sponsorGithubIds = new Set(currentSponsors.map((s) => s.githubId));

// Fetch the set of users currently marked as sponsors using their
// immutable github_id, not their login.
const { data: existingSponsors, error: fetchErr } = await supabaseAdmin
const { data: currentSponsors, error: fetchErr } = await supabaseAdmin
.from("users")
.select("github_id")
.select("github_login")
.eq("is_sponsor", true);

if (fetchErr) {
console.error("Failed to fetch current sponsors:", fetchErr);
return NextResponse.json({ error: "Database error" }, { status: 500 });
}

const existingIds = new Set<string>(
(existingSponsors ?? []).map((u: { github_id: string }) => u.github_id)
const currentLogins = new Set<string>(
(currentSponsors || []).map((u: any) => String(u.github_login))
);
const newLogins = new Set<string>(sponsorLogins);

// Diff on immutable IDs.
const toRevoke = [...existingIds].filter((id) => !sponsorGithubIds.has(id));
const toGrant = [...sponsorGithubIds].filter((id) => !existingIds.has(id));
const toRemove = [...currentLogins].filter((login: string) => !newLogins.has(login));
const toAdd = [...newLogins].filter((login: string) => !currentLogins.has(login));

if (toRevoke.length > 0) {
if (toRemove.length > 0) {
const { error } = await supabaseAdmin
.from("users")
.update({ is_sponsor: false })
.in("github_id", toRevoke);
.in("github_login", toRemove);

if (error) {
console.error("Failed to revoke sponsors:", error);
console.error("Failed to remove sponsors:", error);
return NextResponse.json({ error: "Database error" }, { status: 500 });
}
}

if (toGrant.length > 0) {
if (toAdd.length > 0) {
const { error } = await supabaseAdmin
.from("users")
.update({ is_sponsor: true })
.in("github_id", toGrant);
.in("github_login", toAdd);

if (error) {
console.error("Failed to grant sponsors:", error);
console.error("Failed to add sponsors:", error);
return NextResponse.json({ error: "Database error" }, { status: 500 });
}
}

return NextResponse.json({
success: true,
sponsorCount: currentSponsors.length,
granted: toGrant.length,
revoked: toRevoke.length,
sponsors: currentSponsors.map((s) => s.login),
sponsorCount: sponsorLogins.length,
sponsors: sponsorLogins
});
} catch (error) {
console.error("Error in sponsors sync:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
}
27 changes: 7 additions & 20 deletions src/app/api/wakatime/sync/route.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,13 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { supabaseAdmin } from "@/lib/supabase";
import { decryptToken } from "@/lib/crypto";
import { validateCronRequest } from "@/lib/cron-auth";

export const dynamic = "force-dynamic";

export async function GET(req: Request) {
const authHeader = req.headers.get("authorization");
const cronSecret = process.env.CRON_SECRET;

// Fail closed when CRON_SECRET is not configured. Leaving it undefined
// causes `Bearer ${undefined}` → "Bearer undefined" to become the expected
// credential, which an attacker can trivially supply.
if (!cronSecret) {
return NextResponse.json(
{ error: "CRON_SECRET is not configured" },
{ status: 500 }
);
}

if (authHeader !== `Bearer ${cronSecret}` && process.env.NODE_ENV !== "development") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const authError = validateCronRequest(req);
if (authError) return authError;

// Fetch users with wakatime keys
const { data: users, error } = await supabaseAdmin
Expand All @@ -41,7 +28,7 @@ export async function GET(req: Request) {
const CHUNK_SIZE = 5;
for (let i = 0; i < users.length; i += CHUNK_SIZE) {
const chunk = users.slice(i, i + CHUNK_SIZE);

await Promise.allSettled(chunk.map(async (user) => {
try {
const apiKey = decryptToken(
Expand Down Expand Up @@ -71,7 +58,7 @@ export async function GET(req: Request) {

const data = await res.json();
const now = new Date().toISOString();

const statsToUpsert = data.data.map((day: any) => ({
user_id: user.id,
date: day.range.date,
Expand Down Expand Up @@ -99,4 +86,4 @@ export async function GET(req: Request) {
}

return NextResponse.json({ success: successCount, failure: failureCount });
}
}
76 changes: 76 additions & 0 deletions src/lib/auth-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Authentication rate limiter.
*
* Applies a strict per-IP fixed-window limit to authentication-sensitive
* endpoints (OAuth initiation and callback routes) to prevent brute-force
* and credential-stuffing attacks.
*
* Uses the shared createMemoryFixedWindowRateLimiter factory so behaviour
* is consistent with the rest of the project's rate-limiting infrastructure.
* The auth namespace ("auth:<ip>") is intentionally separate from the metrics
* and contact namespaces so the counters never interfere with each other.
*
* Protected paths (see AUTH_SENSITIVE_PREFIXES):
* POST /api/auth/signin/* — OAuth initiation
* GET /api/auth/callback/* — OAuth callback / code exchange
* GET /api/auth/link-github — Secondary account link initiation
* GET /api/auth/link-github/* — Secondary account link callback
*
* Deliberately NOT rate-limited by this module:
* GET /api/auth/session — called on every page render
* GET /api/auth/csrf — CSRF token fetch, not an attack surface
* GET /api/auth/signout — termination, not initiation
*/

import {
createMemoryFixedWindowRateLimiter,
type MemoryRateLimitResult,
} from "@/lib/rate-limit";

// 15-minute rolling window as specified in issue #1303.
export const AUTH_WINDOW_MS = 15 * 60 * 1000;

// Maximum requests per IP per window in production.
// A full GitHub OAuth sign-in consumes 2 requests (initiation + callback),
// so 5 allows two complete sign-in attempts plus one spare before throttling.
export const AUTH_LIMIT = 5;

/**
* Path prefixes whose requests count toward the authentication rate limit.
* Only the OAuth initiation and callback routes are included; session and
* CSRF endpoints are excluded because they are called on every page render
* and blocking them would lock users out of the UI.
*/
export const AUTH_SENSITIVE_PREFIXES = [
"/api/auth/signin",
"/api/auth/callback",
"/api/auth/link-github",
] as const;

const authLimiter = createMemoryFixedWindowRateLimiter({
windowMs: AUTH_WINDOW_MS,
// Prune stale entries once per window so memory does not grow unbounded.
pruneIntervalMs: AUTH_WINDOW_MS,
maxEntries: 5_000,
});

/**
* Check whether the given IP has exceeded the authentication rate limit.
*
* @param ip - The client IP address (typically from getClientIp()).
* @param limit - Override the production limit (used in tests / dev).
*/
export function checkAuthRateLimit(
ip: string,
limit: number = AUTH_LIMIT,
): MemoryRateLimitResult {
return authLimiter.check(`auth:${ip}`, limit);
}

/**
* Returns true when the pathname belongs to an authentication-sensitive route
* that should be subject to auth rate limiting.
*/
export function isAuthSensitivePath(pathname: string): boolean {
return AUTH_SENSITIVE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
Loading
Loading