Skip to content

Commit 51a4315

Browse files
committed
Centralize HTML safety and align admin authorization checks
Add shared HTML/JSON-LD sanitization utilities and apply them to rich text and structured data rendering paths to reduce script injection risk. Standardize admin checks around profiles.is_admin with legacy fallback support, and align schema and seeding scripts with the new admin source of truth.
1 parent f38b97c commit 51a4315

File tree

11 files changed

+169
-80
lines changed

11 files changed

+169
-80
lines changed

docker/scripts/seed-admin.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,27 @@ if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
4343
USER_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
4444
echo "Admin user created successfully (ID: ${USER_ID})"
4545

46+
if [ -n "$USER_ID" ]; then
47+
echo "Marking seeded user as admin..."
48+
ADMIN_RESPONSE=$(curl -s -w "\n%{http_code}" "http://kong:8000/rest/v1/profiles" \
49+
-H "apikey: ${SUPABASE_SERVICE_KEY}" \
50+
-H "Authorization: Bearer ${SUPABASE_SERVICE_KEY}" \
51+
-H "Content-Type: application/json" \
52+
-H "Prefer: resolution=merge-duplicates" \
53+
-d "{
54+
\"user_id\": \"${USER_ID}\",
55+
\"email\": \"${SEED_ADMIN_EMAIL}\",
56+
\"is_admin\": true
57+
}")
58+
59+
ADMIN_HTTP_CODE=$(echo "$ADMIN_RESPONSE" | tail -n1)
60+
if [ "$ADMIN_HTTP_CODE" = "200" ] || [ "$ADMIN_HTTP_CODE" = "201" ]; then
61+
echo "Admin role granted successfully"
62+
else
63+
echo "Warning: Failed to grant admin role (HTTP ${ADMIN_HTTP_CODE})"
64+
fi
65+
fi
66+
4667
# Grant Pro subscription if AUTO_PRO_SUBSCRIPTION is enabled
4768
if [ "${AUTO_PRO_SUBSCRIPTION:-false}" = "true" ] && [ -n "$USER_ID" ]; then
4869
echo "Granting Pro subscription to admin user..."

