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 29 30 30 - name: Install cosign 31 31 if: github.event_name != 'pull_request' 32 - uses: sigstore/cosign-installer@v3.7.0 32 + uses: sigstore/cosign-installer@v3.8.1 33 33 with: 34 - cosign-release: "v2.4.1" 34 + cosign-release: "v2.4.3" 35 35 36 36 - name: Setup Docker buildx 37 37 uses: docker/setup-buildx-action@v3
+9 -39
Cargo.lock
··· 1440 1440 1441 1441 [[package]] 1442 1442 name = "nu-ansi-term" 1443 - version = "0.46.0" 1443 + version = "0.50.1" 1444 1444 source = "registry+https://github.com/rust-lang/crates.io-index" 1445 - checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" 1445 + checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" 1446 1446 dependencies = [ 1447 - "overload", 1448 - "winapi", 1447 + "windows-sys 0.52.0", 1449 1448 ] 1450 1449 1451 1450 [[package]] ··· 1525 1524 version = "1.20.2" 1526 1525 source = "registry+https://github.com/rust-lang/crates.io-index" 1527 1526 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 1527 1535 1528 [[package]] 1536 1529 name = "parking" ··· 1751 1744 1752 1745 [[package]] 1753 1746 name = "ring" 1754 - version = "0.17.8" 1747 + version = "0.17.13" 1755 1748 source = "registry+https://github.com/rust-lang/crates.io-index" 1756 - checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" 1749 + checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee" 1757 1750 dependencies = [ 1758 1751 "cc", 1759 1752 "cfg-if", 1760 1753 "getrandom", 1761 1754 "libc", 1762 - "spin", 1763 1755 "untrusted", 1764 1756 "windows-sys 0.52.0", 1765 1757 ] ··· 2432 2424 2433 2425 [[package]] 2434 2426 name = "tokio" 2435 - version = "1.43.0" 2427 + version = "1.43.1" 2436 2428 source = "registry+https://github.com/rust-lang/crates.io-index" 2437 - checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" 2429 + checksum = "492a604e2fd7f814268a378409e6c92b5525d747d10db9a229723f55a417958c" 2438 2430 dependencies = [ 2439 2431 "backtrace", 2440 2432 "bytes", ··· 2529 2521 2530 2522 [[package]] 2531 2523 name = "tracing-subscriber" 2532 - version = "0.3.19" 2524 + version = "0.3.20" 2533 2525 source = "registry+https://github.com/rust-lang/crates.io-index" 2534 - checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 2526 + checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 2535 2527 dependencies = [ 2536 2528 "nu-ansi-term", 2537 2529 "sharded-slab", ··· 2739 2731 ] 2740 2732 2741 2733 [[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 2734 name = "winapi-util" 2759 2735 version = "0.1.9" 2760 2736 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2762 2738 dependencies = [ 2763 2739 "windows-sys 0.59.0", 2764 2740 ] 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 2741 2772 2742 [[package]] 2773 2743 name = "windows-core"
+1 -1
Cargo.toml
··· 13 13 actix-web = "4.4" 14 14 actix-files = "0.6" 15 15 actix-cors = "0.6" 16 - tokio = { version = "1.36", features = ["rt-multi-thread", "macros"] } 16 + tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] } 17 17 sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "chrono"] } 18 18 serde = { version = "1.0", features = ["derive"] } 19 19 serde_json = "1.0"
+1 -1
Dockerfile
··· 4 4 WORKDIR /usr/src/frontend 5 5 6 6 # Copy frontend files 7 - COPY frontend/package*.json ./ 7 + COPY frontend/package.json ./ 8 8 RUN bun install 9 9 10 10 COPY frontend/ ./
+29 -13
README.md
··· 1 1 # SimpleLink 2 2 3 - A very performant and light (6mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres. 3 + A very performant and light (2MB in memory) link shortener and tracker. Written in Rust and React and uses Postgres or SQLite. 4 4 5 5 ![MainView](readme_img/mainview.jpg) 6 6 ··· 8 8 9 9 ## How to Run 10 10 11 - ### From Docker: 11 + ### From Docker 12 12 13 - ```Bash 13 + ```bash 14 14 docker run -p 8080:8080 \ 15 15 -e JWT_SECRET=change-me-in-production \ 16 + -e SIMPLELINK_USER=admin@example.com \ 17 + -e SIMPLELINK_PASS=your-secure-password \ 16 18 -v simplelink_data:/data \ 17 - ghcr.io/waveringana/simplelink:v2 19 + ghcr.io/waveringana/simplelink:v2.2 18 20 ``` 19 21 20 - Find the admin-setup-token pasted into the terminal output, or in admin-setup-token.txt in the container's root. 22 + ### Environment Variables 21 23 22 - This is needed to register with the frontend. (TODO, register admin account with ENV) 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" 23 31 24 - ### From Docker Compose: 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 25 35 26 - Edit the docker-compose.yml file. It comes included with a postgressql db for use 36 + Edit the docker-compose.yml file. It comes included with a PostgreSQL db configuration. 27 37 28 38 ## Build 29 39 ··· 31 41 32 42 First configure .env.example and save it to .env 33 43 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 44 ```bash 37 45 git clone https://github.com/waveringana/simplelink && cd simplelink 38 46 ./build.sh 39 47 cargo run 40 48 ``` 41 49 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 50 + Alternatively for a binary build: 45 51 46 52 ```bash 47 53 ./build.sh --binary ··· 55 61 docker build -t simplelink . 56 62 docker run -p 8080:8080 \ 57 63 -e JWT_SECRET=change-me-in-production \ 64 + -e SIMPLELINK_USER=admin@example.com \ 65 + -e SIMPLELINK_PASS=your-secure-password \ 58 66 -v simplelink_data:/data \ 59 67 simplelink 60 68 ``` ··· 62 70 ### From Docker Compose 63 71 64 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 -1
docker-compose.yml
··· 19 19 - shortener-network 20 20 21 21 app: 22 - image: ghcr.io/waveringana/simplelink:v2 22 + image: ghcr.io/waveringana/simplelink:v2.2 23 23 container_name: shortener-app 24 24 ports: 25 25 - "8080:8080"
+6
frontend/src/api/client.ts
··· 58 58 return response.data; 59 59 }; 60 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 + 61 67 export const deleteLink = async (id: number) => { 62 68 await api.delete(`/links/${id}`); 63 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 -19
frontend/src/components/LinkList.tsx
··· 1 - import { useEffect, useState } from 'react' 1 + import { useCallback, useEffect, useState } from 'react' 2 2 import { Link } from '../types/api' 3 3 import { getAllLinks, deleteLink } from '../api/client' 4 4 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" ··· 12 12 } from "@/components/ui/table" 13 13 import { Button } from "@/components/ui/button" 14 14 import { useToast } from "@/hooks/use-toast" 15 - import { Copy, Trash2, BarChart2 } from "lucide-react" 15 + import { Copy, Trash2, BarChart2, Pencil } from "lucide-react" 16 16 import { 17 17 Dialog, 18 18 DialogContent, ··· 23 23 } from "@/components/ui/dialog" 24 24 25 25 import { StatisticsModal } from "./StatisticsModal" 26 + import { EditModal } from './EditModal' 26 27 27 28 interface LinkListProps { 28 29 refresh?: number; ··· 39 40 isOpen: false, 40 41 linkId: null, 41 42 }); 43 + const [editModal, setEditModal] = useState<{ isOpen: boolean; link: Link | null }>({ 44 + isOpen: false, 45 + link: null, 46 + }); 42 47 const { toast } = useToast() 43 48 44 - const fetchLinks = async () => { 49 + const fetchLinks = useCallback(async () => { 45 50 try { 46 51 setLoading(true) 47 52 const data = await getAllLinks() 48 53 setLinks(data) 49 - } catch (err) { 54 + } catch (err: unknown) { 55 + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; 50 56 toast({ 51 57 title: "Error", 52 - description: "Failed to load links", 58 + description: `Failed to load links: ${errorMessage}`, 53 59 variant: "destructive", 54 60 }) 55 61 } finally { 56 62 setLoading(false) 57 63 } 58 - } 64 + }, [toast, setLinks, setLoading]) 59 65 60 66 useEffect(() => { 61 67 fetchLinks() 62 - }, [refresh]) // Re-fetch when refresh counter changes 68 + }, [fetchLinks, refresh]) // Re-fetch when refresh counter changes 63 69 64 70 const handleDelete = async () => { 65 71 if (!deleteModal.linkId) return ··· 71 77 toast({ 72 78 description: "Link deleted successfully", 73 79 }) 74 - } catch (err) { 80 + } catch (err: unknown) { 81 + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; 75 82 toast({ 76 83 title: "Error", 77 - description: "Failed to delete link", 84 + description: `Failed to delete link: ${errorMessage}`, 78 85 variant: "destructive", 79 86 }) 80 87 } ··· 85 92 const baseUrl = window.location.origin 86 93 navigator.clipboard.writeText(`${baseUrl}/${shortCode}`) 87 94 toast({ 88 - description: ( 89 - <> 90 - Link copied to clipboard 91 - <br /> 92 - You can add ?source=TextHere to the end of the link to track the source of clicks 93 - </> 94 - ), 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 + ), 95 102 }) 96 103 } 97 104 ··· 127 134 </CardHeader> 128 135 <CardContent> 129 136 <div className="rounded-md border"> 137 + 130 138 <Table> 131 139 <TableHeader> 132 140 <TableRow> ··· 134 142 <TableHead className="hidden md:table-cell">Original URL</TableHead> 135 143 <TableHead>Clicks</TableHead> 136 144 <TableHead className="hidden md:table-cell">Created</TableHead> 137 - <TableHead>Actions</TableHead> 145 + <TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead> 138 146 </TableRow> 139 147 </TableHeader> 140 148 <TableBody> ··· 148 156 <TableCell className="hidden md:table-cell"> 149 157 {new Date(link.created_at).toLocaleDateString()} 150 158 </TableCell> 151 - <TableCell> 152 - <div className="flex gap-2"> 159 + <TableCell className="p-2 pr-4"> 160 + <div className="flex items-center gap-1"> 153 161 <Button 154 162 variant="ghost" 155 163 size="icon" ··· 171 179 <Button 172 180 variant="ghost" 173 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" 174 191 className="h-8 w-8 text-destructive" 175 192 onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })} 176 193 > ··· 191 208 onClose={() => setStatsModal({ isOpen: false, linkId: null })} 192 209 linkId={statsModal.linkId!} 193 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 + )} 194 219 </> 195 220 ) 196 221 }
+3 -3
frontend/src/components/StatisticsModal.tsx
··· 31 31 label, 32 32 }: { 33 33 active?: boolean; 34 - payload?: any[]; 34 + payload?: { value: number; payload: EnhancedClickStats }[]; 35 35 label?: string; 36 36 }) => { 37 37 if (active && payload && payload.length > 0) { ··· 81 81 82 82 setClicksOverTime(enhancedClicksData); 83 83 setSourcesData(sourcesData); 84 - } catch (error: any) { 84 + } catch (error: unknown) { 85 85 console.error("Failed to fetch statistics:", error); 86 86 toast({ 87 87 variant: "destructive", 88 88 title: "Error", 89 - description: error.response?.data || "Failed to load statistics", 89 + description: error instanceof Error ? error.message : "Failed to load statistics", 90 90 }); 91 91 } finally { 92 92 setLoading(false);
+28 -15
frontend/vite.config.ts
··· 3 3 import tailwindcss from '@tailwindcss/vite' 4 4 import path from "path" 5 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, 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 + }, 13 31 }, 14 - }, 15 - },*/ 16 - resolve: { 17 - alias: { 18 - "@": path.resolve(__dirname, "./src"), 19 - }, 20 - }, 21 - })) 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 crate::{error::AppError, models::Claims}; 1 2 use actix_web::{dev::Payload, FromRequest, HttpRequest}; 2 3 use jsonwebtoken::{decode, DecodingKey, Validation}; 3 4 use std::future::{ready, Ready}; 4 - use crate::{error::AppError, models::Claims}; 5 5 6 6 pub struct AuthenticatedUser { 7 7 pub user_id: i32, ··· 12 12 type Future = Ready<Result<Self, Self::Error>>; 13 13 14 14 fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { 15 - let auth_header = req.headers() 15 + let auth_header = req 16 + .headers() 16 17 .get("Authorization") 17 18 .and_then(|h| h.to_str().ok()); 18 19 19 20 if let Some(auth_header) = auth_header { 20 21 if auth_header.starts_with("Bearer ") { 21 22 let token = &auth_header[7..]; 22 - let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 23 - 23 + let secret = 24 + std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 24 25 match decode::<Claims>( 25 26 token, 26 27 &DecodingKey::from_secret(secret.as_bytes()), 27 - &Validation::default() 28 + &Validation::default(), 28 29 ) { 29 30 Ok(token_data) => { 30 31 return ready(Ok(AuthenticatedUser { ··· 35 36 } 36 37 } 37 38 } 38 - 39 39 ready(Err(AppError::Unauthorized)) 40 40 } 41 - } 41 + } 42 +
+139 -6
src/handlers.rs
··· 131 131 Ok(()) 132 132 } 133 133 134 - fn validate_url(url: &String) -> Result<(), AppError> { 134 + fn validate_url(url: &str) -> Result<(), AppError> { 135 135 if url.is_empty() { 136 136 return Err(AppError::InvalidInput("URL cannot be empty".to_string())); 137 137 } ··· 457 457 })) 458 458 } 459 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 + 460 597 pub async fn delete_link( 461 598 state: web::Data<AppState>, 462 599 user: AuthenticatedUser, 463 600 path: web::Path<i32>, 464 601 ) -> Result<impl Responder, AppError> { 465 - let link_id = path.into_inner(); 602 + let link_id: i32 = path.into_inner(); 466 603 467 604 match &state.db { 468 605 DatabasePool::Postgres(pool) => { ··· 570 707 WHERE link_id = $1 571 708 GROUP BY DATE(created_at) 572 709 ORDER BY DATE(created_at) ASC 573 - LIMIT 30 574 710 "#, 575 711 ) 576 712 .bind(link_id) ··· 587 723 WHERE link_id = ? 588 724 GROUP BY DATE(created_at) 589 725 ORDER BY DATE(created_at) ASC 590 - LIMIT 30 591 726 "#, 592 727 ) 593 728 .bind(link_id) ··· 652 787 AND query_source != '' 653 788 GROUP BY DATE(created_at), query_source 654 789 ORDER BY DATE(created_at) ASC, COUNT(*) DESC 655 - LIMIT 300 656 790 "#, 657 791 ) 658 792 .bind(link_id) ··· 672 806 AND query_source != '' 673 807 GROUP BY DATE(created_at), query_source 674 808 ORDER BY DATE(created_at) ASC, COUNT(*) DESC 675 - LIMIT 300 676 809 "#, 677 810 ) 678 811 .bind(link_id)
+159 -1
src/main.rs
··· 1 1 use actix_cors::Cors; 2 2 use actix_web::{web, App, HttpResponse, HttpServer}; 3 3 use anyhow::Result; 4 + use clap::Parser; 4 5 use rust_embed::RustEmbed; 5 6 use simplelink::check_and_generate_admin_token; 7 + use simplelink::models::DatabasePool; 6 8 use simplelink::{create_db_pool, run_migrations}; 7 9 use simplelink::{handlers, AppState}; 8 - use tracing::info; 10 + use sqlx::{Postgres, Sqlite}; 11 + use tracing::{error, info}; 9 12 13 + #[derive(Parser, Debug)] 14 + #[command(author, version, about, long_about = None)] 10 15 #[derive(RustEmbed)] 11 16 #[folder = "static/"] 12 17 struct Asset; ··· 23 28 } 24 29 } 25 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 + 26 131 #[actix_web::main] 27 132 async fn main() -> Result<()> { 28 133 // Load environment variables from .env file ··· 35 140 let pool = create_db_pool().await?; 36 141 run_migrations(&pool).await?; 37 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 + 38 195 let admin_token = check_and_generate_admin_token(&pool).await?; 39 196 40 197 let state = AppState { ··· 70 227 "/links/{id}/sources", 71 228 web::get().to(handlers::get_link_sources), 72 229 ) 230 + .route("/links/{id}", web::patch().to(handlers::edit_link)) 73 231 .route("/auth/register", web::post().to(handlers::register)) 74 232 .route("/auth/login", web::post().to(handlers::login)) 75 233 .route(
+1 -1
src/models.rs
··· 87 87 .duration_since(UNIX_EPOCH) 88 88 .unwrap() 89 89 .as_secs() as usize 90 - + 24 * 60 * 60; // 24 hours from now 90 + + 14 * 24 * 60 * 60; // 2 weeks from now 91 91 92 92 Self { sub: user_id, exp } 93 93 }