Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
4 changes: 3 additions & 1 deletion app/api/routes-f/anagram/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ function sortChars(s: string): string {
function areAnagrams(a: string, b: string): boolean {
const na = normalize(a);
const nb = normalize(b);
if (na.length !== nb.length) return false;
if (na.length !== nb.length) {
return false;
}
return sortChars(na) === sortChars(nb);
}

Expand Down
10 changes: 7 additions & 3 deletions app/api/routes-f/captcha-math/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ describe("POST /api/routes-f/captcha-math/verify", () => {
const op = match![2];
const b = parseInt(match![3]);
let answer: number;
if (op === "+") answer = a + b;
else if (op === "-") answer = a - b;
else answer = a * b;
if (op === "+") {
answer = a + b;
} else if (op === "-") {
answer = a - b;
} else {
answer = a * b;
}
return { token, answer };
}

Expand Down
13 changes: 7 additions & 6 deletions app/api/routes-f/captcha-math/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { createHmac, randomInt } from "crypto";

const SECRET = "captcha-math-dev-secret-streamfi";
const EXPIRY_MS = 5 * 60 * 1000; // 5 minutes

// In-memory set for single-use token tracking
const usedTokens = new Set<string>();

type Operation = "+" | "-" | "*";

function generateChallenge(): { question: string; answer: number } {
Expand Down Expand Up @@ -40,10 +37,14 @@ function signToken(payload: object): string {

export function verifyToken(token: string): { answer: number; expires_at: number } | null {
const parts = token.split(".");
if (parts.length !== 2) return null;
if (parts.length !== 2) {
return null;
}
const [encoded, sig] = parts;
const expected = createHmac("sha256", SECRET).update(encoded).digest("base64url");
if (sig !== expected) return null;
if (sig !== expected) {
return null;
}
try {
return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
} catch {
Expand Down
15 changes: 11 additions & 4 deletions app/api/routes-f/emoji/_lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,22 @@ type RelevanceScore = 0 | 1 | 2 | 3;

function score(emoji: Emoji, q: string): RelevanceScore {
const query = q.toLowerCase();
if (emoji.name === query) return 3;
if (emoji.shortcode === query) return 2;
if (emoji.keywords.includes(query)) return 1;
if (emoji.name === query) {
return 3;
}
if (emoji.shortcode === query) {
return 2;
}
if (emoji.keywords.includes(query)) {
return 1;
}
if (
emoji.name.includes(query) ||
emoji.shortcode.includes(query) ||
emoji.keywords.some((k) => k.includes(query))
)
) {
return 0;
}
return -1 as unknown as RelevanceScore;
}

Expand Down
80 changes: 80 additions & 0 deletions app/api/routes-f/feedback/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { POST } from "../route";
import { NextRequest } from "next/server";
import { resetState, feedbackStorage } from "../_lib/helpers";

describe("POST /api/routes-f/feedback", () => {
beforeEach(() => {
resetState();
});

function createRequest(body: any, ip: string = "127.0.0.1") {
return new NextRequest("http://localhost/api/routes-f/feedback", {
method: "POST",
headers: {
"content-type": "application/json",
"x-forwarded-for": ip,
},
body: JSON.stringify(body),
});
}

it("should validate and store a successful request", async () => {
const req = createRequest({ message: "This is a valid message length", category: "bug" });
const res = await POST(req);
expect(res.status).toBe(201);

expect(feedbackStorage.length).toBe(1);
expect(feedbackStorage[0].message).toBe("This is a valid message length");
expect(feedbackStorage[0].category).toBe("bug");
});

it("should strip HTML tags from the message", async () => {
const req = createRequest({ message: "This <script>alert(1)</script> is a <b>valid</b> message", category: "feature" });
const res = await POST(req);
expect(res.status).toBe(201);

expect(feedbackStorage[0].message).toBe("This alert(1) is a valid message");
});

it("should reject message that is too short", async () => {
const req = createRequest({ message: "short", category: "other" });
const res = await POST(req);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toMatch(/between 10 and 2000 characters/);
});

it("should reject invalid category", async () => {
const req = createRequest({ message: "This is a valid message length", category: "invalid" });
const res = await POST(req);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe("Invalid category");
});

it("should rate limit after 5 requests from the same IP", async () => {
const ip = "192.168.1.1";
for (let i = 0; i < 5; i++) {
const req = createRequest({ message: "This is a valid message length", category: "bug" }, ip);
const res = await POST(req);
expect(res.status).toBe(201);
}

const req = createRequest({ message: "This is a valid message length", category: "bug" }, ip);
const res = await POST(req);
expect(res.status).toBe(429);
const data = await res.json();
expect(data.error).toMatch(/Too many requests/);
});

it("should not rate limit different IPs", async () => {
for (let i = 0; i < 5; i++) {
const req = createRequest({ message: "This is a valid message length", category: "bug" }, "ip1");
await POST(req);
}

const req2 = createRequest({ message: "This is a valid message length", category: "bug" }, "ip2");
const res2 = await POST(req2);
expect(res2.status).toBe(201);
});
});
61 changes: 61 additions & 0 deletions app/api/routes-f/feedback/_lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { StoredFeedback } from './types';

// Rate Limiter
interface RateLimitEntry {
count: number;
resetAt: number;
}

const rateLimits = new Map<string, RateLimitEntry>();
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour

export function checkRateLimit(ip: string): boolean {
const now = Date.now();
const entry = rateLimits.get(ip);

if (!entry) {
rateLimits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return true;
}

if (now > entry.resetAt) {
rateLimits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return true;
}

if (entry.count >= RATE_LIMIT_MAX) {
return false;
}

entry.count += 1;
return true;
}

// Strip HTML tags
export function stripHtmlTags(input: string): string {
if (!input) {
return '';
}
return input.replace(/<\/?[^>]+(>|$)/g, "");
}

// In-memory storage
export const feedbackStorage: StoredFeedback[] = [];

export function storeFeedback(feedback: StoredFeedback) {
feedbackStorage.push(feedback);
}

// Clear state (useful for tests)
export function resetState() {
rateLimits.clear();
feedbackStorage.length = 0;
}

export function generateId(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
11 changes: 11 additions & 0 deletions app/api/routes-f/feedback/_lib/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface FeedbackRequest {
message: string;
category: "bug" | "feature" | "other";
contact?: string;
}

export interface StoredFeedback extends FeedbackRequest {
id: string;
createdAt: string;
ip: string;
}
66 changes: 66 additions & 0 deletions app/api/routes-f/feedback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { checkRateLimit, stripHtmlTags, storeFeedback, generateId } from "./_lib/helpers";
import { StoredFeedback } from "./_lib/types";

export async function POST(req: NextRequest) {
try {
const ip = req.headers.get("x-forwarded-for") || "unknown-ip";

if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429 }
);
}

const body = await req.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}

const { message, category, contact } = body;

// Validation
if (!message || typeof message !== "string") {
return NextResponse.json({ error: "Message is required and must be a string" }, { status: 400 });
}
if (message.length < 10 || message.length > 2000) {
return NextResponse.json({ error: "Message length must be between 10 and 2000 characters" }, { status: 400 });
}

const validCategories = ["bug", "feature", "other"];
if (!category || !validCategories.includes(category)) {
return NextResponse.json({ error: "Invalid category" }, { status: 400 });
}

if (contact !== undefined && typeof contact !== "string") {
return NextResponse.json({ error: "Contact must be a string" }, { status: 400 });
}

// Sanitize HTML
const sanitizedMessage = stripHtmlTags(message);
const sanitizedContact = contact ? stripHtmlTags(contact) : undefined;

// Store feedback
const newFeedback: StoredFeedback = {
id: generateId(),
message: sanitizedMessage,
category: category as "bug" | "feature" | "other",
contact: sanitizedContact,
ip,
createdAt: new Date().toISOString(),
};

storeFeedback(newFeedback);

return NextResponse.json(
{ success: true, message: "Feedback submitted successfully" },
{ status: 201 }
);
} catch {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
20 changes: 15 additions & 5 deletions app/api/routes-f/isbn/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,35 @@ function normalize(isbn: string): string {
}

function validateIsbn10(isbn: string): boolean {
if (isbn.length !== 10) return false;
if (isbn.length !== 10) {
return false;
}
let sum = 0;
for (let i = 0; i < 9; i++) {
const d = parseInt(isbn[i], 10);
if (isNaN(d)) return false;
if (isNaN(d)) {
return false;
}
sum += (10 - i) * d;
}
const last = isbn[9];
sum += last === "X" ? 10 : parseInt(last, 10);
if (isNaN(sum)) return false;
if (isNaN(sum)) {
return false;
}
return sum % 11 === 0;
}

function validateIsbn13(isbn: string): boolean {
if (isbn.length !== 13) return false;
if (isbn.length !== 13) {
return false;
}
let sum = 0;
for (let i = 0; i < 13; i++) {
const d = parseInt(isbn[i], 10);
if (isNaN(d)) return false;
if (isNaN(d)) {
return false;
}
sum += i % 2 === 0 ? d : d * 3;
}
return sum % 10 === 0;
Expand Down
4 changes: 3 additions & 1 deletion app/api/routes-f/joke/_lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import type { Joke, JokeCategory, JokeResponse } from "./types";
const allJokes = jokes as Joke[];

export function pickRandom(pool: Joke[]): Joke | null {
if (!pool.length) return null;
if (!pool.length) {
return null;
}
return pool[Math.floor(Math.random() * pool.length)];
}

Expand Down
12 changes: 9 additions & 3 deletions app/api/routes-f/palindrome/_lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ export function normalize(
ignoreWhitespace: boolean
): string {
let s = text;
if (ignoreCase) s = s.toLowerCase();
if (ignorePunct) s = s.replace(/[^a-zA-Z0-9\s]/g, "");
if (ignoreWhitespace) s = s.replace(/\s+/g, "");
if (ignoreCase) {
s = s.toLowerCase();
}
if (ignorePunct) {
s = s.replace(/[^a-zA-Z0-9\s]/g, "");
}
if (ignoreWhitespace) {
s = s.replace(/\s+/g, "");
}
return s;
}

Expand Down
4 changes: 3 additions & 1 deletion app/api/routes-f/register/complete/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ const completeSchema = z.object({

export async function POST(request: NextRequest) {
const session = await verifySession(request);
if (!session.ok) return session.response;
if (!session.ok) {
return session.response;
}

let body: z.infer<typeof completeSchema>;
try {
Expand Down
Loading
Loading