docker/supabase/volumes/db/init/01-app-schema.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ CREATE TABLE IF NOT EXISTS public.profiles (
128128
website text NULL,
129129
linkedin_url text NULL,
130130
github_url text NULL,
131+
is_admin boolean NOT NULL DEFAULT false,
131132
work_experience jsonb NULL DEFAULT '[]'::jsonb,
132133
education jsonb NULL DEFAULT '[]'::jsonb,
133134
skills jsonb NULL DEFAULT '[]'::jsonb,
@@ -137,6 +138,9 @@ CREATE TABLE IF NOT EXISTS public.profiles (
137138
CONSTRAINT profiles_user_id_key UNIQUE (user_id)
138139
) TABLESPACE pg_default;
139140

141+
ALTER TABLE public.profiles
142+
ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false;
143+
140144
-- Create updated_at trigger for profiles
141145
DROP TRIGGER IF EXISTS update_profiles_updated_at ON public.profiles;
142146
CREATE TRIGGER update_profiles_updated_at BEFORE

schema.sql

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ CREATE TABLE IF NOT EXISTS public.profiles (
131131
website text NULL,
132132
linkedin_url text NULL,
133133
github_url text NULL,
134+
is_admin boolean NOT NULL DEFAULT false,
134135
work_experience jsonb NULL DEFAULT '[]'::jsonb,
135136
education jsonb NULL DEFAULT '[]'::jsonb,
136137
skills jsonb NULL DEFAULT '[]'::jsonb,
@@ -141,6 +142,9 @@ CREATE TABLE IF NOT EXISTS public.profiles (
141142
CONSTRAINT profiles_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE
142143
) TABLESPACE pg_default;
143144

145+
ALTER TABLE public.profiles
146+
ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false;
147+
144148
-- Create updated_at trigger for profiles
145149
DROP TRIGGER IF EXISTS update_profiles_updated_at ON public.profiles;
146150
CREATE TRIGGER update_profiles_updated_at BEFORE
@@ -172,4 +176,4 @@ CREATE POLICY jobs_policy ON public.jobs
172176
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
173177
CREATE POLICY profiles_policy ON public.profiles
174178
USING (user_id = auth.uid())
175-
WITH CHECK (user_id = auth.uid());
179+
WITH CHECK (user_id = auth.uid());

src/app/admin/actions.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,26 +57,37 @@ export async function checkAdminStatus(): Promise<boolean> {
5757
const { data: { user }, error: authError } = await supabase.auth.getUser();
5858

5959
if (authError || !user) {
60-
console.error('Admin Check: Error fetching user or user not authenticated.', authError);
60+
console.error('Admin check failed to resolve user', authError);
6161
return false; // Not authenticated, definitely not admin
6262
}
6363

64-
// Check the admins table for this user
65-
const { data: adminData, error: dbError } = await supabase
64+
// Primary source of truth: profiles.is_admin
65+
const { data: profileData, error: profileError } = await supabase
66+
.from('profiles')
67+
.select('is_admin')
68+
.eq('user_id', user.id)
69+
.maybeSingle();
70+
71+
if (!profileError) {
72+
return profileData?.is_admin === true;
73+
}
74+
75+
// Backward compatibility for older databases still using public.admins
76+
const { data: adminData, error: adminError } = await supabase
6677
.from('admins')
6778
.select('is_admin')
6879
.eq('user_id', user.id)
6980
.maybeSingle();
7081

71-
// Detailed logging for RLS debugging
72-
if (dbError) {
73-
console.error(`Admin Check DB Error: Failed to query admins table for user ${user.id}. RLS issue?`, { code: dbError.code, message: dbError.message, details: dbError.details, hint: dbError.hint });
74-
return false; // Error occurred, assume not admin for safety
75-
} else {
76-
console.log(`Admin Check DB Success: Query for user ${user.id} returned:`, adminData);
82+
if (adminError) {
83+
console.error('Admin check failed for profiles/admins lookup', {
84+
userId: user.id,
85+
profileError: profileError.message,
86+
adminError: adminError.message,
87+
});
88+
return false;
7789
}
7890

79-
// If adminData exists and is_admin is true, return true
8091
return adminData?.is_admin === true;
8192
}
8293

@@ -582,4 +593,4 @@ export async function updateUserSubscriptionPlan(
582593
message: `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`,
583594
};
584595
}
585-
}
596+
}

src/app/blog/[slug]/page.tsx

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { compileMDX } from "next-mdx-remote/rsc";
22
import { notFound } from "next/navigation";
33
import { Metadata } from "next";
44
import { getAllPosts, getPostBySlug } from "@/lib/blog";
5+
import { toSafeJsonScript } from "@/lib/html-safety";
56
import { mdxComponents } from "@/components/blog/mdx-components";
67
import { Calendar, ArrowLeft, Clock } from "lucide-react";
78
import Link from "next/link";
@@ -90,6 +91,37 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
9091
// Calculate reading time (rough estimate)
9192
const wordCount = post.content.split(/\s+/).length;
9293
const readingTime = Math.ceil(wordCount / 200); // Average reading speed
94+
const structuredData = {
95+
'@context': 'https://schema.org',
96+
'@type': 'Article',
97+
headline: post.frontMatter.title,
98+
description: post.frontMatter.description,
99+
image: 'https://resumelm.com/og.webp',
100+
datePublished: new Date(post.frontMatter.date).toISOString(),
101+
dateModified: new Date(post.frontMatter.date).toISOString(),
102+
author: {
103+
'@type': 'Organization',
104+
name: 'ResumeLM Team',
105+
url: 'https://resumelm.com',
106+
},
107+
publisher: {
108+
'@type': 'Organization',
109+
name: 'ResumeLM',
110+
logo: {
111+
'@type': 'ImageObject',
112+
url: 'https://resumelm.com/og.webp',
113+
},
114+
url: 'https://resumelm.com',
115+
},
116+
mainEntityOfPage: {
117+
'@type': 'WebPage',
118+
'@id': `https://resumelm.com/blog/${slug}`,
119+
},
120+
articleSection: 'Career Advice',
121+
keywords: 'resume builder, tech jobs, Vancouver tech, career advice, AI resume, job search',
122+
wordCount,
123+
timeRequired: `PT${readingTime}M`,
124+
};
93125

