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

Compare changes

Choose any two refs to compare.

+2 -2
.github/workflows/docker-image.yml
··· 29 30 - name: Install cosign 31 if: github.event_name != 'pull_request' 32 - uses: sigstore/cosign-installer@v3.7.0 33 with: 34 - cosign-release: "v2.4.1" 35 36 - name: Setup Docker buildx 37 uses: docker/setup-buildx-action@v3
··· 29 30 - name: Install cosign 31 if: github.event_name != 'pull_request' 32 + uses: sigstore/cosign-installer@v3.8.1 33 with: 34 + cosign-release: "v2.4.3" 35 36 - name: Setup Docker buildx 37 uses: docker/setup-buildx-action@v3
+9 -39
Cargo.lock
··· 1440 1441 [[package]] 1442 name = "nu-ansi-term" 1443 - version = "0.46.0" 1444 source = "registry+https://github.com/rust-lang/crates.io-index" 1445 - checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" 1446 dependencies = [ 1447 - "overload", 1448 - "winapi", 1449 ] 1450 1451 [[package]] ··· 1525 version = "1.20.2" 1526 source = "registry+https://github.com/rust-lang/crates.io-index" 1527 checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 1528 - 1529 - [[package]] 1530 - name = "overload" 1531 - version = "0.1.1" 1532 - source = "registry+https://github.com/rust-lang/crates.io-index" 1533 - checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 1534 1535 [[package]] 1536 name = "parking" ··· 1751 1752 [[package]] 1753 name = "ring" 1754 - version = "0.17.8" 1755 source = "registry+https://github.com/rust-lang/crates.io-index" 1756 - checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" 1757 dependencies = [ 1758 "cc", 1759 "cfg-if", 1760 "getrandom", 1761 "libc", 1762 - "spin", 1763 "untrusted", 1764 "windows-sys 0.52.0", 1765 ] ··· 2432 2433 [[package]] 2434 name = "tokio" 2435 - version = "1.43.0" 2436 source = "registry+https://github.com/rust-lang/crates.io-index" 2437 - checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" 2438 dependencies = [ 2439 "backtrace", 2440 "bytes", ··· 2529 2530 [[package]] 2531 name = "tracing-subscriber" 2532 - version = "0.3.19" 2533 source = "registry+https://github.com/rust-lang/crates.io-index" 2534 - checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 2535 dependencies = [ 2536 "nu-ansi-term", 2537 "sharded-slab", ··· 2739 ] 2740 2741 [[package]] 2742 - name = "winapi" 2743 - version = "0.3.9" 2744 - source = "registry+https://github.com/rust-lang/crates.io-index" 2745 - checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 2746 - dependencies = [ 2747 - "winapi-i686-pc-windows-gnu", 2748 - "winapi-x86_64-pc-windows-gnu", 2749 - ] 2750 - 2751 - [[package]] 2752 - name = "winapi-i686-pc-windows-gnu" 2753 - version = "0.4.0" 2754 - source = "registry+https://github.com/rust-lang/crates.io-index" 2755 - checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 2756 - 2757 - [[package]] 2758 name = "winapi-util" 2759 version = "0.1.9" 2760 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2762 dependencies = [ 2763 "windows-sys 0.59.0", 2764 ] 2765 - 2766 - [[package]] 2767 - name = "winapi-x86_64-pc-windows-gnu" 2768 - version = "0.4.0" 2769 - source = "registry+https://github.com/rust-lang/crates.io-index" 2770 - checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 2771 2772 [[package]] 2773 name = "windows-core"
··· 1440 1441 [[package]] 1442 name = "nu-ansi-term" 1443 + version = "0.50.1" 1444 source = "registry+https://github.com/rust-lang/crates.io-index" 1445 + checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" 1446 dependencies = [ 1447 + "windows-sys 0.52.0", 1448 ] 1449 1450 [[package]] ··· 1524 version = "1.20.2" 1525 source = "registry+https://github.com/rust-lang/crates.io-index" 1526 checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 1527 1528 [[package]] 1529 name = "parking" ··· 1744 1745 [[package]] 1746 name = "ring" 1747 + version = "0.17.13" 1748 source = "registry+https://github.com/rust-lang/crates.io-index" 1749 + checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee" 1750 dependencies = [ 1751 "cc", 1752 "cfg-if", 1753 "getrandom", 1754 "libc", 1755 "untrusted", 1756 "windows-sys 0.52.0", 1757 ] ··· 2424 2425 [[package]] 2426 name = "tokio" 2427 + version = "1.43.1" 2428 source = "registry+https://github.com/rust-lang/crates.io-index" 2429 + checksum = "492a604e2fd7f814268a378409e6c92b5525d747d10db9a229723f55a417958c" 2430 dependencies = [ 2431 "backtrace", 2432 "bytes", ··· 2521 2522 [[package]] 2523 name = "tracing-subscriber" 2524 + version = "0.3.20" 2525 source = "registry+https://github.com/rust-lang/crates.io-index" 2526 + checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 2527 dependencies = [ 2528 "nu-ansi-term", 2529 "sharded-slab", ··· 2731 ] 2732 2733 [[package]] 2734 name = "winapi-util" 2735 version = "0.1.9" 2736 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2738 dependencies = [ 2739 "windows-sys 0.59.0", 2740 ] 2741 2742 [[package]] 2743 name = "windows-core"
+1 -1
Cargo.toml
··· 13 actix-web = "4.4" 14 actix-files = "0.6" 15 actix-cors = "0.6" 16 - tokio = { version = "1.36", features = ["rt-multi-thread", "macros"] } 17 sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "chrono"] } 18 serde = { version = "1.0", features = ["derive"] } 19 serde_json = "1.0"
··· 13 actix-web = "4.4" 14 actix-files = "0.6" 15 actix-cors = "0.6" 16 + tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] } 17 sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "chrono"] } 18 serde = { version = "1.0", features = ["derive"] } 19 serde_json = "1.0"
+1 -1
Dockerfile
··· 4 WORKDIR /usr/src/frontend 5 6 # Copy frontend files 7 - COPY frontend/package*.json ./ 8 RUN bun install 9 10 COPY frontend/ ./
··· 4 WORKDIR /usr/src/frontend 5 6 # Copy frontend files 7 + COPY frontend/package.json ./ 8 RUN bun install 9 10 COPY frontend/ ./
+29 -13
README.md
··· 1 # SimpleLink 2 3 - A very performant and light (6mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres. 4 5 ![MainView](readme_img/mainview.jpg) 6 ··· 8 9 ## How to Run 10 11 - ### From Docker: 12 13 - ```Bash 14 docker run -p 8080:8080 \ 15 -e JWT_SECRET=change-me-in-production \ 16 -v simplelink_data:/data \ 17 - simplelink 18 ``` 19 20 - Find the admin-setup-token pasted into the terminal output, or in admin-setup-token.txt in the container's root. 21 22 - This is needed to register with the frontend. (TODO, register admin account with ENV) 23 24 - ### From Docker Compose: 25 26 - Edit the docker-compose.yml file. It comes included with a postgressql db for use 27 28 ## Build 29 ··· 31 32 First configure .env.example and save it to .env 33 34 - If DATABASE_URL is set, it will connect to a Postgres DB. If blank, it will use an sqlite db in /data 35 - 36 ```bash 37 git clone https://github.com/waveringana/simplelink && cd simplelink 38 ./build.sh 39 cargo run 40 ``` 41 42 - On an empty database, an admin-setup-token.txt is created as well as pasted into the terminal output. This is needed to make the admin account. 43 - 44 - Alternatively if you want a binary form 45 46 ```bash 47 ./build.sh --binary ··· 55 docker build -t simplelink . 56 docker run -p 8080:8080 \ 57 -e JWT_SECRET=change-me-in-production \ 58 -v simplelink_data:/data \ 59 simplelink 60 ``` ··· 62 ### From Docker Compose 63 64 Adjust the included docker-compose.yml to your liking; it includes a postgres config as well.
··· 1 # SimpleLink 2 3 + A very performant and light (2MB in memory) link shortener and tracker. Written in Rust and React and uses Postgres or SQLite. 4 5 ![MainView](readme_img/mainview.jpg) 6 ··· 8 9 ## How to Run 10 11 + ### From Docker 12 13 + ```bash 14 docker run -p 8080:8080 \ 15 -e JWT_SECRET=change-me-in-production \ 16 + -e SIMPLELINK_USER=admin@example.com \ 17 + -e SIMPLELINK_PASS=your-secure-password \ 18 -v simplelink_data:/data \ 19 + ghcr.io/waveringana/simplelink:v2.2 20 ``` 21 22 + ### Environment Variables 23 24 + - `JWT_SECRET`: Required. Used for JWT token generation 25 + - `SIMPLELINK_USER`: Optional. If set along with SIMPLELINK_PASS, creates an admin user on first run 26 + - `SIMPLELINK_PASS`: Optional. Admin user password 27 + - `DATABASE_URL`: Optional. Postgres connection string. If not set, uses SQLite 28 + - `INITIAL_LINKS`: Optional. Semicolon-separated list of initial links in format "url,code;url2,code2" 29 + - `SERVER_HOST`: Optional. Default: "127.0.0.1" 30 + - `SERVER_PORT`: Optional. Default: "8080" 31 32 + If `SIMPLELINK_USER` and `SIMPLELINK_PASS` are not passed, an admin-setup-token is pasted to the console and as a text file in the project root. 33 + 34 + ### From Docker Compose 35 36 + Edit the docker-compose.yml file. It comes included with a PostgreSQL db configuration. 37 38 ## Build 39 ··· 41 42 First configure .env.example and save it to .env 43 44 ```bash 45 git clone https://github.com/waveringana/simplelink && cd simplelink 46 ./build.sh 47 cargo run 48 ``` 49 50 + Alternatively for a binary build: 51 52 ```bash 53 ./build.sh --binary ··· 61 docker build -t simplelink . 62 docker run -p 8080:8080 \ 63 -e JWT_SECRET=change-me-in-production \ 64 + -e SIMPLELINK_USER=admin@example.com \ 65 + -e SIMPLELINK_PASS=your-secure-password \ 66 -v simplelink_data:/data \ 67 simplelink 68 ``` ··· 70 ### From Docker Compose 71 72 Adjust the included docker-compose.yml to your liking; it includes a postgres config as well. 73 + 74 + ## Features 75 + 76 + - Support for both PostgreSQL and SQLite databases 77 + - Initial links can be configured via environment variables 78 + - Admin user can be created on first run via environment variables 79 + - Link click tracking and statistics 80 + - Lightweight and performant
+1 -3
docker-compose.yml
··· 19 - shortener-network 20 21 app: 22 - build: 23 - context: . 24 - dockerfile: Dockerfile 25 container_name: shortener-app 26 ports: 27 - "8080:8080"
··· 19 - shortener-network 20 21 app: 22 + image: ghcr.io/waveringana/simplelink:v2.2 23 container_name: shortener-app 24 ports: 25 - "8080:8080"
+6
frontend/src/api/client.ts
··· 58 return response.data; 59 }; 60 61 export const deleteLink = async (id: number) => { 62 await api.delete(`/links/${id}`); 63 };
··· 58 return response.data; 59 }; 60 61 + export const editLink = async (id: number, data: Partial<CreateLinkRequest>) => { 62 + const response = await api.patch<Link>(`/links/${id}`, data); 63 + return response.data; 64 + }; 65 + 66 + 67 export const deleteLink = async (id: number) => { 68 await api.delete(`/links/${id}`); 69 };
+139
frontend/src/components/EditModal.tsx
···
··· 1 + // src/components/EditModal.tsx 2 + import { useState } from 'react'; 3 + import { useForm } from 'react-hook-form'; 4 + import { zodResolver } from '@hookform/resolvers/zod'; 5 + import * as z from 'zod'; 6 + import { Link } from '../types/api'; 7 + import { editLink } from '../api/client'; 8 + import { useToast } from '@/hooks/use-toast'; 9 + import { 10 + Dialog, 11 + DialogContent, 12 + DialogHeader, 13 + DialogTitle, 14 + DialogFooter, 15 + } from '@/components/ui/dialog'; 16 + import { Button } from '@/components/ui/button'; 17 + import { Input } from '@/components/ui/input'; 18 + import { 19 + Form, 20 + FormControl, 21 + FormField, 22 + FormItem, 23 + FormLabel, 24 + FormMessage, 25 + } from '@/components/ui/form'; 26 + 27 + const formSchema = z.object({ 28 + url: z 29 + .string() 30 + .min(1, 'URL is required') 31 + .url('Must be a valid URL') 32 + .refine((val) => val.startsWith('http://') || val.startsWith('https://'), { 33 + message: 'URL must start with http:// or https://', 34 + }), 35 + custom_code: z 36 + .string() 37 + .regex(/^[a-zA-Z0-9_-]{1,32}$/, { 38 + message: 39 + 'Custom code must be 1-32 characters and contain only letters, numbers, underscores, and hyphens', 40 + }) 41 + .optional(), 42 + }); 43 + 44 + interface EditModalProps { 45 + isOpen: boolean; 46 + onClose: () => void; 47 + link: Link; 48 + onSuccess: () => void; 49 + } 50 + 51 + export function EditModal({ isOpen, onClose, link, onSuccess }: EditModalProps) { 52 + const [loading, setLoading] = useState(false); 53 + const { toast } = useToast(); 54 + 55 + const form = useForm<z.infer<typeof formSchema>>({ 56 + resolver: zodResolver(formSchema), 57 + defaultValues: { 58 + url: link.original_url, 59 + custom_code: link.short_code, 60 + }, 61 + }); 62 + 63 + const onSubmit = async (values: z.infer<typeof formSchema>) => { 64 + try { 65 + setLoading(true); 66 + await editLink(link.id, values); 67 + toast({ 68 + description: 'Link updated successfully', 69 + }); 70 + onSuccess(); 71 + onClose(); 72 + } catch (err: unknown) { 73 + const error = err as { response?: { data?: { error?: string } } }; 74 + toast({ 75 + variant: 'destructive', 76 + title: 'Error', 77 + description: error.response?.data?.error || 'Failed to update link', 78 + }); 79 + } finally { 80 + setLoading(false); 81 + } 82 + }; 83 + 84 + return ( 85 + <Dialog open={isOpen} onOpenChange={onClose}> 86 + <DialogContent> 87 + <DialogHeader> 88 + <DialogTitle>Edit Link</DialogTitle> 89 + </DialogHeader> 90 + 91 + <Form {...form}> 92 + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> 93 + <FormField 94 + control={form.control} 95 + name="url" 96 + render={({ field }) => ( 97 + <FormItem> 98 + <FormLabel>Destination URL</FormLabel> 99 + <FormControl> 100 + <Input placeholder="https://example.com" {...field} /> 101 + </FormControl> 102 + <FormMessage /> 103 + </FormItem> 104 + )} 105 + /> 106 + 107 + <FormField 108 + control={form.control} 109 + name="custom_code" 110 + render={({ field }) => ( 111 + <FormItem> 112 + <FormLabel>Short Code</FormLabel> 113 + <FormControl> 114 + <Input placeholder="custom-code" {...field} /> 115 + </FormControl> 116 + <FormMessage /> 117 + </FormItem> 118 + )} 119 + /> 120 + 121 + <DialogFooter> 122 + <Button 123 + type="button" 124 + variant="outline" 125 + onClick={onClose} 126 + disabled={loading} 127 + > 128 + Cancel 129 + </Button> 130 + <Button type="submit" disabled={loading}> 131 + {loading ? 'Saving...' : 'Save Changes'} 132 + </Button> 133 + </DialogFooter> 134 + </form> 135 + </Form> 136 + </DialogContent> 137 + </Dialog> 138 + ); 139 + }
+44 -13
frontend/src/components/LinkList.tsx
··· 1 - import { useEffect, useState } from 'react' 2 import { Link } from '../types/api' 3 import { getAllLinks, deleteLink } from '../api/client' 4 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" ··· 12 } from "@/components/ui/table" 13 import { Button } from "@/components/ui/button" 14 import { useToast } from "@/hooks/use-toast" 15 - import { Copy, Trash2, BarChart2 } from "lucide-react" 16 import { 17 Dialog, 18 DialogContent, ··· 23 } from "@/components/ui/dialog" 24 25 import { StatisticsModal } from "./StatisticsModal" 26 27 interface LinkListProps { 28 refresh?: number; ··· 39 isOpen: false, 40 linkId: null, 41 }); 42 const { toast } = useToast() 43 44 - const fetchLinks = async () => { 45 try { 46 setLoading(true) 47 const data = await getAllLinks() 48 setLinks(data) 49 - } catch (err) { 50 toast({ 51 title: "Error", 52 - description: "Failed to load links", 53 variant: "destructive", 54 }) 55 } finally { 56 setLoading(false) 57 } 58 - } 59 60 useEffect(() => { 61 fetchLinks() 62 - }, [refresh]) // Re-fetch when refresh counter changes 63 64 const handleDelete = async () => { 65 if (!deleteModal.linkId) return ··· 71 toast({ 72 description: "Link deleted successfully", 73 }) 74 - } catch (err) { 75 toast({ 76 title: "Error", 77 - description: "Failed to delete link", 78 variant: "destructive", 79 }) 80 } ··· 85 const baseUrl = window.location.origin 86 navigator.clipboard.writeText(`${baseUrl}/${shortCode}`) 87 toast({ 88 - description: "Link copied to clipboard", 89 }) 90 } 91 ··· 121 </CardHeader> 122 <CardContent> 123 <div className="rounded-md border"> 124 <Table> 125 <TableHeader> 126 <TableRow> ··· 128 <TableHead className="hidden md:table-cell">Original URL</TableHead> 129 <TableHead>Clicks</TableHead> 130 <TableHead className="hidden md:table-cell">Created</TableHead> 131 - <TableHead>Actions</TableHead> 132 </TableRow> 133 </TableHeader> 134 <TableBody> ··· 142 <TableCell className="hidden md:table-cell"> 143 {new Date(link.created_at).toLocaleDateString()} 144 </TableCell> 145 - <TableCell> 146 - <div className="flex gap-2"> 147 <Button 148 variant="ghost" 149 size="icon" ··· 165 <Button 166 variant="ghost" 167 size="icon" 168 className="h-8 w-8 text-destructive" 169 onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })} 170 > ··· 185 onClose={() => setStatsModal({ isOpen: false, linkId: null })} 186 linkId={statsModal.linkId!} 187 /> 188 </> 189 ) 190 }
··· 1 + import { useCallback, useEffect, useState } from 'react' 2 import { Link } from '../types/api' 3 import { getAllLinks, deleteLink } from '../api/client' 4 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" ··· 12 } from "@/components/ui/table" 13 import { Button } from "@/components/ui/button" 14 import { useToast } from "@/hooks/use-toast" 15 + import { Copy, Trash2, BarChart2, Pencil } from "lucide-react" 16 import { 17 Dialog, 18 DialogContent, ··· 23 } from "@/components/ui/dialog" 24 25 import { StatisticsModal } from "./StatisticsModal" 26 + import { EditModal } from './EditModal' 27 28 interface LinkListProps { 29 refresh?: number; ··· 40 isOpen: false, 41 linkId: null, 42 }); 43 + const [editModal, setEditModal] = useState<{ isOpen: boolean; link: Link | null }>({ 44 + isOpen: false, 45 + link: null, 46 + }); 47 const { toast } = useToast() 48 49 + const fetchLinks = useCallback(async () => { 50 try { 51 setLoading(true) 52 const data = await getAllLinks() 53 setLinks(data) 54 + } catch (err: unknown) { 55 + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; 56 toast({ 57 title: "Error", 58 + description: `Failed to load links: ${errorMessage}`, 59 variant: "destructive", 60 }) 61 } finally { 62 setLoading(false) 63 } 64 + }, [toast, setLinks, setLoading]) 65 66 useEffect(() => { 67 fetchLinks() 68 + }, [fetchLinks, refresh]) // Re-fetch when refresh counter changes 69 70 const handleDelete = async () => { 71 if (!deleteModal.linkId) return ··· 77 toast({ 78 description: "Link deleted successfully", 79 }) 80 + } catch (err: unknown) { 81 + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; 82 toast({ 83 title: "Error", 84 + description: `Failed to delete link: ${errorMessage}`, 85 variant: "destructive", 86 }) 87 } ··· 92 const baseUrl = window.location.origin 93 navigator.clipboard.writeText(`${baseUrl}/${shortCode}`) 94 toast({ 95 + description: ( 96 + <> 97 + Link copied to clipboard 98 + <br /> 99 + You can add ?source=TextHere to the end of the link to track the source of clicks 100 + </> 101 + ), 102 }) 103 } 104 ··· 134 </CardHeader> 135 <CardContent> 136 <div className="rounded-md border"> 137 + 138 <Table> 139 <TableHeader> 140 <TableRow> ··· 142 <TableHead className="hidden md:table-cell">Original URL</TableHead> 143 <TableHead>Clicks</TableHead> 144 <TableHead className="hidden md:table-cell">Created</TableHead> 145 + <TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead> 146 </TableRow> 147 </TableHeader> 148 <TableBody> ··· 156 <TableCell className="hidden md:table-cell"> 157 {new Date(link.created_at).toLocaleDateString()} 158 </TableCell> 159 + <TableCell className="p-2 pr-4"> 160 + <div className="flex items-center gap-1"> 161 <Button 162 variant="ghost" 163 size="icon" ··· 179 <Button 180 variant="ghost" 181 size="icon" 182 + className="h-8 w-8" 183 + onClick={() => setEditModal({ isOpen: true, link })} 184 + > 185 + <Pencil className="h-4 w-4" /> 186 + <span className="sr-only">Edit Link</span> 187 + </Button> 188 + <Button 189 + variant="ghost" 190 + size="icon" 191 className="h-8 w-8 text-destructive" 192 onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })} 193 > ··· 208 onClose={() => setStatsModal({ isOpen: false, linkId: null })} 209 linkId={statsModal.linkId!} 210 /> 211 + {editModal.link && ( 212 + <EditModal 213 + isOpen={editModal.isOpen} 214 + onClose={() => setEditModal({ isOpen: false, link: null })} 215 + link={editModal.link} 216 + onSuccess={fetchLinks} 217 + /> 218 + )} 219 </> 220 ) 221 }
+161 -105
frontend/src/components/StatisticsModal.tsx
··· 1 import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 2 import { 3 - LineChart, 4 - Line, 5 - XAxis, 6 - YAxis, 7 - CartesianGrid, 8 - Tooltip, 9 - ResponsiveContainer, 10 } from "recharts"; 11 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 12 - import { toast } from "@/hooks/use-toast" 13 - import { useState, useEffect } from "react"; 14 15 - import { getLinkClickStats, getLinkSourceStats } from '../api/client'; 16 - import { ClickStats, SourceStats } from '../types/api'; 17 18 interface StatisticsModalProps { 19 - isOpen: boolean; 20 - onClose: () => void; 21 - linkId: number; 22 } 23 24 export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) { 25 - const [clicksOverTime, setClicksOverTime] = useState<ClickStats[]>([]); 26 - const [sourcesData, setSourcesData] = useState<SourceStats[]>([]); 27 - const [loading, setLoading] = useState(true); 28 29 - useEffect(() => { 30 - if (isOpen && linkId) { 31 - const fetchData = async () => { 32 - try { 33 - setLoading(true); 34 - const [clicksData, sourcesData] = await Promise.all([ 35 - getLinkClickStats(linkId), 36 - getLinkSourceStats(linkId), 37 - ]); 38 - setClicksOverTime(clicksData); 39 - setSourcesData(sourcesData); 40 - } catch (error: any) { 41 - console.error("Failed to fetch statistics:", error); 42 - toast({ 43 - variant: "destructive", 44 - title: "Error", 45 - description: error.response?.data || "Failed to load statistics", 46 - }); 47 - } finally { 48 - setLoading(false); 49 - } 50 - }; 51 - 52 - fetchData(); 53 - } 54 - }, [isOpen, linkId]); 55 56 - return ( 57 - <Dialog open={isOpen} onOpenChange={onClose}> 58 - <DialogContent className="max-w-3xl"> 59 - <DialogHeader> 60 - <DialogTitle>Link Statistics</DialogTitle> 61 - </DialogHeader> 62 63 - {loading ? ( 64 - <div className="flex items-center justify-center h-64">Loading...</div> 65 - ) : ( 66 - <div className="grid gap-4"> 67 - <Card> 68 - <CardHeader> 69 - <CardTitle>Clicks Over Time</CardTitle> 70 - </CardHeader> 71 - <CardContent> 72 - <div className="h-[300px]"> 73 - <ResponsiveContainer width="100%" height="100%"> 74 - <LineChart data={clicksOverTime}> 75 - <CartesianGrid strokeDasharray="3 3" /> 76 - <XAxis dataKey="date" /> 77 - <YAxis /> 78 - <Tooltip /> 79 - <Line 80 - type="monotone" 81 - dataKey="clicks" 82 - stroke="#8884d8" 83 - strokeWidth={2} 84 - /> 85 - </LineChart> 86 - </ResponsiveContainer> 87 - </div> 88 - </CardContent> 89 - </Card> 90 91 - <Card> 92 - <CardHeader> 93 - <CardTitle>Top Sources</CardTitle> 94 - </CardHeader> 95 - <CardContent> 96 - <ul className="space-y-2"> 97 - {sourcesData.map((source, index) => ( 98 - <li 99 - key={source.source} 100 - className="flex items-center justify-between py-2 border-b last:border-0" 101 - > 102 - <span className="text-sm"> 103 - <span className="font-medium text-muted-foreground mr-2"> 104 - {index + 1}. 105 - </span> 106 - {source.source} 107 - </span> 108 - <span className="text-sm font-medium"> 109 - {source.count} clicks 110 - </span> 111 - </li> 112 - ))} 113 - </ul> 114 - </CardContent> 115 - </Card> 116 - </div> 117 - )} 118 - </DialogContent> 119 - </Dialog> 120 ); 121 }
··· 1 import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 2 import { 3 + LineChart, 4 + Line, 5 + XAxis, 6 + YAxis, 7 + CartesianGrid, 8 + Tooltip, 9 + ResponsiveContainer, 10 } from "recharts"; 11 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 12 + import { toast } from "@/hooks/use-toast"; 13 + import { useState, useEffect, useMemo } from "react"; 14 15 + import { getLinkClickStats, getLinkSourceStats } from "../api/client"; 16 + import { ClickStats, SourceStats } from "../types/api"; 17 18 interface StatisticsModalProps { 19 + isOpen: boolean; 20 + onClose: () => void; 21 + linkId: number; 22 + } 23 + 24 + interface EnhancedClickStats extends ClickStats { 25 + sources?: { source: string; count: number }[]; 26 } 27 28 + const CustomTooltip = ({ 29 + active, 30 + payload, 31 + label, 32 + }: { 33 + active?: boolean; 34 + payload?: { value: number; payload: EnhancedClickStats }[]; 35 + label?: string; 36 + }) => { 37 + if (active && payload && payload.length > 0) { 38 + const data = payload[0].payload; 39 + return ( 40 + <div className="bg-background text-foreground p-4 rounded-lg shadow-lg border"> 41 + <p className="font-medium">{label}</p> 42 + <p className="text-sm">Clicks: {data.clicks}</p> 43 + {data.sources && data.sources.length > 0 && ( 44 + <div className="mt-2"> 45 + <p className="font-medium text-sm">Sources:</p> 46 + <ul className="text-sm"> 47 + {data.sources.map((source: { source: string; count: number }) => ( 48 + <li key={source.source}> 49 + {source.source}: {source.count} 50 + </li> 51 + ))} 52 + </ul> 53 + </div> 54 + )} 55 + </div> 56 + ); 57 + } 58 + return null; 59 + }; 60 + 61 export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) { 62 + const [clicksOverTime, setClicksOverTime] = useState<EnhancedClickStats[]>([]); 63 + const [sourcesData, setSourcesData] = useState<SourceStats[]>([]); 64 + const [loading, setLoading] = useState(true); 65 66 + useEffect(() => { 67 + if (isOpen && linkId) { 68 + const fetchData = async () => { 69 + try { 70 + setLoading(true); 71 + const [clicksData, sourcesData] = await Promise.all([ 72 + getLinkClickStats(linkId), 73 + getLinkSourceStats(linkId), 74 + ]); 75 76 + // Enhance clicks data with source information 77 + const enhancedClicksData = clicksData.map((clickData) => ({ 78 + ...clickData, 79 + sources: sourcesData.filter((source) => source.date === clickData.date), 80 + })); 81 82 + setClicksOverTime(enhancedClicksData); 83 + setSourcesData(sourcesData); 84 + } catch (error: unknown) { 85 + console.error("Failed to fetch statistics:", error); 86 + toast({ 87 + variant: "destructive", 88 + title: "Error", 89 + description: error instanceof Error ? error.message : "Failed to load statistics", 90 + }); 91 + } finally { 92 + setLoading(false); 93 + } 94 + }; 95 96 + fetchData(); 97 + } 98 + }, [isOpen, linkId]); 99 + 100 + const aggregatedSources = useMemo(() => { 101 + const sourceMap = sourcesData.reduce<Record<string, number>>( 102 + (acc, { source, count }) => ({ 103 + ...acc, 104 + [source]: (acc[source] || 0) + count 105 + }), 106 + {} 107 ); 108 + 109 + return Object.entries(sourceMap) 110 + .map(([source, count]) => ({ source, count })) 111 + .sort((a, b) => b.count - a.count); 112 + }, [sourcesData]); 113 + 114 + return ( 115 + <Dialog open={isOpen} onOpenChange={onClose}> 116 + <DialogContent className="max-w-3xl"> 117 + <DialogHeader> 118 + <DialogTitle>Link Statistics</DialogTitle> 119 + </DialogHeader> 120 + 121 + {loading ? ( 122 + <div className="flex items-center justify-center h-64">Loading...</div> 123 + ) : ( 124 + <div className="grid gap-4"> 125 + <Card> 126 + <CardHeader> 127 + <CardTitle>Clicks Over Time</CardTitle> 128 + </CardHeader> 129 + <CardContent> 130 + <div className="h-[300px]"> 131 + <ResponsiveContainer width="100%" height="100%"> 132 + <LineChart data={clicksOverTime}> 133 + <CartesianGrid strokeDasharray="3 3" /> 134 + <XAxis dataKey="date" /> 135 + <YAxis /> 136 + <Tooltip content={<CustomTooltip />} /> 137 + <Line 138 + type="monotone" 139 + dataKey="clicks" 140 + stroke="#8884d8" 141 + strokeWidth={2} 142 + /> 143 + </LineChart> 144 + </ResponsiveContainer> 145 + </div> 146 + </CardContent> 147 + </Card> 148 + 149 + <Card> 150 + <CardHeader> 151 + <CardTitle>Top Sources</CardTitle> 152 + </CardHeader> 153 + <CardContent> 154 + <ul className="space-y-2"> 155 + {aggregatedSources.map((source, index) => ( 156 + <li 157 + key={source.source} 158 + className="flex items-center justify-between py-2 border-b last:border-0" 159 + > 160 + <span className="text-sm"> 161 + <span className="font-medium text-muted-foreground mr-2"> 162 + {index + 1}. 163 + </span> 164 + {source.source} 165 + </span> 166 + <span className="text-sm font-medium">{source.count} clicks</span> 167 + </li> 168 + ))} 169 + </ul> 170 + </CardContent> 171 + </Card> 172 + </div> 173 + )} 174 + </DialogContent> 175 + </Dialog> 176 + ); 177 }
+1
frontend/src/types/api.ts
··· 32 } 33 34 export interface SourceStats { 35 source: string; 36 count: number; 37 }
··· 32 } 33 34 export interface SourceStats { 35 + date: string; 36 source: string; 37 count: number; 38 }
+28 -15
frontend/vite.config.ts
··· 3 import tailwindcss from '@tailwindcss/vite' 4 import path from "path" 5 6 - export default defineConfig(() => ({ 7 - plugins: [react(), tailwindcss()], 8 - /*server: { 9 - proxy: { 10 - '/api': { 11 - target: process.env.VITE_API_URL || 'http://localhost:8080', 12 - changeOrigin: true, 13 }, 14 - }, 15 - },*/ 16 - resolve: { 17 - alias: { 18 - "@": path.resolve(__dirname, "./src"), 19 - }, 20 - }, 21 - }))
··· 3 import tailwindcss from '@tailwindcss/vite' 4 import path from "path" 5 6 + export default defineConfig(({ command }) => { 7 + if (command === 'serve') { //command == 'dev' 8 + return { 9 + server: { 10 + proxy: { 11 + '/api': { 12 + target: process.env.VITE_API_URL || 'http://localhost:8080', 13 + changeOrigin: true, 14 + }, 15 + }, 16 + }, 17 + plugins: [react(), tailwindcss()], 18 + resolve: { 19 + alias: { 20 + "@": path.resolve(__dirname, "./src"), 21 + }, 22 + }, 23 + } 24 + } else { //command === 'build' 25 + return { 26 + plugins: [react(), tailwindcss()], 27 + resolve: { 28 + alias: { 29 + "@": path.resolve(__dirname, "./src"), 30 + }, 31 }, 32 + } 33 + } 34 + })
+3
migrations/20250219000000_extend_short_code.sql
···
··· 1 + -- PostgreSQL migration 2 + ALTER TABLE links ALTER COLUMN short_code TYPE VARCHAR(32); 3 +
+8 -7
src/auth.rs
··· 1 use actix_web::{dev::Payload, FromRequest, HttpRequest}; 2 use jsonwebtoken::{decode, DecodingKey, Validation}; 3 use std::future::{ready, Ready}; 4 - use crate::{error::AppError, models::Claims}; 5 6 pub struct AuthenticatedUser { 7 pub user_id: i32, ··· 12 type Future = Ready<Result<Self, Self::Error>>; 13 14 fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { 15 - let auth_header = req.headers() 16 .get("Authorization") 17 .and_then(|h| h.to_str().ok()); 18 19 if let Some(auth_header) = auth_header { 20 if auth_header.starts_with("Bearer ") { 21 let token = &auth_header[7..]; 22 - let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 23 - 24 match decode::<Claims>( 25 token, 26 &DecodingKey::from_secret(secret.as_bytes()), 27 - &Validation::default() 28 ) { 29 Ok(token_data) => { 30 return ready(Ok(AuthenticatedUser { ··· 35 } 36 } 37 } 38 - 39 ready(Err(AppError::Unauthorized)) 40 } 41 - }
··· 1 + use crate::{error::AppError, models::Claims}; 2 use actix_web::{dev::Payload, FromRequest, HttpRequest}; 3 use jsonwebtoken::{decode, DecodingKey, Validation}; 4 use std::future::{ready, Ready}; 5 6 pub struct AuthenticatedUser { 7 pub user_id: i32, ··· 12 type Future = Ready<Result<Self, Self::Error>>; 13 14 fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { 15 + let auth_header = req 16 + .headers() 17 .get("Authorization") 18 .and_then(|h| h.to_str().ok()); 19 20 if let Some(auth_header) = auth_header { 21 if auth_header.starts_with("Bearer ") { 22 let token = &auth_header[7..]; 23 + let secret = 24 + std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 25 match decode::<Claims>( 26 token, 27 &DecodingKey::from_secret(secret.as_bytes()), 28 + &Validation::default(), 29 ) { 30 Ok(token_data) => { 31 return ready(Ok(AuthenticatedUser { ··· 36 } 37 } 38 } 39 ready(Err(AppError::Unauthorized)) 40 } 41 + } 42 +
+145 -10
src/handlers.rs
··· 131 Ok(()) 132 } 133 134 - fn validate_url(url: &String) -> Result<(), AppError> { 135 if url.is_empty() { 136 return Err(AppError::InvalidInput("URL cannot be empty".to_string())); 137 } ··· 457 })) 458 } 459 460 pub async fn delete_link( 461 state: web::Data<AppState>, 462 user: AuthenticatedUser, 463 path: web::Path<i32>, 464 ) -> Result<impl Responder, AppError> { 465 - let link_id = path.into_inner(); 466 467 match &state.db { 468 DatabasePool::Postgres(pool) => { ··· 570 WHERE link_id = $1 571 GROUP BY DATE(created_at) 572 ORDER BY DATE(created_at) ASC 573 - LIMIT 30 574 "#, 575 ) 576 .bind(link_id) ··· 587 WHERE link_id = ? 588 GROUP BY DATE(created_at) 589 ORDER BY DATE(created_at) ASC 590 - LIMIT 30 591 "#, 592 ) 593 .bind(link_id) ··· 643 sqlx::query_as::<_, SourceStats>( 644 r#" 645 SELECT 646 query_source as source, 647 COUNT(*)::bigint as count 648 FROM clicks 649 WHERE link_id = $1 650 AND query_source IS NOT NULL 651 AND query_source != '' 652 - GROUP BY query_source 653 - ORDER BY COUNT(*) DESC 654 - LIMIT 10 655 "#, 656 ) 657 .bind(link_id) ··· 662 sqlx::query_as::<_, SourceStats>( 663 r#" 664 SELECT 665 query_source as source, 666 COUNT(*) as count 667 FROM clicks 668 WHERE link_id = ? 669 AND query_source IS NOT NULL 670 AND query_source != '' 671 - GROUP BY query_source 672 - ORDER BY COUNT(*) DESC 673 - LIMIT 10 674 "#, 675 ) 676 .bind(link_id)
··· 131 Ok(()) 132 } 133 134 + fn validate_url(url: &str) -> Result<(), AppError> { 135 if url.is_empty() { 136 return Err(AppError::InvalidInput("URL cannot be empty".to_string())); 137 } ··· 457 })) 458 } 459 460 + pub async fn edit_link( 461 + state: web::Data<AppState>, 462 + user: AuthenticatedUser, 463 + path: web::Path<i32>, 464 + payload: web::Json<CreateLink>, 465 + ) -> Result<impl Responder, AppError> { 466 + let link_id: i32 = path.into_inner(); 467 + 468 + // Validate the new URL if provided 469 + validate_url(&payload.url)?; 470 + 471 + // Validate custom code if provided 472 + if let Some(ref custom_code) = payload.custom_code { 473 + validate_custom_code(custom_code)?; 474 + 475 + // Check if the custom code is already taken by another link 476 + let existing_link = match &state.db { 477 + DatabasePool::Postgres(pool) => { 478 + sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1 AND id != $2") 479 + .bind(custom_code) 480 + .bind(link_id) 481 + .fetch_optional(pool) 482 + .await? 483 + } 484 + DatabasePool::Sqlite(pool) => { 485 + sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = ?1 AND id != ?2") 486 + .bind(custom_code) 487 + .bind(link_id) 488 + .fetch_optional(pool) 489 + .await? 490 + } 491 + }; 492 + 493 + if existing_link.is_some() { 494 + return Err(AppError::InvalidInput( 495 + "Custom code already taken".to_string(), 496 + )); 497 + } 498 + } 499 + 500 + // Update the link 501 + let updated_link = match &state.db { 502 + DatabasePool::Postgres(pool) => { 503 + let mut tx = pool.begin().await?; 504 + 505 + // First verify the link belongs to the user 506 + let link = 507 + sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = $1 AND user_id = $2") 508 + .bind(link_id) 509 + .bind(user.user_id) 510 + .fetch_optional(&mut *tx) 511 + .await?; 512 + 513 + if link.is_none() { 514 + return Err(AppError::NotFound); 515 + } 516 + 517 + // Update the link 518 + let updated = sqlx::query_as::<_, Link>( 519 + r#" 520 + UPDATE links 521 + SET 522 + original_url = $1, 523 + short_code = COALESCE($2, short_code) 524 + WHERE id = $3 AND user_id = $4 525 + RETURNING * 526 + "#, 527 + ) 528 + .bind(&payload.url) 529 + .bind(&payload.custom_code) 530 + .bind(link_id) 531 + .bind(user.user_id) 532 + .fetch_one(&mut *tx) 533 + .await?; 534 + 535 + // If source is provided, add a click record 536 + if let Some(ref source) = payload.source { 537 + sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)") 538 + .bind(link_id) 539 + .bind(source) 540 + .execute(&mut *tx) 541 + .await?; 542 + } 543 + 544 + tx.commit().await?; 545 + updated 546 + } 547 + DatabasePool::Sqlite(pool) => { 548 + let mut tx = pool.begin().await?; 549 + 550 + // First verify the link belongs to the user 551 + let link = 552 + sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = ?1 AND user_id = ?2") 553 + .bind(link_id) 554 + .bind(user.user_id) 555 + .fetch_optional(&mut *tx) 556 + .await?; 557 + 558 + if link.is_none() { 559 + return Err(AppError::NotFound); 560 + } 561 + 562 + // Update the link 563 + let updated = sqlx::query_as::<_, Link>( 564 + r#" 565 + UPDATE links 566 + SET 567 + original_url = ?1, 568 + short_code = COALESCE(?2, short_code) 569 + WHERE id = ?3 AND user_id = ?4 570 + RETURNING * 571 + "#, 572 + ) 573 + .bind(&payload.url) 574 + .bind(&payload.custom_code) 575 + .bind(link_id) 576 + .bind(user.user_id) 577 + .fetch_one(&mut *tx) 578 + .await?; 579 + 580 + // If source is provided, add a click record 581 + if let Some(ref source) = payload.source { 582 + sqlx::query("INSERT INTO clicks (link_id, source) VALUES (?1, ?2)") 583 + .bind(link_id) 584 + .bind(source) 585 + .execute(&mut *tx) 586 + .await?; 587 + } 588 + 589 + tx.commit().await?; 590 + updated 591 + } 592 + }; 593 + 594 + Ok(HttpResponse::Ok().json(updated_link)) 595 + } 596 + 597 pub async fn delete_link( 598 state: web::Data<AppState>, 599 user: AuthenticatedUser, 600 path: web::Path<i32>, 601 ) -> Result<impl Responder, AppError> { 602 + let link_id: i32 = path.into_inner(); 603 604 match &state.db { 605 DatabasePool::Postgres(pool) => { ··· 707 WHERE link_id = $1 708 GROUP BY DATE(created_at) 709 ORDER BY DATE(created_at) ASC 710 "#, 711 ) 712 .bind(link_id) ··· 723 WHERE link_id = ? 724 GROUP BY DATE(created_at) 725 ORDER BY DATE(created_at) ASC 726 "#, 727 ) 728 .bind(link_id) ··· 778 sqlx::query_as::<_, SourceStats>( 779 r#" 780 SELECT 781 + DATE(created_at)::text as date, 782 query_source as source, 783 COUNT(*)::bigint as count 784 FROM clicks 785 WHERE link_id = $1 786 AND query_source IS NOT NULL 787 AND query_source != '' 788 + GROUP BY DATE(created_at), query_source 789 + ORDER BY DATE(created_at) ASC, COUNT(*) DESC 790 "#, 791 ) 792 .bind(link_id) ··· 797 sqlx::query_as::<_, SourceStats>( 798 r#" 799 SELECT 800 + DATE(created_at) as date, 801 query_source as source, 802 COUNT(*) as count 803 FROM clicks 804 WHERE link_id = ? 805 AND query_source IS NOT NULL 806 AND query_source != '' 807 + GROUP BY DATE(created_at), query_source 808 + ORDER BY DATE(created_at) ASC, COUNT(*) DESC 809 "#, 810 ) 811 .bind(link_id)
+159 -1
src/main.rs
··· 1 use actix_cors::Cors; 2 use actix_web::{web, App, HttpResponse, HttpServer}; 3 use anyhow::Result; 4 use rust_embed::RustEmbed; 5 use simplelink::check_and_generate_admin_token; 6 use simplelink::{create_db_pool, run_migrations}; 7 use simplelink::{handlers, AppState}; 8 - use tracing::info; 9 10 #[derive(RustEmbed)] 11 #[folder = "static/"] 12 struct Asset; ··· 23 } 24 } 25 26 #[actix_web::main] 27 async fn main() -> Result<()> { 28 // Load environment variables from .env file ··· 35 let pool = create_db_pool().await?; 36 run_migrations(&pool).await?; 37 38 let admin_token = check_and_generate_admin_token(&pool).await?; 39 40 let state = AppState { ··· 70 "/links/{id}/sources", 71 web::get().to(handlers::get_link_sources), 72 ) 73 .route("/auth/register", web::post().to(handlers::register)) 74 .route("/auth/login", web::post().to(handlers::login)) 75 .route(
··· 1 use actix_cors::Cors; 2 use actix_web::{web, App, HttpResponse, HttpServer}; 3 use anyhow::Result; 4 + use clap::Parser; 5 use rust_embed::RustEmbed; 6 use simplelink::check_and_generate_admin_token; 7 + use simplelink::models::DatabasePool; 8 use simplelink::{create_db_pool, run_migrations}; 9 use simplelink::{handlers, AppState}; 10 + use sqlx::{Postgres, Sqlite}; 11 + use tracing::{error, info}; 12 13 + #[derive(Parser, Debug)] 14 + #[command(author, version, about, long_about = None)] 15 #[derive(RustEmbed)] 16 #[folder = "static/"] 17 struct Asset; ··· 28 } 29 } 30 31 + async fn create_initial_links(pool: &DatabasePool) -> Result<()> { 32 + if let Ok(links) = std::env::var("INITIAL_LINKS") { 33 + for link_entry in links.split(';') { 34 + let parts: Vec<&str> = link_entry.split(',').collect(); 35 + if parts.len() >= 2 { 36 + let url = parts[0]; 37 + let code = parts[1]; 38 + 39 + match pool { 40 + DatabasePool::Postgres(pool) => { 41 + sqlx::query( 42 + "INSERT INTO links (original_url, short_code, user_id) 43 + VALUES ($1, $2, $3) 44 + ON CONFLICT (short_code) 45 + DO UPDATE SET short_code = EXCLUDED.short_code 46 + WHERE links.original_url = EXCLUDED.original_url", 47 + ) 48 + .bind(url) 49 + .bind(code) 50 + .bind(1) 51 + .execute(pool) 52 + .await?; 53 + } 54 + DatabasePool::Sqlite(pool) => { 55 + // First check if the exact combination exists 56 + let exists = sqlx::query_scalar::<_, bool>( 57 + "SELECT EXISTS( 58 + SELECT 1 FROM links 59 + WHERE original_url = ?1 60 + AND short_code = ?2 61 + )", 62 + ) 63 + .bind(url) 64 + .bind(code) 65 + .fetch_one(pool) 66 + .await?; 67 + 68 + // Only insert if the exact combination doesn't exist 69 + if !exists { 70 + sqlx::query( 71 + "INSERT INTO links (original_url, short_code, user_id) 72 + VALUES (?1, ?2, ?3)", 73 + ) 74 + .bind(url) 75 + .bind(code) 76 + .bind(1) 77 + .execute(pool) 78 + .await?; 79 + info!("Created initial link: {} -> {} for user_id: 1", code, url); 80 + } else { 81 + info!("Skipped existing link: {} -> {} for user_id: 1", code, url); 82 + } 83 + } 84 + } 85 + } 86 + } 87 + } 88 + Ok(()) 89 + } 90 + 91 + async fn create_admin_user(pool: &DatabasePool, email: &str, password: &str) -> Result<()> { 92 + use argon2::{ 93 + password_hash::{rand_core::OsRng, SaltString}, 94 + Argon2, PasswordHasher, 95 + }; 96 + 97 + let salt = SaltString::generate(&mut OsRng); 98 + let argon2 = Argon2::default(); 99 + let password_hash = argon2 100 + .hash_password(password.as_bytes(), &salt) 101 + .map_err(|e| anyhow::anyhow!("Password hashing error: {}", e))? 102 + .to_string(); 103 + 104 + match pool { 105 + DatabasePool::Postgres(pool) => { 106 + sqlx::query( 107 + "INSERT INTO users (email, password_hash) 108 + VALUES ($1, $2) 109 + ON CONFLICT (email) DO NOTHING", 110 + ) 111 + .bind(email) 112 + .bind(&password_hash) 113 + .execute(pool) 114 + .await?; 115 + } 116 + DatabasePool::Sqlite(pool) => { 117 + sqlx::query( 118 + "INSERT OR IGNORE INTO users (email, password_hash) 119 + VALUES (?1, ?2)", 120 + ) 121 + .bind(email) 122 + .bind(&password_hash) 123 + .execute(pool) 124 + .await?; 125 + } 126 + } 127 + info!("Created admin user: {}", email); 128 + Ok(()) 129 + } 130 + 131 #[actix_web::main] 132 async fn main() -> Result<()> { 133 // Load environment variables from .env file ··· 140 let pool = create_db_pool().await?; 141 run_migrations(&pool).await?; 142 143 + // First check if admin credentials are provided in environment variables 144 + let admin_credentials = match ( 145 + std::env::var("SIMPLELINK_USER"), 146 + std::env::var("SIMPLELINK_PASS"), 147 + ) { 148 + (Ok(user), Ok(pass)) => Some((user, pass)), 149 + _ => None, 150 + }; 151 + 152 + if let Some((email, password)) = admin_credentials { 153 + // Now check for existing users 154 + let user_count = match &pool { 155 + DatabasePool::Postgres(pool) => { 156 + let mut tx = pool.begin().await?; 157 + let count = 158 + sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users") 159 + .fetch_one(&mut *tx) 160 + .await? 161 + .0; 162 + tx.commit().await?; 163 + count 164 + } 165 + DatabasePool::Sqlite(pool) => { 166 + let mut tx = pool.begin().await?; 167 + let count = sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users") 168 + .fetch_one(&mut *tx) 169 + .await? 170 + .0; 171 + tx.commit().await?; 172 + count 173 + } 174 + }; 175 + 176 + if user_count == 0 { 177 + info!("No users found, creating admin user: {}", email); 178 + match create_admin_user(&pool, &email, &password).await { 179 + Ok(_) => info!("Successfully created admin user"), 180 + Err(e) => { 181 + error!("Failed to create admin user: {}", e); 182 + return Err(anyhow::anyhow!("Failed to create admin user: {}", e)); 183 + } 184 + } 185 + } 186 + } else { 187 + info!( 188 + "No admin credentials provided in environment variables, skipping admin user creation" 189 + ); 190 + } 191 + 192 + // Create initial links from environment variables 193 + create_initial_links(&pool).await?; 194 + 195 let admin_token = check_and_generate_admin_token(&pool).await?; 196 197 let state = AppState { ··· 227 "/links/{id}/sources", 228 web::get().to(handlers::get_link_sources), 229 ) 230 + .route("/links/{id}", web::patch().to(handlers::edit_link)) 231 .route("/auth/register", web::post().to(handlers::register)) 232 .route("/auth/login", web::post().to(handlers::login)) 233 .route(
+2 -1
src/models.rs
··· 87 .duration_since(UNIX_EPOCH) 88 .unwrap() 89 .as_secs() as usize 90 - + 24 * 60 * 60; // 24 hours from now 91 92 Self { sub: user_id, exp } 93 } ··· 150 151 #[derive(sqlx::FromRow, Serialize)] 152 pub struct SourceStats { 153 pub source: String, 154 pub count: i64, 155 }
··· 87 .duration_since(UNIX_EPOCH) 88 .unwrap() 89 .as_secs() as usize 90 + + 14 * 24 * 60 * 60; // 2 weeks from now 91 92 Self { sub: user_id, exp } 93 } ··· 150 151 #[derive(sqlx::FromRow, Serialize)] 152 pub struct SourceStats { 153 + pub date: String, 154 pub source: String, 155 pub count: i64, 156 }