Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ddd4d43
migrated oss sheets to md
huamanraj Nov 28, 2025
b51b929
fix: dynamic page for displaying sheet module content with a dedicate…
huamanraj Nov 28, 2025
2005156
perf: add header caching for sheet
apsinghdev Nov 29, 2025
01df440
fix: login page font fixed
huamanraj Nov 27, 2025
c13eb75
feat: fixed stats count size on small screens
mizurex Nov 27, 2025
3ad4701
ui: fix number size of stats
apsinghdev Nov 29, 2025
5aecef1
enhance root layout metadata
mizurex Nov 26, 2025
6c5d52c
bug-fix:navbar hides properly
praveenzsp Nov 23, 2025
30ed9b3
added back navbar to layout file
praveenzsp Nov 23, 2025
286f0b1
lint fix
praveenzsp Nov 23, 2025
49f740e
Fix typo in blog title ("shouln't" → "shouldn't")
SGNayak12 Nov 20, 2025
c6d38d2
fix: typos in blog titles
Lucifer-0612 Nov 22, 2025
15d2459
fix: normalize blog titles to lowercase
Lucifer-0612 Nov 24, 2025
560ced8
SideBar enhancement for Dashboard
Nov 18, 2025
da4989a
chore: extend offer
apsinghdev Nov 30, 2025
4643a68
feat: OSS programs added with data
huamanraj Nov 25, 2025
f4067d6
feat: update styles and improve accessibility for OSS program components
huamanraj Nov 25, 2025
a085764
fix: fix jsdom esmodule requirement err
apsinghdev Nov 29, 2025
247ceae
fix: ui repsnsiveness and design
huamanraj Nov 29, 2025
0b96b91
fix(ui): fix right side corners of oss programs card
apsinghdev Dec 1, 2025
133debd
fix(ui): fix right side corners of oss programs card
apsinghdev Dec 1, 2025
7479594
Merge branch 'main' of https://github.com/apsinghdev/opensox
huamanraj Dec 1, 2025
f17d0d2
feat: Implement testimonials management with Redis caching
huamanraj Dec 11, 2025
c25bf86
fix: type fixes and image validation
huamanraj Dec 13, 2025
a5722c4
fix: redirect protection to prevent SSRF
huamanraj Dec 16, 2025
37b6cbd
Merge branch 'main' into feat/testimonials-page
huamanraj Dec 16, 2025
5448b4d
fix: added links to testimnial and payment fix
huamanraj Dec 17, 2025
baf013a
Merge branch 'feat/testimonials-page' of https://github.com/huamanraj…
huamanraj Dec 17, 2025
0aea504
build fix
huamanraj Dec 17, 2025
cd72787
redis removed for testimonials
huamanraj Dec 23, 2025
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
3 changes: 3 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ ZEPTOMAIL_TOKEN=zeptomail-token
# key for the encryption
# can be created by running this: echo "$(openssl rand -hex 32)opensox$(openssl rand -hex 16)"
ENCRYPTION_KEY=encryption-key


REDIS_URL=redis://localhost:6379
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@
"zeptomail": "^6.2.1",
"zod": "^4.1.9"
}
}
}
13 changes: 13 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ model User {
accounts Account[]
payments Payment[]
subscriptions Subscription[]
testimonial Testimonial?
}