94126
return (
95127
<main className="min-h-screen relative">
@@ -154,37 +186,7 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
154186
<script
155187
type="application/ld+json"
156188
dangerouslySetInnerHTML={{
157-
__html: JSON.stringify({
158-
'@context': 'https://schema.org',
159-
'@type': 'Article',
160-
headline: post.frontMatter.title,
161-
description: post.frontMatter.description,
162-
image: 'https://resumelm.com/og.webp',
163-
datePublished: new Date(post.frontMatter.date).toISOString(),
164-
dateModified: new Date(post.frontMatter.date).toISOString(),
165-
author: {
166-
'@type': 'Organization',
167-
name: 'ResumeLM Team',
168-
url: 'https://resumelm.com',
169-
},
170-
publisher: {
171-
'@type': 'Organization',
172-
name: 'ResumeLM',
173-
logo: {
174-
'@type': 'ImageObject',
175-
url: 'https://resumelm.com/og.webp',
176-
},
177-
url: 'https://resumelm.com',
178-
},
179-
mainEntityOfPage: {
180-
'@type': 'WebPage',
181-
'@id': `https://resumelm.com/blog/${slug}`,
182-
},
183-
articleSection: 'Career Advice',
184-
keywords: 'resume builder, tech jobs, Vancouver tech, career advice, AI resume, job search',
185-
wordCount: wordCount,
186-
timeRequired: `PT${readingTime}M`,
187-
}),
189+
__html: toSafeJsonScript(structuredData),
188190
}}
189191
/>
190192

@@ -242,4 +244,4 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
242244
</div>
243245
</main>
244246
);
245-
}
247+
}

src/app/page.tsx

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createClient } from "@/utils/supabase/server";
1212
import { redirect } from "next/navigation";
1313
import { Metadata } from "next";
1414
import Script from "next/script";
15+
import { toSafeJsonScript } from "@/lib/html-safety";
1516

