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

add admin setup token i love admin setup token

+20
.sqlx/query-fd64104d130b93dd5fc9414b8710ad5183b647eaaff90decbce15e10d83c7538.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT COUNT(*) as count FROM users", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [] 14 + }, 15 + "nullable": [ 16 + null 17 + ] 18 + }, 19 + "hash": "fd64104d130b93dd5fc9414b8710ad5183b647eaaff90decbce15e10d83c7538" 20 + }
+1
Cargo.lock
··· 2068 "dotenv", 2069 "jsonwebtoken", 2070 "lazy_static", 2071 "regex", 2072 "serde", 2073 "serde_json",
··· 2068 "dotenv", 2069 "jsonwebtoken", 2070 "lazy_static", 2071 + "rand", 2072 "regex", 2073 "serde", 2074 "serde_json",
+1
Cargo.toml
··· 28 regex = "1.10" 29 lazy_static = "1.4" 30 argon2 = "0.5.3"
··· 28 regex = "1.10" 29 lazy_static = "1.4" 30 argon2 = "0.5.3" 31 + rand = { version = "0.8", features = ["std"] }
+1
admin-setup-token.txt
···
··· 1 + fqfO6awRz3mkc2Kxunkp1uTQcXaSfGD9
+3 -3
docker-compose.yml
··· 24 context: . 25 dockerfile: Dockerfile 26 args: 27 - - API_URL=${API_URL:-http://localhost:8080} 28 container_name: shortener-app 29 ports: 30 - - "8080:8080" 31 environment: 32 - DATABASE_URL=postgresql://shortener:shortener123@db:5432/shortener 33 - SERVER_HOST=0.0.0.0 34 - - SERVER_PORT=8080 35 depends_on: 36 db: 37 condition: service_healthy
··· 24 context: . 25 dockerfile: Dockerfile 26 args: 27 + - API_URL=${API_URL:-http://localhost:3000} 28 container_name: shortener-app 29 ports: 30 + - "3000:3000" 31 environment: 32 - DATABASE_URL=postgresql://shortener:shortener123@db:5432/shortener 33 - SERVER_HOST=0.0.0.0 34 + - SERVER_PORT=3000 35 depends_on: 36 db: 37 condition: service_healthy
+2 -1
frontend/src/api/client.ts
··· 24 return response.data; 25 }; 26 27 - export const register = async (email: string, password: string) => { 28 const response = await api.post<AuthResponse>('/auth/register', { 29 email, 30 password, 31 }); 32 return response.data; 33 };
··· 24 return response.data; 25 }; 26 27 + export const register = async (email: string, password: string, adminToken: string) => { 28 const response = await api.post<AuthResponse>('/auth/register', { 29 email, 30 password, 31 + admin_token: adminToken, 32 }); 33 return response.data; 34 };
+21 -3
frontend/src/components/AuthForms.tsx
··· 20 const formSchema = z.object({ 21 email: z.string().email('Invalid email address'), 22 password: z.string().min(6, 'Password must be at least 6 characters long'), 23 }) 24 25 type FormValues = z.infer<typeof formSchema> ··· 34 defaultValues: { 35 email: '', 36 password: '', 37 }, 38 }) 39 ··· 42 if (activeTab === 'login') { 43 await login(values.email, values.password) 44 } else { 45 - await register(values.email, values.password) 46 } 47 form.reset() 48 } catch (err: any) { 49 toast({ 50 variant: 'destructive', 51 title: 'Error', 52 - description: err.response?.data?.error || 'An error occurred', 53 }) 54 } 55 } ··· 93 )} 94 /> 95 96 <Button type="submit" className="w-full"> 97 {activeTab === 'login' ? 'Sign in' : 'Create account'} 98 </Button> ··· 102 </Tabs> 103 </Card> 104 ) 105 - }
··· 20 const formSchema = z.object({ 21 email: z.string().email('Invalid email address'), 22 password: z.string().min(6, 'Password must be at least 6 characters long'), 23 + adminToken: z.string(), 24 }) 25 26 type FormValues = z.infer<typeof formSchema> ··· 35 defaultValues: { 36 email: '', 37 password: '', 38 + adminToken: '', 39 }, 40 }) 41 ··· 44 if (activeTab === 'login') { 45 await login(values.email, values.password) 46 } else { 47 + await register(values.email, values.password, values.adminToken) 48 } 49 form.reset() 50 } catch (err: any) { 51 toast({ 52 variant: 'destructive', 53 title: 'Error', 54 + description: err.response?.data || 'An error occurred', 55 }) 56 } 57 } ··· 95 )} 96 /> 97 98 + {activeTab === 'register' && ( 99 + <FormField 100 + control={form.control} 101 + name="adminToken" 102 + render={({ field }) => ( 103 + <FormItem> 104 + <FormLabel>Admin Setup Token</FormLabel> 105 + <FormControl> 106 + <Input type="text" {...field} /> 107 + </FormControl> 108 + <FormMessage /> 109 + </FormItem> 110 + )} 111 + /> 112 + )} 113 + 114 <Button type="submit" className="w-full"> 115 {activeTab === 'login' ? 'Sign in' : 'Create account'} 116 </Button> ··· 120 </Tabs> 121 </Card> 122 ) 123 + }
+4 -2
frontend/src/components/LinkList.tsx
··· 81 } 82 83 const handleCopy = (shortCode: string) => { 84 - navigator.clipboard.writeText(`http://localhost:8080/${shortCode}`) 85 toast({ 86 - description: "Link copied to clipboard", 87 }) 88 } 89
··· 81 } 82 83 const handleCopy = (shortCode: string) => { 84 + // Use import.meta.env.VITE_BASE_URL or fall back to window.location.origin 85 + const baseUrl = import.meta.env.VITE_API_URL || window.location.origin 86 + navigator.clipboard.writeText(`${baseUrl}/${shortCode}`) 87 toast({ 88 + description: "Link copied to clipboard", 89 }) 90 } 91
+3 -3
frontend/src/context/AuthContext.tsx
··· 5 interface AuthContextType { 6 user: User | null; 7 login: (email: string, password: string) => Promise<void>; 8 - register: (email: string, password: string) => Promise<void>; 9 logout: () => void; 10 isLoading: boolean; 11 } ··· 33 setUser(user); 34 }; 35 36 - const register = async (email: string, password: string) => { 37 - const response = await api.register(email, password); 38 const { token, user } = response; 39 localStorage.setItem('token', token); 40 localStorage.setItem('user', JSON.stringify(user));
··· 5 interface AuthContextType { 6 user: User | null; 7 login: (email: string, password: string) => Promise<void>; 8 + register: (email: string, password: string, adminToken: string) => Promise<void>; 9 logout: () => void; 10 isLoading: boolean; 11 } ··· 33 setUser(user); 34 }; 35 36 + const register = async (email: string, password: string, adminToken: string) => { 37 + const response = await api.register(email, password, adminToken); 38 const { token, user } = response; 39 localStorage.setItem('token', token); 40 localStorage.setItem('user', JSON.stringify(user));
+6
frontend/src/types/api.ts
··· 35 source: string; 36 count: number; 37 }
··· 35 source: string; 36 count: number; 37 } 38 + 39 + export interface RegisterRequest { 40 + email: string; 41 + password: string; 42 + admin_token: string; 43 + }
+4 -8
frontend/vite.config.ts
··· 3 import tailwindcss from '@tailwindcss/vite' 4 import path from "path" 5 6 - export default defineConfig({ 7 - plugins: [ 8 - react(), 9 - tailwindcss(), 10 - ], 11 server: { 12 proxy: { 13 '/api': { 14 - target: 'http://localhost:8080', 15 changeOrigin: true, 16 }, 17 }, ··· 21 "@": path.resolve(__dirname, "./src"), 22 }, 23 }, 24 - }) 25 -
··· 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 }, ··· 18 "@": path.resolve(__dirname, "./src"), 19 }, 20 }, 21 + }))
+21
src/handlers.rs
··· 189 state: web::Data<AppState>, 190 payload: web::Json<RegisterRequest>, 191 ) -> Result<impl Responder, AppError> { 192 let exists = sqlx::query!("SELECT id FROM users WHERE email = $1", payload.email) 193 .fetch_optional(&state.db) 194 .await?;
··· 189 state: web::Data<AppState>, 190 payload: web::Json<RegisterRequest>, 191 ) -> Result<impl Responder, AppError> { 192 + // Check if any users exist 193 + let user_count = sqlx::query!("SELECT COUNT(*) as count FROM users") 194 + .fetch_one(&state.db) 195 + .await? 196 + .count 197 + .unwrap_or(0); 198 + 199 + // If users exist, registration is closed - no exceptions 200 + if user_count > 0 { 201 + return Err(AppError::Auth("Registration is closed".to_string())); 202 + } 203 + 204 + // Verify admin token for first user 205 + match (&state.admin_token, &payload.admin_token) { 206 + (Some(stored_token), Some(provided_token)) if stored_token == provided_token => { 207 + // Token matches, proceed with registration 208 + } 209 + _ => return Err(AppError::Auth("Invalid admin setup token".to_string())), 210 + } 211 + 212 + // Check if email already exists 213 let exists = sqlx::query!("SELECT id FROM users WHERE email = $1", payload.email) 214 .fetch_optional(&state.db) 215 .await?;
+41
src/lib.rs
··· 1 use sqlx::PgPool; 2 3 pub mod auth; 4 pub mod error; ··· 8 #[derive(Clone)] 9 pub struct AppState { 10 pub db: PgPool, 11 }
··· 1 + use rand::Rng; 2 use sqlx::PgPool; 3 + use std::fs::File; 4 + use std::io::Write; 5 + use tracing::info; 6 7 pub mod auth; 8 pub mod error; ··· 12 #[derive(Clone)] 13 pub struct AppState { 14 pub db: PgPool, 15 + pub admin_token: Option<String>, 16 + } 17 + 18 + pub async fn check_and_generate_admin_token(pool: &sqlx::PgPool) -> anyhow::Result<Option<String>> { 19 + // Check if any users exist 20 + let user_count = sqlx::query!("SELECT COUNT(*) as count FROM users") 21 + .fetch_one(pool) 22 + .await? 23 + .count 24 + .unwrap_or(0); 25 + 26 + if user_count == 0 { 27 + // Generate a random token using simple characters 28 + let token: String = (0..32) 29 + .map(|_| { 30 + let idx = rand::thread_rng().gen_range(0..62); 31 + match idx { 32 + 0..=9 => (b'0' + idx as u8) as char, 33 + 10..=35 => (b'a' + (idx - 10) as u8) as char, 34 + _ => (b'A' + (idx - 36) as u8) as char, 35 + } 36 + }) 37 + .collect(); 38 + 39 + // Save token to file 40 + let mut file = File::create("admin-setup-token.txt")?; 41 + writeln!(file, "{}", token)?; 42 + 43 + info!("No users found - generated admin setup token"); 44 + info!("Token has been saved to admin-setup-token.txt"); 45 + info!("Use this token to create the admin user"); 46 + info!("Admin setup token: {}", token); 47 + 48 + Ok(Some(token)) 49 + } else { 50 + Ok(None) 51 + } 52 }
+7 -1
src/main.rs
··· 2 use actix_files as fs; 3 use actix_web::{web, App, HttpServer}; 4 use anyhow::Result; 5 use simplelink::{handlers, AppState}; 6 use sqlx::postgres::PgPoolOptions; 7 use tracing::info; ··· 27 // Run database migrations 28 sqlx::migrate!("./migrations").run(&pool).await?; 29 30 - let state = AppState { db: pool }; 31 32 let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 33 let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "8080".to_string());
··· 2 use actix_files as fs; 3 use actix_web::{web, App, HttpServer}; 4 use anyhow::Result; 5 + use simplelink::check_and_generate_admin_token; 6 use simplelink::{handlers, AppState}; 7 use sqlx::postgres::PgPoolOptions; 8 use tracing::info; ··· 28 // Run database migrations 29 sqlx::migrate!("./migrations").run(&pool).await?; 30 31 + let admin_token = check_and_generate_admin_token(&pool).await?; 32 + 33 + let state = AppState { 34 + db: pool, 35 + admin_token, 36 + }; 37 38 let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 39 let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "8080".to_string());
+1
src/models.rs
··· 49 pub struct RegisterRequest { 50 pub email: String, 51 pub password: String, 52 } 53 54 #[derive(Serialize)]
··· 49 pub struct RegisterRequest { 50 pub email: String, 51 pub password: String, 52 + pub admin_token: Option<String>, 53 } 54 55 #[derive(Serialize)]