model Testimonial {
id String @id @default(cuid())
userId String @unique
content String
name String
avatar String
socialLink String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Account {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routers/_app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { userRouter } from "./user.js";
import { projectRouter } from "./projects.js";
import { authRouter } from "./auth.js";
import { paymentRouter } from "./payment.js";
import { testimonialRouter } from "./testimonial.js";
import { z } from "zod";

const testRouter = router({
Expand All @@ -21,6 +22,7 @@ export const appRouter = router({
project: projectRouter,
auth: authRouter,
payment: paymentRouter,
testimonial: testimonialRouter,
});

export type AppRouter = typeof appRouter;
103 changes: 103 additions & 0 deletions apps/api/src/routers/testimonial.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { router, protectedProcedure, publicProcedure } from "../trpc.js";
import { z } from "zod";
import { userService } from "../services/user.service.js";
import { TRPCError } from "@trpc/server";
import { validateAvatarUrl } from "../utils/avatar-validator.js";

export const testimonialRouter = router({
getAll: publicProcedure.query(async ({ ctx }: any) => {
// Fetch testimonials directly from database without caching
const testimonials = await ctx.db.prisma.testimonial.findMany({
orderBy: {
createdAt: 'desc',
},
});

return testimonials;
}),

getMyTestimonial: protectedProcedure.query(async ({ ctx }: any) => {
const userId = ctx.user.id;

// Check subscription
const { isPaidUser } = await userService.checkSubscriptionStatus(ctx.db.prisma, userId);

if (!isPaidUser) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only premium users can submit testimonials",
});
}

const testimonial = await ctx.db.prisma.testimonial.findUnique({
where: { userId },
});

return {
testimonial,
};
}),

submit: protectedProcedure
.input(z.object({
name: z.string().min(1, "Name is required").max(40, "Name must be at most 40 characters"),
content: z.string().min(10, "Testimonial must be at least 10 characters").max(1000, "Testimonial must be at most 1000 characters"),
avatar: z.string().url("Invalid avatar URL"),
socialLink: z.string().url("Invalid social link URL").refine((url) => {
const supportedPlatforms = [
'twitter.com',
'x.com',
'linkedin.com',
'instagram.com',
'youtube.com',
'youtu.be',
];
try {
const parsedUrl = new URL(url);
return supportedPlatforms.some(platform => parsedUrl.hostname.includes(platform));
} catch {
return false;
}
}, "Only Twitter/X, LinkedIn, Instagram, and YouTube links are supported").optional().or(z.literal('')),
Comment on lines +46 to +61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix insecure hostname validation that allows malicious domains.

The hostname.includes(platform) check is vulnerable to subdomain attacks. For example, malicious-twitter.com or evil-linkedin.com.attacker.net would pass validation because their hostnames contain the platform strings.

🔎 Proposed fix using exact hostname matching
-            socialLink: z.string().url("Invalid social link URL").refine((url) => {
-                const supportedPlatforms = [
-                    'twitter.com',
-                    'x.com',
-                    'linkedin.com',
-                    'instagram.com',
-                    'youtube.com',
-                    'youtu.be',
-                ];
-                try {
-                    const parsedUrl = new URL(url);
-                    return supportedPlatforms.some(platform => parsedUrl.hostname.includes(platform));
-                } catch {
-                    return false;
-                }
-            }, "Only Twitter/X, LinkedIn, Instagram, and YouTube links are supported").optional().or(z.literal('')),
+            socialLink: z.string().url("Invalid social link URL").refine((url) => {
+                const supportedPlatforms = [
+                    'twitter.com',
+                    'x.com',
+                    'linkedin.com',
+                    'instagram.com',
+                    'youtube.com',
+                    'youtu.be',
+                ];
+                try {
+                    const parsedUrl = new URL(url);
+                    const hostname = parsedUrl.hostname;
+                    // exact match or subdomain of allowed platform
+                    return supportedPlatforms.some(platform => 
+                        hostname === platform || hostname.endsWith(`.${platform}`)
+                    );
+                } catch {
+                    return false;
+                }
+            }, "Only Twitter/X, LinkedIn, Instagram, and YouTube links are supported").optional().or(z.literal('')),
🤖 Prompt for AI Agents
In apps/api/src/routers/testimonial.ts around lines 46 to 61, the hostname
validation uses hostname.includes(platform) which allows malicious hosts
containing the platform string; change it to exact-match or proper subdomain
matching: parse the URL and compare parsedUrl.hostname === platform OR
parsedUrl.hostname.endsWith('.' + platform) (this covers valid subdomains like
"www.twitter.com" or "mobile.twitter.com"), apply the same logic for each
supportedPlatforms entry (including handling short hosts like "youtu.be"), keep
the try/catch, and return false on parse errors so only exact platform hostnames
or their subdomains are allowed.

}))
.mutation(async ({ ctx, input }: any) => {
const userId = ctx.user.id;

const { isPaidUser } = await userService.checkSubscriptionStatus(ctx.db.prisma, userId);
if (!isPaidUser) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only premium users can submit testimonials",
});
}



// Check if testimonial already exists - prevent updates
const existingTestimonial = await ctx.db.prisma.testimonial.findUnique({
where: { userId },
});

if (existingTestimonial) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You have already submitted a testimonial. Testimonials cannot be edited once submitted.",
});
}

// Validate avatar URL with strict security checks
await validateAvatarUrl(input.avatar);

const result = await ctx.db.prisma.testimonial.create({
data: {
userId,
name: input.name,
content: input.content,
avatar: input.avatar,
socialLink: input.socialLink || null,
},
});

return result;
}),
});
2 changes: 1 addition & 1 deletion apps/api/src/routers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ export const userRouter = router({
userId,
input.completedSteps
);
}),
}),
});
2 changes: 1 addition & 1 deletion apps/api/src/services/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface CreateOrderInput {
notes?: Record<string, string>;
}

