Skip to content

Commit 55d72ce

Browse files
authored
Merge pull request #262 from huamanraj/feat/testimonials-page
[Feat] pro users testimonials page with redis caching
2 parents 394a566 + 1d56b62 commit 55d72ce

28 files changed

Lines changed: 1371 additions & 73 deletions

File tree

apps/api/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,6 @@ ZEPTOMAIL_TOKEN=zeptomail-token
3030
# key for the encryption
3131
# can be created by running this: echo "$(openssl rand -hex 32)opensox$(openssl rand -hex 16)"
3232
ENCRYPTION_KEY=encryption-key
33+
34+
35+
REDIS_URL=redis://localhost:6379

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,4 @@
4242
"prisma": {
4343
"seed": "tsx prisma/seed.ts"
4444
}
45-
}
45+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- CreateTable
2+
CREATE TABLE "Testimonial" (
3+
"id" TEXT NOT NULL,
4+
"userId" TEXT NOT NULL,
5+
"content" TEXT NOT NULL,
6+
"name" TEXT NOT NULL,
7+
"avatar" TEXT NOT NULL,
8+
"socialLink" TEXT,
9+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
"updatedAt" TIMESTAMP(3) NOT NULL,
11+
12+
CONSTRAINT "Testimonial_pkey" PRIMARY KEY ("id")
13+
);
14+
15+
-- CreateIndex
16+
CREATE UNIQUE INDEX "Testimonial_userId_key" ON "Testimonial"("userId");
17+
18+
-- AddForeignKey
19+
ALTER TABLE "Testimonial" ADD CONSTRAINT "Testimonial_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

apps/api/prisma/schema.prisma

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,19 @@ model User {
4242
accounts Account[]
4343
payments Payment[]
4444
subscriptions Subscription[]
45+
testimonial Testimonial?
46+
}
47+
48+
model Testimonial {
49+
id String @id @default(cuid())
50+
userId String @unique
51+
content String
52+
name String
53+
avatar String
54+
socialLink String?
55+
createdAt DateTime @default(now())
56+
updatedAt DateTime @updatedAt
57+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
4558
}
4659

