A very performant and light (2mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres/SQLite.

I LOVE WOMEN

-1
.gitignore
··· 2 2 **/node_modules 3 3 node_modules 4 4 .env 5 - .sqlx
+2
.preludeignore
··· 1 + .sqlx 2 + .env
+25 -25
Cargo.lock
··· 3 3 version = 4 4 4 5 5 [[package]] 6 - name = "SimpleLink" 7 - version = "0.1.0" 8 - dependencies = [ 9 - "actix-cors", 10 - "actix-web", 11 - "anyhow", 12 - "argon2", 13 - "base62", 14 - "chrono", 15 - "clap", 16 - "dotenv", 17 - "jsonwebtoken", 18 - "lazy_static", 19 - "regex", 20 - "serde", 21 - "serde_json", 22 - "sqlx", 23 - "thiserror 1.0.69", 24 - "tokio", 25 - "tracing", 26 - "tracing-subscriber", 27 - "uuid", 28 - ] 29 - 30 - [[package]] 31 6 name = "actix-codec" 32 7 version = "0.5.2" 33 8 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2037 2012 "num-traits", 2038 2013 "thiserror 2.0.11", 2039 2014 "time", 2015 + ] 2016 + 2017 + [[package]] 2018 + name = "simplelink" 2019 + version = "0.1.0" 2020 + dependencies = [ 2021 + "actix-cors", 2022 + "actix-web", 2023 + "anyhow", 2024 + "argon2", 2025 + "base62", 2026 + "chrono", 2027 + "clap", 2028 + "dotenv", 2029 + "jsonwebtoken", 2030 + "lazy_static", 2031 + "regex", 2032 + "serde", 2033 + "serde_json", 2034 + "sqlx", 2035 + "thiserror 1.0.69", 2036 + "tokio", 2037 + "tracing", 2038 + "tracing-subscriber", 2039 + "uuid", 2040 2040 ] 2041 2041 2042 2042 [[package]]
+2 -2
Cargo.toml
··· 1 1 [package] 2 - name = "SimpleLink" 2 + name = "simplelink" 3 3 version = "0.1.0" 4 4 edition = "2021" 5 5 6 6 [lib] 7 - name = "simple_link" 7 + name = "simplelink" 8 8 path = "src/lib.rs" 9 9 10 10 [dependencies]
+45
Dockerfile
··· 1 + # Build stage 2 + FROM rust:latest as builder 3 + 4 + # Install PostgreSQL client libraries and SSL dependencies 5 + RUN apt-get update && \ 6 + apt-get install -y pkg-config libssl-dev libpq-dev && \ 7 + rm -rf /var/lib/apt/lists/* 8 + 9 + WORKDIR /usr/src/app 10 + 11 + # Copy manifests first (better layer caching) 12 + COPY Cargo.toml Cargo.lock ./ 13 + 14 + # Copy source code and SQLx prepared queries 15 + COPY src/ src/ 16 + COPY migrations/ migrations/ 17 + COPY .sqlx/ .sqlx/ 18 + 19 + # Build your application 20 + RUN cargo build --release 21 + 22 + # Runtime stage 23 + FROM debian:bookworm-slim 24 + 25 + # Install runtime dependencies 26 + RUN apt-get update && \ 27 + apt-get install -y libpq5 ca-certificates openssl libssl3 && \ 28 + rm -rf /var/lib/apt/lists/* 29 + 30 + WORKDIR /app 31 + 32 + # Copy the binary from builder 33 + COPY --from=builder /usr/src/app/target/release/simplelink /app/simplelink 34 + # Copy migrations folder for SQLx 35 + COPY --from=builder /usr/src/app/migrations /app/migrations 36 + 37 + # Expose the port (this is just documentation) 38 + EXPOSE 8080 39 + 40 + # Set default network configuration 41 + ENV SERVER_HOST=0.0.0.0 42 + ENV SERVER_PORT=8080 43 + 44 + # Run the binary 45 + CMD ["./simplelink"]
+29 -24
frontend/src/App.tsx
··· 1 1 import { ThemeProvider } from "@/components/theme-provider" 2 - import { Button } from './components/ui/button' 3 2 import { LinkForm } from './components/LinkForm' 4 3 import { LinkList } from './components/LinkList' 5 4 import { AuthForms } from './components/AuthForms' 6 5 import { AuthProvider, useAuth } from './context/AuthContext' 6 + import { Button } from "@/components/ui/button" 7 + import { Toaster } from './components/ui/toaster' 8 + import { ModeToggle } from './components/mode-toggle' 7 9 import { useState } from 'react' 8 - import { Toaster } from './components/ui/toaster' 9 10 10 11 function AppContent() { 11 12 const { user, logout } = useAuth() 12 13 const [refreshCounter, setRefreshCounter] = useState(0) 13 14 14 15 const handleLinkCreated = () => { 15 - // Increment refresh counter to trigger list refresh 16 16 setRefreshCounter(prev => prev + 1) 17 17 } 18 18 19 19 return ( 20 - <div className="min-h-screen flex flex-col"> 21 - <div className="container max-w-6xl mx-auto py-8 flex-1 flex flex-col"> 22 - <div className="space-y-8 flex-1 flex flex-col justify-center"> 23 - <div className="flex items-center justify-between"> 24 - <h1 className="text-3xl font-bold">SimpleLink</h1> 20 + <div className="min-h-screen bg-background flex flex-col"> 21 + <header className="border-b"> 22 + <div className="container max-w-6xl mx-auto flex h-16 items-center justify-between px-4"> 23 + <h1 className="text-2xl font-bold">SimpleLink</h1> 24 + <div className="flex items-center gap-4"> 25 25 {user ? ( 26 - <div className="flex items-center gap-4"> 27 - <p className="text-sm text-muted-foreground">Welcome, {user.email}</p> 28 - <Button variant="outline" onClick={logout}> 26 + <> 27 + <span className="text-sm text-muted-foreground">Welcome, {user.email}</span> 28 + <Button variant="outline" size="sm" onClick={logout}> 29 29 Logout 30 30 </Button> 31 - </div> 31 + </> 32 32 ) : ( 33 - <div className="flex items-center gap-4"> 34 - <p className="text-sm text-muted-foreground">A link shortening and tracking service</p> 35 - </div> 33 + <span className="text-sm text-muted-foreground">A link shortening and tracking service</span> 36 34 )} 35 + <ModeToggle /> 37 36 </div> 37 + </div> 38 + </header> 38 39 39 - {user ? ( 40 - <> 41 - <LinkForm onSuccess={handleLinkCreated} /> 42 - <LinkList refresh={refreshCounter} /> 43 - </> 44 - ) : ( 45 - <AuthForms /> 46 - )} 40 + <main className="flex-1 flex flex-col"> 41 + <div className="container max-w-6xl mx-auto px-4 py-8 flex-1 flex flex-col"> 42 + <div className="space-y-8 flex-1 flex flex-col justify-center"> 43 + {user ? ( 44 + <> 45 + <LinkForm onSuccess={handleLinkCreated} /> 46 + <LinkList refresh={refreshCounter} /> 47 + </> 48 + ) : ( 49 + <AuthForms /> 50 + )} 51 + </div> 47 52 </div> 48 - </div> 53 + </main> 49 54 </div> 50 55 ) 51 56 }
+1 -1
frontend/src/components/AuthForms.tsx
··· 56 56 57 57 return ( 58 58 <Card className="w-full max-w-md mx-auto p-6"> 59 - <Tabs value={activeTab} onValueChange={(value: 'login' | 'register') => setActiveTab(value)}> 59 + <Tabs value={activeTab} onValueChange={(value: string) => setActiveTab(value as 'login' | 'register')}> 60 60 <TabsList className="grid w-full grid-cols-2"> 61 61 <TabsTrigger value="login">Login</TabsTrigger> 62 62 <TabsTrigger value="register">Register</TabsTrigger>
+50 -43
frontend/src/components/LinkForm.tsx
··· 2 2 import { useForm } from 'react-hook-form' 3 3 import { zodResolver } from '@hookform/resolvers/zod' 4 4 import * as z from 'zod' 5 - import { CreateLinkRequest, Link } from '../types/api' 5 + import { CreateLinkRequest } from '../types/api' 6 6 import { createShortLink } from '../api/client' 7 7 import { Button } from "@/components/ui/button" 8 + import { Input } from "@/components/ui/input" 9 + import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 10 + import { LinkIcon } from "lucide-react" 8 11 import { 9 12 Form, 10 13 FormControl, 11 14 FormField, 12 - FormItem, 13 15 FormLabel, 14 16 FormMessage, 15 17 } from "@/components/ui/form" 16 - import { Input } from "@/components/ui/input" 17 18 import { useToast } from "@/hooks/use-toast" 18 19 19 20 const formSchema = z.object({ ··· 29 30 }) 30 31 31 32 interface LinkFormProps { 32 - onSuccess: (link: Link) => void 33 + onSuccess: () => void; 33 34 } 34 35 35 36 export function LinkForm({ onSuccess }: LinkFormProps) { ··· 47 48 const onSubmit = async (values: z.infer<typeof formSchema>) => { 48 49 try { 49 50 setLoading(true) 50 - const link = await createShortLink(values as CreateLinkRequest) 51 + await createShortLink(values as CreateLinkRequest) 51 52 form.reset() 52 - onSuccess(link) 53 + onSuccess() // Call the onSuccess callback to trigger refresh 53 54 toast({ 54 55 description: "Short link created successfully", 55 56 }) ··· 65 66 } 66 67 67 68 return ( 68 - <div className="max-w-[500px] mx-auto"> 69 - <Form {...form}> 70 - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> 71 - <FormField 72 - control={form.control} 73 - name="url" 74 - render={({ field }) => ( 75 - <FormItem> 76 - <FormLabel>URL</FormLabel> 77 - <FormControl> 78 - <Input placeholder="https://example.com" {...field} /> 79 - </FormControl> 80 - <FormMessage /> 81 - </FormItem> 82 - )} 83 - /> 69 + <Card className="mb-8"> 70 + <CardHeader> 71 + <CardTitle>Create Short Link</CardTitle> 72 + <CardDescription>Enter a URL to generate a shortened link</CardDescription> 73 + </CardHeader> 74 + <CardContent> 75 + <Form {...form}> 76 + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4 md:flex-row md:items-end"> 77 + <FormField 78 + control={form.control} 79 + name="url" 80 + render={({ field }) => ( 81 + <div className="flex-1 space-y-2"> 82 + <FormLabel>URL</FormLabel> 83 + <FormControl> 84 + <div className="relative"> 85 + <LinkIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> 86 + <Input placeholder="https://example.com" className="pl-9" {...field} /> 87 + </div> 88 + </FormControl> 89 + <FormMessage /> 90 + </div> 91 + )} 92 + /> 84 93 85 - <FormField 86 - control={form.control} 87 - name="custom_code" 88 - render={({ field }) => ( 89 - <FormItem> 90 - <FormLabel>Custom Code (optional)</FormLabel> 91 - <FormControl> 92 - <Input placeholder="example" {...field} /> 93 - </FormControl> 94 - <FormMessage /> 95 - </FormItem> 96 - )} 97 - /> 94 + <FormField 95 + control={form.control} 96 + name="custom_code" 97 + render={({ field }) => ( 98 + <div className="w-full md:w-1/4 space-y-2"> 99 + <FormLabel>Custom Code <span className="text-muted-foreground">(optional)</span></FormLabel> 100 + <FormControl> 101 + <Input placeholder="custom-code" {...field} /> 102 + </FormControl> 103 + <FormMessage /> 104 + </div> 105 + )} 106 + /> 98 107 99 - <div className="flex justify-end"> 100 - <Button type="submit" disabled={loading}> 108 + <Button type="submit" disabled={loading} className="md:w-auto"> 101 109 {loading ? "Creating..." : "Create Short Link"} 102 110 </Button> 103 - </div> 104 - </form> 105 - </Form> 106 - </div> 111 + </form> 112 + </Form> 113 + </CardContent> 114 + </Card> 107 115 ) 108 - } 109 - 116 + }
+74 -51
frontend/src/components/LinkList.tsx
··· 1 1 import { useEffect, useState } from 'react' 2 2 import { Link } from '../types/api' 3 3 import { getAllLinks, deleteLink } from '../api/client' 4 + import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 4 5 import { 5 6 Table, 6 7 TableBody, ··· 9 10 TableHeader, 10 11 TableRow, 11 12 } from "@/components/ui/table" 13 + import { Button } from "@/components/ui/button" 14 + import { useToast } from "@/hooks/use-toast" 15 + import { Copy, Trash2 } from "lucide-react" 12 16 import { 13 17 Dialog, 14 18 DialogContent, ··· 17 21 DialogDescription, 18 22 DialogFooter, 19 23 } from "@/components/ui/dialog" 20 - import { Button } from "@/components/ui/button" 21 - import { useToast } from "@/hooks/use-toast" 22 24 23 25 interface LinkListProps { 24 - refresh: number 26 + refresh?: number; 25 27 } 26 28 27 - export function LinkList({ refresh }: LinkListProps) { 29 + export function LinkList({ refresh = 0 }: LinkListProps) { 28 30 const [links, setLinks] = useState<Link[]>([]) 29 31 const [loading, setLoading] = useState(true) 30 32 const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; linkId: number | null }>({ ··· 51 53 52 54 useEffect(() => { 53 55 fetchLinks() 54 - }, [refresh]) 56 + }, [refresh]) // Re-fetch when refresh counter changes 55 57 56 58 const handleDelete = async () => { 57 59 if (!deleteModal.linkId) return ··· 61 63 await fetchLinks() 62 64 setDeleteModal({ isOpen: false, linkId: null }) 63 65 toast({ 64 - title: "Link deleted", 65 - description: "The link has been successfully deleted.", 66 + description: "Link deleted successfully", 66 67 }) 67 68 } catch (err) { 68 69 toast({ ··· 73 74 } 74 75 } 75 76 77 + const handleCopy = (shortCode: string) => { 78 + navigator.clipboard.writeText(`http://localhost:8080/${shortCode}`) 79 + toast({ 80 + description: "Link copied to clipboard", 81 + }) 82 + } 83 + 76 84 if (loading && !links.length) { 77 85 return <div className="text-center py-4">Loading...</div> 78 86 } 79 87 80 88 return ( 81 - <div className="space-y-4"> 89 + <> 82 90 <Dialog open={deleteModal.isOpen} onOpenChange={(open) => setDeleteModal({ isOpen: open, linkId: null })}> 83 91 <DialogContent> 84 92 <DialogHeader> ··· 98 106 </DialogContent> 99 107 </Dialog> 100 108 101 - <Table> 102 - <TableHeader> 103 - <TableRow> 104 - <TableHead>Short Code</TableHead> 105 - <TableHead>Original URL</TableHead> 106 - <TableHead>Clicks</TableHead> 107 - <TableHead>Created</TableHead> 108 - <TableHead>Actions</TableHead> 109 - </TableRow> 110 - </TableHeader> 111 - <TableBody> 112 - {links.map((link) => ( 113 - <TableRow key={link.id}> 114 - <TableCell>{link.short_code}</TableCell> 115 - <TableCell className="max-w-[300px] truncate">{link.original_url}</TableCell> 116 - <TableCell>{link.clicks}</TableCell> 117 - <TableCell>{new Date(link.created_at).toLocaleDateString()}</TableCell> 118 - <TableCell> 119 - <div className="flex gap-2"> 120 - <Button 121 - variant="secondary" 122 - size="sm" 123 - onClick={() => { 124 - navigator.clipboard.writeText(`http://localhost:8080/${link.short_code}`) 125 - toast({ description: "Link copied to clipboard" }) 126 - }} 127 - > 128 - Copy 129 - </Button> 130 - <Button 131 - variant="destructive" 132 - size="sm" 133 - onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })} 134 - > 135 - Delete 136 - </Button> 137 - </div> 138 - </TableCell> 139 - </TableRow> 140 - ))} 141 - </TableBody> 142 - </Table> 143 - </div> 109 + <Card> 110 + <CardHeader> 111 + <CardTitle>Your Links</CardTitle> 112 + <CardDescription>Manage and track your shortened links</CardDescription> 113 + </CardHeader> 114 + <CardContent> 115 + <div className="rounded-md border"> 116 + <Table> 117 + <TableHeader> 118 + <TableRow> 119 + <TableHead>Short Code</TableHead> 120 + <TableHead className="hidden md:table-cell">Original URL</TableHead> 121 + <TableHead>Clicks</TableHead> 122 + <TableHead className="hidden md:table-cell">Created</TableHead> 123 + <TableHead>Actions</TableHead> 124 + </TableRow> 125 + </TableHeader> 126 + <TableBody> 127 + {links.map((link) => ( 128 + <TableRow key={link.id}> 129 + <TableCell className="font-medium">{link.short_code}</TableCell> 130 + <TableCell className="hidden md:table-cell max-w-[300px] truncate"> 131 + {link.original_url} 132 + </TableCell> 133 + <TableCell>{link.clicks}</TableCell> 134 + <TableCell className="hidden md:table-cell"> 135 + {new Date(link.created_at).toLocaleDateString()} 136 + </TableCell> 137 + <TableCell> 138 + <div className="flex gap-2"> 139 + <Button 140 + variant="ghost" 141 + size="icon" 142 + className="h-8 w-8" 143 + onClick={() => handleCopy(link.short_code)} 144 + > 145 + <Copy className="h-4 w-4" /> 146 + <span className="sr-only">Copy link</span> 147 + </Button> 148 + <Button 149 + variant="ghost" 150 + size="icon" 151 + className="h-8 w-8 text-destructive" 152 + onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })} 153 + > 154 + <Trash2 className="h-4 w-4" /> 155 + <span className="sr-only">Delete link</span> 156 + </Button> 157 + </div> 158 + </TableCell> 159 + </TableRow> 160 + ))} 161 + </TableBody> 162 + </Table> 163 + </div> 164 + </CardContent> 165 + </Card> 166 + </> 144 167 ) 145 168 }
+4 -2
src/main.rs
··· 28 28 29 29 let state = AppState { db: pool }; 30 30 31 - info!("Starting server at http://127.0.0.1:8080"); 31 + let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 32 + let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "8080".to_string()); 33 + info!("Starting server at http://{}:{}", host, port); 32 34 33 35 // Start HTTP server 34 36 HttpServer::new(move || { ··· 54 56 }) 55 57 .workers(2) 56 58 .backlog(10_000) 57 - .bind("127.0.0.1:8080")? 59 + .bind(format!("{}:{}", host, port))? 58 60 .run() 59 61 .await?; 60 62