interface RazorpayOrderSuccess {
export interface RazorpayOrderSuccess {
amount: number;
amount_due: number;
amount_paid: number;
Expand Down
10 changes: 7 additions & 3 deletions apps/api/src/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { Context } from "./context.js";
import { verifyToken } from "./utils/auth.js";
import type { User } from "@prisma/client";

// Type for context after authentication middleware
type ProtectedContext = Context & { user: User };

const t = initTRPC.context<Context>().create({
transformer: superjson,
});

const isAuthed = t.middleware(async ({ ctx, next }) => {
const isAuthed = t.middleware<{ ctx: ProtectedContext }>(async ({ ctx, next }) => {
const authHeader = ctx.req.headers.authorization;

if (!authHeader || !authHeader.startsWith("Bearer ")) {
Expand All @@ -25,7 +29,7 @@ const isAuthed = t.middleware(async ({ ctx, next }) => {
ctx: {
...ctx,
user,
},
} as ProtectedContext,
});
} catch (error) {
throw new TRPCError({
Expand All @@ -37,4 +41,4 @@ const isAuthed = t.middleware(async ({ ctx, next }) => {

export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure:any = t.procedure.use(isAuthed);
export const protectedProcedure = t.procedure.use(isAuthed);
172 changes: 172 additions & 0 deletions apps/api/src/utils/avatar-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { TRPCError } from "@trpc/server";
import { isIP } from "net";

// Configuration
const ALLOWED_IMAGE_HOSTS = [
"avatars.githubusercontent.com",
"lh3.googleusercontent.com",
"graph.facebook.com",
"pbs.twimg.com",
"cdn.discordapp.com",
"i.imgur.com",
"res.cloudinary.com",
"ik.imagekit.io",
"images.unsplash.com",
"ui-avatars.com",
];

const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
const REQUEST_TIMEOUT_MS = 5000; // 5 seconds

// Private IP ranges
const PRIVATE_IP_RANGES = [
/^127\./, // 127.0.0.0/8 (localhost)
/^10\./, // 10.0.0.0/8
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12
/^192\.168\./, // 192.168.0.0/16
/^169\.254\./, // 169.254.0.0/16 (link-local)
/^::1$/, // IPv6 localhost
/^fe80:/, // IPv6 link-local
/^fc00:/, // IPv6 unique local
/^fd00:/, // IPv6 unique local
];

/**
* Validates if an IP address is private or localhost
*/
function isPrivateOrLocalIP(ip: string): boolean {
return PRIVATE_IP_RANGES.some((range) => range.test(ip));
}

/**
* Validates avatar URL with strict security checks
* @param avatarUrl - The URL to validate
* @throws TRPCError if validation fails
*/
export async function validateAvatarUrl(avatarUrl: string): Promise<void> {
// Step 1: Basic URL format validation
let parsedUrl: URL;
try {
parsedUrl = new URL(avatarUrl);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid avatar URL format",
});
}

// Step 2: Require HTTPS scheme
if (parsedUrl.protocol !== "https:") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Avatar URL must use HTTPS protocol",
});
}

// Step 3: Extract and validate hostname
const hostname = parsedUrl.hostname;

// Step 4: Reject direct IP addresses
if (isIP(hostname)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Avatar URL cannot be a direct IP address. Please use a trusted image hosting service.",
});
}

// Step 5: Check for localhost or private IP ranges
if (isPrivateOrLocalIP(hostname)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Avatar URL cannot point to localhost or private network addresses",
});
}

// Step 6: Validate against allowlist of trusted hosts
const isAllowedHost = ALLOWED_IMAGE_HOSTS.some((allowedHost) => {
return hostname === allowedHost || hostname.endsWith(`.${allowedHost}`);
});

if (!isAllowedHost) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Avatar URL must be from a trusted image hosting service. Allowed hosts: ${ALLOWED_IMAGE_HOSTS.join(", ")}`,
});
}

// Step 7: Perform server-side HEAD request to validate the resource
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);

const response = await fetch(avatarUrl, {
method: "HEAD",
signal: controller.signal,
redirect: "error",
headers: {
"User-Agent": "OpenSox-Avatar-Validator/1.0",
},
});

clearTimeout(timeoutId);

// Check if request was successful
if (!response.ok) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Avatar URL is not accessible (HTTP ${response.status})`,
});
}

// Step 8: Validate Content-Type is an image
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.startsWith("image/")) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Avatar URL must point to an image file. Received content-type: ${contentType || "unknown"}`,
});
}

// Step 9: Validate Content-Length is within limits
const contentLength = response.headers.get("content-length");
if (contentLength) {
const sizeBytes = parseInt(contentLength, 10);
if (sizeBytes > MAX_IMAGE_SIZE_BYTES) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Avatar image is too large. Maximum size: ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024}MB`,
});
}
}
} catch (error) {
// Handle fetch errors
if (error instanceof TRPCError) {
throw error;
}

if ((error as Error).name === "AbortError") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Avatar URL validation timed out. The image may be too large or the server is unresponsive.",
});
}

throw new TRPCError({
code: "BAD_REQUEST",
message: `Failed to validate avatar URL: ${(error as Error).message}`,
});
}
}

/**
* Zod custom refinement for avatar URL validation
* Use this with .refine() on a z.string().url() schema
*/
export async function avatarUrlRefinement(url: string): Promise<boolean> {
try {
await validateAvatarUrl(url);
return true;
} catch (error) {
return false;
}
}
Loading