Skip to content
Open
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
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 @@ -42,4 +42,4 @@
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "Testimonial" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"name" TEXT NOT NULL,
"avatar" TEXT NOT NULL,
"socialLink" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "Testimonial_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "Testimonial_userId_key" ON "Testimonial"("userId");

-- AddForeignKey
ALTER TABLE "Testimonial" ADD CONSTRAINT "Testimonial_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
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;
129 changes: 129 additions & 0 deletions apps/api/src/routers/testimonial.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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) => {
const testimonials = await ctx.db.prisma.testimonial.findMany({
orderBy: {
createdAt: "desc",
},
});

return testimonials;
}),

getMyTestimonial: protectedProcedure.query(async ({ ctx }: 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",
});
}

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(1500, "Testimonial must be at most 1500 characters"),
avatar: z.url(),
socialLink: z
.string()
.optional()
.refine(
(val) => {
if (!val || val === "") return true;
try {
const parsedUrl = new URL(val);
const supportedPlatforms = [
"twitter.com",
"x.com",
"linkedin.com",
"instagram.com",
"youtube.com",
"youtu.be",
];
return supportedPlatforms.some(
(platform) =>
parsedUrl.hostname === platform ||
parsedUrl.hostname.endsWith("." + platform)
);
} catch {
return false;
}
},
{
message:
"Must be a valid Twitter/X, LinkedIn, Instagram, or YouTube URL",
}
)
.or(z.literal("")),
})
)
.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",
});
}

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
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