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 2068 "dotenv", 2069 2069 "jsonwebtoken", 2070 2070 "lazy_static", 2071 + "rand", 2071 2072 "regex", 2072 2073 "serde", 2073 2074 "serde_json",
+1
Cargo.toml
··· 28 28 regex = "1.10" 29 29 lazy_static = "1.4" 30 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 24 context: . 25 25 dockerfile: Dockerfile 26 26 args: 27 - - API_URL=${API_URL:-http://localhost:8080} 27 + - API_URL=${API_URL:-http://localhost:3000} 28 28 container_name: shortener-app 29 29 ports: 30 - - "8080:8080" 30 + - "3000:3000" 31 31 environment: 32 32 - DATABASE_URL=postgresql://shortener:shortener123@db:5432/shortener 33 33 - SERVER_HOST=0.0.0.0 34 - - SERVER_PORT=8080 34 + - SERVER_PORT=3000 35 35 depends_on: 36 36 db: 37 37 condition: service_healthy
+2 -1
frontend/src/api/client.ts
··· 24 24 return response.data; 25 25 }; 26 26 27 - export const register = async (email: string, password: string) => { 27 + export const register = async (email: string, password: string, adminToken: string) => { 28 28 const response = await api.post<AuthResponse>('/auth/register', { 29 29 email, 30 30 password, 31 + admin_token: adminToken, 31 32 }); 32 33 return response.data; 33 34 };
+21 -3
frontend/src/components/AuthForms.tsx
··· 20 20 const formSchema = z.object({ 21 21 email: z.string().email('Invalid email address'), 22 22 password: z.string().min(6, 'Password must be at least 6 characters long'), 23 + adminToken: z.string(), 23 24 }) 24 25 25 26 type FormValues = z.infer<typeof formSchema> ··· 34 35 defaultValues: { 35 36 email: '', 36 37 password: '', 38 + adminToken: '', 37 39 }, 38 40 }) 39 41 ··· 42 44 if (activeTab === 'login') { 43 45 await login(values.email, values.password) 44 46 } else { 45 - await register(values.email, values.password) 47 + await register(values.email, values.password, values.adminToken) 46 48 } 47 49 form.reset() 48 50 } catch (err: any) { 49 51 toast({ 50 52 variant: 'destructive', 51 53 title: 'Error', 52 - description: err.response?.data?.error || 'An error occurred', 54 + description: err.response?.data || 'An error occurred', 53 55 }) 54 56 } 55 57 } ··· 93 95 )} 94 96 /> 95 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 + 96 114 <Button type="submit" className="w-full"> 97 115 {activeTab === 'login' ? 'Sign in' : 'Create account'} 98 116 </Button> ··· 102 120 </Tabs> 103 121 </Card> 104 122 ) 105 - } 123 + }
+4 -2
frontend/src/components/LinkList.tsx
··· 81 81 } 82 82 83 83 const handleCopy = (shortCode: string) => { 84 - navigator.clipboard.writeText(`http://localhost:8080/${shortCode}`) 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}`) 85 87 toast({ 86 - description: "Link copied to clipboard", 88 + description: "Link copied to clipboard", 87 89 }) 88 90 } 89 91
+3 -3
frontend/src/context/AuthContext.tsx
··· 5 5 interface AuthContextType { 6 6 user: User | null; 7 7 login: (email: string, password: string) => Promise<void>; 8 - register: (email: string, password: string) => Promise<void>; 8 + register: (email: string, password: string, adminToken: string) => Promise<void>; 9 9 logout: () => void; 10 10 isLoading: boolean; 11 11 } ··· 33 33 setUser(user); 34 34 }; 35 35 36 - const register = async (email: string, password: string) => { 37 - const response = await api.register(email, password); 36 + const register = async (email: string, password: string, adminToken: string) => { 37 + const response = await api.register(email, password, adminToken); 38 38 const { token, user } = response; 39 39 localStorage.setItem('token', token); 40 40 localStorage.setItem('user', JSON.stringify(user));
+6
frontend/src/types/api.ts
··· 35 35 source: string; 36 36 count: number; 37 37 } 38 + 39 + export interface RegisterRequest { 40 + email: string; 41 + password: string; 42 + admin_token: string; 43 + }
+4 -8
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: [ 8 - react(), 9 - tailwindcss(), 10 - ], 6 + export default defineConfig(() => ({ 7 + plugins: [react(), tailwindcss()], 11 8 server: { 12 9 proxy: { 13 10 '/api': { 14 - target: 'http://localhost:8080', 11 + target: process.env.VITE_API_URL || 'http://localhost:8080', 15 12 changeOrigin: true, 16 13 }, 17 14 }, ··· 21 18 "@": path.resolve(__dirname, "./src"), 22 19 }, 23 20 }, 24 - }) 25 - 21 + }))
+21
src/handlers.rs
··· 189 189 state: web::Data<AppState>, 190 190 payload: web::Json<RegisterRequest>, 191 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 192 213 let exists = sqlx::query!("SELECT id FROM users WHERE email = $1", payload.email) 193 214 .fetch_optional(&state.db) 194 215 .await?;
+41
src/lib.rs
··· 1 + use rand::Rng; 1 2 use sqlx::PgPool; 3 + use std::fs::File; 4 + use std::io::Write; 5 + use tracing::info; 2 6 3 7 pub mod auth; 4 8 pub mod error; ··· 8 12 #[derive(Clone)] 9 13 pub struct AppState { 10 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 + } 11 52 }
+7 -1
src/main.rs
··· 2 2 use actix_files as fs; 3 3 use actix_web::{web, App, HttpServer}; 4 4 use anyhow::Result; 5 + use simplelink::check_and_generate_admin_token; 5 6 use simplelink::{handlers, AppState}; 6 7 use sqlx::postgres::PgPoolOptions; 7 8 use tracing::info; ··· 27 28 // Run database migrations 28 29 sqlx::migrate!("./migrations").run(&pool).await?; 29 30 30 - let state = AppState { db: pool }; 31 + let admin_token = check_and_generate_admin_token(&pool).await?; 32 + 33 + let state = AppState { 34 + db: pool, 35 + admin_token, 36 + }; 31 37 32 38 let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 33 39 let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "8080".to_string());
+1
src/models.rs
··· 49 49 pub struct RegisterRequest { 50 50 pub email: String, 51 51 pub password: String, 52 + pub admin_token: Option<String>, 52 53 } 53 54 54 55 #[derive(Serialize)]