4760
model Account {

apps/api/src/routers/_app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { userRouter } from "./user.js";
44
import { projectRouter } from "./projects.js";
55
import { authRouter } from "./auth.js";
66
import { paymentRouter } from "./payment.js";
7+
import { testimonialRouter } from "./testimonial.js";
78
import { z } from "zod";
89

910
const testRouter = router({
@@ -21,6 +22,7 @@ export const appRouter = router({
2122
project: projectRouter,
2223
auth: authRouter,
2324
payment: paymentRouter,
25+
testimonial: testimonialRouter,
2426
});
2527

2628
export type AppRouter = typeof appRouter;
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { router, protectedProcedure, publicProcedure } from "../trpc.js";
2+
import { z } from "zod";
3+
import { userService } from "../services/user.service.js";
4+
import { TRPCError } from "@trpc/server";
5+
import { validateAvatarUrl } from "../utils/avatar-validator.js";
6+
7+
export const testimonialRouter = router({
8+
getAll: publicProcedure.query(async ({ ctx }: any) => {
9+
const testimonials = await ctx.db.prisma.testimonial.findMany({
10+
orderBy: {
11+
createdAt: "desc",
12+
},
13+
});
14+
15+
return testimonials;
16+
}),
17+
18+
getMyTestimonial: protectedProcedure.query(async ({ ctx }: any) => {
19+
const userId = ctx.user.id;
20+
21+
const { isPaidUser } = await userService.checkSubscriptionStatus(
22+
ctx.db.prisma,
23+
userId
24+
);
25+
26+
if (!isPaidUser) {
27+
throw new TRPCError({
28+
code: "FORBIDDEN",
29+
message: "Only premium users can submit testimonials",
30+
});
31+
}
32+
33+
const testimonial = await ctx.db.prisma.testimonial.findUnique({
34+
where: { userId },
35+
});
36+
37+
return {
38+
testimonial,
39+
};
40+
}),
41+
42+
submit: protectedProcedure
43+
.input(
44+
z.object({
45+
name: z
46+
.string()
47+
.min(1, "Name is required")
48+
.max(40, "Name must be at most 40 characters"),
49+
content: z
50+
.string()
51+
.min(10, "Testimonial must be at least 10 characters")
52+
.max(1500, "Testimonial must be at most 1500 characters"),
53+
avatar: z.url(),
54+
socialLink: z
55+
.string()
56+
.optional()
57+
.refine(
58+
(val) => {
59+
if (!val || val === "") return true;
60+
try {
61+
const parsedUrl = new URL(val);
62+
const supportedPlatforms = [
63+
"twitter.com",
64+
"x.com",
65+
"linkedin.com",
66+
"instagram.com",
67+
"youtube.com",
68+
"youtu.be",
69+
];
70+
return supportedPlatforms.some(
71+
(platform) =>
72+
parsedUrl.hostname === platform ||
73+
parsedUrl.hostname.endsWith("." + platform)
74+
);
75+
} catch {
76+
return false;
77+
}
78+
},
79+
{
80+
message:
81+
"Must be a valid Twitter/X, LinkedIn, Instagram, or YouTube URL",
82+
}
83+
)
84+
.or(z.literal("")),
85+
})
86+
)
87+
.mutation(async ({ ctx, input }: any) => {
88+
const userId = ctx.user.id;
89+
90+
const { isPaidUser } = await userService.checkSubscriptionStatus(
91+
ctx.db.prisma,
92+
userId
93+
);
94+
95+
if (!isPaidUser) {
96+
throw new TRPCError({
97+
code: "FORBIDDEN",
98+
message: "Only premium users can submit testimonials",
99+
});
100+
}
101+
102+
const existingTestimonial = await ctx.db.prisma.testimonial.findUnique({
103+
where: { userId },
104+
});
105+
106+
if (existingTestimonial) {
107+
throw new TRPCError({
108+
code: "BAD_REQUEST",
109+
message:
110+
"You have already submitted a testimonial. Testimonials cannot be edited once submitted.",
111+
});
112+
}
113+
114+
// Validate avatar URL with strict security checks
115+
await validateAvatarUrl(input.avatar);
116+
117+
const result = await ctx.db.prisma.testimonial.create({
118+
data: {
119+
userId,
120+
name: input.name,
121+
content: input.content,
122+
avatar: input.avatar,
123+
socialLink: input.socialLink || null,
124+
},
125+
});
126+
127+
return result;
128+
}),
129+
});

apps/api/src/routers/user.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,5 @@ export const userRouter = router({
3434
userId,
3535
input.completedSteps
3636
);
37-
}),
37+
}),
3838
});

apps/api/src/services/payment.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ interface CreateOrderInput {
1515
notes?: Record<string, string>;
1616
}
1717

