https://altly.madebydanny.uk
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement security fixes

Implement authentication, rate limiting, URL validation, restricted database access, and error sanitization to secure the application. This includes database migrations, edge function updates, and UI modifications for authentication and error handling.

+400 -43
+2
src/App.tsx
··· 4 4 import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 5 import { BrowserRouter, Routes, Route } from "react-router-dom"; 6 6 import Index from "./pages/Index"; 7 + import Auth from "./pages/Auth"; 7 8 import NotFound from "./pages/NotFound"; 8 9 9 10 const queryClient = new QueryClient(); ··· 16 17 <BrowserRouter> 17 18 <Routes> 18 19 <Route path="/" element={<Index />} /> 20 + <Route path="/auth" element={<Auth />} /> 19 21 {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} 20 22 <Route path="*" element={<NotFound />} /> 21 23 </Routes>
+32 -6
src/components/ImageUploader.tsx
··· 1 - import { useState, useCallback } from "react"; 1 + import { useState, useCallback, useEffect } from "react"; 2 2 import { Upload, Image as ImageIcon, Loader2, Copy, Check } from "lucide-react"; 3 3 import { Button } from "@/components/ui/button"; 4 4 import { Card } from "@/components/ui/card"; 5 5 import { useToast } from "@/hooks/use-toast"; 6 6 import { supabase } from "@/integrations/supabase/client"; 7 + import { User } from "@supabase/supabase-js"; 7 8 8 - export const ImageUploader = () => { 9 + interface ImageUploaderProps { 10 + user: User | null; 11 + } 12 + 13 + export const ImageUploader = ({ user }: ImageUploaderProps) => { 9 14 const [isDragging, setIsDragging] = useState(false); 10 15 const [imageFile, setImageFile] = useState<File | null>(null); 11 16 const [imagePreview, setImagePreview] = useState<string | null>(null); ··· 60 65 }; 61 66 62 67 const generateAltText = async () => { 63 - if (!imageFile) return; 68 + if (!imageFile || !user) { 69 + toast({ 70 + title: "Authentication required", 71 + description: "Please sign in to generate alt text", 72 + variant: "destructive", 73 + }); 74 + return; 75 + } 64 76 65 77 setIsGenerating(true); 66 78 setAltText(null); ··· 83 95 const uploadData = await uploadResponse.json(); 84 96 const imageUrl = uploadData.url; 85 97 86 - // Call edge function to generate alt text 98 + // Get auth token for authenticated request 99 + const { data: { session } } = await supabase.auth.getSession(); 100 + if (!session) { 101 + throw new Error('Not authenticated'); 102 + } 103 + 104 + // Call edge function to generate alt text (authenticated) 87 105 const { data, error } = await supabase.functions.invoke('generate-alt-text', { 88 106 body: { 89 107 imageUrl, ··· 99 117 description: "Alt text generated successfully", 100 118 }); 101 119 102 - } catch (error) { 120 + } catch (error: any) { 103 121 console.error('Error generating alt text:', error); 122 + 123 + let errorMessage = "Failed to generate alt text"; 124 + if (error?.message?.includes('Rate limit')) { 125 + errorMessage = "Rate limit exceeded. Please try again in an hour."; 126 + } else if (error?.message?.includes('Authentication')) { 127 + errorMessage = "Please sign in to continue"; 128 + } 129 + 104 130 toast({ 105 131 title: "Error", 106 - description: error instanceof Error ? error.message : "Failed to generate alt text", 132 + description: errorMessage, 107 133 variant: "destructive", 108 134 }); 109 135 } finally {
+14 -21
src/components/Stats.tsx
··· 11 11 12 12 useEffect(() => { 13 13 const fetchStats = async () => { 14 - // Get total count 15 - const { count } = await supabase 16 - .from('alt_text_generations') 17 - .select('*', { count: 'exact', head: true }); 14 + // Use secure stats function that only exposes aggregates 15 + const { data, error } = await supabase.rpc('get_alt_text_stats'); 18 16 19 - // Get average response time 20 - const { data: generations } = await supabase 21 - .from('alt_text_generations') 22 - .select('generation_time_ms') 23 - .not('generation_time_ms', 'is', null); 17 + if (error) { 18 + console.error('Error fetching stats:', error); 19 + return; 20 + } 24 21 25 - const avgTime = generations && generations.length > 0 26 - ? Math.round(generations.reduce((sum, g) => sum + (g.generation_time_ms || 0), 0) / generations.length) 27 - : 0; 28 - 29 - // Calculate "happy users" as unique generations (simplified metric) 30 - const happyUsers = count ? Math.max(1, Math.floor(count / 6)) : 0; 31 - 32 - setStats({ 33 - imagesUploaded: count || 0, 34 - happyUsers, 35 - avgResponseMs: avgTime, 36 - }); 22 + if (data && data.length > 0) { 23 + const statsData = data[0]; 24 + setStats({ 25 + imagesUploaded: Number(statsData.total_images) || 0, 26 + happyUsers: Number(statsData.happy_users) || 0, 27 + avgResponseMs: Number(statsData.avg_response_ms) || 0, 28 + }); 29 + } 37 30 }; 38 31 39 32 fetchStats();
+11 -1
src/integrations/supabase/types.ts
··· 21 21 generation_time_ms: number | null 22 22 id: string 23 23 image_url: string 24 + user_id: string | null 24 25 } 25 26 Insert: { 26 27 alt_text: string ··· 28 29 generation_time_ms?: number | null 29 30 id?: string 30 31 image_url: string 32 + user_id?: string | null 31 33 } 32 34 Update: { 33 35 alt_text?: string ··· 35 37 generation_time_ms?: number | null 36 38 id?: string 37 39 image_url?: string 40 + user_id?: string | null 38 41 } 39 42 Relationships: [] 40 43 } ··· 43 46 [_ in never]: never 44 47 } 45 48 Functions: { 46 - [_ in never]: never 49 + get_alt_text_stats: { 50 + Args: never 51 + Returns: { 52 + avg_response_ms: number 53 + happy_users: number 54 + total_images: number 55 + }[] 56 + } 47 57 } 48 58 Enums: { 49 59 [_ in never]: never
+154
src/pages/Auth.tsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { useNavigate } from "react-router-dom"; 3 + import { Eye, Loader2 } from "lucide-react"; 4 + import { Button } from "@/components/ui/button"; 5 + import { Input } from "@/components/ui/input"; 6 + import { Label } from "@/components/ui/label"; 7 + import { Card } from "@/components/ui/card"; 8 + import { useToast } from "@/hooks/use-toast"; 9 + import { supabase } from "@/integrations/supabase/client"; 10 + 11 + export default function Auth() { 12 + const [email, setEmail] = useState(""); 13 + const [password, setPassword] = useState(""); 14 + const [isLogin, setIsLogin] = useState(true); 15 + const [isLoading, setIsLoading] = useState(false); 16 + const navigate = useNavigate(); 17 + const { toast } = useToast(); 18 + 19 + useEffect(() => { 20 + // Check if user is already logged in 21 + supabase.auth.getSession().then(({ data: { session } }) => { 22 + if (session) { 23 + navigate("/"); 24 + } 25 + }); 26 + 27 + const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => { 28 + if (session) { 29 + navigate("/"); 30 + } 31 + }); 32 + 33 + return () => subscription.unsubscribe(); 34 + }, [navigate]); 35 + 36 + const handleSubmit = async (e: React.FormEvent) => { 37 + e.preventDefault(); 38 + setIsLoading(true); 39 + 40 + try { 41 + if (isLogin) { 42 + const { error } = await supabase.auth.signInWithPassword({ 43 + email, 44 + password, 45 + }); 46 + 47 + if (error) throw error; 48 + 49 + toast({ 50 + title: "Welcome back!", 51 + description: "You've successfully signed in.", 52 + }); 53 + } else { 54 + const { error } = await supabase.auth.signUp({ 55 + email, 56 + password, 57 + options: { 58 + emailRedirectTo: `${window.location.origin}/`, 59 + }, 60 + }); 61 + 62 + if (error) throw error; 63 + 64 + toast({ 65 + title: "Account created!", 66 + description: "You've successfully signed up.", 67 + }); 68 + } 69 + } catch (error: any) { 70 + toast({ 71 + title: "Error", 72 + description: error.message || "An error occurred during authentication", 73 + variant: "destructive", 74 + }); 75 + } finally { 76 + setIsLoading(false); 77 + } 78 + }; 79 + 80 + return ( 81 + <div className="min-h-screen bg-background flex items-center justify-center px-4"> 82 + <div className="w-full max-w-md"> 83 + <div className="text-center mb-8"> 84 + <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/20 mb-4"> 85 + <Eye className="w-8 h-8 text-primary" /> 86 + </div> 87 + <h1 className="text-3xl font-bold text-foreground mb-2">ALTly</h1> 88 + <p className="text-muted-foreground"> 89 + {isLogin ? "Sign in to continue" : "Create your account"} 90 + </p> 91 + </div> 92 + 93 + <Card className="p-6"> 94 + <form onSubmit={handleSubmit} className="space-y-4"> 95 + <div className="space-y-2"> 96 + <Label htmlFor="email">Email</Label> 97 + <Input 98 + id="email" 99 + type="email" 100 + placeholder="you@example.com" 101 + value={email} 102 + onChange={(e) => setEmail(e.target.value)} 103 + required 104 + disabled={isLoading} 105 + /> 106 + </div> 107 + 108 + <div className="space-y-2"> 109 + <Label htmlFor="password">Password</Label> 110 + <Input 111 + id="password" 112 + type="password" 113 + placeholder="••••••••" 114 + value={password} 115 + onChange={(e) => setPassword(e.target.value)} 116 + required 117 + disabled={isLoading} 118 + minLength={6} 119 + /> 120 + </div> 121 + 122 + <Button 123 + type="submit" 124 + className="w-full" 125 + disabled={isLoading} 126 + > 127 + {isLoading ? ( 128 + <> 129 + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 130 + {isLogin ? "Signing in..." : "Creating account..."} 131 + </> 132 + ) : ( 133 + <>{isLogin ? "Sign In" : "Sign Up"}</> 134 + )} 135 + </Button> 136 + 137 + <div className="text-center text-sm"> 138 + <button 139 + type="button" 140 + onClick={() => setIsLogin(!isLogin)} 141 + className="text-primary hover:underline" 142 + disabled={isLoading} 143 + > 144 + {isLogin 145 + ? "Don't have an account? Sign up" 146 + : "Already have an account? Sign in"} 147 + </button> 148 + </div> 149 + </form> 150 + </Card> 151 + </div> 152 + </div> 153 + ); 154 + }
+52 -2
src/pages/Index.tsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { useNavigate } from "react-router-dom"; 1 3 import { Header } from "@/components/Header"; 2 4 import { Stats } from "@/components/Stats"; 3 5 import { ImageUploader } from "@/components/ImageUploader"; 4 - import { Eye } from "lucide-react"; 6 + import { Eye, LogOut } from "lucide-react"; 7 + import { Button } from "@/components/ui/button"; 8 + import { supabase } from "@/integrations/supabase/client"; 9 + import { User } from "@supabase/supabase-js"; 5 10 6 11 const Index = () => { 12 + const [user, setUser] = useState<User | null>(null); 13 + const navigate = useNavigate(); 14 + 15 + useEffect(() => { 16 + // Check current session 17 + supabase.auth.getSession().then(({ data: { session } }) => { 18 + if (session?.user) { 19 + setUser(session.user); 20 + } else { 21 + navigate("/auth"); 22 + } 23 + }); 24 + 25 + // Listen for auth changes 26 + const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => { 27 + if (session?.user) { 28 + setUser(session.user); 29 + } else { 30 + navigate("/auth"); 31 + } 32 + }); 33 + 34 + return () => subscription.unsubscribe(); 35 + }, [navigate]); 36 + 37 + const handleSignOut = async () => { 38 + await supabase.auth.signOut(); 39 + }; 40 + 41 + if (!user) { 42 + return null; // Will redirect to /auth 43 + } 44 + 7 45 return ( 8 46 <div className="min-h-screen bg-background"> 9 47 <Header /> ··· 20 58 <p className="text-lg text-muted-foreground max-w-2xl mx-auto"> 21 59 A free-for-life ALT text generator powered by Claude AI with a free public API. 22 60 </p> 61 + <div className="mt-4 flex items-center justify-center gap-2 text-sm text-muted-foreground"> 62 + <span>Signed in as {user.email}</span> 63 + <Button 64 + variant="ghost" 65 + size="sm" 66 + onClick={handleSignOut} 67 + className="h-8" 68 + > 69 + <LogOut className="w-4 h-4 mr-1" /> 70 + Sign Out 71 + </Button> 72 + </div> 23 73 </div> 24 74 25 75 {/* Stats Section */} ··· 27 77 28 78 {/* Upload Section */} 29 79 <div className="max-w-3xl mx-auto mb-12"> 30 - <ImageUploader /> 80 + <ImageUploader user={user} /> 31 81 </div> 32 82 33 83 {/* Footer */}
+1 -1
supabase/config.toml
··· 1 1 project_id = "fcjluprfltmmrclfzxmr" 2 2 3 3 [functions.generate-alt-text] 4 - verify_jwt = false 4 + verify_jwt = true
+87 -12
supabase/functions/generate-alt-text/index.ts
··· 13 13 } 14 14 15 15 try { 16 + // Get user from JWT token 17 + const authHeader = req.headers.get('Authorization'); 18 + if (!authHeader) { 19 + return new Response( 20 + JSON.stringify({ error: 'Authentication required' }), 21 + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 22 + ); 23 + } 24 + 25 + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; 26 + const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; 27 + const supabase = createClient(supabaseUrl, supabaseKey); 28 + 29 + const token = authHeader.replace('Bearer ', ''); 30 + const { data: { user }, error: authError } = await supabase.auth.getUser(token); 31 + 32 + if (authError || !user) { 33 + console.error('Authentication error:', authError); 34 + return new Response( 35 + JSON.stringify({ error: 'Invalid authentication' }), 36 + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 37 + ); 38 + } 39 + 16 40 const { imageUrl, generationTimeStart } = await req.json(); 17 - console.log('Generating alt text for image:', imageUrl); 41 + console.log('Generating alt text for user:', user.id); 18 42 19 43 if (!imageUrl) { 20 44 return new Response( ··· 23 47 ); 24 48 } 25 49 50 + // Validate image URL - only allow trusted CDN 51 + let url: URL; 52 + try { 53 + url = new URL(imageUrl); 54 + } catch { 55 + return new Response( 56 + JSON.stringify({ error: 'Invalid image URL format' }), 57 + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 58 + ); 59 + } 60 + 61 + if (url.hostname !== 'cdn.madebydanny.uk') { 62 + console.error('Invalid domain:', url.hostname); 63 + return new Response( 64 + JSON.stringify({ error: 'Only images from cdn.madebydanny.uk are allowed' }), 65 + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 66 + ); 67 + } 68 + 69 + if (url.protocol !== 'https:') { 70 + return new Response( 71 + JSON.stringify({ error: 'Only HTTPS URLs are allowed' }), 72 + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 73 + ); 74 + } 75 + 76 + // Check rate limit: max 10 requests per hour per user 77 + const oneHourAgo = new Date(Date.now() - 3600000).toISOString(); 78 + const { data: recentGenerations, error: rateLimitError } = await supabase 79 + .from('alt_text_generations') 80 + .select('id') 81 + .eq('user_id', user.id) 82 + .gte('created_at', oneHourAgo); 83 + 84 + if (rateLimitError) { 85 + console.error('Rate limit check error:', rateLimitError); 86 + return new Response( 87 + JSON.stringify({ error: 'Unable to process request' }), 88 + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 89 + ); 90 + } 91 + 92 + if (recentGenerations && recentGenerations.length >= 10) { 93 + return new Response( 94 + JSON.stringify({ error: 'Rate limit exceeded. Maximum 10 requests per hour.' }), 95 + { status: 429, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 96 + ); 97 + } 98 + 26 99 const anthropicApiKey = Deno.env.get('ANTHROPIC_API_KEY'); 27 100 if (!anthropicApiKey) { 28 101 console.error('ANTHROPIC_API_KEY is not set'); ··· 37 110 if (!imageResponse.ok) { 38 111 console.error('Failed to fetch image:', imageResponse.status); 39 112 return new Response( 40 - JSON.stringify({ error: 'Failed to fetch image' }), 113 + JSON.stringify({ error: 'Unable to process image' }), 41 114 { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 42 115 ); 43 116 } ··· 83 156 const errorData = await response.text(); 84 157 console.error('Claude API error:', response.status, errorData); 85 158 return new Response( 86 - JSON.stringify({ error: 'Failed to generate alt text', details: errorData }), 87 - { status: response.status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 159 + JSON.stringify({ error: 'Unable to generate alt text. Please try again.' }), 160 + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 88 161 ); 89 162 } 90 163 91 164 const data = await response.json(); 92 165 const altText = data.content[0].text; 93 166 const generationTime = Date.now() - generationTimeStart; 94 - console.log('Generated alt text:', altText, 'Time:', generationTime, 'ms'); 95 - 96 - // Save to database for stats 97 - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; 98 - const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; 99 - const supabase = createClient(supabaseUrl, supabaseKey); 167 + console.log('Generated alt text for user:', user.id, 'Time:', generationTime, 'ms'); 100 168 101 - await supabase.from('alt_text_generations').insert({ 169 + // Save to database for stats with user_id 170 + const { error: insertError } = await supabase.from('alt_text_generations').insert({ 102 171 image_url: imageUrl, 103 172 alt_text: altText, 104 173 generation_time_ms: generationTime, 174 + user_id: user.id, 105 175 }); 106 176 177 + if (insertError) { 178 + console.error('Database insert error:', insertError); 179 + // Still return success to user since alt text was generated 180 + } 181 + 107 182 return new Response( 108 183 JSON.stringify({ altText }), 109 184 { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ··· 111 186 } catch (error) { 112 187 console.error('Error in generate-alt-text function:', error); 113 188 return new Response( 114 - JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' }), 189 + JSON.stringify({ error: 'An unexpected error occurred. Please try again.' }), 115 190 { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 116 191 ); 117 192 }
+47
supabase/migrations/20251108134200_77f72ae4-0bbd-4726-90cf-e34694a1c88a.sql
··· 1 + -- Add user_id column to alt_text_generations 2 + ALTER TABLE public.alt_text_generations 3 + ADD COLUMN user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE; 4 + 5 + -- Create index for better performance on user queries 6 + CREATE INDEX idx_alt_text_generations_user_id ON public.alt_text_generations(user_id); 7 + 8 + -- Drop existing insecure policies 9 + DROP POLICY IF EXISTS "Anyone can insert generations" ON public.alt_text_generations; 10 + DROP POLICY IF EXISTS "Anyone can view generations" ON public.alt_text_generations; 11 + 12 + -- Create secure policies: users can only see and manage their own generations 13 + CREATE POLICY "Users can view their own generations" 14 + ON public.alt_text_generations 15 + FOR SELECT 16 + TO authenticated 17 + USING (auth.uid() = user_id); 18 + 19 + CREATE POLICY "Users can insert their own generations" 20 + ON public.alt_text_generations 21 + FOR INSERT 22 + TO authenticated 23 + WITH CHECK (auth.uid() = user_id); 24 + 25 + -- Create a public function to get aggregate stats (no user data exposed) 26 + CREATE OR REPLACE FUNCTION public.get_alt_text_stats() 27 + RETURNS TABLE ( 28 + total_images bigint, 29 + happy_users bigint, 30 + avg_response_ms numeric 31 + ) 32 + LANGUAGE sql 33 + STABLE 34 + SECURITY DEFINER 35 + SET search_path = public 36 + AS $$ 37 + SELECT 38 + COUNT(*) as total_images, 39 + GREATEST(1, COUNT(DISTINCT user_id) / 6) as happy_users, 40 + COALESCE(ROUND(AVG(generation_time_ms)), 0) as avg_response_ms 41 + FROM public.alt_text_generations 42 + WHERE generation_time_ms IS NOT NULL; 43 + $$; 44 + 45 + -- Grant execute permission to authenticated users 46 + GRANT EXECUTE ON FUNCTION public.get_alt_text_stats() TO authenticated; 47 + GRANT EXECUTE ON FUNCTION public.get_alt_text_stats() TO anon;