From 7891a24d838e9f9229afaa036d0a2c6997c3283c Mon Sep 17 00:00:00 2001 From: Cameron Custer Date: Wed, 5 Mar 2025 09:57:29 -0800 Subject: [PATCH 1/2] auth --- src/lib/header/Header.svelte | 112 +++++++- src/lib/services/auth.ts | 69 +++++ src/routes/+layout.svelte | 18 +- src/routes/auth/callback/+server.js | 22 ++ src/routes/submit/+page.svelte | 414 ++++++++++++++++++++++++++++ supabase.session.sql | 94 ++++++- 6 files changed, 717 insertions(+), 12 deletions(-) create mode 100644 src/lib/services/auth.ts create mode 100644 src/routes/auth/callback/+server.js create mode 100644 src/routes/submit/+page.svelte diff --git a/src/lib/header/Header.svelte b/src/lib/header/Header.svelte index f4b76c3..66b49db 100644 --- a/src/lib/header/Header.svelte +++ b/src/lib/header/Header.svelte @@ -1,5 +1,23 @@ -
@@ -18,7 +36,24 @@
  • About
  • + {#if $user} +
  • + Submit Problem +
  • + {/if} +
    + {#if $user} + + {:else} + + {/if} +
    @@ -59,6 +94,7 @@ nav { display: flex; align-items: center; + gap: 1.5rem; } ul { @@ -98,13 +134,87 @@ color: var(--accent-color); } + .auth-buttons { + display: flex; + align-items: center; + } + + .login-button, + .logout-button { + border: none; + border-radius: 4px; + padding: 0.4rem 0.75rem; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.4rem; + } + + .login-button { + background-color: #4285f4; + color: white; + border: 1px solid #4285f4; + border-radius: 4px; + padding: 0.4rem 0.75rem; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + } + + .login-button:hover { + background-color: #3367d6; + border-color: #3367d6; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } + + .logout-button { + background-color: transparent; + color: var(--text-color); + border: 1px solid var(--border-color); + } + + .logout-button:hover { + background-color: rgba(0, 0, 0, 0.05); + color: var(--text-color); + } + + .user-info { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .user-name { + font-weight: 600; + font-size: 0.875rem; + } + @media (max-width: 768px) { .container { padding: 0 1rem; } + nav { + flex-direction: column; + align-items: flex-end; + gap: 0.75rem; + } + .logo span { display: none; } + + .user-name { + display: none; + } + + .login-button { + padding: 0.4rem 0.75rem; + } } diff --git a/src/lib/services/auth.ts b/src/lib/services/auth.ts new file mode 100644 index 0000000..f50a04b --- /dev/null +++ b/src/lib/services/auth.ts @@ -0,0 +1,69 @@ +import { supabase } from './database'; +import { writable } from 'svelte/store'; +import type { Session, User } from '@supabase/supabase-js'; + +// Create a store for the user +export const user = writable(null); +export const session = writable(null); + +// Initialize the auth state +export async function initAuth() { + // Get the initial session + const { data } = await supabase.auth.getSession(); + session.set(data.session); + user.set(data.session?.user || null); + + // Listen for auth changes + const { data: authListener } = supabase.auth.onAuthStateChange((event, newSession) => { + session.set(newSession); + user.set(newSession?.user || null); + }); + + return authListener; +} + +// Sign in with Github +export async function signInWithGithub() { + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'github', + options: { + redirectTo: `${window.location.origin}/auth/callback` + } + }); + + if (error) { + console.error('Error signing in with Github:', error); + throw error; + } +} + +// Sign out +export async function signOut() { + const { error } = await supabase.auth.signOut(); + + if (error) { + console.error('Error signing out:', error); + throw error; + } +} + +// Check if user has admin role +export async function isAdmin(userId: string): Promise { + try { + const { data, error } = await supabase + .from('user_roles') + .select('role') + .eq('user_id', userId) + .single(); + + if (error) { + console.error('Error checking admin status:', error); + return false; + } + + return data?.role === 'admin'; + } catch (err) { + console.error('Failed to check admin status:', err); + return false; + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e440aee..a48ff05 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,7 +1,23 @@ -
    diff --git a/src/routes/auth/callback/+server.js b/src/routes/auth/callback/+server.js new file mode 100644 index 0000000..f54bdaf --- /dev/null +++ b/src/routes/auth/callback/+server.js @@ -0,0 +1,22 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +import { redirect } from '@sveltejs/kit'; + +export const GET = async (event) => { + const { + url, + locals: { supabase } + } = event; + const code = url.searchParams.get('code'); + const next = url.searchParams.get('next') ?? '/'; + + if (code) { + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + throw redirect(303, `/${next.slice(1)}`); + } + } + + // return the user to an error page with instructions + throw redirect(303, '/auth/auth-code-error'); +}; diff --git a/src/routes/submit/+page.svelte b/src/routes/submit/+page.svelte new file mode 100644 index 0000000..8734bdb --- /dev/null +++ b/src/routes/submit/+page.svelte @@ -0,0 +1,414 @@ + + + + Submit Problem - Probhub + + +
    +
    +

    Submit a Problem

    +

    + Share interesting programming problems with the community. Please ensure the problem is from + Codeforces and provide accurate information. +

    + + {#if checkingAdmin} +
    Checking your permissions...
    + {:else if error} +
    {error}
    + {:else if success} +
    + Problem submitted successfully! Thank you for contributing to Probhub. +
    + {/if} + + {#if isAdminUser && !checkingAdmin} +
    +
    + + +
    + +
    + + + Please use the format: https://codeforces.com/contest/1234/problem/A +
    + +
    + + + Codeforces rating from 800 to 3500 +
    + +
    + +
    + +
    + {#if tags.length > 0} +
    + {#each tags as tag, index} + + {tag} + + + {/each} +
    + {/if} + Common tags: dp, greedy, math, implementation, graphs +
    + +
    + +
    +
    + {/if} +
    +
    + + diff --git a/supabase.session.sql b/supabase.session.sql index 723b4bd..b6b3851 100644 --- a/supabase.session.sql +++ b/supabase.session.sql @@ -1,14 +1,88 @@ --- Create problems table -CREATE TABLE problems ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), +-- Create user_roles table for managing admin users +CREATE TABLE IF NOT EXISTS user_roles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('admin', 'user')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id) +); +-- Create RLS policies for user_roles table +ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY; +-- Allow users to read their own role +CREATE POLICY "Users can read their own role" ON user_roles FOR +SELECT USING (auth.uid() = user_id); +-- Only allow super admins to insert/update roles +-- Note: The first admin will need to be created manually by a database administrator +CREATE POLICY "Only super admins can insert roles" ON user_roles FOR +INSERT WITH CHECK ( + EXISTS ( + SELECT 1 + FROM user_roles + WHERE user_id = auth.uid() + AND role = 'admin' + ) + ); +CREATE POLICY "Only super admins can update roles" ON user_roles FOR +UPDATE USING ( + EXISTS ( + SELECT 1 + FROM user_roles + WHERE user_id = auth.uid() + AND role = 'admin' + ) + ); +-- Disable deletion of user roles entirely +-- We don't want to allow deletion of user roles at all +-- Create function to automatically assign 'user' role to new users +CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS TRIGGER AS $$ BEGIN +INSERT INTO public.user_roles (user_id, role) +VALUES (NEW.id, 'user'); +RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; +-- Create trigger to call the function when a new user is created +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created +AFTER +INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); +-- Create problems table if it doesn't exist +CREATE TABLE IF NOT EXISTS problems ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, - tags TEXT [] NOT NULL, + tags TEXT [] NOT NULL DEFAULT '{}', difficulty INTEGER NOT NULL, - url TEXT NOT NULL UNIQUE, - solved INTEGER, - date_added DATE NOT NULL DEFAULT CURRENT_DATE, + url TEXT NOT NULL, + solved INTEGER DEFAULT 0, + date_added TIMESTAMP WITH TIME ZONE DEFAULT NOW(), added_by TEXT NOT NULL, added_by_url TEXT NOT NULL, - likes INTEGER NOT NULL DEFAULT 0, - dislikes INTEGER NOT NULL DEFAULT 0 -); \ No newline at end of file + likes INTEGER DEFAULT 0, + dislikes INTEGER DEFAULT 0 +); +-- Create RLS policies for problems table +ALTER TABLE problems ENABLE ROW LEVEL SECURITY; +-- Allow anyone to read problems +CREATE POLICY "Anyone can read problems" ON problems FOR +SELECT USING (true); +-- Only admins can insert problems +CREATE POLICY "Only admins can insert problems" ON problems FOR +INSERT WITH CHECK ( + EXISTS ( + SELECT 1 + FROM user_roles + WHERE user_id = auth.uid() + AND role = 'admin' + ) + ); +-- Only admins can update problems +CREATE POLICY "Only admins can update problems" ON problems FOR +UPDATE USING ( + EXISTS ( + SELECT 1 + FROM user_roles + WHERE user_id = auth.uid() + AND role = 'admin' + ) + ); +-- Disable deletion of problems entirely +-- We don't want to allow deletion of problems at all \ No newline at end of file From d9af2606f200ed33b152b1f6d2620d26b353b7d8 Mon Sep 17 00:00:00 2001 From: Cameron Custer Date: Wed, 5 Mar 2025 21:38:16 -0800 Subject: [PATCH 2/2] problem submission lean on the CF api --- src/routes/submit/+page.svelte | 623 ++++++++++++++++++++++----------- 1 file changed, 424 insertions(+), 199 deletions(-) diff --git a/src/routes/submit/+page.svelte b/src/routes/submit/+page.svelte index 8734bdb..3e7ae95 100644 --- a/src/routes/submit/+page.svelte +++ b/src/routes/submit/+page.svelte @@ -7,11 +7,7 @@ import type { Unsubscriber } from 'svelte/store'; // Form data - let name = ''; - let url = ''; - let difficulty = 1500; - let tags: string[] = []; - let tagInput = ''; + let problemUrls = ''; let loading = false; let error: string | null = null; let success = false; @@ -19,6 +15,14 @@ let checkingAdmin = true; let userUnsubscribe: Unsubscriber | null = null; + // Processing status + let processingResults: { + url: string; + status: 'pending' | 'success' | 'error'; + message?: string; + name?: string; + }[] = []; + // Redirect if not logged in and check admin status onMount(() => { userUnsubscribe = user.subscribe(async (value) => { @@ -50,30 +54,100 @@ }; }); - // Handle tag input - function handleTagKeydown(event: KeyboardEvent) { - if (event.key === 'Enter' || event.key === ',') { - event.preventDefault(); - addTag(); + // Function to extract contest ID and problem index from URL + function extractProblemInfo(problemUrl: string) { + const urlPattern = /contest\/(\d+)\/problem\/([A-Z\d]+)/; + const match = problemUrl.match(urlPattern); + + if (!match) { + return null; } + + return { + contestId: match[1], + index: match[2], + problemId: `${match[1]}${match[2]}`, + url: problemUrl.trim() + }; } - function addTag() { - const tag = tagInput.trim().toLowerCase(); - if (tag && !tags.includes(tag)) { - tags = [...tags, tag]; - tagInput = ''; - } + // Function to extract all valid URLs from the input text + function extractUrls(text: string): string[] { + // Match URLs that contain "codeforces.com/contest" and "/problem/" + const urlRegex = /https?:\/\/(?:www\.)?codeforces\.com\/contest\/\d+\/problem\/[A-Z\d]+/g; + return (text.match(urlRegex) || []).map((url) => url.trim()); } - function removeTag(index: number) { - tags = tags.filter((_, i) => i !== index); + // Function to fetch problem data from Codeforces API + async function fetchProblemData(problemInfo: { + contestId: string; + index: string; + problemId: string; + url: string; + }) { + try { + // Check if problem already exists in our database + const { data: existingProblem } = await supabase + .from('problems') + .select('id') + .eq('id', problemInfo.problemId) + .single(); + + if (existingProblem) { + return { + success: false, + message: 'Problem already exists in database', + problemInfo + }; + } + + // Fetch problem data from Codeforces API + const response = await fetch( + `https://codeforces.com/api/contest.standings?contestId=${problemInfo.contestId}&from=1&count=1` + ); + const data = await response.json(); + + if (data.status !== 'OK') { + throw new Error('Failed to fetch problem data from Codeforces API'); + } + + // Find the problem in the response + const problem = data.result.problems.find((p: any) => p.index === problemInfo.index); + + if (!problem) { + throw new Error('Problem not found in Codeforces API response'); + } + + return { + success: true, + problem: { + id: problemInfo.problemId, + name: problem.name, + tags: problem.tags || [], + difficulty: problem.rating || 1500, + url: problemInfo.url, + solved: 0, + date_added: new Date().toISOString(), + added_by: $user?.email?.split('@')[0] || 'anonymous', + added_by_url: `https://github.com/${$user?.email?.split('@')[0] || 'anonymous'}`, + likes: 0, + dislikes: 0 + } + }; + } catch (err) { + console.error('Error fetching problem data:', err); + return { + success: false, + message: err instanceof Error ? err.message : 'Unknown error', + problemInfo + }; + } } - // Submit the problem - async function handleSubmit() { + // Function to process all problem URLs + async function processProblems() { if (!$user) { - error = 'You must be logged in to submit a problem.'; + error = 'You must be logged in to submit problems.'; return; } @@ -82,8 +156,10 @@ return; } - if (!name || !url || !difficulty) { - error = 'Please fill in all required fields.'; + const urls = extractUrls(problemUrls); + + if (urls.length === 0) { + error = 'No valid Codeforces problem URLs found. Please enter at least one valid URL.'; return; } @@ -91,81 +167,110 @@ error = null; success = false; + // Initialize processing results + processingResults = urls.map((url) => ({ + url, + status: 'pending' + })); + try { - // Extract contest ID and index from URL - // Example: https://codeforces.com/contest/1234/problem/A - const urlPattern = /contest\/(\d+)\/problem\/([A-Z\d]+)/; - const match = url.match(urlPattern); - - if (!match) { - error = - 'Invalid Codeforces problem URL. Please use the format: https://codeforces.com/contest/1234/problem/A'; - loading = false; - return; - } + // Process each URL + for (let i = 0; i < urls.length; i++) { + const url = urls[i]; + const problemInfo = extractProblemInfo(url); + + if (!problemInfo) { + processingResults[i] = { + url, + status: 'error', + message: 'Invalid URL format' + }; + continue; + } - const contestId = match[1]; - const index = match[2]; - const problemId = `${contestId}${index}`; + // Update status to show we're processing this URL + processingResults[i] = { + ...processingResults[i], + message: 'Fetching data...' + }; + + // Force UI update + processingResults = [...processingResults]; + + // Fetch problem data + const result = await fetchProblemData(problemInfo); + + if (!result.success) { + processingResults[i] = { + url, + status: 'error', + message: result.message + }; + continue; + } - // Check if problem already exists - const { data: existingProblem } = await supabase - .from('problems') - .select('id') - .eq('id', problemId) - .single(); + // Try to insert the problem + try { + const { error: insertError } = await supabase.from('problems').insert(result.problem); + + if (insertError) { + processingResults[i] = { + url, + status: 'error', + message: `Database error: ${insertError.message}` + }; + } else { + processingResults[i] = { + url, + status: 'success', + name: result.problem.name, + message: 'Added successfully' + }; + } + } catch (err) { + processingResults[i] = { + url, + status: 'error', + message: err instanceof Error ? err.message : 'Failed to insert problem' + }; + } - if (existingProblem) { - error = 'This problem has already been submitted.'; - loading = false; - return; + // Force UI update + processingResults = [...processingResults]; } - // Submit the problem - const { error: insertError } = await supabase.from('problems').insert({ - id: problemId, - name, - tags, - difficulty, - url, - solved: 0, - date_added: new Date().toISOString(), - added_by: $user.email?.split('@')[0] || 'anonymous', - added_by_url: `https://github.com/${$user.email?.split('@')[0] || 'anonymous'}`, - likes: 0, - dislikes: 0 - }); - - if (insertError) { - throw insertError; + // Check if any problems were successfully added + const successCount = processingResults.filter((r) => r.status === 'success').length; + if (successCount > 0) { + success = true; } - - // Reset form - name = ''; - url = ''; - difficulty = 1500; - tags = []; - tagInput = ''; - success = true; } catch (err) { - console.error('Error submitting problem:', err); - error = 'Failed to submit problem. Please try again.'; + console.error('Error processing problems:', err); + error = 'An unexpected error occurred while processing problems.'; } finally { loading = false; } } + + // Clear the form + function clearForm() { + problemUrls = ''; + processingResults = []; + error = null; + success = false; + } - Submit Problem - Probhub + Submit Problems - Probhub
    -

    Submit a Problem

    +

    Submit Problems

    - Share interesting programming problems with the community. Please ensure the problem is from - Codeforces and provide accurate information. + Share interesting programming problems with the community. Paste one or more Codeforces + problem URLs below, and we'll automatically fetch and add them to the database.

    {#if checkingAdmin} @@ -173,146 +278,158 @@ {:else if error}
    {error}
    {:else if success} -
    - Problem submitted successfully! Thank you for contributing to Probhub. -
    +
    Problems processed successfully! See results below.
    {/if} {#if isAdminUser && !checkingAdmin} -
    -
    - - -
    - -
    - - - Please use the format: https://codeforces.com/contest/1234/problem/A -
    - +
    - - Problem URLs * + + + Format: https://codeforces.com/contest/1234/problem/A +
    + You can paste multiple URLs - one per line, or separated by spaces. +
    + {#if processingResults.length > 0} + + {/if}
    + + {#if processingResults.length > 0} +
    +

    Processing Results

    +
    + {#each processingResults as result} +
    + +
    + {#if result.status === 'pending'} + Pending + {:else if result.status === 'success'} + ✓ {result.message} + {:else} + ✗ {result.message} + {/if} +
    +
    + {/each} +
    +
    +

    + Total: {processingResults.length} | Success: {processingResults.filter( + (r) => r.status === 'success' + ).length} | Failed: {processingResults.filter((r) => r.status === 'error').length} | Pending: + {processingResults.filter((r) => r.status === 'pending').length} +

    +
    +
    + {/if} {/if}