18-
interface RazorpayOrderSuccess {
18+
export interface RazorpayOrderSuccess {
1919
amount: number;
2020
amount_due: number;
2121
amount_paid: number;
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { TRPCError } from "@trpc/server";
2+
import { isIP } from "net";
3+
4+
// Configuration
5+
const ALLOWED_IMAGE_HOSTS = [
6+
"avatars.githubusercontent.com",
7+
"lh3.googleusercontent.com",
8+
"graph.facebook.com",
9+
"pbs.twimg.com",
10+
"cdn.discordapp.com",
11+
"i.imgur.com",
12+
"res.cloudinary.com",
13+
"ik.imagekit.io",
14+
"images.unsplash.com",
15+
"ui-avatars.com",
16+
];
17+
18+
const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
19+
const REQUEST_TIMEOUT_MS = 5000; // 5 seconds
20+
21+
// Private IP ranges
22+
const PRIVATE_IP_RANGES = [
23+
/^127\./, // 127.0.0.0/8 (localhost)
24+
/^10\./, // 10.0.0.0/8
25+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12
26+
/^192\.168\./, // 192.168.0.0/16
27+
/^169\.254\./, // 169.254.0.0/16 (link-local)
28+
/^::1$/, // IPv6 localhost
29+
/^fe80:/, // IPv6 link-local
30+
/^fc00:/, // IPv6 unique local
31+
/^fd00:/, // IPv6 unique local
32+
];
33+
34+
/**
35+
* Validates if an IP address is private or localhost
36+
*/
37+
function isPrivateOrLocalIP(ip: string): boolean {
38+
return PRIVATE_IP_RANGES.some((range) => range.test(ip));
39+
}
40+
41+
/**
42+
* Validates avatar URL with strict security checks
43+
* @param avatarUrl - The URL to validate
44+
* @throws TRPCError if validation fails
45+
*/
46+
export async function validateAvatarUrl(avatarUrl: string): Promise<void> {
47+
// Step 1: Basic URL format validation
48+
let parsedUrl: URL;
49+
try {
50+
parsedUrl = new URL(avatarUrl);
51+
} catch (error) {
52+
throw new TRPCError({
53+
code: "BAD_REQUEST",
54+
message: "Invalid avatar URL format",
55+
});
56+
}
57+
58+
// Step 2: Require HTTPS scheme
59+
if (parsedUrl.protocol !== "https:") {
60+
throw new TRPCError({
61+
code: "BAD_REQUEST",
62+
message: "Avatar URL must use HTTPS protocol",
63+
});
64+
}
65+
66+
// Step 3: Extract and validate hostname
67+
const hostname = parsedUrl.hostname;
68+
69+
// Step 4: Reject direct IP addresses
70+
if (isIP(hostname)) {
71+
throw new TRPCError({
72+
code: "BAD_REQUEST",
73+
message: "Avatar URL cannot be a direct IP address. Please use a trusted image hosting service.",
74+
});
75+
}
76+
77+
// Step 5: Check for localhost or private IP ranges
78+
if (isPrivateOrLocalIP(hostname)) {
79+
throw new TRPCError({
80+
code: "BAD_REQUEST",
81+
message: "Avatar URL cannot point to localhost or private network addresses",
82+
});
83+
}
84+
85+
// Step 6: Validate against allowlist of trusted hosts
86+
const isAllowedHost = ALLOWED_IMAGE_HOSTS.some((allowedHost) => {
87+
return hostname === allowedHost || hostname.endsWith(`.${allowedHost}`);
88+
});
89+
90+
if (!isAllowedHost) {
91+
throw new TRPCError({
92+
code: "BAD_REQUEST",
93+
message: `Avatar URL must be from a trusted image hosting service. Allowed hosts: ${ALLOWED_IMAGE_HOSTS.join(", ")}`,
94+
});
95+
}
96+
97+
// Step 7: Perform server-side HEAD request to validate the resource
98+
try {
99+
const controller = new AbortController();
100+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
101+
102+
const response = await fetch(avatarUrl, {
103+
method: "HEAD",
104+
signal: controller.signal,
105+
redirect: "error",
106+
headers: {
107+
"User-Agent": "OpenSox-Avatar-Validator/1.0",
108+
},
109+
});
110+
111+
clearTimeout(timeoutId);
112+
113+
// Check if request was successful
114+
if (!response.ok) {
115+
throw new TRPCError({
116+
code: "BAD_REQUEST",
117+
message: `Avatar URL is not accessible (HTTP ${response.status})`,
118+
});
119+
}
120+
121+
// Step 8: Validate Content-Type is an image
122+
const contentType = response.headers.get("content-type");
123+
if (!contentType || !contentType.startsWith("image/")) {
124+
throw new TRPCError({
125+
code: "BAD_REQUEST",
126+
message: `Avatar URL must point to an image file. Received content-type: ${contentType || "unknown"}`,
127+
});
128+
}
129+
130+
// Step 9: Validate Content-Length is within limits
131+
const contentLength = response.headers.get("content-length");
132+
if (contentLength) {
133+
const sizeBytes = parseInt(contentLength, 10);
134+
if (sizeBytes > MAX_IMAGE_SIZE_BYTES) {
135+
throw new TRPCError({
136+
code: "BAD_REQUEST",
137+
message: `Avatar image is too large. Maximum size: ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024}MB`,
138+
});
139+
}
140+
}
141+
} catch (error) {
142+
// Handle fetch errors
143+
if (error instanceof TRPCError) {
144+
throw error;
145+
}
146+
147+
if ((error as Error).name === "AbortError") {
148+
throw new TRPCError({
149+
code: "BAD_REQUEST",
150+
message: "Avatar URL validation timed out. The image may be too large or the server is unresponsive.",
151+
});
152+
}
153+
154+
throw new TRPCError({
155+
code: "BAD_REQUEST",
156+
message: `Failed to validate avatar URL: ${(error as Error).message}`,
157+
});
158+
}
159+
}
160+
161+
/**
162+
* Zod custom refinement for avatar URL validation
163+
* Use this with .refine() on a z.string().url() schema
164+
*/
165+
export async function avatarUrlRefinement(url: string): Promise<boolean> {
166+
try {
167+
await validateAvatarUrl(url);
168+
return true;
169+
} catch (error) {
170+
return false;
171+
}
172+
}

0 commit comments

Comments
 (0)