1617
// Page-specific metadata that extends the base metadata from layout.tsx
1718
export const metadata: Metadata = {
@@ -37,6 +38,25 @@ export default async function Page() {
3738
if (user) {
3839
redirect("/home");
3940
}
41+
42+
const structuredData = {
43+
"@context": "https://schema.org",
44+
"@type": "SoftwareApplication",
45+
"name": "ResumeLM",
46+
"applicationCategory": "BusinessApplication",
47+
"offers": {
48+
"@type": "Offer",
49+
"price": "0",
50+
"priceCurrency": "USD"
51+
},
52+
"description": "Create ATS-optimized tech resumes in under 10 minutes. 3x your interview chances with AI-powered resume tailoring.",
53+
"operatingSystem": "Web",
54+
"aggregateRating": {
55+
"@type": "AggregateRating",
56+
"ratingValue": "4.8",
57+
"ratingCount": "500"
58+
}
59+
};
4060

4161
return (
4262
<>
@@ -45,24 +65,7 @@ export default async function Page() {
4565
id="schema-data"
4666
type="application/ld+json"
4767
dangerouslySetInnerHTML={{
48-
__html: JSON.stringify({
49-
"@context": "https://schema.org",
50-
"@type": "SoftwareApplication",
51-
"name": "ResumeLM",
52-
"applicationCategory": "BusinessApplication",
53-
"offers": {
54-
"@type": "Offer",
55-
"price": "0",
56-
"priceCurrency": "USD"
57-
},
58-
"description": "Create ATS-optimized tech resumes in under 10 minutes. 3x your interview chances with AI-powered resume tailoring.",
59-
"operatingSystem": "Web",
60-
"aggregateRating": {
61-
"@type": "AggregateRating",
62-
"ratingValue": "4.8",
63-
"ratingCount": "500"
64-
}
65-
})
68+
__html: toSafeJsonScript(structuredData)
6669
}}
6770
/>
6871

@@ -113,4 +116,4 @@ export default async function Page() {
113116
</main>
114117
</>
115118
);
116-
}
119+
}

src/components/cover-letter/cover-letter.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,19 @@ import { useRef, useCallback, useMemo } from 'react';
33
import { Button } from "@/components/ui/button";
44
import { Plus } from "lucide-react";
55
import { useResumeContext } from '@/components/resume/editor/resume-editor-context';
6+
import { sanitizeRichTextHtml } from '@/lib/html-safety';
67

78

89
interface CoverLetterProps {
910
containerWidth: number;
1011

1112
}
1213

13-
function sanitizeHtmlForPreview(html: string): string {
14-
return html
15-
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
16-
.replace(/\son\w+=("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
17-
.replace(/\s(href|src)=("|')\s*javascript:[^"']*("|')/gi, '');
18-
}
19-
20-
2114
export default function CoverLetter({ containerWidth }: CoverLetterProps) {
2215
const { state, dispatch } = useResumeContext();
2316
const contentRef = useRef<HTMLDivElement>(null);
2417
const sanitizedCoverLetterContent = useMemo(
25-
() => sanitizeHtmlForPreview((state.resume.cover_letter?.content as string) || ''),
18+
() => sanitizeRichTextHtml((state.resume.cover_letter?.content as string) || ''),
2619
[state.resume.cover_letter?.content]
2720
);
2821

src/components/jobs/job-listings-card.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,25 @@ export function JobListingsCard() {
4848
const { data: { user } } = await supabase.auth.getUser();
4949

5050
if (user) {
51-
const { data: profile } = await supabase
51+
const { data: profile, error: profileError } = await supabase
5252
.from('profiles')
5353
.select('is_admin')
5454
.eq('user_id', user.id)
55-
.single();
56-
57-
setIsAdmin(profile?.is_admin ?? false);
55+
.maybeSingle();
56+
57+
if (!profileError) {
58+
setIsAdmin(profile?.is_admin === true);
59+
return;
60+
}
61+
62+
// Backward compatibility for deployments still using public.admins
63+
const { data: adminData } = await supabase
64+
.from('admins')
65+
.select('is_admin')
66+
.eq('user_id', user.id)
67+
.maybeSingle();
68+
69+
setIsAdmin(adminData?.is_admin === true);
5870
}
5971
}
6072

@@ -300,4 +312,4 @@ export function JobListingsCard() {
300312
</Card>
301313
</div>
302314
);
303-
}
315+
}

src/components/resume/editor/forms/basic-info-form.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export const BasicInfoForm = memo(function BasicInfoFormComponent({
7979
if (!profile) return;
8080

8181
// List of fields to copy from profile
82-
const fieldsToFill: (keyof Profile)[] = [
82+
const fieldsToFill = [
8383
'first_name',
8484
'last_name',
8585
'email',
@@ -88,7 +88,7 @@ export const BasicInfoForm = memo(function BasicInfoFormComponent({
8888
'website',
8989
'linkedin_url',
9090
'github_url'
91-
];
91+
] as const satisfies Array<keyof Resume>;
9292

9393
// Copy each field if it exists in the profile
9494
fieldsToFill.forEach((field) => {
@@ -192,4 +192,4 @@ export const BasicInfoForm = memo(function BasicInfoFormComponent({
192192
</Card>
193193
</div>
194194
);
195-
}, areBasicInfoPropsEqual);
195+
}, areBasicInfoPropsEqual);

0 commit comments

Comments
 (0)